From 76d12021e8b0ae1016b69d9b4bbcba6f0f3b2edd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 27 Aug 2026 18:33:53 +0000 Subject: [PATCH 1/3] fix: close packaging and static-audit gaps Stage only git-tracked runtime files and a fresh GUI build into the macOS app, fail package:macos on a dirty tree, and scan the staged bundle. Bound request bodies while streaming, redact privacy-scan matches, fail closed on malformed percent-encoding, and honor the confirmed-launch gate on delegation Install/Update/Remove. Co-authored-by: pavelhov --- README.md | 2 + .../src/content/docs/guides/macos-menu-bar.md | 3 +- gui/src/api-access-models.ts | 26 ++++ .../CodexDelegationSetupCard.tsx | 21 +++- gui/src/i18n/de.ts | 1 + gui/src/i18n/en.ts | 1 + gui/src/i18n/ja.ts | 1 + gui/src/i18n/ko.ts | 1 + gui/src/i18n/ru.ts | 1 + gui/src/i18n/zh.ts | 1 + gui/src/pages/ApiKeys.tsx | 21 +--- gui/tests/api-access-models.test.ts | 18 +++ gui/tests/apikeys-layout.test.ts | 4 +- gui/tests/apikeys-model-test-wire.test.tsx | 4 +- gui/tests/apikeys-models-states.test.tsx | 17 +-- gui/tests/apikeys-mutation-timeout.test.tsx | 2 +- gui/tests/apikeys-refresh-preserve.test.tsx | 4 +- gui/tests/codex-delegation-setup.test.tsx | 21 ++++ scripts/build-macos-app.sh | 56 ++++++++- scripts/package-macos-release.sh | 6 + scripts/package-tree-safety.ts | 48 +++++++- scripts/privacy-scan.ts | 115 ++++++++++++++---- src/server/index.ts | 7 +- src/server/live.ts | 16 ++- src/server/request-decompress.ts | 54 ++++++-- tests/macos-build-script.test.ts | 60 ++++----- tests/package-tree-safety.test.ts | 19 ++- tests/privacy-scan.test.ts | 55 +++++++++ tests/request-decompress.test.ts | 54 ++++++++ tests/server-images.test.ts | 6 + tests/server-live.test.ts | 2 + 31 files changed, 538 insertions(+), 109 deletions(-) create mode 100644 tests/privacy-scan.test.ts diff --git a/README.md b/README.md index 69ef786898..5aeb49189e 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,8 @@ required): UNIVERSAL=1 bun run package:macos ``` +`package:macos` requires a clean git working tree so a release archive cannot include uncommitted files. Use `bun run build:macos` for a local development build from a dirty checkout. + Every built app launches only the Bun runtime and server resources embedded in its own `Contents/Resources/runtime`; it never executes checkout `src/` or an ambient `ccx`. Rebuild the app to pick up source changes. If startup fails, the menu app stays open so its diagnostics and **Start** diff --git a/docs-site/src/content/docs/guides/macos-menu-bar.md b/docs-site/src/content/docs/guides/macos-menu-bar.md index 79e0d6911e..6a14d3ae85 100644 --- a/docs-site/src/content/docs/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/guides/macos-menu-bar.md @@ -275,8 +275,7 @@ workflow does not install or copy the app into Application Support. A rebuild at the same path is detected on the next launch and refreshes the existing Login Item registration only when Launch at Login remains on. Each build stamps its exact Git revision into `CodexCommanderSourceRevision` in the bundle's `Info.plist` -and prints it at the end of the build. Uncommitted source is marked with `-dirty`, so commit before -making a final distributable bundle. +and prints it at the end of the build. Uncommitted source is marked with `-dirty`. `bun run package:macos` refuses a dirty working tree so a distributable archive cannot include uncommitted files; commit first, or use `bun run build:macos` for a local development build. The source-build `.app` is a thin development artifact for the current checkout, not the public distribution format. Use the universal release archive for public installation. A source app in the diff --git a/gui/src/api-access-models.ts b/gui/src/api-access-models.ts index f0f84c94b3..01b38759a4 100644 --- a/gui/src/api-access-models.ts +++ b/gui/src/api-access-models.ts @@ -43,6 +43,32 @@ export function classifyExternalModel(row: { }; } +/** + * Classify a management `/api/models` row for the API-keys catalog. + * Disabled rows are omitted: they are not callable on the data plane. + */ +export function classifyManagementModel(row: unknown): ExternalModelRow | null { + if (row === null || typeof row !== "object" || Array.isArray(row)) return null; + const data = row as Record; + if (data.disabled === true) return null; + if (typeof data.id !== "string" || data.id.length === 0) return null; + const namespaced = typeof data.namespaced === "string" && data.namespaced.length > 0 + ? data.namespaced + : data.id; + const provider = typeof data.provider === "string" && data.provider.length > 0 + ? data.provider + : (namespaced.includes("/") ? namespaced.slice(0, namespaced.indexOf("/")) : "openai"); + return { + id: namespaced, + displayName: typeof data.displayName === "string" && data.displayName.length > 0 + ? data.displayName + : namespaced, + provider, + native: data.native === true, + custom: data.custom === true, + }; +} + export function externalModelId(model: ExternalModelRow): string { return model.id; } diff --git a/gui/src/components/subagents-workspace/CodexDelegationSetupCard.tsx b/gui/src/components/subagents-workspace/CodexDelegationSetupCard.tsx index e1198eb496..71c7918561 100644 --- a/gui/src/components/subagents-workspace/CodexDelegationSetupCard.tsx +++ b/gui/src/components/subagents-workspace/CodexDelegationSetupCard.tsx @@ -1,6 +1,11 @@ import { useEffect, useRef, useState } from "react"; import { useT, type TKey } from "../../i18n/shared"; import { useCopyFeedback } from "../use-copy-feedback"; +import { + isConfirmedGuiLaunch, + subscribeGuiLaunchCapability, + whenGuiLaunchCapabilitySettles, +} from "../../api"; import type { CodexDelegationSetupController, CodexDelegationStatus } from "../../pages/use-codex-delegation-setup"; function statusKey(status: CodexDelegationStatus): TKey { @@ -41,11 +46,18 @@ export default function CodexDelegationSetupCard({ delegationSetup }: { delegati const previewConfirmRef = useRef(null); const removeTriggerRef = useRef(null); const copyFeedback = useCopyFeedback(); + const [confirmedLaunch, setConfirmedLaunch] = useState(isConfirmedGuiLaunch); + useEffect(() => { + const update = () => setConfirmedLaunch(isConfirmedGuiLaunch()); + const unsubscribe = subscribeGuiLaunchCapability(update); + void whenGuiLaunchCapabilitySettles().then(update); + return unsubscribe; + }, []); const blocked = status?.state === "conflict" || status?.state === "unsafe"; - const canMutate = loaded && !!status && !blocked && !busy; + const canMutate = loaded && !!status && !blocked && !busy && confirmedLaunch; const installed = status?.state === "current"; const removable = !!status && isRemovable(status); - const canRemove = removable && !busy; + const canRemove = removable && !busy && confirmedLaunch; const primaryKey = status?.state === "update-available" ? "sub.delegationSetup.update" : status?.state === "partial" ? "sub.delegationSetup.repair" : "sub.delegationSetup.install"; const prompt = status?.copyPrompts[selectedMode] ?? ""; @@ -92,6 +104,7 @@ export default function CodexDelegationSetupCard({ delegationSetup }: { delegati
  • {t("sub.delegationSetup.agentsArtifact")}{status.artifacts.agentsPolicy.displayPath}
  • {blocked &&

    {t(blockedReason(status))}

    } + {!confirmedLaunch &&

    {t("sub.delegationSetup.launcherRequired")}

    } {error &&

    {t("sub.delegationSetup.error")}

    }
    @@ -103,8 +116,8 @@ export default function CodexDelegationSetupCard({ delegationSetup }: { delegati {success &&

    {t("sub.delegationSetup.newTask")}

    }
    {t("sub.delegationSetup.manual")}

    {t("sub.delegationSetup.manualHint")}

    } - {previewOpen && status &&
    event.stopPropagation()}>

    {t("sub.delegationSetup.preview")}

    {status.previews[selectedMode].skillText}
    {status.previews[selectedMode].agentsBlockText}
    {previewApply && }
    } - {removeOpen &&
    event.stopPropagation()}>

    {t("sub.delegationSetup.removeTitle")}

    {t("sub.delegationSetup.removeConfirm")}

    {error &&

    {t("sub.delegationSetup.error")}

    }
    } + {previewOpen && status &&
    event.stopPropagation()}>

    {t("sub.delegationSetup.preview")}

    {status.previews[selectedMode].skillText}
    {status.previews[selectedMode].agentsBlockText}
    {previewApply && }
    } + {removeOpen &&
    event.stopPropagation()}>

    {t("sub.delegationSetup.removeTitle")}

    {t("sub.delegationSetup.removeConfirm")}

    {error &&

    {t("sub.delegationSetup.error")}

    }
    } ); } diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 4dce11aec5..4acaf79e2a 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -2247,4 +2247,5 @@ export const de: Record = { "sub.delegationSetup.close": "Schließen", "sub.delegationSetup.cancel": "Abbrechen", "sub.delegationSetup.confirmChangeMode": "Modus ändern", + "sub.delegationSetup.launcherRequired": "Dieses Dashboard ist für Installieren, Aktualisieren und Entfernen schreibgeschützt. Öffne es mit `ccx gui` oder über die CodexCommander-Menüleiste, um diese Änderungen zu bestätigen.", }; diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index b1f442cdcf..2f12d23e38 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2275,6 +2275,7 @@ export const en = { "sub.delegationSetup.close": "Close", "sub.delegationSetup.cancel": "Cancel", "sub.delegationSetup.confirmChangeMode": "Change mode", + "sub.delegationSetup.launcherRequired": "This dashboard is read-only for Install, Update, and Remove. Open it with `ccx gui` or from the CodexCommander menu bar app to confirm those changes.", } as const; diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index e69b0f3738..508edec1cc 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2267,4 +2267,5 @@ export const ja: Record = { "sub.delegationSetup.close": "閉じる", "sub.delegationSetup.cancel": "キャンセル", "sub.delegationSetup.confirmChangeMode": "モードを変更", + "sub.delegationSetup.launcherRequired": "このダッシュボードではインストール、更新、削除は読み取り専用です。`ccx gui` または CodexCommander メニューバーアプリから開いて、これらの変更を確認してください。", }; diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index ba906092c3..1f6d715ebe 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -2267,5 +2267,6 @@ export const ko: Record = { "sub.delegationSetup.close": "닫기", "sub.delegationSetup.cancel": "취소", "sub.delegationSetup.confirmChangeMode": "모드 변경", + "sub.delegationSetup.launcherRequired": "이 대시보드에서는 설치, 업데이트, 제거가 읽기 전용입니다. `ccx gui` 또는 CodexCommander 메뉴 막대 앱에서 열어 해당 변경을 확인하세요.", }; diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 230495e0b1..c5977b866b 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2269,4 +2269,5 @@ export const ru: Record = { "sub.delegationSetup.close": "Закрыть", "sub.delegationSetup.cancel": "Отмена", "sub.delegationSetup.confirmChangeMode": "Сменить режим", + "sub.delegationSetup.launcherRequired": "Эта панель только для чтения при установке, обновлении и удалении. Откройте её через `ccx gui` или приложение CodexCommander в строке меню, чтобы подтвердить эти изменения.", }; diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 0e3d3f7d84..e5cdb52851 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2267,4 +2267,5 @@ export const zh: Record = { "sub.delegationSetup.close": "关闭", "sub.delegationSetup.cancel": "取消", "sub.delegationSetup.confirmChangeMode": "更改模式", + "sub.delegationSetup.launcherRequired": "此仪表板的安装、更新和移除为只读。请通过 `ccx gui` 或 CodexCommander 菜单栏应用打开,以确认这些更改。", }; diff --git a/gui/src/pages/ApiKeys.tsx b/gui/src/pages/ApiKeys.tsx index 0f3e0487d3..7358a083ed 100644 --- a/gui/src/pages/ApiKeys.tsx +++ b/gui/src/pages/ApiKeys.tsx @@ -4,7 +4,7 @@ import { useI18n, LOCALES } from "../i18n/shared"; import { formatProviderDisplayName } from "../provider-icons"; import { readJsonIfOk, readJsonOrThrow } from "../fetch-json"; import { - classifyExternalModel, + classifyManagementModel, externalModelId, type ExternalModelRow, type GatewayInboundProtocol, @@ -153,22 +153,13 @@ export default function ApiKeys({ apiBase, active = true }: { apiBase: string; a }, [apiBase, keysCacheKey, t]); const fetchModels = useCallback(async (signal: AbortSignal): Promise => { - const res = await fetch(`${apiBase}/v1/models`, { signal }); + const res = await fetch(`${apiBase}/api/models`, { signal }); if (!res.ok) throw new Error(t("api.modelsLoadFailed")); const data = await res.json() as unknown; - const rawRows = Array.isArray(data) - ? data - : (typeof data === "object" && data !== null && Array.isArray((data as { data?: unknown }).data) - ? (data as { data: unknown[] }).data - : null); - if (!rawRows) throw new Error(t("api.modelsLoadFailed")); - const rows = rawRows - .filter((row): row is { id: string; owned_by?: string } => ( - typeof row === "object" - && row !== null - && typeof (row as { id?: unknown }).id === "string" - )) - .map(row => classifyExternalModel(row)) + if (!Array.isArray(data)) throw new Error(t("api.modelsLoadFailed")); + const rows = data + .map(row => classifyManagementModel(row)) + .filter((row): row is ExternalModelRow => row !== null) .sort((a, b) => externalModelId(a).localeCompare(externalModelId(b))); writeSessionListCache(modelsCacheKey, rows); return rows; diff --git a/gui/tests/api-access-models.test.ts b/gui/tests/api-access-models.test.ts index efe4cd0f2d..fba0714559 100644 --- a/gui/tests/api-access-models.test.ts +++ b/gui/tests/api-access-models.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { classifyExternalModel, + classifyManagementModel, gatewayInboundProtocols, } from "../src/api-access-models"; @@ -46,6 +47,23 @@ describe("classifyExternalModel", () => { }); }); +describe("classifyManagementModel", () => { + test("uses the namespaced callable id and drops disabled rows", () => { + expect(classifyManagementModel({ + id: "opus-4-6", namespaced: "anthropic/opus-4-6", provider: "anthropic", custom: true, disabled: false, + })).toEqual({ + id: "anthropic/opus-4-6", + displayName: "anthropic/opus-4-6", + provider: "anthropic", + native: false, + custom: true, + }); + expect(classifyManagementModel({ + id: "gpt-5.4", namespaced: "gpt-5.4", provider: "openai", native: true, disabled: true, + })).toBeNull(); + }); +}); + describe("gatewayInboundProtocols", () => { test("lists gateway protocols and hides Messages when Claude inbound is off", () => { expect(gatewayInboundProtocols(true)).toEqual(["responses", "chat", "messages"]); diff --git a/gui/tests/apikeys-layout.test.ts b/gui/tests/apikeys-layout.test.ts index d1768e1d95..12f0c715fe 100644 --- a/gui/tests/apikeys-layout.test.ts +++ b/gui/tests/apikeys-layout.test.ts @@ -102,7 +102,9 @@ test("ApiKeys workspace keeps endpoint, generate, models, and usage panels", asy expect(between).not.toContain('t("api.usageChatTitle")'); expect(between).not.toContain('t("api.usageResponsesTitle")'); expect(src).toContain("gatewayInboundProtocols(claudeCodeEnabled)"); - expect(page).toContain("classifyExternalModel(row)"); + expect(page).toContain("classifyManagementModel(row)"); + expect(page).toContain("${apiBase}/api/models"); + expect(page).not.toContain("${apiBase}/v1/models"); expect(page).toContain('from "../api-access-models"'); }); diff --git a/gui/tests/apikeys-model-test-wire.test.tsx b/gui/tests/apikeys-model-test-wire.test.tsx index b2fe446506..2d6d5cc589 100644 --- a/gui/tests/apikeys-model-test-wire.test.tsx +++ b/gui/tests/apikeys-model-test-wire.test.tsx @@ -88,8 +88,8 @@ function installFetch(sent: SentRequest[], dataPlaneStatus = 200): void { globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); const method = (init?.method ?? "GET").toUpperCase(); - if (url.endsWith("/v1/models") && method === "GET") { - return Response.json({ data: [{ id: "gpt-5.4", owned_by: "openai" }] }); + if (url.endsWith("/api/models") && method === "GET") { + return Response.json([{ id: "gpt-5.4", namespaced: "gpt-5.4", provider: "openai", native: true, disabled: false }]); } if (url.endsWith("/api/keys") && method === "GET") return Response.json(KEYS_OK); if (url.endsWith("/api/keys") && method === "POST") return Response.json({ key: ONE_TIME_KEY }); diff --git a/gui/tests/apikeys-models-states.test.tsx b/gui/tests/apikeys-models-states.test.tsx index 7d00dbdc01..cf832ff59a 100644 --- a/gui/tests/apikeys-models-states.test.tsx +++ b/gui/tests/apikeys-models-states.test.tsx @@ -77,7 +77,7 @@ function installFetch(models: () => Response, counter: { gets: number }): void { globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); const method = (init?.method ?? "GET").toUpperCase(); - if (url.endsWith("/v1/models") && method === "GET") { + if (url.endsWith("/api/models") && method === "GET") { counter.gets += 1; return models(); } @@ -121,7 +121,7 @@ function retryButton(container: HTMLDivElement): HTMLButtonElement | undefined { test("an empty catalog says the catalog is empty, with no query in the sentence", async () => { const counter = { gets: 0 }; - installFetch(() => Response.json({ data: [] }), counter); + installFetch(() => Response.json([]), counter); const { container, root } = await mountPage(); try { expect(container.textContent).toContain("No externally callable models are available yet."); @@ -134,9 +134,10 @@ test("an empty catalog says the catalog is empty, with no query in the sentence" test("a query matching nothing names the query, and does not claim the catalog is empty", async () => { const counter = { gets: 0 }; - installFetch(() => Response.json({ - data: [{ id: "gpt-5.4", owned_by: "openai" }, { id: "claude/opus-4-6", owned_by: "anthropic" }], - }), counter); + installFetch(() => Response.json([ + { id: "gpt-5.4", namespaced: "gpt-5.4", provider: "openai", native: true, disabled: false }, + { id: "opus-4-6", namespaced: "claude/opus-4-6", provider: "anthropic", custom: true, disabled: false }, + ]), counter); const { container, root } = await mountPage(); try { expect(container.textContent).toContain("gpt-5.4"); @@ -158,7 +159,7 @@ test("a failed cold load offers a retry that really refetches, and no false empt installFetch( () => (fail ? new Response("upstream unavailable", { status: 503 }) - : Response.json({ data: [{ id: "gpt-5.4", owned_by: "openai" }] })), + : Response.json([{ id: "gpt-5.4", namespaced: "gpt-5.4", provider: "openai", native: true, disabled: false }])), counter, ); const { container, root } = await mountPage(); @@ -220,12 +221,12 @@ test("a failure is announced, and a cache-backed retry shows progress without lo globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); const method = (init?.method ?? "GET").toUpperCase(); - if (url.endsWith("/v1/models") && method === "GET") { + if (url.endsWith("/api/models") && method === "GET") { counter.gets += 1; if (counter.gets === 1) return new Response("upstream unavailable", { status: 503 }); // Hold the retry open so the in-flight state is observable. await new Promise(resolve => { release = resolve; }); - return Response.json({ data: [{ id: "fresh-model", owned_by: "openai" }] }); + return Response.json([{ id: "fresh-model", namespaced: "fresh-model", provider: "openai", native: true, disabled: false }]); } if (url.endsWith("/api/keys") && method === "GET") return Response.json(KEYS_OK); return new Response(null, { status: 404 }); diff --git a/gui/tests/apikeys-mutation-timeout.test.tsx b/gui/tests/apikeys-mutation-timeout.test.tsx index 676de6c3b2..27434451e8 100644 --- a/gui/tests/apikeys-mutation-timeout.test.tsx +++ b/gui/tests/apikeys-mutation-timeout.test.tsx @@ -114,7 +114,7 @@ function installStallingFetch(seen: { aborted: boolean }): void { globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); const method = (init?.method ?? "GET").toUpperCase(); - if (url.endsWith("/v1/models")) return Response.json({ data: [] }); + if (url.endsWith("/api/models")) return Response.json([]); if (url.endsWith("/api/keys") && method === "GET") return Response.json(KEYS_OK); return new Promise((_resolve, reject) => { const signal = init?.signal; diff --git a/gui/tests/apikeys-refresh-preserve.test.tsx b/gui/tests/apikeys-refresh-preserve.test.tsx index f79c3921d6..cc17108fde 100644 --- a/gui/tests/apikeys-refresh-preserve.test.tsx +++ b/gui/tests/apikeys-refresh-preserve.test.tsx @@ -87,7 +87,7 @@ test("successful key create keeps last-good keys visible when follow-up refresh globalThis.fetch = (async (input, init) => { const url = String(input); const method = (init?.method ?? "GET").toUpperCase(); - if (url.endsWith("/v1/models")) { + if (url.endsWith("/api/models")) { return Response.json({ data: [] }); } if (url.endsWith("/api/keys") && method === "GET") { @@ -162,7 +162,7 @@ test("successful key delete keeps last-good keys visible when follow-up refresh globalThis.fetch = (async (input, init) => { const url = String(input); const method = (init?.method ?? "GET").toUpperCase(); - if (url.endsWith("/v1/models")) { + if (url.endsWith("/api/models")) { return Response.json({ data: [] }); } if (url.endsWith("/api/keys") && method === "GET") { diff --git a/gui/tests/codex-delegation-setup.test.tsx b/gui/tests/codex-delegation-setup.test.tsx index c3375c6dab..baf0049ea9 100644 --- a/gui/tests/codex-delegation-setup.test.tsx +++ b/gui/tests/codex-delegation-setup.test.tsx @@ -10,6 +10,7 @@ import { type CodexDelegationStatus, } from "../src/pages/use-codex-delegation-setup"; import { LanguageProvider } from "../src/i18n/provider"; +import { setConfirmedGuiLaunchForTests } from "../src/api"; const globals = ["document", "window", "navigator", "localStorage", "sessionStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const; let previousGlobals: Record<(typeof globals)[number], unknown>; @@ -46,6 +47,7 @@ function makeArtifactStatus( } beforeEach(() => { + setConfirmedGuiLaunchForTests(true); previousGlobals = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; testWindow = new Window({ url: "http://localhost/" }); Object.defineProperty(testWindow.navigator, "language", { configurable: true, value: "en-US" }); @@ -206,6 +208,25 @@ for (const state of ["conflict", "unsafe"] as const) { }); } +test("without a confirmed launch Install, Update, and Remove stay disabled", async () => { + setConfirmedGuiLaunchForTests(false); + let installs = 0; + let uninstalls = 0; + await mountDirect(makeStatus("current", "balanced"), { + install: async () => { installs++; return true; }, + uninstall: async () => { uninstalls++; return true; }, + }); + expect(button("Change mode").disabled).toBe(true); + expect(button("Remove").disabled).toBe(true); + expect(container.querySelector('[role="status"]')?.textContent).toContain("read-only for Install, Update, and Remove"); + button("Change mode").click(); + button("Remove").click(); + expect(installs).toBe(0); + expect(uninstalls).toBe(0); + expect(container.querySelector('[role="dialog"]')).toBeNull(); + expect(container.querySelector('[role="alertdialog"]')).toBeNull(); +}); + test("shadowed current install is truthful and never claims Ready", async () => { const value = makeStatus("current", "balanced"); value.activation = "shadowed"; value.override.state = "active"; await mountDirect(value); diff --git a/scripts/build-macos-app.sh b/scripts/build-macos-app.sh index 4514d6e051..01b9057aac 100755 --- a/scripts/build-macos-app.sh +++ b/scripts/build-macos-app.sh @@ -98,11 +98,43 @@ assert_safe_file "$repo_root/gui/public/logo.png" "gui/public/logo.png" "$repo_r assert_safe_file "$repo_root/gui/public/favicon.png" "gui/public/favicon.png" "$repo_root" assert_safe_file "$repo_root/LICENSE" "LICENSE" "$repo_root" assert_safe_file "$repo_root/THIRD_PARTY_NOTICES.md" "THIRD_PARTY_NOTICES.md" "$repo_root" -assert_safe_tree "$repo_root/src" "src" "$repo_root" -assert_safe_tree "$repo_root/bin" "bin" "$repo_root" -assert_safe_tree "$repo_root/gui/dist" "gui/dist" "$repo_root" assert_safe_tree "$repo_root/gui/public/provider-icons" "gui/public/provider-icons" "$repo_root" +assert_tracked_tree() { + local prefix="$1" + local rel found=0 + while IFS= read -r -d '' rel; do + found=1 + assert_safe_file "$repo_root/$rel" "$rel" "$repo_root" + done < <(git -C "$repo_root" ls-files -z -- "$prefix") + if [[ "$found" -eq 0 ]]; then + echo "No tracked files under $prefix to package." >&2 + exit 1 + fi +} + +# Inspect tracked runtime sources before the Swift/GUI work. Untracked files are +# ignored here on purpose: they must never enter the bundle. +assert_tracked_tree "src" +assert_tracked_tree "bin" + +stage_tracked_tree() { + local prefix="$1" + local dest_root="$2" + local rel dest dest_dir found=0 + while IFS= read -r -d '' rel; do + found=1 + dest="$dest_root/$rel" + dest_dir="$(dirname "$dest")" + mkdir -p "$dest_dir" + copy_verified_file "$repo_root/$rel" "$dest" "$rel" "$repo_root" "$staging_root" + done < <(git -C "$repo_root" ls-files -z -- "$prefix") + if [[ "$found" -eq 0 ]]; then + echo "No tracked files under $prefix to stage." >&2 + exit 1 + fi +} + # Validate BEFORE creating anything, so the script cannot leave a directory behind at a # path it then refuses to build into. # @@ -243,13 +275,19 @@ copy_verified_file "$package_dir/Info.plist" "$staged_app/Contents/Info.plist" \ # relying on the caller's checkout, PATH, npm, or a separately installed Bun. Resolve one # lockfile-pinned production install for both Darwin architectures; the installed app never # repeats this step or performs a first-launch network install. +# +# Stage only git-tracked runtime files. Copying the live working tree would ship untracked +# secrets dropped under src/ or bin/. A fresh GUI build replaces ignored gui/dist so a +# stale dashboard cannot ride into the bundle. runtime_root="$staged_app/Contents/Resources/runtime" +echo "==> Building GUI…" +(cd "$repo_root/gui" && bun install --frozen-lockfile && bun run build) assert_safe_file "$repo_root/gui/dist/index.html" "gui/dist/index.html" "$repo_root" mkdir -p "$runtime_root" copy_verified_file "$repo_root/package.json" "$runtime_root/package.json" "package.json" "$repo_root" "$staging_root" copy_verified_file "$repo_root/bun.lock" "$runtime_root/bun.lock" "bun.lock" "$repo_root" "$staging_root" -copy_verified_tree "$repo_root/src" "$runtime_root/src" "src" "$repo_root" "$staging_root" -copy_verified_tree "$repo_root/bin" "$runtime_root/bin" "bin" "$repo_root" "$staging_root" +stage_tracked_tree "src" "$runtime_root" +stage_tracked_tree "bin" "$runtime_root" mkdir -p "$runtime_root/gui" copy_verified_tree "$repo_root/gui/dist" "$runtime_root/gui/dist" "gui/dist" "$repo_root" "$staging_root" @@ -288,6 +326,8 @@ chmod 755 "$runtime_bun" rm -f "$runtime_root/node_modules/bun/bin/bunx" "$runtime_root/node_modules/bun/bin/bunx.exe" \ "$runtime_root/node_modules/.bin/bunx" ln -s bun.exe "$runtime_root/node_modules/bun/bin/bunx.exe" +bun "$script_dir/package-tree-safety.ts" --check-symlinks \ + "$runtime_root/node_modules" "$staging_root" keyring_version="$(sed -n 's/^[[:space:]]*"version":[[:space:]]*"\([^"]*\)",/\1/p' \ "$runtime_root/node_modules/@napi-rs/keyring/package.json" | head -n 1)" @@ -357,6 +397,12 @@ assert_safe_tree "$runtime_root/bin" "staged runtime bin" "$staging_root" assert_safe_tree "$runtime_root/gui/dist" "staged runtime gui/dist" "$staging_root" assert_safe_tree "$staged_app/Contents/Resources/provider-icons" "staged provider icons" "$staging_root" +echo "==> Scanning staged runtime…" +if ! bun "$repo_root/scripts/privacy-scan.ts" --scan-root "$runtime_root"; then + echo "Staged app failed the privacy scan." >&2 + exit 1 +fi + # The app version comes from package.json, so it can never claim a version the release # did not ship. version="$(sed -n 's/^[[:space:]]*"version": "\([^"]*\)",/\1/p' "$repo_root/package.json" | head -n 1)" diff --git a/scripts/package-macos-release.sh b/scripts/package-macos-release.sh index 8ece1ee2a6..c30d33673e 100755 --- a/scripts/package-macos-release.sh +++ b/scripts/package-macos-release.sh @@ -34,6 +34,12 @@ if [[ "$universal" != "0" && "$universal" != "1" ]]; then exit 1 fi +if [[ -n "$(git -C "$repo_root" status --porcelain --untracked-files=normal 2>/dev/null)" ]]; then + echo "package:macos requires a clean git working tree (no uncommitted or untracked files)." >&2 + echo "Commit your changes, or use bun run build:macos for a local development build." >&2 + exit 1 +fi + mkdir -p "$output_dir" output_dir="$(cd "$output_dir" && pwd)" diff --git a/scripts/package-tree-safety.ts b/scripts/package-tree-safety.ts index e21620eead..f2e2d57aa4 100644 --- a/scripts/package-tree-safety.ts +++ b/scripts/package-tree-safety.ts @@ -1,5 +1,5 @@ -import { lstatSync, realpathSync, readdirSync } from "node:fs"; -import { isAbsolute, relative } from "node:path"; +import { lstatSync, readdirSync, readlinkSync, realpathSync } from "node:fs"; +import { isAbsolute, join, relative, resolve } from "node:path"; function failsContainment(root: string, candidate: string): boolean { const path = relative(root, candidate); @@ -65,3 +65,47 @@ export function assertSafePackageFile(path: string, label: string, trustedRoot: const root = resolveTrustedRoot(trustedRoot); assertSafeRegularFile(path, label, root); } + +/** + * Package-manager trees may contain relative shim links. Absolute targets and + * any link that escapes the trusted root are refused before the tree is archived. + */ +export function assertSafeBundledSymlinks(path: string, label: string, trustedRoot: string): void { + const root = resolveTrustedRoot(trustedRoot); + const walk = (current: string): void => { + const stat = lstatSync(current); + if (stat.isSymbolicLink()) { + const target = readlinkSync(current); + if (isAbsolute(target)) { + throw new Error(`${label} contains an absolute symlink`); + } + let physical: string; + try { + physical = realpathSync(current); + } catch { + physical = resolve(join(current, ".."), target); + } + if (failsContainment(root, physical)) { + throw new Error(`${label} contains a symlink that escapes its trusted root`); + } + return; + } + if (stat.isDirectory()) { + for (const entry of readdirSync(current)) walk(join(current, entry)); + } + }; + walk(path); +} + +if (import.meta.main) { + const command = process.argv[2]; + if (command === "--check-symlinks") { + const tree = process.argv[3]; + const trustedRoot = process.argv[4]; + if (!tree || !trustedRoot) { + console.error("usage: package-tree-safety.ts --check-symlinks "); + process.exit(1); + } + assertSafeBundledSymlinks(tree, "bundled node_modules", trustedRoot); + } +} diff --git a/scripts/privacy-scan.ts b/scripts/privacy-scan.ts index 9a3621fdf0..25e7a6b505 100644 --- a/scripts/privacy-scan.ts +++ b/scripts/privacy-scan.ts @@ -1,15 +1,15 @@ -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; -type Finding = { +export type Finding = { file: string; line: number; kind: string; value: string; }; -const TEXT_FILE_RE = /\.(?:cjs|css|html|js|json|jsonc|md|mjs|ps1|sh|toml|ts|tsx|txt|yml|yaml)$/; +export const TEXT_FILE_RE = /\.(?:cjs|css|html|js|json|jsonc|md|mjs|plist|ps1|sh|swift|toml|ts|tsx|txt|yml|yaml)$/; const EXCLUDED_PREFIXES = [ - "gui/dist/", "node_modules/", "tests/.tmp-", ]; @@ -30,14 +30,26 @@ function gitLsFiles(): string[] { .filter(Boolean); } -function shouldScan(file: string): boolean { - if (!TEXT_FILE_RE.test(file)) return false; - if (EXCLUDED_PREFIXES.some(prefix => file.startsWith(prefix))) return false; - if (EXCLUDED_SUFFIXES.some(suffix => file.endsWith(suffix))) return false; +function posixPath(file: string): string { + return file.replaceAll("\\", "/"); +} + +function pathHasSegment(file: string, segment: string): boolean { + return posixPath(file).split("/").includes(segment); +} + +export function shouldScan(file: string): boolean { + const normalized = posixPath(file); + if (!TEXT_FILE_RE.test(normalized)) return false; + if (pathHasSegment(normalized, "node_modules")) return false; + if (EXCLUDED_PREFIXES.some(prefix => normalized.startsWith(prefix) || normalized.includes(`/${prefix}`))) { + return false; + } + if (EXCLUDED_SUFFIXES.some(suffix => normalized.endsWith(suffix))) return false; return true; } -function lineNumber(text: string, index: number): number { +export function lineNumber(text: string, index: number): number { let line = 1; for (let i = 0; i < index; i += 1) { if (text.charCodeAt(i) === 10) line += 1; @@ -57,16 +69,23 @@ function isAllowedEmail(file: string, email: string): boolean { } function isAllowedHomePath(file: string, username: string): boolean { - if (file.startsWith("tests/") && (username === "example" || username === "test" || username === "x")) { + const normalized = posixPath(file); + if ( + (normalized.startsWith("tests/") + || normalized.startsWith("app/") + || normalized.includes("/tests/") + || normalized.includes("/app/")) + && (username === "example" || username === "test" || username === "x") + ) { return true; } - if (file.startsWith("docs/") && (username === "me" || username === "user")) return true; - if (file.startsWith("docs-site/") && username === "example") return true; + if (normalized.startsWith("docs/") && (username === "me" || username === "user")) return true; + if (normalized.startsWith("docs-site/") && username === "example") return true; return false; } function isAllowedTokenLooking(file: string, token: string): boolean { - if (file.startsWith("tests/")) { + if (posixPath(file).includes("/tests/") || posixPath(file).startsWith("tests/")) { // Test fixture sentinels: sk-rawsentinel..., sk-test-... return /^sk-(?:rawsentinel|test-)\d+[a-z]*$/.test(token); } @@ -74,7 +93,8 @@ function isAllowedTokenLooking(file: string, token: string): boolean { } function isAllowedBearerToken(file: string, token: string): boolean { - if (!file.startsWith("tests/")) return false; + const normalized = posixPath(file); + if (!normalized.startsWith("tests/") && !normalized.includes("/tests/")) return false; return /^(?:access|stack|usage-debug)-token(?:-value)?-[A-Za-z0-9-]+$/.test(token); } @@ -97,8 +117,7 @@ function addFindingsForPattern( } } -function scanFile(file: string): Finding[] { - const text = readFileSync(file, "utf-8"); +export function scanFile(file: string, text: string = readFileSync(file, "utf-8")): Finding[] { const findings: Finding[] = []; addFindingsForPattern( findings, @@ -136,17 +155,61 @@ function scanFile(file: string): Finding[] { return findings; } -const findings = gitLsFiles() - .filter(existsSync) - .filter(shouldScan) - .flatMap(scanFile); +export function redactSecret(value: string): string { + return `[redacted ${value.length} chars]`; +} + +export function formatFinding(finding: Finding): string { + return `${finding.file}:${finding.line} ${finding.kind}: ${redactSecret(finding.value)}`; +} + +function walkTextFiles(root: string, files: string[] = []): string[] { + if (!existsSync(root)) return files; + const entries = readdirSync(root, { withFileTypes: true }); + for (const entry of entries) { + const full = join(root, entry.name); + if (entry.isDirectory()) { + if (entry.name === "node_modules") continue; + walkTextFiles(full, files); + continue; + } + if (entry.isFile() || entry.isSymbolicLink()) files.push(full); + } + return files; +} -if (findings.length > 0) { - console.error("Privacy scan failed:"); - for (const finding of findings) { - console.error(`${finding.file}:${finding.line} ${finding.kind}: ${finding.value}`); +export function collectScanFiles(options: { scanRoot?: string; cwd?: string } = {}): string[] { + if (options.scanRoot) { + return walkTextFiles(options.scanRoot).filter(existsSync).filter(shouldScan); } - process.exit(1); + const tracked = gitLsFiles().filter(existsSync).filter(shouldScan); + const generatedGui = walkTextFiles(join(options.cwd ?? ".", "gui/dist")).filter(shouldScan); + return [...new Set([...tracked, ...generatedGui])]; } -console.log("Privacy scan passed"); +export function scanFiles(files: string[]): Finding[] { + return files.flatMap(file => scanFile(file)); +} + +export function main(argv: string[] = process.argv.slice(2)): number { + const scanRootIndex = argv.indexOf("--scan-root"); + const scanRoot = scanRootIndex >= 0 ? argv[scanRootIndex + 1] : undefined; + if (scanRootIndex >= 0 && !scanRoot) { + console.error("privacy-scan: --scan-root requires a directory"); + return 2; + } + const findings = scanFiles(collectScanFiles({ scanRoot })); + if (findings.length > 0) { + console.error("Privacy scan failed:"); + for (const finding of findings) { + console.error(formatFinding(finding)); + } + return 1; + } + console.log("Privacy scan passed"); + return 0; +} + +if (import.meta.main) { + process.exit(main()); +} diff --git a/src/server/index.ts b/src/server/index.ts index 5d7a9b434d..c28a1a6325 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1060,7 +1060,12 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server= 0 ? length : null; } +function concatChunks(chunks: Uint8Array[], length: number): Uint8Array { + const body = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return body; +} + +/** + * Read a request body while counting bytes, aborting as soon as the cap is + * exceeded. `req.arrayBuffer()` cannot do this: it materializes the entire + * body first, so a missing or dishonest Content-Length still OOMs. + */ +export async function readBoundedRawRequestBody(req: Request, maxBytes: number): Promise { + const declaredLength = declaredBodyLength(req); + if (declaredLength !== null && declaredLength > maxBytes) { + throw new DecompressedBodyTooLargeError(declaredLength, maxBytes); + } + if (req.body == null) return new Uint8Array(); + + const reader = req.body.getReader(); + const chunks: Uint8Array[] = []; + let length = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + length += value.byteLength; + if (length > maxBytes) { + try { await reader.cancel(); } catch { /* the size error wins */ } + throw new DecompressedBodyTooLargeError(length, maxBytes); + } + chunks.push(value); + } + } catch (error) { + try { await reader.cancel(); } catch { /* the original body error wins */ } + throw error; + } finally { + try { reader.releaseLock(); } catch { /* already cancelled or unlocked */ } + } + return concatChunks(chunks, length); +} + function inflateDeflateBody(compressed: Uint8Array, opts: { maxOutputLength: number }): Uint8Array { // HTTP "deflate" appears both zlib-wrapped and raw in the wild (Bun.deflateSync emits raw, // which the previous Bun.inflateSync accepted). Try zlib-wrapped first, fall back to raw — @@ -93,18 +139,12 @@ export async function readBoundedJsonRequestBody( ): Promise { const encoding = req.headers.get("content-encoding"); const declaredLength = declaredBodyLength(req); - // Reject an honest oversized declaration before req.arrayBuffer() can allocate it. - // Missing, malformed, or dishonest declarations remain covered by decodeRequestBody's - // post-read cap below. - if (declaredLength !== null && declaredLength > maxBytes) { - throw new DecompressedBodyTooLargeError(declaredLength, maxBytes); - } const releaseReservation = budget && declaredLength !== null && declaredLength > 0 ? budget.observeAcceptedRequestCopy(declaredLength) : undefined; let raw: Uint8Array; try { - raw = new Uint8Array(await req.arrayBuffer()); + raw = await readBoundedRawRequestBody(req, maxBytes); } finally { releaseReservation?.(); } diff --git a/tests/macos-build-script.test.ts b/tests/macos-build-script.test.ts index 377353c92e..ca9addb591 100644 --- a/tests/macos-build-script.test.ts +++ b/tests/macos-build-script.test.ts @@ -26,9 +26,20 @@ describe("macOS build script bundle contract", () => { expect(scriptSource).toContain('staged_app="$staging_root/CodexCommander.app"'); expect(scriptSource).toContain('! -f "$runtime_root/bin/ccx.mjs"'); expect(scriptSource).toContain('source_revision="${CCX_BUILD_REVISION:-}"'); - expect(scriptSource).toContain('assert_safe_tree "$repo_root/gui/dist" "gui/dist" "$repo_root"'); + expect(scriptSource).toContain("git ls-files -z --"); + expect(scriptSource).toContain("assert_tracked_tree \"src\""); + expect(scriptSource).toContain("assert_tracked_tree \"bin\""); + expect(scriptSource).toContain("stage_tracked_tree \"src\""); + expect(scriptSource).toContain("stage_tracked_tree \"bin\""); + expect(scriptSource).toContain("bun run build"); + expect(scriptSource).toContain("privacy-scan.ts"); + expect(scriptSource).toContain("--check-symlinks"); + expect(scriptSource).not.toContain('copy_verified_tree "$repo_root/src"'); + expect(scriptSource).not.toContain('copy_verified_tree "$repo_root/bin"'); expect(scriptSource).toContain('copy_verified_tree "$repo_root/gui/dist" "$runtime_root/gui/dist"'); expect(scriptSource).toContain('find -P "$path" -print0'); + expect(releaseScriptSource).toContain("status --porcelain"); + expect(releaseScriptSource).toContain("package:macos requires a clean git working tree"); }); test("requires the canonical delegation skill in staged and archived runtimes", () => { @@ -73,14 +84,15 @@ async function withSandbox(body: (sandbox: string) => Promise): Promise } describe.skipIf(!isMacOS)("macOS build script containment", () => { - test("fails closed on linked GUI source entries before invoking the build", async () => { + test("fails closed on a tracked source path that became a symlink", async () => { await withSandbox(async sandbox => { - const guiDist = join(repoRoot, "gui", "dist"); + const tracked = join(repoRoot, "src", "identity.ts"); + const original = readFileSync(tracked); const external = join(sandbox, "external.txt"); - const sourceLink = join(guiDist, `.ccx-unsafe-file-${process.pid}`); writeFileSync(external, "external content must not be copied or chmodded"); chmodSync(external, 0o600); - symlinkSync(external, sourceLink); + rmSync(tracked); + symlinkSync(external, tracked); try { const { stderr, exitCode } = await runScript(join(sandbox, "output")); expect(exitCode).not.toBe(0); @@ -88,39 +100,26 @@ describe.skipIf(!isMacOS)("macOS build script containment", () => { expect(stderr).toContain("symbolic link"); expect(readFileSync(external, "utf8")).toBe("external content must not be copied or chmodded"); } finally { - rmSync(sourceLink, { force: true }); + rmSync(tracked, { force: true }); + writeFileSync(tracked, original); } }); }, 120_000); - test("rejects symlinked GUI directories and hard-linked files without modifying the external inode", async () => { + test("rejects a multiply-linked tracked source file without modifying the external inode", async () => { await withSandbox(async sandbox => { - const guiDist = join(repoRoot, "gui", "dist"); - const externalDir = join(sandbox, "external-dir"); - const external = join(sandbox, "external.txt"); - const directoryLink = join(guiDist, `.ccx-unsafe-dir-${process.pid}`); - const hardLink = join(guiDist, `.ccx-unsafe-hardlink-${process.pid}`); - mkdirSync(externalDir); - writeFileSync(join(externalDir, "asset.js"), "outside"); - writeFileSync(external, "external hardlink content"); - chmodSync(external, 0o600); - symlinkSync(externalDir, directoryLink); - try { - let result = await runScript(join(sandbox, "directory-output")); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("symbolic link"); - } finally { - rmSync(directoryLink, { force: true }); - } - - linkSync(external, hardLink); + const tracked = join(repoRoot, "src", "identity.ts"); + const original = readFileSync(tracked, "utf8"); + const extraLink = join(sandbox, "identity-hardlink.ts"); + linkSync(tracked, extraLink); try { const result = await runScript(join(sandbox, "hardlink-output")); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("multiply linked"); - expect(readFileSync(external, "utf8")).toBe("external hardlink content"); + expect(readFileSync(tracked, "utf8")).toBe(original); + expect(readFileSync(extraLink, "utf8")).toBe(original); } finally { - rmSync(hardLink, { force: true }); + rmSync(extraLink, { force: true }); } }); }, 120_000); @@ -247,6 +246,9 @@ describe.skipIf(!isMacOS)("macOS build script containment", () => { test("allows a destination inside the repository", async () => { const inside = join(repoRoot, "dist", `ccx-inside-${process.pid}`); const probeCwd = mkdtempSync(join(tmpdir(), "ccx-bundled-probe-")); + const untrackedName = `.ccx-untracked-secret-${process.pid}.ts`; + const untracked = join(repoRoot, "src", untrackedName); + writeFileSync(untracked, "export const shouldNotShip = true;\n"); try { const { stderr, exitCode } = await runScript(inside); expect(exitCode).toBe(0); @@ -265,6 +267,7 @@ describe.skipIf(!isMacOS)("macOS build script containment", () => { expect(existsSync(join(runtime, "package.json"))).toBe(true); expect(existsSync(join(runtime, "bin", "ccx.mjs"))).toBe(true); expect(existsSync(join(runtime, "src", "cli", "index.ts"))).toBe(true); + expect(existsSync(join(runtime, "src", untrackedName))).toBe(false); const bundledBun = existsSync(join(runtime, "node_modules", "bun", "bin", "bun.exe")) ? join(runtime, "node_modules", "bun", "bin", "bun.exe") : join(runtime, "node_modules", "bun", "bin", "bun"); @@ -293,6 +296,7 @@ describe.skipIf(!isMacOS)("macOS build script containment", () => { expect(info).toContain("CodexCommanderSourceRevision"); expect(info).toMatch(/[0-9a-f]{40}(?:-dirty)?/); } finally { + rmSync(untracked, { force: true }); rmSync(inside, { recursive: true, force: true }); rmSync(probeCwd, { recursive: true, force: true }); } diff --git a/tests/package-tree-safety.test.ts b/tests/package-tree-safety.test.ts index bd321d11af..9bd656b996 100644 --- a/tests/package-tree-safety.test.ts +++ b/tests/package-tree-safety.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import { chmodSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { assertSafePackageFile, assertSafePackageTree } from "../scripts/package-tree-safety"; +import { assertSafeBundledSymlinks, assertSafePackageFile, assertSafePackageTree } from "../scripts/package-tree-safety"; function withTree(body: (root: string, outside: string) => void): void { const sandbox = mkdtempSync(join(tmpdir(), "ccx-package-tree-")); @@ -90,4 +90,21 @@ describe("package source-tree safety", () => { linkSync(external, linked); expect(() => assertSafePackageFile(linked, "launcher", root)).toThrow("multiply linked"); })); + + test("accepts relative bundled shims and rejects absolute or escaping symlink targets", () => withTree((root, outside) => { + const modules = join(root, "node_modules", "bun", "bin"); + mkdirSync(modules, { recursive: true }); + writeFileSync(join(modules, "bun.exe"), "#!/usr/bin/env bun"); + symlinkSync("bun.exe", join(modules, "bunx.exe")); + expect(() => assertSafeBundledSymlinks(join(root, "node_modules"), "bundled node_modules", root)).not.toThrow(); + + symlinkSync("/tmp/outside-bin", join(modules, "absolute.exe")); + expect(() => assertSafeBundledSymlinks(join(root, "node_modules"), "bundled node_modules", root)).toThrow("absolute symlink"); + rmSync(join(modules, "absolute.exe")); + + writeFileSync(join(outside, "secret"), "keep-this-external-content"); + symlinkSync(join("..", "..", "..", "..", "outside", "secret"), join(modules, "escape.exe")); + expect(() => assertSafeBundledSymlinks(join(root, "node_modules"), "bundled node_modules", root)).toThrow("escapes its trusted root"); + expect(readFileSync(join(outside, "secret"), "utf8")).toBe("keep-this-external-content"); + })); }); diff --git a/tests/privacy-scan.test.ts b/tests/privacy-scan.test.ts new file mode 100644 index 0000000000..d4bcbeedf2 --- /dev/null +++ b/tests/privacy-scan.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + formatFinding, + main, + redactSecret, + scanFile, + shouldScan, +} from "../scripts/privacy-scan"; + +describe("privacy scan coverage", () => { + test("scans Swift, plist, and generated GUI files", () => { + expect(shouldScan("app/Sources/MenuBarCore/ProxyClient.swift")).toBe(true); + expect(shouldScan("app/Info.plist")).toBe(true); + expect(shouldScan("gui/dist/index.html")).toBe(true); + expect(shouldScan("gui/dist/assets/index.js")).toBe(true); + expect(shouldScan("node_modules/secret.ts")).toBe(false); + expect(shouldScan("src/foo/node_modules/secret.ts")).toBe(false); + }); + + test("redacts matched secrets instead of printing them", () => { + const secret = ["sk-", "liveabcdefghijklmnopqrstuvwxyz012345"].join(""); + const finding = scanFile("src/example.ts", `const token = "${secret}";\n`)[0]; + expect(finding?.kind).toBe("token-looking"); + const printed = formatFinding(finding!); + expect(printed).toContain("token-looking"); + expect(printed).toContain("src/example.ts:1"); + expect(printed).toContain(redactSecret(secret)); + expect(printed).not.toContain(secret); + expect(redactSecret(secret)).toBe(`[redacted ${secret.length} chars]`); + }); + + test("scan-root fails closed without echoing the secret into logs", () => { + const root = mkdtempSync(join(tmpdir(), "ccx-privacy-scan-")); + const secret = ["ghp_", "abcdefghijklmnopqrstuvwxyz0123"].join(""); + mkdirSync(join(root, "gui", "dist"), { recursive: true }); + writeFileSync(join(root, "gui", "dist", "app.js"), `export const leak = "${secret}";\n`); + const errors: string[] = []; + const originalError = console.error; + const originalLog = console.log; + console.error = (...args: unknown[]) => { errors.push(args.map(String).join(" ")); }; + console.log = () => {}; + try { + expect(main(["--scan-root", root])).toBe(1); + expect(errors.some(line => line.includes("token-looking"))).toBe(true); + expect(errors.join("\n")).not.toContain(secret); + } finally { + console.error = originalError; + console.log = originalLog; + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/request-decompress.test.ts b/tests/request-decompress.test.ts index 7c0f1b213e..02e3f8dc77 100644 --- a/tests/request-decompress.test.ts +++ b/tests/request-decompress.test.ts @@ -3,6 +3,8 @@ import { DecompressedBodyTooLargeError, decodeRequestBody, MAX_DECOMPRESSED_BODY_BYTES, + readBoundedJsonRequestBody, + readBoundedRawRequestBody, readJsonRequestBody, UnsupportedContentEncodingError, } from "../src/server/request-decompress"; @@ -113,6 +115,58 @@ describe("readJsonRequestBody", () => { expect(arrayBufferCalls).toBe(0); }); + test("rejects undeclared over-cap bodies while streaming without buffering the remainder", async () => { + const cap = 2048; + let pulled = 0; + let cancelled = false; + let arrayBufferCalls = 0; + const stream = new ReadableStream({ + pull(controller) { + pulled += 1; + controller.enqueue(new Uint8Array(512).fill(65)); + }, + cancel() { + cancelled = true; + }, + }); + const req = { + headers: new Headers(), + body: stream, + arrayBuffer: async () => { + arrayBufferCalls += 1; + return new ArrayBuffer(0); + }, + } as Request; + await expect(readBoundedJsonRequestBody(req, cap)).rejects.toBeInstanceOf(DecompressedBodyTooLargeError); + expect(arrayBufferCalls).toBe(0); + expect(pulled).toBeLessThan(16); + expect(cancelled).toBe(true); + }); + + test("rejects a dishonest understated Content-Length once the streamed body exceeds the cap", async () => { + const cap = 1024; + let pulled = 0; + let arrayBufferCalls = 0; + const stream = new ReadableStream({ + pull(controller) { + pulled += 1; + controller.enqueue(new Uint8Array(400).fill(66)); + }, + cancel() {}, + }); + const req = { + headers: new Headers({ "content-length": "8" }), + body: stream, + arrayBuffer: async () => { + arrayBufferCalls += 1; + return new ArrayBuffer(0); + }, + } as Request; + await expect(readBoundedRawRequestBody(req, cap)).rejects.toBeInstanceOf(DecompressedBodyTooLargeError); + expect(arrayBufferCalls).toBe(0); + expect(pulled).toBeLessThan(10); + }); + test("management routes reject a lying declaration when the buffered body exceeds 4 MiB", async () => { const body = JSON.stringify({ codexAutoStart: "x".repeat(MANAGEMENT_JSON_BODY_MAX_BYTES) }); const req = new Request("http://localhost/api/settings", { diff --git a/tests/server-images.test.ts b/tests/server-images.test.ts index 207d1f9f6e..816594f836 100644 --- a/tests/server-images.test.ts +++ b/tests/server-images.test.ts @@ -1887,6 +1887,12 @@ test("GET /v1/codexcommander/artifacts/:id serves opaque artifacts with API auth headers: { authorization: "Bearer proxy-admission-secret" }, }); expect(traversal.status).toBe(404); + + const malformed = await fetch(`http://127.0.0.1:${server.port}/v1/codexcommander/artifacts/%E0%A4%A`, { + headers: { authorization: "Bearer proxy-admission-secret" }, + }); + expect(malformed.status).toBe(400); + expect(await malformed.json()).toMatchObject({ error: { message: "invalid artifact id encoding" } }); } finally { await server.stop(true); delete process.env.CODEXCOMMANDER_API_AUTH_TOKEN; diff --git a/tests/server-live.test.ts b/tests/server-live.test.ts index 995b7bc4bd..5be34fc335 100644 --- a/tests/server-live.test.ts +++ b/tests/server-live.test.ts @@ -578,6 +578,8 @@ test("buildLiveSidebandUpstreamWsUrl maps Frameless and Realtime join shapes", a style: "realtime-query", callId: "rtc_2", }); + expect(parseLiveSidebandTarget("/v1/live/%E0%A4%A", new URLSearchParams())).toBeNull(); + expect(parseLiveSidebandTarget("/v1/realtime/calls/%E0%A4%A", new URLSearchParams())).toBeNull(); expect( buildLiveSidebandUpstreamWsUrl({ From ff1432754f217044c57c09b6db13fbf2e96f3af2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 27 Aug 2026 18:35:40 +0000 Subject: [PATCH 2/3] test: match git -C ls-files staging assertion The packaging script lists tracked files with `git -C "$repo_root" ls-files -z --`, not `git ls-files -z --`. Co-authored-by: pavelhov --- tests/macos-build-script.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/macos-build-script.test.ts b/tests/macos-build-script.test.ts index ca9addb591..a537fd7601 100644 --- a/tests/macos-build-script.test.ts +++ b/tests/macos-build-script.test.ts @@ -26,7 +26,7 @@ describe("macOS build script bundle contract", () => { expect(scriptSource).toContain('staged_app="$staging_root/CodexCommander.app"'); expect(scriptSource).toContain('! -f "$runtime_root/bin/ccx.mjs"'); expect(scriptSource).toContain('source_revision="${CCX_BUILD_REVISION:-}"'); - expect(scriptSource).toContain("git ls-files -z --"); + expect(scriptSource).toContain("ls-files -z --"); expect(scriptSource).toContain("assert_tracked_tree \"src\""); expect(scriptSource).toContain("assert_tracked_tree \"bin\""); expect(scriptSource).toContain("stage_tracked_tree \"src\""); From 67f5d1c897fd5c0ecdf0c047e6c72c4ac71cc967 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 27 Aug 2026 18:37:57 +0000 Subject: [PATCH 3/3] test(gui): include launcherRequired in delegation i18n contract The confirmed-launch notice added a locale key that every locale already ships; the parity list in subagents-classic must name it too. Co-authored-by: pavelhov --- gui/tests/subagents-classic.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/gui/tests/subagents-classic.test.ts b/gui/tests/subagents-classic.test.ts index edf10a5a79..4daf5f832d 100644 --- a/gui/tests/subagents-classic.test.ts +++ b/gui/tests/subagents-classic.test.ts @@ -121,6 +121,7 @@ const delegationSetupKeys = [ "sub.delegationSetup.close", "sub.delegationSetup.cancel", "sub.delegationSetup.confirmChangeMode", + "sub.delegationSetup.launcherRequired", ] as const; function findHardcodedVisibleJsxCopy(src: string): string[] {