From 0307a1e70c7f758ecf3f9e422deeef5d4599e3f4 Mon Sep 17 00:00:00 2001 From: pavelhov Date: Thu, 20 Aug 2026 16:43:57 -0400 Subject: [PATCH 01/28] docs: design zero-click macOS first-run bootstrap --- ...08-20-macos-zero-click-first-run-design.md | 349 ++++++++++++++++++ 1 file changed, 349 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-20-macos-zero-click-first-run-design.md diff --git a/docs/superpowers/specs/2026-08-20-macos-zero-click-first-run-design.md b/docs/superpowers/specs/2026-08-20-macos-zero-click-first-run-design.md new file mode 100644 index 0000000000..aaa0c4aadf --- /dev/null +++ b/docs/superpowers/specs/2026-08-20-macos-zero-click-first-run-design.md @@ -0,0 +1,349 @@ +# macOS Zero-Click First-Run Bootstrap + +## Summary + +The packaged macOS app currently contains the complete CodexCommander runtime but +cannot start on a fresh Mac when `~/.codexcommander/config.json` is absent. A direct +app launch performs an explicit Start, which first persists the desired Codex routing +state. The existing field-scoped mutator deliberately refuses to create a missing +configuration file, so Start stops with: + +> Codex routing could not be enabled: No config file exists to record the switch in. + +The macOS app will gain a direct-launch-only bootstrap step. It will atomically create +the existing secret-free `getDefaultConfig()` when, and only when, the CodexCommander +configuration is genuinely absent. It will not run the interactive CLI wizard, write +credentials, overwrite existing configuration, create Codex-owned configuration, or +move the application. + +## Goals + +- Make first launch zero-click when Codex is already initialized. +- Start the proxy and route Codex through the existing default ChatGPT passthrough + provider without requiring terminal access. +- Keep the dashboard available when Codex itself has not initialized its configuration. +- Preserve every existing valid CodexCommander configuration byte-for-byte during + bootstrap admission. +- Refuse malformed, unreadable, unsafe, or conflicting configuration instead of + replacing it with defaults. +- Preserve existing CLI, passive-launch, external-provider, routing-recovery, and + lifecycle-authority semantics. +- Treat an app outside Applications as usable for the current session, while keeping + Launch at Login unavailable until the user moves it. + +## Non-goals + +- Reimplement the interactive `ccx init` wizard in Swift. +- Prompt for, generate, import, or copy API keys, OAuth credentials, account state, or + provider-specific configuration. +- Create or repair `$CODEX_HOME/config.toml`. +- Automatically move, copy, or relaunch `CodexCommander.app`. +- Change ordinary `ccx start`, `ccx ensure`, `setIntegrationEnabled()`, or passive + companion launch behavior. +- Add a persistent first-run marker or a background file watcher. + +## Product decisions + +1. First launch is zero-click when the required Codex state already exists. +2. Missing Codex-owned configuration is a recoverable prerequisite, not permission to + manufacture another application's file. +3. An app launched from Desktop or Downloads may run for the current session but does + not register as a Login Item. +4. An app running from `/AppTranslocation/` must not start a detached proxy from an + ephemeral runtime path. It asks the user to move the app and reopen it. +5. The TypeScript runtime remains the sole owner of the configuration schema and fresh + defaults. Swift receives structured outcomes only. + +## Existing invariants to preserve + +- `getDefaultConfig()` is the canonical fresh configuration. It currently selects the + no-secret OpenAI/ChatGPT passthrough provider. +- `mutatePersistedConfig()` and `setIntegrationEnabled()` fail closed on a missing or + malformed file. Their contracts remain unchanged. +- Explicit Start is the operation that may turn a prior native/OFF decision back on. +- External user-managed Codex providers are preserved. +- Lifecycle and configuration mutations remain serialized by their existing authorities. +- CodexCommander never silently overwrites malformed or untrusted configuration. +- The native lifecycle bridge emits one bounded, secret-free JSON frame. + +## Architecture + +### 1. Configuration initializer + +Add a narrow primitive to `src/config.ts` that initializes a supplied, schema-valid +configuration only if the persisted file is genuinely absent. + +The initializer returns a discriminated result: + +- `created` — the candidate was atomically persisted. +- `existing` — a valid configuration appeared before the commit or already existed. +- `refused` — a file or filesystem object exists but is invalid, unreadable, unsafe, or + otherwise cannot be admitted. + +The primitive owns: + +- the existing configuration mutation lock; +- an under-lock re-read immediately before committing; +- schema validation of the candidate; +- atomic persistence; +- state-directory permissions and existing ownership metadata behavior; and +- stable, non-secret refusal reason codes. + +It does not import macOS, lifecycle, provider-selection, or Codex-path logic. It does +not change `mutatePersistedConfig()`. + +### 2. macOS bootstrap policy + +Add a TypeScript policy in the macOS/lifecycle layer. The policy classifies Codex's +configuration path as absent or not absent using the established effective Codex home. +Only an actual missing-path result counts as absent; a directory, unsafe link, +permission error, or other filesystem failure is left for existing routing admission +to reject. + +The candidate is always a clone of `getDefaultConfig()`: + +- When Codex configuration is present, persist the clone unchanged. +- When Codex configuration is absent, add + `clientIntegrations.codex = false` before persistence. + +The `false` value belongs to CodexCommander's own configuration and prevents a fresh +proxy from attempting Codex catalog injection before Codex has created its files. +No credential-bearing input participates in bootstrap. + +### 3. Lifecycle orchestration + +Only direct macOS app Start receives bootstrap authority. The orchestration enters the +existing lifecycle authority, performs the app bootstrap under the configuration lock, +and then continues without releasing lifecycle authority. + +- A present Codex configuration follows the existing explicit Start transaction. +- A newly bootstrapped configuration with Codex integration off starts or attaches to + the proxy without enabling Codex routing. +- An existing valid CodexCommander configuration paired with absent Codex + configuration remains byte-for-byte untouched by bootstrap. Existing Start behavior + may bring up the proxy and encounter the expected missing-Codex sync refusal; when + the proxy is proven running, orchestration reports `codex-first-run` rather than + claiming that routing succeeded or flattening the prerequisite into a generic error. +- A refused bootstrap returns a structured configuration error without attempting to + replace the file. +- A concurrently created valid configuration is adopted and processed through the + existing Start behavior. + +The returned action remains `start`, even when the fresh-Codex-missing branch performs +only the proxy portion of Start. This keeps the native bridge contract aligned with the +user action. + +Ordinary CLI Start, Ensure, passive companion launches, Restore, Stop, and routing +toggles do not receive this bootstrap hook. + +### 4. Native lifecycle bridge + +Extend the bounded lifecycle JSON result with an optional structured setup requirement. +The initial recognized value is `codex-first-run`. + +The field is additive and secret-free. Swift must tolerate: + +- the field being absent when talking to older helpers; +- the recognized value; and +- unknown future values without rejecting the entire lifecycle result. + +The Swift action coordinator maps a running result with `codex-first-run` to a dedicated +setup-required outcome instead of treating it as `START_FAILED`. + +### 5. Native UI + +When setup is required, the menu shows a persistent, nonfatal card: + +- The proxy is described as running. +- The explanation says Codex has not created its local configuration yet. +- The user is told to open Codex once, then use the existing **Route Codex Through + Proxy** operation. +- Dashboard, Logs, Refresh, provider management, and proxy-stop controls remain usable. +- The app does not open the dashboard, launch Codex, retry in the background, or dismiss + the card on a timer. + +The existing route operation is the only retry path. Once Codex configuration exists, +it turns integration back on and performs the normal identity-attested live sync. + +### 6. Launch at Login presentation + +An app outside `/Applications`, `~/Applications`, or the repository's supported source +build path receives a distinct relocation-required presentation: + +- Proxy startup remains enabled for a physical Desktop/Downloads bundle. +- The Launch at Login toggle is disabled. +- The row says Launch at Login is available after moving the app to Applications. +- A non-destructive action opens the Applications folder in Finder. +- The condition is not styled or reported as a proxy/lifecycle failure. + +An `/AppTranslocation/` bundle is different: detached startup is blocked because the +embedded runtime path is ephemeral. The app asks the user to move it to Applications +and reopen it. + +## Startup state matrix + +### Missing CodexCommander config, Codex ready + +1. Create the canonical default atomically. +2. Execute normal explicit Start. +3. Start or attach to the proxy. +4. Synchronize the model catalog. +5. Route Codex through the attested proxy. + +Expected outcome: running and ready with no first-run prompt. + +### Missing CodexCommander config, Codex not initialized + +1. Create the canonical default with `clientIntegrations.codex = false`. +2. Start or attach to the proxy without changing Codex routing. +3. Return `setupRequired: "codex-first-run"`. +4. Keep the dashboard and menu controls available. + +Expected outcome: proxy running, Codex native, actionable setup card visible. + +### Existing valid CodexCommander config + +Do not bootstrap, merge, backfill, or rewrite it. Continue through existing lifecycle +behavior, including preservation of external Codex providers and explicit Start's +current routing semantics. If Codex configuration is absent but the proxy is proven +running, report the structured Codex first-run requirement and keep the dashboard +available. Do not claim that Codex routing succeeded. + +### Existing invalid or unsafe CodexCommander config + +Refuse bootstrap and leave the bytes or filesystem object untouched. Return a stable +configuration-repair result. Do not silently run on transient defaults. + +### Competing first launch or configuration edit + +Re-read under the configuration mutation lock. If a valid file now exists, adopt it. +If an invalid object now exists, refuse it. Never overwrite the winner. + +## Race behavior + +- If Codex creates its configuration after bootstrap classified it as absent, the + proxy remains native until the explicit route retry. This is a safe false negative. +- If Codex configuration disappears after being classified as present, existing + routing admission fails closed. +- If CodexCommander configuration disappears after bootstrap but before routing intent + is saved, the existing field-scoped mutation refusal remains authoritative. +- If another process creates CodexCommander configuration between the initial read and + commit, the under-lock re-read adopts or refuses that object instead of replacing it. +- No retry loop recreates a file that vanished during an admitted mutation. + +## Error handling + +User-visible errors use stable classifications rather than parsing raw exception text: + +- configuration needs repair; +- configuration is inaccessible; +- Codex first-run setup is required; +- app must be moved and reopened because it is translocated; and +- ordinary lifecycle or routing failure. + +Messages do not include credentials, raw configuration, account identities, request +content, or private filesystem paths. Existing Logs and diagnostics remain the detailed +troubleshooting surfaces. + +## Security and privacy + +- The bootstrap candidate comes only from checked-in runtime defaults. +- The default contains no API key or OAuth credential. +- Existing configuration is never used as a stale base for a replacement write. +- Bootstrap uses existing filesystem-hardening, atomic-write, mutation-lock, and + ownership-metadata paths. +- Missing, invalid, unreadable, and conflicting states remain distinct. +- External Codex routes and recovery journals stay under existing ownership checks. +- The native bridge remains bounded and secret-free. +- No new telemetry, logs, or persistent onboarding identifiers are introduced. + +## Distribution behavior + +The feature does not make the development app a universal release artifact. Public +distribution must continue using the release packaging path, which preserves bundle +metadata and produces the requested architecture slices. The documented Control-click +Open/Gatekeeper behavior for an ad-hoc, unnotarized preview remains unchanged. + +The app must not treat copying provider or credential state from another Mac as part of +first-run bootstrap. + +## Testing + +### Configuration unit tests + +- Missing file creates the exact validated candidate. +- Existing valid file is unchanged byte-for-byte. +- Invalid JSON and schema-invalid files are refused unchanged. +- A directory, unreadable object, unsafe link, or ownership failure is refused. +- A competing valid creation wins and is adopted. +- A competing invalid creation wins and is refused. +- The state directory and file retain the existing hardened permissions/ownership + behavior. +- Candidate schema validation occurs before persistence. + +### Lifecycle tests + +- Missing app config plus present Codex config bootstraps and runs explicit Start. +- Missing app config plus absent Codex config bootstraps with integration off and starts + the proxy without routing. +- Existing valid config bypasses bootstrap. +- Existing valid app config plus absent Codex config remains unchanged by bootstrap and + reports setup required only when the proxy is proven running. +- Existing explicit OFF intent is not changed by the bootstrap initializer. +- Existing external Codex routing remains preserved. +- Refused bootstrap does not spawn or attach to a proxy through transient defaults. +- Already-running, current-home proxy behavior remains identity-attested. +- Codex appearance/disappearance races produce the documented safe outcomes. +- Ordinary CLI Start and `setIntegrationEnabled()` still refuse a missing config. + +### Bridge and Swift tests + +- Lifecycle JSON remains under its byte bound. +- Results without `setupRequired` decode as before. +- `codex-first-run` maps to a dedicated setup outcome. +- Unknown setup values do not invalidate the full result. +- The setup card leaves dashboard and proxy controls enabled. +- Route retry uses the existing route operation. +- Physical non-Applications paths show neutral relocation guidance. +- Translocated paths block detached startup and show move-and-reopen guidance. + +### End-to-end verification + +Use temporary CodexCommander and Codex homes; never exercise the developer's real home. +Verify a fresh-home packaged app flow with Codex present and absent without contacting a +real provider. Run: + +- focused Bun configuration and lifecycle tests; +- focused Swift core and UI tests; +- `bun run typecheck`; +- `bun run test:parallel` with serial fallback if required; +- `bun run privacy:scan`; +- `bun run test:macos`; and +- `bun run build:macos`. + +## Documentation changes + +Update the macOS menu-bar guide and installation/quickstart documentation to state: + +- a fresh direct app launch creates the secret-free default automatically; +- Codex must initialize its own configuration before routing; +- the app does not copy providers or credentials from another Mac; +- Desktop/Downloads launches work for the current session but not Launch at Login; +- translocated apps must be moved and reopened; and +- the universal release archive remains the supported distribution artifact. + +## Acceptance criteria + +1. Copying a supported packaged app to a fresh Apple-silicon or Intel Mac and launching + it from Applications no longer produces the missing CodexCommander configuration + error. +2. With Codex already initialized, the first app launch reaches the same running, + synchronized, routed state as an explicitly configured default installation. +3. Without Codex configuration, the proxy and dashboard run, Codex remains native, and + the menu presents an accurate retry action. +4. No existing valid, invalid, unreadable, unsafe, external-provider, or explicitly OFF + configuration is replaced by bootstrap defaults. +5. CLI and passive-launch behavior remain backward compatible. +6. An app outside Applications is not misreported as a proxy failure, and an + AppTranslocation runtime is not used for detached startup. +7. All focused and repository-required verification commands pass. From 2fa23271238c3b4fbdeda6768326e0a643dd00a0 Mon Sep 17 00:00:00 2001 From: pavelhov Date: Thu, 20 Aug 2026 16:59:54 -0400 Subject: [PATCH 02/28] docs: plan zero-click macOS first run --- .../2026-08-20-macos-zero-click-first-run.md | 1435 +++++++++++++++++ 1 file changed, 1435 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-20-macos-zero-click-first-run.md diff --git a/docs/superpowers/plans/2026-08-20-macos-zero-click-first-run.md b/docs/superpowers/plans/2026-08-20-macos-zero-click-first-run.md new file mode 100644 index 0000000000..da073c09f8 --- /dev/null +++ b/docs/superpowers/plans/2026-08-20-macos-zero-click-first-run.md @@ -0,0 +1,1435 @@ +# macOS Zero-Click First-Run Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make a direct packaged macOS app launch safely create CodexCommander's secret-free default configuration, start the proxy, and route initialized Codex without terminal setup. + +**Architecture:** A no-clobber initializer in the TypeScript configuration layer remains the only code that can create `config.json`. A macOS-only policy supplies `getDefaultConfig()` and a typed start preparation that runs under existing lifecycle authority; the bounded JSON bridge carries setup state to Swift, where the menu presents nonfatal guidance. App-location classification remains native and blocks only ephemeral App Translocation startup. + +**Tech Stack:** Bun-native strict TypeScript, Bun test, Swift 5.9/AppKit/ServiceManagement, the existing bounded lifecycle JSON bridge, Astro/Starlight Markdown documentation. + +**Spec:** `docs/superpowers/specs/2026-08-20-macos-zero-click-first-run-design.md` + +## Global Constraints + +- macOS remains version 13.0 or later; do not add a newer framework requirement. +- `getDefaultConfig()` remains the single source of truth for the fresh provider and settings. +- Bootstrap may create only a genuinely absent `$CODEXCOMMANDER_HOME/config.json`; it must never overwrite a valid, invalid, unreadable, linked, or non-regular entry. +- Bootstrap must not create or repair `$CODEX_HOME/config.toml`. +- The generated configuration contains no API key, OAuth credential, account identity, or copied machine state. +- Ordinary CLI Start/Ensure, passive companion launches, Stop/Restore, and `setIntegrationEnabled()` retain their current missing-config behavior. +- Existing external Codex routes, recovery journals, and explicit Start ownership checks remain authoritative. +- A physical Desktop/Downloads app may run for the current session but cannot register at login; an `/AppTranslocation/` app must not start a detached proxy. +- Swift must not parse `config.json`, select a provider, or infer setup state from human-readable error strings. +- Lifecycle JSON remains at most 2,048 bytes and secret-free. +- Tests use temporary CodexCommander and Codex homes and never mutate the developer's real home. +- No live provider endpoint is contacted by tests. + +## File Map + +- `src/config.ts` — create-only, no-clobber configuration initialization and typed refusal reasons. +- `src/cli/macos-first-run.ts` — macOS direct-launch policy that selects the canonical candidate and classifies missing Codex state. +- `src/cli/proxy-lifecycle.ts` — run the preparation under lifecycle authority and carry setup state through Start. +- `src/cli/macos-lifecycle.ts` — install the preparation hook only for the native helper's direct `start` action. +- `tests/config.test.ts` — configuration creation, refusal, race, and permissions coverage. +- `tests/macos-first-run.test.ts` — pure policy matrix. +- `tests/proxy-lifecycle.test.ts` — authority ordering, proxy-only Start, setup result, and CLI regressions. +- `tests/macos-lifecycle.test.ts` — app-only wiring and bounded result encoding. +- `app/Sources/MenuBarCore/LifecycleHelper.swift` — additive JSON decoding with unknown-value tolerance. +- `app/Sources/MenuBarCore/ActionCoordinator.swift` — map structured setup requirements into native outcomes. +- `app/Sources/MenuBarCore/LaunchAtLogin.swift` — classify stable, relocatable, and translocated app bundles and expose neutral remediation. +- `app/Sources/MenuBarUI/LifecyclePresentation.swift` — user-facing first-run and relocation copy. +- `app/Sources/MenuBarUI/OperationStatusView.swift` — existing warning-tone rendering; no new persistence store. +- `app/Sources/MenuBarUI/StartupModeView.swift` — render Login Item remediation as Open Settings or Open Applications. +- `app/Sources/MenuBarUI/PopoverViewController.swift` — expose setup warnings and startup remediation callbacks. +- `app/Sources/MenuBarUI/AppDelegate.swift` — handle setup outcomes, open Applications, and block translocated Start. +- `app/Sources/MenuBarCoreTests/*.swift` and `app/Sources/MenuBarUITests/main.swift` — bridge, state mapping, location, copy, accessibility, and action coverage. +- `README.md`, `docs-site/src/content/docs/getting-started/installation.md`, `docs-site/src/content/docs/getting-started/quickstart.md`, `docs-site/src/content/docs/guides/macos-menu-bar.md`, `structure/01_runtime.md`, and `structure/02_config-and-codex-home.md` — user guidance and maintainer invariants. + +--- + +### Task 1: Add a no-clobber configuration initializer + +**Files:** +- Modify: `src/config.ts:1599-1638,1887-1937` +- Test: `tests/config.test.ts:1-55,730-790` + +**Interfaces:** +- Consumes: `validateConfigCandidate(value)`, `withConfigMutationLockSync(fn)`, `bumpGenerationForCooperatingConfigWrite()`, `recordOwnedConfigPath(configDir, path)`, and the existing secret-path hardening functions. +- Produces: + +```ts +export type ConfigInitializationRefusal = + | "candidate-invalid" + | "existing-invalid" + | "existing-inaccessible" + | "existing-unsafe" + | "coordination-unavailable"; + +export type ConfigInitializationResult = + | { status: "created" } + | { status: "existing" } + | { status: "refused"; reason: ConfigInitializationRefusal }; + +export function initializeConfigIfMissing( + candidate: CodexCommanderConfig, +): ConfigInitializationResult; + +export function setConfigInitializationBeforePublishForTests( + hook: (() => void) | null, +): void; +``` + +- [ ] **Step 1: Write failing tests for missing, existing, and invalid entries** + +Add imports for `initializeConfigIfMissing` and `setConfigInitializationBeforePublishForTests`, then add a focused describe block: + +```ts +describe("create-only config initialization", () => { + test("creates the canonical candidate only when config.json is absent", () => { + const candidate = getDefaultConfig(); + expect(initializeConfigIfMissing(candidate)).toEqual({ status: "created" }); + expect(JSON.parse(readFileSync(getConfigPath(), "utf8"))).toEqual(candidate); + expect(lstatSync(getConfigPath()).isFile()).toBe(true); + if (process.platform !== "win32") { + expect(lstatSync(getConfigPath()).mode & 0o077).toBe(0); + } + }); + + test("keeps an existing valid config byte-for-byte", () => { + const bytes = `${JSON.stringify({ ...getDefaultConfig(), port: 12001 })}\n`; + writeFileSync(getConfigPath(), bytes, { mode: 0o600 }); + expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ status: "existing" }); + expect(readFileSync(getConfigPath(), "utf8")).toBe(bytes); + }); + + test("refuses malformed and schema-invalid config without rewriting", () => { + for (const bytes of ["{", '{"port":10100,"providers":{},"defaultProvider":"missing"}']) { + writeFileSync(getConfigPath(), bytes, "utf8"); + expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ + status: "refused", + reason: "existing-invalid", + }); + expect(readFileSync(getConfigPath(), "utf8")).toBe(bytes); + unlinkSync(getConfigPath()); + } + }); + + test("rejects an invalid candidate without creating config state", () => { + const invalid = { ...getDefaultConfig(), defaultProvider: "missing" }; + expect(initializeConfigIfMissing(invalid)).toEqual({ + status: "refused", + reason: "candidate-invalid", + }); + expect(existsSync(getConfigPath())).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run the focused tests and confirm the API is missing** + +Run: `bun test tests/config.test.ts --test-name-pattern "create-only config initialization"` + +Expected: FAIL because `initializeConfigIfMissing` and its test seam are not exported. + +- [ ] **Step 3: Add unsafe-entry and no-clobber race tests** + +Extend the same describe block: + +```ts +test("refuses linked and non-regular destinations", () => { + const real = join(testDir, "real-config.json"); + writeFileSync(real, `${JSON.stringify(getDefaultConfig())}\n`, "utf8"); + symlinkSync(real, getConfigPath()); + expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ + status: "refused", + reason: "existing-unsafe", + }); + unlinkSync(getConfigPath()); + mkdirSync(getConfigPath()); + expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ + status: "refused", + reason: "existing-unsafe", + }); +}); + +test("refuses an inaccessible existing file without replacing it", () => { + if (process.platform === "win32") return; + writeFileSync(getConfigPath(), `${JSON.stringify(getDefaultConfig())}\n`, { mode: 0o600 }); + chmodSync(getConfigPath(), 0o000); + try { + expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ + status: "refused", + reason: "existing-inaccessible", + }); + } finally { + chmodSync(getConfigPath(), 0o600); + } +}); + +test("refuses to claim a nonempty unowned configuration root", () => { + const foreign = join(testDir, "foreign.txt"); + writeFileSync(foreign, "keep", "utf8"); + expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ + status: "refused", + reason: "existing-unsafe", + }); + expect(readFileSync(foreign, "utf8")).toBe("keep"); + expect(existsSync(getConfigPath())).toBe(false); +}); + +test("adopts a valid file that wins immediately before no-clobber publish", () => { + const winner = { ...getDefaultConfig(), port: 12002 }; + setConfigInitializationBeforePublishForTests(() => { + writeFileSync(getConfigPath(), `${JSON.stringify(winner)}\n`, { mode: 0o600 }); + }); + expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ status: "existing" }); + expect(JSON.parse(readFileSync(getConfigPath(), "utf8"))).toEqual(winner); +}); + +test("refuses an invalid file that wins immediately before publish", () => { + setConfigInitializationBeforePublishForTests(() => { + writeFileSync(getConfigPath(), "{", { mode: 0o600 }); + }); + expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ + status: "refused", + reason: "existing-invalid", + }); + expect(readFileSync(getConfigPath(), "utf8")).toBe("{"); +}); +``` + +- [ ] **Step 4: Implement typed probing and exclusive publication** + +Use `lstatSync` before every read, treat only `ENOENT` as missing, and keep raw bytes private. Add `linkSync` using the existing no-clobber pattern from service persistence so publication never renames over a race winner. The implementation shape is: + +```ts +type ConfigEntryProbe = + | { kind: "missing" } + | { kind: "valid" } + | { kind: "refused"; reason: Exclude }; + +function probeConfigEntry(): ConfigEntryProbe { + let entry; + try { + entry = lstatSync(getConfigPath()); + } catch (error) { + return isMissingPathError(error) + ? { kind: "missing" } + : { kind: "refused", reason: "existing-inaccessible" }; + } + if (!entry.isFile() || entry.isSymbolicLink() || entry.nlink !== 1) { + return { kind: "refused", reason: "existing-unsafe" }; + } + try { + return configDiagnosticsFromRaw(readFileSync(getConfigPath(), "utf8")).source === "file" + ? { kind: "valid" } + : { kind: "refused", reason: "existing-invalid" }; + } catch { + return { kind: "refused", reason: "existing-inaccessible" }; + } +} + +export function initializeConfigIfMissing( + candidate: CodexCommanderConfig, +): ConfigInitializationResult { + const validated = validateConfigCandidate(candidate); + if (!validated.ok) return { status: "refused", reason: "candidate-invalid" }; + const observed = probeConfigEntry(); + if (observed.kind === "valid") return { status: "existing" }; + if (observed.kind === "refused") return { status: "refused", reason: observed.reason }; + try { + if (!recordOwnedConfigPath(getConfigDir(), getConfigPath())) { + return { status: "refused", reason: "existing-unsafe" }; + } + } catch { + return { status: "refused", reason: "existing-inaccessible" }; + } + + try { + return withConfigMutationLockSync(() => { + const current = probeConfigEntry(); + if (current.kind === "valid") return { status: "existing" } as const; + if (current.kind === "refused") { + return { status: "refused", reason: current.reason } as const; + } + const bytes = `${JSON.stringify(validated.config, null, 2)}\n`; + const published = publishConfigNoReplace(getConfigPath(), bytes); + if (!published) { + const winner = probeConfigEntry(); + if (winner.kind === "valid") return { status: "existing" } as const; + return { + status: "refused", + reason: winner.kind === "refused" ? winner.reason : "coordination-unavailable", + } as const; + } + bumpGenerationForCooperatingConfigWrite(); + return { status: "created" } as const; + }); + } catch { + return { status: "refused", reason: "coordination-unavailable" }; + } +} +``` + +Implement the private publisher explicitly: + +```ts +function publishConfigNoReplace(path: string, bytes: string): boolean { + recordOwnedConfigPath(resolveConfigDir(), path); + const target = resolveWriteTarget(path); + assertResolvedTargetAllowed(path, target); + const temp = `${target}.ccx.${process.pid}.${++_atomicSeq}.create.tmp`; + let published = false; + try { + writeFileSync(temp, bytes, { encoding: "utf8", mode: 0o600, flag: "wx" }); + try { chmodSync(temp, 0o600); } catch { /* filesystem may ignore chmod */ } + if (process.platform === "win32") { + hardenSecretPath(temp, { required: true, timeoutMemoKey: path }); + } + const hook = configInitializationBeforePublishForTests; + configInitializationBeforePublishForTests = null; + hook?.(); + try { + linkSync(temp, target); + published = true; + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") return false; + throw error; + } + } finally { + try { + unlinkSync(temp); + forgetEphemeralSecretPath(temp); + } catch (error) { + if (!isMissingPathError(error)) { + // After link succeeds, temp and destination are the same inode. Never + // truncate the temp in that state because it would erase config.json too. + if (!published) { + try { truncateSync(temp, 0); } catch { /* residual error below is authoritative */ } + } + throw new AtomicWriteSecretResidualError(temp, { cause: error }); + } + } + } +} +``` + +Export the one-shot setter, reset the hook in `afterEach`, and import `linkSync`. The destination has two hard links only during publication; removing the temp leaves a private single-link `config.json`. + +- [ ] **Step 5: Run focused and regression tests** + +Run: + +```bash +bun test tests/config.test.ts --test-name-pattern "create-only config initialization" +bun test tests/codex-desired-state.test.ts --test-name-pattern "missing config refuses" +bun run typecheck +``` + +Expected: all commands PASS; the desired-state regression still returns `reason: "missing"`. + +- [ ] **Step 6: Commit the initializer** + +```bash +git add src/config.ts tests/config.test.ts +git commit -m "feat(config): add create-only first-run initialization" +``` + +--- + +### Task 2: Add the pure macOS first-run policy + +**Files:** +- Create: `src/cli/macos-first-run.ts` +- Create: `tests/macos-first-run.test.ts` + +**Interfaces:** +- Consumes: `getDefaultConfig()`, `initializeConfigIfMissing(candidate)`, and `CODEX_CONFIG_PATH` from `src/codex/paths.ts`. +- Produces: + +```ts +export type ProxySetupRequirement = "codex-first-run"; + +export type ProxyStartPreparation = + | { + ok: true; + changed: boolean; + enableCodexRouting: boolean; + setupRequired?: ProxySetupRequirement; + } + | { + ok: false; + changed: false; + message: string; + errorCode: "CONFIGURATION_REQUIRED"; + }; + +export interface MacOSFirstRunIo { + initializeConfig?: typeof initializeConfigIfMissing; + codexConfigState?: () => "present-or-unreadable" | "missing"; +} + +export function prepareMacOSAppStart(io?: MacOSFirstRunIo): ProxyStartPreparation; +``` + +- [ ] **Step 1: Write the policy matrix as failing tests** + +Create `tests/macos-first-run.test.ts`: + +```ts +import { describe, expect, test } from "bun:test"; +import { getDefaultConfig } from "../src/config"; +import { prepareMacOSAppStart } from "../src/cli/macos-first-run"; + +describe("macOS first-run preparation", () => { + test("fresh app plus initialized Codex enables normal explicit routing", () => { + let candidate = getDefaultConfig(); + const result = prepareMacOSAppStart({ + codexConfigState: () => "present-or-unreadable", + initializeConfig: value => { candidate = value; return { status: "created" }; }, + }); + expect(candidate).toEqual(getDefaultConfig()); + expect(result).toEqual({ ok: true, changed: true, enableCodexRouting: true }); + }); + + test("fresh app plus missing Codex persists integration off and requests setup", () => { + let candidate = getDefaultConfig(); + const result = prepareMacOSAppStart({ + codexConfigState: () => "missing", + initializeConfig: value => { candidate = value; return { status: "created" }; }, + }); + expect(candidate.clientIntegrations).toEqual({ codex: false }); + expect(result).toEqual({ + ok: true, + changed: true, + enableCodexRouting: false, + setupRequired: "codex-first-run", + }); + }); + + test("existing config is never replaced even when Codex is missing", () => { + const result = prepareMacOSAppStart({ + codexConfigState: () => "missing", + initializeConfig: () => ({ status: "existing" }), + }); + expect(result).toEqual({ + ok: true, + changed: false, + enableCodexRouting: true, + setupRequired: "codex-first-run", + }); + }); + + test("typed initialization refusals become a secret-free app error", () => { + const result = prepareMacOSAppStart({ + codexConfigState: () => "present-or-unreadable", + initializeConfig: () => ({ status: "refused", reason: "existing-invalid" }), + }); + expect(result).toEqual({ + ok: false, + changed: false, + message: "CodexCommander configuration needs repair; no files were changed.", + errorCode: "CONFIGURATION_REQUIRED", + }); + }); +}); +``` + +- [ ] **Step 2: Run the new test and confirm the module is missing** + +Run: `bun test tests/macos-first-run.test.ts` + +Expected: FAIL because `src/cli/macos-first-run.ts` does not exist. + +- [ ] **Step 3: Implement the policy without reading credentials** + +Create the module with an `lstatSync(CODEX_CONFIG_PATH)` classifier that returns `missing` only for `ENOENT`; every other result is `present-or-unreadable` so existing Codex admission performs the authoritative validation. + +```ts +export function prepareMacOSAppStart( + io: MacOSFirstRunIo = {}, +): ProxyStartPreparation { + const codexState = (io.codexConfigState ?? defaultCodexConfigState)(); + const candidate = structuredClone(getDefaultConfig()); + if (codexState === "missing") { + candidate.clientIntegrations = { + ...(candidate.clientIntegrations ?? {}), + codex: false, + }; + } + const initialized = (io.initializeConfig ?? initializeConfigIfMissing)(candidate); + if (initialized.status === "refused") { + return { + ok: false, + changed: false, + message: initialized.reason === "existing-invalid" + ? "CodexCommander configuration needs repair; no files were changed." + : "CodexCommander configuration is inaccessible or unsafe; no files were changed.", + errorCode: "CONFIGURATION_REQUIRED", + }; + } + return { + ok: true, + changed: initialized.status === "created", + enableCodexRouting: !(initialized.status === "created" && codexState === "missing"), + ...(codexState === "missing" ? { setupRequired: "codex-first-run" as const } : {}), + }; +} +``` + +- [ ] **Step 4: Run policy tests and typecheck** + +Run: + +```bash +bun test tests/macos-first-run.test.ts +bun run typecheck +``` + +Expected: PASS. + +- [ ] **Step 5: Commit the policy** + +```bash +git add src/cli/macos-first-run.ts tests/macos-first-run.test.ts +git commit -m "feat(macos): define first-run bootstrap policy" +``` + +--- + +### Task 3: Run app preparation inside lifecycle authority + +**Files:** +- Modify: `src/cli/proxy-lifecycle.ts:73-183,647-930` +- Test: `tests/proxy-lifecycle.test.ts:1-95,100-760` + +**Interfaces:** +- Consumes: `ProxyStartPreparation` and `ProxySetupRequirement` from Task 2 through an injected `EnsureProxyLifecycleIo.prepareStart` hook. +- Produces: `ProxyLifecycleResult.setupRequired?: ProxySetupRequirement` and `ProxyLifecycleResult.errorCode` value `CONFIGURATION_REQUIRED`. + +- [ ] **Step 1: Write failing tests for authority ordering and refusal** + +Add `prepareStart?: () => ProxyStartPreparation` to the test IO only after the production type exists. First add tests that describe the required call order: + +```ts +test("app preparation runs under E before config load and routing preparation", async () => { + const calls: string[] = []; + const result = await ensureProxyLifecycle({ + action: "start", + ensureCompanion: false, + io: baseIo({ + acquireAuthority: async () => { calls.push("acquire-E"); return authority(calls); }, + prepareStart: () => { + calls.push("prepare-app"); + return { ok: true, changed: true, enableCodexRouting: true }; + }, + loadConfig: () => { calls.push("load-config"); return config(); }, + findLive: async () => ({ pid: 42, port: 10100, source: "runtime" }), + setEnabled: (_client, enabled) => { + calls.push(`enable:${enabled}`); + return { ok: true, status: "unchanged", enabled }; + }, + }), + }); + expect(result.ok).toBe(true); + expect(calls.indexOf("acquire-E")).toBeLessThan(calls.indexOf("prepare-app")); + expect(calls.indexOf("prepare-app")).toBeLessThan(calls.indexOf("load-config")); + expect(calls.indexOf("load-config")).toBeLessThan(calls.indexOf("enable:true")); +}); + +test("refused app preparation exits before load, probe, or spawn", async () => { + const calls: string[] = []; + const result = await ensureProxyLifecycle({ + action: "start", + io: baseIo({ + prepareStart: () => ({ + ok: false, + changed: false, + message: "CodexCommander configuration needs repair; no files were changed.", + errorCode: "CONFIGURATION_REQUIRED", + }), + loadConfig: () => { calls.push("load"); return config(); }, + findLive: async () => { calls.push("find"); return null; }, + spawnStart: async () => { calls.push("spawn"); }, + }), + }); + expect(result).toMatchObject({ + action: "start", + ok: false, + state: "blocked", + errorCode: "CONFIGURATION_REQUIRED", + }); + expect(calls).toEqual([]); +}); +``` + +- [ ] **Step 2: Run the focused tests and verify the missing hook/type failures** + +Run: `bun test tests/proxy-lifecycle.test.ts --test-name-pattern "app preparation"` + +Expected: FAIL because `prepareStart`, `setupRequired`, and `CONFIGURATION_REQUIRED` are not part of the lifecycle contract. + +- [ ] **Step 3: Write failing tests for proxy-only setup and running setup recovery** + +```ts +test("fresh missing-Codex preparation starts without enabling routing", async () => { + const calls: string[] = []; + const result = await ensureProxyLifecycle({ + action: "start", + ensureCompanion: false, + io: baseIo({ + prepareStart: () => ({ + ok: true, + changed: true, + enableCodexRouting: false, + setupRequired: "codex-first-run", + }), + loadConfig: () => ({ ...config(), clientIntegrations: { codex: false } }), + setEnabled: () => { calls.push("enable"); return { ok: true, status: "committed", enabled: true }; }, + findLive: async () => ({ pid: 42, port: 10100, source: "runtime" }), + syncLive: async () => ({ + status: "skipped", + skippedReason: "desired_disabled", + ok: true, + catalogQuality: "native-only", + catalogState: { state: "not_running", processes: [], catalogMtimeMs: null }, + }), + }), + }); + expect(calls).toEqual([]); + expect(result).toMatchObject({ + action: "start", + ok: true, + state: "running", + changed: true, + setupRequired: "codex-first-run", + }); +}); + +test("a proven running proxy reports missing Codex as setup instead of generic sync failure", async () => { + const result = await ensureProxyLifecycle({ + action: "start", + io: baseIo({ + prepareStart: () => ({ + ok: true, + changed: false, + enableCodexRouting: true, + setupRequired: "codex-first-run", + }), + findLive: async () => ({ pid: 42, port: 10100, source: "runtime" }), + syncLive: async () => ({ + status: "refused", + ok: false, + message: "Codex configuration is unavailable.", + lifecycleErrorCode: "SYNC_FAILED", + }), + }), + }); + expect(result).toMatchObject({ + ok: true, + state: "running", + pid: 42, + setupRequired: "codex-first-run", + }); + expect(result.errorCode).toBeUndefined(); +}); + +test("app preparation still preserves an external Codex provider", async () => { + const calls: string[] = []; + const result = await ensureProxyLifecycle({ + action: "start", + io: baseIo({ + prepareStart: () => ({ ok: true, changed: true, enableCodexRouting: true }), + externalProvider: () => "external-owner", + findLive: async () => ({ pid: 42, port: 10100, source: "runtime" }), + setEnabled: (_client, enabled) => { + calls.push(`enabled:${enabled}`); + return { ok: true, status: "unchanged", enabled }; + }, + syncLive: async () => ({ + status: "skipped", + skippedReason: "external_provider", + ok: true, + catalogQuality: "native-only", + catalogState: { state: "not_running", processes: [], catalogMtimeMs: null }, + }), + }), + }); + expect(result).toMatchObject({ ok: true, state: "running" }); + expect(calls).toEqual(["enabled:true"]); +}); +``` + +- [ ] **Step 4: Implement preparation plumbing and result propagation** + +In `EnsureProxyLifecycleIo`, add the hook. At the start of `ensureProxyLifecycleUnderLock`, before `loadConfig()`, evaluate it only for `action === "start"`: + +```ts +const startPreparation: ProxyStartPreparation = action === "start" + ? io.prepareStart?.() ?? { ok: true, changed: false, enableCodexRouting: true } + : { ok: true, changed: false, enableCodexRouting: action === "restart" }; +if (!startPreparation.ok) { + return lifecycleResult(action, "blocked", { + ok: false, + changed: startPreparation.changed, + message: startPreparation.message, + errorCode: startPreparation.errorCode, + }); +} +let config = (io.loadConfig ?? loadConfig)(); +let preparedChanged = startPreparation.changed; +``` + +Keep recordless-proxy checks for every explicit `start`. Gate only the durable OFF-to-ON transition: + +```ts +if (action === "start" && startPreparation.enableCodexRouting) { + const prepared = prepareExplicitProxyStartWithIo(io, live?.pid ?? undefined); + // existing refusal and reload behavior + preparedChanged ||= prepared.changed; +} +``` + +Add `setupRequired` to `lifecycleResult` options and returned JSON. After `syncResult` is known and a live proxy is proven, give setup priority over `syncProblem` and `syncNotice`: + +```ts +if (action === "start" && startPreparation.setupRequired) { + return lifecycleResult(action, "running", { + ok: true, + changed: preparedChanged || startedHere, + live, + message: "CodexCommander is running. Open Codex once, then route Codex through the proxy.", + setupRequired: startPreparation.setupRequired, + }); +} +``` + +Do not add the hook to Stop, Restore, Restart, service, or ordinary CLI callers. + +- [ ] **Step 5: Run lifecycle and desired-state regressions** + +Run: + +```bash +bun test tests/proxy-lifecycle.test.ts +bun test tests/codex-desired-state.test.ts +bun run typecheck +``` + +Expected: PASS, including existing lifecycle authority and missing-config refusal tests. + +- [ ] **Step 6: Commit lifecycle support** + +```bash +git add src/cli/proxy-lifecycle.ts tests/proxy-lifecycle.test.ts +git commit -m "feat(lifecycle): carry macOS first-run setup state" +``` + +--- + +### Task 4: Wire bootstrap only into the native Start bridge + +**Files:** +- Modify: `src/cli/macos-lifecycle.ts:1-105` +- Modify: `tests/macos-lifecycle.test.ts:1-115` + +**Interfaces:** +- Consumes: `prepareMacOSAppStart()` from Task 2 and `EnsureProxyLifecycleIo.prepareStart` from Task 3. +- Produces: `performMacOSLifecycleAction(action, deps?)` as a testable fixed-action dispatcher; production still exposes only `runMacOSLifecycleHelper(args)`. + +- [ ] **Step 1: Write a failing app-only wiring test** + +Add an injected dispatcher test: + +```ts +test("only direct native start installs first-run preparation", async () => { + const calls: string[] = []; + const ensure = async (options: Parameters[0]) => { + calls.push(`${options.action}:${options.io?.prepareStart ? "prepared" : "plain"}`); + return { ...success(), action: options.action ?? "ensure" }; + }; + await performMacOSLifecycleAction("ensure", { ensureProxyLifecycle: ensure }); + await performMacOSLifecycleAction("start", { + ensureProxyLifecycle: ensure, + prepareMacOSAppStart: () => ({ ok: true, changed: false, enableCodexRouting: true }), + }); + expect(calls).toEqual(["ensure:plain", "start:prepared"]); +}); +``` + +- [ ] **Step 2: Run the test and confirm the dispatcher is not exported** + +Run: `bun test tests/macos-lifecycle.test.ts --test-name-pattern "direct native start"` + +Expected: FAIL because `performMacOSLifecycleAction` and dependency injection do not exist. + +- [ ] **Step 3: Split the dispatcher and wire the preparation hook** + +Define a narrow dependency interface with production defaults, then split `ensure` and `start` cases: + +```ts +export interface MacOSLifecycleDeps { + ensureProxyLifecycle?: typeof ensureProxyLifecycle; + prepareMacOSAppStart?: typeof prepareMacOSAppStart; +} + +export async function performMacOSLifecycleAction( + action: MacOSLifecycleAction, + deps: MacOSLifecycleDeps = {}, +): Promise { + const ensure = deps.ensureProxyLifecycle ?? ensureProxyLifecycle; + switch (action) { + case "ensure": + return ensure({ action, honorAutoStart: false, ensureCompanion: false }); + case "start": + return ensure({ + action, + honorAutoStart: false, + ensureCompanion: false, + io: { prepareStart: deps.prepareMacOSAppStart ?? prepareMacOSAppStart }, + }); + // retain each existing fixed action branch unchanged + } +} +``` + +`runMacOSLifecycleHelper` calls this dispatcher after allowlist validation. It still suppresses diagnostics and emits exactly one frame. + +- [ ] **Step 4: Add bounded setup-result encoding coverage** + +```ts +test("Codex first-run setup remains a bounded zero-exit running result", () => { + const result: ProxyLifecycleResult = { + ...success("CodexCommander is running. Open Codex once, then route Codex through the proxy."), + action: "start", + setupRequired: "codex-first-run", + }; + const encoded = encodeMacOSLifecycleResult("start", result); + expect(encoded.exitCode).toBe(0); + expect(Buffer.byteLength(encoded.frame, "utf8")).toBeLessThanOrEqual(MACOS_LIFECYCLE_JSON_MAX_BYTES); + expect(JSON.parse(encoded.frame)).toMatchObject({ + action: "start", + ok: true, + state: "running", + setupRequired: "codex-first-run", + }); +}); +``` + +- [ ] **Step 5: Run bridge tests, concurrency smoke, and typecheck** + +Run: + +```bash +bun test tests/macos-lifecycle.test.ts +bun test tests/proxy-lifecycle-concurrency.test.ts +bun run typecheck +``` + +Expected: PASS. + +- [ ] **Step 6: Commit bridge wiring** + +```bash +git add src/cli/macos-lifecycle.ts tests/macos-lifecycle.test.ts +git commit -m "feat(macos): bootstrap direct app start" +``` + +--- + +### Task 5: Decode setup state and map it into native outcomes + +**Files:** +- Modify: `app/Sources/MenuBarCore/LifecycleHelper.swift:17-108` +- Modify: `app/Sources/MenuBarCore/ActionCoordinator.swift:1-105` +- Test: `app/Sources/MenuBarCoreTests/LifecycleHelperSuite.swift` +- Test: `app/Sources/MenuBarCoreTests/ActionSuite.swift` + +**Interfaces:** +- Consumes: optional JSON string `setupRequired` from Task 3. +- Produces: + +```swift +public enum ProxySetupRequirement: Equatable, Sendable { + case codexFirstRun + case unknown(String) +} + +public enum ProxyControlOutcome: Equatable, Sendable { + case running + case stopped + case setupRequired(ProxySetupRequirement) + case catalogUpdateReady(staleWorkerCount: Int?) + case failed(String) +} +``` + +- [ ] **Step 1: Write failing decoder tests for absent, known, and unknown values** + +In `LifecycleHelperSuite.swift`, decode three bounded JSON frames and assert: + +```swift +let absent = try JSONDecoder().decode( + LifecycleCommandResult.self, + from: Data(#"{"schemaVersion":1,"action":"start","ok":true,"state":"running","changed":false,"pid":42,"port":10100,"message":"running"}"#.utf8) +) +t.isNil(absent.setupRequired) + +let known = try JSONDecoder().decode( + LifecycleCommandResult.self, + from: Data(#"{"schemaVersion":1,"action":"start","ok":true,"state":"running","changed":true,"pid":42,"port":10100,"message":"setup","setupRequired":"codex-first-run"}"#.utf8) +) +t.equal(known.setupRequired, "codex-first-run") + +let unknown = try JSONDecoder().decode( + LifecycleCommandResult.self, + from: Data(#"{"schemaVersion":1,"action":"start","ok":true,"state":"running","changed":false,"pid":42,"port":10100,"message":"setup","setupRequired":"future-setup"}"#.utf8) +) +t.equal(unknown.setupRequired, "future-setup") +``` + +- [ ] **Step 2: Run the Swift core suite and confirm the property is absent** + +Run: `swift run --package-path app MenuBarCoreTests` + +Expected: FAIL because `LifecycleCommandResult.setupRequired` does not exist. + +- [ ] **Step 3: Add tolerant string decoding** + +Add `public let setupRequired: String?`, its initializer argument, coding key, and `decodeIfPresent(String.self, forKey:)`. Do not decode it directly into a raw-value enum; retaining the string is what keeps unknown future values compatible. + +- [ ] **Step 4: Write failing ActionCoordinator mapping tests** + +Extend `ActionSuite.swift` with a runner result containing `setupRequired: "codex-first-run"`, then assert: + +```swift +t.equal( + sync { await coordinator.start() }, + .setupRequired(.codexFirstRun) +) +``` + +Add an unknown value case: + +```swift +t.equal( + sync { await coordinator.start() }, + .setupRequired(.unknown("future-setup")) +) +``` + +- [ ] **Step 5: Implement typed setup mapping before generic success** + +```swift +public enum ProxySetupRequirement: Equatable, Sendable { + case codexFirstRun + case unknown(String) + + init(rawValue: String) { + self = rawValue == "codex-first-run" ? .codexFirstRun : .unknown(rawValue) + } +} +``` + +In `runLifecycle`, after confirming `result.ok && result.state == .running` but before returning `.running`, map any nonempty setup string. Preserve catalog-update priority only when `codexRestartRequired == true`; setup priority otherwise prevents a running prerequisite from becoming a failure. + +- [ ] **Step 6: Update exhaustive policy switches and run core tests** + +Update `StopAndQuitPolicy.shouldTerminate` and existing test fixtures to treat `.setupRequired` as non-stopped. Run: + +```bash +swift run --package-path app MenuBarCoreTests +bun run typecheck +``` + +Expected: PASS. + +- [ ] **Step 7: Commit native bridge support** + +```bash +git add app/Sources/MenuBarCore/LifecycleHelper.swift app/Sources/MenuBarCore/ActionCoordinator.swift app/Sources/MenuBarCoreTests/LifecycleHelperSuite.swift app/Sources/MenuBarCoreTests/ActionSuite.swift +git commit -m "feat(macos): decode first-run setup outcomes" +``` + +--- + +### Task 6: Present Codex first-run guidance without disabling the proxy UI + +**Files:** +- Modify: `app/Sources/MenuBarUI/LifecyclePresentation.swift:57-93` +- Modify: `app/Sources/MenuBarUI/OperationStatusView.swift:35-220` +- Modify: `app/Sources/MenuBarUI/PopoverViewController.swift:290-350` +- Modify: `app/Sources/MenuBarUI/AppDelegate.swift:383-460` +- Test: `app/Sources/MenuBarUITests/main.swift` + +**Interfaces:** +- Consumes: `ProxyControlOutcome.setupRequired` from Task 5. +- Produces: `LifecycleResultMessage.setupRequired(_:)` and `PopoverViewController.showSetupRequired(_:)`. + +- [ ] **Step 1: Write failing presentation-copy tests** + +Add UI test assertions: + +```swift +let firstRun = LifecycleResultMessage.setupRequired(.codexFirstRun) +runner.equal(firstRun.title, "Open Codex to finish setup") +runner.equal( + firstRun.detail, + "CodexCommander is running. Open Codex once, then choose Route Codex Through Proxy." +) + +let future = LifecycleResultMessage.setupRequired(.unknown("future-setup")) +runner.equal(future.title, "CodexCommander setup is required") +runner.equal(future.detail, "The proxy is running. Update CodexCommander for setup instructions.") +``` + +- [ ] **Step 2: Run UI tests and confirm the message factory is missing** + +Run: `swift run --package-path app MenuBarUITests` + +Expected: FAIL because `LifecycleResultMessage.setupRequired` does not exist. + +- [ ] **Step 3: Add warning-tone rendering through the existing status view** + +Add the message factory and controller method: + +```swift +public func showSetupRequired(_ requirement: ProxySetupRequirement) { + let result = LifecycleResultMessage.setupRequired(requirement) + operationStatus.showResult( + title: result.title, + detail: result.detail, + tone: .warning + ) + refreshSize() +} +``` + +This reuses the existing persistent-until-dismissed status surface. Do not add a timer, modal alert, automatic Codex launch, dashboard launch, or background retry. + +- [ ] **Step 4: Handle setup outcomes from both launch Start and manual Start** + +In both AppDelegate switches, add: + +```swift +case .setupRequired(let requirement): + self.clearCatalogUpdate() + self.companionHeartbeat?.reportNow() + self.controller.showSetupRequired(requirement) +``` + +Keep lifecycle controls enabled after the result. The subsequent fresh snapshot enables the existing **Route Codex Through Proxy** button when the proxy is running and Codex is native or unconfirmed. + +- [ ] **Step 5: Add controller behavior and accessibility assertions** + +In the UI harness, call `showSetupRequired(.codexFirstRun)` and verify: + +```swift +runner.equal(controller.operationStatusTitle, "Open Codex to finish setup") +runner.equal( + controller.operationStatusDetail, + "CodexCommander is running. Open Codex once, then choose Route Codex Through Proxy." +) +runner.equal(controller.operationStatusTone, .warning) +runner.expect(controller.routeThroughProxyEnabled, "route retry remains available") +``` + +Store the last rendered `OperationStatusTone` in a package-private property and expose only package-level read-only hooks for the title, detail, tone, and route-button enabled state. Apply a running/native `ProxySnapshot` before asserting route availability. + +- [ ] **Step 6: Run Swift core and UI suites** + +Run: + +```bash +swift run --package-path app MenuBarCoreTests +swift run --package-path app MenuBarUITests +``` + +Expected: PASS. + +- [ ] **Step 7: Commit setup UI** + +```bash +git add app/Sources/MenuBarUI/LifecyclePresentation.swift app/Sources/MenuBarUI/OperationStatusView.swift app/Sources/MenuBarUI/PopoverViewController.swift app/Sources/MenuBarUI/AppDelegate.swift app/Sources/MenuBarUITests/main.swift +git commit -m "feat(macos): show nonfatal Codex setup guidance" +``` + +--- + +### Task 7: Make app-location handling neutral and translocation-safe + +**Files:** +- Modify: `app/Sources/MenuBarCore/LaunchAtLogin.swift:1-330` +- Modify: `app/Sources/MenuBarUI/StartupModeView.swift:1-120` +- Modify: `app/Sources/MenuBarUI/PopoverViewController.swift:1-120` +- Modify: `app/Sources/MenuBarUI/AppDelegate.swift:20-180,383-455` +- Modify: `app/Sources/MenuBarUI/LifecyclePresentation.swift:57-100` +- Test: `app/Sources/MenuBarCoreTests/LaunchAtLoginSuite.swift:200-400` +- Test: `app/Sources/MenuBarUITests/main.swift` + +**Interfaces:** +- Produces: + +```swift +public enum AppBundleLocation: Equatable, Sendable { + case stable + case relocatable + case translocated +} + +public enum LaunchAtLoginRemediation: Equatable, Sendable { + case openSystemSettings + case openApplications +} +``` + +- `LaunchAtLoginPresentation` gains `relocationRequired: Bool` and computed `remediation` while retaining its existing `LaunchAtLoginStatus` raw values for management-heartbeat compatibility. + +- [ ] **Step 1: Replace boolean path tests with a failing three-state matrix** + +Update `LaunchAtLoginSuite.swift`: + +```swift +t.equal( + LaunchAtLoginEligibility.classify( + URL(fileURLWithPath: "/Applications/CodexCommander.app"), + home: home + ), + .stable +) +t.equal( + LaunchAtLoginEligibility.classify( + URL(fileURLWithPath: "/Users/example/Downloads/CodexCommander.app"), + home: home + ), + .relocatable +) +t.equal( + LaunchAtLoginEligibility.classify( + URL(fileURLWithPath: "/private/var/folders/xx/AppTranslocation/CodexCommander.app"), + home: home + ), + .translocated +) +``` + +Keep stable source-build, `/Applications`, `~/Applications`, wrong bundle name, and wrong source-path cases in the matrix. + +- [ ] **Step 2: Run core tests and confirm the classifier is missing** + +Run: `swift run --package-path app MenuBarCoreTests` + +Expected: FAIL because `classify` and `AppBundleLocation` do not exist. + +- [ ] **Step 3: Implement classification without changing heartbeat status values** + +Implement `classify` before the compatibility wrapper: + +```swift +public static func classify( + _ bundleURL: URL, + home: URL = FileManager.default.homeDirectoryForCurrentUser +) -> AppBundleLocation { + let bundle = bundleURL.resolvingSymlinksInPath() + let path = bundle.path + if path.contains("/AppTranslocation/") { return .translocated } + guard bundle.pathExtension == "app", + bundle.lastPathComponent == "CodexCommander.app" + else { return .relocatable } + if path.hasPrefix("/Applications/") { return .stable } + let userApplications = home.appendingPathComponent("Applications", isDirectory: true).path + if path.hasPrefix("\(userApplications)/") { return .stable } + let sourceBuild = bundle.deletingLastPathComponent().lastPathComponent == "macos" + && bundle.deletingLastPathComponent().deletingLastPathComponent().lastPathComponent == "dist" + return sourceBuild ? .stable : .relocatable +} +``` + +Retain `isStableBundle` as `classify(...) == .stable` for existing callers/tests until AppDelegate migrates. + +- [ ] **Step 4: Write failing neutral-remediation tests** + +Assert that `registrationAllowed: false` produces: + +```swift +t.equal(presentation.status, .unavailable) +t.equal(presentation.relocationRequired, true) +t.equal(presentation.remediation, .openApplications) +t.isNil(presentation.errorMessage) +t.equal(presentation.isToggleEnabled, false) +``` + +Assert that `.requiresApproval` produces `.openSystemSettings` and no relocation flag. + +- [ ] **Step 5: Implement remediation rendering and callbacks** + +Replace `StartupModeView`'s settings-only button plumbing with one remediation button. Render: + +- `.openSystemSettings` as **Open Settings**, gear icon, existing accessibility label. +- `.openApplications` as **Open Applications**, folder icon, accessibility label “Open Applications folder”. + +For relocation, set detail to `Move CodexCommander to Applications to launch at login.` using `Theme.faint`, not `Theme.red`. Route the typed remediation through `PopoverViewController` to AppDelegate. AppDelegate opens `URL(fileURLWithPath: "/Applications", isDirectory: true)` with `NSWorkspace.shared.open` only after the user presses the button. + +- [ ] **Step 6: Block automatic and manual Start from App Translocation** + +Cache `AppBundleLocation` in AppDelegate. At the start of `startProxyOnLaunch()` and `startProxy()`, refuse only `.translocated` and show: + +```swift +package static let appTranslocated = ( + title: "Move CodexCommander to Applications", + detail: "This temporary macOS launch location cannot safely run the background proxy. Move the app, then reopen it." +) +``` + +Do not call `ActionCoordinator.start()` in this branch. `.relocatable` continues through Start for the current session. Documentation and UI copy must tell users to stop CodexCommander before moving a running app so the embedded runtime path is not changed underneath the proxy. + +- [ ] **Step 7: Add UI tests for neutral color, action, and blocked Start** + +Verify: + +- relocation detail is not red; +- the button title and accessibility label are correct; +- activating it invokes the Applications remediation callback; +- approval-required still invokes the System Settings callback; +- a translocated launch shows move-and-reopen guidance and records zero lifecycle Start calls; +- a relocatable launch still records one Start call. + +- [ ] **Step 8: Run all macOS tests** + +Run: + +```bash +swift run --package-path app MenuBarCoreTests +swift run --package-path app MenuBarUITests +bun run test:macos +``` + +Expected: PASS. + +- [ ] **Step 9: Commit location behavior** + +```bash +git add app/Sources/MenuBarCore/LaunchAtLogin.swift app/Sources/MenuBarCoreTests/LaunchAtLoginSuite.swift app/Sources/MenuBarUI/StartupModeView.swift app/Sources/MenuBarUI/PopoverViewController.swift app/Sources/MenuBarUI/AppDelegate.swift app/Sources/MenuBarUI/LifecyclePresentation.swift app/Sources/MenuBarUITests/main.swift +git commit -m "feat(macos): make first-run location guidance actionable" +``` + +--- + +### Task 8: Document the zero-click flow and maintainer invariants + +**Files:** +- Modify: `README.md:58-140` +- Modify: `docs-site/src/content/docs/getting-started/installation.md:1-75` +- Modify: `docs-site/src/content/docs/getting-started/quickstart.md:1-55` +- Modify: `docs-site/src/content/docs/guides/macos-menu-bar.md:1-75,218-275` +- Modify: `structure/01_runtime.md:45-115` +- Modify: `structure/02_config-and-codex-home.md:1-45,160-215` + +**Interfaces:** +- Consumes: final user-visible behavior and exact UI copy from Tasks 1-7. +- Produces: one consistent installation path for app users and unchanged CLI instructions for source/headless users. + +- [ ] **Step 1: Update the README macOS install flow** + +State explicitly: + +```md +On a fresh Mac, a direct app launch creates CodexCommander's secret-free ChatGPT passthrough default automatically. If Codex has not created `~/.codex/config.toml` yet, the proxy and dashboard still start while Codex remains native; open Codex once, then choose **Route Codex Through Proxy** from the menu. +``` + +Also state that providers, API keys, and OAuth accounts are not copied from another Mac and that public distribution uses the universal release archive rather than a thin development `.app`. + +- [ ] **Step 2: Split app and CLI quickstarts clearly** + +In installation and quickstart docs: + +- keep `ccx init` as the required source/headless CLI setup; +- document that direct packaged macOS app Start alone owns automatic default creation; +- explain missing Codex setup without suggesting manual JSON creation; +- explain that existing invalid config is preserved and must be repaired; +- state that external Codex providers remain untouched. + +- [ ] **Step 3: Update app-location and uninstall guidance** + +Document: + +- Applications and `~/Applications` support Launch at Login; +- Desktop/Downloads works only for the current session and shows neutral relocation guidance; +- stop CodexCommander before moving a running app; +- App Translocation requires move-and-reopen before Start; +- the app never moves itself; +- ad-hoc Gatekeeper instructions remain unchanged. + +- [ ] **Step 4: Record the new SOT invariants** + +Update `structure/01_runtime.md` and `structure/02_config-and-codex-home.md` with: + +- direct native Start's app-only bootstrap hook; +- E-lock then config-mutation-lock ordering; +- no-clobber initialization and race-winner preservation; +- ordinary CLI missing-config refusal; +- missing Codex setup result and proxy-running semantics; +- translocation prohibition and neutral relocation presentation. + +- [ ] **Step 5: Validate documentation links and privacy language** + +Run: + +```bash +cd docs-site +bun install --frozen-lockfile +bun run build +cd .. +bun run privacy:scan +``` + +Expected: PASS with no contradictory CLI/app setup instructions and no private path introduced. + +- [ ] **Step 6: Commit documentation** + +```bash +git add README.md docs-site/src/content/docs/getting-started/installation.md docs-site/src/content/docs/getting-started/quickstart.md docs-site/src/content/docs/guides/macos-menu-bar.md structure/01_runtime.md structure/02_config-and-codex-home.md +git commit -m "docs: explain zero-click macOS first run" +``` + +--- + +### Task 9: Run full verification and review the branch + +**Files:** +- Verify all files changed in Tasks 1-8. +- Do not edit generated `dist/`, `gui/dist`, or Swift `.build/` output. + +**Interfaces:** +- Consumes: the complete feature branch. +- Produces: evidence that the app bootstrap, CLI regressions, privacy boundary, and packaged runtime all pass. + +- [ ] **Step 1: Inspect the branch for unintended changes** + +Run: + +```bash +git status --short +git diff --check main...HEAD +git diff --stat main...HEAD +``` + +Expected: only the approved implementation, tests, structure notes, and user documentation are present; no build output or credential-bearing fixture appears. + +- [ ] **Step 2: Run focused TypeScript tests together** + +Run: + +```bash +bun test tests/config.test.ts tests/macos-first-run.test.ts tests/proxy-lifecycle.test.ts tests/macos-lifecycle.test.ts tests/codex-desired-state.test.ts tests/proxy-lifecycle-concurrency.test.ts +``` + +Expected: PASS. + +- [ ] **Step 3: Run repository TypeScript verification** + +Run: + +```bash +bun run typecheck +bun run test:parallel +``` + +Expected: PASS. If the parallel runner itself misbehaves, run `bun run test` and record why the fallback was used. + +- [ ] **Step 4: Run privacy and native app verification** + +Run: + +```bash +bun run privacy:scan +bun run test:macos +bun run build:macos +``` + +Expected: PASS. Confirm the built app still reports its source revision and `lipo -archs` reports the host build architecture. + +- [ ] **Step 5: Perform two temporary-home packaged smoke tests** + +Use `mktemp -d` roots and the embedded Bun runtime. Do not point either selector at the real home. Run this from the repository root: + +```bash +set -euo pipefail +ccx_smoke_bundle="$PWD/dist/macos/CodexCommander.app" +ccx_smoke_runtime="$ccx_smoke_bundle/Contents/Resources/runtime" +ccx_smoke_bun="$ccx_smoke_runtime/node_modules/bun/bin/bun.exe" +ccx_smoke_entry="$ccx_smoke_runtime/src/cli/index.ts" +ccx_smoke_dead_proxy="http://127.0.0.1:9" +ccx_smoke_tmp="${TMPDIR:-/tmp}" +ccx_smoke_tmp="${ccx_smoke_tmp%/}" +ccx_smoke_root="$(mktemp -d "$ccx_smoke_tmp/ccx-first-run.XXXXXX")" +ccx_smoke_state_a="$ccx_smoke_root/state-a" +ccx_smoke_codex_a="$ccx_smoke_root/codex-a" +ccx_smoke_state_b="$ccx_smoke_root/state-b" +ccx_smoke_codex_b="$ccx_smoke_root/codex-b" +mkdir -p "$ccx_smoke_state_a" "$ccx_smoke_codex_a" "$ccx_smoke_state_b" "$ccx_smoke_codex_b" +install -m 600 /dev/null "$ccx_smoke_codex_a/config.toml" + +ccx_smoke_json_a="$( + CODEXCOMMANDER_HOME="$ccx_smoke_state_a" CODEX_HOME="$ccx_smoke_codex_a" \ + HTTP_PROXY="$ccx_smoke_dead_proxy" HTTPS_PROXY="$ccx_smoke_dead_proxy" \ + "$ccx_smoke_bun" --no-install --no-env-file --config=/dev/null \ + "$ccx_smoke_entry" __macos-lifecycle start +)" +CCX_SMOKE_JSON="$ccx_smoke_json_a" "$ccx_smoke_bun" -e ' + const value = JSON.parse(process.env.CCX_SMOKE_JSON ?? "null"); + if (value?.ok !== true || value?.state !== "running" || value?.setupRequired !== undefined) process.exit(1); +' +CODEXCOMMANDER_HOME="$ccx_smoke_state_a" CODEX_HOME="$ccx_smoke_codex_a" \ + "$ccx_smoke_bun" --no-install --no-env-file --config=/dev/null \ + "$ccx_smoke_entry" __macos-lifecycle stop >/dev/null + +ccx_smoke_json_b="$( + CODEXCOMMANDER_HOME="$ccx_smoke_state_b" CODEX_HOME="$ccx_smoke_codex_b" \ + HTTP_PROXY="$ccx_smoke_dead_proxy" HTTPS_PROXY="$ccx_smoke_dead_proxy" \ + "$ccx_smoke_bun" --no-install --no-env-file --config=/dev/null \ + "$ccx_smoke_entry" __macos-lifecycle start +)" +CCX_SMOKE_JSON="$ccx_smoke_json_b" "$ccx_smoke_bun" -e ' + const value = JSON.parse(process.env.CCX_SMOKE_JSON ?? "null"); + if (value?.ok !== true || value?.state !== "running" || value?.setupRequired !== "codex-first-run") process.exit(1); +' +CCX_SMOKE_CONFIG_MODULE="$ccx_smoke_runtime/src/config.ts" \ +CODEXCOMMANDER_HOME="$ccx_smoke_state_b" "$ccx_smoke_bun" -e ' + const configModule = await import(process.env.CCX_SMOKE_CONFIG_MODULE ?? ""); + const expected = structuredClone(configModule.getDefaultConfig()); + expected.clientIntegrations = { ...(expected.clientIntegrations ?? {}), codex: false }; + const actual = JSON.parse(await Bun.file(configModule.getConfigPath()).text()); + if (JSON.stringify(actual) !== JSON.stringify(expected)) process.exit(1); +' +test ! -e "$ccx_smoke_codex_b/config.toml" +CODEXCOMMANDER_HOME="$ccx_smoke_state_b" CODEX_HOME="$ccx_smoke_codex_b" \ + "$ccx_smoke_bun" --no-install --no-env-file --config=/dev/null \ + "$ccx_smoke_entry" __macos-lifecycle stop >/dev/null + +case "$ccx_smoke_root" in + "$ccx_smoke_tmp"/ccx-first-run.*) rm -rf -- "$ccx_smoke_root" ;; + *) echo "Refusing unexpected smoke root: $ccx_smoke_root" >&2; exit 1 ;; +esac +``` + +Expected: both smokes pass without provider network calls and leave only files inside their temporary roots. + +- [ ] **Step 6: Review security-sensitive boundaries** + +Confirm from the diff and tests: + +- the initializer publishes with no-replace semantics; +- linked/non-regular/invalid config is refused; +- lifecycle preparation runs while E is held; +- the bridge contains no path, credentials, or raw config; +- App Translocation cannot invoke Start; +- ordinary CLI and passive actions do not install the bootstrap hook. + +- [ ] **Step 7: Confirm the verified branch is clean** + +Run: `git status --short` + +Expected: no output. If a verification failure requires a tracked correction, return to the task that owns that file, repeat its failing-test/implementation/pass cycle, use that task's explicit `git add` list and commit message, then rerun Task 9 from Step 1. From 2144e7b7d3582dae09b6e70472d01a6b590e41a8 Mon Sep 17 00:00:00 2001 From: pavelhov Date: Thu, 20 Aug 2026 17:37:00 -0400 Subject: [PATCH 03/28] feat(config): add create-only first-run initialization --- src/config.ts | 134 ++++++++++++++++++++++++++++++++++++++++++- tests/config.test.ts | 104 +++++++++++++++++++++++++++++++++ 2 files changed, 237 insertions(+), 1 deletion(-) diff --git a/src/config.ts b/src/config.ts index 48bda8fad7..e2b56158b1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,6 @@ import { execFileSync } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; -import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, copyFileSync, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { Database } from "bun:sqlite"; @@ -1637,6 +1637,138 @@ export function readConfigDiagnostics(): ConfigDiagnostics { return readConfigFileSnapshot().diagnostics; } +export type ConfigInitializationRefusal = + | "candidate-invalid" + | "existing-invalid" + | "existing-inaccessible" + | "existing-unsafe" + | "coordination-unavailable"; + +export type ConfigInitializationResult = + | { status: "created" } + | { status: "existing" } + | { status: "refused"; reason: ConfigInitializationRefusal }; + +type ConfigEntryProbe = + | { kind: "missing" } + | { kind: "valid" } + | { + kind: "refused"; + reason: Exclude< + ConfigInitializationRefusal, + "candidate-invalid" | "coordination-unavailable" + >; + }; + +function probeConfigEntry(): ConfigEntryProbe { + let entry; + try { + entry = lstatSync(getConfigPath()); + } catch (error) { + return isMissingPathError(error) + ? { kind: "missing" } + : { kind: "refused", reason: "existing-inaccessible" }; + } + if (!entry.isFile() || entry.isSymbolicLink() || entry.nlink !== 1) { + return { kind: "refused", reason: "existing-unsafe" }; + } + try { + return configDiagnosticsFromRaw(readFileSync(getConfigPath(), "utf8")).source === "file" + ? { kind: "valid" } + : { kind: "refused", reason: "existing-invalid" }; + } catch { + return { kind: "refused", reason: "existing-inaccessible" }; + } +} + +let configInitializationBeforePublishForTests: (() => void) | null = null; + +/** Test-only one-shot seam: inject a competing writer immediately before no-clobber publication. */ +export function setConfigInitializationBeforePublishForTests(hook: (() => void) | null): void { + configInitializationBeforePublishForTests = hook; +} + +function publishConfigNoReplace(path: string, bytes: string): boolean { + recordOwnedConfigPath(resolveConfigDir(), path); + const target = resolveWriteTarget(path); + assertResolvedTargetAllowed(path, target); + const temp = `${target}.ccx.${process.pid}.${++_atomicSeq}.create.tmp`; + let published = false; + try { + writeFileSync(temp, bytes, { encoding: "utf8", mode: 0o600, flag: "wx" }); + try { chmodSync(temp, 0o600); } catch { /* filesystem may ignore chmod */ } + if (process.platform === "win32") { + hardenSecretPath(temp, { required: true, timeoutMemoKey: path }); + } + const hook = configInitializationBeforePublishForTests; + configInitializationBeforePublishForTests = null; + hook?.(); + try { + linkSync(temp, target); + published = true; + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") return false; + throw error; + } + } finally { + try { + unlinkSync(temp); + forgetEphemeralSecretPath(temp); + } catch (error) { + if (!isMissingPathError(error)) { + // After link succeeds, temp and destination are the same inode. Never + // truncate the temp in that state because it would erase config.json too. + if (!published) { + try { truncateSync(temp, 0); } catch { /* residual error below is authoritative */ } + } + throw new AtomicWriteSecretResidualError(temp, { cause: error }); + } + } + } +} + +export function initializeConfigIfMissing( + candidate: CodexCommanderConfig, +): ConfigInitializationResult { + const validated = validateConfigCandidate(candidate); + if (!validated.ok) return { status: "refused", reason: "candidate-invalid" }; + const observed = probeConfigEntry(); + if (observed.kind === "valid") return { status: "existing" }; + if (observed.kind === "refused") return { status: "refused", reason: observed.reason }; + try { + if (!recordOwnedConfigPath(getConfigDir(), getConfigPath())) { + return { status: "refused", reason: "existing-unsafe" }; + } + } catch { + return { status: "refused", reason: "existing-inaccessible" }; + } + + try { + return withConfigMutationLockSync(() => { + const current = probeConfigEntry(); + if (current.kind === "valid") return { status: "existing" } as const; + if (current.kind === "refused") { + return { status: "refused", reason: current.reason } as const; + } + const bytes = `${JSON.stringify(validated.config, null, 2)}\n`; + const published = publishConfigNoReplace(getConfigPath(), bytes); + if (!published) { + const winner = probeConfigEntry(); + if (winner.kind === "valid") return { status: "existing" } as const; + return { + status: "refused", + reason: winner.kind === "refused" ? winner.reason : "coordination-unavailable", + } as const; + } + bumpGenerationForCooperatingConfigWrite(); + return { status: "created" } as const; + }); + } catch { + return { status: "refused", reason: "coordination-unavailable" }; + } +} + /** * The persisted config, plus a digest of the EXACT bytes it was parsed from. * diff --git a/tests/config.test.ts b/tests/config.test.ts index 9ea8340890..c9198edddb 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -12,6 +12,7 @@ import { getRuntimePortPath, isValidProviderName, isCodexCommanderStartCommandLine, + initializeConfigIfMissing, loadConfig, multiAgentGuidanceEnabled, parsePidFile, @@ -21,6 +22,7 @@ import { readRuntimePort, removePid, removeRuntimePort, + setConfigInitializationBeforePublishForTests, validateConfigCandidate, writeRuntimePort, writePid, @@ -38,6 +40,7 @@ beforeEach(() => { }); afterEach(() => { + setConfigInitializationBeforePublishForTests(null); if (previousCodexCommanderHome === undefined) delete process.env.CODEXCOMMANDER_HOME; else process.env.CODEXCOMMANDER_HOME = previousCodexCommanderHome; previousCodexCommanderHome = undefined; @@ -102,6 +105,107 @@ function writeAccountNamespaceConfig( }); } +describe("create-only config initialization", () => { + test("creates the canonical candidate only when config.json is absent", () => { + const candidate = getDefaultConfig(); + expect(initializeConfigIfMissing(candidate)).toEqual({ status: "created" }); + expect(JSON.parse(readFileSync(getConfigPath(), "utf8"))).toEqual(candidate); + expect(lstatSync(getConfigPath()).isFile()).toBe(true); + if (process.platform !== "win32") { + expect(lstatSync(getConfigPath()).mode & 0o077).toBe(0); + } + }); + + test("keeps an existing valid config byte-for-byte", () => { + const bytes = `${JSON.stringify({ ...getDefaultConfig(), port: 12001 })}\n`; + writeFileSync(getConfigPath(), bytes, { mode: 0o600 }); + expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ status: "existing" }); + expect(readFileSync(getConfigPath(), "utf8")).toBe(bytes); + }); + + test("refuses malformed and schema-invalid config without rewriting", () => { + for (const bytes of ["{", '{"port":10100,"providers":{},"defaultProvider":"missing"}']) { + writeFileSync(getConfigPath(), bytes, "utf8"); + expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ + status: "refused", + reason: "existing-invalid", + }); + expect(readFileSync(getConfigPath(), "utf8")).toBe(bytes); + unlinkSync(getConfigPath()); + } + }); + + test("rejects an invalid candidate without creating config state", () => { + const invalid = { ...getDefaultConfig(), defaultProvider: "missing" }; + expect(initializeConfigIfMissing(invalid)).toEqual({ + status: "refused", + reason: "candidate-invalid", + }); + expect(existsSync(getConfigPath())).toBe(false); + }); + + test("refuses linked and non-regular destinations", () => { + const real = join(testDir, "real-config.json"); + writeFileSync(real, `${JSON.stringify(getDefaultConfig())}\n`, "utf8"); + symlinkSync(real, getConfigPath()); + expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ + status: "refused", + reason: "existing-unsafe", + }); + unlinkSync(getConfigPath()); + mkdirSync(getConfigPath()); + expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ + status: "refused", + reason: "existing-unsafe", + }); + }); + + test("refuses an inaccessible existing file without replacing it", () => { + if (process.platform === "win32") return; + writeFileSync(getConfigPath(), `${JSON.stringify(getDefaultConfig())}\n`, { mode: 0o600 }); + chmodSync(getConfigPath(), 0o000); + try { + expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ + status: "refused", + reason: "existing-inaccessible", + }); + } finally { + chmodSync(getConfigPath(), 0o600); + } + }); + + test("refuses to claim a nonempty unowned configuration root", () => { + const foreign = join(testDir, "foreign.txt"); + writeFileSync(foreign, "keep", "utf8"); + expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ + status: "refused", + reason: "existing-unsafe", + }); + expect(readFileSync(foreign, "utf8")).toBe("keep"); + expect(existsSync(getConfigPath())).toBe(false); + }); + + test("adopts a valid file that wins immediately before no-clobber publish", () => { + const winner = { ...getDefaultConfig(), port: 12002 }; + setConfigInitializationBeforePublishForTests(() => { + writeFileSync(getConfigPath(), `${JSON.stringify(winner)}\n`, { mode: 0o600 }); + }); + expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ status: "existing" }); + expect(JSON.parse(readFileSync(getConfigPath(), "utf8"))).toEqual(winner); + }); + + test("refuses an invalid file that wins immediately before publish", () => { + setConfigInitializationBeforePublishForTests(() => { + writeFileSync(getConfigPath(), "{", { mode: 0o600 }); + }); + expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ + status: "refused", + reason: "existing-invalid", + }); + expect(readFileSync(getConfigPath(), "utf8")).toBe("{"); + }); +}); + describe("CodexCommander config defaults", () => { test("usage and MCP config overrides change the effective bound while defaults remain compatible", () => { const defaults = getDefaultConfig(); From d1bf52565c4cbc94bdf601fb028d1e0b56334269 Mon Sep 17 00:00:00 2001 From: pavelhov Date: Thu, 20 Aug 2026 18:00:53 -0400 Subject: [PATCH 04/28] fix(config): harden first-run initialization races --- src/config.ts | 211 ++++++++++++++++++++++++++++++++---- src/lib/config-ownership.ts | 54 ++++++++- tests/config.test.ts | 121 +++++++++++++++++++++ 3 files changed, 358 insertions(+), 28 deletions(-) diff --git a/src/config.ts b/src/config.ts index e2b56158b1..9f4d2b86c3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1660,6 +1660,64 @@ type ConfigEntryProbe = >; }; +type ConfigRootIdentity = { + path: string; + canonicalPath: string; + dev: number; + ino: number; +}; + +type ConfigRootProbe = + | { kind: "missing" } + | { kind: "valid"; identity: ConfigRootIdentity } + | { kind: "refused"; reason: "existing-inaccessible" | "existing-unsafe" }; + +const CONFIG_INITIALIZATION_WAIT_MS = 2_000; +const CONFIG_INITIALIZATION_POLL_MS = 10; + +function samePhysicalConfigRoot(left: ConfigRootIdentity, right: ConfigRootIdentity): boolean { + const sameCanonicalPath = process.platform === "win32" + ? left.canonicalPath.toLowerCase() === right.canonicalPath.toLowerCase() + : left.canonicalPath === right.canonicalPath; + return left.path === right.path + && sameCanonicalPath + && left.dev === right.dev + && left.ino === right.ino; +} + +function probeConfigRoot(): ConfigRootProbe { + const path = getConfigDir(); + let entry; + try { + entry = lstatSync(path); + } catch (error) { + return isMissingPathError(error) + ? { kind: "missing" } + : { kind: "refused", reason: "existing-inaccessible" }; + } + if (!entry.isDirectory() || entry.isSymbolicLink()) { + return { kind: "refused", reason: "existing-unsafe" }; + } + try { + return { + kind: "valid", + identity: { + path, + canonicalPath: realpathSync.native(path), + dev: entry.dev, + ino: entry.ino, + }, + }; + } catch { + return { kind: "refused", reason: "existing-inaccessible" }; + } +} + +function configRootStillMatches(expected: ConfigRootIdentity): boolean { + const current = probeConfigRoot(); + return current.kind === "valid" && samePhysicalConfigRoot(expected, current.identity); +} + function probeConfigEntry(): ConfigEntryProbe { let entry; try { @@ -1689,7 +1747,6 @@ export function setConfigInitializationBeforePublishForTests(hook: (() => void) } function publishConfigNoReplace(path: string, bytes: string): boolean { - recordOwnedConfigPath(resolveConfigDir(), path); const target = resolveWriteTarget(path); assertResolvedTargetAllowed(path, target); const temp = `${target}.ccx.${process.pid}.${++_atomicSeq}.create.tmp`; @@ -1700,9 +1757,6 @@ function publishConfigNoReplace(path: string, bytes: string): boolean { if (process.platform === "win32") { hardenSecretPath(temp, { required: true, timeoutMemoKey: path }); } - const hook = configInitializationBeforePublishForTests; - configInitializationBeforePublishForTests = null; - hook?.(); try { linkSync(temp, target); published = true; @@ -1728,44 +1782,153 @@ function publishConfigNoReplace(path: string, bytes: string): boolean { } } +function configInitializationContenderObservation( + expectedRoot: ConfigRootIdentity, +): ConfigInitializationResult | ConfigEntryProbe { + if (!configRootStillMatches(expectedRoot)) { + return { status: "refused", reason: "existing-unsafe" }; + } + const current = probeConfigEntry(); + if (!configRootStillMatches(expectedRoot)) { + return { status: "refused", reason: "existing-unsafe" }; + } + if (current.kind === "valid") return { status: "existing" }; + if (current.kind === "refused" && current.reason === "existing-invalid") { + return { status: "refused", reason: current.reason }; + } + return current; +} + +function waitForConfigInitializationWinner( + expectedRoot: ConfigRootIdentity, + deadline: number, + fallback: ConfigInitializationRefusal, +): ConfigInitializationResult { + let lastRefusal: ConfigInitializationRefusal | null = null; + for (;;) { + const observed = configInitializationContenderObservation(expectedRoot); + if ("status" in observed) return observed; + if (observed.kind === "refused") lastRefusal = observed.reason; + if (performance.now() >= deadline) { + return { status: "refused", reason: lastRefusal ?? fallback }; + } + Bun.sleepSync(CONFIG_INITIALIZATION_POLL_MS); + } +} + export function initializeConfigIfMissing( candidate: CodexCommanderConfig, ): ConfigInitializationResult { const validated = validateConfigCandidate(candidate); if (!validated.ok) return { status: "refused", reason: "candidate-invalid" }; + const deadline = performance.now() + CONFIG_INITIALIZATION_WAIT_MS; + const initialRoot = probeConfigRoot(); + if (initialRoot.kind === "refused") { + return { status: "refused", reason: initialRoot.reason }; + } const observed = probeConfigEntry(); + if (initialRoot.kind === "valid" && !configRootStillMatches(initialRoot.identity)) { + return { status: "refused", reason: "existing-unsafe" }; + } if (observed.kind === "valid") return { status: "existing" }; if (observed.kind === "refused") return { status: "refused", reason: observed.reason }; + let ownershipFailure: ConfigInitializationRefusal | null = null; try { if (!recordOwnedConfigPath(getConfigDir(), getConfigPath())) { - return { status: "refused", reason: "existing-unsafe" }; + ownershipFailure = "existing-unsafe"; } } catch { - return { status: "refused", reason: "existing-inaccessible" }; + ownershipFailure = "existing-inaccessible"; } - try { - return withConfigMutationLockSync(() => { - const current = probeConfigEntry(); - if (current.kind === "valid") return { status: "existing" } as const; - if (current.kind === "refused") { - return { status: "refused", reason: current.reason } as const; + const ownedRoot = probeConfigRoot(); + if (ownedRoot.kind !== "valid") { + return { + status: "refused", + reason: ownedRoot.kind === "refused" ? ownedRoot.reason : "existing-unsafe", + }; + } + if ( + initialRoot.kind === "valid" + && !samePhysicalConfigRoot(initialRoot.identity, ownedRoot.identity) + ) { + return { status: "refused", reason: "existing-unsafe" }; + } + if (!ownershipFailure) { + try { + if (!recordOwnedConfigPath(getConfigDir(), getConfigPath())) { + ownershipFailure = "existing-unsafe"; + } + } catch { + ownershipFailure = "existing-inaccessible"; + } + if (!configRootStillMatches(ownedRoot.identity)) { + return { status: "refused", reason: "existing-unsafe" }; + } + } + if (ownershipFailure) { + return waitForConfigInitializationWinner(ownedRoot.identity, deadline, ownershipFailure); + } + + let lastContentionRefusal: ConfigInitializationRefusal | null = null; + for (;;) { + if (!configRootStillMatches(ownedRoot.identity)) { + return { status: "refused", reason: "existing-unsafe" }; + } + try { + return withConfigMutationLockSync(() => { + if (!configRootStillMatches(ownedRoot.identity)) { + return { status: "refused", reason: "existing-unsafe" } as const; + } + const current = probeConfigEntry(); + if (current.kind === "valid") return { status: "existing" } as const; + if (current.kind === "refused") { + return { status: "refused", reason: current.reason } as const; + } + + const hook = configInitializationBeforePublishForTests; + configInitializationBeforePublishForTests = null; + hook?.(); + if (!configRootStillMatches(ownedRoot.identity)) { + return { status: "refused", reason: "existing-unsafe" } as const; + } + const afterHook = probeConfigEntry(); + if (afterHook.kind === "valid") return { status: "existing" } as const; + if (afterHook.kind === "refused") { + return { status: "refused", reason: afterHook.reason } as const; + } + + const bytes = `${JSON.stringify(validated.config, null, 2)}\n`; + if (!configRootStillMatches(ownedRoot.identity)) { + return { status: "refused", reason: "existing-unsafe" } as const; + } + const published = publishConfigNoReplace(getConfigPath(), bytes); + if (!published) { + const winner = probeConfigEntry(); + if (winner.kind === "valid") return { status: "existing" } as const; + return { + status: "refused", + reason: winner.kind === "refused" ? winner.reason : "coordination-unavailable", + } as const; + } + bumpGenerationForCooperatingConfigWrite(); + return { status: "created" } as const; + }); + } catch (error) { + if (!(error instanceof ConfigMutationLockError)) { + return { status: "refused", reason: "coordination-unavailable" }; } - const bytes = `${JSON.stringify(validated.config, null, 2)}\n`; - const published = publishConfigNoReplace(getConfigPath(), bytes); - if (!published) { - const winner = probeConfigEntry(); - if (winner.kind === "valid") return { status: "existing" } as const; + const contender = configInitializationContenderObservation(ownedRoot.identity); + if ("status" in contender) return contender; + if (contender.kind === "refused") lastContentionRefusal = contender.reason; + if (performance.now() >= deadline) { return { status: "refused", - reason: winner.kind === "refused" ? winner.reason : "coordination-unavailable", - } as const; + reason: lastContentionRefusal ?? "coordination-unavailable", + }; } - bumpGenerationForCooperatingConfigWrite(); - return { status: "created" } as const; - }); - } catch { - return { status: "refused", reason: "coordination-unavailable" }; + Bun.sleepSync(CONFIG_INITIALIZATION_POLL_MS); + } } } diff --git a/src/lib/config-ownership.ts b/src/lib/config-ownership.ts index a3d73b661c..32181891de 100644 --- a/src/lib/config-ownership.ts +++ b/src/lib/config-ownership.ts @@ -46,6 +46,13 @@ type ConfigOwnership = { manifest: ConfigUninstallManifest; ownerFile: string; manifestFile: string; + rootIdentity: ConfigOwnershipRootIdentity; +}; + +type ConfigOwnershipRootIdentity = { + canonicalPath: string; + dev: number; + ino: number; }; const METADATA_MAX_BYTES = 64 * 1024; @@ -163,6 +170,27 @@ function canonicalRoot(configDir: string): string { return realpathSync.native(resolve(configDir)); } +function configOwnershipRootIdentity(configDir: string): ConfigOwnershipRootIdentity { + const root = lstatSync(configDir); + if (!root.isDirectory() || root.isSymbolicLink()) { + throw new Error("config ownership root is not a physical directory"); + } + return { + canonicalPath: canonicalRoot(configDir), + dev: root.dev, + ino: root.ino, + }; +} + +function sameConfigOwnershipRoot( + left: ConfigOwnershipRootIdentity, + right: ConfigOwnershipRootIdentity, +): boolean { + return samePath(left.canonicalPath, right.canonicalPath) + && left.dev === right.dev + && left.ino === right.ino; +} + function isWithinRoot(root: string, candidate: string): boolean { const rel = relative(root, candidate); return rel === "" || ( @@ -199,7 +227,8 @@ function loadOwnership(configDir: string): ConfigOwnership | null { try { const owner = readBoundedJson(ownerPath); const manifest = readBoundedJson(manifestPath); - const root = canonicalRoot(configDir); + const rootIdentity = configOwnershipRootIdentity(configDir); + const root = rootIdentity.canonicalPath; if ( !isOwner(owner) || !isManifest(manifest) @@ -212,6 +241,7 @@ function loadOwnership(configDir: string): ConfigOwnership | null { manifest, ownerFile: ownerName, manifestFile: manifestName, + rootIdentity, }; } catch { return null; @@ -219,12 +249,17 @@ function loadOwnership(configDir: string): ConfigOwnership | null { } function createOwnership(configDir: string): ConfigOwnership | null { - const rootStat = lstatSync(configDir); - if (!rootStat.isDirectory() || rootStat.isSymbolicLink() || readdirSync(configDir).length !== 0) return null; + let rootIdentity: ConfigOwnershipRootIdentity; + try { + rootIdentity = configOwnershipRootIdentity(configDir); + } catch { + return null; + } + if (readdirSync(configDir).length !== 0) return null; const owner: ConfigOwner = { version: 1, ownerId: randomUUID(), - root: canonicalRoot(configDir), + root: rootIdentity.canonicalPath, }; const manifest: ConfigUninstallManifest = { ...owner, paths: [...INITIAL_OWNED_PATHS] }; writeFileSync(join(configDir, CONFIG_OWNER_FILE), `${JSON.stringify(owner, null, 2)}\n`, { @@ -247,6 +282,7 @@ function createOwnership(configDir: string): ConfigOwnership | null { manifest, ownerFile: CONFIG_OWNER_FILE, manifestFile: CONFIG_UNINSTALL_MANIFEST, + rootIdentity, }; } @@ -296,6 +332,16 @@ export function recordOwnedConfigPath(configDir: string, candidatePath: string): mkdirSync(configDir, { recursive: true, mode: 0o700 }); } let ownership = ownershipCache.get(cacheKey); + if (ownership) { + try { + if (!sameConfigOwnershipRoot( + ownership.rootIdentity, + configOwnershipRootIdentity(configDir), + )) return false; + } catch { + return false; + } + } if (ownership === undefined) { ownership = loadOwnership(configDir) ?? createOwnership(configDir); ownershipCache.set(cacheKey, ownership); diff --git a/tests/config.test.ts b/tests/config.test.ts index c9198edddb..1f2e6dd903 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, renameSync, rmSync, symlinkSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; import { CODEX_SHIM_AUTO_RESTORE_ENV, codexAutoStartEnabled, @@ -204,6 +205,126 @@ describe("create-only config initialization", () => { }); expect(readFileSync(getConfigPath(), "utf8")).toBe("{"); }); + + test("two processes racing initialization publish once and adopt the winner", async () => { + const raceRoot = join(testDir, "raced-home"); + const releasePath = join(testDir, "race-release"); + const configModuleUrl = pathToFileURL(join(import.meta.dir, "../src/config.ts")).href; + const children = ["a", "b"].map(id => { + const readyPath = join(testDir, `race-${id}-ready`); + const childSource = ` + import { existsSync, writeFileSync } from "node:fs"; + import { + getDefaultConfig, + initializeConfigIfMissing, + } from ${JSON.stringify(configModuleUrl)}; + writeFileSync(${JSON.stringify(readyPath)}, "ready"); + while (!existsSync(${JSON.stringify(releasePath)})) Bun.sleepSync(5); + console.log(JSON.stringify(initializeConfigIfMissing(getDefaultConfig()))); + `; + return { + child: Bun.spawn([process.execPath, "-e", childSource], { + cwd: join(import.meta.dir, ".."), + env: { ...process.env, CODEXCOMMANDER_HOME: raceRoot }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }), + readyPath, + }; + }); + + try { + for (let attempt = 0; attempt < 1_000; attempt += 1) { + if (children.every(({ readyPath }) => existsSync(readyPath))) break; + await Bun.sleep(5); + } + expect(children.every(({ readyPath }) => existsSync(readyPath))).toBe(true); + writeFileSync(releasePath, "go"); + const results = await Promise.all(children.map(async ({ child }) => { + const exitCode = await Promise.race([ + child.exited, + Bun.sleep(10_000).then(() => null), + ]); + if (exitCode === null) { + child.kill(); + await child.exited; + throw new Error("Timed out waiting for config initializer child"); + } + const stdout = await new Response(child.stdout).text(); + const stderr = await new Response(child.stderr).text(); + if (exitCode !== 0) { + throw new Error(`Config initializer child exited ${exitCode}: ${stderr}`); + } + return JSON.parse(stdout.trim()) as ReturnType; + })); + + expect(results.map(result => result.status).sort()).toEqual(["created", "existing"]); + const finalPath = join(raceRoot, "config.json"); + expect(JSON.parse(readFileSync(finalPath, "utf8"))).toEqual(getDefaultConfig()); + expect(lstatSync(finalPath).nlink).toBe(1); + } finally { + writeFileSync(releasePath, "go"); + for (const { child } of children) { + if (child.exitCode === null) child.kill(); + await child.exited; + } + } + }, { timeout: 20_000 }); + + test("refuses a linked configuration root even when its target has valid ownership", () => { + const realRoot = join(testDir, "owned-real-root"); + const linkedRoot = join(testDir, "linked-root"); + mkdirSync(realRoot); + process.env.CODEXCOMMANDER_HOME = realRoot; + expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ status: "created" }); + unlinkSync(getConfigPath()); + symlinkSync(realRoot, linkedRoot, process.platform === "win32" ? "junction" : "dir"); + process.env.CODEXCOMMANDER_HOME = linkedRoot; + + expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ + status: "refused", + reason: "existing-unsafe", + }); + expect(existsSync(join(realRoot, "config.json"))).toBe(false); + }); + + test("refuses a configuration root replaced after ownership was cached", () => { + const displacedRoot = `${testDir}.displaced`; + expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ status: "created" }); + unlinkSync(getConfigPath()); + setConfigInitializationBeforePublishForTests(() => { + renameSync(testDir, displacedRoot); + mkdirSync(testDir, { mode: 0o700 }); + }); + try { + expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ + status: "refused", + reason: "existing-unsafe", + }); + expect(existsSync(getConfigPath())).toBe(false); + expect(existsSync(join(displacedRoot, "config.json"))).toBe(false); + } finally { + rmSync(displacedRoot, { recursive: true, force: true }); + } + }); + + test("does not reuse cached ownership after the configuration root was replaced", () => { + const displacedRoot = `${testDir}.cached-root`; + expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ status: "created" }); + unlinkSync(getConfigPath()); + renameSync(testDir, displacedRoot); + mkdirSync(testDir, { mode: 0o700 }); + try { + expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ + status: "refused", + reason: "existing-unsafe", + }); + expect(existsSync(getConfigPath())).toBe(false); + } finally { + rmSync(displacedRoot, { recursive: true, force: true }); + } + }); }); describe("CodexCommander config defaults", () => { From 3d204326ab0ca2cf97816277aa3e3d317fe47af7 Mon Sep 17 00:00:00 2001 From: pavelhov Date: Thu, 20 Aug 2026 18:10:52 -0400 Subject: [PATCH 05/28] fix(config): retain lossless root identities --- src/config.ts | 13 ++++--- src/lib/config-ownership.ts | 45 +++++++++++++++++++---- tests/config-ownership-uninstall.test.ts | 15 ++++++++ tests/config.test.ts | 46 ++++++++++++++++++++++++ 4 files changed, 106 insertions(+), 13 deletions(-) diff --git a/src/config.ts b/src/config.ts index 9f4d2b86c3..b5a3eb2b45 100644 --- a/src/config.ts +++ b/src/config.ts @@ -40,7 +40,7 @@ import { hardenSecretPathAsync, windowsSecretAclApplies, } from "./lib/windows-secret-acl"; -import { recordOwnedConfigPath } from "./lib/config-ownership"; +import { inspectPhysicalConfigRoot, recordOwnedConfigPath } from "./lib/config-ownership"; import { assertNotRealHomeUnderTest } from "./lib/test-home-guard"; import { isLocalAttestationSecret } from "./lib/local-management-attestation"; import { providerDestinationConfigError } from "./lib/destination-policy"; @@ -1663,8 +1663,8 @@ type ConfigEntryProbe = type ConfigRootIdentity = { path: string; canonicalPath: string; - dev: number; - ino: number; + dev: bigint; + ino: bigint; }; type ConfigRootProbe = @@ -1689,13 +1689,13 @@ function probeConfigRoot(): ConfigRootProbe { const path = getConfigDir(); let entry; try { - entry = lstatSync(path); + entry = inspectPhysicalConfigRoot(path); } catch (error) { return isMissingPathError(error) ? { kind: "missing" } : { kind: "refused", reason: "existing-inaccessible" }; } - if (!entry.isDirectory() || entry.isSymbolicLink()) { + if (entry.kind !== "valid") { return { kind: "refused", reason: "existing-unsafe" }; } try { @@ -1704,8 +1704,7 @@ function probeConfigRoot(): ConfigRootProbe { identity: { path, canonicalPath: realpathSync.native(path), - dev: entry.dev, - ino: entry.ino, + ...entry.identity, }, }; } catch { diff --git a/src/lib/config-ownership.ts b/src/lib/config-ownership.ts index 32181891de..ba60a845c2 100644 --- a/src/lib/config-ownership.ts +++ b/src/lib/config-ownership.ts @@ -51,10 +51,44 @@ type ConfigOwnership = { type ConfigOwnershipRootIdentity = { canonicalPath: string; - dev: number; - ino: number; + dev: bigint; + ino: bigint; }; +export type ConfigRootFileIdentity = { + dev: bigint; + ino: bigint; +}; + +type ConfigRootIdentityOverride = ( + path: string, + actual: ConfigRootFileIdentity, +) => ConfigRootFileIdentity; + +let configRootIdentityOverrideForTests: ConfigRootIdentityOverride | null = null; + +/** Test-only seam for simulating filesystem identifiers that local fixtures cannot produce. */ +export function setConfigRootIdentityOverrideForTests( + override: ConfigRootIdentityOverride | null, +): void { + configRootIdentityOverrideForTests = override; +} + +export type PhysicalConfigRootInspection = + | { kind: "unsafe" } + | { kind: "valid"; identity: ConfigRootFileIdentity }; + +/** Inspect a root without following links and retain its filesystem identity losslessly. */ +export function inspectPhysicalConfigRoot(path: string): PhysicalConfigRootInspection { + const root = lstatSync(path, { bigint: true }); + if (!root.isDirectory() || root.isSymbolicLink()) return { kind: "unsafe" }; + const actual = { dev: root.dev, ino: root.ino }; + const identity = configRootIdentityOverrideForTests?.(path, actual) ?? actual; + // Some filesystems report ino=0 when no stable file identifier is available. + if (identity.ino === 0n) return { kind: "unsafe" }; + return { kind: "valid", identity }; +} + const METADATA_MAX_BYTES = 64 * 1024; const MANIFEST_MAX_PATHS = 1024; const INITIAL_OWNED_PATHS = [ @@ -171,14 +205,13 @@ function canonicalRoot(configDir: string): string { } function configOwnershipRootIdentity(configDir: string): ConfigOwnershipRootIdentity { - const root = lstatSync(configDir); - if (!root.isDirectory() || root.isSymbolicLink()) { + const root = inspectPhysicalConfigRoot(configDir); + if (root.kind !== "valid") { throw new Error("config ownership root is not a physical directory"); } return { canonicalPath: canonicalRoot(configDir), - dev: root.dev, - ino: root.ino, + ...root.identity, }; } diff --git a/tests/config-ownership-uninstall.test.ts b/tests/config-ownership-uninstall.test.ts index 8c850604d6..453622a7b5 100644 --- a/tests/config-ownership-uninstall.test.ts +++ b/tests/config-ownership-uninstall.test.ts @@ -8,6 +8,7 @@ import { recordOwnedConfigPath, removeOwnedConfigArtifactsRetainingLifecycleRoot, removeOwnedConfigState, + setConfigRootIdentityOverrideForTests, } from "../src/lib/config-ownership"; import { getDefaultConfig, saveConfig } from "../src/config"; @@ -25,6 +26,20 @@ describe("owned config uninstall", () => { } }); + test("does not establish ownership without a stable nonzero root inode", () => { + const dir = mkdtempSync(join(tmpdir(), "ccx-config-zero-root-inode-")); + setConfigRootIdentityOverrideForTests((_path, actual) => ({ ...actual, ino: 0n })); + + try { + expect(recordOwnedConfigPath(dir, join(dir, "config.json"))).toBe(false); + expect(existsSync(join(dir, CONFIG_OWNER_FILE))).toBe(false); + expect(existsSync(join(dir, CONFIG_UNINSTALL_MANIFEST))).toBe(false); + } finally { + setConfigRootIdentityOverrideForTests(null); + rmSync(dir, { recursive: true, force: true }); + } + }); + test("refuses an unowned config directory without ownership metadata", () => { const dir = mkdtempSync(join(tmpdir(), "ccx-uninstall-unowned-")); const configPath = join(dir, "config.json"); diff --git a/tests/config.test.ts b/tests/config.test.ts index 1f2e6dd903..b1bf8933f8 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -31,6 +31,7 @@ import { import * as windowsAcl from "../src/lib/windows-secret-acl"; import { AtomicWriteResidualTempError, atomicWriteFile, atomicWriteFileAsync, hardenConfigDir, hardenExistingSecret, renameAtomicFile, saveConfig } from "../src/config"; +import { setConfigRootIdentityOverrideForTests } from "../src/lib/config-ownership"; let testDir = ""; let previousCodexCommanderHome: string | undefined; @@ -42,6 +43,7 @@ beforeEach(() => { afterEach(() => { setConfigInitializationBeforePublishForTests(null); + setConfigRootIdentityOverrideForTests(null); if (previousCodexCommanderHome === undefined) delete process.env.CODEXCOMMANDER_HOME; else process.env.CODEXCOMMANDER_HOME = previousCodexCommanderHome; previousCodexCommanderHome = undefined; @@ -325,6 +327,50 @@ describe("create-only config initialization", () => { rmSync(displacedRoot, { recursive: true, force: true }); } }); + + test("refuses a configuration root whose filesystem identity has a zero inode", () => { + setConfigRootIdentityOverrideForTests((_path, actual) => ({ + ...actual, + ino: 0n, + })); + + expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ + status: "refused", + reason: "existing-unsafe", + }); + expect(readdirSync(testDir)).toEqual([]); + }); + + test("detects replacement roots whose inode numbers collide after numeric conversion", () => { + const firstIno = 2n ** 53n; + const replacementIno = firstIno + 1n; + expect(Number(firstIno)).toBe(Number(replacementIno)); + + let initialActualIdentity: string | null = null; + setConfigRootIdentityOverrideForTests((_path, actual) => { + const actualIdentity = `${actual.dev}:${actual.ino}`; + initialActualIdentity ??= actualIdentity; + return { + dev: 1n, + ino: actualIdentity === initialActualIdentity ? firstIno : replacementIno, + }; + }); + + const displacedRoot = `${testDir}.precision-collision`; + expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ status: "created" }); + unlinkSync(getConfigPath()); + renameSync(testDir, displacedRoot); + mkdirSync(testDir, { mode: 0o700 }); + try { + expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ + status: "refused", + reason: "existing-unsafe", + }); + expect(existsSync(getConfigPath())).toBe(false); + } finally { + rmSync(displacedRoot, { recursive: true, force: true }); + } + }); }); describe("CodexCommander config defaults", () => { From 757deb7f6f05cb6111cd0a82435312acfbfa5143 Mon Sep 17 00:00:00 2001 From: pavelhov Date: Thu, 20 Aug 2026 18:18:31 -0400 Subject: [PATCH 06/28] fix(config): remove mutable identity test seam --- src/config.ts | 9 +++-- src/lib/config-ownership.ts | 31 +++++++--------- tests/config-ownership-uninstall.test.ts | 23 ++++++------ tests/config.test.ts | 47 +++--------------------- 4 files changed, 37 insertions(+), 73 deletions(-) diff --git a/src/config.ts b/src/config.ts index b5a3eb2b45..6900b99863 100644 --- a/src/config.ts +++ b/src/config.ts @@ -40,7 +40,11 @@ import { hardenSecretPathAsync, windowsSecretAclApplies, } from "./lib/windows-secret-acl"; -import { inspectPhysicalConfigRoot, recordOwnedConfigPath } from "./lib/config-ownership"; +import { + inspectPhysicalConfigRoot, + recordOwnedConfigPath, + sameConfigRootFileIdentity, +} from "./lib/config-ownership"; import { assertNotRealHomeUnderTest } from "./lib/test-home-guard"; import { isLocalAttestationSecret } from "./lib/local-management-attestation"; import { providerDestinationConfigError } from "./lib/destination-policy"; @@ -1681,8 +1685,7 @@ function samePhysicalConfigRoot(left: ConfigRootIdentity, right: ConfigRootIdent : left.canonicalPath === right.canonicalPath; return left.path === right.path && sameCanonicalPath - && left.dev === right.dev - && left.ino === right.ino; + && sameConfigRootFileIdentity(left, right); } function probeConfigRoot(): ConfigRootProbe { diff --git a/src/lib/config-ownership.ts b/src/lib/config-ownership.ts index ba60a845c2..3a8efbca9b 100644 --- a/src/lib/config-ownership.ts +++ b/src/lib/config-ownership.ts @@ -60,18 +60,18 @@ export type ConfigRootFileIdentity = { ino: bigint; }; -type ConfigRootIdentityOverride = ( - path: string, - actual: ConfigRootFileIdentity, -) => ConfigRootFileIdentity; - -let configRootIdentityOverrideForTests: ConfigRootIdentityOverride | null = null; +export function stableConfigRootFileIdentity( + identity: ConfigRootFileIdentity, +): ConfigRootFileIdentity | null { + // Some filesystems report ino=0 when no stable file identifier is available. + return identity.ino === 0n ? null : identity; +} -/** Test-only seam for simulating filesystem identifiers that local fixtures cannot produce. */ -export function setConfigRootIdentityOverrideForTests( - override: ConfigRootIdentityOverride | null, -): void { - configRootIdentityOverrideForTests = override; +export function sameConfigRootFileIdentity( + left: ConfigRootFileIdentity, + right: ConfigRootFileIdentity, +): boolean { + return left.dev === right.dev && left.ino === right.ino; } export type PhysicalConfigRootInspection = @@ -82,10 +82,8 @@ export type PhysicalConfigRootInspection = export function inspectPhysicalConfigRoot(path: string): PhysicalConfigRootInspection { const root = lstatSync(path, { bigint: true }); if (!root.isDirectory() || root.isSymbolicLink()) return { kind: "unsafe" }; - const actual = { dev: root.dev, ino: root.ino }; - const identity = configRootIdentityOverrideForTests?.(path, actual) ?? actual; - // Some filesystems report ino=0 when no stable file identifier is available. - if (identity.ino === 0n) return { kind: "unsafe" }; + const identity = stableConfigRootFileIdentity({ dev: root.dev, ino: root.ino }); + if (!identity) return { kind: "unsafe" }; return { kind: "valid", identity }; } @@ -220,8 +218,7 @@ function sameConfigOwnershipRoot( right: ConfigOwnershipRootIdentity, ): boolean { return samePath(left.canonicalPath, right.canonicalPath) - && left.dev === right.dev - && left.ino === right.ino; + && sameConfigRootFileIdentity(left, right); } function isWithinRoot(root: string, candidate: string): boolean { diff --git a/tests/config-ownership-uninstall.test.ts b/tests/config-ownership-uninstall.test.ts index 453622a7b5..0965a715f7 100644 --- a/tests/config-ownership-uninstall.test.ts +++ b/tests/config-ownership-uninstall.test.ts @@ -2,13 +2,14 @@ import { describe, expect, test } from "bun:test"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import * as configOwnership from "../src/lib/config-ownership"; import { CONFIG_OWNER_FILE, CONFIG_UNINSTALL_MANIFEST, recordOwnedConfigPath, removeOwnedConfigArtifactsRetainingLifecycleRoot, removeOwnedConfigState, - setConfigRootIdentityOverrideForTests, + stableConfigRootFileIdentity, } from "../src/lib/config-ownership"; import { getDefaultConfig, saveConfig } from "../src/config"; @@ -26,18 +27,16 @@ describe("owned config uninstall", () => { } }); - test("does not establish ownership without a stable nonzero root inode", () => { - const dir = mkdtempSync(join(tmpdir(), "ccx-config-zero-root-inode-")); - setConfigRootIdentityOverrideForTests((_path, actual) => ({ ...actual, ino: 0n })); + test("classifies a zero root inode as an unavailable stable identity", () => { + expect(stableConfigRootFileIdentity({ dev: 1n, ino: 0n })).toBeNull(); + expect(stableConfigRootFileIdentity({ dev: 0n, ino: 1n })).toEqual({ + dev: 0n, + ino: 1n, + }); + }); - try { - expect(recordOwnedConfigPath(dir, join(dir, "config.json"))).toBe(false); - expect(existsSync(join(dir, CONFIG_OWNER_FILE))).toBe(false); - expect(existsSync(join(dir, CONFIG_UNINSTALL_MANIFEST))).toBe(false); - } finally { - setConfigRootIdentityOverrideForTests(null); - rmSync(dir, { recursive: true, force: true }); - } + test("does not expose a mutable root-identity override", () => { + expect("setConfigRootIdentityOverrideForTests" in configOwnership).toBe(false); }); test("refuses an unowned config directory without ownership metadata", () => { diff --git a/tests/config.test.ts b/tests/config.test.ts index b1bf8933f8..71e6fdf8da 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -31,7 +31,7 @@ import { import * as windowsAcl from "../src/lib/windows-secret-acl"; import { AtomicWriteResidualTempError, atomicWriteFile, atomicWriteFileAsync, hardenConfigDir, hardenExistingSecret, renameAtomicFile, saveConfig } from "../src/config"; -import { setConfigRootIdentityOverrideForTests } from "../src/lib/config-ownership"; +import { sameConfigRootFileIdentity } from "../src/lib/config-ownership"; let testDir = ""; let previousCodexCommanderHome: string | undefined; @@ -43,7 +43,6 @@ beforeEach(() => { afterEach(() => { setConfigInitializationBeforePublishForTests(null); - setConfigRootIdentityOverrideForTests(null); if (previousCodexCommanderHome === undefined) delete process.env.CODEXCOMMANDER_HOME; else process.env.CODEXCOMMANDER_HOME = previousCodexCommanderHome; previousCodexCommanderHome = undefined; @@ -328,48 +327,14 @@ describe("create-only config initialization", () => { } }); - test("refuses a configuration root whose filesystem identity has a zero inode", () => { - setConfigRootIdentityOverrideForTests((_path, actual) => ({ - ...actual, - ino: 0n, - })); - - expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ - status: "refused", - reason: "existing-unsafe", - }); - expect(readdirSync(testDir)).toEqual([]); - }); - - test("detects replacement roots whose inode numbers collide after numeric conversion", () => { + test("distinguishes bigint root identities whose inode numbers collide after numeric conversion", () => { const firstIno = 2n ** 53n; const replacementIno = firstIno + 1n; expect(Number(firstIno)).toBe(Number(replacementIno)); - - let initialActualIdentity: string | null = null; - setConfigRootIdentityOverrideForTests((_path, actual) => { - const actualIdentity = `${actual.dev}:${actual.ino}`; - initialActualIdentity ??= actualIdentity; - return { - dev: 1n, - ino: actualIdentity === initialActualIdentity ? firstIno : replacementIno, - }; - }); - - const displacedRoot = `${testDir}.precision-collision`; - expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ status: "created" }); - unlinkSync(getConfigPath()); - renameSync(testDir, displacedRoot); - mkdirSync(testDir, { mode: 0o700 }); - try { - expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ - status: "refused", - reason: "existing-unsafe", - }); - expect(existsSync(getConfigPath())).toBe(false); - } finally { - rmSync(displacedRoot, { recursive: true, force: true }); - } + expect(sameConfigRootFileIdentity( + { dev: 1n, ino: firstIno }, + { dev: 1n, ino: replacementIno }, + )).toBe(false); }); }); From 4f1bc4c6b0b04e2f7cc542440c15de924a5287f1 Mon Sep 17 00:00:00 2001 From: pavelhov Date: Thu, 20 Aug 2026 18:31:09 -0400 Subject: [PATCH 07/28] feat(macos): define first-run bootstrap policy --- src/cli/macos-first-run.ts | 75 +++++++++++++++++++++++++++++++++++ tests/macos-first-run.test.ts | 56 ++++++++++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 src/cli/macos-first-run.ts create mode 100644 tests/macos-first-run.test.ts diff --git a/src/cli/macos-first-run.ts b/src/cli/macos-first-run.ts new file mode 100644 index 0000000000..ec6e5dcc35 --- /dev/null +++ b/src/cli/macos-first-run.ts @@ -0,0 +1,75 @@ +import { lstatSync } from "node:fs"; +import { CODEX_CONFIG_PATH } from "../codex/paths"; +import { + getDefaultConfig, + initializeConfigIfMissing, + type ConfigInitializationResult, +} from "../config"; + +export type ProxySetupRequirement = "codex-first-run"; + +export type ProxyStartPreparation = + | { + ok: true; + changed: boolean; + enableCodexRouting: boolean; + setupRequired?: ProxySetupRequirement; + } + | { + ok: false; + changed: false; + message: string; + errorCode: "CONFIGURATION_REQUIRED"; + }; + +export interface MacOSFirstRunIo { + initializeConfig?: typeof initializeConfigIfMissing; + codexConfigState?: () => "present-or-unreadable" | "missing"; +} + +function defaultCodexConfigState(): "present-or-unreadable" | "missing" { + try { + lstatSync(CODEX_CONFIG_PATH); + return "present-or-unreadable"; + } catch (error) { + return (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT" + ? "missing" + : "present-or-unreadable"; + } +} + +function refusalMessage(reason: Extract["reason"]): string { + return reason === "existing-invalid" + ? "CodexCommander configuration needs repair; no files were changed." + : "CodexCommander configuration is inaccessible or unsafe; no files were changed."; +} + +export function prepareMacOSAppStart( + io: MacOSFirstRunIo = {}, +): ProxyStartPreparation { + const codexState = (io.codexConfigState ?? defaultCodexConfigState)(); + const candidate = structuredClone(getDefaultConfig()); + if (codexState === "missing") { + candidate.clientIntegrations = { + ...(candidate.clientIntegrations ?? {}), + codex: false, + }; + } + + const initialized = (io.initializeConfig ?? initializeConfigIfMissing)(candidate); + if (initialized.status === "refused") { + return { + ok: false, + changed: false, + message: refusalMessage(initialized.reason), + errorCode: "CONFIGURATION_REQUIRED", + }; + } + + return { + ok: true, + changed: initialized.status === "created", + enableCodexRouting: !(initialized.status === "created" && codexState === "missing"), + ...(codexState === "missing" ? { setupRequired: "codex-first-run" as const } : {}), + }; +} diff --git a/tests/macos-first-run.test.ts b/tests/macos-first-run.test.ts new file mode 100644 index 0000000000..bb0d109bfd --- /dev/null +++ b/tests/macos-first-run.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from "bun:test"; +import { getDefaultConfig } from "../src/config"; +import { prepareMacOSAppStart } from "../src/cli/macos-first-run"; + +describe("macOS first-run preparation", () => { + test("fresh app plus initialized Codex enables normal explicit routing", () => { + let candidate = getDefaultConfig(); + const result = prepareMacOSAppStart({ + codexConfigState: () => "present-or-unreadable", + initializeConfig: value => { candidate = value; return { status: "created" }; }, + }); + expect(candidate).toEqual(getDefaultConfig()); + expect(result).toEqual({ ok: true, changed: true, enableCodexRouting: true }); + }); + + test("fresh app plus missing Codex persists integration off and requests setup", () => { + let candidate = getDefaultConfig(); + const result = prepareMacOSAppStart({ + codexConfigState: () => "missing", + initializeConfig: value => { candidate = value; return { status: "created" }; }, + }); + expect(candidate.clientIntegrations).toEqual({ codex: false }); + expect(result).toEqual({ + ok: true, + changed: true, + enableCodexRouting: false, + setupRequired: "codex-first-run", + }); + }); + + test("existing config is never replaced even when Codex is missing", () => { + const result = prepareMacOSAppStart({ + codexConfigState: () => "missing", + initializeConfig: () => ({ status: "existing" }), + }); + expect(result).toEqual({ + ok: true, + changed: false, + enableCodexRouting: true, + setupRequired: "codex-first-run", + }); + }); + + test("typed initialization refusals become a secret-free app error", () => { + const result = prepareMacOSAppStart({ + codexConfigState: () => "present-or-unreadable", + initializeConfig: () => ({ status: "refused", reason: "existing-invalid" }), + }); + expect(result).toEqual({ + ok: false, + changed: false, + message: "CodexCommander configuration needs repair; no files were changed.", + errorCode: "CONFIGURATION_REQUIRED", + }); + }); +}); From 8f82bb25d4b6b33beaeaed69e81a8e8003df4dc3 Mon Sep 17 00:00:00 2001 From: pavelhov Date: Thu, 20 Aug 2026 18:31:48 -0400 Subject: [PATCH 08/28] docs: report macOS first-run policy --- .../task-2-report.md | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .superpowers/sdd/2026-08-20-macos-zero-click-first-run/task-2-report.md diff --git a/.superpowers/sdd/2026-08-20-macos-zero-click-first-run/task-2-report.md b/.superpowers/sdd/2026-08-20-macos-zero-click-first-run/task-2-report.md new file mode 100644 index 0000000000..1ca611d892 --- /dev/null +++ b/.superpowers/sdd/2026-08-20-macos-zero-click-first-run/task-2-report.md @@ -0,0 +1,36 @@ +# Task 2: macOS first-run policy + +Status: complete + +Commit: `4f1bc4c6b0b04e2f7cc542440c15de924a5287f1` + +## Result + +Added the pure `prepareMacOSAppStart` policy layer. It consumes the canonical +default config and Task 1's lossless `initializeConfigIfMissing` result, +classifies Codex config presence using `lstatSync` (only `ENOENT` is missing), +persists `clientIntegrations.codex=false` only on a newly created app config +when Codex is missing, and returns the explicit first-run setup requirement. +Existing app config and all Codex config files remain untouched. Typed +initialization refusals map to secret-free `CONFIGURATION_REQUIRED` errors. + +## Validation + +- `bun test tests/macos-first-run.test.ts` — 4 pass, 0 fail. +- `bun run typecheck` — pass. +- `bun run test:parallel` — reached all 596 test-file completions with no + failures observed, but was interrupted after the runner continued draining + unusually slow unrelated macOS/build and discovery tests; it did not produce + a final aggregate exit result. + +## Files changed + +- `src/cli/macos-first-run.ts` +- `tests/macos-first-run.test.ts` + +## Concerns + +No known policy concerns. Full-suite aggregate completion remains unconfirmed +because unrelated long-running tests prevented the parallel runner from +exiting in a reasonable time; focused policy tests and strict typecheck are +clean. From 03ce05234cf576449de3dc3f0354bc8526791b16 Mon Sep 17 00:00:00 2001 From: pavelhov Date: Thu, 20 Aug 2026 18:41:48 -0400 Subject: [PATCH 09/28] test(macos): exercise first-run policy in temp homes --- .../task-2-report.md | 14 ++ tests/macos-first-run.test.ts | 162 +++++++++++++++--- 2 files changed, 149 insertions(+), 27 deletions(-) diff --git a/.superpowers/sdd/2026-08-20-macos-zero-click-first-run/task-2-report.md b/.superpowers/sdd/2026-08-20-macos-zero-click-first-run/task-2-report.md index 1ca611d892..c65862b64a 100644 --- a/.superpowers/sdd/2026-08-20-macos-zero-click-first-run/task-2-report.md +++ b/.superpowers/sdd/2026-08-20-macos-zero-click-first-run/task-2-report.md @@ -34,3 +34,17 @@ No known policy concerns. Full-suite aggregate completion remains unconfirmed because unrelated long-running tests prevented the parallel runner from exiting in a reasonable time; focused policy tests and strict typecheck are clean. + +## Fix round 1 + +Addressed review feedback by replacing injected-only branch coverage with four +isolated subprocess probes. Each probe sets temporary `CODEXCOMMANDER_HOME` and +`CODEX_HOME` before importing the production policy, blocks `fetch`, runs the +real initializer/classifier, and asserts exact app config bytes, app metadata +entries, Codex config bytes, and Codex directory entries for its branch. + +Validation: + +- `bun test tests/macos-first-run.test.ts` — 4 pass, 0 fail (22 assertions). +- `bun run typecheck` — pass. +- `git diff --check` — pass. diff --git a/tests/macos-first-run.test.ts b/tests/macos-first-run.test.ts index bb0d109bfd..3e46e04c43 100644 --- a/tests/macos-first-run.test.ts +++ b/tests/macos-first-run.test.ts @@ -1,56 +1,164 @@ import { describe, expect, test } from "bun:test"; -import { getDefaultConfig } from "../src/config"; -import { prepareMacOSAppStart } from "../src/cli/macos-first-run"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; -describe("macOS first-run preparation", () => { - test("fresh app plus initialized Codex enables normal explicit routing", () => { - let candidate = getDefaultConfig(); - const result = prepareMacOSAppStart({ - codexConfigState: () => "present-or-unreadable", - initializeConfig: value => { candidate = value; return { status: "created" }; }, +const REPO_ROOT = resolve(import.meta.dir, ".."); + +type ProductionSnapshot = { + result: unknown; + expectedDefault: Record; + expectedMissing: Record; + appRaw: string | null; + appConfig: unknown; + appEntries: string[]; + codexRaw: string | null; + codexEntries: string[]; +}; + +/** + * Import the production policy only after both homes are configured. The child + * also replaces fetch so an accidental provider/network call fails the probe. + */ +function runProductionScenario(options: { appRaw?: string; codexRaw?: string }): ProductionSnapshot { + const root = mkdtempSync(join(tmpdir(), "ccx-macos-first-run-")); + const appHome = join(root, "app-home"); + const codexHome = join(root, "codex-home"); + mkdirSync(appHome); + mkdirSync(codexHome); + if (options.appRaw !== undefined) writeFileSync(join(appHome, "config.json"), options.appRaw, "utf8"); + if (options.codexRaw !== undefined) writeFileSync(join(codexHome, "config.toml"), options.codexRaw, "utf8"); + + const script = ` + globalThis.fetch = () => { throw new Error("network blocked by macOS first-run test"); }; + const { existsSync, readFileSync, readdirSync } = await import("node:fs"); + const { join } = await import("node:path"); + const { getDefaultConfig, validateConfigCandidate } = await import("./src/config.ts"); + const { prepareMacOSAppStart } = await import("./src/cli/macos-first-run.ts"); + const appHome = process.env.CODEXCOMMANDER_HOME; + const codexHome = process.env.CODEX_HOME; + const appPath = join(appHome, "config.json"); + const codexPath = join(codexHome, "config.toml"); + const raw = path => existsSync(path) ? readFileSync(path, "utf8") : null; + const parse = value => { + if (value === null) return null; + try { return JSON.parse(value); } catch { return null; } + }; + console.log(JSON.stringify({ + result: prepareMacOSAppStart(), + expectedDefault: validateConfigCandidate(getDefaultConfig()).config, + expectedMissing: validateConfigCandidate({ + ...getDefaultConfig(), + clientIntegrations: { codex: false }, + }).config, + appRaw: raw(appPath), + appConfig: parse(raw(appPath)), + appEntries: readdirSync(appHome).sort(), + codexRaw: raw(codexPath), + codexEntries: readdirSync(codexHome).sort(), + })); + `; + try { + const child = Bun.spawnSync([process.execPath, "--eval", script], { + cwd: REPO_ROOT, + env: { + ...process.env, + CODEXCOMMANDER_HOME: appHome, + CODEX_HOME: codexHome, + CCX_TEST_NETWORK_BLOCKED: "1", + }, + stdout: "pipe", + stderr: "pipe", }); - expect(candidate).toEqual(getDefaultConfig()); - expect(result).toEqual({ ok: true, changed: true, enableCodexRouting: true }); + const stdout = new TextDecoder().decode(child.stdout).trim(); + const stderr = new TextDecoder().decode(child.stderr).trim(); + if (child.exitCode !== 0) { + throw new Error(`production probe failed (${child.exitCode}): ${stderr || stdout}`); + } + const line = stdout.split("\n").at(-1); + if (!line) throw new Error(`production probe returned no JSON: ${stderr}`); + return JSON.parse(line) as ProductionSnapshot; + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +const existingConfigBytes = `${JSON.stringify({ + port: 12001, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + }, + defaultProvider: "openai", + multiAgentGuidanceEnabled: true, +})}\n`; +const codexConfigBytes = 'model = "gpt-5.5"\n'; + +describe("macOS first-run preparation (production filesystem paths)", () => { + test("fresh app plus initialized Codex enables normal explicit routing", () => { + const snapshot = runProductionScenario({ codexRaw: codexConfigBytes }); + expect(snapshot.result).toEqual({ ok: true, changed: true, enableCodexRouting: true }); + expect(snapshot.appConfig).toEqual(snapshot.expectedDefault); + expect(snapshot.appRaw).toBe(`${JSON.stringify(snapshot.expectedDefault, null, 2)}\n`); + expect(snapshot.appEntries).toEqual([ + ".codexcommander-owner.json", + ".codexcommander-uninstall.json", + "config-mutation.sqlite", + "config.json", + ]); + expect(snapshot.codexRaw).toBe(codexConfigBytes); + expect(snapshot.codexEntries).toEqual(["config.toml"]); }); test("fresh app plus missing Codex persists integration off and requests setup", () => { - let candidate = getDefaultConfig(); - const result = prepareMacOSAppStart({ - codexConfigState: () => "missing", - initializeConfig: value => { candidate = value; return { status: "created" }; }, - }); - expect(candidate.clientIntegrations).toEqual({ codex: false }); - expect(result).toEqual({ + const snapshot = runProductionScenario({}); + expect(snapshot.result).toEqual({ ok: true, changed: true, enableCodexRouting: false, setupRequired: "codex-first-run", }); + expect(snapshot.appConfig).toEqual(snapshot.expectedMissing); + expect(snapshot.appRaw).toBe(`${JSON.stringify(snapshot.expectedMissing, null, 2)}\n`); + expect(snapshot.appEntries).toEqual([ + ".codexcommander-owner.json", + ".codexcommander-uninstall.json", + "config-mutation.sqlite", + "config.json", + ]); + expect(snapshot.codexRaw).toBeNull(); + expect(snapshot.codexEntries).toEqual([]); }); test("existing config is never replaced even when Codex is missing", () => { - const result = prepareMacOSAppStart({ - codexConfigState: () => "missing", - initializeConfig: () => ({ status: "existing" }), - }); - expect(result).toEqual({ + const snapshot = runProductionScenario({ appRaw: existingConfigBytes }); + expect(snapshot.result).toEqual({ ok: true, changed: false, enableCodexRouting: true, setupRequired: "codex-first-run", }); + expect(snapshot.appRaw).toBe(existingConfigBytes); + expect(snapshot.appEntries).toEqual(["config.json"]); + expect(snapshot.codexRaw).toBeNull(); + expect(snapshot.codexEntries).toEqual([]); }); test("typed initialization refusals become a secret-free app error", () => { - const result = prepareMacOSAppStart({ - codexConfigState: () => "present-or-unreadable", - initializeConfig: () => ({ status: "refused", reason: "existing-invalid" }), - }); - expect(result).toEqual({ + const invalidAppBytes = "{\n"; + const snapshot = runProductionScenario({ appRaw: invalidAppBytes, codexRaw: codexConfigBytes }); + expect(snapshot.result).toEqual({ ok: false, changed: false, message: "CodexCommander configuration needs repair; no files were changed.", errorCode: "CONFIGURATION_REQUIRED", }); + expect(snapshot.appRaw).toBe(invalidAppBytes); + expect(snapshot.appEntries).toEqual(["config.json"]); + expect(snapshot.codexRaw).toBe(codexConfigBytes); + expect(snapshot.codexEntries).toEqual(["config.toml"]); }); }); From 484e3769252651d26ec3619846e024944038996e Mon Sep 17 00:00:00 2001 From: pavelhov Date: Thu, 20 Aug 2026 18:49:00 -0400 Subject: [PATCH 10/28] test(macos): harden first-run subprocess probes --- .../task-2-report.md | 19 +++ tests/macos-first-run.test.ts | 117 +++++++++++++----- 2 files changed, 106 insertions(+), 30 deletions(-) diff --git a/.superpowers/sdd/2026-08-20-macos-zero-click-first-run/task-2-report.md b/.superpowers/sdd/2026-08-20-macos-zero-click-first-run/task-2-report.md index c65862b64a..ec6507aea4 100644 --- a/.superpowers/sdd/2026-08-20-macos-zero-click-first-run/task-2-report.md +++ b/.superpowers/sdd/2026-08-20-macos-zero-click-first-run/task-2-report.md @@ -48,3 +48,22 @@ Validation: - `bun test tests/macos-first-run.test.ts` — 4 pass, 0 fail (22 assertions). - `bun run typecheck` — pass. - `git diff --check` — pass. + +## Fix round 2 + +Added a production-path `ENOTDIR` probe: the child imports the policy while +`CODEX_HOME` is a directory, replaces that path with a sentinel-bearing file, +then runs startup so `lstatSync(CODEX_CONFIG_PATH)` receives `ENOTDIR`. The +probe verifies normal routing, no setup requirement, canonical default app +bytes, and unchanged Codex sentinel bytes. + +The child harness now requires empty stderr and parses the complete stdout as +one JSON document (no last-line fallback). Fixture bytes are inspected by the +parent after the child exits, so the refusal fixture's secret sentinel can be +asserted absent from both child streams. + +Validation: + +- `bun test tests/macos-first-run.test.ts` — 5 pass, 0 fail (33 assertions). +- `bun run typecheck` — pass. +- `git diff --check` — pass. diff --git a/tests/macos-first-run.test.ts b/tests/macos-first-run.test.ts index 3e46e04c43..d461a980be 100644 --- a/tests/macos-first-run.test.ts +++ b/tests/macos-first-run.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; @@ -13,14 +13,21 @@ type ProductionSnapshot = { appConfig: unknown; appEntries: string[]; codexRaw: string | null; - codexEntries: string[]; + codexEntries: string[] | null; + codexHomeIsFile: boolean; + childStdout: string; + childStderr: string; }; /** * Import the production policy only after both homes are configured. The child * also replaces fetch so an accidental provider/network call fails the probe. */ -function runProductionScenario(options: { appRaw?: string; codexRaw?: string }): ProductionSnapshot { +function runProductionScenario(options: { + appRaw?: string; + codexRaw?: string; + codexHomeFileRaw?: string; +}): ProductionSnapshot { const root = mkdtempSync(join(tmpdir(), "ccx-macos-first-run-")); const appHome = join(root, "app-home"); const codexHome = join(root, "codex-home"); @@ -31,19 +38,14 @@ function runProductionScenario(options: { appRaw?: string; codexRaw?: string }): const script = ` globalThis.fetch = () => { throw new Error("network blocked by macOS first-run test"); }; - const { existsSync, readFileSync, readdirSync } = await import("node:fs"); - const { join } = await import("node:path"); + const { rmSync, writeFileSync } = await import("node:fs"); const { getDefaultConfig, validateConfigCandidate } = await import("./src/config.ts"); const { prepareMacOSAppStart } = await import("./src/cli/macos-first-run.ts"); - const appHome = process.env.CODEXCOMMANDER_HOME; const codexHome = process.env.CODEX_HOME; - const appPath = join(appHome, "config.json"); - const codexPath = join(codexHome, "config.toml"); - const raw = path => existsSync(path) ? readFileSync(path, "utf8") : null; - const parse = value => { - if (value === null) return null; - try { return JSON.parse(value); } catch { return null; } - }; + if (process.env.CCX_TEST_REPLACE_CODEX_HOME === "1") { + rmSync(codexHome, { recursive: true, force: true }); + writeFileSync(codexHome, process.env.CCX_TEST_CODEX_SENTINEL ?? "", "utf8"); + } console.log(JSON.stringify({ result: prepareMacOSAppStart(), expectedDefault: validateConfigCandidate(getDefaultConfig()).config, @@ -51,33 +53,66 @@ function runProductionScenario(options: { appRaw?: string; codexRaw?: string }): ...getDefaultConfig(), clientIntegrations: { codex: false }, }).config, - appRaw: raw(appPath), - appConfig: parse(raw(appPath)), - appEntries: readdirSync(appHome).sort(), - codexRaw: raw(codexPath), - codexEntries: readdirSync(codexHome).sort(), })); `; try { + const childEnv = { + ...process.env, + CODEXCOMMANDER_HOME: appHome, + CODEX_HOME: codexHome, + CCX_TEST_NETWORK_BLOCKED: "1", + ...(options.codexHomeFileRaw === undefined + ? {} + : { + CCX_TEST_REPLACE_CODEX_HOME: "1", + CCX_TEST_CODEX_SENTINEL: options.codexHomeFileRaw, + }), + }; const child = Bun.spawnSync([process.execPath, "--eval", script], { cwd: REPO_ROOT, - env: { - ...process.env, - CODEXCOMMANDER_HOME: appHome, - CODEX_HOME: codexHome, - CCX_TEST_NETWORK_BLOCKED: "1", - }, + env: childEnv, stdout: "pipe", stderr: "pipe", }); - const stdout = new TextDecoder().decode(child.stdout).trim(); - const stderr = new TextDecoder().decode(child.stderr).trim(); + const stdout = new TextDecoder().decode(child.stdout); + const stderr = new TextDecoder().decode(child.stderr); if (child.exitCode !== 0) { throw new Error(`production probe failed (${child.exitCode}): ${stderr || stdout}`); } - const line = stdout.split("\n").at(-1); - if (!line) throw new Error(`production probe returned no JSON: ${stderr}`); - return JSON.parse(line) as ProductionSnapshot; + if (stderr !== "") throw new Error(`production probe wrote stderr: ${stderr}`); + let payload: Pick; + try { + payload = JSON.parse(stdout) as typeof payload; + } catch (error) { + throw new Error(`production probe returned non-JSON stdout: ${JSON.stringify(stdout)}`, { cause: error }); + } + + const appPath = join(appHome, "config.json"); + const appRaw = existsSync(appPath) ? readFileSync(appPath, "utf8") : null; + const appConfig = appRaw === null ? null : (() => { + try { return JSON.parse(appRaw); } catch { return null; } + })(); + const appEntries = readdirSync(appHome).sort(); + const codexHomeStat = lstatSync(codexHome); + const codexHomeIsFile = codexHomeStat.isFile(); + const codexRaw = codexHomeIsFile + ? readFileSync(codexHome, "utf8") + : (() => { + const codexPath = join(codexHome, "config.toml"); + return existsSync(codexPath) ? readFileSync(codexPath, "utf8") : null; + })(); + const codexEntries = codexHomeIsFile ? null : readdirSync(codexHome).sort(); + return { + ...payload, + appRaw, + appConfig, + appEntries, + codexRaw, + codexEntries, + codexHomeIsFile, + childStdout: stdout, + childStderr: stderr, + }; } finally { rmSync(root, { recursive: true, force: true }); } @@ -113,6 +148,25 @@ describe("macOS first-run preparation (production filesystem paths)", () => { expect(snapshot.codexEntries).toEqual(["config.toml"]); }); + test("an ENOTDIR Codex home is present-or-unreadable, not missing", () => { + const codexSentinel = "codex-home-file-sentinel"; + const snapshot = runProductionScenario({ codexHomeFileRaw: codexSentinel }); + expect(snapshot.result).toEqual({ ok: true, changed: true, enableCodexRouting: true }); + expect(snapshot.appConfig).toEqual(snapshot.expectedDefault); + expect(snapshot.appRaw).toBe(`${JSON.stringify(snapshot.expectedDefault, null, 2)}\n`); + expect(snapshot.appEntries).toEqual([ + ".codexcommander-owner.json", + ".codexcommander-uninstall.json", + "config-mutation.sqlite", + "config.json", + ]); + expect(snapshot.codexHomeIsFile).toBe(true); + expect(snapshot.codexRaw).toBe(codexSentinel); + expect(snapshot.codexEntries).toBeNull(); + expect(snapshot.childStdout).not.toContain(codexSentinel); + expect(snapshot.childStderr).toBe(""); + }); + test("fresh app plus missing Codex persists integration off and requests setup", () => { const snapshot = runProductionScenario({}); expect(snapshot.result).toEqual({ @@ -148,7 +202,8 @@ describe("macOS first-run preparation (production filesystem paths)", () => { }); test("typed initialization refusals become a secret-free app error", () => { - const invalidAppBytes = "{\n"; + const secretSentinel = "macos-first-run-secret-sentinel"; + const invalidAppBytes = `{"secret":"${secretSentinel}"\n`; const snapshot = runProductionScenario({ appRaw: invalidAppBytes, codexRaw: codexConfigBytes }); expect(snapshot.result).toEqual({ ok: false, @@ -160,5 +215,7 @@ describe("macOS first-run preparation (production filesystem paths)", () => { expect(snapshot.appEntries).toEqual(["config.json"]); expect(snapshot.codexRaw).toBe(codexConfigBytes); expect(snapshot.codexEntries).toEqual(["config.toml"]); + expect(snapshot.childStdout).not.toContain(secretSentinel); + expect(snapshot.childStderr).toBe(""); }); }); From ead09dccfbe1dc6a3f3c4ccb16e55f96ff692e10 Mon Sep 17 00:00:00 2001 From: pavelhov Date: Thu, 20 Aug 2026 19:07:02 -0400 Subject: [PATCH 11/28] feat(lifecycle): carry macOS first-run setup state --- src/cli/proxy-lifecycle.ts | 36 ++++++-- tests/proxy-lifecycle.test.ts | 160 ++++++++++++++++++++++++++++++++++ 2 files changed, 191 insertions(+), 5 deletions(-) diff --git a/src/cli/proxy-lifecycle.ts b/src/cli/proxy-lifecycle.ts index d6c4ebaf17..e5e224b58d 100644 --- a/src/cli/proxy-lifecycle.ts +++ b/src/cli/proxy-lifecycle.ts @@ -58,6 +58,7 @@ import { } from "../server/proxy-start-lock"; import { injectSystemEnv, revertSystemEnv } from "../server/system-env"; import type { CodexCommanderConfig } from "../types"; +import type { ProxySetupRequirement, ProxyStartPreparation } from "./macos-first-run"; import { RuntimeApiError, runtimeRequest } from "./runtime-api"; export type ProxyLifecycleAction = @@ -79,6 +80,7 @@ export interface ProxyLifecycleResult { pid: number | null; port: number | null; message: string; + setupRequired?: ProxySetupRequirement; /** Additive catalog-apply fields consumed by the native companion. */ catalogUpdated?: boolean; codexRestartRequired?: boolean; @@ -96,7 +98,8 @@ export interface ProxyLifecycleResult { | "STOP_FAILED" | "SYNC_FAILED" | "ROUTING_RECOVERY_REQUIRED" - | "CODEX_RESTART_REQUIRED"; + | "CODEX_RESTART_REQUIRED" + | "CONFIGURATION_REQUIRED"; } export type ProxyStartupReadiness = "ready" | "failed" | "timeout"; @@ -150,6 +153,7 @@ export interface ExplicitProxyStartIo { } export interface EnsureProxyLifecycleIo extends ExplicitProxyStartIo { + prepareStart?: () => ProxyStartPreparation; findLive?: () => Promise; loadConfig?: () => CodexCommanderConfig; diagnoseService?: typeof diagnoseService; @@ -314,6 +318,7 @@ function lifecycleResult( live?: LiveProxy | null; message: string; errorCode?: ProxyLifecycleResult["errorCode"]; + setupRequired?: ProxySetupRequirement; catalogUpdated?: boolean; codexRestartRequired?: boolean; staleWorkerCount?: number; @@ -330,6 +335,7 @@ function lifecycleResult( pid: options.live?.pid ?? null, port: options.live?.port ?? null, message: options.message.slice(0, 240), + ...(options.setupRequired ? { setupRequired: options.setupRequired } : {}), ...(options.catalogUpdated !== undefined ? { catalogUpdated: options.catalogUpdated } : {}), ...(options.codexRestartRequired !== undefined ? { codexRestartRequired: options.codexRestartRequired } @@ -680,8 +686,19 @@ async function ensureProxyLifecycleUnderLock( const logger = options.logger ?? quietLogger; const io = options.io ?? {}; const findLive = io.findLive ?? findLiveProxy; + const startPreparation: ProxyStartPreparation = action === "start" + ? io.prepareStart?.() ?? { ok: true, changed: false, enableCodexRouting: true } + : { ok: true, changed: false, enableCodexRouting: action === "restart" }; + if (!startPreparation.ok) { + return lifecycleResult(action, "blocked", { + ok: false, + changed: startPreparation.changed, + message: startPreparation.message, + errorCode: startPreparation.errorCode, + }); + } let config = (io.loadConfig ?? loadConfig)(); - let preparedChanged = false; + let preparedChanged = startPreparation.changed; // Probe before mutating durable intent. Only this home's protected runtime // record may authorize retirement of a journal whose owner is still alive. let live = action === "start" ? await findLive() : null; @@ -693,17 +710,17 @@ async function ensureProxyLifecycleUnderLock( errorCode: "START_FAILED", }); } - if (action === "start") { + if (action === "start" && startPreparation.enableCodexRouting) { const prepared = prepareExplicitProxyStartWithIo(io, live?.pid ?? undefined); if (!prepared.success) { return lifecycleResult(action, "blocked", { ok: false, - changed: prepared.changed, + changed: preparedChanged || prepared.changed, message: prepared.message, errorCode: "START_FAILED", }); } - preparedChanged = prepared.changed; + preparedChanged ||= prepared.changed; try { config = (io.loadConfig ?? loadConfig)(); } catch { @@ -898,6 +915,15 @@ async function ensureProxyLifecycleUnderLock( ); syncProblem = catalogSyncFailure(syncResult, config); syncNotice = catalogSyncNotice(syncResult); + if (action === "start" && startPreparation.setupRequired) { + return lifecycleResult(action, "running", { + ok: true, + changed: preparedChanged || startedHere, + live, + message: "CodexCommander is running. Open Codex once, then route Codex through the proxy.", + setupRequired: startPreparation.setupRequired, + }); + } if (syncProblem) { return lifecycleResult(action, "running", { ok: false, diff --git a/tests/proxy-lifecycle.test.ts b/tests/proxy-lifecycle.test.ts index 83ae87de98..8b369fc8c2 100644 --- a/tests/proxy-lifecycle.test.ts +++ b/tests/proxy-lifecycle.test.ts @@ -85,6 +85,7 @@ function baseIo(overrides: EnsureProxyLifecycleIo = {}): EnsureProxyLifecycleIo reconcile: () => {}, journalPending: () => false, externalProvider: () => null, + setEnabled: (_client, enabled) => ({ ok: true, status: "unchanged", enabled }), acquireAuthority: async () => authority(), diagnoseService: () => service(), waitForReady: async () => "ready", @@ -191,6 +192,165 @@ describe("shared proxy lifecycle authority", () => { expect(calls.indexOf("release-E")).toBeLessThan(calls.indexOf("enabled:false")); }); + test("app preparation runs under E before config load and routing preparation", async () => { + const calls: string[] = []; + const result = await ensureProxyLifecycle({ + action: "start", + ensureCompanion: false, + io: baseIo({ + acquireAuthority: async () => { + calls.push("acquire-E"); + return authority(calls); + }, + prepareStart: () => { + calls.push("prepare-app"); + return { ok: true, changed: true, enableCodexRouting: true }; + }, + loadConfig: () => { + calls.push("load-config"); + return config(); + }, + findLive: async () => ({ pid: 42, port: 10100, source: "runtime" }), + setEnabled: (_client, enabled) => { + calls.push(`enable:${enabled}`); + return { ok: true, status: "unchanged", enabled }; + }, + }), + }); + + expect(result.ok).toBe(true); + expect(calls.indexOf("acquire-E")).toBeLessThan(calls.indexOf("prepare-app")); + expect(calls.indexOf("prepare-app")).toBeLessThan(calls.indexOf("load-config")); + expect(calls.indexOf("load-config")).toBeLessThan(calls.indexOf("enable:true")); + }); + + test("refused app preparation exits before load, probe, or spawn", async () => { + const calls: string[] = []; + const result = await ensureProxyLifecycle({ + action: "start", + io: baseIo({ + prepareStart: () => ({ + ok: false, + changed: false, + message: "CodexCommander configuration needs repair; no files were changed.", + errorCode: "CONFIGURATION_REQUIRED", + }), + loadConfig: () => { + calls.push("load"); + return config(); + }, + findLive: async () => { + calls.push("find"); + return null; + }, + spawnStart: async () => { + calls.push("spawn"); + }, + }), + }); + + expect(result).toMatchObject({ + action: "start", + ok: false, + state: "blocked", + errorCode: "CONFIGURATION_REQUIRED", + }); + expect(calls).toEqual([]); + }); + + test("fresh missing-Codex preparation starts without enabling routing", async () => { + const calls: string[] = []; + const result = await ensureProxyLifecycle({ + action: "start", + ensureCompanion: false, + io: baseIo({ + prepareStart: () => ({ + ok: true, + changed: true, + enableCodexRouting: false, + setupRequired: "codex-first-run", + }), + loadConfig: () => ({ ...config(), clientIntegrations: { codex: false } }), + setEnabled: () => { + calls.push("enable"); + return { ok: true, status: "committed", enabled: true }; + }, + findLive: async () => ({ pid: 42, port: 10100, source: "runtime" }), + syncLive: async () => ({ + status: "skipped", + skippedReason: "desired_disabled", + ok: true, + catalogQuality: "native-only", + catalogState: { state: "not_running", processes: [], catalogMtimeMs: null }, + }), + }), + }); + + expect(calls).toEqual([]); + expect(result).toMatchObject({ + action: "start", + ok: true, + state: "running", + changed: true, + setupRequired: "codex-first-run", + }); + }); + + test("a proven running proxy reports missing Codex as setup instead of generic sync failure", async () => { + const result = await ensureProxyLifecycle({ + action: "start", + io: baseIo({ + prepareStart: () => ({ + ok: true, + changed: false, + enableCodexRouting: true, + setupRequired: "codex-first-run", + }), + findLive: async () => ({ pid: 42, port: 10100, source: "runtime" }), + syncLive: async () => ({ + status: "refused", + ok: false, + message: "Codex configuration is unavailable.", + lifecycleErrorCode: "SYNC_FAILED", + }), + }), + }); + + expect(result).toMatchObject({ + ok: true, + state: "running", + pid: 42, + setupRequired: "codex-first-run", + }); + expect(result.errorCode).toBeUndefined(); + }); + + test("app preparation still preserves an external Codex provider", async () => { + const calls: string[] = []; + const result = await ensureProxyLifecycle({ + action: "start", + io: baseIo({ + prepareStart: () => ({ ok: true, changed: true, enableCodexRouting: true }), + externalProvider: () => "external-owner", + findLive: async () => ({ pid: 42, port: 10100, source: "runtime" }), + setEnabled: (_client, enabled) => { + calls.push(`enabled:${enabled}`); + return { ok: true, status: "unchanged", enabled }; + }, + syncLive: async () => ({ + status: "skipped", + skippedReason: "external_provider", + ok: true, + catalogQuality: "native-only", + catalogState: { state: "not_running", processes: [], catalogMtimeMs: null }, + }), + }), + }); + + expect(result).toMatchObject({ ok: true, state: "running" }); + expect(calls).toEqual(["enabled:true"]); + }); + test("a native escape refusal leaves service and proxy running", async () => { const calls: string[] = []; const result = await stopProxyLifecycle({ From 59e80b2f50aa4272084984d655c5f1d7fba29359 Mon Sep 17 00:00:00 2001 From: pavelhov Date: Thu, 20 Aug 2026 19:15:27 -0400 Subject: [PATCH 12/28] feat(macos): bootstrap direct app start --- src/cli/macos-lifecycle.ts | 22 ++++++++++++++++++--- tests/macos-lifecycle.test.ts | 37 ++++++++++++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/src/cli/macos-lifecycle.ts b/src/cli/macos-lifecycle.ts index bd4aac6e42..80476fd9d4 100644 --- a/src/cli/macos-lifecycle.ts +++ b/src/cli/macos-lifecycle.ts @@ -8,6 +8,7 @@ import { type ProxyLifecycleAction, type ProxyLifecycleResult, } from "./proxy-lifecycle"; +import { prepareMacOSAppStart } from "./macos-first-run"; import { APPLY_CODEX_CATALOG_ACTION, type ApplyCodexCatalogLifecycleResult, @@ -80,16 +81,31 @@ export function encodeMacOSLifecycleResult( return { frame, exitCode: emitted.ok ? 0 : 1 }; } -async function perform(action: MacOSLifecycleAction): Promise { +export interface MacOSLifecycleDeps { + ensureProxyLifecycle?: typeof ensureProxyLifecycle; + prepareMacOSAppStart?: typeof prepareMacOSAppStart; +} + +export async function performMacOSLifecycleAction( + action: MacOSLifecycleAction, + deps: MacOSLifecycleDeps = {}, +): Promise { + const ensure = deps.ensureProxyLifecycle ?? ensureProxyLifecycle; switch (action) { case "status": return proxyLifecycleStatus(); case "ensure": + return ensure({ + action, + honorAutoStart: false, + ensureCompanion: false, + }); case "start": - return ensureProxyLifecycle({ + return ensure({ action, honorAutoStart: false, ensureCompanion: false, + io: { prepareStart: deps.prepareMacOSAppStart ?? prepareMacOSAppStart }, }); case "stop": return stopProxyLifecycle(); @@ -130,7 +146,7 @@ export async function runMacOSLifecycleHelper(args: string[]): Promise { console.error = () => {}; let result: MacOSLifecycleResult; try { - result = await perform(action); + result = await performMacOSLifecycleAction(action); } catch { result = failedResult(action); } finally { diff --git a/tests/macos-lifecycle.test.ts b/tests/macos-lifecycle.test.ts index b15a2d9cab..5b05ae1472 100644 --- a/tests/macos-lifecycle.test.ts +++ b/tests/macos-lifecycle.test.ts @@ -2,12 +2,16 @@ import { describe, expect, test } from "bun:test"; import { MACOS_LIFECYCLE_JSON_MAX_BYTES, encodeMacOSLifecycleResult, + performMacOSLifecycleAction, } from "../src/cli/macos-lifecycle"; import { APPLY_CODEX_CATALOG_ACTION, type ApplyCodexCatalogLifecycleResult, } from "../src/codex/catalog-apply"; -import type { ProxyLifecycleResult } from "../src/cli/proxy-lifecycle"; +import { + ensureProxyLifecycle, + type ProxyLifecycleResult, +} from "../src/cli/proxy-lifecycle"; function success(message = "CodexCommander is running."): ProxyLifecycleResult { return { @@ -23,6 +27,37 @@ function success(message = "CodexCommander is running."): ProxyLifecycleResult { } describe("macOS lifecycle JSON frame", () => { + test("only direct native start installs first-run preparation", async () => { + const calls: string[] = []; + const ensure = async (options: Parameters[0]) => { + calls.push(`${options.action}:${options.io?.prepareStart ? "prepared" : "plain"}`); + return { ...success(), action: options.action ?? "ensure" }; + }; + await performMacOSLifecycleAction("ensure", { ensureProxyLifecycle: ensure }); + await performMacOSLifecycleAction("start", { + ensureProxyLifecycle: ensure, + prepareMacOSAppStart: () => ({ ok: true, changed: false, enableCodexRouting: true }), + }); + expect(calls).toEqual(["ensure:plain", "start:prepared"]); + }); + + test("Codex first-run setup remains a bounded zero-exit running result", () => { + const result: ProxyLifecycleResult = { + ...success("CodexCommander is running. Open Codex once, then route Codex through the proxy."), + action: "start", + setupRequired: "codex-first-run", + }; + const encoded = encodeMacOSLifecycleResult("start", result); + expect(encoded.exitCode).toBe(0); + expect(Buffer.byteLength(encoded.frame, "utf8")).toBeLessThanOrEqual(MACOS_LIFECYCLE_JSON_MAX_BYTES); + expect(JSON.parse(encoded.frame)).toMatchObject({ + action: "start", + ok: true, + state: "running", + setupRequired: "codex-first-run", + }); + }); + test("restore actions keep their structured action names", () => { for (const action of ["restore-native", "restore-back"] as const) { const result: ProxyLifecycleResult = { From 161d1c53261416ff344fdd49d4b35ed6e9db1914 Mon Sep 17 00:00:00 2001 From: pavelhov Date: Thu, 20 Aug 2026 19:25:22 -0400 Subject: [PATCH 13/28] feat(macos): decode first-run setup outcomes --- .../MenuBarCore/ActionCoordinator.swift | 15 +++++++++ app/Sources/MenuBarCore/LifecycleHelper.swift | 7 ++++ .../MenuBarCoreTests/ActionSuite.swift | 33 +++++++++++++++++++ .../LifecycleHelperSuite.swift | 20 +++++++++++ .../MenuBarUI/LifecyclePresentation.swift | 8 +++-- 5 files changed, 81 insertions(+), 2 deletions(-) diff --git a/app/Sources/MenuBarCore/ActionCoordinator.swift b/app/Sources/MenuBarCore/ActionCoordinator.swift index 3def9036a2..4878671d2e 100644 --- a/app/Sources/MenuBarCore/ActionCoordinator.swift +++ b/app/Sources/MenuBarCore/ActionCoordinator.swift @@ -5,9 +5,19 @@ public enum RestartOutcome: Equatable, Sendable { case failed(String) } +public enum ProxySetupRequirement: Equatable, Sendable { + case codexFirstRun + case unknown(String) + + init(rawValue: String) { + self = rawValue == "codex-first-run" ? .codexFirstRun : .unknown(rawValue) + } +} + public enum ProxyControlOutcome: Equatable, Sendable { case running case stopped + case setupRequired(ProxySetupRequirement) /// The proxy is healthy, but long-lived Codex workers still hold an older roster. case catalogUpdateReady(staleWorkerCount: Int?) case failed(String) @@ -98,6 +108,11 @@ public actor ActionCoordinator { guard result.ok, result.state == expected else { return .failed(result.message) } + if expected == .running, + let rawSetup = result.setupRequired, + !rawSetup.isEmpty { + return .setupRequired(ProxySetupRequirement(rawValue: rawSetup)) + } return expected == .running ? .running : .stopped } catch let error as LifecycleHelperError { return .failed(error.userMessage) diff --git a/app/Sources/MenuBarCore/LifecycleHelper.swift b/app/Sources/MenuBarCore/LifecycleHelper.swift index 567fdb7fc9..5cd8fae37d 100644 --- a/app/Sources/MenuBarCore/LifecycleHelper.swift +++ b/app/Sources/MenuBarCore/LifecycleHelper.swift @@ -30,6 +30,9 @@ public struct LifecycleCommandResult: Decodable, Equatable, Sendable { public let port: Int? public let message: String public let errorCode: String? + /// Optional setup guidance from a successful proxy start. Retain the raw string + /// so newer helper values remain forward-compatible with older app builds. + public let setupRequired: String? /// These fields are present only for the `applyCodexCatalog` action. The app receives /// counts rather than process identifiers. public let catalogUpdated: Bool? @@ -48,6 +51,7 @@ public struct LifecycleCommandResult: Decodable, Equatable, Sendable { port: Int? = nil, message: String, errorCode: String? = nil, + setupRequired: String? = nil, catalogUpdated: Bool? = nil, codexRestartRequired: Bool? = nil, staleWorkerCount: Int? = nil, @@ -63,6 +67,7 @@ public struct LifecycleCommandResult: Decodable, Equatable, Sendable { self.port = port self.message = message self.errorCode = errorCode + self.setupRequired = setupRequired self.catalogUpdated = catalogUpdated self.codexRestartRequired = codexRestartRequired self.staleWorkerCount = staleWorkerCount @@ -72,6 +77,7 @@ public struct LifecycleCommandResult: Decodable, Equatable, Sendable { private enum CodingKeys: String, CodingKey { case schemaVersion, action, ok, state, changed, pid, port, message, errorCode + case setupRequired case catalogUpdated, codexRestartRequired, staleWorkerCount case stoppedWorkerCount, survivingWorkerCount } @@ -89,6 +95,7 @@ public struct LifecycleCommandResult: Decodable, Equatable, Sendable { port = try values.decode(Int?.self, forKey: .port) message = try values.decode(String.self, forKey: .message) errorCode = try values.decodeIfPresent(String.self, forKey: .errorCode) + setupRequired = try values.decodeIfPresent(String.self, forKey: .setupRequired) if action == .applyCodexCatalog { catalogUpdated = try values.decode(Bool.self, forKey: .catalogUpdated) diff --git a/app/Sources/MenuBarCoreTests/ActionSuite.swift b/app/Sources/MenuBarCoreTests/ActionSuite.swift index e7c22deae7..325960b92e 100644 --- a/app/Sources/MenuBarCoreTests/ActionSuite.swift +++ b/app/Sources/MenuBarCoreTests/ActionSuite.swift @@ -177,6 +177,7 @@ enum ActionSuite { changed: true, pid: 41, port: 10100, message: "Restart ChatGPT to load the routed models.", errorCode: "CODEX_RESTART_REQUIRED", + setupRequired: "codex-first-run", codexRestartRequired: true, staleWorkerCount: 2 ), @@ -188,6 +189,38 @@ enum ActionSuite { ) } + t.test("lifecycle: known setup requirement becomes a typed outcome") { + let lifecycle = FakeLifecycleRunner(results: [ + LifecycleCommandResult( + action: .start, ok: true, state: .running, + changed: true, pid: 41, port: 10100, + message: "Complete Codex setup to continue.", + setupRequired: "codex-first-run" + ), + ]) + let coordinator = ActionCoordinator(lifecycle: lifecycle) + t.equal( + sync { await coordinator.start() }, + .setupRequired(.codexFirstRun) + ) + } + + t.test("lifecycle: unknown setup requirement remains forward-compatible") { + let lifecycle = FakeLifecycleRunner(results: [ + LifecycleCommandResult( + action: .start, ok: true, state: .running, + changed: false, pid: 41, port: 10100, + message: "Complete a future setup step.", + setupRequired: "future-setup" + ), + ]) + let coordinator = ActionCoordinator(lifecycle: lifecycle) + t.equal( + sync { await coordinator.start() }, + .setupRequired(.unknown("future-setup")) + ) + } + t.test("lifecycle: restart-like error codes without the additive flag remain failures") { let lifecycle = FakeLifecycleRunner(results: [ LifecycleCommandResult( diff --git a/app/Sources/MenuBarCoreTests/LifecycleHelperSuite.swift b/app/Sources/MenuBarCoreTests/LifecycleHelperSuite.swift index 03aa9b9ba9..955617a1d2 100644 --- a/app/Sources/MenuBarCoreTests/LifecycleHelperSuite.swift +++ b/app/Sources/MenuBarCoreTests/LifecycleHelperSuite.swift @@ -4,6 +4,26 @@ import MenuBarCore enum LifecycleHelperSuite { static func run(_ t: TestRunner) { + t.test("lifecycle result: setup requirement is optional and preserves unknown strings") { + let absent = try JSONDecoder().decode( + LifecycleCommandResult.self, + from: Data(#"{"schemaVersion":1,"action":"start","ok":true,"state":"running","changed":false,"pid":42,"port":10100,"message":"running"}"#.utf8) + ) + t.isNil(absent.setupRequired, "absent setup requirement") + + let known = try JSONDecoder().decode( + LifecycleCommandResult.self, + from: Data(#"{"schemaVersion":1,"action":"start","ok":true,"state":"running","changed":true,"pid":42,"port":10100,"message":"setup","setupRequired":"codex-first-run"}"#.utf8) + ) + t.equal(known.setupRequired, "codex-first-run") + + let unknown = try JSONDecoder().decode( + LifecycleCommandResult.self, + from: Data(#"{"schemaVersion":1,"action":"start","ok":true,"state":"running","changed":false,"pid":42,"port":10100,"message":"setup","setupRequired":"future-setup"}"#.utf8) + ) + t.equal(unknown.setupRequired, "future-setup") + } + t.test("lifecycle helper: renamed app resolves only its bundled runtime") { try withTemporaryDirectory { root in let bundle = root.appendingPathComponent( diff --git a/app/Sources/MenuBarUI/LifecyclePresentation.swift b/app/Sources/MenuBarUI/LifecyclePresentation.swift index 6ae9825bce..a0cf9d9e54 100644 --- a/app/Sources/MenuBarUI/LifecyclePresentation.swift +++ b/app/Sources/MenuBarUI/LifecyclePresentation.swift @@ -271,7 +271,11 @@ package enum ApplicationMenuFactory { /// A failed or ambiguous stop leaves the UI alive so the user can see and recover. package enum StopAndQuitPolicy { package static func shouldTerminate(after outcome: ProxyControlOutcome) -> Bool { - if case .stopped = outcome { return true } - return false + switch outcome { + case .stopped: + return true + case .running, .setupRequired, .catalogUpdateReady, .failed: + return false + } } } From 39fb835dc6a3cff00835f4192ac0aa1e4aaa2991 Mon Sep 17 00:00:00 2001 From: pavelhov Date: Thu, 20 Aug 2026 19:43:51 -0400 Subject: [PATCH 14/28] feat(macos): show nonfatal Codex setup guidance --- app/Sources/MenuBarUI/AppDelegate.swift | 14 +++++ .../MenuBarUI/LifecyclePresentation.swift | 18 +++++++ .../MenuBarUI/OperationStatusView.swift | 2 + .../MenuBarUI/PopoverViewController.swift | 16 ++++++ app/Sources/MenuBarUITests/main.swift | 51 +++++++++++++++++++ 5 files changed, 101 insertions(+) diff --git a/app/Sources/MenuBarUI/AppDelegate.swift b/app/Sources/MenuBarUI/AppDelegate.swift index 4e9c38e62c..49f4c23952 100644 --- a/app/Sources/MenuBarUI/AppDelegate.swift +++ b/app/Sources/MenuBarUI/AppDelegate.swift @@ -397,6 +397,10 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValid case .running: self.clearCatalogUpdate() self.companionHeartbeat?.reportNow() + case .setupRequired(let requirement): + self.clearCatalogUpdate() + self.companionHeartbeat?.reportNow() + self.controller.showSetupRequired(requirement) case .catalogUpdateReady(let count): // The proxy is running with a pending catalog refresh; report now so // a failed pre-start report is retried right after startup. @@ -432,6 +436,10 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValid self.clearCatalogUpdate() self.companionHeartbeat?.reportNow() self.controller.showResult("CodexCommander started.", isError: false) + case .setupRequired(let requirement): + self.clearCatalogUpdate() + self.companionHeartbeat?.reportNow() + self.controller.showSetupRequired(requirement) case .stopped: self.controller.showResult("CodexCommander did not start.", isError: true) case .catalogUpdateReady(let count): @@ -490,6 +498,8 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValid ) case .running: self.controller.showResult("CodexCommander is still running.", isError: true) + case .setupRequired: + self.controller.showResult("CodexCommander is still running.", isError: true) case .catalogUpdateReady(let count): self.presentCatalogUpdate(staleWorkerCount: count) self.controller.showResult("CodexCommander is still running.", isError: true) @@ -672,6 +682,10 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValid "No stale ChatGPT worker is detected. Start a new task after ChatGPT reopens.", isError: false ) + case .setupRequired(let requirement): + self.clearCatalogUpdate() + self.companionHeartbeat?.reportNow() + self.controller.showSetupRequired(requirement) case .catalogUpdateReady(let count): self.presentCatalogUpdate(staleWorkerCount: count) self.controller.showResult( diff --git a/app/Sources/MenuBarUI/LifecyclePresentation.swift b/app/Sources/MenuBarUI/LifecyclePresentation.swift index a0cf9d9e54..2594ad1ea4 100644 --- a/app/Sources/MenuBarUI/LifecyclePresentation.swift +++ b/app/Sources/MenuBarUI/LifecyclePresentation.swift @@ -65,6 +65,24 @@ package enum LifecycleResultMessage { package static let proxyStopped = "Proxy stopped. Fully quit ChatGPT and Codex if still open, then reopen them to use native routing." + package static func setupRequired(_ requirement: ProxySetupRequirement) -> ( + title: String, + detail: String + ) { + switch requirement { + case .codexFirstRun: + return ( + "Open Codex to finish setup", + "CodexCommander is running. Open Codex once, then choose Route Codex Through Proxy." + ) + case .unknown: + return ( + "CodexCommander setup is required", + "The proxy is running. Update CodexCommander for setup instructions." + ) + } + } + package static func codexRouteSaved(_ destination: CodexRouteDestination) -> ( title: String, detail: String diff --git a/app/Sources/MenuBarUI/OperationStatusView.swift b/app/Sources/MenuBarUI/OperationStatusView.swift index 83b40126d5..6542a807df 100644 --- a/app/Sources/MenuBarUI/OperationStatusView.swift +++ b/app/Sources/MenuBarUI/OperationStatusView.swift @@ -61,6 +61,7 @@ package final class OperationStatusView: NSView { private var startedAt: Date? private var destination: CodexRouteDestination? private var phase: CodexRouteOperationPhase? + package private(set) var lastRenderedTone: OperationStatusTone? var onDismiss: (() -> Void)? @@ -203,6 +204,7 @@ package final class OperationStatusView: NSView { tone: OperationStatusTone ) { stopTimer() + lastRenderedTone = tone destination = nil phase = nil spinner.stopAnimation(nil) diff --git a/app/Sources/MenuBarUI/PopoverViewController.swift b/app/Sources/MenuBarUI/PopoverViewController.swift index 8128958d96..e1c705aca8 100644 --- a/app/Sources/MenuBarUI/PopoverViewController.swift +++ b/app/Sources/MenuBarUI/PopoverViewController.swift @@ -299,6 +299,16 @@ public final class PopoverViewController: NSViewController { refreshSize() } + public func showSetupRequired(_ requirement: ProxySetupRequirement) { + let result = LifecycleResultMessage.setupRequired(requirement) + operationStatus.showResult( + title: result.title, + detail: result.detail, + tone: .warning + ) + refreshSize() + } + public func showProgress(_ text: String) { operationStatus.beginOperation(text) refreshSize() @@ -558,6 +568,12 @@ public final class PopoverViewController: NSViewController { package var hasVerticalScroller: Bool { scrollView.hasVerticalScroller } package var headerView: StatusHeaderView { header } package var operationStatusView: OperationStatusView { operationStatus } + package var operationStatusTitle: String { operationStatus.titleText } + package var operationStatusDetail: String? { operationStatus.detailText } + package var operationStatusTone: OperationStatusTone? { + operationStatus.lastRenderedTone + } + package var routeThroughProxyEnabled: Bool { routeThroughProxyButton.isEnabled } package var startupModeView: StartupModeView { startupMode } package var catalogUpdateVisible: Bool { !catalogUpdate.isHidden } package var catalogUpdateDetail: String { catalogUpdate.detailText } diff --git a/app/Sources/MenuBarUITests/main.swift b/app/Sources/MenuBarUITests/main.swift index 2f4b3577bd..6554b0f75e 100644 --- a/app/Sources/MenuBarUITests/main.swift +++ b/app/Sources/MenuBarUITests/main.swift @@ -795,6 +795,36 @@ runner.test("ui: saved route with unavailable confirmation stays a caution, not runner.equal(status.isDismissVisible, true, "caution persists") } +runner.test("ui: first-run guidance warns without disabling proxy route recovery") { + let controller = PopoverViewController() + _ = controller.view + let snapshot = makeSnapshot(health: currentHealth( + routingKind: "native", + routingInjected: false + )) + controller.apply(snapshot) + + controller.showSetupRequired(.codexFirstRun) + + runner.equal(controller.operationStatusTitle, "Open Codex to finish setup") + runner.equal( + controller.operationStatusDetail, + "CodexCommander is running. Open Codex once, then choose Route Codex Through Proxy." + ) + runner.equal(controller.operationStatusTone, .warning) + runner.expect(controller.routeThroughProxyEnabled, "route retry remains available") + runner.equal( + controller.operationStatusView.accessibilityLabel(), + "Open Codex to finish setup", + "setup warning has a concise accessible label" + ) + runner.equal( + controller.operationStatusView.accessibilityStatusValue, + "Open Codex to finish setup. CodexCommander is running. Open Codex once, then choose Route Codex Through Proxy.", + "setup warning exposes the complete recovery instruction" + ) +} + runner.test("ui: route success persists with explicit ChatGPT restart step until dismissed") { let controller = PopoverViewController() _ = controller.view @@ -1186,6 +1216,22 @@ runner.test("ui: lifecycle confirmations default to Cancel and mark stop actions ) } +runner.test("ui: setup requirements present actionable and forward-compatible guidance") { + let firstRun = LifecycleResultMessage.setupRequired(.codexFirstRun) + runner.equal(firstRun.title, "Open Codex to finish setup") + runner.equal( + firstRun.detail, + "CodexCommander is running. Open Codex once, then choose Route Codex Through Proxy." + ) + + let future = LifecycleResultMessage.setupRequired(.unknown("future-setup")) + runner.equal(future.title, "CodexCommander setup is required") + runner.equal( + future.detail, + "The proxy is running. Update CodexCommander for setup instructions." + ) +} + runner.test("ui: catalog confirmation is activity-aware and defaults to Later") { let busy = CatalogUpdateConfirmation(activity: .active(2)) let busyAlert = busy.makeAlert() @@ -1355,6 +1401,11 @@ runner.test("ui: app menu starts with destructive exit disabled and safe quit en runner.test("ui: stop-and-quit exits only after a confirmed stopped outcome") { runner.equal(StopAndQuitPolicy.shouldTerminate(after: .stopped), true) runner.equal(StopAndQuitPolicy.shouldTerminate(after: .running), false) + runner.equal( + StopAndQuitPolicy.shouldTerminate(after: .setupRequired(.codexFirstRun)), + false, + "first-run guidance never turns a failed stop into termination" + ) runner.equal( StopAndQuitPolicy.shouldTerminate(after: .failed("still running")), false From 8e24503e04b7ab5948299e8a9fae5395ea37fc9a Mon Sep 17 00:00:00 2001 From: pavelhov Date: Thu, 20 Aug 2026 20:04:09 -0400 Subject: [PATCH 15/28] feat(macos): make first-run location guidance actionable --- app/Sources/MenuBarCore/LaunchAtLogin.swift | 51 +++++-- .../MenuBarCoreTests/LaunchAtLoginSuite.swift | 64 ++++++--- app/Sources/MenuBarUI/AppDelegate.swift | 42 +++++- .../MenuBarUI/LifecyclePresentation.swift | 4 + .../MenuBarUI/PopoverViewController.swift | 16 ++- app/Sources/MenuBarUI/StartupModeView.swift | 87 +++++++---- app/Sources/MenuBarUITests/main.swift | 135 +++++++++++++++++- 7 files changed, 332 insertions(+), 67 deletions(-) diff --git a/app/Sources/MenuBarCore/LaunchAtLogin.swift b/app/Sources/MenuBarCore/LaunchAtLogin.swift index cf731a925f..122a061300 100644 --- a/app/Sources/MenuBarCore/LaunchAtLogin.swift +++ b/app/Sources/MenuBarCore/LaunchAtLogin.swift @@ -9,26 +9,39 @@ public enum LaunchAtLoginStatus: String, Equatable, Sendable { case unavailable } +public enum LaunchAtLoginRemediation: Equatable, Sendable { + case openSystemSettings + case openApplications +} + public struct LaunchAtLoginPresentation: Equatable, Sendable { public let status: LaunchAtLoginStatus public let desiredEnabled: Bool public let isToggleEnabled: Bool public let errorMessage: String? + public let relocationRequired: Bool public init( status: LaunchAtLoginStatus, desiredEnabled: Bool, isToggleEnabled: Bool, - errorMessage: String? = nil + errorMessage: String? = nil, + relocationRequired: Bool = false ) { self.status = status self.desiredEnabled = desiredEnabled self.isToggleEnabled = isToggleEnabled self.errorMessage = errorMessage + self.relocationRequired = relocationRequired } public var isOn: Bool { status == .enabled } public var needsApproval: Bool { status == .requiresApproval } + public var remediation: LaunchAtLoginRemediation? { + if relocationRequired { return .openApplications } + if needsApproval { return .openSystemSettings } + return nil + } } public enum DesktopStartupMode: String, Equatable, Sendable { @@ -161,7 +174,7 @@ public final class LaunchAtLoginController { status: .unavailable, desiredEnabled: preferences.desiredEnabled ?? true, isToggleEnabled: false, - errorMessage: "Move CodexCommander to Applications or use its repository build." + relocationRequired: true ) } if preferences.desiredEnabled == nil { @@ -211,7 +224,7 @@ public final class LaunchAtLoginController { status: .unavailable, desiredEnabled: preferences.desiredEnabled ?? false, isToggleEnabled: false, - errorMessage: "Move CodexCommander to Applications or use its repository build." + relocationRequired: true ) } let previousDesiredEnabled = preferences.desiredEnabled @@ -259,7 +272,7 @@ public final class LaunchAtLoginController { status: .unavailable, desiredEnabled: preferences.desiredEnabled ?? true, isToggleEnabled: false, - errorMessage: "Move CodexCommander to Applications or use its repository build." + relocationRequired: true ) } if service.status == .enabled, preferences.desiredEnabled == false { @@ -315,24 +328,38 @@ public enum ExecutableFingerprint { } } +public enum AppBundleLocation: Equatable, Sendable { + case stable + case relocatable + case translocated +} + public enum LaunchAtLoginEligibility { - public static func isStableBundle( + public static func classify( _ bundleURL: URL, home: URL = FileManager.default.homeDirectoryForCurrentUser - ) -> Bool { + ) -> AppBundleLocation { let bundle = bundleURL.resolvingSymlinksInPath() let path = bundle.path + if path.contains("/AppTranslocation/") { return .translocated } guard bundle.pathExtension == "app", - bundle.lastPathComponent == "CodexCommander.app", - !path.contains("/AppTranslocation/") - else { return false } + bundle.lastPathComponent == "CodexCommander.app" + else { return .relocatable } - if path.hasPrefix("/Applications/") { return true } + if path.hasPrefix("/Applications/") { return .stable } let userApplications = home.appendingPathComponent("Applications", isDirectory: true).path - if path.hasPrefix("\(userApplications)/") { return true } + if path.hasPrefix("\(userApplications)/") { return .stable } - return bundle.deletingLastPathComponent().lastPathComponent == "macos" + let sourceBuild = bundle.deletingLastPathComponent().lastPathComponent == "macos" && bundle.deletingLastPathComponent() .deletingLastPathComponent().lastPathComponent == "dist" + return sourceBuild ? .stable : .relocatable + } + + public static func isStableBundle( + _ bundleURL: URL, + home: URL = FileManager.default.homeDirectoryForCurrentUser + ) -> Bool { + classify(bundleURL, home: home) == .stable } } diff --git a/app/Sources/MenuBarCoreTests/LaunchAtLoginSuite.swift b/app/Sources/MenuBarCoreTests/LaunchAtLoginSuite.swift index da7611fb18..6e3d201dec 100644 --- a/app/Sources/MenuBarCoreTests/LaunchAtLoginSuite.swift +++ b/app/Sources/MenuBarCoreTests/LaunchAtLoginSuite.swift @@ -209,13 +209,15 @@ enum LaunchAtLoginSuite { t.equal(result.status, .requiresApproval) t.equal(result.needsApproval, true) + t.equal(result.relocationRequired, false) + t.equal(result.remediation, .openSystemSettings) t.equal(result.isToggleEnabled, false) t.equal(service.registerCalls, 0) t.equal(service.unregisterCalls, 0) t.equal(service.settingsCalls, 1) } - t.test("login: an unstable app path never creates a registration") { + t.test("login: a relocatable app path offers neutral Applications remediation") { let service = FakeLaunchAtLoginService(status: .disabled) let preferences = FakeLaunchAtLoginPreferences() let controller = LaunchAtLoginController( @@ -231,6 +233,12 @@ enum LaunchAtLoginSuite { t.equal(result.status, .unavailable) t.equal(refreshed.status, .unavailable) + t.equal(result.relocationRequired, true) + t.equal(refreshed.relocationRequired, true) + t.equal(result.remediation, .openApplications) + t.equal(refreshed.remediation, .openApplications) + t.isNil(result.errorMessage, "relocation is guidance, not an error") + t.isNil(refreshed.errorMessage, "refreshed relocation remains neutral") t.equal(result.isToggleEnabled, false) t.equal(refreshed.isToggleEnabled, false) t.equal(service.registerCalls, 0) @@ -335,56 +343,78 @@ enum LaunchAtLoginSuite { ) } - t.test("menu app: stable login paths exclude downloads and translocation") { + t.test("menu app: bundle locations distinguish stable, relocatable, and translocated copies") { let home = URL(fileURLWithPath: "/Users/example", isDirectory: true) t.equal( - LaunchAtLoginEligibility.isStableBundle( + LaunchAtLoginEligibility.classify( URL(fileURLWithPath: "/repo/dist/macos/CodexCommander.app"), home: home ), - true + .stable ) t.equal( - LaunchAtLoginEligibility.isStableBundle( + LaunchAtLoginEligibility.classify( URL(fileURLWithPath: "/Applications/CodexCommander.app"), home: home ), - true + .stable ) t.equal( - LaunchAtLoginEligibility.isStableBundle( + LaunchAtLoginEligibility.classify( URL(fileURLWithPath: "/Users/example/Applications/CodexCommander.app"), home: home ), - true + .stable ) t.equal( - LaunchAtLoginEligibility.isStableBundle( + LaunchAtLoginEligibility.classify( URL(fileURLWithPath: "/Users/example/Downloads/CodexCommander.app"), home: home ), - false + .relocatable ) t.equal( - LaunchAtLoginEligibility.isStableBundle( - URL(fileURLWithPath: "/private/var/folders/AppTranslocation/CodexCommander.app"), + LaunchAtLoginEligibility.classify( + URL(fileURLWithPath: "/private/var/folders/xx/AppTranslocation/CodexCommander.app"), home: home ), - false + .translocated ) t.equal( - LaunchAtLoginEligibility.isStableBundle( + LaunchAtLoginEligibility.classify( URL(fileURLWithPath: "/repo/dist/macos/Other.app"), home: home ), - false + .relocatable ) t.equal( - LaunchAtLoginEligibility.isStableBundle( + LaunchAtLoginEligibility.classify( URL(fileURLWithPath: "/Applications/Other.app"), home: home ), - false + .relocatable + ) + t.equal( + LaunchAtLoginEligibility.classify( + URL(fileURLWithPath: "/repo/build/macos/CodexCommander.app"), + home: home + ), + .relocatable + ) + t.equal( + LaunchAtLoginEligibility.classify( + URL(fileURLWithPath: "/repo/dist/release/CodexCommander.app"), + home: home + ), + .relocatable + ) + t.equal( + LaunchAtLoginEligibility.isStableBundle( + URL(fileURLWithPath: "/Users/example/Downloads/CodexCommander.app"), + home: home + ), + false, + "compatibility wrapper remains stable-only" ) } diff --git a/app/Sources/MenuBarUI/AppDelegate.swift b/app/Sources/MenuBarUI/AppDelegate.swift index 49f4c23952..ba52b28367 100644 --- a/app/Sources/MenuBarUI/AppDelegate.swift +++ b/app/Sources/MenuBarUI/AppDelegate.swift @@ -23,14 +23,27 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValid private var catalogUpdateReady = false private var companionHeartbeat: CompanionHeartbeat? private let launchAtLoginController = LaunchAtLoginController() + private let appBundleLocation: AppBundleLocation private lazy var executableFingerprint = ExecutableFingerprint.current() private lazy var sourceRevision = BuildProvenance.shortRevision( Bundle.main.object(forInfoDictionaryKey: "CodexCommanderSourceRevision") ) private lazy var launchAtLoginRegistrationAllowed = - LaunchAtLoginEligibility.isStableBundle(Bundle.main.bundleURL) + appBundleLocation == .stable - public override init() { super.init() } + public override init() { + appBundleLocation = LaunchAtLoginEligibility.classify(Bundle.main.bundleURL) + super.init() + } + + package init( + appBundleLocation: AppBundleLocation, + actions: ActionCoordinator? + ) { + self.appBundleLocation = appBundleLocation + self.actions = actions + super.init() + } public func applicationDidFinishLaunching(_ notification: Notification) { installApplicationMenu() @@ -136,8 +149,8 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValid controller.onLaunchAtLoginChange = { [weak self] enabled in self?.setLaunchAtLogin(enabled) } - controller.onOpenLoginSettings = { [weak self] in - self?.launchAtLoginController.openSystemSettings() + controller.onLaunchAtLoginRemediation = { [weak self] remediation in + self?.performLaunchAtLoginRemediation(remediation) } controller.onManageProvider = { [weak self] provider in self?.openProvider(provider) @@ -349,6 +362,15 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValid } } + private func performLaunchAtLoginRemediation(_ remediation: LaunchAtLoginRemediation) { + switch remediation { + case .openSystemSettings: + launchAtLoginController.openSystemSettings() + case .openApplications: + NSWorkspace.shared.open(URL(fileURLWithPath: "/Applications", isDirectory: true)) + } + } + private func installApplicationMenu() { NSApp.mainMenu = ApplicationMenuFactory.make( target: self, @@ -381,6 +403,10 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValid /// Manual and Launch-at-Login openings share the explicit Start contract. A failed /// start leaves the menu app alive so its diagnostics and Start control remain usable. private func startProxyOnLaunch() { + guard appBundleLocation != .translocated else { + controller.showAppTranslocated() + return + } guard !lifecycleInFlight, !restartInFlight, !catalogActionInFlight else { return } lifecycleInFlight = true updateApplicationMenu() @@ -418,6 +444,10 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValid } private func startProxy() { + guard appBundleLocation != .translocated else { + controller.showAppTranslocated() + return + } guard !lifecycleInFlight, !restartInFlight, !catalogActionInFlight else { return } lifecycleInFlight = true updateApplicationMenu() @@ -748,6 +778,10 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValid } return choice } + + package var presentationControllerForTesting: PopoverViewController { controller } + package func startProxyOnLaunchForTesting() { startProxyOnLaunch() } + package func startProxyForTesting() { startProxy() } } /// Best-effort, non-blocking reporter of the native app's launch-at-login state. diff --git a/app/Sources/MenuBarUI/LifecyclePresentation.swift b/app/Sources/MenuBarUI/LifecyclePresentation.swift index 2594ad1ea4..43c944dd38 100644 --- a/app/Sources/MenuBarUI/LifecyclePresentation.swift +++ b/app/Sources/MenuBarUI/LifecyclePresentation.swift @@ -64,6 +64,10 @@ package enum LifecycleConfirmation { package enum LifecycleResultMessage { package static let proxyStopped = "Proxy stopped. Fully quit ChatGPT and Codex if still open, then reopen them to use native routing." + package static let appTranslocated = ( + title: "Move CodexCommander to Applications", + detail: "This temporary macOS launch location cannot safely run the background proxy. Move the app, then reopen it." + ) package static func setupRequired(_ requirement: ProxySetupRequirement) -> ( title: String, diff --git a/app/Sources/MenuBarUI/PopoverViewController.swift b/app/Sources/MenuBarUI/PopoverViewController.swift index e1c705aca8..abaff85e31 100644 --- a/app/Sources/MenuBarUI/PopoverViewController.swift +++ b/app/Sources/MenuBarUI/PopoverViewController.swift @@ -57,7 +57,7 @@ public final class PopoverViewController: NSViewController { public var onQuitMenuBar: (() -> Void)? public var onStopAndQuit: (() -> Void)? public var onLaunchAtLoginChange: ((Bool) -> Void)? - public var onOpenLoginSettings: (() -> Void)? + public var onLaunchAtLoginRemediation: ((LaunchAtLoginRemediation) -> Void)? public var onManageProvider: ((String) -> Void)? public var onViewAllProviders: (() -> Void)? @@ -83,8 +83,8 @@ public final class PopoverViewController: NSViewController { startupMode.onToggle = { [weak self] enabled in self?.onLaunchAtLoginChange?(enabled) } - startupMode.onOpenSettings = { [weak self] in - self?.onOpenLoginSettings?() + startupMode.onRemediation = { [weak self] remediation in + self?.onLaunchAtLoginRemediation?(remediation) } catalogUpdate.onApply = { [weak self] in self?.onApplyCodexCatalog?() @@ -309,6 +309,16 @@ public final class PopoverViewController: NSViewController { refreshSize() } + public func showAppTranslocated() { + let result = LifecycleResultMessage.appTranslocated + operationStatus.showResult( + title: result.title, + detail: result.detail, + tone: .warning + ) + refreshSize() + } + public func showProgress(_ text: String) { operationStatus.beginOperation(text) refreshSize() diff --git a/app/Sources/MenuBarUI/StartupModeView.swift b/app/Sources/MenuBarUI/StartupModeView.swift index 5958eb12e7..0148b9909d 100644 --- a/app/Sources/MenuBarUI/StartupModeView.swift +++ b/app/Sources/MenuBarUI/StartupModeView.swift @@ -15,11 +15,12 @@ public final class StartupModeView: NSView { return field }() private let toggle = NSSwitch() - private let settingsButton = NSButton() + private let remediationButton = NSButton() private var applying = false + private var remediation: LaunchAtLoginRemediation? public var onToggle: ((Bool) -> Void)? - public var onOpenSettings: (() -> Void)? + public var onRemediation: ((LaunchAtLoginRemediation) -> Void)? public override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -29,28 +30,22 @@ public final class StartupModeView: NSView { toggle.action = #selector(toggleChanged) toggle.setAccessibilityLabel("Launch CodexCommander at login") - settingsButton.title = "Open Settings" - settingsButton.image = NSImage( - systemSymbolName: "gearshape", - accessibilityDescription: "Open Login Items settings" - ) - settingsButton.imagePosition = .imageLeading - settingsButton.bezelStyle = .recessed - settingsButton.isBordered = false - settingsButton.controlSize = .small - settingsButton.font = Theme.caption - settingsButton.contentTintColor = Theme.text - settingsButton.target = self - settingsButton.action = #selector(openSettings) - settingsButton.setAccessibilityLabel("Open Login Items settings") - settingsButton.isHidden = true + remediationButton.imagePosition = .imageLeading + remediationButton.bezelStyle = .recessed + remediationButton.isBordered = false + remediationButton.controlSize = .small + remediationButton.font = Theme.caption + remediationButton.contentTintColor = Theme.text + remediationButton.target = self + remediationButton.action = #selector(activateRemediation) + remediationButton.isHidden = true let labels = NSStackView(views: [title, detail]) labels.orientation = .vertical labels.alignment = .leading labels.spacing = 1 - let row = NSStackView(views: [labels, NSView(), settingsButton, toggle]) + let row = NSStackView(views: [labels, NSView(), remediationButton, toggle]) row.orientation = .horizontal row.alignment = .centerY row.spacing = Theme.rowGap @@ -78,28 +73,72 @@ public final class StartupModeView: NSView { toggle.state = presentation.isOn ? .on : .off toggle.isEnabled = presentation.isToggleEnabled toggle.alphaValue = toggle.isEnabled ? 1 : 0.5 - settingsButton.isHidden = !presentation.needsApproval + remediation = presentation.remediation + applyRemediation(presentation.remediation) let summary = DesktopStartupMode.summary( loginStatus: presentation.status, serviceManaged: serviceManaged ) - detail.stringValue = presentation.errorMessage ?? summary - detail.textColor = presentation.errorMessage == nil ? Theme.faint : Theme.red - setAccessibilityLabel("CodexCommander startup mode, \(summary)") + if presentation.relocationRequired { + detail.stringValue = "Move CodexCommander to Applications to launch at login." + detail.textColor = Theme.faint + } else { + detail.stringValue = presentation.errorMessage ?? summary + detail.textColor = presentation.errorMessage == nil ? Theme.faint : Theme.red + } + setAccessibilityLabel("CodexCommander startup mode, \(detail.stringValue)") applying = false } + private func applyRemediation(_ remediation: LaunchAtLoginRemediation?) { + remediationButton.isHidden = remediation == nil + switch remediation { + case .openSystemSettings: + remediationButton.title = "Open Settings" + remediationButton.toolTip = nil + remediationButton.image = NSImage( + systemSymbolName: "gearshape", + accessibilityDescription: "Open Login Items settings" + ) + remediationButton.setAccessibilityLabel("Open Login Items settings") + case .openApplications: + remediationButton.title = "Open Applications" + remediationButton.toolTip = + "Quit CodexCommander before moving the app, then reopen it from Applications." + remediationButton.image = NSImage( + systemSymbolName: "folder", + accessibilityDescription: "Open Applications folder" + ) + remediationButton.setAccessibilityLabel("Open Applications folder") + case nil: + remediationButton.title = "" + remediationButton.toolTip = nil + remediationButton.image = nil + remediationButton.setAccessibilityLabel(nil) + } + } + @objc private func toggleChanged() { guard !applying, toggle.isEnabled else { return } onToggle?(toggle.state == .on) } - @objc private func openSettings() { onOpenSettings?() } + @objc private func activateRemediation() { + guard let remediation else { return } + onRemediation?(remediation) + } package var modeText: String { detail.stringValue } + package var modeTextColor: NSColor? { detail.textColor } package var isLaunchAtLoginOn: Bool { toggle.state == .on } package var isLaunchAtLoginToggleEnabled: Bool { toggle.isEnabled } - package var showsSettingsButton: Bool { !settingsButton.isHidden } + package var showsRemediationButton: Bool { !remediationButton.isHidden } + package var remediationButtonTitle: String { remediationButton.title } + package var remediationButtonAccessibilityLabel: String? { + remediationButton.accessibilityLabel() + } + package var remediationButtonToolTip: String? { remediationButton.toolTip } package func activateLaunchAtLoginToggleForTesting() { toggle.performClick(nil) } + package func activateRemediationForTesting() { remediationButton.performClick(nil) } } diff --git a/app/Sources/MenuBarUITests/main.swift b/app/Sources/MenuBarUITests/main.swift index 6554b0f75e..f5f991ffba 100644 --- a/app/Sources/MenuBarUITests/main.swift +++ b/app/Sources/MenuBarUITests/main.swift @@ -15,6 +15,28 @@ final class ApplicationMenuTarget: NSObject { @objc func stopCodexCommanderAndQuit(_ sender: Any?) {} } +final class RecordingLifecycleRunner: @unchecked Sendable, LifecycleCommandRunning { + private let queue = DispatchQueue(label: "menu-bar-ui-tests.lifecycle-recorder") + private var actions: [LifecycleAction] = [] + + func run(_ action: LifecycleAction) async throws -> LifecycleCommandResult { + queue.sync { actions.append(action) } + return LifecycleCommandResult( + action: action, + ok: true, + state: .running, + changed: true, + pid: 41, + port: 10100, + message: "running" + ) + } + + var recordedActions: [LifecycleAction] { + queue.sync { actions } + } +} + func quotaJSON( provider: String, label: String, @@ -291,7 +313,7 @@ runner.test("ui: startup control exposes desktop, headless, off, and approval st ) runner.equal(controller.startupModeView.isLaunchAtLoginToggleEnabled, false) runner.expect( - controller.startupModeView.showsSettingsButton, + controller.startupModeView.showsRemediationButton, "approval state should expose Login Items settings" ) } @@ -332,11 +354,57 @@ runner.test("ui: unavailable startup switch ignores AppKit clicks") { runner.isNil(requested, "a disabled NSSwitch must not dispatch its action") } -runner.test("ui: approval disables startup switch and forwards settings") { +runner.test("ui: relocation guidance is neutral and opens Applications on explicit action") { let controller = PopoverViewController() _ = controller.view - var openedSettings = false - controller.onOpenLoginSettings = { openedSettings = true } + var remediation: LaunchAtLoginRemediation? + controller.onLaunchAtLoginRemediation = { remediation = $0 } + + controller.applyLaunchAtLogin( + LaunchAtLoginPresentation( + status: .unavailable, + desiredEnabled: true, + isToggleEnabled: false, + errorMessage: "comparison error" + ) + ) + let errorColor = controller.startupModeView.modeTextColor + + controller.applyLaunchAtLogin( + LaunchAtLoginPresentation( + status: .unavailable, + desiredEnabled: true, + isToggleEnabled: false, + relocationRequired: true + ) + ) + + runner.equal( + controller.startupModeView.modeText, + "Move CodexCommander to Applications to launch at login." + ) + runner.expect( + controller.startupModeView.modeTextColor != errorColor, + "relocation detail must use the neutral faint tone, not the error tone" + ) + runner.equal(controller.startupModeView.remediationButtonTitle, "Open Applications") + runner.equal( + controller.startupModeView.remediationButtonAccessibilityLabel, + "Open Applications folder" + ) + runner.equal( + controller.startupModeView.remediationButtonToolTip, + "Quit CodexCommander before moving the app, then reopen it from Applications." + ) + controller.startupModeView.activateRemediationForTesting() + runner.equal(remediation, .openApplications) +} + +runner.test("ui: approval disables startup switch and forwards System Settings remediation") { + let controller = PopoverViewController() + _ = controller.view + var remediation: LaunchAtLoginRemediation? + controller.onLaunchAtLoginRemediation = { remediation = $0 } controller.applyLaunchAtLogin( LaunchAtLoginPresentation( status: .requiresApproval, @@ -346,9 +414,14 @@ runner.test("ui: approval disables startup switch and forwards settings") { ) runner.equal(controller.startupModeView.isLaunchAtLoginToggleEnabled, false) - runner.equal(controller.startupModeView.showsSettingsButton, true) - controller.startupModeView.onOpenSettings?() - runner.equal(openedSettings, true) + runner.equal(controller.startupModeView.showsRemediationButton, true) + runner.equal(controller.startupModeView.remediationButtonTitle, "Open Settings") + runner.equal( + controller.startupModeView.remediationButtonAccessibilityLabel, + "Open Login Items settings" + ) + controller.startupModeView.activateRemediationForTesting() + runner.equal(remediation, .openSystemSettings) } runner.test("ui: running snapshot with recommended guidance offers Startup options, not a raw command") { @@ -1412,6 +1485,54 @@ runner.test("ui: stop-and-quit exits only after a confirmed stopped outcome") { ) } +runner.test("ui: translocated automatic launch shows move guidance without starting lifecycle") { + let lifecycle = RecordingLifecycleRunner() + let delegate = AppDelegate( + appBundleLocation: .translocated, + actions: ActionCoordinator(lifecycle: lifecycle) + ) + + delegate.startProxyOnLaunchForTesting() + spinMainRunLoop(seconds: 0.05) + + runner.equal(lifecycle.recordedActions, [], "randomized bundle path must never reach Start") + runner.equal( + delegate.presentationControllerForTesting.operationStatusTitle, + "Move CodexCommander to Applications" + ) + runner.equal( + delegate.presentationControllerForTesting.operationStatusDetail, + "This temporary macOS launch location cannot safely run the background proxy. Move the app, then reopen it." + ) +} + +runner.test("ui: translocated manual Start remains blocked") { + let lifecycle = RecordingLifecycleRunner() + let delegate = AppDelegate( + appBundleLocation: .translocated, + actions: ActionCoordinator(lifecycle: lifecycle) + ) + + delegate.startProxyForTesting() + spinMainRunLoop(seconds: 0.05) + + runner.equal(lifecycle.recordedActions, [], "manual Start must not escape translocation blocking") + runner.equal(delegate.presentationControllerForTesting.operationStatusTone, .warning) +} + +runner.test("ui: relocatable automatic launch continues through Start for this session") { + let lifecycle = RecordingLifecycleRunner() + let delegate = AppDelegate( + appBundleLocation: .relocatable, + actions: ActionCoordinator(lifecycle: lifecycle) + ) + + delegate.startProxyOnLaunchForTesting() + spinMainRunLoop(seconds: 0.10) + + runner.equal(lifecycle.recordedActions, [.start]) +} + // MARK: - Resource honesty runner.test("ui: provider icon loader returns real SVG-backed images for known providers") { From edc0656f2a19effb2754cedd7a84481f3ae28ab2 Mon Sep 17 00:00:00 2001 From: pavelhov Date: Thu, 20 Aug 2026 20:11:37 -0400 Subject: [PATCH 16/28] fix(macos): narrow app translocation detection --- app/Sources/MenuBarCore/LaunchAtLogin.swift | 8 +++-- .../MenuBarCoreTests/LaunchAtLoginSuite.swift | 34 +++++++++++++++++++ app/Sources/MenuBarUITests/main.swift | 23 ++++++++++++- 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/app/Sources/MenuBarCore/LaunchAtLogin.swift b/app/Sources/MenuBarCore/LaunchAtLogin.swift index 122a061300..09513afa55 100644 --- a/app/Sources/MenuBarCore/LaunchAtLogin.swift +++ b/app/Sources/MenuBarCore/LaunchAtLogin.swift @@ -341,13 +341,17 @@ public enum LaunchAtLoginEligibility { ) -> AppBundleLocation { let bundle = bundleURL.resolvingSymlinksInPath() let path = bundle.path - if path.contains("/AppTranslocation/") { return .translocated } + if path.hasPrefix("/private/var/folders/"), + path.contains("/AppTranslocation/") { + return .translocated + } guard bundle.pathExtension == "app", bundle.lastPathComponent == "CodexCommander.app" else { return .relocatable } if path.hasPrefix("/Applications/") { return .stable } - let userApplications = home.appendingPathComponent("Applications", isDirectory: true).path + let userApplications = home.resolvingSymlinksInPath() + .appendingPathComponent("Applications", isDirectory: true).path if path.hasPrefix("\(userApplications)/") { return .stable } let sourceBuild = bundle.deletingLastPathComponent().lastPathComponent == "macos" diff --git a/app/Sources/MenuBarCoreTests/LaunchAtLoginSuite.swift b/app/Sources/MenuBarCoreTests/LaunchAtLoginSuite.swift index 6e3d201dec..c9bfead4b8 100644 --- a/app/Sources/MenuBarCoreTests/LaunchAtLoginSuite.swift +++ b/app/Sources/MenuBarCoreTests/LaunchAtLoginSuite.swift @@ -373,6 +373,14 @@ enum LaunchAtLoginSuite { ), .relocatable ) + t.equal( + LaunchAtLoginEligibility.classify( + URL(fileURLWithPath: "/Users/example/Downloads/AppTranslocation/CodexCommander.app"), + home: home + ), + .relocatable, + "an ordinary folder-name collision is not macOS App Translocation" + ) t.equal( LaunchAtLoginEligibility.classify( URL(fileURLWithPath: "/private/var/folders/xx/AppTranslocation/CodexCommander.app"), @@ -418,6 +426,32 @@ enum LaunchAtLoginSuite { ) } + t.test("menu app: symlinked home keeps its Applications bundle stable") { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "CodexCommander-Home-\(UUID().uuidString)", + isDirectory: true + ) + let physicalHome = root.appendingPathComponent("physical-home", isDirectory: true) + let linkedHome = root.appendingPathComponent("linked-home", isDirectory: true) + let bundle = linkedHome + .appendingPathComponent("Applications", isDirectory: true) + .appendingPathComponent("CodexCommander.app", isDirectory: true) + try FileManager.default.createDirectory( + at: physicalHome.appendingPathComponent("Applications/CodexCommander.app"), + withIntermediateDirectories: true + ) + try FileManager.default.createSymbolicLink( + at: linkedHome, + withDestinationURL: physicalHome + ) + defer { try? FileManager.default.removeItem(at: root) } + + t.equal( + LaunchAtLoginEligibility.classify(bundle, home: linkedHome), + .stable + ) + } + t.test("menu app: process lock admits exactly one owner") { let root = FileManager.default.temporaryDirectory.appendingPathComponent( "CodexCommander-Instance-\(UUID().uuidString)", diff --git a/app/Sources/MenuBarUITests/main.swift b/app/Sources/MenuBarUITests/main.swift index f5f991ffba..ea1185d536 100644 --- a/app/Sources/MenuBarUITests/main.swift +++ b/app/Sources/MenuBarUITests/main.swift @@ -1487,8 +1487,12 @@ runner.test("ui: stop-and-quit exits only after a confirmed stopped outcome") { runner.test("ui: translocated automatic launch shows move guidance without starting lifecycle") { let lifecycle = RecordingLifecycleRunner() + let location = LaunchAtLoginEligibility.classify( + URL(fileURLWithPath: "/private/var/folders/xx/AppTranslocation/CodexCommander.app") + ) + runner.equal(location, .translocated) let delegate = AppDelegate( - appBundleLocation: .translocated, + appBundleLocation: location, actions: ActionCoordinator(lifecycle: lifecycle) ) @@ -1533,6 +1537,23 @@ runner.test("ui: relocatable automatic launch continues through Start for this s runner.equal(lifecycle.recordedActions, [.start]) } +runner.test("ui: ordinary AppTranslocation folder-name collision still starts this session") { + let lifecycle = RecordingLifecycleRunner() + let location = LaunchAtLoginEligibility.classify( + URL(fileURLWithPath: "/Users/example/Downloads/AppTranslocation/CodexCommander.app") + ) + runner.equal(location, .relocatable) + let delegate = AppDelegate( + appBundleLocation: location, + actions: ActionCoordinator(lifecycle: lifecycle) + ) + + delegate.startProxyOnLaunchForTesting() + spinMainRunLoop(seconds: 0.10) + + runner.equal(lifecycle.recordedActions, [.start]) +} + // MARK: - Resource honesty runner.test("ui: provider icon loader returns real SVG-backed images for known providers") { From ed6d0b8b0944caf667b2683b53843fc25c13de58 Mon Sep 17 00:00:00 2001 From: pavelhov Date: Thu, 20 Aug 2026 20:25:07 -0400 Subject: [PATCH 17/28] docs: explain zero-click macOS first run --- README.md | 22 +++++++++++++ .../docs/getting-started/installation.md | 31 +++++++++++++++++- .../docs/getting-started/quickstart.md | 15 +++++++++ .../src/content/docs/guides/macos-menu-bar.md | 32 +++++++++++++++++++ .../2026-08-20-macos-zero-click-first-run.md | 2 +- structure/01_runtime.md | 24 ++++++++++++++ structure/02_config-and-codex-home.md | 30 +++++++++++++++++ 7 files changed, 154 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4493e3170f..4a24ae0d1e 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,22 @@ This preview requires macOS 13 or later. It is ad-hoc signed and not notarized y 3. If macOS still blocks it, open **System Settings → Privacy & Security** and choose **Open Anyway**. Do not disable Gatekeeper. See [Apple's instructions](https://support.apple.com/guide/mac-help/open-a-mac-app-from-an-unknown-developer-mh40616/mac). +On a fresh Mac, a direct app launch creates CodexCommander's secret-free ChatGPT passthrough default +automatically. If Codex has not created `~/.codex/config.toml` yet, the proxy and dashboard still start +while Codex remains native; open Codex once, then choose **Route Codex Through Proxy** from the menu. +The app never creates Codex configuration automatically. Existing valid, invalid, unreadable, or +unsafe CodexCommander configuration is preserved and is never overwritten; repair an invalid or +inaccessible configuration before trying again. Providers, API keys, and OAuth accounts are not copied +from another Mac. Public distribution uses the universal release archive above, not the thin +development `.app` produced by a source checkout. + +Applications and `~/Applications` support **Launch at Login**. A copy launched from Desktop or +Downloads is allowed to run for the current session, but the app shows neutral guidance to move it to +Applications for login startup. Quit CodexCommander before moving a running app, then reopen it from +its new location; the app never moves itself. If macOS launches the app through App Translocation, +**Start** is blocked before the proxy launches: move the app and reopen it. These location rules do not +change the ad-hoc Gatekeeper steps above. +

CodexCommander macOS menu bar companion showing a confirmed Codex route, a live request, provider quotas, and proxy controls

@@ -150,6 +166,12 @@ until an explicit `ccx start`. Headless commands like `ccx provider add` and `cc the **live** proxy and exit nonzero when it is unreachable. `ccx status` / `ccx doctor` / `ccx health` report the running state. +The source/headless path requires `ccx init` (or the equivalent `bun run src/cli/index.ts init`) to +create CodexCommander's configuration before ordinary CLI starts. Ordinary CLI startup does not own +the macOS app's automatic bootstrap and refuses a missing configuration; it never creates a Codex +config or a hand-written JSON substitute. Existing Codex configuration, including an external provider, +is left untouched unless you explicitly choose a CodexCommander route. + ## Supported platforms | OS | Status | Service manager | diff --git a/docs-site/src/content/docs/getting-started/installation.md b/docs-site/src/content/docs/getting-started/installation.md index 4e6e779123..4e7b96a9e8 100644 --- a/docs-site/src/content/docs/getting-started/installation.md +++ b/docs-site/src/content/docs/getting-started/installation.md @@ -31,6 +31,35 @@ terminal: bun run src/cli/index.ts --version ``` +This is the source/headless installation path. Run `ccx init` (or +`bun run src/cli/index.ts init`) before an ordinary CLI start so CodexCommander has a valid +`$CODEXCOMMANDER_HOME/config.json`: + +```bash +ccx init +ccx start +``` + +The CLI does not create a missing configuration implicitly and does not create a Codex config by +writing JSON or TOML for you. If its configuration is missing, invalid, unreadable, or unsafe, repair +that state and retry; existing bytes are preserved. Codex's own configuration and any external Codex +provider remain untouched unless you explicitly choose a CodexCommander route. + +## Packaged macOS app + +The direct **Start** action in the packaged macOS companion is the one app-only exception: on a fresh +Mac it creates the canonical secret-free ChatGPT passthrough default without a setup wizard. It never +copies providers, API keys, or OAuth accounts from another Mac. If Codex has not created +`~/.codex/config.toml` yet, the proxy and dashboard still start while Codex stays native. Open Codex +once, then choose **Route Codex Through Proxy** from the menu. The app never creates Codex's config +automatically. + +The app initializer is create-only/no-clobber. An existing valid, invalid, unreadable, or unsafe +CodexCommander config is never overwritten; invalid or inaccessible state must be repaired before the +app can start. A user-managed external Codex provider is also preserved. For release installation, +use the universal Intel + Apple silicon archive from [GitHub Releases](https://github.com/pavelhov/CodexCommander/releases), +not a thin development `.app` from a source checkout. + ## Development mode Use separate proxy and dashboard processes while editing the UI: @@ -47,7 +76,7 @@ bun run dev:gui # another terminal On macOS, build the companion from this same checkout with `bun run test:macos && bun run build:macos`. Its source-build location is `dist/macos/CodexCommander.app`; do not copy that development build into Application Support. See [macOS Menu Bar Companion](/guides/macos-menu-bar/) for lifecycle -behavior, Launch at Login, Desktop/Headless/Off modes, and source-build operation. +behavior, Launch at Login, Desktop/Headless/Off modes, app-location rules, and source-build operation. ## What gets created diff --git a/docs-site/src/content/docs/getting-started/quickstart.md b/docs-site/src/content/docs/getting-started/quickstart.md index c276fa9009..1b8b83797b 100644 --- a/docs-site/src/content/docs/getting-started/quickstart.md +++ b/docs-site/src/content/docs/getting-started/quickstart.md @@ -5,6 +5,11 @@ description: Configure your first provider and route OpenAI Codex through CodexC This guide takes you from a fresh install to running Codex against a non-OpenAI model. +This page documents the source/headless CLI path. A packaged macOS app has a separate app-only +bootstrap: direct **Start** creates the secret-free ChatGPT passthrough default on a fresh install, +without running `ccx init`. It is the only path that creates the CodexCommander config automatically; +the CLI requires the setup wizard below and never creates Codex's config for you. + ## 1. Run the setup wizard ```bash @@ -25,6 +30,11 @@ ccx init The result is saved to `$CODEXCOMMANDER_HOME/config.json` (default `~/.codexcommander/config.json`). +If the CodexCommander config is missing, invalid, unreadable, or unsafe, repair it before retrying; +the CLI refuses to start and existing bytes are not replaced. Do not work around this by hand-writing +JSON or TOML. Codex's own config is never created automatically, and an external Codex provider is +left untouched. + :::note[GPT-5.6 rollout entries] The current source tree seeds GPT-5.6 Sol/Terra/Luna for ChatGPT passthrough, OpenAI API-key, OpenRouter, and @@ -50,6 +60,11 @@ On start, CodexCommander: If the requested port is busy, `ccx start` selects a free port, records it in `runtime-port.json`, and updates Codex to use the live listener. +When the packaged macOS app starts before Codex has created `~/.codex/config.toml`, it still starts +the proxy and dashboard but leaves Codex native. Open Codex once, return to the menu bar companion, +and choose **Route Codex Through Proxy**. Existing CodexCommander and Codex configuration remains +untouched unless you explicitly choose a route. + Check it: ```bash 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 551823d873..c600f37455 100644 --- a/docs-site/src/content/docs/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/guides/macos-menu-bar.md @@ -19,6 +19,26 @@ then Control-click the app and choose **Open** on first launch. If macOS still b To build from source instead, follow [Build from source](#build-from-source) below. +## First run and app location + +Direct **Start** in the packaged app owns the macOS first-run bootstrap. On a fresh Mac it creates +CodexCommander's canonical secret-free ChatGPT passthrough default without copying providers, API keys, +or OAuth accounts from another Mac. The initializer is create-only/no-clobber: an existing valid, +invalid, unreadable, or unsafe CodexCommander config is never overwritten, so repair an invalid or +inaccessible config before trying again. The app never creates `~/.codex/config.toml` (or any other +Codex config) automatically, and an external user-managed Codex provider remains untouched. + +If Codex has not created `~/.codex/config.toml` yet, the proxy and dashboard still start while Codex +remains native. Open Codex once, then return to the companion and choose **Route Codex Through Proxy**. +The warning is nonfatal: the proxy stays running and the route button remains available. + +An app in `/Applications` or `~/Applications` is eligible for **Launch at Login**. A physical copy in +Desktop or Downloads is allowed to run for the current session, but the startup row presents neutral +guidance to move it to Applications for login startup. Quit CodexCommander before moving a running app, +then reopen it from the new location; CodexCommander never moves the app itself. True macOS App +Translocation is different: **Start** is blocked before proxy launch and the companion tells you to move +the app and reopen it. The ad-hoc Gatekeeper steps above remain unchanged. + ## Startup modes The panel has one **Launch at Login** switch and reports the resulting mode: @@ -239,10 +259,22 @@ Each build stamps its exact Git revision into `CodexCommanderSourceRevision` in and prints it at the end of the build. Uncommitted source is marked with `-dirty`, so commit before making a final distributable bundle. +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 +supported `dist/macos` location may run the same session-start behavior; copies elsewhere remain +relocatable and are not moved automatically. + ## Troubleshooting - **Proxy unavailable** — use **Start Proxy** in the bundled app. Source builds can also use ccx start or install the background service with ccx service install. +- **Open Codex to finish setup** — the proxy is running while Codex remains native because Codex had + not created its config yet. Open Codex once, return to the companion, and choose **Route Codex Through + Proxy**. +- **Move CodexCommander to Applications** — a Desktop/Downloads copy can run for this session but is + not eligible for login startup. Quit CodexCommander before moving it, move it yourself, and reopen it. +- **Start is blocked after a temporary macOS launch** — App Translocation is active. Move the app out of + the translocated location and reopen it; CodexCommander never moves it automatically. - **Menu icon missing after login** — open the app, check its **Launch at Login** row, and follow the **Open Settings** action if macOS reports that approval is required. - **Authentication unavailable** — run ccx doctor; verify that the CodexCommander state diff --git a/docs/superpowers/plans/2026-08-20-macos-zero-click-first-run.md b/docs/superpowers/plans/2026-08-20-macos-zero-click-first-run.md index da073c09f8..f7e459885c 100644 --- a/docs/superpowers/plans/2026-08-20-macos-zero-click-first-run.md +++ b/docs/superpowers/plans/2026-08-20-macos-zero-click-first-run.md @@ -1101,7 +1101,7 @@ t.equal( ) t.equal( LaunchAtLoginEligibility.classify( - URL(fileURLWithPath: "/Users/example/Downloads/CodexCommander.app"), + URL(fileURLWithPath: "/tmp/codexcommander-fixture-home/Downloads/CodexCommander.app"), home: home ), .relocatable diff --git a/structure/01_runtime.md b/structure/01_runtime.md index 5d02893a08..a1df7df626 100644 --- a/structure/01_runtime.md +++ b/structure/01_runtime.md @@ -57,6 +57,30 @@ restore and verify native routing before terminating anything. In this document, restoring native means removing CodexCommander-owned routing; an external user-managed Codex provider is preserved. +Direct packaged macOS **Start** is the only lifecycle entrypoint with an app-only configuration +bootstrap. `src/cli/macos-lifecycle.ts` passes `prepareMacOSAppStart` through the canonical lifecycle +authority before config load, liveness probing, routing mutation, proxy launch, or catalog sync. The +authority acquires Ensure (`E`) first; the preparation hook may then acquire the shared config-mutation +lock, preserving the required E-lock → config-mutation-lock ordering. Ordinary CLI `ccx start` and +service paths do not call this hook: they require `ccx init` to have created a configuration and refuse +a missing one. + +The app hook validates the canonical secret-free ChatGPT passthrough default and initializes only a +missing CodexCommander config. Publication is create-only/no-clobber: an existing valid, invalid, +unreadable, or unsafe config is never overwritten, and a concurrent race loser adopts the winner's +valid bytes rather than replacing them. If Codex has not created its config yet on this fresh app +start, the proxy and dashboard still run while Codex routing stays native; the result carries +`setupRequired: "codex-first-run"` so the companion tells the user to open Codex once and then choose +**Route Codex Through Proxy**. The hook never creates Codex configuration and never copies provider +secrets, API keys, or OAuth accounts. Existing external Codex providers remain outside its ownership. + +The native companion classifies its physical bundle location before dispatching either automatic or +manual Start. `/Applications`, `~/Applications`, and the source-build bundle are stable; ordinary +physical copies such as Desktop or Downloads are relocatable and may run for the current session while +showing neutral move-to-Applications guidance. Users must quit before moving a running app, and the app +never moves itself. True macOS App Translocation blocks Start before proxy launch and requires the user +to move the app and reopen it. + An installed Codex shim is checked on ordinary CLI startup with a regular-file/1 MiB state bound plus bounded metadata and prefix reads. A complete replacement must produce identical fingerprints and prefixes across a 100 ms observation interval; changing launchers are silently deferred, while mixed diff --git a/structure/02_config-and-codex-home.md b/structure/02_config-and-codex-home.md index b0f9594834..b1356ce1a2 100644 --- a/structure/02_config-and-codex-home.md +++ b/structure/02_config-and-codex-home.md @@ -20,6 +20,36 @@ $CODEX_HOME/.codexcommander-native-main-profiles/ Never assume macOS-only paths. Windows, service installs, and app-launched Codex can all depend on the resolved `CODEX_HOME`. +## macOS app first-run bootstrap + +The direct packaged macOS companion **Start** path is the only app-only configuration bootstrap. It +uses `getDefaultConfig()`'s canonical secret-free ChatGPT passthrough provider and calls +`initializeConfigIfMissing` with create-only/no-clobber semantics. The initializer validates the +candidate before touching disk, refuses existing invalid, unreadable, inaccessible, or unsafe state, +and never overwrites an existing valid file. A concurrent contender that loses publication adopts the +winner's valid bytes; it never replaces the winner. Providers, API keys, OAuth accounts, and Codex +configuration are not copied or created by this bootstrap. + +Lifecycle authority acquires the Ensure lock (`E`) before the app preparation hook; the hook acquires +the shared `config-mutation.sqlite` lock only after E is held. This E → config-mutation-lock ordering +is an invariant: it keeps direct app bootstrap serialized with lifecycle start while preserving the +config writer's cross-process race protections. Ordinary CLI and service startup do not call the app +hook and require `ccx init` to create a valid CodexCommander config; a missing config is refused rather +than synthesized. + +When the app observes that `$CODEX_HOME/config.toml` is missing on a fresh app bootstrap, it writes +only the app-owned default with `clientIntegrations.codex=false`. The proxy and dashboard still start, +but Codex remains native and the result reports `setupRequired: "codex-first-run"`; the companion tells +the user to open Codex once and then choose **Route Codex Through Proxy**. No Codex file is created +automatically. Existing Codex config, including an external provider route, remains untouched. + +The companion's physical bundle classifier is part of this contract. `/Applications`, `~/Applications`, +and the source-build path are stable for Launch at Login; Desktop or Downloads copies are relocatable, +allowed for the current session, and presented with neutral guidance to move to Applications. The user +must quit CodexCommander before moving a running app, and the app never moves it. True App +Translocation is a hard pre-dispatch prohibition: Start stops before proxy launch and requires move and +reopen. + Native-main profile ownership is bound to the real `CODEX_HOME`, not to a CodexCommander instance. Its encrypted vault, transaction journal, recovery marker, and referenced quarantine files live in the owner-only `.codexcommander-native-main-profiles` directory. The unchanged From 53b0ad751b843d6ed72e45fa177300baa1879c02 Mon Sep 17 00:00:00 2001 From: pavelhov Date: Thu, 20 Aug 2026 20:35:46 -0400 Subject: [PATCH 18/28] docs: align CLI and app startup guidance --- README.md | 17 ++++++++----- .../docs/getting-started/for-agents.md | 15 ++++++----- .../docs/getting-started/installation.md | 1 + .../src/content/docs/guides/macos-menu-bar.md | 25 ++++++++++++------- readme/README.ja.md | 4 +-- readme/README.ko.md | 4 +-- readme/README.ru.md | 10 ++++---- readme/README.zh-CN.md | 4 +-- structure/01_runtime.md | 10 ++++++-- structure/02_config-and-codex-home.md | 8 ++++-- 10 files changed, 60 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 4a24ae0d1e..3a38d33e0e 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ Two commands, and every one of them runs any LLM you point it at.

```bash bun install bun run build:gui +bun run src/cli/index.ts init bun run src/cli/index.ts start ``` @@ -34,6 +35,7 @@ account while existing threads stay pinned to the account that started them. ```bash bun install bun run build:gui +bun run src/cli/index.ts init bun run src/cli/index.ts start # or use `service` instead of `start` ``` @@ -93,7 +95,7 @@ change the ad-hoc Gatekeeper steps above. | Action | What it does | |---|---| -| **Start Proxy** | Starts or attaches to the proxy, then routes Codex through it. | +| **Start Proxy** | Starts or attaches to the proxy, then routes Codex through it when Codex configuration exists; on a fresh missing-Codex start, leaves Codex native and shows setup guidance. | | **Restore Native Codex** | Switches only Codex back to OpenAI; the proxy keeps running. | | **Route Codex Through Proxy** | Switches only the Codex route to the already-running proxy. | | **Stop Proxy… / Restart Proxy…** | Restores native routing before stopping; Restart then starts and routes back. | @@ -136,10 +138,13 @@ Codex tasks, history, or authentication, and it does not require a repair comman database. Generated catalogs and caches may remain on disk, but native Codex no longer references them. -On its first launch, the app enables **Launch at Login** so the menu icon returns after sign-in. -On every new manual or Login Item launch, the app performs an explicit **Start**: it starts or -attaches to the proxy, then routes managed Codex through it. An external user-managed Codex provider -is preserved. The startup row exposes the actual mode: **Desktop** +On its first launch from **Applications**, `~/Applications`, or the supported source-build location, +the app enables **Launch at Login** so the menu icon returns after sign-in. A Desktop or Downloads copy +may run for the current session but is not eligible for login startup. On every new manual or Login Item +launch, the app performs an explicit **Start**: it starts or attaches to the proxy, then routes managed +Codex through it when Codex configuration exists. If Codex has not created its config yet, the proxy and +dashboard still run with Codex native and the app shows setup guidance to open Codex once, then choose +**Route Codex Through Proxy**. An external user-managed Codex provider is preserved. The startup row exposes the actual mode: **Desktop** performs this app-managed start, **Headless** leaves only an installed background service at login, and **Off** starts neither automatically. Rebuilt source apps refresh their login registration in place; they are never copied into Application Support. Full @@ -156,8 +161,8 @@ rewrites OpenCode config files. For plain OpenCode or the Desktop app, use the d ### For agents ```bash -bun run src/cli/index.ts start # or use `service` bun run src/cli/index.ts init # interactive setup: writes config; can route through a proven live proxy +bun run src/cli/index.ts start # or use `service`, after init ``` `ccx init` never starts the proxy. If a current-home proxy is already running and its protected diff --git a/docs-site/src/content/docs/getting-started/for-agents.md b/docs-site/src/content/docs/getting-started/for-agents.md index 73048cfe3a..6983d53410 100644 --- a/docs-site/src/content/docs/getting-started/for-agents.md +++ b/docs-site/src/content/docs/getting-started/for-agents.md @@ -18,7 +18,13 @@ bun run build:gui bun run src/cli/index.ts --version ``` -Choose one way to run the proxy: +Initialize the source/headless configuration before starting the proxy: + +```bash +bun run src/cli/index.ts init +``` + +Then choose one way to run the proxy: ```bash # Foreground: blocks this terminal until stopped. @@ -28,13 +34,6 @@ bun run src/cli/index.ts start bun run src/cli/index.ts service ``` -Run `ccx init` in an interactive terminal. If `ccx start` is occupying the foreground, use a -second terminal: - -```bash -bun run src/cli/index.ts init -``` - The wizard writes `$CODEXCOMMANDER_HOME/config.json` (normally `~/.codexcommander/config.json`). It can route Codex through an already-running proxy only after its protected current-home runtime identity is proven, and it can install the optional Codex autostart diff --git a/docs-site/src/content/docs/getting-started/installation.md b/docs-site/src/content/docs/getting-started/installation.md index 4e7b96a9e8..827d4f14b2 100644 --- a/docs-site/src/content/docs/getting-started/installation.md +++ b/docs-site/src/content/docs/getting-started/installation.md @@ -20,6 +20,7 @@ vision and web-search sidecars can also use your ChatGPT login when a routed mod ```bash bun install bun run build:gui +bun run src/cli/index.ts init bun run src/cli/index.ts 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 c600f37455..df2aa107cc 100644 --- a/docs-site/src/content/docs/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/guides/macos-menu-bar.md @@ -44,13 +44,15 @@ the app and reopen it. The ad-hoc Gatekeeper steps above remain unchanged. The panel has one **Launch at Login** switch and reports the resulting mode: - **Desktop** — the CodexCommander menu app launches when you sign in, performs an explicit Start, - starts or attaches to exactly one proxy, and routes managed Codex through it. An external - user-managed Codex provider is preserved. This is the default desktop experience and is reported - as **App-managed** in Startup. + starts or attaches to exactly one proxy, and routes managed Codex through it when Codex + configuration exists. On a fresh missing-Codex start, it leaves Codex native while the proxy runs + and setup guidance is shown. An external user-managed Codex provider is preserved. This is the + default desktop experience and is reported as **App-managed** in Startup. - **Headless** — the menu app is not a login item, but an independently installed `ccx service` continues starting and supervising the server. - **Off** — neither the menu app nor a background service starts automatically. A new manual app - launch performs the same explicit Start-and-route transition as `ccx start`. + launch performs the same explicit Start transition as `ccx start`; it routes Codex when Codex + configuration exists, or leaves Codex native with setup guidance when Codex has not run yet. Throughout this guide, **restore native** means removing CodexCommander-owned routing. An external user-managed Codex provider is left unchanged. @@ -65,8 +67,10 @@ responsibilities of one installation, not duplicate app copies. Turning off Laun installs, removes, starts, or stops the background service. App-managed startup and the background service solve different problems. The app starts or attaches -to the proxy at sign-in and routes managed Codex through it, which is enough for normal desktop use. The -optional background service additionally +to the proxy at sign-in and routes managed Codex through it when Codex configuration exists, which is +enough for normal desktop use. On a fresh missing-Codex start, the proxy remains running while Codex +stays native until the user opens Codex once and chooses **Route Codex Through Proxy**. The optional +background service additionally supervises the proxy and restarts it after a crash, so the dashboard labels it **Background recovery** instead of presenting it as a requirement. The companion periodically reports its current Launch at Login state to the local proxy; that short-lived report is kept only in memory and is used @@ -106,7 +110,9 @@ override it. workers still hold an older model roster. The CodexCommander proxy remains healthy and running. - **Show restart steps…** — explains the recommended reload boundary: quit ChatGPT completely, reopen it, and then start a new task. The menu app does not force-restart background workers from this card. -- **Start Proxy** — starts or attaches to the proxy, then routes Codex through the live endpoint. +- **Start Proxy** — starts or attaches to the proxy, then routes Codex through the live endpoint when + Codex configuration exists. If Codex has not run yet, the proxy stays running, Codex remains native, + and the setup-required card explains how to finish setup. - **Stop Proxy…** — always asks for confirmation and restores native Codex routing before it stops the proxy. If the native route cannot be verified, the proxy and service stay running. The menu app stays open. - **Restart Proxy…** — always asks for confirmation and runs the same safe stop→start transaction as @@ -249,8 +255,9 @@ open dist/macos/CodexCommander.app The development app is exactly `dist/macos/CodexCommander.app`. Every build embeds the Bun runtime and CodexCommander server resources inside the app bundle; the running app never executes `src/` from the checkout. Rebuild the app to pick up source changes. Double-clicking it to launch a new app process -performs an explicit Start: it starts or attaches to the proxy and routes managed Codex through it. -An external user-managed provider is preserved. An offline failure or failed start +performs an explicit Start: it starts or attaches to the proxy and routes managed Codex through it when +Codex configuration exists. If Codex has not run yet, it leaves Codex native while the proxy runs and +shows setup guidance. An external user-managed provider is preserved. An offline failure or failed start does not close the app: its status panel remains available and **Start** can be retried. This source workflow does not install or copy the app into Application Support. A rebuild at the same path is detected on the diff --git a/readme/README.ja.md b/readme/README.ja.md index 921bf5bf27..bb0e1f7e18 100644 --- a/readme/README.ja.md +++ b/readme/README.ja.md @@ -72,13 +72,13 @@ bun run src/cli/index.ts start # バックグラウンド実行には start ### エージェント向け ```bash -bun run src/cli/index.ts start bun run src/cli/index.ts init +bun run src/cli/index.ts start ``` このソースチェックアウトでは、以降の `ccx ` を `bun run src/cli/index.ts ` として実行できます。 -`ccx init` 自体はプロキシを起動しません。先に起動しても後から起動しても構いませんが、`ccx provider add` や `ccx combo set` などのヘッドレスコマンドは**稼働中の**プロキシと通信し、接続できない場合は非ゼロで終了します。`ccx status` / `ccx doctor` / `ccx health` で稼働状態を確認できます。 +`ccx init` 自体はプロキシを起動しないため、通常の CLI または service を起動する前に先に実行してください。設定がない状態で通常起動すると拒否されます。`ccx provider add` や `ccx combo set` などのヘッドレスコマンドは**稼働中の**プロキシと通信し、接続できない場合は非ゼロで終了します。`ccx status` / `ccx doctor` / `ccx health` で稼働状態を確認できます。 ## プロバイダーを追加 diff --git a/readme/README.ko.md b/readme/README.ko.md index f230239b42..ea35b9e389 100644 --- a/readme/README.ko.md +++ b/readme/README.ko.md @@ -72,13 +72,13 @@ http://localhost:10100에서 웹 대시보드를 열어 프로바이더, 모델, ### 에이전트용 ```bash -bun run src/cli/index.ts start bun run src/cli/index.ts init +bun run src/cli/index.ts start ``` 이 소스 체크아웃에서는 아래의 `ccx ` 명령을 `bun run src/cli/index.ts `로 실행할 수 있습니다. -`ccx init`은 프록시를 시작하지 않습니다. 먼저 시작하세요(또는 나중에 해도 됩니다. 순서는 상관없지만, `ccx provider add`와 `ccx combo set` 같은 헤드리스 명령은 **실행 중인** 프록시와 통신하며 접근할 수 없으면 nonzero로 종료합니다). `ccx status` / `ccx doctor` / `ccx health`는 실행 상태를 보고합니다. +`ccx init`은 프록시를 시작하지 않으므로 일반 CLI 또는 service를 시작하기 전에 먼저 실행해야 합니다. 초기화하지 않은 상태에서 일반 시작을 시도하면 설정이 없다는 이유로 거부됩니다. `ccx provider add`와 `ccx combo set` 같은 헤드리스 명령은 **실행 중인** 프록시와 통신하며 접근할 수 없으면 nonzero로 종료합니다. `ccx status` / `ccx doctor` / `ccx health`는 실행 상태를 보고합니다. ## 프로바이더 추가하기 diff --git a/readme/README.ru.md b/readme/README.ru.md index e56d30d07e..6e2116a253 100644 --- a/readme/README.ru.md +++ b/readme/README.ru.md @@ -76,16 +76,16 @@ bun run src/cli/index.ts start # для фонового режима испо ### Для агентов ```bash -bun run src/cli/index.ts start bun run src/cli/index.ts init +bun run src/cli/index.ts start ``` В этом checkout любую приведённую ниже команду `ccx ` можно выполнить как `bun run src/cli/index.ts `. -`ccx init` никогда не запускает прокси; сначала запустите его сами (или после — оба порядка -работают, но headless-команды вроде `ccx provider add` и `ccx combo set` обращаются к **живому** -прокси и завершаются с ненулевым кодом, если он недоступен). Состояние запущенного прокси -показывают `ccx status`, `ccx doctor` и `ccx health`. +`ccx init` никогда не запускает прокси, поэтому сначала выполните инициализацию, а затем запускайте +обычный CLI или service. Запуск без созданной конфигурации завершается отказом. Headless-команды +вроде `ccx provider add` и `ccx combo set` обращаются к **живому** прокси и завершаются с ненулевым +кодом, если он недоступен. Состояние запущенного прокси показывают `ccx status`, `ccx doctor` и `ccx health`. ## Добавление провайдера diff --git a/readme/README.zh-CN.md b/readme/README.zh-CN.md index d4cc90bcef..fd3eb271cc 100644 --- a/readme/README.zh-CN.md +++ b/readme/README.zh-CN.md @@ -72,13 +72,13 @@ provider,或任意 OpenAI 兼容端点)、选择模型并管理账户。随 ### 面向代理 ```bash -bun run src/cli/index.ts start bun run src/cli/index.ts init +bun run src/cli/index.ts start ``` 在此源码检出目录中,下文的 `ccx ` 命令都可以写成 `bun run src/cli/index.ts `。 -`ccx init` 不会启动代理;可以先启动代理,也可以之后再启动——两种顺序都可行,但 +`ccx init` 不会启动代理,因此必须先完成初始化,再启动普通 CLI 或 service。未初始化时尝试普通启动会因缺少配置而被拒绝。 `ccx provider add`、`ccx combo set` 等无头命令会连接**正在运行的**代理,无法访问时将以非零状态 退出。`ccx status` / `ccx doctor` / `ccx health` 可报告运行状态。 diff --git a/structure/01_runtime.md b/structure/01_runtime.md index a1df7df626..f1e77c3a3f 100644 --- a/structure/01_runtime.md +++ b/structure/01_runtime.md @@ -48,8 +48,8 @@ their own files. ## Lifecycle Explicit starts (`ccx start`, every new companion launch, companion Start, and service -create/`install`/`repair`/`start`) enable managed Codex integration, preserve an external user-managed -provider, refuse a duplicate PID, start the proxy, write +create/`install`/`repair`/`start`) normally enable managed Codex integration, preserve an external +user-managed provider, refuse a duplicate PID, start the proxy, write `~/.codexcommander/codexcommander.pid`, and sync Codex config/catalog. Automatic `ensure` preserves an intentional OFF state. Normal standalone shutdown restores native routing. Service mode sets `CCX_SERVICE=1`, so manager restarts preserve the current route; explicit service stop and uninstall @@ -57,6 +57,12 @@ restore and verify native routing before terminating anything. In this document, restoring native means removing CodexCommander-owned routing; an external user-managed Codex provider is preserved. +The fresh direct app-start exception is deliberate: when the app-only bootstrap creates the +CodexCommander config before Codex has created `$CODEX_HOME/config.toml`, it starts the proxy and +dashboard but leaves Codex native and returns `setupRequired: "codex-first-run"`. The companion then +asks the user to open Codex once and choose **Route Codex Through Proxy**. Passive companion `ensure` +does not install or invoke this bootstrap hook; it preserves the existing OFF/native intent. + Direct packaged macOS **Start** is the only lifecycle entrypoint with an app-only configuration bootstrap. `src/cli/macos-lifecycle.ts` passes `prepareMacOSAppStart` through the canonical lifecycle authority before config load, liveness probing, routing mutation, proxy launch, or catalog sync. The diff --git a/structure/02_config-and-codex-home.md b/structure/02_config-and-codex-home.md index b1356ce1a2..6a16da41a8 100644 --- a/structure/02_config-and-codex-home.md +++ b/structure/02_config-and-codex-home.md @@ -212,8 +212,12 @@ proxy lifecycle. Existing `codexcommander-catalog.json` and `models_cache.json` but are inert once `config.toml` no longer references them. Codex tasks, thread/history/rollout state, and authentication are outside lifecycle ownership and are never modified by the native escape. -Explicit Start and Route Back are the inverse OFF→ON transition. If a recovery journal exists, they -first classify what it represents. The journal is a protected crash-recovery checkpoint: it records +Explicit Start and Route Back are the inverse OFF→ON transition once Codex configuration exists. The +fresh direct app-start bootstrap is the exception: if Codex has not created `$CODEX_HOME/config.toml`, +the app starts the proxy and dashboard with Codex native, returns `setupRequired: "codex-first-run"`, +and waits for the user to open Codex once before choosing **Route Codex Through Proxy**. Passive +companion `ensure` does not install or invoke the bootstrap hook. If a recovery journal exists, Start +and Route Back first classify what it represents. The journal is a protected crash-recovery checkpoint: it records the exact config/profile images needed to distinguish CodexCommander's write from unrelated user edits. It is not a second routing preference or user-maintained database, and users must not delete it manually. From 58356e6008dafdfb173ca0c5e71d42e563efc2fd Mon Sep 17 00:00:00 2001 From: pavelhov Date: Thu, 20 Aug 2026 20:42:06 -0400 Subject: [PATCH 19/28] docs: finish macOS startup semantics --- docs-site/src/content/docs/guides/macos-menu-bar.md | 5 +++-- readme/README.ja.md | 2 ++ readme/README.ko.md | 2 ++ readme/README.ru.md | 2 ++ readme/README.zh-CN.md | 2 ++ structure/01_runtime.md | 12 ++++++++---- 6 files changed, 19 insertions(+), 6 deletions(-) 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 df2aa107cc..9943bff0e8 100644 --- a/docs-site/src/content/docs/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/guides/macos-menu-bar.md @@ -51,8 +51,9 @@ The panel has one **Launch at Login** switch and reports the resulting mode: - **Headless** — the menu app is not a login item, but an independently installed `ccx service` continues starting and supervising the server. - **Off** — neither the menu app nor a background service starts automatically. A new manual app - launch performs the same explicit Start transition as `ccx start`; it routes Codex when Codex - configuration exists, or leaves Codex native with setup guidance when Codex has not run yet. + launch uses the app-owned Start path, including its first-run preparation; it routes Codex when Codex + configuration exists, or leaves Codex native with setup guidance when Codex has not run yet. The + ordinary CLI path is separate: run `ccx init` before `ccx start` or `ccx service`. Throughout this guide, **restore native** means removing CodexCommander-owned routing. An external user-managed Codex provider is left unchanged. diff --git a/readme/README.ja.md b/readme/README.ja.md index bb0e1f7e18..2ef7f54df9 100644 --- a/readme/README.ja.md +++ b/readme/README.ja.md @@ -5,6 +5,7 @@ ```bash bun install bun run build:gui +bun run src/cli/index.ts init bun run src/cli/index.ts start ``` @@ -64,6 +65,7 @@ flowchart LR ```bash bun install bun run build:gui +bun run src/cli/index.ts init bun run src/cli/index.ts start # バックグラウンド実行には start の代わりに service ``` diff --git a/readme/README.ko.md b/readme/README.ko.md index ea35b9e389..f975b3b4b2 100644 --- a/readme/README.ko.md +++ b/readme/README.ko.md @@ -5,6 +5,7 @@ ```bash bun install bun run build:gui +bun run src/cli/index.ts init bun run src/cli/index.ts start ``` @@ -64,6 +65,7 @@ flowchart LR ```bash bun install bun run build:gui +bun run src/cli/index.ts init bun run src/cli/index.ts start # 백그라운드 실행에는 start 대신 service ``` diff --git a/readme/README.ru.md b/readme/README.ru.md index 6e2116a253..0aa2dffdf3 100644 --- a/readme/README.ru.md +++ b/readme/README.ru.md @@ -5,6 +5,7 @@ ```bash bun install bun run build:gui +bun run src/cli/index.ts init bun run src/cli/index.ts start ``` @@ -66,6 +67,7 @@ flowchart LR ```bash bun install bun run build:gui +bun run src/cli/index.ts init bun run src/cli/index.ts start # для фонового режима используйте service вместо start ``` diff --git a/readme/README.zh-CN.md b/readme/README.zh-CN.md index fd3eb271cc..a1ca5d1d96 100644 --- a/readme/README.zh-CN.md +++ b/readme/README.zh-CN.md @@ -5,6 +5,7 @@ ```bash bun install bun run build:gui +bun run src/cli/index.ts init bun run src/cli/index.ts start ``` @@ -63,6 +64,7 @@ flowchart LR ```bash bun install bun run build:gui +bun run src/cli/index.ts init bun run src/cli/index.ts start # 后台运行时用 service 代替 start ``` diff --git a/structure/01_runtime.md b/structure/01_runtime.md index f1e77c3a3f..ae18c476e8 100644 --- a/structure/01_runtime.md +++ b/structure/01_runtime.md @@ -129,10 +129,14 @@ untouched. The GUI sidebar stop button calls this endpoint. Every new manual or Login Item launch of the macOS companion performs an explicit Start. A failed or offline start must leave the menu app alive with its status/Start controls available; it cannot self-terminate just because the proxy is unavailable. Its **Quit** action terminates only the AppKit process. Explicit -**Start** enables Codex routing through the proxy. **Stop** restores and verifies native routing before -termination and keeps the menu app open. **Restart** runs the canonical stop→start transaction: it -restores native routing before terminating the old proxy, then its explicit Start phase launches the -replacement and routes Codex back through it. A failed restart leaves Codex native. +**Start** enables Codex routing through the proxy when usable Codex configuration exists. On the +fresh app-first-run path where Codex has not created its config, Start still starts the proxy and +dashboard but leaves Codex native and returns `setupRequired: "codex-first-run"`; it does not enable +routing until the user opens Codex once and chooses **Route Codex Through Proxy**. **Stop** restores and +verifies native routing before termination and keeps the menu app open. **Restart** runs the canonical +stop→start transaction: it restores native routing before terminating the old proxy, then its explicit +Start phase launches the replacement and routes Codex back through it when configuration is usable. A +failed restart leaves Codex native. **Restore Native Codex** and **Route Codex Through Proxy** change routing without changing proxy lifecycle. The main app is the default desktop Login Item; launchd remains an independent optional headless From b7397d232479bb789aa2b8cfe0a7226b9f76017b Mon Sep 17 00:00:00 2001 From: pavelhov Date: Thu, 20 Aug 2026 21:57:43 -0400 Subject: [PATCH 20/28] fix: wait for proxy runtime ownership after spawn --- src/cli/proxy-lifecycle.ts | 27 +++++++++++++++++++++------ tests/proxy-lifecycle.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/src/cli/proxy-lifecycle.ts b/src/cli/proxy-lifecycle.ts index e5e224b58d..af21360589 100644 --- a/src/cli/proxy-lifecycle.ts +++ b/src/cli/proxy-lifecycle.ts @@ -387,12 +387,27 @@ export function spawnDetachedProxyStart(options: SpawnDetachedProxyOptions = {}) }); } -export async function waitForProxy(timeoutMs = 12_000): Promise { - const deadline = Date.now() + Math.max(0, timeoutMs); - while (Date.now() < deadline) { - const live = await findLiveProxy(); - if (live) return live; - await Bun.sleep(150); +export interface WaitForProxyIo { + findLive?: typeof findLiveProxy; + now?: () => number; + sleep?: (milliseconds: number) => Promise; +} + +export async function waitForProxy( + timeoutMs = 12_000, + io: WaitForProxyIo = {}, +): Promise { + const now = io.now ?? Date.now; + const sleep = io.sleep ?? Bun.sleep; + const findLive = io.findLive ?? findLiveProxy; + const deadline = now() + Math.max(0, timeoutMs); + while (now() < deadline) { + const live = await findLive(); + // Bun starts answering /healthz before the child publishes its protected + // runtime record. Config-port discovery is therefore evidence of progress, + // not post-spawn ownership; keep waiting for the runtime identity fence. + if (live?.source === "runtime" && live.pid !== null) return live; + await sleep(150); } return null; } diff --git a/tests/proxy-lifecycle.test.ts b/tests/proxy-lifecycle.test.ts index 8b369fc8c2..b12b330241 100644 --- a/tests/proxy-lifecycle.test.ts +++ b/tests/proxy-lifecycle.test.ts @@ -9,6 +9,7 @@ import { restoreBackRoutingLifecycle, spawnDetachedProxyStart, stopProxyLifecycle, + waitForProxy, type EnsureProxyLifecycleIo, } from "../src/cli/proxy-lifecycle"; import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCES, BUN_RUNTIME_SOURCE_ENV } from "../src/lib/bun-runtime"; @@ -108,6 +109,27 @@ function baseIo(overrides: EnsureProxyLifecycleIo = {}): EnsureProxyLifecycleIo } describe("shared proxy lifecycle authority", () => { + test("post-spawn wait ignores config fallback until runtime ownership is published", async () => { + const observations = [ + { pid: 77, port: 10123, source: "config" as const }, + { pid: 77, port: 10123, source: "runtime" as const }, + ]; + let now = 0; + let probes = 0; + + const result = await waitForProxy(500, { + findLive: async () => { + probes += 1; + return observations.shift() ?? null; + }, + now: () => now, + sleep: async milliseconds => { now += milliseconds; }, + }); + + expect(result).toEqual({ pid: 77, port: 10123, source: "runtime" }); + expect(probes).toBe(2); + }); + test("Stop cannot pass an in-flight explicit Start while E is held", async () => { const calls: string[] = []; let held = false; From 7880ae6dc4e455a22b81fca10c86b581e5d4602d Mon Sep 17 00:00:00 2001 From: pavelhov Date: Thu, 20 Aug 2026 23:34:55 -0400 Subject: [PATCH 21/28] fix(config): bind first-run writes to physical root --- src/config.ts | 370 +++++++++++++++++++++++++++++++++++-------- tests/config.test.ts | 197 ++++++++++++++++++++++- 2 files changed, 497 insertions(+), 70 deletions(-) diff --git a/src/config.ts b/src/config.ts index 6900b99863..14988022b8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,7 +2,7 @@ import { execFileSync } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; import { chmodSync, copyFileSync, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { Database } from "bun:sqlite"; import * as z from "zod/v4"; import { @@ -44,8 +44,9 @@ import { inspectPhysicalConfigRoot, recordOwnedConfigPath, sameConfigRootFileIdentity, + type ConfigRootFileIdentity, } from "./lib/config-ownership"; -import { assertNotRealHomeUnderTest } from "./lib/test-home-guard"; +import { assertNotRealHomeUnderTest, isTestHomeGuardArmed } from "./lib/test-home-guard"; import { isLocalAttestationSecret } from "./lib/local-management-attestation"; import { providerDestinationConfigError } from "./lib/destination-policy"; import { redactSecretString } from "./lib/redact"; @@ -1676,6 +1677,13 @@ type ConfigRootProbe = | { kind: "valid"; identity: ConfigRootIdentity } | { kind: "refused"; reason: "existing-inaccessible" | "existing-unsafe" }; +class ConfigRootChangedDuringInitializationError extends Error { + constructor() { + super("Configuration root changed during initialization"); + this.name = "ConfigRootChangedDuringInitializationError"; + } +} + const CONFIG_INITIALIZATION_WAIT_MS = 2_000; const CONFIG_INITIALIZATION_POLL_MS = 10; @@ -1720,10 +1728,77 @@ function configRootStillMatches(expected: ConfigRootIdentity): boolean { return current.kind === "valid" && samePhysicalConfigRoot(expected, current.identity); } -function probeConfigEntry(): ConfigEntryProbe { +function boundConfigRootStillMatches(expected: ConfigRootIdentity): boolean { + try { + const current = inspectPhysicalConfigRoot("."); + return current.kind === "valid" + && sameConfigRootFileIdentity(expected, current.identity); + } catch { + return false; + } +} + +function relativePathWithin(root: string, candidate: string): string | null { + const rel = relative(root, candidate); + if ( + rel === "" + || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)) + ) return rel; + return null; +} + +/** + * Run one strictly synchronous initializer section with `.` anchored to the admitted + * physical root. Relative filesystem operations keep resolving through that directory + * object if its absolute name is renamed and replaced. No user callback or await may + * enter this section. + */ +function withBoundConfigRootSync( + expected: ConfigRootIdentity, + operation: () => T, +): T { + const previousCwd = process.cwd(); + const canonicalPreviousCwd = realpathSync.native(previousCwd); + const previousRelativeToRoot = relativePathWithin( + expected.canonicalPath, + canonicalPreviousCwd, + ); + let entered = false; + try { + if (previousRelativeToRoot === null) { + process.chdir(expected.path); + } else if (previousRelativeToRoot !== "") { + const depth = previousRelativeToRoot.split(sep).filter(Boolean).length; + process.chdir(Array.from({ length: depth }, () => "..").join(sep)); + } + entered = true; + if (!boundConfigRootStillMatches(expected) || !configRootStillMatches(expected)) { + throw new ConfigRootChangedDuringInitializationError(); + } + return operation(); + } catch (error) { + if ( + !entered + && !(error instanceof ConfigRootChangedDuringInitializationError) + ) { + throw new ConfigRootChangedDuringInitializationError(); + } + throw error; + } finally { + if (entered) { + if (previousRelativeToRoot === null) { + process.chdir(previousCwd); + } else if (previousRelativeToRoot !== "") { + process.chdir(previousRelativeToRoot); + } + } + } +} + +function probeConfigEntry(path = getConfigPath()): ConfigEntryProbe { let entry; try { - entry = lstatSync(getConfigPath()); + entry = lstatSync(path); } catch (error) { return isMissingPathError(error) ? { kind: "missing" } @@ -1733,7 +1808,7 @@ function probeConfigEntry(): ConfigEntryProbe { return { kind: "refused", reason: "existing-unsafe" }; } try { - return configDiagnosticsFromRaw(readFileSync(getConfigPath(), "utf8")).source === "file" + return configDiagnosticsFromRaw(readFileSync(path, "utf8")).source === "file" ? { kind: "valid" } : { kind: "refused", reason: "existing-invalid" }; } catch { @@ -1743,29 +1818,55 @@ function probeConfigEntry(): ConfigEntryProbe { let configInitializationBeforePublishForTests: (() => void) | null = null; -/** Test-only one-shot seam: inject a competing writer immediately before no-clobber publication. */ +/** Test-only one-shot seam: inject a competing writer between preflight and bound mutation. */ export function setConfigInitializationBeforePublishForTests(hook: (() => void) | null): void { configInitializationBeforePublishForTests = hook; } -function publishConfigNoReplace(path: string, bytes: string): boolean { +function unlinkPublishedConfigIfUnchanged( + path: string, + expected: ConfigRootFileIdentity, +): void { + const current = lstatSync(path, { bigint: true }); + if ( + !current.isFile() + || current.isSymbolicLink() + || current.nlink !== 1n + || !sameConfigRootFileIdentity(expected, current) + ) { + throw new Error("Published configuration identity changed before cleanup"); + } + unlinkSync(path); +} + +function publishConfigNoReplace( + path: string, + bytes: string, + expectedRoot: ConfigRootIdentity, +): boolean { const target = resolveWriteTarget(path); assertResolvedTargetAllowed(path, target); const temp = `${target}.ccx.${process.pid}.${++_atomicSeq}.create.tmp`; let published = false; + let collision = false; + let publishedIdentity: ConfigRootFileIdentity | null = null; try { writeFileSync(temp, bytes, { encoding: "utf8", mode: 0o600, flag: "wx" }); try { chmodSync(temp, 0o600); } catch { /* filesystem may ignore chmod */ } if (process.platform === "win32") { hardenSecretPath(temp, { required: true, timeoutMemoKey: path }); } + const source = lstatSync(temp, { bigint: true }); + if (!source.isFile() || source.isSymbolicLink() || source.nlink !== 1n) { + throw new Error("Configuration publication source is not a private regular file"); + } + publishedIdentity = { dev: source.dev, ino: source.ino }; try { linkSync(temp, target); published = true; - return true; } catch (error) { - if ((error as NodeJS.ErrnoException).code === "EEXIST") return false; - throw error; + if ((error as NodeJS.ErrnoException).code === "EEXIST") collision = true; + else throw error; } } finally { try { @@ -1782,6 +1883,29 @@ function publishConfigNoReplace(path: string, bytes: string): boolean { } } } + if (collision) return false; + if (!published || !publishedIdentity) { + throw new Error("Configuration publication did not establish a destination"); + } + let destinationValid = false; + try { + const destination = lstatSync(target, { bigint: true }); + destinationValid = destination.isFile() + && !destination.isSymbolicLink() + && destination.nlink === 1n + && sameConfigRootFileIdentity(publishedIdentity, destination) + && readFileSync(target, "utf8") === bytes; + } catch { + destinationValid = false; + } + const rootValid = boundConfigRootStillMatches(expectedRoot) + && configRootStillMatches(expectedRoot); + if (!destinationValid || !rootValid) { + unlinkPublishedConfigIfUnchanged(target, publishedIdentity); + if (!rootValid) throw new ConfigRootChangedDuringInitializationError(); + throw new Error("Published configuration could not be verified"); + } + return true; } function configInitializationContenderObservation( @@ -1818,95 +1942,112 @@ function waitForConfigInitializationWinner( } } -export function initializeConfigIfMissing( +export type ConfigInitializationTestAction = { + kind: "barrier"; + stage: "after-final-root-check"; + readyPath: string; + releasePath: string; +}; + +function runConfigInitializationTestAction( + action: ConfigInitializationTestAction, + expectedRoot: ConfigRootIdentity, +): void { + if (!isTestHomeGuardArmed()) { + throw new Error("Config initialization test actions require the explicit test-home guard"); + } + if ( + action.kind !== "barrier" + || action.stage !== "after-final-root-check" + || !isAbsolute(action.readyPath) + || !isAbsolute(action.releasePath) + || relativePathWithin(expectedRoot.path, action.readyPath) !== null + || relativePathWithin(expectedRoot.path, action.releasePath) !== null + ) { + throw new Error("Config initialization test barrier paths must be absolute and outside the config root"); + } + writeFileSync(action.readyPath, "ready", { encoding: "utf8", flag: "wx", mode: 0o600 }); + const deadline = performance.now() + 10_000; + while (!existsSync(action.releasePath)) { + if (performance.now() >= deadline) { + throw new Error("Config initialization test barrier timed out"); + } + Bun.sleepSync(5); + } +} + +function initializeConfigInBoundRoot( candidate: CodexCommanderConfig, + expectedRoot: ConfigRootIdentity, + deadline: number, + testAction?: ConfigInitializationTestAction, ): ConfigInitializationResult { - const validated = validateConfigCandidate(candidate); - if (!validated.ok) return { status: "refused", reason: "candidate-invalid" }; - const deadline = performance.now() + CONFIG_INITIALIZATION_WAIT_MS; - const initialRoot = probeConfigRoot(); - if (initialRoot.kind === "refused") { - return { status: "refused", reason: initialRoot.reason }; - } - const observed = probeConfigEntry(); - if (initialRoot.kind === "valid" && !configRootStillMatches(initialRoot.identity)) { - return { status: "refused", reason: "existing-unsafe" }; + const configPath = "config.json"; + const observed = probeConfigEntry(configPath); + if (!boundConfigRootStillMatches(expectedRoot) || !configRootStillMatches(expectedRoot)) { + throw new ConfigRootChangedDuringInitializationError(); } if (observed.kind === "valid") return { status: "existing" }; if (observed.kind === "refused") return { status: "refused", reason: observed.reason }; + let ownershipFailure: ConfigInitializationRefusal | null = null; try { - if (!recordOwnedConfigPath(getConfigDir(), getConfigPath())) { - ownershipFailure = "existing-unsafe"; - } + if (!recordOwnedConfigPath(".", configPath)) ownershipFailure = "existing-unsafe"; } catch { ownershipFailure = "existing-inaccessible"; } - - const ownedRoot = probeConfigRoot(); - if (ownedRoot.kind !== "valid") { - return { - status: "refused", - reason: ownedRoot.kind === "refused" ? ownedRoot.reason : "existing-unsafe", - }; - } - if ( - initialRoot.kind === "valid" - && !samePhysicalConfigRoot(initialRoot.identity, ownedRoot.identity) - ) { - return { status: "refused", reason: "existing-unsafe" }; + if (!boundConfigRootStillMatches(expectedRoot) || !configRootStillMatches(expectedRoot)) { + throw new ConfigRootChangedDuringInitializationError(); } if (!ownershipFailure) { try { - if (!recordOwnedConfigPath(getConfigDir(), getConfigPath())) { - ownershipFailure = "existing-unsafe"; - } + if (!recordOwnedConfigPath(".", configPath)) ownershipFailure = "existing-unsafe"; } catch { ownershipFailure = "existing-inaccessible"; } - if (!configRootStillMatches(ownedRoot.identity)) { - return { status: "refused", reason: "existing-unsafe" }; + if (!boundConfigRootStillMatches(expectedRoot) || !configRootStillMatches(expectedRoot)) { + throw new ConfigRootChangedDuringInitializationError(); } } if (ownershipFailure) { - return waitForConfigInitializationWinner(ownedRoot.identity, deadline, ownershipFailure); + return waitForConfigInitializationWinner(expectedRoot, deadline, ownershipFailure); } let lastContentionRefusal: ConfigInitializationRefusal | null = null; + let pendingTestAction = testAction; for (;;) { - if (!configRootStillMatches(ownedRoot.identity)) { - return { status: "refused", reason: "existing-unsafe" }; + if (!boundConfigRootStillMatches(expectedRoot) || !configRootStillMatches(expectedRoot)) { + throw new ConfigRootChangedDuringInitializationError(); } try { - return withConfigMutationLockSync(() => { - if (!configRootStillMatches(ownedRoot.identity)) { - return { status: "refused", reason: "existing-unsafe" } as const; + return withConfigMutationLockAtDirSync(".", () => { + if (!boundConfigRootStillMatches(expectedRoot) || !configRootStillMatches(expectedRoot)) { + throw new ConfigRootChangedDuringInitializationError(); } - const current = probeConfigEntry(); + const current = probeConfigEntry(configPath); if (current.kind === "valid") return { status: "existing" } as const; if (current.kind === "refused") { return { status: "refused", reason: current.reason } as const; } - const hook = configInitializationBeforePublishForTests; - configInitializationBeforePublishForTests = null; - hook?.(); - if (!configRootStillMatches(ownedRoot.identity)) { - return { status: "refused", reason: "existing-unsafe" } as const; + const bytes = `${JSON.stringify(candidate, null, 2)}\n`; + if (!boundConfigRootStillMatches(expectedRoot) || !configRootStillMatches(expectedRoot)) { + throw new ConfigRootChangedDuringInitializationError(); } - const afterHook = probeConfigEntry(); - if (afterHook.kind === "valid") return { status: "existing" } as const; - if (afterHook.kind === "refused") { - return { status: "refused", reason: afterHook.reason } as const; + const action = pendingTestAction; + pendingTestAction = undefined; + if (action) { + runConfigInitializationTestAction(action, expectedRoot); + if (!boundConfigRootStillMatches(expectedRoot) || !configRootStillMatches(expectedRoot)) { + throw new ConfigRootChangedDuringInitializationError(); + } } - - const bytes = `${JSON.stringify(validated.config, null, 2)}\n`; - if (!configRootStillMatches(ownedRoot.identity)) { - return { status: "refused", reason: "existing-unsafe" } as const; - } - const published = publishConfigNoReplace(getConfigPath(), bytes); + const published = publishConfigNoReplace(configPath, bytes, expectedRoot); if (!published) { - const winner = probeConfigEntry(); + if (!boundConfigRootStillMatches(expectedRoot) || !configRootStillMatches(expectedRoot)) { + throw new ConfigRootChangedDuringInitializationError(); + } + const winner = probeConfigEntry(configPath); if (winner.kind === "valid") return { status: "existing" } as const; return { status: "refused", @@ -1917,10 +2058,11 @@ export function initializeConfigIfMissing( return { status: "created" } as const; }); } catch (error) { + if (error instanceof ConfigRootChangedDuringInitializationError) throw error; if (!(error instanceof ConfigMutationLockError)) { return { status: "refused", reason: "coordination-unavailable" }; } - const contender = configInitializationContenderObservation(ownedRoot.identity); + const contender = configInitializationContenderObservation(expectedRoot); if ("status" in contender) return contender; if (contender.kind === "refused") lastContentionRefusal = contender.reason; if (performance.now() >= deadline) { @@ -1934,6 +2076,93 @@ export function initializeConfigIfMissing( } } +function initializeConfigIfMissingInternal( + candidate: CodexCommanderConfig, + testAction?: ConfigInitializationTestAction, +): ConfigInitializationResult { + const validated = validateConfigCandidate(candidate); + if (!validated.ok) return { status: "refused", reason: "candidate-invalid" }; + // Entering a process-global CWD fence from an arbitrary outer mutation callback + // would create a reentrant seam while relative paths are authoritative. + if (configMutationLockDepth > 0) { + return { status: "refused", reason: "coordination-unavailable" }; + } + const deadline = performance.now() + CONFIG_INITIALIZATION_WAIT_MS; + const initialRoot = probeConfigRoot(); + if (initialRoot.kind === "refused") { + return { status: "refused", reason: initialRoot.reason }; + } + if (initialRoot.kind === "missing") { + try { + assertNotRealHomeUnderTest(getConfigDir()); + mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 }); + } catch { + return { status: "refused", reason: "existing-inaccessible" }; + } + } + const admittedRoot = probeConfigRoot(); + if (admittedRoot.kind !== "valid") { + return { + status: "refused", + reason: admittedRoot.kind === "refused" ? admittedRoot.reason : "existing-unsafe", + }; + } + if ( + initialRoot.kind === "valid" + && !samePhysicalConfigRoot(initialRoot.identity, admittedRoot.identity) + ) { + return { status: "refused", reason: "existing-unsafe" }; + } + + try { + const preflight = withBoundConfigRootSync(admittedRoot.identity, () => { + const observed = probeConfigEntry("config.json"); + if (!boundConfigRootStillMatches(admittedRoot.identity) + || !configRootStillMatches(admittedRoot.identity)) { + throw new ConfigRootChangedDuringInitializationError(); + } + return observed; + }); + if (preflight.kind === "valid") return { status: "existing" }; + if (preflight.kind === "refused") { + return { status: "refused", reason: preflight.reason }; + } + + const hook = configInitializationBeforePublishForTests; + configInitializationBeforePublishForTests = null; + hook?.(); + + return withBoundConfigRootSync(admittedRoot.identity, () => + initializeConfigInBoundRoot( + validated.config, + admittedRoot.identity, + deadline, + testAction, + )); + } catch (error) { + return { + status: "refused", + reason: error instanceof ConfigRootChangedDuringInitializationError + ? "existing-unsafe" + : "coordination-unavailable", + }; + } +} + +export function initializeConfigIfMissing( + candidate: CodexCommanderConfig, +): ConfigInitializationResult { + return initializeConfigIfMissingInternal(candidate); +} + +/** Explicit, immutable test action; production initialization has no mutable root seam. */ +export function initializeConfigIfMissingForTests( + candidate: CodexCommanderConfig, + action: ConfigInitializationTestAction, +): ConfigInitializationResult { + return initializeConfigIfMissingInternal(candidate, action); +} + /** * The persisted config, plus a digest of the EXACT bytes it was parsed from. * @@ -1995,8 +2224,7 @@ export class ConfigMutationLockError extends Error { } } -function configMutationDatabasePath(): string { - const dir = getConfigDir(); +function configMutationDatabasePath(dir = getConfigDir()): string { // First statement on purpose: a rejected mutation must leave nothing behind, not a // freshly created/chmod'd directory or database. See src/lib/test-home-guard.ts. assertNotRealHomeUnderTest(dir); @@ -2041,6 +2269,10 @@ let configMutationDatabase: Database | null = null; * Reentrancy is limited to the current synchronous call stack; never return a Promise from `fn`. */ export function withConfigMutationLockSync(fn: () => T): T { + return withConfigMutationLockAtDirSync(getConfigDir(), fn); +} + +function withConfigMutationLockAtDirSync(dir: string, fn: () => T): T { if (configMutationLockDepth > 0) { configMutationLockDepth += 1; try { @@ -2049,7 +2281,7 @@ export function withConfigMutationLockSync(fn: () => T): T { configMutationLockDepth -= 1; } } - const path = configMutationDatabasePath(); + const path = configMutationDatabasePath(dir); let database: Database | undefined; let transactionOpen = false; try { diff --git a/tests/config.test.ts b/tests/config.test.ts index 71e6fdf8da..8fbcc3e03d 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; -import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, renameSync, rmSync, symlinkSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; +import * as nodeFs from "node:fs"; +import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, symlinkSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; @@ -25,6 +26,7 @@ import { removeRuntimePort, setConfigInitializationBeforePublishForTests, validateConfigCandidate, + withConfigMutationLockSync, writeRuntimePort, writePid, } from "../src/config"; @@ -54,6 +56,25 @@ function backupNames(): string[] { return readdirSync(testDir).filter(name => name.startsWith("config.json.invalid-")); } +function replaceAnchoredRoot( + root: string, + displacedRoot: string, + replacementMode: number, +): "replaced" | "blocked" { + try { + renameSync(root, displacedRoot); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if ( + process.platform === "win32" + && (code === "EACCES" || code === "EBUSY" || code === "EPERM") + ) return "blocked"; + throw error; + } + mkdirSync(root, { mode: replacementMode }); + return "replaced"; +} + function writeConfig(content: unknown): void { const current = content && typeof content === "object" && !Array.isArray(content) ? { multiAgentGuidanceEnabled: true, ...content as Record } @@ -109,8 +130,10 @@ function writeAccountNamespaceConfig( describe("create-only config initialization", () => { test("creates the canonical candidate only when config.json is absent", () => { + const previousCwd = process.cwd(); const candidate = getDefaultConfig(); expect(initializeConfigIfMissing(candidate)).toEqual({ status: "created" }); + expect(process.cwd()).toBe(previousCwd); expect(JSON.parse(readFileSync(getConfigPath(), "utf8"))).toEqual(candidate); expect(lstatSync(getConfigPath()).isFile()).toBe(true); if (process.platform !== "win32") { @@ -273,6 +296,74 @@ describe("create-only config initialization", () => { } }, { timeout: 20_000 }); + test("a subprocess paused after the final root check leaves a swapped-in root untouched", async () => { + const displacedRoot = `${testDir}.barrier-boundary`; + const readyPath = `${testDir}.barrier-ready`; + const releasePath = `${testDir}.barrier-release`; + const configModuleUrl = pathToFileURL(join(import.meta.dir, "../src/config.ts")).href; + const childSource = ` + import { + getDefaultConfig, + initializeConfigIfMissingForTests, + } from ${JSON.stringify(configModuleUrl)}; + const cwdBefore = process.cwd(); + const result = initializeConfigIfMissingForTests(getDefaultConfig(), { + kind: "barrier", + stage: "after-final-root-check", + readyPath: ${JSON.stringify(readyPath)}, + releasePath: ${JSON.stringify(releasePath)}, + }); + console.log(JSON.stringify({ result, cwdBefore, cwdAfter: process.cwd() })); + `; + const child = Bun.spawn([process.execPath, "-e", childSource], { + cwd: join(import.meta.dir, ".."), + env: { ...process.env, CODEXCOMMANDER_HOME: testDir }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + + try { + for (let attempt = 0; attempt < 1_000; attempt += 1) { + if (existsSync(readyPath) || child.exitCode !== null) break; + await Bun.sleep(5); + } + if (!existsSync(readyPath)) { + const stderr = await new Response(child.stderr).text(); + throw new Error(`Initializer exited before the root-swap barrier: ${stderr}`); + } + const swap = replaceAnchoredRoot(testDir, displacedRoot, 0o700); + writeFileSync(releasePath, "release"); + + const exitCode = await Promise.race([ + child.exited, + Bun.sleep(10_000).then(() => null), + ]); + expect(exitCode).toBe(0); + const stdout = await new Response(child.stdout).text(); + const payload = JSON.parse(stdout.trim()) as { + result: ReturnType; + cwdBefore: string; + cwdAfter: string; + }; + expect(payload.cwdAfter).toBe(payload.cwdBefore); + if (swap === "replaced") { + expect(payload.result).toEqual({ status: "refused", reason: "existing-unsafe" }); + expect(readdirSync(testDir)).toEqual([]); + } else { + expect(payload.result).toEqual({ status: "created" }); + expect(JSON.parse(readFileSync(getConfigPath(), "utf8"))).toEqual(getDefaultConfig()); + } + } finally { + writeFileSync(releasePath, "release"); + if (child.exitCode === null) child.kill(); + await child.exited; + rmSync(displacedRoot, { recursive: true, force: true }); + rmSync(readyPath, { force: true }); + rmSync(releasePath, { force: true }); + } + }, { timeout: 20_000 }); + test("refuses a linked configuration root even when its target has valid ownership", () => { const realRoot = join(testDir, "owned-real-root"); const linkedRoot = join(testDir, "linked-root"); @@ -327,6 +418,110 @@ describe("create-only config initialization", () => { } }); + test("leaves a replacement root untouched when replacement lands at lock acquisition", () => { + const previousCwd = process.cwd(); + const displacedRoot = `${testDir}.lock-boundary`; + const originalChmod = nodeFs.chmodSync; + let swap: "pending" | "replaced" | "blocked" = "pending"; + const chmodSpy = spyOn(nodeFs, "chmodSync").mockImplementation(((...args: unknown[]) => { + if (swap === "pending" && (args[0] === testDir || args[0] === ".") && args[1] === 0o700) { + swap = replaceAnchoredRoot(testDir, displacedRoot, 0o755); + } + return (originalChmod as (...values: unknown[]) => void)(...args); + }) as typeof nodeFs.chmodSync); + + try { + const result = initializeConfigIfMissing(getDefaultConfig()); + if (swap === "replaced") { + expect({ result, replacementEntries: readdirSync(testDir) }).toEqual({ + result: { status: "refused", reason: "existing-unsafe" }, + replacementEntries: [], + }); + expect(lstatSync(testDir).mode & 0o777).toBe(0o755); + } else { + expect(swap).toBe("blocked"); + expect(result).toEqual({ status: "created" }); + } + expect(process.cwd()).toBe(previousCwd); + } finally { + chmodSpy.mockRestore(); + rmSync(displacedRoot, { recursive: true, force: true }); + } + }); + + test("leaves a replacement root untouched when replacement lands after the final publication check", () => { + const previousCwd = process.cwd(); + const displacedRoot = `${testDir}.publication-boundary`; + const originalWrite = nodeFs.writeFileSync; + let swap: "pending" | "replaced" | "blocked" = "pending"; + const writeSpy = spyOn(nodeFs, "writeFileSync").mockImplementation(((...args: unknown[]) => { + if ( + swap === "pending" + && typeof args[0] === "string" + && args[0].endsWith(".create.tmp") + ) { + swap = replaceAnchoredRoot(testDir, displacedRoot, 0o700); + } + return (originalWrite as (...values: unknown[]) => unknown)(...args); + }) as typeof nodeFs.writeFileSync); + + try { + const result = initializeConfigIfMissing(getDefaultConfig()); + if (swap === "replaced") { + expect({ result, replacementEntries: readdirSync(testDir) }).toEqual({ + result: { status: "refused", reason: "existing-unsafe" }, + replacementEntries: [], + }); + } else { + expect(swap).toBe("blocked"); + expect(result).toEqual({ status: "created" }); + } + expect(process.cwd()).toBe(previousCwd); + } finally { + writeSpy.mockRestore(); + rmSync(displacedRoot, { recursive: true, force: true }); + } + }); + + test("restores the previous cwd when bound publication throws", () => { + const previousCwd = process.cwd(); + const expectedBoundCwd = realpathSync.native(testDir); + const originalWrite = nodeFs.writeFileSync; + let observedWriteCwd: string | null = null; + const writeSpy = spyOn(nodeFs, "writeFileSync").mockImplementation(((...args: unknown[]) => { + if (typeof args[0] === "string" && args[0].endsWith(".create.tmp")) { + observedWriteCwd = realpathSync.native(process.cwd()); + throw new Error("publication fixture failure"); + } + return (originalWrite as (...values: unknown[]) => unknown)(...args); + }) as typeof nodeFs.writeFileSync); + + try { + expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ + status: "refused", + reason: "coordination-unavailable", + }); + expect(observedWriteCwd).toBe(expectedBoundCwd); + expect(process.cwd()).toBe(previousCwd); + } finally { + writeSpy.mockRestore(); + } + }); + + test("refuses reentrant initialization without entering the cwd fence", () => { + const previousCwd = process.cwd(); + let result: ReturnType | undefined; + + withConfigMutationLockSync(() => { + result = initializeConfigIfMissing(getDefaultConfig()); + expect(process.cwd()).toBe(previousCwd); + }); + + expect(result).toEqual({ status: "refused", reason: "coordination-unavailable" }); + expect(existsSync(getConfigPath())).toBe(false); + expect(process.cwd()).toBe(previousCwd); + }); + test("distinguishes bigint root identities whose inode numbers collide after numeric conversion", () => { const firstIno = 2n ** 53n; const replacementIno = firstIno + 1n; From aee935434aa7821be9ff2192e9b17f560c2c7df1 Mon Sep 17 00:00:00 2001 From: pavelhov Date: Fri, 21 Aug 2026 00:14:14 -0400 Subject: [PATCH 22/28] fix(config): isolate fenced initialization inputs --- src/config.ts | 214 +++++++++++++++++++++++++++++++++++-------- tests/config.test.ts | 156 ++++++++++++++++++++++++++++++- 2 files changed, 331 insertions(+), 39 deletions(-) diff --git a/src/config.ts b/src/config.ts index 14988022b8..130e3b60be 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1663,6 +1663,7 @@ type ConfigEntryProbe = ConfigInitializationRefusal, "candidate-invalid" | "coordination-unavailable" >; + retryablePublicationState?: boolean; }; type ConfigRootIdentity = { @@ -1686,6 +1687,7 @@ class ConfigRootChangedDuringInitializationError extends Error { const CONFIG_INITIALIZATION_WAIT_MS = 2_000; const CONFIG_INITIALIZATION_POLL_MS = 10; +const CONFIG_INITIALIZATION_CONFIG_PATH = "config.json"; function samePhysicalConfigRoot(left: ConfigRootIdentity, right: ConfigRootIdentity): boolean { const sameCanonicalPath = process.platform === "win32" @@ -1804,9 +1806,18 @@ function probeConfigEntry(path = getConfigPath()): ConfigEntryProbe { ? { kind: "missing" } : { kind: "refused", reason: "existing-inaccessible" }; } - if (!entry.isFile() || entry.isSymbolicLink() || entry.nlink !== 1) { + if (!entry.isFile() || entry.isSymbolicLink()) { return { kind: "refused", reason: "existing-unsafe" }; } + if (entry.nlink !== 1) { + return { + kind: "refused", + reason: "existing-unsafe", + // The no-clobber publisher has exactly two links only between link() and + // temp cleanup. Other link counts remain a static unsafe destination. + retryablePublicationState: entry.nlink === 2, + }; + } try { return configDiagnosticsFromRaw(readFileSync(path, "utf8")).source === "file" ? { kind: "valid" } @@ -1840,12 +1851,13 @@ function unlinkPublishedConfigIfUnchanged( } function publishConfigNoReplace( - path: string, bytes: string, expectedRoot: ConfigRootIdentity, ): boolean { - const target = resolveWriteTarget(path); - assertResolvedTargetAllowed(path, target); + // This literal directory entry is already validated by the initializer. Resolving it + // would follow a contender symlink or turn it into an absolute pathname that can escape + // the admitted physical root after that root is renamed. + const target = CONFIG_INITIALIZATION_CONFIG_PATH; const temp = `${target}.ccx.${process.pid}.${++_atomicSeq}.create.tmp`; let published = false; let collision = false; @@ -1854,7 +1866,7 @@ function publishConfigNoReplace( writeFileSync(temp, bytes, { encoding: "utf8", mode: 0o600, flag: "wx" }); try { chmodSync(temp, 0o600); } catch { /* filesystem may ignore chmod */ } if (process.platform === "win32") { - hardenSecretPath(temp, { required: true, timeoutMemoKey: path }); + hardenSecretPath(temp, { required: true, timeoutMemoKey: target }); } const source = lstatSync(temp, { bigint: true }); if (!source.isFile() || source.isSymbolicLink() || source.nlink !== 1n) { @@ -1949,23 +1961,136 @@ export type ConfigInitializationTestAction = { releasePath: string; }; -function runConfigInitializationTestAction( +type PreparedConfigInitializationTestAction = Readonly<{ + readyPath: string; + releasePath: string; +}>; + +type PreparedConfigInitialization = + | Readonly<{ + kind: "ready"; + bytes: string; + testAction?: PreparedConfigInitializationTestAction; + }> + | Readonly<{ + kind: "refused"; + reason: "candidate-invalid" | "coordination-unavailable"; + }>; + +let configInitializationPreparationDepth = 0; + +function canonicalConfigInitializationBytes( + candidate: CodexCommanderConfig, + callerCwd: string, +): string | null { + process.chdir(callerCwd); + const validated = validateConfigCandidate(candidate); + process.chdir(callerCwd); + if (!validated.ok) return null; + + // `desktopProfile` is deliberately `unknown` at the Zod boundary. Serialize it while + // still outside both CWD fences, then parse and validate that private data-only copy. + // The second stringify cannot reach caller getters or inherited `toJSON` methods. + const serialized = JSON.stringify(validated.config); + process.chdir(callerCwd); + if (serialized === undefined) return null; + const privateCandidate = JSON.parse(serialized) as unknown; + const canonical = validateConfigCandidate(privateCandidate); + if (!canonical.ok) return null; + return `${JSON.stringify(canonical.config, null, 2)}\n`; +} + +function snapshotConfigInitializationTestAction( action: ConfigInitializationTestAction, - expectedRoot: ConfigRootIdentity, -): void { - if (!isTestHomeGuardArmed()) { - throw new Error("Config initialization test actions require the explicit test-home guard"); - } + configRootPath: string, + callerCwd: string, +): PreparedConfigInitializationTestAction | null { + if (!isTestHomeGuardArmed()) return null; + + process.chdir(callerCwd); + const keys = Reflect.ownKeys(action as object); + process.chdir(callerCwd); + const kind = action.kind; + process.chdir(callerCwd); + const stage = action.stage; + process.chdir(callerCwd); + const readyPath = action.readyPath; + process.chdir(callerCwd); + const releasePath = action.releasePath; + process.chdir(callerCwd); + + const expectedKeys = new Set(["kind", "stage", "readyPath", "releasePath"]); if ( - action.kind !== "barrier" - || action.stage !== "after-final-root-check" - || !isAbsolute(action.readyPath) - || !isAbsolute(action.releasePath) - || relativePathWithin(expectedRoot.path, action.readyPath) !== null - || relativePathWithin(expectedRoot.path, action.releasePath) !== null - ) { - throw new Error("Config initialization test barrier paths must be absolute and outside the config root"); + keys.length !== expectedKeys.size + || keys.some(key => typeof key !== "string" || !expectedKeys.has(key)) + || kind !== "barrier" + || stage !== "after-final-root-check" + || typeof readyPath !== "string" + || typeof releasePath !== "string" + || !isAbsolute(readyPath) + || !isAbsolute(releasePath) + || readyPath === releasePath + || relativePathWithin(configRootPath, readyPath) !== null + || relativePathWithin(configRootPath, releasePath) !== null + ) return null; + + return Object.freeze({ readyPath, releasePath }); +} + +function prepareConfigInitialization( + candidate: CodexCommanderConfig, + action: ConfigInitializationTestAction | undefined, + configRootPath: string, +): PreparedConfigInitialization { + const callerCwd = process.cwd(); + let prepared: PreparedConfigInitialization = { + kind: "refused", + reason: "candidate-invalid", + }; + let restoreFailed = false; + configInitializationPreparationDepth = 1; + try { + let bytes: string | null = null; + try { + bytes = canonicalConfigInitializationBytes(candidate, callerCwd); + } catch { + bytes = null; + } + if (bytes !== null) { + let testAction: PreparedConfigInitializationTestAction | undefined; + let actionValid = true; + if (action !== undefined) { + try { + testAction = snapshotConfigInitializationTestAction( + action, + configRootPath, + callerCwd, + ) ?? undefined; + actionValid = testAction !== undefined; + } catch { + actionValid = false; + } + } + prepared = actionValid + ? Object.freeze({ kind: "ready", bytes, ...(testAction ? { testAction } : {}) }) + : { kind: "refused", reason: "coordination-unavailable" }; + } + } finally { + try { + process.chdir(callerCwd); + } catch { + restoreFailed = true; + } + configInitializationPreparationDepth = 0; } + return restoreFailed + ? { kind: "refused", reason: "coordination-unavailable" } + : prepared; +} + +function runConfigInitializationTestAction( + action: PreparedConfigInitializationTestAction, +): void { writeFileSync(action.readyPath, "ready", { encoding: "utf8", flag: "wx", mode: 0o600 }); const deadline = performance.now() + 10_000; while (!existsSync(action.releasePath)) { @@ -1977,12 +2102,12 @@ function runConfigInitializationTestAction( } function initializeConfigInBoundRoot( - candidate: CodexCommanderConfig, + bytes: string, expectedRoot: ConfigRootIdentity, deadline: number, - testAction?: ConfigInitializationTestAction, + testAction?: PreparedConfigInitializationTestAction, ): ConfigInitializationResult { - const configPath = "config.json"; + const configPath = CONFIG_INITIALIZATION_CONFIG_PATH; const observed = probeConfigEntry(configPath); if (!boundConfigRootStillMatches(expectedRoot) || !configRootStillMatches(expectedRoot)) { throw new ConfigRootChangedDuringInitializationError(); @@ -2020,6 +2145,7 @@ function initializeConfigInBoundRoot( throw new ConfigRootChangedDuringInitializationError(); } try { + const remainingWaitMs = Math.max(0, Math.ceil(deadline - performance.now())); return withConfigMutationLockAtDirSync(".", () => { if (!boundConfigRootStillMatches(expectedRoot) || !configRootStillMatches(expectedRoot)) { throw new ConfigRootChangedDuringInitializationError(); @@ -2030,19 +2156,18 @@ function initializeConfigInBoundRoot( return { status: "refused", reason: current.reason } as const; } - const bytes = `${JSON.stringify(candidate, null, 2)}\n`; if (!boundConfigRootStillMatches(expectedRoot) || !configRootStillMatches(expectedRoot)) { throw new ConfigRootChangedDuringInitializationError(); } const action = pendingTestAction; pendingTestAction = undefined; if (action) { - runConfigInitializationTestAction(action, expectedRoot); + runConfigInitializationTestAction(action); if (!boundConfigRootStillMatches(expectedRoot) || !configRootStillMatches(expectedRoot)) { throw new ConfigRootChangedDuringInitializationError(); } } - const published = publishConfigNoReplace(configPath, bytes, expectedRoot); + const published = publishConfigNoReplace(bytes, expectedRoot); if (!published) { if (!boundConfigRootStillMatches(expectedRoot) || !configRootStillMatches(expectedRoot)) { throw new ConfigRootChangedDuringInitializationError(); @@ -2056,7 +2181,7 @@ function initializeConfigInBoundRoot( } bumpGenerationForCooperatingConfigWrite(); return { status: "created" } as const; - }); + }, remainingWaitMs); } catch (error) { if (error instanceof ConfigRootChangedDuringInitializationError) throw error; if (!(error instanceof ConfigMutationLockError)) { @@ -2080,13 +2205,17 @@ function initializeConfigIfMissingInternal( candidate: CodexCommanderConfig, testAction?: ConfigInitializationTestAction, ): ConfigInitializationResult { - const validated = validateConfigCandidate(candidate); - if (!validated.ok) return { status: "refused", reason: "candidate-invalid" }; - // Entering a process-global CWD fence from an arbitrary outer mutation callback - // would create a reentrant seam while relative paths are authoritative. - if (configMutationLockDepth > 0) { + // Reject reentry before inspecting caller-owned candidate/action values. Preparation + // itself may run hostile getters or `toJSON`, but only at the restored caller CWD and + // before any configuration-root mutation. + if (configInitializationPreparationDepth > 0 || configMutationLockDepth > 0) { return { status: "refused", reason: "coordination-unavailable" }; } + const configRootPath = getConfigDir(); + const prepared = prepareConfigInitialization(candidate, testAction, configRootPath); + if (prepared.kind === "refused") { + return { status: "refused", reason: prepared.reason }; + } const deadline = performance.now() + CONFIG_INITIALIZATION_WAIT_MS; const initialRoot = probeConfigRoot(); if (initialRoot.kind === "refused") { @@ -2116,7 +2245,7 @@ function initializeConfigIfMissingInternal( try { const preflight = withBoundConfigRootSync(admittedRoot.identity, () => { - const observed = probeConfigEntry("config.json"); + const observed = probeConfigEntry(CONFIG_INITIALIZATION_CONFIG_PATH); if (!boundConfigRootStillMatches(admittedRoot.identity) || !configRootStillMatches(admittedRoot.identity)) { throw new ConfigRootChangedDuringInitializationError(); @@ -2125,7 +2254,14 @@ function initializeConfigIfMissingInternal( }); if (preflight.kind === "valid") return { status: "existing" }; if (preflight.kind === "refused") { - return { status: "refused", reason: preflight.reason }; + if (!preflight.retryablePublicationState) { + return { status: "refused", reason: preflight.reason }; + } + return waitForConfigInitializationWinner( + admittedRoot.identity, + deadline, + preflight.reason, + ); } const hook = configInitializationBeforePublishForTests; @@ -2134,10 +2270,10 @@ function initializeConfigIfMissingInternal( return withBoundConfigRootSync(admittedRoot.identity, () => initializeConfigInBoundRoot( - validated.config, + prepared.bytes, admittedRoot.identity, deadline, - testAction, + prepared.testAction, )); } catch (error) { return { @@ -2272,7 +2408,11 @@ export function withConfigMutationLockSync(fn: () => T): T { return withConfigMutationLockAtDirSync(getConfigDir(), fn); } -function withConfigMutationLockAtDirSync(dir: string, fn: () => T): T { +function withConfigMutationLockAtDirSync( + dir: string, + fn: () => T, + busyTimeoutMs = 0, +): T { if (configMutationLockDepth > 0) { configMutationLockDepth += 1; try { @@ -2287,7 +2427,7 @@ function withConfigMutationLockAtDirSync(dir: string, fn: () => T): T { try { database = new Database(path, { create: true }); try { chmodSync(path, 0o600); } catch { /* platform may ignore chmod */ } - database.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE"); + database.exec(`PRAGMA busy_timeout = ${busyTimeoutMs}; BEGIN IMMEDIATE`); transactionOpen = true; initializeConfigGeneration(database); } catch (cause) { diff --git a/tests/config.test.ts b/tests/config.test.ts index 8fbcc3e03d..40b1d41853 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import * as nodeFs from "node:fs"; import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, symlinkSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { isAbsolute, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { CODEX_SHIM_AUTO_RESTORE_ENV, @@ -15,6 +15,7 @@ import { isValidProviderName, isCodexCommanderStartCommandLine, initializeConfigIfMissing, + initializeConfigIfMissingForTests, loadConfig, multiAgentGuidanceEnabled, parsePidFile, @@ -283,7 +284,10 @@ describe("create-only config initialization", () => { return JSON.parse(stdout.trim()) as ReturnType; })); - expect(results.map(result => result.status).sort()).toEqual(["created", "existing"]); + expect(results.sort((left, right) => left.status.localeCompare(right.status))).toEqual([ + { status: "created" }, + { status: "existing" }, + ]); const finalPath = join(raceRoot, "config.json"); expect(JSON.parse(readFileSync(finalPath, "utf8"))).toEqual(getDefaultConfig()); expect(lstatSync(finalPath).nlink).toBe(1); @@ -364,6 +368,54 @@ describe("create-only config initialization", () => { } }, { timeout: 20_000 }); + test("a contender symlink cannot move publication outside the admitted root", () => { + const displacedRoot = `${testDir}.contender-root`; + const externalDir = `${testDir}.contender-external`; + const externalTarget = join(externalDir, "external-config.json"); + const readyPath = `${testDir}.contender-ready`; + const releasePath = `${testDir}.contender-release`; + mkdirSync(externalDir, { mode: 0o700 }); + writeFileSync(externalTarget, "external bytes", { mode: 0o600 }); + writeFileSync(releasePath, "release", { mode: 0o600 }); + + const originalWrite = nodeFs.writeFileSync; + let swap: "pending" | "replaced" | "blocked" = "pending"; + const writeSpy = spyOn(nodeFs, "writeFileSync").mockImplementation(((...args: unknown[]) => { + if (args[0] === readyPath) { + symlinkSync(externalTarget, "config.json"); + } else if ( + swap === "pending" + && typeof args[0] === "string" + && args[0].endsWith(".create.tmp") + ) { + swap = replaceAnchoredRoot(testDir, displacedRoot, 0o700); + if (isAbsolute(args[0])) { + throw new Error("publication escaped the admitted root"); + } + } + return (originalWrite as (...values: unknown[]) => unknown)(...args); + }) as typeof nodeFs.writeFileSync); + + try { + expect(initializeConfigIfMissingForTests(getDefaultConfig(), { + kind: "barrier", + stage: "after-final-root-check", + readyPath, + releasePath, + })).toEqual({ status: "refused", reason: "existing-unsafe" }); + expect(readFileSync(externalTarget, "utf8")).toBe("external bytes"); + expect(readdirSync(externalDir)).toEqual(["external-config.json"]); + if (swap === "replaced") expect(readdirSync(testDir)).toEqual([]); + else expect(swap).toBe("blocked"); + } finally { + writeSpy.mockRestore(); + rmSync(displacedRoot, { recursive: true, force: true }); + rmSync(externalDir, { recursive: true, force: true }); + rmSync(readyPath, { force: true }); + rmSync(releasePath, { force: true }); + } + }); + test("refuses a linked configuration root even when its target has valid ownership", () => { const realRoot = join(testDir, "owned-real-root"); const linkedRoot = join(testDir, "linked-root"); @@ -508,6 +560,106 @@ describe("create-only config initialization", () => { } }); + test("canonicalizes inherited toJSON before config mutation and blocks reentry", () => { + const originalCwd = process.cwd(); + process.chdir(testDir); + const callerCwd = process.cwd(); + const hostileCwd = `${testDir}.hostile-cwd`; + mkdirSync(hostileCwd, { mode: 0o700 }); + let toJsonCwd: string | null = null; + let reentrantResult: ReturnType | null = null; + const profilePrototype = { + toJSON(): never { + toJsonCwd = process.cwd(); + reentrantResult = initializeConfigIfMissing(getDefaultConfig()); + process.chdir(hostileCwd); + throw new Error("hostile inherited toJSON"); + }, + }; + const desktopProfile = Object.assign(Object.create(profilePrototype) as object, { + version: 1, + assignments: {}, + defaults: { opus: null, fable: null, sonnet: null, haiku: null }, + }); + const defaults = getDefaultConfig(); + const candidate = { + ...defaults, + claudeCode: { ...defaults.claudeCode, desktopProfile }, + } as typeof defaults; + + try { + expect(initializeConfigIfMissing(candidate)).toEqual({ + status: "refused", + reason: "candidate-invalid", + }); + expect(toJsonCwd).toBe(callerCwd); + expect(reentrantResult).toEqual({ + status: "refused", + reason: "coordination-unavailable", + }); + expect(process.cwd()).toBe(callerCwd); + expect(readdirSync(testDir)).toEqual([]); + } finally { + process.chdir(originalCwd); + rmSync(hostileCwd, { recursive: true, force: true }); + } + }); + + test("snapshots mutable test-action getters once before entering the cwd fence", () => { + const callerCwd = process.cwd(); + const readyPath = `${testDir}.mutable-action-ready`; + const releasePath = `${testDir}.mutable-action-release`; + const hostileCwd = `${testDir}.mutable-action-cwd`; + writeFileSync(releasePath, "release", { mode: 0o600 }); + mkdirSync(hostileCwd, { mode: 0o700 }); + const reads = { kind: 0, stage: 0, readyPath: 0, releasePath: 0 }; + const observedCwds: string[] = []; + let reentrantResult: ReturnType | null = null; + const action: Parameters[1] = { + get kind(): "barrier" { + reads.kind += 1; + observedCwds.push(process.cwd()); + reentrantResult = initializeConfigIfMissing(getDefaultConfig()); + process.chdir(hostileCwd); + return "barrier"; + }, + get stage(): "after-final-root-check" { + reads.stage += 1; + observedCwds.push(process.cwd()); + return "after-final-root-check"; + }, + get readyPath(): string { + reads.readyPath += 1; + observedCwds.push(process.cwd()); + return reads.readyPath <= 2 ? readyPath : getConfigPath(); + }, + get releasePath(): string { + reads.releasePath += 1; + observedCwds.push(process.cwd()); + return releasePath; + }, + }; + + try { + expect(initializeConfigIfMissingForTests(getDefaultConfig(), action)).toEqual({ + status: "created", + }); + expect(reads).toEqual({ kind: 1, stage: 1, readyPath: 1, releasePath: 1 }); + expect(observedCwds).toEqual(Array(4).fill(callerCwd)); + expect(reentrantResult).toEqual({ + status: "refused", + reason: "coordination-unavailable", + }); + expect(JSON.parse(readFileSync(getConfigPath(), "utf8"))).toEqual(getDefaultConfig()); + expect(readFileSync(readyPath, "utf8")).toBe("ready"); + expect(process.cwd()).toBe(callerCwd); + } finally { + rmSync(readyPath, { force: true }); + rmSync(releasePath, { force: true }); + rmSync(hostileCwd, { recursive: true, force: true }); + } + }); + test("refuses reentrant initialization without entering the cwd fence", () => { const previousCwd = process.cwd(); let result: ReturnType | undefined; From 40c878f6cc40e49cc5f1dd54230e52a3a56d5557 Mon Sep 17 00:00:00 2001 From: pavelhov Date: Fri, 21 Aug 2026 00:41:45 -0400 Subject: [PATCH 23/28] fix(config): close final initializer races --- src/config.ts | 266 +++++++++++++++++++++++++++++++++++-------- tests/config.test.ts | 258 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 465 insertions(+), 59 deletions(-) diff --git a/src/config.ts b/src/config.ts index 130e3b60be..f034359bd1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,6 @@ import { execFileSync } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; -import { chmodSync, copyFileSync, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, closeSync, copyFileSync, existsSync, fchmodSync, fstatSync, ftruncateSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { Database } from "bun:sqlite"; @@ -1656,14 +1656,14 @@ export type ConfigInitializationResult = type ConfigEntryProbe = | { kind: "missing" } - | { kind: "valid" } + | { kind: "valid"; identity: ConfigRootFileIdentity } + | { kind: "publishing"; identity: ConfigRootFileIdentity } | { kind: "refused"; reason: Exclude< ConfigInitializationRefusal, "candidate-invalid" | "coordination-unavailable" >; - retryablePublicationState?: boolean; }; type ConfigRootIdentity = { @@ -1800,27 +1800,31 @@ function withBoundConfigRootSync( function probeConfigEntry(path = getConfigPath()): ConfigEntryProbe { let entry; try { - entry = lstatSync(path); + entry = lstatSync(path, { bigint: true }); } catch (error) { return isMissingPathError(error) ? { kind: "missing" } : { kind: "refused", reason: "existing-inaccessible" }; } - if (!entry.isFile() || entry.isSymbolicLink()) { + if (!entry.isFile() || entry.isSymbolicLink() || entry.ino === 0n) { return { kind: "refused", reason: "existing-unsafe" }; } - if (entry.nlink !== 1) { - return { - kind: "refused", - reason: "existing-unsafe", - // The no-clobber publisher has exactly two links only between link() and - // temp cleanup. Other link counts remain a static unsafe destination. - retryablePublicationState: entry.nlink === 2, - }; - } + const identity = { dev: entry.dev, ino: entry.ino }; + if (entry.nlink === 2n) return { kind: "publishing", identity }; + if (entry.nlink !== 1n) return { kind: "refused", reason: "existing-unsafe" }; try { - return configDiagnosticsFromRaw(readFileSync(path, "utf8")).source === "file" - ? { kind: "valid" } + const raw = readFileSync(path, "utf8"); + const afterRead = lstatSync(path, { bigint: true }); + if ( + !afterRead.isFile() + || afterRead.isSymbolicLink() + || afterRead.nlink !== 1n + || !sameConfigRootFileIdentity(identity, afterRead) + ) { + return { kind: "refused", reason: "existing-unsafe" }; + } + return configDiagnosticsFromRaw(raw).source === "file" + ? { kind: "valid", identity } : { kind: "refused", reason: "existing-invalid" }; } catch { return { kind: "refused", reason: "existing-inaccessible" }; @@ -1850,6 +1854,22 @@ function unlinkPublishedConfigIfUnchanged( unlinkSync(path); } +function publicationPathMatchesOwnedFile( + path: string, + expected: ConfigRootFileIdentity, + expectedLinks: bigint, +): boolean { + try { + const current = lstatSync(path, { bigint: true }); + return current.isFile() + && !current.isSymbolicLink() + && current.nlink === expectedLinks + && sameConfigRootFileIdentity(expected, current); + } catch { + return false; + } +} + function publishConfigNoReplace( bytes: string, expectedRoot: ConfigRootIdentity, @@ -1861,18 +1881,29 @@ function publishConfigNoReplace( const temp = `${target}.ccx.${process.pid}.${++_atomicSeq}.create.tmp`; let published = false; let collision = false; + let created = false; + let descriptor: number | undefined; let publishedIdentity: ConfigRootFileIdentity | null = null; + let cleanupFailure: unknown = null; try { - writeFileSync(temp, bytes, { encoding: "utf8", mode: 0o600, flag: "wx" }); - try { chmodSync(temp, 0o600); } catch { /* filesystem may ignore chmod */ } + descriptor = openSync(temp, "wx", 0o600); + created = true; + const source = fstatSync(descriptor, { bigint: true }); + if (!source.isFile() || source.ino === 0n || source.nlink !== 1n) { + throw new Error("Configuration publication source is not a private regular file"); + } + publishedIdentity = { dev: source.dev, ino: source.ino }; + writeFileSync(descriptor, bytes, { encoding: "utf8" }); + try { fchmodSync(descriptor, 0o600); } catch { /* filesystem may ignore chmod */ } if (process.platform === "win32") { + if (!publicationPathMatchesOwnedFile(temp, publishedIdentity, 1n)) { + throw new Error("Configuration publication source identity changed before hardening"); + } hardenSecretPath(temp, { required: true, timeoutMemoKey: target }); } - const source = lstatSync(temp, { bigint: true }); - if (!source.isFile() || source.isSymbolicLink() || source.nlink !== 1n) { - throw new Error("Configuration publication source is not a private regular file"); + if (!publicationPathMatchesOwnedFile(temp, publishedIdentity, 1n)) { + throw new Error("Configuration publication source identity changed before linking"); } - publishedIdentity = { dev: source.dev, ino: source.ino }; try { linkSync(temp, target); published = true; @@ -1882,17 +1913,30 @@ function publishConfigNoReplace( } } finally { try { - unlinkSync(temp); - forgetEphemeralSecretPath(temp); - } catch (error) { - if (!isMissingPathError(error)) { - // After link succeeds, temp and destination are the same inode. Never - // truncate the temp in that state because it would erase config.json too. - if (!published) { - try { truncateSync(temp, 0); } catch { /* residual error below is authoritative */ } + if (created) { + if ( + !publishedIdentity + || !publicationPathMatchesOwnedFile(temp, publishedIdentity, published ? 2n : 1n) + ) { + throw new Error("Configuration publication temp identity changed before cleanup"); } - throw new AtomicWriteSecretResidualError(temp, { cause: error }); + unlinkSync(temp); + forgetEphemeralSecretPath(temp); + } + } catch (error) { + cleanupFailure = error; + // After link succeeds, descriptor and destination are the same inode. Never + // truncate the descriptor in that state because it would erase config.json too. + if (!published && descriptor !== undefined) { + try { ftruncateSync(descriptor, 0); } catch { /* residual error below is authoritative */ } } + } finally { + if (descriptor !== undefined) { + try { closeSync(descriptor); } catch (error) { cleanupFailure ??= error; } + } + } + if (cleanupFailure !== null) { + throw new AtomicWriteSecretResidualError(temp, { cause: cleanupFailure }); } } if (collision) return false; @@ -1941,12 +1985,46 @@ function waitForConfigInitializationWinner( expectedRoot: ConfigRootIdentity, deadline: number, fallback: ConfigInitializationRefusal, + initialPublicationIdentity?: ConfigRootFileIdentity, ): ConfigInitializationResult { let lastRefusal: ConfigInitializationRefusal | null = null; + let publicationIdentity = initialPublicationIdentity; for (;;) { - const observed = configInitializationContenderObservation(expectedRoot); - if ("status" in observed) return observed; - if (observed.kind === "refused") lastRefusal = observed.reason; + if (!configRootStillMatches(expectedRoot)) { + return { status: "refused", reason: "existing-unsafe" }; + } + const observed = probeConfigEntry(); + if (!configRootStillMatches(expectedRoot)) { + return { status: "refused", reason: "existing-unsafe" }; + } + if (publicationIdentity) { + if ( + observed.kind === "valid" + && sameConfigRootFileIdentity(publicationIdentity, observed.identity) + ) return { status: "existing" }; + if ( + observed.kind === "publishing" + && sameConfigRootFileIdentity(publicationIdentity, observed.identity) + ) { + // The exact inode is still in the publisher's two-link cleanup window. + } else { + return { + status: "refused", + reason: observed.kind === "refused" + ? observed.reason + : "existing-unsafe", + }; + } + } else if (observed.kind === "valid") { + return { status: "existing" }; + } else if (observed.kind === "publishing") { + publicationIdentity = observed.identity; + } else if (observed.kind === "refused") { + if (observed.reason === "existing-invalid") { + return { status: "refused", reason: observed.reason }; + } + lastRefusal = observed.reason; + } if (performance.now() >= deadline) { return { status: "refused", reason: lastRefusal ?? fallback }; } @@ -1977,8 +2055,62 @@ type PreparedConfigInitialization = reason: "candidate-invalid" | "coordination-unavailable"; }>; +type ConfigInitializationMutationResult = + | ConfigInitializationResult + | Readonly<{ + status: "awaiting-publication"; + identity: ConfigRootFileIdentity; + }>; + let configInitializationPreparationDepth = 0; +type ConfigInitializationJsonData = + | null + | boolean + | number + | string + | ConfigInitializationJsonData[] + | { [key: string]: ConfigInitializationJsonData }; + +function copyConfigInitializationJsonData( + value: unknown, +): ConfigInitializationJsonData | undefined { + if ( + value === null + || typeof value === "string" + || typeof value === "boolean" + ) return value; + if (typeof value === "number") return Number.isFinite(value) ? value : undefined; + if (Array.isArray(value)) { + const copy = new Array(value.length); + for (let index = 0; index < value.length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor || !("value" in descriptor)) return undefined; + const item = copyConfigInitializationJsonData(descriptor.value); + if (item === undefined) return undefined; + copy[index] = item; + } + Object.setPrototypeOf(copy, null); + return copy; + } + if (typeof value !== "object") return undefined; + + const copy = Object.create(null) as { [key: string]: ConfigInitializationJsonData }; + for (const key of Object.keys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor)) return undefined; + const item = copyConfigInitializationJsonData(descriptor.value); + if (item === undefined) return undefined; + Object.defineProperty(copy, key, { + configurable: true, + enumerable: true, + value: item, + writable: true, + }); + } + return copy; +} + function canonicalConfigInitializationBytes( candidate: CodexCommanderConfig, callerCwd: string, @@ -1997,7 +2129,15 @@ function canonicalConfigInitializationBytes( const privateCandidate = JSON.parse(serialized) as unknown; const canonical = validateConfigCandidate(privateCandidate); if (!canonical.ok) return null; - return `${JSON.stringify(canonical.config, null, 2)}\n`; + const dataOnlyCandidate = copyConfigInitializationJsonData(canonical.config); + if (dataOnlyCandidate === undefined) return null; + const exactCandidate = validateConfigCandidate(dataOnlyCandidate); + if (!exactCandidate.ok) return null; + const finalJson = JSON.stringify(dataOnlyCandidate, null, 2); + if (finalJson === undefined) return null; + const finalBytes = `${finalJson}\n`; + const finalCandidate = validateConfigCandidate(JSON.parse(finalBytes) as unknown); + return finalCandidate.ok ? finalBytes : null; } function snapshotConfigInitializationTestAction( @@ -2113,6 +2253,14 @@ function initializeConfigInBoundRoot( throw new ConfigRootChangedDuringInitializationError(); } if (observed.kind === "valid") return { status: "existing" }; + if (observed.kind === "publishing") { + return waitForConfigInitializationWinner( + expectedRoot, + deadline, + "existing-unsafe", + observed.identity, + ); + } if (observed.kind === "refused") return { status: "refused", reason: observed.reason }; let ownershipFailure: ConfigInitializationRefusal | null = null; @@ -2145,13 +2293,15 @@ function initializeConfigInBoundRoot( throw new ConfigRootChangedDuringInitializationError(); } try { - const remainingWaitMs = Math.max(0, Math.ceil(deadline - performance.now())); - return withConfigMutationLockAtDirSync(".", () => { + const mutationResult: ConfigInitializationMutationResult = withConfigMutationLockAtDirSync(".", () => { if (!boundConfigRootStillMatches(expectedRoot) || !configRootStillMatches(expectedRoot)) { throw new ConfigRootChangedDuringInitializationError(); } const current = probeConfigEntry(configPath); if (current.kind === "valid") return { status: "existing" } as const; + if (current.kind === "publishing") { + return { status: "awaiting-publication", identity: current.identity } as const; + } if (current.kind === "refused") { return { status: "refused", reason: current.reason } as const; } @@ -2174,6 +2324,9 @@ function initializeConfigInBoundRoot( } const winner = probeConfigEntry(configPath); if (winner.kind === "valid") return { status: "existing" } as const; + if (winner.kind === "publishing") { + return { status: "awaiting-publication", identity: winner.identity } as const; + } return { status: "refused", reason: winner.kind === "refused" ? winner.reason : "coordination-unavailable", @@ -2181,7 +2334,16 @@ function initializeConfigInBoundRoot( } bumpGenerationForCooperatingConfigWrite(); return { status: "created" } as const; - }, remainingWaitMs); + }, deadline); + if (mutationResult.status === "awaiting-publication") { + return waitForConfigInitializationWinner( + expectedRoot, + deadline, + "existing-unsafe", + mutationResult.identity, + ); + } + return mutationResult; } catch (error) { if (error instanceof ConfigRootChangedDuringInitializationError) throw error; if (!(error instanceof ConfigMutationLockError)) { @@ -2189,6 +2351,14 @@ function initializeConfigInBoundRoot( } const contender = configInitializationContenderObservation(expectedRoot); if ("status" in contender) return contender; + if (contender.kind === "publishing") { + return waitForConfigInitializationWinner( + expectedRoot, + deadline, + "existing-unsafe", + contender.identity, + ); + } if (contender.kind === "refused") lastContentionRefusal = contender.reason; if (performance.now() >= deadline) { return { @@ -2253,16 +2423,17 @@ function initializeConfigIfMissingInternal( return observed; }); if (preflight.kind === "valid") return { status: "existing" }; - if (preflight.kind === "refused") { - if (!preflight.retryablePublicationState) { - return { status: "refused", reason: preflight.reason }; - } + if (preflight.kind === "publishing") { return waitForConfigInitializationWinner( admittedRoot.identity, deadline, - preflight.reason, + "existing-unsafe", + preflight.identity, ); } + if (preflight.kind === "refused") { + return { status: "refused", reason: preflight.reason }; + } const hook = configInitializationBeforePublishForTests; configInitializationBeforePublishForTests = null; @@ -2411,7 +2582,7 @@ export function withConfigMutationLockSync(fn: () => T): T { function withConfigMutationLockAtDirSync( dir: string, fn: () => T, - busyTimeoutMs = 0, + deadline?: number, ): T { if (configMutationLockDepth > 0) { configMutationLockDepth += 1; @@ -2422,12 +2593,15 @@ function withConfigMutationLockAtDirSync( } } const path = configMutationDatabasePath(dir); + const remainingBusyTimeoutMs = (): number => deadline === undefined + ? 0 + : Math.max(0, Math.ceil(deadline - performance.now())); let database: Database | undefined; let transactionOpen = false; try { database = new Database(path, { create: true }); try { chmodSync(path, 0o600); } catch { /* platform may ignore chmod */ } - database.exec(`PRAGMA busy_timeout = ${busyTimeoutMs}; BEGIN IMMEDIATE`); + database.exec(`PRAGMA busy_timeout = ${remainingBusyTimeoutMs()}; BEGIN IMMEDIATE`); transactionOpen = true; initializeConfigGeneration(database); } catch (cause) { @@ -2448,7 +2622,7 @@ function withConfigMutationLockAtDirSync( configMutationDatabase = database; try { const value = fn(); - database.exec("COMMIT"); + database.exec(`PRAGMA busy_timeout = ${remainingBusyTimeoutMs()}; COMMIT`); transactionOpen = false; return value; } catch (error) { diff --git a/tests/config.test.ts b/tests/config.test.ts index 40b1d41853..b63c861a1c 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { Database } from "bun:sqlite"; import * as nodeFs from "node:fs"; import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, symlinkSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; @@ -300,6 +301,51 @@ describe("create-only config initialization", () => { } }, { timeout: 20_000 }); + test("only adopts the exact two-link publication inode transitioning to one link", () => { + const bytes = `${JSON.stringify(getDefaultConfig())}\n`; + + for (const replacement of [false, true]) { + const extraLink = join(testDir, `config-publication-${replacement}.tmp`); + writeFileSync(getConfigPath(), bytes, { mode: 0o600 }); + nodeFs.linkSync(getConfigPath(), extraLink); + const observedIdentity = lstatSync(getConfigPath(), { bigint: true }); + let intercepted = false; + const originalLstat = nodeFs.lstatSync; + const lstatSpy = spyOn(nodeFs, "lstatSync").mockImplementation(((...args: unknown[]) => { + const result = (originalLstat as (...values: unknown[]) => unknown)(...args); + if (!intercepted && args[0] === "config.json") { + intercepted = true; + if (replacement) { + unlinkSync(getConfigPath()); + unlinkSync(extraLink); + writeFileSync(getConfigPath(), bytes, { mode: 0o600 }); + } else { + unlinkSync(extraLink); + } + } + return result; + }) as typeof nodeFs.lstatSync); + + try { + const result = initializeConfigIfMissing(getDefaultConfig()); + const finalIdentity = lstatSync(getConfigPath(), { bigint: true }); + if (replacement) { + expect(result).toEqual({ status: "refused", reason: "existing-unsafe" }); + expect(sameConfigRootFileIdentity(observedIdentity, finalIdentity)).toBe(false); + } else { + expect(result).toEqual({ status: "existing" }); + expect(sameConfigRootFileIdentity(observedIdentity, finalIdentity)).toBe(true); + } + expect(finalIdentity.nlink).toBe(1n); + expect(readFileSync(getConfigPath(), "utf8")).toBe(bytes); + } finally { + lstatSpy.mockRestore(); + rmSync(getConfigPath(), { force: true }); + rmSync(extraLink, { force: true }); + } + } + }); + test("a subprocess paused after the final root check leaves a swapped-in root untouched", async () => { const displacedRoot = `${testDir}.barrier-boundary`; const readyPath = `${testDir}.barrier-ready`; @@ -378,12 +424,11 @@ describe("create-only config initialization", () => { writeFileSync(externalTarget, "external bytes", { mode: 0o600 }); writeFileSync(releasePath, "release", { mode: 0o600 }); + const originalOpen = nodeFs.openSync; const originalWrite = nodeFs.writeFileSync; let swap: "pending" | "replaced" | "blocked" = "pending"; - const writeSpy = spyOn(nodeFs, "writeFileSync").mockImplementation(((...args: unknown[]) => { - if (args[0] === readyPath) { - symlinkSync(externalTarget, "config.json"); - } else if ( + const openSpy = spyOn(nodeFs, "openSync").mockImplementation(((...args: unknown[]) => { + if ( swap === "pending" && typeof args[0] === "string" && args[0].endsWith(".create.tmp") @@ -393,6 +438,12 @@ describe("create-only config initialization", () => { throw new Error("publication escaped the admitted root"); } } + return (originalOpen as (...values: unknown[]) => number)(...args); + }) as typeof nodeFs.openSync); + const writeSpy = spyOn(nodeFs, "writeFileSync").mockImplementation(((...args: unknown[]) => { + if (args[0] === readyPath) { + symlinkSync(externalTarget, "config.json"); + } return (originalWrite as (...values: unknown[]) => unknown)(...args); }) as typeof nodeFs.writeFileSync); @@ -408,6 +459,7 @@ describe("create-only config initialization", () => { if (swap === "replaced") expect(readdirSync(testDir)).toEqual([]); else expect(swap).toBe("blocked"); } finally { + openSpy.mockRestore(); writeSpy.mockRestore(); rmSync(displacedRoot, { recursive: true, force: true }); rmSync(externalDir, { recursive: true, force: true }); @@ -504,9 +556,9 @@ describe("create-only config initialization", () => { test("leaves a replacement root untouched when replacement lands after the final publication check", () => { const previousCwd = process.cwd(); const displacedRoot = `${testDir}.publication-boundary`; - const originalWrite = nodeFs.writeFileSync; + const originalOpen = nodeFs.openSync; let swap: "pending" | "replaced" | "blocked" = "pending"; - const writeSpy = spyOn(nodeFs, "writeFileSync").mockImplementation(((...args: unknown[]) => { + const openSpy = spyOn(nodeFs, "openSync").mockImplementation(((...args: unknown[]) => { if ( swap === "pending" && typeof args[0] === "string" @@ -514,8 +566,8 @@ describe("create-only config initialization", () => { ) { swap = replaceAnchoredRoot(testDir, displacedRoot, 0o700); } - return (originalWrite as (...values: unknown[]) => unknown)(...args); - }) as typeof nodeFs.writeFileSync); + return (originalOpen as (...values: unknown[]) => number)(...args); + }) as typeof nodeFs.openSync); try { const result = initializeConfigIfMissing(getDefaultConfig()); @@ -530,7 +582,7 @@ describe("create-only config initialization", () => { } expect(process.cwd()).toBe(previousCwd); } finally { - writeSpy.mockRestore(); + openSpy.mockRestore(); rmSync(displacedRoot, { recursive: true, force: true }); } }); @@ -538,15 +590,15 @@ describe("create-only config initialization", () => { test("restores the previous cwd when bound publication throws", () => { const previousCwd = process.cwd(); const expectedBoundCwd = realpathSync.native(testDir); - const originalWrite = nodeFs.writeFileSync; + const originalOpen = nodeFs.openSync; let observedWriteCwd: string | null = null; - const writeSpy = spyOn(nodeFs, "writeFileSync").mockImplementation(((...args: unknown[]) => { + const openSpy = spyOn(nodeFs, "openSync").mockImplementation(((...args: unknown[]) => { if (typeof args[0] === "string" && args[0].endsWith(".create.tmp")) { observedWriteCwd = realpathSync.native(process.cwd()); throw new Error("publication fixture failure"); } - return (originalWrite as (...values: unknown[]) => unknown)(...args); - }) as typeof nodeFs.writeFileSync); + return (originalOpen as (...values: unknown[]) => number)(...args); + }) as typeof nodeFs.openSync); try { expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ @@ -556,7 +608,93 @@ describe("create-only config initialization", () => { expect(observedWriteCwd).toBe(expectedBoundCwd); expect(process.cwd()).toBe(previousCwd); } finally { + openSpy.mockRestore(); + } + }); + + test("does not remove a pre-existing publication temp after exclusive create fails", () => { + const originalOpen = nodeFs.openSync; + const originalWrite = nodeFs.writeFileSync; + let tempName: string | null = null; + let injected = false; + const injectForeignTemp = (path: unknown): void => { + if ( + !injected + && typeof path === "string" + && path.endsWith(".create.tmp") + ) { + injected = true; + tempName = path; + originalWrite(path, "foreign temp", { mode: 0o600 }); + } + }; + const openSpy = spyOn(nodeFs, "openSync").mockImplementation(((...args: unknown[]) => { + injectForeignTemp(args[0]); + return (originalOpen as (...values: unknown[]) => number)(...args); + }) as typeof nodeFs.openSync); + const writeSpy = spyOn(nodeFs, "writeFileSync").mockImplementation(((...args: unknown[]) => { + injectForeignTemp(args[0]); + return (originalWrite as (...values: unknown[]) => unknown)(...args); + }) as typeof nodeFs.writeFileSync); + + try { + expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ + status: "refused", + reason: "coordination-unavailable", + }); + expect(tempName).not.toBeNull(); + expect(readFileSync(join(testDir, tempName!), "utf8")).toBe("foreign temp"); + expect(existsSync(getConfigPath())).toBe(false); + } finally { + openSpy.mockRestore(); + writeSpy.mockRestore(); + if (tempName !== null) rmSync(join(testDir, tempName), { force: true }); + } + }); + + test("never unlinks or truncates a substituted publication temp", () => { + const originalOpen = nodeFs.openSync; + const originalWrite = nodeFs.writeFileSync; + let tempName: string | null = null; + let displacedTemp: string | null = null; + let substituted = false; + const openSpy = spyOn(nodeFs, "openSync").mockImplementation(((...args: unknown[]) => { + if (typeof args[0] === "string" && args[0].endsWith(".create.tmp")) { + tempName = args[0]; + } + return (originalOpen as (...values: unknown[]) => number)(...args); + }) as typeof nodeFs.openSync); + const writeSpy = spyOn(nodeFs, "writeFileSync").mockImplementation(((...args: unknown[]) => { + if (typeof args[0] === "string" && args[0].endsWith(".create.tmp")) { + tempName = args[0]; + } + const result = (originalWrite as (...values: unknown[]) => unknown)(...args); + if (!substituted && tempName !== null && ( + args[0] === tempName || typeof args[0] === "number" + )) { + substituted = true; + displacedTemp = `${tempName}.owned-displaced`; + renameSync(tempName, displacedTemp); + originalWrite(tempName, "replacement temp", { mode: 0o600 }); + } + return result; + }) as typeof nodeFs.writeFileSync); + + try { + expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ + status: "refused", + reason: "coordination-unavailable", + }); + expect(tempName).not.toBeNull(); + expect(displacedTemp).not.toBeNull(); + expect(readFileSync(join(testDir, tempName!), "utf8")).toBe("replacement temp"); + expect(lstatSync(join(testDir, displacedTemp!)).size).toBe(0); + expect(existsSync(getConfigPath())).toBe(false); + } finally { + openSpy.mockRestore(); writeSpy.mockRestore(); + if (tempName !== null) rmSync(join(testDir, tempName), { force: true }); + if (displacedTemp !== null) rmSync(join(testDir, displacedTemp), { force: true }); } }); @@ -605,6 +743,100 @@ describe("create-only config initialization", () => { } }); + test("final config bytes ignore serialization methods installed during the first pass", () => { + const defaults = getDefaultConfig(); + const desktopProfile = Object.assign(Object.create({ + toJSON(): unknown { + Object.defineProperty(Object.prototype, "toJSON", { + configurable: true, + value(this: unknown, key: string): unknown { + return key === "" ? undefined : this; + }, + }); + return { + version: 1, + assignments: {}, + defaults: { opus: null, fable: null, sonnet: null, haiku: null }, + }; + }, + }) as object, { + version: 1, + assignments: {}, + defaults: { opus: null, fable: null, sonnet: null, haiku: null }, + }); + const candidate = { + ...defaults, + claudeCode: { ...defaults.claudeCode, desktopProfile }, + } as typeof defaults; + + try { + expect(initializeConfigIfMissing(candidate)).toEqual({ status: "created" }); + const bytes = readFileSync(getConfigPath(), "utf8"); + expect(validateConfigCandidate(JSON.parse(bytes))).toMatchObject({ ok: true }); + expect(JSON.parse(bytes)).toEqual({ + ...candidate, + claudeCode: { + ...candidate.claudeCode, + desktopProfile: { + version: 1, + assignments: {}, + defaults: { opus: null, fable: null, sonnet: null, haiku: null }, + }, + }, + }); + } finally { + delete (Object.prototype as { toJSON?: unknown }).toJSON; + } + }); + + test("uses one absolute deadline for SQLite acquisition and commit", () => { + const originalChmod = nodeFs.chmodSync; + const originalLink = nodeFs.linkSync; + const originalExec = Database.prototype.exec; + let fakeNow = 1_000; + let setupAdvanced = false; + let publicationAdvanced = false; + const busyTimeouts: number[] = []; + const nowSpy = spyOn(performance, "now").mockImplementation(() => fakeNow); + const chmodSpy = spyOn(nodeFs, "chmodSync").mockImplementation(((...args: unknown[]) => { + if (!setupAdvanced && args[0] === "." && args[1] === 0o700) { + setupAdvanced = true; + fakeNow += 600; + } + return (originalChmod as (...values: unknown[]) => void)(...args); + }) as typeof nodeFs.chmodSync); + const linkSpy = spyOn(nodeFs, "linkSync").mockImplementation(((...args: unknown[]) => { + if (!publicationAdvanced && args[1] === "config.json") { + publicationAdvanced = true; + fakeNow += 1_500; + } + return (originalLink as (...values: unknown[]) => void)(...args); + }) as typeof nodeFs.linkSync); + const execSpy = spyOn(Database.prototype, "exec").mockImplementation(function( + this: Database, + sql: string, + ): void { + for (const match of sql.matchAll(/PRAGMA busy_timeout = (\d+)/g)) { + busyTimeouts.push(Number(match[1])); + } + originalExec.call(this, sql); + }); + + try { + expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ status: "created" }); + expect({ setupAdvanced, publicationAdvanced, busyTimeouts }).toEqual({ + setupAdvanced: true, + publicationAdvanced: true, + busyTimeouts: [1_400, 0], + }); + } finally { + execSpy.mockRestore(); + linkSpy.mockRestore(); + chmodSpy.mockRestore(); + nowSpy.mockRestore(); + } + }); + test("snapshots mutable test-action getters once before entering the cwd fence", () => { const callerCwd = process.cwd(); const readyPath = `${testDir}.mutable-action-ready`; From c744b369b255695e358ae4e6e4367636fd9f8093 Mon Sep 17 00:00:00 2001 From: pavelhov Date: Fri, 21 Aug 2026 01:21:41 -0400 Subject: [PATCH 24/28] refactor(config): simplify first-run creation --- .../2026-08-20-macos-zero-click-first-run.md | 159 ++-- ...08-20-macos-zero-click-first-run-design.md | 33 +- src/config.ts | 773 ++---------------- structure/01_runtime.md | 11 +- structure/02_config-and-codex-home.md | 15 +- tests/config.test.ts | 743 +++-------------- 6 files changed, 347 insertions(+), 1387 deletions(-) diff --git a/docs/superpowers/plans/2026-08-20-macos-zero-click-first-run.md b/docs/superpowers/plans/2026-08-20-macos-zero-click-first-run.md index f7e459885c..6eca1edde4 100644 --- a/docs/superpowers/plans/2026-08-20-macos-zero-click-first-run.md +++ b/docs/superpowers/plans/2026-08-20-macos-zero-click-first-run.md @@ -4,7 +4,7 @@ **Goal:** Make a direct packaged macOS app launch safely create CodexCommander's secret-free default configuration, start the proxy, and route initialized Codex without terminal setup. -**Architecture:** A no-clobber initializer in the TypeScript configuration layer remains the only code that can create `config.json`. A macOS-only policy supplies `getDefaultConfig()` and a typed start preparation that runs under existing lifecycle authority; the bounded JSON bridge carries setup state to Swift, where the menu presents nonfatal guidance. App-location classification remains native and blocks only ephemeral App Translocation startup. +**Architecture:** A no-clobber initializer in the TypeScript configuration layer remains the only app-bootstrap code that can create `config.json`; under config mutation coordination it directly opens the final entry with `wx`, writes and flushes through the owned descriptor, and never overwrites. A macOS-only policy supplies trusted `getDefaultConfig()` data and a typed start preparation that runs under existing lifecycle authority; the bounded JSON bridge carries setup state to Swift, where the menu presents nonfatal guidance. App-location classification remains native and blocks only ephemeral App Translocation startup. **Tech Stack:** Bun-native strict TypeScript, Bun test, Swift 5.9/AppKit/ServiceManagement, the existing bounded lifecycle JSON bridge, Astro/Starlight Markdown documentation. @@ -15,6 +15,7 @@ - macOS remains version 13.0 or later; do not add a newer framework requirement. - `getDefaultConfig()` remains the single source of truth for the fresh provider and settings. - Bootstrap may create only a genuinely absent `$CODEXCOMMANDER_HOME/config.json`; it must never overwrite a valid, invalid, unreadable, linked, or non-regular entry. +- The bootstrap candidate is trusted in-process policy data and is validated for correctness. Active same-user filesystem mutation after the coordinated probe is out of scope; do not add descriptor-relative root anchoring, pathname-swap barriers, or hostile-object test seams. - Bootstrap must not create or repair `$CODEX_HOME/config.toml`. - The generated configuration contains no API key, OAuth credential, account identity, or copied machine state. - Ordinary CLI Start/Ensure, passive companion launches, Stop/Restore, and `setIntegrationEnabled()` retain their current missing-config behavior. @@ -51,11 +52,11 @@ ### Task 1: Add a no-clobber configuration initializer **Files:** -- Modify: `src/config.ts:1599-1638,1887-1937` -- Test: `tests/config.test.ts:1-55,730-790` +- Modify: `src/config.ts:1645-1855,1950-2025` +- Test: `tests/config.test.ts:1-35,110-410` **Interfaces:** -- Consumes: `validateConfigCandidate(value)`, `withConfigMutationLockSync(fn)`, `bumpGenerationForCooperatingConfigWrite()`, `recordOwnedConfigPath(configDir, path)`, and the existing secret-path hardening functions. +- Consumes: `validateConfigCandidate(value)`, the existing config mutation transaction (with a private initializer-only bounded timeout), `bumpGenerationForCooperatingConfigWrite()`, `recordOwnedConfigPath(configDir, path)`, and the existing secret-path hardening functions. - Produces: ```ts @@ -74,15 +75,11 @@ export type ConfigInitializationResult = export function initializeConfigIfMissing( candidate: CodexCommanderConfig, ): ConfigInitializationResult; - -export function setConfigInitializationBeforePublishForTests( - hook: (() => void) | null, -): void; ``` - [ ] **Step 1: Write failing tests for missing, existing, and invalid entries** -Add imports for `initializeConfigIfMissing` and `setConfigInitializationBeforePublishForTests`, then add a focused describe block: +Add an import for `initializeConfigIfMissing`, then add a focused describe block: ```ts describe("create-only config initialization", () => { @@ -130,7 +127,7 @@ describe("create-only config initialization", () => { Run: `bun test tests/config.test.ts --test-name-pattern "create-only config initialization"` -Expected: FAIL because `initializeConfigIfMissing` and its test seam are not exported. +Expected: FAIL because `initializeConfigIfMissing` is not exported. - [ ] **Step 3: Add unsafe-entry and no-clobber race tests** @@ -178,30 +175,71 @@ test("refuses to claim a nonempty unowned configuration root", () => { expect(existsSync(getConfigPath())).toBe(false); }); -test("adopts a valid file that wins immediately before no-clobber publish", () => { +test("adopts a valid file that wins exclusive final creation", () => { const winner = { ...getDefaultConfig(), port: 12002 }; - setConfigInitializationBeforePublishForTests(() => { - writeFileSync(getConfigPath(), `${JSON.stringify(winner)}\n`, { mode: 0o600 }); - }); - expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ status: "existing" }); - expect(JSON.parse(readFileSync(getConfigPath(), "utf8"))).toEqual(winner); + const winnerBytes = `${JSON.stringify(winner)}\n`; + const originalOpen = nodeFs.openSync; + const openSpy = spyOn(nodeFs, "openSync").mockImplementation(((...args: unknown[]) => { + if (args[0] === getConfigPath() && args[1] === "wx") { + writeFileSync(getConfigPath(), winnerBytes, { mode: 0o600 }); + throw Object.assign(new Error("winner created config"), { code: "EEXIST" }); + } + return (originalOpen as (...values: unknown[]) => number)(...args); + }) as typeof nodeFs.openSync); + try { + expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ status: "existing" }); + expect(readFileSync(getConfigPath(), "utf8")).toBe(winnerBytes); + } finally { + openSpy.mockRestore(); + } }); -test("refuses an invalid file that wins immediately before publish", () => { - setConfigInitializationBeforePublishForTests(() => { - writeFileSync(getConfigPath(), "{", { mode: 0o600 }); - }); - expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ - status: "refused", - reason: "existing-invalid", - }); - expect(readFileSync(getConfigPath(), "utf8")).toBe("{"); +test("refuses an invalid file that wins exclusive final creation", () => { + const originalOpen = nodeFs.openSync; + const openSpy = spyOn(nodeFs, "openSync").mockImplementation(((...args: unknown[]) => { + if (args[0] === getConfigPath() && args[1] === "wx") { + writeFileSync(getConfigPath(), "{", { mode: 0o600 }); + throw Object.assign(new Error("invalid winner created config"), { code: "EEXIST" }); + } + return (originalOpen as (...values: unknown[]) => number)(...args); + }) as typeof nodeFs.openSync); + try { + expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ + status: "refused", + reason: "existing-invalid", + }); + expect(readFileSync(getConfigPath(), "utf8")).toBe("{"); + } finally { + openSpy.mockRestore(); + } +}); + +test("rechecks a transient incomplete preflight under coordination", () => { + expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ status: "created" }); + unlinkSync(getConfigPath()); + const bytes = `${JSON.stringify({ ...getDefaultConfig(), port: 12003 })}\n`; + writeFileSync(getConfigPath(), bytes, { mode: 0o600 }); + const originalRead = nodeFs.readFileSync; + let injected = false; + const readSpy = spyOn(nodeFs, "readFileSync").mockImplementation(((...args: unknown[]) => { + if (!injected && args[0] === getConfigPath()) { + injected = true; + return "{"; + } + return (originalRead as (...values: unknown[]) => unknown)(...args); + }) as typeof nodeFs.readFileSync); + try { + expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ status: "existing" }); + expect(readFileSync(getConfigPath(), "utf8")).toBe(bytes); + } finally { + readSpy.mockRestore(); + } }); ``` - [ ] **Step 4: Implement typed probing and exclusive publication** -Use `lstatSync` before every read, treat only `ENOENT` as missing, and keep raw bytes private. Add `linkSync` using the existing no-clobber pattern from service persistence so publication never renames over a race winner. The implementation shape is: +Use `lstatSync` before every read, treat only `ENOENT` as missing, and keep raw bytes private. Under the existing config mutation coordination, open the final `config.json` directly with `openSync(path, "wx", 0o600)`, write and flush through that descriptor, and never overwrite. Keep the public runtime mutation lock at `busy_timeout=0`; initializer-only acquisition may use the bounded 2-second timeout required for the two-process `created`/`existing` result. The implementation shape is: ```ts type ConfigEntryProbe = @@ -247,14 +285,14 @@ export function initializeConfigIfMissing( } try { - return withConfigMutationLockSync(() => { + return withConfigMutationLockTimeoutSync(() => { const current = probeConfigEntry(); if (current.kind === "valid") return { status: "existing" } as const; if (current.kind === "refused") { return { status: "refused", reason: current.reason } as const; } const bytes = `${JSON.stringify(validated.config, null, 2)}\n`; - const published = publishConfigNoReplace(getConfigPath(), bytes); + const published = createConfigExclusive(getConfigPath(), bytes); if (!published) { const winner = probeConfigEntry(); if (winner.kind === "valid") return { status: "existing" } as const; @@ -265,7 +303,7 @@ export function initializeConfigIfMissing( } bumpGenerationForCooperatingConfigWrite(); return { status: "created" } as const; - }); + }, CONFIG_INITIALIZATION_WAIT_MS); } catch { return { status: "refused", reason: "coordination-unavailable" }; } @@ -275,48 +313,39 @@ export function initializeConfigIfMissing( Implement the private publisher explicitly: ```ts -function publishConfigNoReplace(path: string, bytes: string): boolean { - recordOwnedConfigPath(resolveConfigDir(), path); - const target = resolveWriteTarget(path); - assertResolvedTargetAllowed(path, target); - const temp = `${target}.ccx.${process.pid}.${++_atomicSeq}.create.tmp`; - let published = false; +function createConfigExclusive(path: string, bytes: string): boolean { + let descriptor: number; + try { + descriptor = openSync(path, "wx", 0o600); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") return false; + throw error; + } + + let complete = false; + let descriptorOpen = true; try { - writeFileSync(temp, bytes, { encoding: "utf8", mode: 0o600, flag: "wx" }); - try { chmodSync(temp, 0o600); } catch { /* filesystem may ignore chmod */ } + writeFileSync(descriptor, bytes, { encoding: "utf8" }); + try { fchmodSync(descriptor, 0o600); } catch { /* filesystem may ignore chmod */ } + fsyncSync(descriptor); + closeSync(descriptor); + descriptorOpen = false; if (process.platform === "win32") { - hardenSecretPath(temp, { required: true, timeoutMemoKey: path }); - } - const hook = configInitializationBeforePublishForTests; - configInitializationBeforePublishForTests = null; - hook?.(); - try { - linkSync(temp, target); - published = true; - return true; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "EEXIST") return false; - throw error; + hardenSecretPath(path, { required: true, timeoutMemoKey: path }); } + complete = true; + return true; } finally { - try { - unlinkSync(temp); - forgetEphemeralSecretPath(temp); - } catch (error) { - if (!isMissingPathError(error)) { - // After link succeeds, temp and destination are the same inode. Never - // truncate the temp in that state because it would erase config.json too. - if (!published) { - try { truncateSync(temp, 0); } catch { /* residual error below is authoritative */ } - } - throw new AtomicWriteSecretResidualError(temp, { cause: error }); - } - } + if (descriptorOpen) try { closeSync(descriptor); } catch { /* original error wins */ } + if (!complete) try { unlinkSync(path); } catch { /* later probes refuse residue */ } } } ``` -Export the one-shot setter, reset the hook in `afterEach`, and import `linkSync`. The destination has two hard links only during publication; removing the temp leaves a private single-link `config.json`. +Do not add a production test callback or CWD/root-anchoring seam. The trust boundary is the +coordinated CodexCommander process and its trusted policy candidate; active same-user pathname swaps +after admission are explicitly out of scope. On `EEXIST`, re-probe exactly once and adopt only a +complete valid ordinary single-link file. - [ ] **Step 5: Run focused and regression tests** @@ -325,6 +354,7 @@ Run: ```bash bun test tests/config.test.ts --test-name-pattern "create-only config initialization" bun test tests/codex-desired-state.test.ts --test-name-pattern "missing config refuses" +bun test tests/config.test.ts --test-name-pattern "two processes racing initialization" --rerun-each 300 --only-failures bun run typecheck ``` @@ -1421,7 +1451,8 @@ Expected: both smokes pass without provider network calls and leave only files i Confirm from the diff and tests: -- the initializer publishes with no-replace semantics; +- the initializer creates the final entry directly with `wx`, writes and flushes through the + owned descriptor, and re-probes once after `EEXIST` without overwriting; - linked/non-regular/invalid config is refused; - lifecycle preparation runs while E is held; - the bridge contains no path, credentials, or raw config; diff --git a/docs/superpowers/specs/2026-08-20-macos-zero-click-first-run-design.md b/docs/superpowers/specs/2026-08-20-macos-zero-click-first-run-design.md index aaa0c4aadf..2124a293a6 100644 --- a/docs/superpowers/specs/2026-08-20-macos-zero-click-first-run-design.md +++ b/docs/superpowers/specs/2026-08-20-macos-zero-click-first-run-design.md @@ -85,10 +85,20 @@ The primitive owns: - the existing configuration mutation lock; - an under-lock re-read immediately before committing; - schema validation of the candidate; -- atomic persistence; +- direct exclusive creation of the final `config.json` entry with `wx`, owner-only + permissions, descriptor-based writing, and a descriptor flush before success; - state-directory permissions and existing ownership metadata behavior; and - stable, non-secret refusal reason codes. +The candidate is trusted in-process data produced by the app bootstrap policy. Validation +still proves schema correctness, but the initializer is not an isolation boundary against +hostile getters, serialization methods, or an active same-user filesystem process. Under the +configuration mutation transaction it probes the final entry, opens that final entry directly +with exclusive-create semantics, and never overwrites. If exclusive creation reports `EEXIST`, +it re-probes once and adopts only a complete valid ordinary single-link configuration. +An incomplete preflight read in an already owned root is not adopted or rejected before +coordination; the under-lock probe is authoritative so a cooperating initializer can finish. + It does not import macOS, lifecycle, provider-selection, or Codex-path logic. It does not change `mutatePersistedConfig()`. @@ -228,8 +238,12 @@ If an invalid object now exists, refuse it. Never overwrite the winner. - If CodexCommander configuration disappears after bootstrap but before routing intent is saved, the existing field-scoped mutation refusal remains authoritative. - If another process creates CodexCommander configuration between the initial read and - commit, the under-lock re-read adopts or refuses that object instead of replacing it. + exclusive creation, the one post-`EEXIST` probe adopts a complete valid ordinary file or + refuses the observed state instead of replacing it. - No retry loop recreates a file that vanished during an admitted mutation. +- Active same-user mutation after the coordinated probe is outside this initializer's trust + boundary. Such a process already has authority to alter the resulting file immediately after + initialization; this bootstrap does not add descriptor-relative or pathname-swap defenses for it. ## Error handling @@ -250,9 +264,12 @@ troubleshooting surfaces. - The bootstrap candidate comes only from checked-in runtime defaults. - The default contains no API key or OAuth credential. - Existing configuration is never used as a stale base for a replacement write. -- Bootstrap uses existing filesystem-hardening, atomic-write, mutation-lock, and - ownership-metadata paths. +- Bootstrap uses the existing mutation lock and ownership metadata, then creates the final file + directly with `wx`, mode `0600`, descriptor-based writing, and a flush before success. - Missing, invalid, unreadable, and conflicting states remain distinct. +- The same-user active-filesystem-adversary case is explicitly out of scope; static symlinks, + nonregular entries, hard links, linked roots, inaccessible state, and unowned roots are still + refused before creation. - External Codex routes and recovery journals stay under existing ownership checks. - The native bridge remains bounded and secret-free. - No new telemetry, logs, or persistent onboarding identifiers are introduced. @@ -275,8 +292,12 @@ first-run bootstrap. - Existing valid file is unchanged byte-for-byte. - Invalid JSON and schema-invalid files are refused unchanged. - A directory, unreadable object, unsafe link, or ownership failure is refused. -- A competing valid creation wins and is adopted. -- A competing invalid creation wins and is refused. +- An `EEXIST` winner is re-probed once: a complete valid ordinary single-link file is adopted, + while an invalid or unsafe winner is refused. +- A transient incomplete preflight in an already owned root is rechecked under the mutation + transaction before it can be classified as invalid. +- Two coordinated CodexCommander processes produce exactly one `created` and one `existing`, + with canonical bytes and a final single-link file. - The state directory and file retain the existing hardened permissions/ownership behavior. - Candidate schema validation occurs before persistence. diff --git a/src/config.ts b/src/config.ts index f034359bd1..13f7d5ca20 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,8 +1,8 @@ import { execFileSync } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; -import { chmodSync, closeSync, copyFileSync, existsSync, fchmodSync, fstatSync, ftruncateSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, closeSync, copyFileSync, existsSync, fchmodSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; -import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { Database } from "bun:sqlite"; import * as z from "zod/v4"; import { @@ -43,10 +43,8 @@ import { import { inspectPhysicalConfigRoot, recordOwnedConfigPath, - sameConfigRootFileIdentity, - type ConfigRootFileIdentity, } from "./lib/config-ownership"; -import { assertNotRealHomeUnderTest, isTestHomeGuardArmed } from "./lib/test-home-guard"; +import { assertNotRealHomeUnderTest } from "./lib/test-home-guard"; import { isLocalAttestationSecret } from "./lib/local-management-attestation"; import { providerDestinationConfigError } from "./lib/destination-policy"; import { redactSecretString } from "./lib/redact"; @@ -1656,8 +1654,7 @@ export type ConfigInitializationResult = type ConfigEntryProbe = | { kind: "missing" } - | { kind: "valid"; identity: ConfigRootFileIdentity } - | { kind: "publishing"; identity: ConfigRootFileIdentity } + | { kind: "valid" } | { kind: "refused"; reason: Exclude< @@ -1666,37 +1663,13 @@ type ConfigEntryProbe = >; }; -type ConfigRootIdentity = { - path: string; - canonicalPath: string; - dev: bigint; - ino: bigint; -}; - type ConfigRootProbe = | { kind: "missing" } - | { kind: "valid"; identity: ConfigRootIdentity } + | { kind: "valid" } | { kind: "refused"; reason: "existing-inaccessible" | "existing-unsafe" }; -class ConfigRootChangedDuringInitializationError extends Error { - constructor() { - super("Configuration root changed during initialization"); - this.name = "ConfigRootChangedDuringInitializationError"; - } -} - const CONFIG_INITIALIZATION_WAIT_MS = 2_000; const CONFIG_INITIALIZATION_POLL_MS = 10; -const CONFIG_INITIALIZATION_CONFIG_PATH = "config.json"; - -function samePhysicalConfigRoot(left: ConfigRootIdentity, right: ConfigRootIdentity): boolean { - const sameCanonicalPath = process.platform === "win32" - ? left.canonicalPath.toLowerCase() === right.canonicalPath.toLowerCase() - : left.canonicalPath === right.canonicalPath; - return left.path === right.path - && sameCanonicalPath - && sameConfigRootFileIdentity(left, right); -} function probeConfigRoot(): ConfigRootProbe { const path = getConfigDir(); @@ -1711,320 +1684,77 @@ function probeConfigRoot(): ConfigRootProbe { if (entry.kind !== "valid") { return { kind: "refused", reason: "existing-unsafe" }; } - try { - return { - kind: "valid", - identity: { - path, - canonicalPath: realpathSync.native(path), - ...entry.identity, - }, - }; - } catch { - return { kind: "refused", reason: "existing-inaccessible" }; - } -} - -function configRootStillMatches(expected: ConfigRootIdentity): boolean { - const current = probeConfigRoot(); - return current.kind === "valid" && samePhysicalConfigRoot(expected, current.identity); -} - -function boundConfigRootStillMatches(expected: ConfigRootIdentity): boolean { - try { - const current = inspectPhysicalConfigRoot("."); - return current.kind === "valid" - && sameConfigRootFileIdentity(expected, current.identity); - } catch { - return false; - } -} - -function relativePathWithin(root: string, candidate: string): string | null { - const rel = relative(root, candidate); - if ( - rel === "" - || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)) - ) return rel; - return null; -} - -/** - * Run one strictly synchronous initializer section with `.` anchored to the admitted - * physical root. Relative filesystem operations keep resolving through that directory - * object if its absolute name is renamed and replaced. No user callback or await may - * enter this section. - */ -function withBoundConfigRootSync( - expected: ConfigRootIdentity, - operation: () => T, -): T { - const previousCwd = process.cwd(); - const canonicalPreviousCwd = realpathSync.native(previousCwd); - const previousRelativeToRoot = relativePathWithin( - expected.canonicalPath, - canonicalPreviousCwd, - ); - let entered = false; - try { - if (previousRelativeToRoot === null) { - process.chdir(expected.path); - } else if (previousRelativeToRoot !== "") { - const depth = previousRelativeToRoot.split(sep).filter(Boolean).length; - process.chdir(Array.from({ length: depth }, () => "..").join(sep)); - } - entered = true; - if (!boundConfigRootStillMatches(expected) || !configRootStillMatches(expected)) { - throw new ConfigRootChangedDuringInitializationError(); - } - return operation(); - } catch (error) { - if ( - !entered - && !(error instanceof ConfigRootChangedDuringInitializationError) - ) { - throw new ConfigRootChangedDuringInitializationError(); - } - throw error; - } finally { - if (entered) { - if (previousRelativeToRoot === null) { - process.chdir(previousCwd); - } else if (previousRelativeToRoot !== "") { - process.chdir(previousRelativeToRoot); - } - } - } + return { kind: "valid" }; } -function probeConfigEntry(path = getConfigPath()): ConfigEntryProbe { +function probeConfigEntry(): ConfigEntryProbe { let entry; try { - entry = lstatSync(path, { bigint: true }); + entry = lstatSync(getConfigPath()); } catch (error) { return isMissingPathError(error) ? { kind: "missing" } : { kind: "refused", reason: "existing-inaccessible" }; } - if (!entry.isFile() || entry.isSymbolicLink() || entry.ino === 0n) { + if (!entry.isFile() || entry.isSymbolicLink() || entry.nlink !== 1) { return { kind: "refused", reason: "existing-unsafe" }; } - const identity = { dev: entry.dev, ino: entry.ino }; - if (entry.nlink === 2n) return { kind: "publishing", identity }; - if (entry.nlink !== 1n) return { kind: "refused", reason: "existing-unsafe" }; try { - const raw = readFileSync(path, "utf8"); - const afterRead = lstatSync(path, { bigint: true }); - if ( - !afterRead.isFile() - || afterRead.isSymbolicLink() - || afterRead.nlink !== 1n - || !sameConfigRootFileIdentity(identity, afterRead) - ) { - return { kind: "refused", reason: "existing-unsafe" }; - } - return configDiagnosticsFromRaw(raw).source === "file" - ? { kind: "valid", identity } + return configDiagnosticsFromRaw(readFileSync(getConfigPath(), "utf8")).source === "file" + ? { kind: "valid" } : { kind: "refused", reason: "existing-invalid" }; } catch { return { kind: "refused", reason: "existing-inaccessible" }; } } -let configInitializationBeforePublishForTests: (() => void) | null = null; - -/** Test-only one-shot seam: inject a competing writer between preflight and bound mutation. */ -export function setConfigInitializationBeforePublishForTests(hook: (() => void) | null): void { - configInitializationBeforePublishForTests = hook; -} - -function unlinkPublishedConfigIfUnchanged( - path: string, - expected: ConfigRootFileIdentity, -): void { - const current = lstatSync(path, { bigint: true }); - if ( - !current.isFile() - || current.isSymbolicLink() - || current.nlink !== 1n - || !sameConfigRootFileIdentity(expected, current) - ) { - throw new Error("Published configuration identity changed before cleanup"); - } - unlinkSync(path); -} - -function publicationPathMatchesOwnedFile( - path: string, - expected: ConfigRootFileIdentity, - expectedLinks: bigint, -): boolean { +function createConfigExclusive(path: string, bytes: string): boolean { + let descriptor: number; try { - const current = lstatSync(path, { bigint: true }); - return current.isFile() - && !current.isSymbolicLink() - && current.nlink === expectedLinks - && sameConfigRootFileIdentity(expected, current); - } catch { - return false; + descriptor = openSync(path, "wx", 0o600); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") return false; + throw error; } -} -function publishConfigNoReplace( - bytes: string, - expectedRoot: ConfigRootIdentity, -): boolean { - // This literal directory entry is already validated by the initializer. Resolving it - // would follow a contender symlink or turn it into an absolute pathname that can escape - // the admitted physical root after that root is renamed. - const target = CONFIG_INITIALIZATION_CONFIG_PATH; - const temp = `${target}.ccx.${process.pid}.${++_atomicSeq}.create.tmp`; - let published = false; - let collision = false; - let created = false; - let descriptor: number | undefined; - let publishedIdentity: ConfigRootFileIdentity | null = null; - let cleanupFailure: unknown = null; + let complete = false; + let descriptorOpen = true; try { - descriptor = openSync(temp, "wx", 0o600); - created = true; - const source = fstatSync(descriptor, { bigint: true }); - if (!source.isFile() || source.ino === 0n || source.nlink !== 1n) { - throw new Error("Configuration publication source is not a private regular file"); - } - publishedIdentity = { dev: source.dev, ino: source.ino }; writeFileSync(descriptor, bytes, { encoding: "utf8" }); try { fchmodSync(descriptor, 0o600); } catch { /* filesystem may ignore chmod */ } + fsyncSync(descriptor); + closeSync(descriptor); + descriptorOpen = false; if (process.platform === "win32") { - if (!publicationPathMatchesOwnedFile(temp, publishedIdentity, 1n)) { - throw new Error("Configuration publication source identity changed before hardening"); - } - hardenSecretPath(temp, { required: true, timeoutMemoKey: target }); - } - if (!publicationPathMatchesOwnedFile(temp, publishedIdentity, 1n)) { - throw new Error("Configuration publication source identity changed before linking"); - } - try { - linkSync(temp, target); - published = true; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "EEXIST") collision = true; - else throw error; + hardenSecretPath(path, { required: true, timeoutMemoKey: path }); } + complete = true; + return true; } finally { - try { - if (created) { - if ( - !publishedIdentity - || !publicationPathMatchesOwnedFile(temp, publishedIdentity, published ? 2n : 1n) - ) { - throw new Error("Configuration publication temp identity changed before cleanup"); - } - unlinkSync(temp); - forgetEphemeralSecretPath(temp); - } - } catch (error) { - cleanupFailure = error; - // After link succeeds, descriptor and destination are the same inode. Never - // truncate the descriptor in that state because it would erase config.json too. - if (!published && descriptor !== undefined) { - try { ftruncateSync(descriptor, 0); } catch { /* residual error below is authoritative */ } - } - } finally { - if (descriptor !== undefined) { - try { closeSync(descriptor); } catch (error) { cleanupFailure ??= error; } - } + if (descriptorOpen) { + try { closeSync(descriptor); } catch { /* the create/write error remains authoritative */ } } - if (cleanupFailure !== null) { - throw new AtomicWriteSecretResidualError(temp, { cause: cleanupFailure }); + if (!complete) { + try { unlinkSync(path); } catch { /* a later static probe will refuse any residue */ } } } - if (collision) return false; - if (!published || !publishedIdentity) { - throw new Error("Configuration publication did not establish a destination"); - } - let destinationValid = false; - try { - const destination = lstatSync(target, { bigint: true }); - destinationValid = destination.isFile() - && !destination.isSymbolicLink() - && destination.nlink === 1n - && sameConfigRootFileIdentity(publishedIdentity, destination) - && readFileSync(target, "utf8") === bytes; - } catch { - destinationValid = false; - } - const rootValid = boundConfigRootStillMatches(expectedRoot) - && configRootStillMatches(expectedRoot); - if (!destinationValid || !rootValid) { - unlinkPublishedConfigIfUnchanged(target, publishedIdentity); - if (!rootValid) throw new ConfigRootChangedDuringInitializationError(); - throw new Error("Published configuration could not be verified"); - } - return true; } -function configInitializationContenderObservation( - expectedRoot: ConfigRootIdentity, -): ConfigInitializationResult | ConfigEntryProbe { - if (!configRootStillMatches(expectedRoot)) { - return { status: "refused", reason: "existing-unsafe" }; - } +function configInitializationContenderObservation(): ConfigInitializationResult | ConfigEntryProbe { const current = probeConfigEntry(); - if (!configRootStillMatches(expectedRoot)) { - return { status: "refused", reason: "existing-unsafe" }; - } if (current.kind === "valid") return { status: "existing" }; - if (current.kind === "refused" && current.reason === "existing-invalid") { - return { status: "refused", reason: current.reason }; - } return current; } function waitForConfigInitializationWinner( - expectedRoot: ConfigRootIdentity, deadline: number, fallback: ConfigInitializationRefusal, - initialPublicationIdentity?: ConfigRootFileIdentity, ): ConfigInitializationResult { let lastRefusal: ConfigInitializationRefusal | null = null; - let publicationIdentity = initialPublicationIdentity; for (;;) { - if (!configRootStillMatches(expectedRoot)) { - return { status: "refused", reason: "existing-unsafe" }; - } - const observed = probeConfigEntry(); - if (!configRootStillMatches(expectedRoot)) { - return { status: "refused", reason: "existing-unsafe" }; - } - if (publicationIdentity) { - if ( - observed.kind === "valid" - && sameConfigRootFileIdentity(publicationIdentity, observed.identity) - ) return { status: "existing" }; - if ( - observed.kind === "publishing" - && sameConfigRootFileIdentity(publicationIdentity, observed.identity) - ) { - // The exact inode is still in the publisher's two-link cleanup window. - } else { - return { - status: "refused", - reason: observed.kind === "refused" - ? observed.reason - : "existing-unsafe", - }; - } - } else if (observed.kind === "valid") { - return { status: "existing" }; - } else if (observed.kind === "publishing") { - publicationIdentity = observed.identity; - } else if (observed.kind === "refused") { - if (observed.reason === "existing-invalid") { - return { status: "refused", reason: observed.reason }; - } - lastRefusal = observed.reason; - } + const observed = configInitializationContenderObservation(); + if ("status" in observed) return observed; + if (observed.kind === "refused") lastRefusal = observed.reason; if (performance.now() >= deadline) { return { status: "refused", reason: lastRefusal ?? fallback }; } @@ -2032,301 +1762,68 @@ function waitForConfigInitializationWinner( } } -export type ConfigInitializationTestAction = { - kind: "barrier"; - stage: "after-final-root-check"; - readyPath: string; - releasePath: string; -}; - -type PreparedConfigInitializationTestAction = Readonly<{ - readyPath: string; - releasePath: string; -}>; - -type PreparedConfigInitialization = - | Readonly<{ - kind: "ready"; - bytes: string; - testAction?: PreparedConfigInitializationTestAction; - }> - | Readonly<{ - kind: "refused"; - reason: "candidate-invalid" | "coordination-unavailable"; - }>; - -type ConfigInitializationMutationResult = - | ConfigInitializationResult - | Readonly<{ - status: "awaiting-publication"; - identity: ConfigRootFileIdentity; - }>; - -let configInitializationPreparationDepth = 0; - -type ConfigInitializationJsonData = - | null - | boolean - | number - | string - | ConfigInitializationJsonData[] - | { [key: string]: ConfigInitializationJsonData }; - -function copyConfigInitializationJsonData( - value: unknown, -): ConfigInitializationJsonData | undefined { - if ( - value === null - || typeof value === "string" - || typeof value === "boolean" - ) return value; - if (typeof value === "number") return Number.isFinite(value) ? value : undefined; - if (Array.isArray(value)) { - const copy = new Array(value.length); - for (let index = 0; index < value.length; index += 1) { - const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); - if (!descriptor || !("value" in descriptor)) return undefined; - const item = copyConfigInitializationJsonData(descriptor.value); - if (item === undefined) return undefined; - copy[index] = item; - } - Object.setPrototypeOf(copy, null); - return copy; - } - if (typeof value !== "object") return undefined; - - const copy = Object.create(null) as { [key: string]: ConfigInitializationJsonData }; - for (const key of Object.keys(value)) { - const descriptor = Object.getOwnPropertyDescriptor(value, key); - if (!descriptor || !("value" in descriptor)) return undefined; - const item = copyConfigInitializationJsonData(descriptor.value); - if (item === undefined) return undefined; - Object.defineProperty(copy, key, { - configurable: true, - enumerable: true, - value: item, - writable: true, - }); - } - return copy; -} - -function canonicalConfigInitializationBytes( - candidate: CodexCommanderConfig, - callerCwd: string, -): string | null { - process.chdir(callerCwd); - const validated = validateConfigCandidate(candidate); - process.chdir(callerCwd); - if (!validated.ok) return null; - - // `desktopProfile` is deliberately `unknown` at the Zod boundary. Serialize it while - // still outside both CWD fences, then parse and validate that private data-only copy. - // The second stringify cannot reach caller getters or inherited `toJSON` methods. - const serialized = JSON.stringify(validated.config); - process.chdir(callerCwd); - if (serialized === undefined) return null; - const privateCandidate = JSON.parse(serialized) as unknown; - const canonical = validateConfigCandidate(privateCandidate); - if (!canonical.ok) return null; - const dataOnlyCandidate = copyConfigInitializationJsonData(canonical.config); - if (dataOnlyCandidate === undefined) return null; - const exactCandidate = validateConfigCandidate(dataOnlyCandidate); - if (!exactCandidate.ok) return null; - const finalJson = JSON.stringify(dataOnlyCandidate, null, 2); - if (finalJson === undefined) return null; - const finalBytes = `${finalJson}\n`; - const finalCandidate = validateConfigCandidate(JSON.parse(finalBytes) as unknown); - return finalCandidate.ok ? finalBytes : null; -} - -function snapshotConfigInitializationTestAction( - action: ConfigInitializationTestAction, - configRootPath: string, - callerCwd: string, -): PreparedConfigInitializationTestAction | null { - if (!isTestHomeGuardArmed()) return null; - - process.chdir(callerCwd); - const keys = Reflect.ownKeys(action as object); - process.chdir(callerCwd); - const kind = action.kind; - process.chdir(callerCwd); - const stage = action.stage; - process.chdir(callerCwd); - const readyPath = action.readyPath; - process.chdir(callerCwd); - const releasePath = action.releasePath; - process.chdir(callerCwd); - - const expectedKeys = new Set(["kind", "stage", "readyPath", "releasePath"]); - if ( - keys.length !== expectedKeys.size - || keys.some(key => typeof key !== "string" || !expectedKeys.has(key)) - || kind !== "barrier" - || stage !== "after-final-root-check" - || typeof readyPath !== "string" - || typeof releasePath !== "string" - || !isAbsolute(readyPath) - || !isAbsolute(releasePath) - || readyPath === releasePath - || relativePathWithin(configRootPath, readyPath) !== null - || relativePathWithin(configRootPath, releasePath) !== null - ) return null; - - return Object.freeze({ readyPath, releasePath }); -} - -function prepareConfigInitialization( +export function initializeConfigIfMissing( candidate: CodexCommanderConfig, - action: ConfigInitializationTestAction | undefined, - configRootPath: string, -): PreparedConfigInitialization { - const callerCwd = process.cwd(); - let prepared: PreparedConfigInitialization = { - kind: "refused", - reason: "candidate-invalid", - }; - let restoreFailed = false; - configInitializationPreparationDepth = 1; - try { - let bytes: string | null = null; - try { - bytes = canonicalConfigInitializationBytes(candidate, callerCwd); - } catch { - bytes = null; - } - if (bytes !== null) { - let testAction: PreparedConfigInitializationTestAction | undefined; - let actionValid = true; - if (action !== undefined) { - try { - testAction = snapshotConfigInitializationTestAction( - action, - configRootPath, - callerCwd, - ) ?? undefined; - actionValid = testAction !== undefined; - } catch { - actionValid = false; - } - } - prepared = actionValid - ? Object.freeze({ kind: "ready", bytes, ...(testAction ? { testAction } : {}) }) - : { kind: "refused", reason: "coordination-unavailable" }; - } - } finally { - try { - process.chdir(callerCwd); - } catch { - restoreFailed = true; - } - configInitializationPreparationDepth = 0; - } - return restoreFailed - ? { kind: "refused", reason: "coordination-unavailable" } - : prepared; -} - -function runConfigInitializationTestAction( - action: PreparedConfigInitializationTestAction, -): void { - writeFileSync(action.readyPath, "ready", { encoding: "utf8", flag: "wx", mode: 0o600 }); - const deadline = performance.now() + 10_000; - while (!existsSync(action.releasePath)) { - if (performance.now() >= deadline) { - throw new Error("Config initialization test barrier timed out"); - } - Bun.sleepSync(5); - } -} - -function initializeConfigInBoundRoot( - bytes: string, - expectedRoot: ConfigRootIdentity, - deadline: number, - testAction?: PreparedConfigInitializationTestAction, ): ConfigInitializationResult { - const configPath = CONFIG_INITIALIZATION_CONFIG_PATH; - const observed = probeConfigEntry(configPath); - if (!boundConfigRootStillMatches(expectedRoot) || !configRootStillMatches(expectedRoot)) { - throw new ConfigRootChangedDuringInitializationError(); + const validated = validateConfigCandidate(candidate); + if (!validated.ok) return { status: "refused", reason: "candidate-invalid" }; + const deadline = performance.now() + CONFIG_INITIALIZATION_WAIT_MS; + const initialRoot = probeConfigRoot(); + if (initialRoot.kind === "refused") { + return { status: "refused", reason: initialRoot.reason }; } + const observed = probeConfigEntry(); if (observed.kind === "valid") return { status: "existing" }; - if (observed.kind === "publishing") { - return waitForConfigInitializationWinner( - expectedRoot, - deadline, - "existing-unsafe", - observed.identity, - ); + if (observed.kind === "refused" && observed.reason !== "existing-invalid") { + return { status: "refused", reason: observed.reason }; } - if (observed.kind === "refused") return { status: "refused", reason: observed.reason }; - let ownershipFailure: ConfigInitializationRefusal | null = null; try { - if (!recordOwnedConfigPath(".", configPath)) ownershipFailure = "existing-unsafe"; + if (!recordOwnedConfigPath(getConfigDir(), getConfigPath())) { + ownershipFailure = "existing-unsafe"; + } } catch { ownershipFailure = "existing-inaccessible"; } - if (!boundConfigRootStillMatches(expectedRoot) || !configRootStillMatches(expectedRoot)) { - throw new ConfigRootChangedDuringInitializationError(); + + const ownedRoot = probeConfigRoot(); + if (ownedRoot.kind !== "valid") { + return { + status: "refused", + reason: ownedRoot.kind === "refused" ? ownedRoot.reason : "existing-unsafe", + }; } if (!ownershipFailure) { try { - if (!recordOwnedConfigPath(".", configPath)) ownershipFailure = "existing-unsafe"; + if (!recordOwnedConfigPath(getConfigDir(), getConfigPath())) { + ownershipFailure = "existing-unsafe"; + } } catch { ownershipFailure = "existing-inaccessible"; } - if (!boundConfigRootStillMatches(expectedRoot) || !configRootStillMatches(expectedRoot)) { - throw new ConfigRootChangedDuringInitializationError(); - } } if (ownershipFailure) { - return waitForConfigInitializationWinner(expectedRoot, deadline, ownershipFailure); + if (observed.kind === "refused" && observed.reason === "existing-invalid") { + return { status: "refused", reason: observed.reason }; + } + return waitForConfigInitializationWinner(deadline, ownershipFailure); } let lastContentionRefusal: ConfigInitializationRefusal | null = null; - let pendingTestAction = testAction; for (;;) { - if (!boundConfigRootStillMatches(expectedRoot) || !configRootStillMatches(expectedRoot)) { - throw new ConfigRootChangedDuringInitializationError(); - } try { - const mutationResult: ConfigInitializationMutationResult = withConfigMutationLockAtDirSync(".", () => { - if (!boundConfigRootStillMatches(expectedRoot) || !configRootStillMatches(expectedRoot)) { - throw new ConfigRootChangedDuringInitializationError(); - } - const current = probeConfigEntry(configPath); + return withConfigMutationLockTimeoutSync(() => { + const current = probeConfigEntry(); if (current.kind === "valid") return { status: "existing" } as const; - if (current.kind === "publishing") { - return { status: "awaiting-publication", identity: current.identity } as const; - } if (current.kind === "refused") { return { status: "refused", reason: current.reason } as const; } - if (!boundConfigRootStillMatches(expectedRoot) || !configRootStillMatches(expectedRoot)) { - throw new ConfigRootChangedDuringInitializationError(); - } - const action = pendingTestAction; - pendingTestAction = undefined; - if (action) { - runConfigInitializationTestAction(action); - if (!boundConfigRootStillMatches(expectedRoot) || !configRootStillMatches(expectedRoot)) { - throw new ConfigRootChangedDuringInitializationError(); - } - } - const published = publishConfigNoReplace(bytes, expectedRoot); + const bytes = `${JSON.stringify(validated.config, null, 2)}\n`; + const published = createConfigExclusive(getConfigPath(), bytes); if (!published) { - if (!boundConfigRootStillMatches(expectedRoot) || !configRootStillMatches(expectedRoot)) { - throw new ConfigRootChangedDuringInitializationError(); - } - const winner = probeConfigEntry(configPath); + const winner = probeConfigEntry(); if (winner.kind === "valid") return { status: "existing" } as const; - if (winner.kind === "publishing") { - return { status: "awaiting-publication", identity: winner.identity } as const; - } return { status: "refused", reason: winner.kind === "refused" ? winner.reason : "coordination-unavailable", @@ -2334,31 +1831,13 @@ function initializeConfigInBoundRoot( } bumpGenerationForCooperatingConfigWrite(); return { status: "created" } as const; - }, deadline); - if (mutationResult.status === "awaiting-publication") { - return waitForConfigInitializationWinner( - expectedRoot, - deadline, - "existing-unsafe", - mutationResult.identity, - ); - } - return mutationResult; + }, CONFIG_INITIALIZATION_WAIT_MS); } catch (error) { - if (error instanceof ConfigRootChangedDuringInitializationError) throw error; if (!(error instanceof ConfigMutationLockError)) { return { status: "refused", reason: "coordination-unavailable" }; } - const contender = configInitializationContenderObservation(expectedRoot); + const contender = configInitializationContenderObservation(); if ("status" in contender) return contender; - if (contender.kind === "publishing") { - return waitForConfigInitializationWinner( - expectedRoot, - deadline, - "existing-unsafe", - contender.identity, - ); - } if (contender.kind === "refused") lastContentionRefusal = contender.reason; if (performance.now() >= deadline) { return { @@ -2371,105 +1850,6 @@ function initializeConfigInBoundRoot( } } -function initializeConfigIfMissingInternal( - candidate: CodexCommanderConfig, - testAction?: ConfigInitializationTestAction, -): ConfigInitializationResult { - // Reject reentry before inspecting caller-owned candidate/action values. Preparation - // itself may run hostile getters or `toJSON`, but only at the restored caller CWD and - // before any configuration-root mutation. - if (configInitializationPreparationDepth > 0 || configMutationLockDepth > 0) { - return { status: "refused", reason: "coordination-unavailable" }; - } - const configRootPath = getConfigDir(); - const prepared = prepareConfigInitialization(candidate, testAction, configRootPath); - if (prepared.kind === "refused") { - return { status: "refused", reason: prepared.reason }; - } - const deadline = performance.now() + CONFIG_INITIALIZATION_WAIT_MS; - const initialRoot = probeConfigRoot(); - if (initialRoot.kind === "refused") { - return { status: "refused", reason: initialRoot.reason }; - } - if (initialRoot.kind === "missing") { - try { - assertNotRealHomeUnderTest(getConfigDir()); - mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 }); - } catch { - return { status: "refused", reason: "existing-inaccessible" }; - } - } - const admittedRoot = probeConfigRoot(); - if (admittedRoot.kind !== "valid") { - return { - status: "refused", - reason: admittedRoot.kind === "refused" ? admittedRoot.reason : "existing-unsafe", - }; - } - if ( - initialRoot.kind === "valid" - && !samePhysicalConfigRoot(initialRoot.identity, admittedRoot.identity) - ) { - return { status: "refused", reason: "existing-unsafe" }; - } - - try { - const preflight = withBoundConfigRootSync(admittedRoot.identity, () => { - const observed = probeConfigEntry(CONFIG_INITIALIZATION_CONFIG_PATH); - if (!boundConfigRootStillMatches(admittedRoot.identity) - || !configRootStillMatches(admittedRoot.identity)) { - throw new ConfigRootChangedDuringInitializationError(); - } - return observed; - }); - if (preflight.kind === "valid") return { status: "existing" }; - if (preflight.kind === "publishing") { - return waitForConfigInitializationWinner( - admittedRoot.identity, - deadline, - "existing-unsafe", - preflight.identity, - ); - } - if (preflight.kind === "refused") { - return { status: "refused", reason: preflight.reason }; - } - - const hook = configInitializationBeforePublishForTests; - configInitializationBeforePublishForTests = null; - hook?.(); - - return withBoundConfigRootSync(admittedRoot.identity, () => - initializeConfigInBoundRoot( - prepared.bytes, - admittedRoot.identity, - deadline, - prepared.testAction, - )); - } catch (error) { - return { - status: "refused", - reason: error instanceof ConfigRootChangedDuringInitializationError - ? "existing-unsafe" - : "coordination-unavailable", - }; - } -} - -export function initializeConfigIfMissing( - candidate: CodexCommanderConfig, -): ConfigInitializationResult { - return initializeConfigIfMissingInternal(candidate); -} - -/** Explicit, immutable test action; production initialization has no mutable root seam. */ -export function initializeConfigIfMissingForTests( - candidate: CodexCommanderConfig, - action: ConfigInitializationTestAction, -): ConfigInitializationResult { - return initializeConfigIfMissingInternal(candidate, action); -} - /** * The persisted config, plus a digest of the EXACT bytes it was parsed from. * @@ -2531,7 +1911,8 @@ export class ConfigMutationLockError extends Error { } } -function configMutationDatabasePath(dir = getConfigDir()): string { +function configMutationDatabasePath(): string { + const dir = getConfigDir(); // First statement on purpose: a rejected mutation must leave nothing behind, not a // freshly created/chmod'd directory or database. See src/lib/test-home-guard.ts. assertNotRealHomeUnderTest(dir); @@ -2576,13 +1957,12 @@ let configMutationDatabase: Database | null = null; * Reentrancy is limited to the current synchronous call stack; never return a Promise from `fn`. */ export function withConfigMutationLockSync(fn: () => T): T { - return withConfigMutationLockAtDirSync(getConfigDir(), fn); + return withConfigMutationLockTimeoutSync(fn, 0); } -function withConfigMutationLockAtDirSync( - dir: string, +function withConfigMutationLockTimeoutSync( fn: () => T, - deadline?: number, + busyTimeoutMs: number, ): T { if (configMutationLockDepth > 0) { configMutationLockDepth += 1; @@ -2592,16 +1972,13 @@ function withConfigMutationLockAtDirSync( configMutationLockDepth -= 1; } } - const path = configMutationDatabasePath(dir); - const remainingBusyTimeoutMs = (): number => deadline === undefined - ? 0 - : Math.max(0, Math.ceil(deadline - performance.now())); + const path = configMutationDatabasePath(); let database: Database | undefined; let transactionOpen = false; try { database = new Database(path, { create: true }); try { chmodSync(path, 0o600); } catch { /* platform may ignore chmod */ } - database.exec(`PRAGMA busy_timeout = ${remainingBusyTimeoutMs()}; BEGIN IMMEDIATE`); + database.exec(`PRAGMA busy_timeout = ${busyTimeoutMs}; BEGIN IMMEDIATE`); transactionOpen = true; initializeConfigGeneration(database); } catch (cause) { @@ -2622,7 +1999,7 @@ function withConfigMutationLockAtDirSync( configMutationDatabase = database; try { const value = fn(); - database.exec(`PRAGMA busy_timeout = ${remainingBusyTimeoutMs()}; COMMIT`); + database.exec("COMMIT"); transactionOpen = false; return value; } catch (error) { diff --git a/structure/01_runtime.md b/structure/01_runtime.md index ae18c476e8..8530cd62ad 100644 --- a/structure/01_runtime.md +++ b/structure/01_runtime.md @@ -72,10 +72,13 @@ service paths do not call this hook: they require `ccx init` to have created a c a missing one. The app hook validates the canonical secret-free ChatGPT passthrough default and initializes only a -missing CodexCommander config. Publication is create-only/no-clobber: an existing valid, invalid, -unreadable, or unsafe config is never overwritten, and a concurrent race loser adopts the winner's -valid bytes rather than replacing them. If Codex has not created its config yet on this fresh app -start, the proxy and dashboard still run while Codex routing stays native; the result carries +missing CodexCommander config. Publication is create-only/no-clobber: under config mutation +coordination it directly opens the final entry with `wx`, writes and flushes through the owned +descriptor, and never overwrites an existing valid, invalid, unreadable, or unsafe config. An +`EEXIST` loser re-probes once and adopts only a complete valid ordinary single-link winner. The +trusted candidate comes from in-process app policy; active same-user filesystem mutation after the +coordinated probe is outside this bootstrap boundary. If Codex has not created its config yet on this +fresh app start, the proxy and dashboard still run while Codex routing stays native; the result carries `setupRequired: "codex-first-run"` so the companion tells the user to open Codex once and then choose **Route Codex Through Proxy**. The hook never creates Codex configuration and never copies provider secrets, API keys, or OAuth accounts. Existing external Codex providers remain outside its ownership. diff --git a/structure/02_config-and-codex-home.md b/structure/02_config-and-codex-home.md index 6a16da41a8..726fc69b7d 100644 --- a/structure/02_config-and-codex-home.md +++ b/structure/02_config-and-codex-home.md @@ -26,9 +26,18 @@ The direct packaged macOS companion **Start** path is the only app-only configur uses `getDefaultConfig()`'s canonical secret-free ChatGPT passthrough provider and calls `initializeConfigIfMissing` with create-only/no-clobber semantics. The initializer validates the candidate before touching disk, refuses existing invalid, unreadable, inaccessible, or unsafe state, -and never overwrites an existing valid file. A concurrent contender that loses publication adopts the -winner's valid bytes; it never replaces the winner. Providers, API keys, OAuth accounts, and Codex -configuration are not copied or created by this bootstrap. +and never overwrites an existing valid file. At the coordinated missing-entry probe it opens the final +`config.json` directly with `wx` and mode `0600`, writes and flushes through the owned descriptor, and +re-probes once after `EEXIST`; only a complete valid ordinary single-link winner is adopted. Providers, +API keys, OAuth accounts, and Codex configuration are not copied or created by this bootstrap. +An incomplete first read in an already owned root is rechecked only after acquiring mutation +coordination, so one CodexCommander process never adopts or rejects another's partial descriptor write. + +The bootstrap candidate is trusted in-process policy data and is validated for schema correctness. +Static unsafe state remains fail-closed: linked roots, symlinked/nonregular/hard-linked entries, +inaccessible state, and unowned roots are refused. Active same-user filesystem mutation after the +coordinated probe is outside this narrow bootstrap boundary; the initializer does not anchor process +CWD or promise descriptor-relative defense against a same-user pathname swap. Lifecycle authority acquires the Ensure lock (`E`) before the app preparation hook; the hook acquires the shared `config-mutation.sqlite` lock only after E is held. This E → config-mutation-lock ordering diff --git a/tests/config.test.ts b/tests/config.test.ts index b63c861a1c..1d0dbd9e42 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -1,9 +1,8 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; -import { Database } from "bun:sqlite"; import * as nodeFs from "node:fs"; -import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, symlinkSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, renameSync, rmSync, symlinkSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; -import { isAbsolute, join, resolve } from "node:path"; +import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { CODEX_SHIM_AUTO_RESTORE_ENV, @@ -16,7 +15,6 @@ import { isValidProviderName, isCodexCommanderStartCommandLine, initializeConfigIfMissing, - initializeConfigIfMissingForTests, loadConfig, multiAgentGuidanceEnabled, parsePidFile, @@ -26,9 +24,7 @@ import { readRuntimePort, removePid, removeRuntimePort, - setConfigInitializationBeforePublishForTests, validateConfigCandidate, - withConfigMutationLockSync, writeRuntimePort, writePid, } from "../src/config"; @@ -46,7 +42,6 @@ beforeEach(() => { }); afterEach(() => { - setConfigInitializationBeforePublishForTests(null); if (previousCodexCommanderHome === undefined) delete process.env.CODEXCOMMANDER_HOME; else process.env.CODEXCOMMANDER_HOME = previousCodexCommanderHome; previousCodexCommanderHome = undefined; @@ -58,25 +53,6 @@ function backupNames(): string[] { return readdirSync(testDir).filter(name => name.startsWith("config.json.invalid-")); } -function replaceAnchoredRoot( - root: string, - displacedRoot: string, - replacementMode: number, -): "replaced" | "blocked" { - try { - renameSync(root, displacedRoot); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if ( - process.platform === "win32" - && (code === "EACCES" || code === "EBUSY" || code === "EPERM") - ) return "blocked"; - throw error; - } - mkdirSync(root, { mode: replacementMode }); - return "replaced"; -} - function writeConfig(content: unknown): void { const current = content && typeof content === "object" && !Array.isArray(content) ? { multiAgentGuidanceEnabled: true, ...content as Record } @@ -162,6 +138,29 @@ describe("create-only config initialization", () => { } }); + test("rechecks a transient incomplete preflight under coordination", () => { + expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ status: "created" }); + unlinkSync(getConfigPath()); + const bytes = `${JSON.stringify({ ...getDefaultConfig(), port: 12003 })}\n`; + writeFileSync(getConfigPath(), bytes, { mode: 0o600 }); + const originalRead = nodeFs.readFileSync; + let injected = false; + const readSpy = spyOn(nodeFs, "readFileSync").mockImplementation(((...args: unknown[]) => { + if (!injected && args[0] === getConfigPath()) { + injected = true; + return "{"; + } + return (originalRead as (...values: unknown[]) => unknown)(...args); + }) as typeof nodeFs.readFileSync); + + try { + expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ status: "existing" }); + expect(readFileSync(getConfigPath(), "utf8")).toBe(bytes); + } finally { + readSpy.mockRestore(); + } + }); + test("rejects an invalid candidate without creating config state", () => { const invalid = { ...getDefaultConfig(), defaultProvider: "missing" }; expect(initializeConfigIfMissing(invalid)).toEqual({ @@ -171,6 +170,62 @@ describe("create-only config initialization", () => { expect(existsSync(getConfigPath())).toBe(false); }); + test("refuses when exclusive creation of the final config entry fails", () => { + const originalOpen = nodeFs.openSync; + const openSpy = spyOn(nodeFs, "openSync").mockImplementation(((...args: unknown[]) => { + if (args[0] === getConfigPath() && args[1] === "wx") { + throw Object.assign(new Error("exclusive final create failed"), { code: "EACCES" }); + } + return (originalOpen as (...values: unknown[]) => number)(...args); + }) as typeof nodeFs.openSync); + + try { + expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ + status: "refused", + reason: "coordination-unavailable", + }); + expect(existsSync(getConfigPath())).toBe(false); + } finally { + openSpy.mockRestore(); + } + }); + + test("refuses and removes its incomplete file when descriptor write fails", () => { + const originalWrite = nodeFs.writeFileSync; + const writeSpy = spyOn(nodeFs, "writeFileSync").mockImplementation(((...args: unknown[]) => { + if (typeof args[0] === "number") { + throw Object.assign(new Error("descriptor write failed"), { code: "EIO" }); + } + return (originalWrite as (...values: unknown[]) => unknown)(...args); + }) as typeof nodeFs.writeFileSync); + + try { + expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ + status: "refused", + reason: "coordination-unavailable", + }); + expect(existsSync(getConfigPath())).toBe(false); + } finally { + writeSpy.mockRestore(); + } + }); + + test("refuses and removes its incomplete file when descriptor flush fails", () => { + const fsyncSpy = spyOn(nodeFs, "fsyncSync").mockImplementation(() => { + throw Object.assign(new Error("flush failed"), { code: "EIO" }); + }); + + try { + expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ + status: "refused", + reason: "coordination-unavailable", + }); + expect(existsSync(getConfigPath())).toBe(false); + } finally { + fsyncSpy.mockRestore(); + } + }); + test("refuses linked and non-regular destinations", () => { const real = join(testDir, "real-config.json"); writeFileSync(real, `${JSON.stringify(getDefaultConfig())}\n`, "utf8"); @@ -187,6 +242,20 @@ describe("create-only config initialization", () => { }); }); + test("refuses a hard-linked destination without changing either link", () => { + const real = join(testDir, "hard-linked-config.json"); + const bytes = `${JSON.stringify(getDefaultConfig())}\n`; + writeFileSync(real, bytes, { mode: 0o600 }); + nodeFs.linkSync(real, getConfigPath()); + + expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ + status: "refused", + reason: "existing-unsafe", + }); + expect(readFileSync(real, "utf8")).toBe(bytes); + expect(readFileSync(getConfigPath(), "utf8")).toBe(bytes); + }); + test("refuses an inaccessible existing file without replacing it", () => { if (process.platform === "win32") return; writeFileSync(getConfigPath(), `${JSON.stringify(getDefaultConfig())}\n`, { mode: 0o600 }); @@ -212,24 +281,45 @@ describe("create-only config initialization", () => { expect(existsSync(getConfigPath())).toBe(false); }); - test("adopts a valid file that wins immediately before no-clobber publish", () => { + test("adopts a valid file that wins exclusive final creation", () => { const winner = { ...getDefaultConfig(), port: 12002 }; - setConfigInitializationBeforePublishForTests(() => { - writeFileSync(getConfigPath(), `${JSON.stringify(winner)}\n`, { mode: 0o600 }); - }); - expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ status: "existing" }); - expect(JSON.parse(readFileSync(getConfigPath(), "utf8"))).toEqual(winner); + const winnerBytes = `${JSON.stringify(winner)}\n`; + const originalOpen = nodeFs.openSync; + const openSpy = spyOn(nodeFs, "openSync").mockImplementation(((...args: unknown[]) => { + if (args[0] === getConfigPath() && args[1] === "wx") { + writeFileSync(getConfigPath(), winnerBytes, { mode: 0o600 }); + throw Object.assign(new Error("winner created config"), { code: "EEXIST" }); + } + return (originalOpen as (...values: unknown[]) => number)(...args); + }) as typeof nodeFs.openSync); + + try { + expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ status: "existing" }); + expect(readFileSync(getConfigPath(), "utf8")).toBe(winnerBytes); + } finally { + openSpy.mockRestore(); + } }); - test("refuses an invalid file that wins immediately before publish", () => { - setConfigInitializationBeforePublishForTests(() => { - writeFileSync(getConfigPath(), "{", { mode: 0o600 }); - }); - expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ - status: "refused", - reason: "existing-invalid", - }); - expect(readFileSync(getConfigPath(), "utf8")).toBe("{"); + test("refuses an invalid file that wins exclusive final creation", () => { + const originalOpen = nodeFs.openSync; + const openSpy = spyOn(nodeFs, "openSync").mockImplementation(((...args: unknown[]) => { + if (args[0] === getConfigPath() && args[1] === "wx") { + writeFileSync(getConfigPath(), "{", { mode: 0o600 }); + throw Object.assign(new Error("invalid winner created config"), { code: "EEXIST" }); + } + return (originalOpen as (...values: unknown[]) => number)(...args); + }) as typeof nodeFs.openSync); + + try { + expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ + status: "refused", + reason: "existing-invalid", + }); + expect(readFileSync(getConfigPath(), "utf8")).toBe("{"); + } finally { + openSpy.mockRestore(); + } }); test("two processes racing initialization publish once and adopt the winner", async () => { @@ -301,173 +391,6 @@ describe("create-only config initialization", () => { } }, { timeout: 20_000 }); - test("only adopts the exact two-link publication inode transitioning to one link", () => { - const bytes = `${JSON.stringify(getDefaultConfig())}\n`; - - for (const replacement of [false, true]) { - const extraLink = join(testDir, `config-publication-${replacement}.tmp`); - writeFileSync(getConfigPath(), bytes, { mode: 0o600 }); - nodeFs.linkSync(getConfigPath(), extraLink); - const observedIdentity = lstatSync(getConfigPath(), { bigint: true }); - let intercepted = false; - const originalLstat = nodeFs.lstatSync; - const lstatSpy = spyOn(nodeFs, "lstatSync").mockImplementation(((...args: unknown[]) => { - const result = (originalLstat as (...values: unknown[]) => unknown)(...args); - if (!intercepted && args[0] === "config.json") { - intercepted = true; - if (replacement) { - unlinkSync(getConfigPath()); - unlinkSync(extraLink); - writeFileSync(getConfigPath(), bytes, { mode: 0o600 }); - } else { - unlinkSync(extraLink); - } - } - return result; - }) as typeof nodeFs.lstatSync); - - try { - const result = initializeConfigIfMissing(getDefaultConfig()); - const finalIdentity = lstatSync(getConfigPath(), { bigint: true }); - if (replacement) { - expect(result).toEqual({ status: "refused", reason: "existing-unsafe" }); - expect(sameConfigRootFileIdentity(observedIdentity, finalIdentity)).toBe(false); - } else { - expect(result).toEqual({ status: "existing" }); - expect(sameConfigRootFileIdentity(observedIdentity, finalIdentity)).toBe(true); - } - expect(finalIdentity.nlink).toBe(1n); - expect(readFileSync(getConfigPath(), "utf8")).toBe(bytes); - } finally { - lstatSpy.mockRestore(); - rmSync(getConfigPath(), { force: true }); - rmSync(extraLink, { force: true }); - } - } - }); - - test("a subprocess paused after the final root check leaves a swapped-in root untouched", async () => { - const displacedRoot = `${testDir}.barrier-boundary`; - const readyPath = `${testDir}.barrier-ready`; - const releasePath = `${testDir}.barrier-release`; - const configModuleUrl = pathToFileURL(join(import.meta.dir, "../src/config.ts")).href; - const childSource = ` - import { - getDefaultConfig, - initializeConfigIfMissingForTests, - } from ${JSON.stringify(configModuleUrl)}; - const cwdBefore = process.cwd(); - const result = initializeConfigIfMissingForTests(getDefaultConfig(), { - kind: "barrier", - stage: "after-final-root-check", - readyPath: ${JSON.stringify(readyPath)}, - releasePath: ${JSON.stringify(releasePath)}, - }); - console.log(JSON.stringify({ result, cwdBefore, cwdAfter: process.cwd() })); - `; - const child = Bun.spawn([process.execPath, "-e", childSource], { - cwd: join(import.meta.dir, ".."), - env: { ...process.env, CODEXCOMMANDER_HOME: testDir }, - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - }); - - try { - for (let attempt = 0; attempt < 1_000; attempt += 1) { - if (existsSync(readyPath) || child.exitCode !== null) break; - await Bun.sleep(5); - } - if (!existsSync(readyPath)) { - const stderr = await new Response(child.stderr).text(); - throw new Error(`Initializer exited before the root-swap barrier: ${stderr}`); - } - const swap = replaceAnchoredRoot(testDir, displacedRoot, 0o700); - writeFileSync(releasePath, "release"); - - const exitCode = await Promise.race([ - child.exited, - Bun.sleep(10_000).then(() => null), - ]); - expect(exitCode).toBe(0); - const stdout = await new Response(child.stdout).text(); - const payload = JSON.parse(stdout.trim()) as { - result: ReturnType; - cwdBefore: string; - cwdAfter: string; - }; - expect(payload.cwdAfter).toBe(payload.cwdBefore); - if (swap === "replaced") { - expect(payload.result).toEqual({ status: "refused", reason: "existing-unsafe" }); - expect(readdirSync(testDir)).toEqual([]); - } else { - expect(payload.result).toEqual({ status: "created" }); - expect(JSON.parse(readFileSync(getConfigPath(), "utf8"))).toEqual(getDefaultConfig()); - } - } finally { - writeFileSync(releasePath, "release"); - if (child.exitCode === null) child.kill(); - await child.exited; - rmSync(displacedRoot, { recursive: true, force: true }); - rmSync(readyPath, { force: true }); - rmSync(releasePath, { force: true }); - } - }, { timeout: 20_000 }); - - test("a contender symlink cannot move publication outside the admitted root", () => { - const displacedRoot = `${testDir}.contender-root`; - const externalDir = `${testDir}.contender-external`; - const externalTarget = join(externalDir, "external-config.json"); - const readyPath = `${testDir}.contender-ready`; - const releasePath = `${testDir}.contender-release`; - mkdirSync(externalDir, { mode: 0o700 }); - writeFileSync(externalTarget, "external bytes", { mode: 0o600 }); - writeFileSync(releasePath, "release", { mode: 0o600 }); - - const originalOpen = nodeFs.openSync; - const originalWrite = nodeFs.writeFileSync; - let swap: "pending" | "replaced" | "blocked" = "pending"; - const openSpy = spyOn(nodeFs, "openSync").mockImplementation(((...args: unknown[]) => { - if ( - swap === "pending" - && typeof args[0] === "string" - && args[0].endsWith(".create.tmp") - ) { - swap = replaceAnchoredRoot(testDir, displacedRoot, 0o700); - if (isAbsolute(args[0])) { - throw new Error("publication escaped the admitted root"); - } - } - return (originalOpen as (...values: unknown[]) => number)(...args); - }) as typeof nodeFs.openSync); - const writeSpy = spyOn(nodeFs, "writeFileSync").mockImplementation(((...args: unknown[]) => { - if (args[0] === readyPath) { - symlinkSync(externalTarget, "config.json"); - } - return (originalWrite as (...values: unknown[]) => unknown)(...args); - }) as typeof nodeFs.writeFileSync); - - try { - expect(initializeConfigIfMissingForTests(getDefaultConfig(), { - kind: "barrier", - stage: "after-final-root-check", - readyPath, - releasePath, - })).toEqual({ status: "refused", reason: "existing-unsafe" }); - expect(readFileSync(externalTarget, "utf8")).toBe("external bytes"); - expect(readdirSync(externalDir)).toEqual(["external-config.json"]); - if (swap === "replaced") expect(readdirSync(testDir)).toEqual([]); - else expect(swap).toBe("blocked"); - } finally { - openSpy.mockRestore(); - writeSpy.mockRestore(); - rmSync(displacedRoot, { recursive: true, force: true }); - rmSync(externalDir, { recursive: true, force: true }); - rmSync(readyPath, { force: true }); - rmSync(releasePath, { force: true }); - } - }); - test("refuses a linked configuration root even when its target has valid ownership", () => { const realRoot = join(testDir, "owned-real-root"); const linkedRoot = join(testDir, "linked-root"); @@ -485,26 +408,6 @@ describe("create-only config initialization", () => { expect(existsSync(join(realRoot, "config.json"))).toBe(false); }); - test("refuses a configuration root replaced after ownership was cached", () => { - const displacedRoot = `${testDir}.displaced`; - expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ status: "created" }); - unlinkSync(getConfigPath()); - setConfigInitializationBeforePublishForTests(() => { - renameSync(testDir, displacedRoot); - mkdirSync(testDir, { mode: 0o700 }); - }); - try { - expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ - status: "refused", - reason: "existing-unsafe", - }); - expect(existsSync(getConfigPath())).toBe(false); - expect(existsSync(join(displacedRoot, "config.json"))).toBe(false); - } finally { - rmSync(displacedRoot, { recursive: true, force: true }); - } - }); - test("does not reuse cached ownership after the configuration root was replaced", () => { const displacedRoot = `${testDir}.cached-root`; expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ status: "created" }); @@ -522,390 +425,6 @@ describe("create-only config initialization", () => { } }); - test("leaves a replacement root untouched when replacement lands at lock acquisition", () => { - const previousCwd = process.cwd(); - const displacedRoot = `${testDir}.lock-boundary`; - const originalChmod = nodeFs.chmodSync; - let swap: "pending" | "replaced" | "blocked" = "pending"; - const chmodSpy = spyOn(nodeFs, "chmodSync").mockImplementation(((...args: unknown[]) => { - if (swap === "pending" && (args[0] === testDir || args[0] === ".") && args[1] === 0o700) { - swap = replaceAnchoredRoot(testDir, displacedRoot, 0o755); - } - return (originalChmod as (...values: unknown[]) => void)(...args); - }) as typeof nodeFs.chmodSync); - - try { - const result = initializeConfigIfMissing(getDefaultConfig()); - if (swap === "replaced") { - expect({ result, replacementEntries: readdirSync(testDir) }).toEqual({ - result: { status: "refused", reason: "existing-unsafe" }, - replacementEntries: [], - }); - expect(lstatSync(testDir).mode & 0o777).toBe(0o755); - } else { - expect(swap).toBe("blocked"); - expect(result).toEqual({ status: "created" }); - } - expect(process.cwd()).toBe(previousCwd); - } finally { - chmodSpy.mockRestore(); - rmSync(displacedRoot, { recursive: true, force: true }); - } - }); - - test("leaves a replacement root untouched when replacement lands after the final publication check", () => { - const previousCwd = process.cwd(); - const displacedRoot = `${testDir}.publication-boundary`; - const originalOpen = nodeFs.openSync; - let swap: "pending" | "replaced" | "blocked" = "pending"; - const openSpy = spyOn(nodeFs, "openSync").mockImplementation(((...args: unknown[]) => { - if ( - swap === "pending" - && typeof args[0] === "string" - && args[0].endsWith(".create.tmp") - ) { - swap = replaceAnchoredRoot(testDir, displacedRoot, 0o700); - } - return (originalOpen as (...values: unknown[]) => number)(...args); - }) as typeof nodeFs.openSync); - - try { - const result = initializeConfigIfMissing(getDefaultConfig()); - if (swap === "replaced") { - expect({ result, replacementEntries: readdirSync(testDir) }).toEqual({ - result: { status: "refused", reason: "existing-unsafe" }, - replacementEntries: [], - }); - } else { - expect(swap).toBe("blocked"); - expect(result).toEqual({ status: "created" }); - } - expect(process.cwd()).toBe(previousCwd); - } finally { - openSpy.mockRestore(); - rmSync(displacedRoot, { recursive: true, force: true }); - } - }); - - test("restores the previous cwd when bound publication throws", () => { - const previousCwd = process.cwd(); - const expectedBoundCwd = realpathSync.native(testDir); - const originalOpen = nodeFs.openSync; - let observedWriteCwd: string | null = null; - const openSpy = spyOn(nodeFs, "openSync").mockImplementation(((...args: unknown[]) => { - if (typeof args[0] === "string" && args[0].endsWith(".create.tmp")) { - observedWriteCwd = realpathSync.native(process.cwd()); - throw new Error("publication fixture failure"); - } - return (originalOpen as (...values: unknown[]) => number)(...args); - }) as typeof nodeFs.openSync); - - try { - expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ - status: "refused", - reason: "coordination-unavailable", - }); - expect(observedWriteCwd).toBe(expectedBoundCwd); - expect(process.cwd()).toBe(previousCwd); - } finally { - openSpy.mockRestore(); - } - }); - - test("does not remove a pre-existing publication temp after exclusive create fails", () => { - const originalOpen = nodeFs.openSync; - const originalWrite = nodeFs.writeFileSync; - let tempName: string | null = null; - let injected = false; - const injectForeignTemp = (path: unknown): void => { - if ( - !injected - && typeof path === "string" - && path.endsWith(".create.tmp") - ) { - injected = true; - tempName = path; - originalWrite(path, "foreign temp", { mode: 0o600 }); - } - }; - const openSpy = spyOn(nodeFs, "openSync").mockImplementation(((...args: unknown[]) => { - injectForeignTemp(args[0]); - return (originalOpen as (...values: unknown[]) => number)(...args); - }) as typeof nodeFs.openSync); - const writeSpy = spyOn(nodeFs, "writeFileSync").mockImplementation(((...args: unknown[]) => { - injectForeignTemp(args[0]); - return (originalWrite as (...values: unknown[]) => unknown)(...args); - }) as typeof nodeFs.writeFileSync); - - try { - expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ - status: "refused", - reason: "coordination-unavailable", - }); - expect(tempName).not.toBeNull(); - expect(readFileSync(join(testDir, tempName!), "utf8")).toBe("foreign temp"); - expect(existsSync(getConfigPath())).toBe(false); - } finally { - openSpy.mockRestore(); - writeSpy.mockRestore(); - if (tempName !== null) rmSync(join(testDir, tempName), { force: true }); - } - }); - - test("never unlinks or truncates a substituted publication temp", () => { - const originalOpen = nodeFs.openSync; - const originalWrite = nodeFs.writeFileSync; - let tempName: string | null = null; - let displacedTemp: string | null = null; - let substituted = false; - const openSpy = spyOn(nodeFs, "openSync").mockImplementation(((...args: unknown[]) => { - if (typeof args[0] === "string" && args[0].endsWith(".create.tmp")) { - tempName = args[0]; - } - return (originalOpen as (...values: unknown[]) => number)(...args); - }) as typeof nodeFs.openSync); - const writeSpy = spyOn(nodeFs, "writeFileSync").mockImplementation(((...args: unknown[]) => { - if (typeof args[0] === "string" && args[0].endsWith(".create.tmp")) { - tempName = args[0]; - } - const result = (originalWrite as (...values: unknown[]) => unknown)(...args); - if (!substituted && tempName !== null && ( - args[0] === tempName || typeof args[0] === "number" - )) { - substituted = true; - displacedTemp = `${tempName}.owned-displaced`; - renameSync(tempName, displacedTemp); - originalWrite(tempName, "replacement temp", { mode: 0o600 }); - } - return result; - }) as typeof nodeFs.writeFileSync); - - try { - expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ - status: "refused", - reason: "coordination-unavailable", - }); - expect(tempName).not.toBeNull(); - expect(displacedTemp).not.toBeNull(); - expect(readFileSync(join(testDir, tempName!), "utf8")).toBe("replacement temp"); - expect(lstatSync(join(testDir, displacedTemp!)).size).toBe(0); - expect(existsSync(getConfigPath())).toBe(false); - } finally { - openSpy.mockRestore(); - writeSpy.mockRestore(); - if (tempName !== null) rmSync(join(testDir, tempName), { force: true }); - if (displacedTemp !== null) rmSync(join(testDir, displacedTemp), { force: true }); - } - }); - - test("canonicalizes inherited toJSON before config mutation and blocks reentry", () => { - const originalCwd = process.cwd(); - process.chdir(testDir); - const callerCwd = process.cwd(); - const hostileCwd = `${testDir}.hostile-cwd`; - mkdirSync(hostileCwd, { mode: 0o700 }); - let toJsonCwd: string | null = null; - let reentrantResult: ReturnType | null = null; - const profilePrototype = { - toJSON(): never { - toJsonCwd = process.cwd(); - reentrantResult = initializeConfigIfMissing(getDefaultConfig()); - process.chdir(hostileCwd); - throw new Error("hostile inherited toJSON"); - }, - }; - const desktopProfile = Object.assign(Object.create(profilePrototype) as object, { - version: 1, - assignments: {}, - defaults: { opus: null, fable: null, sonnet: null, haiku: null }, - }); - const defaults = getDefaultConfig(); - const candidate = { - ...defaults, - claudeCode: { ...defaults.claudeCode, desktopProfile }, - } as typeof defaults; - - try { - expect(initializeConfigIfMissing(candidate)).toEqual({ - status: "refused", - reason: "candidate-invalid", - }); - expect(toJsonCwd).toBe(callerCwd); - expect(reentrantResult).toEqual({ - status: "refused", - reason: "coordination-unavailable", - }); - expect(process.cwd()).toBe(callerCwd); - expect(readdirSync(testDir)).toEqual([]); - } finally { - process.chdir(originalCwd); - rmSync(hostileCwd, { recursive: true, force: true }); - } - }); - - test("final config bytes ignore serialization methods installed during the first pass", () => { - const defaults = getDefaultConfig(); - const desktopProfile = Object.assign(Object.create({ - toJSON(): unknown { - Object.defineProperty(Object.prototype, "toJSON", { - configurable: true, - value(this: unknown, key: string): unknown { - return key === "" ? undefined : this; - }, - }); - return { - version: 1, - assignments: {}, - defaults: { opus: null, fable: null, sonnet: null, haiku: null }, - }; - }, - }) as object, { - version: 1, - assignments: {}, - defaults: { opus: null, fable: null, sonnet: null, haiku: null }, - }); - const candidate = { - ...defaults, - claudeCode: { ...defaults.claudeCode, desktopProfile }, - } as typeof defaults; - - try { - expect(initializeConfigIfMissing(candidate)).toEqual({ status: "created" }); - const bytes = readFileSync(getConfigPath(), "utf8"); - expect(validateConfigCandidate(JSON.parse(bytes))).toMatchObject({ ok: true }); - expect(JSON.parse(bytes)).toEqual({ - ...candidate, - claudeCode: { - ...candidate.claudeCode, - desktopProfile: { - version: 1, - assignments: {}, - defaults: { opus: null, fable: null, sonnet: null, haiku: null }, - }, - }, - }); - } finally { - delete (Object.prototype as { toJSON?: unknown }).toJSON; - } - }); - - test("uses one absolute deadline for SQLite acquisition and commit", () => { - const originalChmod = nodeFs.chmodSync; - const originalLink = nodeFs.linkSync; - const originalExec = Database.prototype.exec; - let fakeNow = 1_000; - let setupAdvanced = false; - let publicationAdvanced = false; - const busyTimeouts: number[] = []; - const nowSpy = spyOn(performance, "now").mockImplementation(() => fakeNow); - const chmodSpy = spyOn(nodeFs, "chmodSync").mockImplementation(((...args: unknown[]) => { - if (!setupAdvanced && args[0] === "." && args[1] === 0o700) { - setupAdvanced = true; - fakeNow += 600; - } - return (originalChmod as (...values: unknown[]) => void)(...args); - }) as typeof nodeFs.chmodSync); - const linkSpy = spyOn(nodeFs, "linkSync").mockImplementation(((...args: unknown[]) => { - if (!publicationAdvanced && args[1] === "config.json") { - publicationAdvanced = true; - fakeNow += 1_500; - } - return (originalLink as (...values: unknown[]) => void)(...args); - }) as typeof nodeFs.linkSync); - const execSpy = spyOn(Database.prototype, "exec").mockImplementation(function( - this: Database, - sql: string, - ): void { - for (const match of sql.matchAll(/PRAGMA busy_timeout = (\d+)/g)) { - busyTimeouts.push(Number(match[1])); - } - originalExec.call(this, sql); - }); - - try { - expect(initializeConfigIfMissing(getDefaultConfig())).toEqual({ status: "created" }); - expect({ setupAdvanced, publicationAdvanced, busyTimeouts }).toEqual({ - setupAdvanced: true, - publicationAdvanced: true, - busyTimeouts: [1_400, 0], - }); - } finally { - execSpy.mockRestore(); - linkSpy.mockRestore(); - chmodSpy.mockRestore(); - nowSpy.mockRestore(); - } - }); - - test("snapshots mutable test-action getters once before entering the cwd fence", () => { - const callerCwd = process.cwd(); - const readyPath = `${testDir}.mutable-action-ready`; - const releasePath = `${testDir}.mutable-action-release`; - const hostileCwd = `${testDir}.mutable-action-cwd`; - writeFileSync(releasePath, "release", { mode: 0o600 }); - mkdirSync(hostileCwd, { mode: 0o700 }); - const reads = { kind: 0, stage: 0, readyPath: 0, releasePath: 0 }; - const observedCwds: string[] = []; - let reentrantResult: ReturnType | null = null; - const action: Parameters[1] = { - get kind(): "barrier" { - reads.kind += 1; - observedCwds.push(process.cwd()); - reentrantResult = initializeConfigIfMissing(getDefaultConfig()); - process.chdir(hostileCwd); - return "barrier"; - }, - get stage(): "after-final-root-check" { - reads.stage += 1; - observedCwds.push(process.cwd()); - return "after-final-root-check"; - }, - get readyPath(): string { - reads.readyPath += 1; - observedCwds.push(process.cwd()); - return reads.readyPath <= 2 ? readyPath : getConfigPath(); - }, - get releasePath(): string { - reads.releasePath += 1; - observedCwds.push(process.cwd()); - return releasePath; - }, - }; - - try { - expect(initializeConfigIfMissingForTests(getDefaultConfig(), action)).toEqual({ - status: "created", - }); - expect(reads).toEqual({ kind: 1, stage: 1, readyPath: 1, releasePath: 1 }); - expect(observedCwds).toEqual(Array(4).fill(callerCwd)); - expect(reentrantResult).toEqual({ - status: "refused", - reason: "coordination-unavailable", - }); - expect(JSON.parse(readFileSync(getConfigPath(), "utf8"))).toEqual(getDefaultConfig()); - expect(readFileSync(readyPath, "utf8")).toBe("ready"); - expect(process.cwd()).toBe(callerCwd); - } finally { - rmSync(readyPath, { force: true }); - rmSync(releasePath, { force: true }); - rmSync(hostileCwd, { recursive: true, force: true }); - } - }); - - test("refuses reentrant initialization without entering the cwd fence", () => { - const previousCwd = process.cwd(); - let result: ReturnType | undefined; - - withConfigMutationLockSync(() => { - result = initializeConfigIfMissing(getDefaultConfig()); - expect(process.cwd()).toBe(previousCwd); - }); - - expect(result).toEqual({ status: "refused", reason: "coordination-unavailable" }); - expect(existsSync(getConfigPath())).toBe(false); - expect(process.cwd()).toBe(previousCwd); - }); - test("distinguishes bigint root identities whose inode numbers collide after numeric conversion", () => { const firstIno = 2n ** 53n; const replacementIno = firstIno + 1n; From 5b538cced22a48fd79723e033f1be71555c0ff5b Mon Sep 17 00:00:00 2001 From: pavelhov Date: Fri, 21 Aug 2026 01:35:00 -0400 Subject: [PATCH 25/28] docs(config): align initializer contract --- .../2026-08-20-macos-zero-click-first-run.md | 93 ++++++++++++++----- ...08-20-macos-zero-click-first-run-design.md | 24 +++-- tests/config.test.ts | 7 +- 3 files changed, 93 insertions(+), 31 deletions(-) diff --git a/docs/superpowers/plans/2026-08-20-macos-zero-click-first-run.md b/docs/superpowers/plans/2026-08-20-macos-zero-click-first-run.md index 6eca1edde4..17d795180b 100644 --- a/docs/superpowers/plans/2026-08-20-macos-zero-click-first-run.md +++ b/docs/superpowers/plans/2026-08-20-macos-zero-click-first-run.md @@ -273,39 +273,90 @@ export function initializeConfigIfMissing( ): ConfigInitializationResult { const validated = validateConfigCandidate(candidate); if (!validated.ok) return { status: "refused", reason: "candidate-invalid" }; + const deadline = performance.now() + CONFIG_INITIALIZATION_WAIT_MS; + const initialRoot = probeConfigRoot(); + if (initialRoot.kind === "refused") { + return { status: "refused", reason: initialRoot.reason }; + } const observed = probeConfigEntry(); if (observed.kind === "valid") return { status: "existing" }; - if (observed.kind === "refused") return { status: "refused", reason: observed.reason }; + if (observed.kind === "refused" && observed.reason !== "existing-invalid") { + return { status: "refused", reason: observed.reason }; + } + + // A transient empty or partial entry may be visible while another cooperating + // initializer owns the mutation transaction. Establish ownership/coordination + // before treating this preflight existing-invalid observation as authoritative. + let ownershipFailure: ConfigInitializationRefusal | null = null; try { if (!recordOwnedConfigPath(getConfigDir(), getConfigPath())) { - return { status: "refused", reason: "existing-unsafe" }; + ownershipFailure = "existing-unsafe"; } } catch { - return { status: "refused", reason: "existing-inaccessible" }; + ownershipFailure = "existing-inaccessible"; } - try { - return withConfigMutationLockTimeoutSync(() => { - const current = probeConfigEntry(); - if (current.kind === "valid") return { status: "existing" } as const; - if (current.kind === "refused") { - return { status: "refused", reason: current.reason } as const; + const ownedRoot = probeConfigRoot(); + if (ownedRoot.kind !== "valid") { + return { + status: "refused", + reason: ownedRoot.kind === "refused" ? ownedRoot.reason : "existing-unsafe", + }; + } + if (!ownershipFailure) { + try { + if (!recordOwnedConfigPath(getConfigDir(), getConfigPath())) { + ownershipFailure = "existing-unsafe"; } - const bytes = `${JSON.stringify(validated.config, null, 2)}\n`; - const published = createConfigExclusive(getConfigPath(), bytes); - if (!published) { - const winner = probeConfigEntry(); - if (winner.kind === "valid") return { status: "existing" } as const; + } catch { + ownershipFailure = "existing-inaccessible"; + } + } + if (ownershipFailure) { + if (observed.kind === "refused" && observed.reason === "existing-invalid") { + return { status: "refused", reason: observed.reason }; + } + return waitForConfigInitializationWinner(deadline, ownershipFailure); + } + + let lastContentionRefusal: ConfigInitializationRefusal | null = null; + for (;;) { + try { + return withConfigMutationLockTimeoutSync(() => { + // This under-lock probe, not the preflight observation, is authoritative. + const current = probeConfigEntry(); + if (current.kind === "valid") return { status: "existing" } as const; + if (current.kind === "refused") { + return { status: "refused", reason: current.reason } as const; + } + const bytes = `${JSON.stringify(validated.config, null, 2)}\n`; + const published = createConfigExclusive(getConfigPath(), bytes); + if (!published) { + const winner = probeConfigEntry(); + if (winner.kind === "valid") return { status: "existing" } as const; + return { + status: "refused", + reason: winner.kind === "refused" ? winner.reason : "coordination-unavailable", + } as const; + } + bumpGenerationForCooperatingConfigWrite(); + return { status: "created" } as const; + }, CONFIG_INITIALIZATION_WAIT_MS); + } catch (error) { + if (!(error instanceof ConfigMutationLockError)) { + return { status: "refused", reason: "coordination-unavailable" }; + } + const contender = configInitializationContenderObservation(); + if ("status" in contender) return contender; + if (contender.kind === "refused") lastContentionRefusal = contender.reason; + if (performance.now() >= deadline) { return { status: "refused", - reason: winner.kind === "refused" ? winner.reason : "coordination-unavailable", - } as const; + reason: lastContentionRefusal ?? "coordination-unavailable", + }; } - bumpGenerationForCooperatingConfigWrite(); - return { status: "created" } as const; - }, CONFIG_INITIALIZATION_WAIT_MS); - } catch { - return { status: "refused", reason: "coordination-unavailable" }; + Bun.sleepSync(CONFIG_INITIALIZATION_POLL_MS); + } } } ``` diff --git a/docs/superpowers/specs/2026-08-20-macos-zero-click-first-run-design.md b/docs/superpowers/specs/2026-08-20-macos-zero-click-first-run-design.md index 2124a293a6..9996b61e52 100644 --- a/docs/superpowers/specs/2026-08-20-macos-zero-click-first-run-design.md +++ b/docs/superpowers/specs/2026-08-20-macos-zero-click-first-run-design.md @@ -10,11 +10,11 @@ configuration file, so Start stops with: > Codex routing could not be enabled: No config file exists to record the switch in. -The macOS app will gain a direct-launch-only bootstrap step. It will atomically create -the existing secret-free `getDefaultConfig()` when, and only when, the CodexCommander -configuration is genuinely absent. It will not run the interactive CLI wizard, write -credentials, overwrite existing configuration, create Codex-owned configuration, or -move the application. +The macOS app will gain a direct-launch-only bootstrap step. It will create the existing +secret-free `getDefaultConfig()` through exclusive final-entry creation when, and only when, +the CodexCommander configuration is genuinely absent. It will not run the interactive CLI +wizard, write credentials, overwrite existing configuration, create Codex-owned configuration, +or move the application. ## Goals @@ -75,7 +75,8 @@ configuration only if the persisted file is genuinely absent. The initializer returns a discriminated result: -- `created` — the candidate was atomically persisted. +- `created` — final-entry creation succeeded exclusively with owner-only requested permissions, + and descriptor writing and flushing completed before the result was reported. - `existing` — a valid configuration appeared before the commit or already existed. - `refused` — a file or filesystem object exists but is invalid, unreadable, unsafe, or otherwise cannot be admitted. @@ -96,8 +97,13 @@ hostile getters, serialization methods, or an active same-user filesystem proces configuration mutation transaction it probes the final entry, opens that final entry directly with exclusive-create semantics, and never overwrites. If exclusive creation reports `EEXIST`, it re-probes once and adopts only a complete valid ordinary single-link configuration. -An incomplete preflight read in an already owned root is not adopted or rejected before -coordination; the under-lock probe is authoritative so a cooperating initializer can finish. +Persisted bytes are the pretty-printed schema-validated configuration, including its +schema-produced property order, rather than the raw candidate object's literal property order. +The final entry may be transiently visible as empty or partial between exclusive creation and +the completed descriptor write and flush. The initializer does not report `created` until those +operations finish. An incomplete preflight read in an already owned root is not adopted or +rejected before coordination; cooperating initializers recheck under the mutation coordination, +and that under-lock probe is authoritative. It does not import macOS, lifecycle, provider-selection, or Codex-path logic. It does not change `mutatePersistedConfig()`. @@ -194,7 +200,7 @@ and reopen it. ### Missing CodexCommander config, Codex ready -1. Create the canonical default atomically. +1. Create the canonical default through exclusive final-entry creation and descriptor flush. 2. Execute normal explicit Start. 3. Start or attach to the proxy. 4. Synchronize the model catalog. diff --git a/tests/config.test.ts b/tests/config.test.ts index 1d0dbd9e42..e8e744acca 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -380,7 +380,12 @@ describe("create-only config initialization", () => { { status: "existing" }, ]); const finalPath = join(raceRoot, "config.json"); - expect(JSON.parse(readFileSync(finalPath, "utf8"))).toEqual(getDefaultConfig()); + const validatedDefault = validateConfigCandidate(getDefaultConfig()); + expect(validatedDefault.ok).toBe(true); + if (!validatedDefault.ok) throw new Error(validatedDefault.error); + expect(readFileSync(finalPath, "utf8")).toBe( + `${JSON.stringify(validatedDefault.config, null, 2)}\n`, + ); expect(lstatSync(finalPath).nlink).toBe(1); } finally { writeFileSync(releasePath, "go"); From bb27a747912190512dc86605a65326f47c7ca144 Mon Sep 17 00:00:00 2001 From: pavelhov Date: Fri, 21 Aug 2026 01:41:03 -0400 Subject: [PATCH 26/28] docs: fix landing source quickstart --- docs-site/src/components/Landing.astro | 1 + 1 file changed, 1 insertion(+) diff --git a/docs-site/src/components/Landing.astro b/docs-site/src/components/Landing.astro index 43e8ff1d41..3b1788621b 100644 --- a/docs-site/src/components/Landing.astro +++ b/docs-site/src/components/Landing.astro @@ -51,6 +51,7 @@ const providers: { label: string; path?: string }[] = [ const quickstartMini = [ { cmd: 'bun install && bun run build:gui', note: 'Prepare this checkout' }, + { cmd: 'bun run src/cli/index.ts init', note: 'Initialize CodexCommander' }, { cmd: 'bun run src/cli/index.ts start', note: 'Start the proxy' }, ]; From d699683bf5bbb778552885ab4fd4652ef3561e16 Mon Sep 17 00:00:00 2001 From: pavelhov Date: Fri, 21 Aug 2026 02:42:15 -0400 Subject: [PATCH 27/28] fix(macos): fence translocated spawn actions --- app/Sources/MenuBarUI/AppDelegate.swift | 35 +++++++++--- app/Sources/MenuBarUITests/main.swift | 73 +++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 9 deletions(-) diff --git a/app/Sources/MenuBarUI/AppDelegate.swift b/app/Sources/MenuBarUI/AppDelegate.swift index ba52b28367..e66674197d 100644 --- a/app/Sources/MenuBarUI/AppDelegate.swift +++ b/app/Sources/MenuBarUI/AppDelegate.swift @@ -24,6 +24,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValid private var companionHeartbeat: CompanionHeartbeat? private let launchAtLoginController = LaunchAtLoginController() private let appBundleLocation: AppBundleLocation + private let lifecycleConfirmation: ((LifecycleConfirmation) -> Bool)? private lazy var executableFingerprint = ExecutableFingerprint.current() private lazy var sourceRevision = BuildProvenance.shortRevision( Bundle.main.object(forInfoDictionaryKey: "CodexCommanderSourceRevision") @@ -33,15 +34,18 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValid public override init() { appBundleLocation = LaunchAtLoginEligibility.classify(Bundle.main.bundleURL) + lifecycleConfirmation = nil super.init() } package init( appBundleLocation: AppBundleLocation, - actions: ActionCoordinator? + actions: ActionCoordinator?, + lifecycleConfirmation: ((LifecycleConfirmation) -> Bool)? = nil ) { self.appBundleLocation = appBundleLocation self.actions = actions + self.lifecycleConfirmation = lifecycleConfirmation super.init() } @@ -403,10 +407,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValid /// Manual and Launch-at-Login openings share the explicit Start contract. A failed /// start leaves the menu app alive so its diagnostics and Start control remain usable. private func startProxyOnLaunch() { - guard appBundleLocation != .translocated else { - controller.showAppTranslocated() - return - } + guard permitsSpawnCapableLifecycleAction() else { return } guard !lifecycleInFlight, !restartInFlight, !catalogActionInFlight else { return } lifecycleInFlight = true updateApplicationMenu() @@ -444,10 +445,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValid } private func startProxy() { - guard appBundleLocation != .translocated else { - controller.showAppTranslocated() - return - } + guard permitsSpawnCapableLifecycleAction() else { return } guard !lifecycleInFlight, !restartInFlight, !catalogActionInFlight else { return } lifecycleInFlight = true updateApplicationMenu() @@ -625,6 +623,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValid /// Restart is destructive to in-flight work, so it always confirms first and only /// reports success only after the lifecycle helper confirms a running replacement. private func restartProxy() { + guard permitsSpawnCapableLifecycleAction() else { return } guard !restartInFlight, !lifecycleInFlight, !catalogActionInFlight else { return } guard confirm(.restartProxy) else { return } @@ -690,6 +689,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValid !lifecycleInFlight, !restartInFlight else { return } + guard permitsSpawnCapableLifecycleAction() else { return } catalogActionInFlight = true updateApplicationMenu() @@ -753,7 +753,16 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValid )) } + private func permitsSpawnCapableLifecycleAction() -> Bool { + guard appBundleLocation == .translocated else { return true } + controller.showAppTranslocated() + return false + } + private func confirm(_ confirmation: LifecycleConfirmation) -> Bool { + if let lifecycleConfirmation { + return lifecycleConfirmation(confirmation) + } let alert = confirmation.makeAlert() panel.isPresentingModal = true NSApp.activate(ignoringOtherApps: true) @@ -782,6 +791,14 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValid package var presentationControllerForTesting: PopoverViewController { controller } package func startProxyOnLaunchForTesting() { startProxyOnLaunch() } package func startProxyForTesting() { startProxy() } + package func restartProxyForTesting() { restartProxy() } + package func recheckCodexCatalogForTesting() { + presentCatalogUpdate(staleWorkerCount: nil) + recheckCodexCatalog() + } + package var spawnActionInFlightForTesting: Bool { + lifecycleInFlight || restartInFlight || catalogActionInFlight + } } /// Best-effort, non-blocking reporter of the native app's launch-at-login state. diff --git a/app/Sources/MenuBarUITests/main.swift b/app/Sources/MenuBarUITests/main.swift index ea1185d536..9f406ef3d9 100644 --- a/app/Sources/MenuBarUITests/main.swift +++ b/app/Sources/MenuBarUITests/main.swift @@ -1554,6 +1554,79 @@ runner.test("ui: ordinary AppTranslocation folder-name collision still starts th runner.equal(lifecycle.recordedActions, [.start]) } +runner.test("ui: translocated Restart records zero lifecycle dispatch") { + let lifecycle = RecordingLifecycleRunner() + let delegate = AppDelegate( + appBundleLocation: .translocated, + actions: ActionCoordinator(lifecycle: lifecycle), + lifecycleConfirmation: { _ in true } + ) + + delegate.restartProxyForTesting() + spinMainRunLoop(seconds: 0.05) + + runner.equal(lifecycle.recordedActions, []) + runner.equal(delegate.spawnActionInFlightForTesting, false) + runner.equal( + delegate.presentationControllerForTesting.operationStatusTitle, + "Move CodexCommander to Applications" + ) +} + +runner.test("ui: stable and relocatable Restart still dispatch lifecycle") { + for location in [AppBundleLocation.stable, .relocatable] { + let lifecycle = RecordingLifecycleRunner() + let delegate = AppDelegate( + appBundleLocation: location, + actions: ActionCoordinator(lifecycle: lifecycle), + lifecycleConfirmation: { _ in true } + ) + + delegate.restartProxyForTesting() + spinMainRunLoop(seconds: 0.10) + + runner.equal(lifecycle.recordedActions, [.restart], "\(location)") + } +} + +runner.test("ui: translocated catalog recheck records zero lifecycle dispatch") { + let lifecycle = RecordingLifecycleRunner() + let delegate = AppDelegate( + appBundleLocation: .translocated, + actions: ActionCoordinator(lifecycle: lifecycle) + ) + + delegate.recheckCodexCatalogForTesting() + spinMainRunLoop(seconds: 0.05) + + runner.equal(lifecycle.recordedActions, []) + runner.equal(delegate.spawnActionInFlightForTesting, false) + runner.equal( + delegate.presentationControllerForTesting.operationStatusTitle, + "Move CodexCommander to Applications" + ) + runner.equal( + delegate.presentationControllerForTesting.catalogUpdateVisible, + true, + "blocking Ensure preserves the pending catalog card" + ) +} + +runner.test("ui: stable and relocatable catalog recheck still dispatches Ensure") { + for location in [AppBundleLocation.stable, .relocatable] { + let lifecycle = RecordingLifecycleRunner() + let delegate = AppDelegate( + appBundleLocation: location, + actions: ActionCoordinator(lifecycle: lifecycle) + ) + + delegate.recheckCodexCatalogForTesting() + spinMainRunLoop(seconds: 0.10) + + runner.equal(lifecycle.recordedActions, [.ensure], "\(location)") + } +} + // MARK: - Resource honesty runner.test("ui: provider icon loader returns real SVG-backed images for known providers") { From bfb372e07b8f8cf6beb47a03c2b1305886de5f92 Mon Sep 17 00:00:00 2001 From: pavelhov Date: Fri, 21 Aug 2026 06:30:30 -0400 Subject: [PATCH 28/28] test: protect macOS first-run temp cleanup --- tests/macos-first-run.test.ts | 52 +++++++++++++++++------------------ 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/tests/macos-first-run.test.ts b/tests/macos-first-run.test.ts index d461a980be..8a063f5668 100644 --- a/tests/macos-first-run.test.ts +++ b/tests/macos-first-run.test.ts @@ -29,33 +29,33 @@ function runProductionScenario(options: { codexHomeFileRaw?: string; }): ProductionSnapshot { const root = mkdtempSync(join(tmpdir(), "ccx-macos-first-run-")); - const appHome = join(root, "app-home"); - const codexHome = join(root, "codex-home"); - mkdirSync(appHome); - mkdirSync(codexHome); - if (options.appRaw !== undefined) writeFileSync(join(appHome, "config.json"), options.appRaw, "utf8"); - if (options.codexRaw !== undefined) writeFileSync(join(codexHome, "config.toml"), options.codexRaw, "utf8"); - - const script = ` - globalThis.fetch = () => { throw new Error("network blocked by macOS first-run test"); }; - const { rmSync, writeFileSync } = await import("node:fs"); - const { getDefaultConfig, validateConfigCandidate } = await import("./src/config.ts"); - const { prepareMacOSAppStart } = await import("./src/cli/macos-first-run.ts"); - const codexHome = process.env.CODEX_HOME; - if (process.env.CCX_TEST_REPLACE_CODEX_HOME === "1") { - rmSync(codexHome, { recursive: true, force: true }); - writeFileSync(codexHome, process.env.CCX_TEST_CODEX_SENTINEL ?? "", "utf8"); - } - console.log(JSON.stringify({ - result: prepareMacOSAppStart(), - expectedDefault: validateConfigCandidate(getDefaultConfig()).config, - expectedMissing: validateConfigCandidate({ - ...getDefaultConfig(), - clientIntegrations: { codex: false }, - }).config, - })); - `; try { + const appHome = join(root, "app-home"); + const codexHome = join(root, "codex-home"); + mkdirSync(appHome); + mkdirSync(codexHome); + if (options.appRaw !== undefined) writeFileSync(join(appHome, "config.json"), options.appRaw, "utf8"); + if (options.codexRaw !== undefined) writeFileSync(join(codexHome, "config.toml"), options.codexRaw, "utf8"); + + const script = ` + globalThis.fetch = () => { throw new Error("network blocked by macOS first-run test"); }; + const { rmSync, writeFileSync } = await import("node:fs"); + const { getDefaultConfig, validateConfigCandidate } = await import("./src/config.ts"); + const { prepareMacOSAppStart } = await import("./src/cli/macos-first-run.ts"); + const codexHome = process.env.CODEX_HOME; + if (process.env.CCX_TEST_REPLACE_CODEX_HOME === "1") { + rmSync(codexHome, { recursive: true, force: true }); + writeFileSync(codexHome, process.env.CCX_TEST_CODEX_SENTINEL ?? "", "utf8"); + } + console.log(JSON.stringify({ + result: prepareMacOSAppStart(), + expectedDefault: validateConfigCandidate(getDefaultConfig()).config, + expectedMissing: validateConfigCandidate({ + ...getDefaultConfig(), + clientIntegrations: { codex: false }, + }).config, + })); + `; const childEnv = { ...process.env, CODEXCOMMANDER_HOME: appHome,