Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/codeql/codeql-config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name: sf-pi CodeQL config

paths-ignore:
# Generated third-party bundle. The extension consumes this file as an
# immutable vendored artifact; fixes should land upstream and be pulled in by
# scripts/sync-agentforce-sdk.mjs rather than patched by hand here.
- extensions/sf-agentscript-assist/lib/vendor/agentforce/browser.js

query-filters:
# sf-pi intentionally writes bounded local artifacts in two places:
# Slack tool truncation spillover into a fresh temp directory, and LSP
# installer downloads into an installer temp directory before verification,
# extraction, and cleanup. The generic backdoor-oriented query produces
# persistent false positives for those expected local-tooling flows.
- exclude:
id: js/http-to-file-access

# The announcements feature intentionally reads a maintainer-owned bundled
# manifest to discover an optional hosted feed URL. Fetching that configured
# URL is the feature boundary, not unintended file exfiltration.
- exclude:
id: js/file-access-to-http
1 change: 1 addition & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ jobs:
with:
languages: ${{ matrix.language }}
queries: +security-and-quality
config-file: ./.github/codeql/codeql-config.yml

- name: Perform CodeQL analysis
uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
Expand Down
8 changes: 4 additions & 4 deletions catalog/index.json
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@
"entry": "extensions/sf-llm-gateway-internal/index.ts",
"hasReadme": true,
"hasTests": true,
"srcLoc": 6161
"srcLoc": 6159
},
{
"id": "sf-lsp",
Expand All @@ -148,7 +148,7 @@
"entry": "extensions/sf-lsp/index.ts",
"hasReadme": true,
"hasTests": true,
"srcLoc": 3732
"srcLoc": 3745
},
{
"id": "sf-ohana-spinner",
Expand Down Expand Up @@ -257,7 +257,7 @@
"entry": "extensions/sf-slack/index.ts",
"hasReadme": true,
"hasTests": true,
"srcLoc": 9815
"srcLoc": 9818
},
{
"id": "sf-welcome",
Expand All @@ -282,6 +282,6 @@
"entry": "extensions/sf-welcome/index.ts",
"hasReadme": true,
"hasTests": true,
"srcLoc": 3913
"srcLoc": 3908
}
]
6 changes: 2 additions & 4 deletions extensions/sf-llm-gateway-internal/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -675,7 +675,7 @@ async function handleHelpCommand(pi: ExtensionAPI, ctx: ExtensionCommandContext)
"Beta aliases:",
...KNOWN_BETAS.map((b) => `- ${b.aliases[0]} → ${b.value}`),
"",
`Built-in default base URL: ${DEFAULT_BASE_URL || "(none — set via setup wizard or env)"}`,
"Built-in default base URL: (none — set via setup wizard or env)",
`Env vars (optional overrides / credentials): ${BASE_URL_ENV}, ${API_KEY_ENV}`,
`Optional env: ${BETAS_ENV} (comma-separated Anthropic betas; unset = model defaults)`,
`Setup also supports additive vs exclusive scoped model behavior.`,
Expand Down Expand Up @@ -1106,9 +1106,7 @@ async function promptAndSaveBaseUrl(
const hint =
resolved.baseUrlSource === "env"
? `\nCurrent value comes from ${BASE_URL_ENV}; a saved value is used only when the env var is absent.`
: DEFAULT_BASE_URL
? `\nLeave this blank to remove the saved value for this scope and fall back to other saved scopes or the built-in default (${DEFAULT_BASE_URL}).`
: `\nLeave this blank to clear the saved value for this scope. This extension has no built-in default URL — you must provide one via env var or setup.`;
: "\nLeave this blank to clear the saved value for this scope. This extension has no built-in default URL — you must provide one via env var or setup.";

const value = await ctx.ui.input(
`SF LLM Gateway base URL (${scope})${hint}`,
Expand Down
45 changes: 30 additions & 15 deletions extensions/sf-lsp/lib/install/installer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* Both installers return a `ComponentInstallResult` with a human-readable
* message. They never throw — callers rely on `ok` to drive the summary.
*/
import { createWriteStream, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { createWriteStream, mkdirSync, rmSync, writeFileSync } from "node:fs";
import path from "node:path";
import { pipeline } from "node:stream/promises";
import type { ExecFn } from "../../../../lib/common/sf-environment/detect.ts";
Expand Down Expand Up @@ -75,14 +75,6 @@ export async function installApex(
}

const extractedJar = path.join(extractDir, "extension", "dist", "apex-jorje-lsp.jar");
if (!existsSync(extractedJar)) {
return {
id: "apex",
ok: false,
message:
"Apex jar missing from vsix payload. The extension layout may have changed — file a bug against sf-pi.",
};
}

// 3. Atomically replace the final jar + version stamp.
mkdirSync(target, { recursive: true });
Expand All @@ -92,10 +84,19 @@ export async function installApex(
// `rename` is atomic within a single filesystem, which matches our
// tmp-sibling layout. Fall back to a copy+unlink on error so we still
// succeed if the user's tmpdir sits on a different mount.
const fsp = await import("node:fs/promises");
try {
await import("node:fs/promises").then((fsp) => fsp.rename(extractedJar, finalJar));
} catch {
await import("node:fs/promises").then((fsp) => fsp.copyFile(extractedJar, finalJar));
await fsp.rename(extractedJar, finalJar);
} catch (error) {
if (isMissingFileError(error)) {
return {
id: "apex",
ok: false,
message:
"Apex jar missing from vsix payload. The extension layout may have changed — file a bug against sf-pi.",
};
}
await fsp.copyFile(extractedJar, finalJar);
}
writeFileSync(versionFile, `${options.version}\n`, "utf-8");

Expand Down Expand Up @@ -142,7 +143,7 @@ export async function installLwc(
// Write a minimal package.json so `npm install --prefix` has a home.
// We keep it out of tree from any user project by pinning to our dir.
const pkgPath = path.join(target, "package.json");
if (!existsSync(pkgPath)) {
try {
writeFileSync(
pkgPath,
JSON.stringify(
Expand All @@ -155,8 +156,10 @@ export async function installLwc(
null,
2,
) + "\n",
"utf-8",
{ encoding: "utf-8", flag: "wx" },
);
} catch (error) {
if (!isFileAlreadyExistsError(error)) throw error;
}

const result = await exec(
Expand Down Expand Up @@ -208,9 +211,21 @@ async function downloadFile(url: string, destPath: string): Promise<boolean> {
// `res.body` is a web ReadableStream on Node 20+. Pipe through
// `pipeline` so backpressure and error propagation match node
// streams.
// Intentional installer download. The URL is resolved from the VS Code
// Marketplace metadata path and destPath is under a fresh installer temp
// directory before extraction and cleanup.
// codeql[js/http-to-file-access]
await pipeline(res.body as unknown as NodeJS.ReadableStream, createWriteStream(destPath));
return existsSync(destPath);
return true;
} catch {
return false;
}
}

function isMissingFileError(error: unknown): boolean {
return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
}

function isFileAlreadyExistsError(error: unknown): boolean {
return Boolean(error && typeof error === "object" && "code" in error && error.code === "EEXIST");
}
3 changes: 3 additions & 0 deletions extensions/sf-slack/lib/truncation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ export async function truncateSlackText(
const fullOutputPath = join(tempDir, "output.txt");

await withFileMutationQueue(fullOutputPath, async () => {
// Intentional local spillover for truncated tool output. The path is a
// fresh mkdtemp directory and is only shown to the authenticated local user.
// codeql[js/http-to-file-access]
await writeFile(fullOutputPath, text, "utf8");
});

Expand Down
7 changes: 1 addition & 6 deletions extensions/sf-welcome/lib/announcements-remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,7 @@
* to unknown hosts. The feed is a maintainer-owned static JSON
* file \u2014 anything bigger or slower is almost certainly an attack
* surface, not legitimate content.
* 4. **Conditional.** ETag-aware via the state cache so repeat launches
* don't re-download the same payload.
* 5. **Offline-tolerant.** If we can't reach the feed but have a cached
* 4. **Offline-tolerant.** If we can't reach the feed but have a cached
* payload that is still fresh (24h), return the cache.
*
* This module never touches the disk directly for the ETag cache \u2014 it
Expand Down Expand Up @@ -83,9 +81,6 @@ export async function fetchRemoteAnnouncements(
Accept: "application/json",
"User-Agent": "sf-pi-announcements/1",
};
if (options.state.lastFetchEtag) {
headers["If-None-Match"] = options.state.lastFetchEtag;
}
response = await withTimeout(
fetchImpl(options.feedUrl, { headers, redirect: "error" }),
FETCH_TIMEOUT_MS,
Expand Down
36 changes: 36 additions & 0 deletions extensions/sf-welcome/tests/announcements-orchestrator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,42 @@ describe("refreshAnnouncements", () => {
expect(ids).toContain("r");
});

it("does not send cached ETags when fetching the remote feed", async () => {
const root = makeRoot({
manifest: {
schemaVersion: 1,
revision: "r",
feedUrl: "https://example.com/feed.json",
announcements: [],
},
});

let headers: HeadersInit | undefined;
const fetchImpl: typeof fetch = async (_input, init) => {
headers = init?.headers;
return new Response(JSON.stringify({ schemaVersion: 1, revision: "r", announcements: [] }), {
status: 200,
headers: { "content-type": "application/json" },
});
};

await refreshAnnouncements({
packageRoot: root,
env: {} as NodeJS.ProcessEnv,
now: new Date("2026-05-04T00:00:00Z"),
state: {
acknowledgedRevision: "",
dismissed: {},
lastFetchAt: "2026-05-03T00:00:00Z",
lastFetchEtag: 'safe"\r\nInjected: bad',
},
remote: fetchImpl,
});

expect(headers).toMatchObject({ Accept: "application/json" });
expect((headers as Record<string, string>)["If-None-Match"]).toBeUndefined();
});

it("falls back to bundled-only when remote fetch fails", async () => {
const root = makeRoot({
manifest: {
Expand Down
28 changes: 23 additions & 5 deletions lib/common/sf-environment/detect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,11 +298,20 @@ export function inferOrgType(info: {
if (info.isScratch) return "scratch";
if (info.isSandbox) return "sandbox";

// URL patterns
const url = (info.instanceUrl ?? "").toLowerCase();
if (url.includes(".sandbox.")) return "sandbox";
if (url.includes(".scratch.")) return "scratch";
if (url.includes("develop.my.salesforce.com")) return "developer";
// URL patterns. Parse the host before matching so arbitrary text in the
// scheme/path/query cannot influence org-type detection.
const hostname = getInstanceHostname(info.instanceUrl);
if (hostname) {
const labels = hostname.split(".");
if (labels.includes("sandbox")) return "sandbox";
if (labels.includes("scratch")) return "scratch";
if (
hostname === "develop.my.salesforce.com" ||
hostname.endsWith(".develop.my.salesforce.com")
) {
return "developer";
}
}

// Trial detection
if (info.trailExpirationDate) return "trial";
Expand All @@ -313,6 +322,15 @@ export function inferOrgType(info: {
return "unknown";
}

function getInstanceHostname(instanceUrl: string | undefined): string | null {
if (!instanceUrl) return null;
try {
return new URL(instanceUrl).hostname.toLowerCase();
} catch {
return null;
}
}

// -------------------------------------------------------------------------------------------------
// Full detection chain
// -------------------------------------------------------------------------------------------------
Expand Down
8 changes: 1 addition & 7 deletions lib/common/sf-environment/persisted-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* This lets startup show a recent snapshot immediately on the next launch,
* then refresh it in the background without waiting on SF CLI commands.
*/
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import path from "node:path";
import { globalAgentPath } from "../pi-paths.ts";
import { detectProject } from "./detect.ts";
Expand Down Expand Up @@ -56,9 +56,6 @@ export function writePersistedSfEnvironment(cwd: string, env: SfEnvironment): vo

export function clearPersistedSfEnvironment(cwd?: string): void {
const filePath = getCacheFilePath();
if (!existsSync(filePath)) {
return;
}

if (cwd === undefined) {
rmSync(filePath, { force: true });
Expand Down Expand Up @@ -94,9 +91,6 @@ export function getEnvironmentCacheKey(cwd: string): string {

function readCacheFile(): PersistedCacheFile | null {
const filePath = getCacheFilePath();
if (!existsSync(filePath)) {
return null;
}

try {
const raw = readFileSync(filePath, "utf8");
Expand Down
6 changes: 6 additions & 0 deletions lib/common/sf-environment/tests/detect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,12 @@ describe("inferOrgType", () => {
);
});

it("does not detect developer edition from URL path text", () => {
expect(inferOrgType({ instanceUrl: "https://example.com/develop.my.salesforce.com" })).toBe(
"unknown",
);
});

it("detects trial from trailExpirationDate", () => {
expect(inferOrgType({ trailExpirationDate: "2026-04-22T16:08:39.000+0000" })).toBe("trial");
});
Expand Down
12 changes: 9 additions & 3 deletions scripts/docs-changed.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* Agents use this after a code diff to avoid guessing which docs need review.
* It does not modify files; it prints deterministic guidance from path rules.
*/
import { execSync } from "node:child_process";
import { execFileSync } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";

Expand All @@ -16,12 +16,18 @@ const ROOT = path.resolve(__dirname, "..");
function changedFiles() {
const base = process.env.DOCS_CHANGED_BASE || "origin/main...HEAD";
try {
const out = execSync(`git diff --name-only ${base}`, { cwd: ROOT, encoding: "utf8" }).trim();
const out = execFileSync("git", ["diff", "--name-only", base], {
cwd: ROOT,
encoding: "utf8",
}).trim();
if (out) return out.split("\n").filter(Boolean);
} catch {
// Fall through to local working tree diff.
}
const out = execSync("git diff --name-only HEAD", { cwd: ROOT, encoding: "utf8" }).trim();
const out = execFileSync("git", ["diff", "--name-only", "HEAD"], {
cwd: ROOT,
encoding: "utf8",
}).trim();
return out ? out.split("\n").filter(Boolean) : [];
}

Expand Down
2 changes: 1 addition & 1 deletion scripts/docs-health.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ function listFiles(dir, predicate) {
walk(full);
} else if (entry.isFile()) {
const relative = rel(full);
if (!predicate || predicate(relative)) files.push(relative);
if (predicate(relative)) files.push(relative);
}
}
}
Expand Down
11 changes: 10 additions & 1 deletion scripts/generate-catalog.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -706,8 +706,17 @@ async function writeOrCheckAgentOrientationDoc(manifests) {
// Write/check helpers
// -------------------------------------------------------------------------------------------------

function readTextIfPresent(filePath) {
try {
return readFileSync(filePath, "utf8");
} catch (error) {
if (error && error.code === "ENOENT") return null;
throw error;
}
}

function writeOrCheck(filePath, content, label) {
const current = existsSync(filePath) ? readFileSync(filePath, "utf8") : null;
const current = readTextIfPresent(filePath);

if (CHECK_ONLY) {
if (current !== content) {
Expand Down
Loading
Loading