From ea3fdd682cabadc32cf8c16e29c725c675f7f11d Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 30 Oct 2026 13:54:57 +0900 Subject: [PATCH 1/8] fix(credentials): stop Windows ancestor-watch feedback loops --- src/local-credentials/generation.ts | 8 +- src/local-credentials/inventory.ts | 75 ++++++- structure/remote-link.md | 2 +- .../local-credential-generation.test.ts | 210 +++++++++++++++++- 4 files changed, 283 insertions(+), 12 deletions(-) diff --git a/src/local-credentials/generation.ts b/src/local-credentials/generation.ts index 0da2acb199c..1c370840f52 100644 --- a/src/local-credentials/generation.ts +++ b/src/local-credentials/generation.ts @@ -212,8 +212,12 @@ export function claimCredentialObservation(home: string, scan: (accounts: readon }); } /** - * Advance on every observed event and rescan; a failed scan leaves an empty vector, which never - * attests. It never waits for the fence: the runtime observer retries asynchronously instead. + * Advance on every observed coverage-relevant event and rescan; a failed scan leaves an empty + * vector, which never attests. It never waits for the fence: the runtime observer retries + * asynchronously instead. "Coverage-relevant" is the watcher's classification in inventory.ts: + * every event on a target name, credential-write residue, or ancestor structural change -- + * but not an ancestor directory's last-write tick from ordinary child churn, which reports no + * credential change and would re-arm the observer's own save forever. */ export function updateCredentialObservation(home: string, owner: string, scan: ((accounts: readonly string[]) => string) | null): void { withCredentialPublicationFence(home, () => { diff --git a/src/local-credentials/inventory.ts b/src/local-credentials/inventory.ts index 2f817b38811..38107c54557 100644 --- a/src/local-credentials/inventory.ts +++ b/src/local-credentials/inventory.ts @@ -40,6 +40,56 @@ function assertParents(path: string): void { if (p === parse(p).root) break; } } +/** Watch classification for one parent directory, per basenames seen there. */ +export type WatchEntry = { + /** + * Basenames that are coverage targets themselves: a file whose bytes are scanned or a + * directory whose whole listing is checked. Every event on these is coverage-relevant + * -- an in-place content change reconciles exactly like a rename, even when the scan + * vector ends up identical, because the advance is what proves the observation ran. + */ + names: Set; + /** + * Basenames watched only as ancestors of a deeper target, paired with the identity + * recorded at install. A "rename" always reconciles: a replacement is the signal these + * entries exist to catch. A "change" reconciles too unless the entry re-identifies as + * the same real directory -- on Windows a directory entry reports "change" whenever a + * child is added or removed, so this observer's own local-credentials.json save would + * otherwise re-arm the same reconcile forever. null means the ancestor could not be + * identified at install, and an unverifiable ancestor reconciles on any event. + */ + ancestors: Map; +}; +const CREDENTIAL_TMP_NAME = /^(auth|config|codex-accounts)\.json\.ocx\./; +function ancestorIdentity(parent: string, child: string): { dev: bigint; ino: bigint } | null { + try { + const stat = lstatSync(join(parent, child), { bigint: true }); + return stat.isDirectory() && !stat.isSymbolicLink() ? { dev: stat.dev, ino: stat.ino } : null; + } catch { return null; } +} +type WatchEventAction = "fail" | "reconcile" | "ignore"; +/** + * The single (parent, event, name) decision behind the observation watchers, exported so + * tests can pin every branch without timing-dependent filesystem notifications. + */ +export function credentialWatchEventAction( + parent: string, entry: WatchEntry, event: string, name: string | null, +): WatchEventAction { + if (name === null) return "fail"; + if (entry.names.has(name) || CREDENTIAL_TMP_NAME.test(name)) return "reconcile"; + const ancestor = entry.ancestors.get(name); + if (ancestor === undefined) return "ignore"; + // Renames (create/delete/move, including a same-identity rename) are structural and + // always reconcile; only "change" may be child-list noise from a same-identity dir. + if (event !== "change" || ancestor === null) return "reconcile"; + try { + const stat = lstatSync(join(parent, name), { bigint: true }); + if (stat.isDirectory() && !stat.isSymbolicLink() && stat.dev === ancestor.dev && stat.ino === ancestor.ino) { + return "ignore"; + } + } catch { /* an ancestor that cannot be re-identified cannot prove the tick is noise */ } + return "reconcile"; +} export function scanLocalCredentials(accounts: readonly string[] = []): { count: number; vector: string; targets: string[] } { const home = getConfigDir(); const codex = getCodexHome(); @@ -166,6 +216,10 @@ export function scanLocalCredentials(accounts: readonly string[] = []): { count: const profiles = countLocalNativeProfileSlots(vault, homeId); if (profiles.active && !main) throw new Error("Active native profile has no readable auth"); count += main + profiles.inactive; + // The whole listing is verified below, so the directory itself is a coverage target: + // an unknown create/delete must reach a watcher even when the child's own name is not. + targets.add(shared); + assertParents(shared); if (exists(shared)) { if (!lstatSync(shared).isDirectory() || lstatSync(shared).isSymbolicLink()) throw new Error("Unsafe native vault"); const expected = new Set([`${homeId}.vault.json`, `${homeId}.stages.json`]); @@ -223,23 +277,28 @@ export function startLocalCredentialObservation(): { stop(): void; reconcile(): } }; const install = (targets: string[]) => { - const watched = new Map>(); + const watched = new Map(); for (const target of targets) { + let leaf = true; for (let path = target; dirname(path) !== path; path = dirname(path)) { const parent = dirname(path); if (!exists(parent)) continue; assertParents(path); - const names = watched.get(parent) ?? new Set(); - names.add(path.slice(parent.length + (parent.endsWith("/") || parent.endsWith("\\") ? 0 : 1))); - watched.set(parent, names); + const entry = watched.get(parent) ?? { names: new Set(), ancestors: new Map() }; + const child = path.slice(parent.length + (parent.endsWith("/") || parent.endsWith("\\") ? 0 : 1)); + if (leaf) entry.names.add(child); + else if (!entry.ancestors.has(child)) entry.ancestors.set(child, ancestorIdentity(parent, child)); + watched.set(parent, entry); + leaf = false; } } const next: FSWatcher[] = []; try { - for (const [parent, names] of watched) { - const watcher = watch(parent, { persistent: false }, (_event, name) => { - if (name === null) { fail(); return; } - if (names.has(String(name)) || /^(auth|config|codex-accounts)\.json\.ocx\./.test(String(name))) reconcile(); + for (const [parent, entry] of watched) { + const watcher = watch(parent, { persistent: false }, (event, name) => { + const action = credentialWatchEventAction(parent, entry, event, name === null ? null : String(name)); + if (action === "fail") fail(); + else if (action === "reconcile") reconcile(); }); watcher.on("error", fail); next.push(watcher); diff --git a/structure/remote-link.md b/structure/remote-link.md index f1b94aebbed..d939b996712 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -119,7 +119,7 @@ The explicit `ocx link revoke --force --yes` branch in `src/cli/link.ts` sends o `ocx status --json` reports `localCredentials: {count, generation}` from `src/local-credentials/` on any runtime. `inventory.ts` counts this installation's stored provider credentials passively: OAuth accounts and the pre-multiauth backup, provider API-key slots including disabled entries and keychain, environment and GCP references, Codex pool records, the native Codex `auth.json` and inactive native profiles. It never counts link, management or incoming API keys, so a Child that holds only its link key reports zero, and accounts its Home serves are never local. A link-mode Child relays every model request to its Home and never dispatches to a provider itself, so a provider that needs no stored credential cannot serve a Child request and has nothing to count. An unreadable or unfamiliar source, a write residue, a pending native-profile transaction or a native Codex credential store other than `file` throws, and status omits the field instead of reporting zero. -`generation.ts` keeps `local-credentials.json` (epoch, sequence, pending tickets, touched keychain accounts and the observer's file-identity vector; never a credential value). Instrumented writers publish a ticket before credential bytes change and advance the sequence afterwards: `src/oauth/store.ts`, `src/codex/account-store.ts`, `src/config/persist-unlocked.ts`, `src/codex/main-account.ts`, the native-profile writers and the provider keychain factory in `src/providers/api-key-resolve.ts`. The token `lc1::` therefore changes on every add, replacement or removal, including one that ends at the same count. Every instrumented publication runs inside the credential-publication fence (`publication-fence.ts`), the OS lock of `local-credential-publication.sqlite`, separate from the config mutation lock: a writer holds it exclusively across its ticket, the write and the ticket's close, and fails if it cannot take it within ten seconds. Once coordination metadata exists, a publication whose ticket cannot be persisted (unreadable or unwritable metadata) aborts before it changes any credential bytes, so a completed write can never leave an earlier token attestable. Status holds it shared across both generation reads and the source scan, retries briefly while a publication holds it, and otherwise omits the field, so no snapshot can interleave with an instrumented write. Ticket and observer metadata are updated only under the fence, so contention on the config lock cannot drop a ticket. OS-store accounts are retained the same way: the observer seeds every `keychain:` and `gcp-sa:` account `config.json` references at start and on every reconcile, config persistence registers the references on both sides of a write before it lands (`references.ts`), and a keychain write or deletion registers its account before it is attempted. An account leaves the count only after a read proves it absent, so a secret whose config slot was removed while its deletion failed or never ran keeps counting. A proven absence is remembered in the metadata: a reference that still names that account is not registered again (it counts only as a configured slot while it stays in config), and only a new keychain write or deletion attempt for the account clears the mark. `restoreProviderKeyFromKeychain` in `src/providers/key-store.ts` saves the plaintext config before it deletes the OS entries; the other deletions remove secrets the config never referenced or run after the removal is committed. `runtime-observation.ts` gives `startServer` and `startClientRuntime` the same observer, which takes an OS lease (`observation-lease.ts`), watches the sources, rescans on every event and starts a new epoch at each runtime start; the client runtime releases it before a restart hands over to a replacement. Status attests only while that lease is held, no ticket is pending and the vector matches a fresh scan, so a stopped runtime, a lost watcher or an in-flight write omits the field. +`generation.ts` keeps `local-credentials.json` (epoch, sequence, pending tickets, touched keychain accounts and the observer's file-identity vector; never a credential value). Instrumented writers publish a ticket before credential bytes change and advance the sequence afterwards: `src/oauth/store.ts`, `src/codex/account-store.ts`, `src/config/persist-unlocked.ts`, `src/codex/main-account.ts`, the native-profile writers and the provider keychain factory in `src/providers/api-key-resolve.ts`. The token `lc1::` therefore changes on every add, replacement or removal, including one that ends at the same count. Every instrumented publication runs inside the credential-publication fence (`publication-fence.ts`), the OS lock of `local-credential-publication.sqlite`, separate from the config mutation lock: a writer holds it exclusively across its ticket, the write and the ticket's close, and fails if it cannot take it within ten seconds. Once coordination metadata exists, a publication whose ticket cannot be persisted (unreadable or unwritable metadata) aborts before it changes any credential bytes, so a completed write can never leave an earlier token attestable. Status holds it shared across both generation reads and the source scan, retries briefly while a publication holds it, and otherwise omits the field, so no snapshot can interleave with an instrumented write. Ticket and observer metadata are updated only under the fence, so contention on the config lock cannot drop a ticket. OS-store accounts are retained the same way: the observer seeds every `keychain:` and `gcp-sa:` account `config.json` references at start and on every reconcile, config persistence registers the references on both sides of a write before it lands (`references.ts`), and a keychain write or deletion registers its account before it is attempted. An account leaves the count only after a read proves it absent, so a secret whose config slot was removed while its deletion failed or never ran keeps counting. A proven absence is remembered in the metadata: a reference that still names that account is not registered again (it counts only as a configured slot while it stays in config), and only a new keychain write or deletion attempt for the account clears the mark. `restoreProviderKeyFromKeychain` in `src/providers/key-store.ts` saves the plaintext config before it deletes the OS entries; the other deletions remove secrets the config never referenced or run after the removal is committed. `runtime-observation.ts` gives `startServer` and `startClientRuntime` the same observer, which takes an OS lease (`observation-lease.ts`), watches the sources, rescans on every event and starts a new epoch at each runtime start; the client runtime releases it before a restart hands over to a replacement. Status attests only while that lease is held, no ticket is pending and the vector matches a fresh scan, so a stopped runtime, a lost watcher or an in-flight write omits the field. "Every event" above means every coverage-relevant event, classified per (watched parent, event, entry name) by `credentialWatchEventAction` in `inventory.ts`: an event on a scanned file, on a whole-listing-verified directory such as `.opencodex-native-main-profiles`, or on credential-write residue, and a `rename` on a tracked target or ancestor entry, always reconcile, while an ancestor directory's `change` is skipped only when the entry re-identifies as the same real directory recorded at install -- ordinary child churn, including this observer's own save, would otherwise re-arm the reconcile forever. A missing, replaced, symlinked or unidentifiable ancestor still reconciles, and a watcher error or a null event name fails the observation closed. The vault directory's target status means a child event inside it reaches the observer wherever the platform delivers one -- Windows reports the parent's last-write tick for child churn, while other platforms may deliver no event for the pair -- but the fail-closed part is platform-common: a fresh scan still throws on an unknown entry and status omits the attestation while it remains. There is no debounce, coalescing or scan-vector shortcut: every coverage-relevant event advances the generation even when the rescan is unchanged, because the advance is what proves the observation ran. A link-mode Child reports its current tunnel connection as `link.connectionGeneration` in a ready `/readyz`; `src/cli/status-link.ts` reads it from the live client runtime and omits it whenever that runtime does not answer ready with a valid value. Names have the form `:` and come from one process-wide sequence (`nextLinkConnectionGeneration` in `src/link/relay-auth.ts`), so a supervisor rebuilt in the same process, such as after a restart rollback, never repeats one. A Child-initiated link takes a new name from its supervisor on every transition into connected. For a Home-initiated link the Home supervisor names each ssh child it spawns, and its relay-auth answer carries that name with its own proof over the nonce, key and link ids (`linkConnectionProof` in `src/link/relay-auth.ts`), and the Child keeps the value from the latest authenticated answer (`src/client/link-connection.ts`). Every connect and every runtime restart changes it. An authenticated answer without a generation, or with one whose proof does not match, makes it unknown, so a Child that reconnects to an older Home never keeps reporting the previous connection. GOAT Ready requires client role, zero local credentials and equal generations before and after a completed Home inference; linked and healthy alone do not prove attribution. Coverage: `tests/cli/local-credentials.test.ts`, `tests/config/local-credential-generation.test.ts`, `tests/clients/link-connection-generation.test.ts`, `tests/clients/client-link-tunnel.test.ts`, `tests/clients/link-readiness.test.ts`. diff --git a/tests/config/local-credential-generation.test.ts b/tests/config/local-credential-generation.test.ts index 9eab3780211..c4d93d6d192 100644 --- a/tests/config/local-credential-generation.test.ts +++ b/tests/config/local-credential-generation.test.ts @@ -1,7 +1,8 @@ import { expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; import { join } from "node:path"; -import { chmodSync, existsSync, readFileSync, unlinkSync } from "node:fs"; +import { chmodSync, existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { withLocalCredentialFixture } from "../helpers/local-credentials"; const accountStoreUrl = new URL("../../src/codex/account-store.ts", import.meta.url).href; @@ -294,3 +295,210 @@ for (const observedFirst of [true, false]) { }); }); } + +async function generationOf(inventory: typeof import("../../src/local-credentials/inventory")): Promise { + return (await inventory.collectLocalCredentials())?.generation; +} +async function waitForNewGeneration( + inventory: typeof import("../../src/local-credentials/inventory"), + previous: string | undefined, + timeoutMs = 4000, +): Promise { + const deadline = Date.now() + timeoutMs; + let next: string | undefined; + do { + next = await generationOf(inventory); + if (next !== undefined && next !== previous) return next; + if (Date.now() >= deadline) return next; + await new Promise(resolve => setTimeout(resolve, 40)); + } while (true); +} +async function expectStableGeneration( + inventory: typeof import("../../src/local-credentials/inventory"), + previous: string | undefined, + windowMs = 1500, +): Promise { + const deadline = Date.now() + windowMs; + while (Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, 60)); + expect(await generationOf(inventory)).toBe(previous); + } +} + +// Waits until the generation stays constant over one quiet window, tolerating +// residual notifications from the mutation that produced it. Throws rather than +// return an unsettled token, so a caller can never start the ABA on a baseline +// that was still racing events. +async function settleGeneration( + inventory: typeof import("../../src/local-credentials/inventory"), + quietMs = 500, + timeoutMs = 4000, +): Promise { + const deadline = Date.now() + timeoutMs; + let last = await generationOf(inventory); + let quietSince = Date.now(); + while (Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, 50)); + const next = await generationOf(inventory); + if (next !== last) { last = next; quietSince = Date.now(); continue; } + if (Date.now() - quietSince >= quietMs) return last; + } + throw new Error("credential generation never settled: notifications still arriving at timeout"); +} + +test("the watch seam classifies direct, ancestor, identity, and failure cases deterministically", async () => { + const inventory = await import("../../src/local-credentials/inventory"); + const dir = mkdtempSync(join(tmpdir(), "ocx-watch-seam-")); + try { + const ancestor = join(dir, "ancestor"); + const direct = join(dir, "direct"); + const foreign = join(dir, "foreign"); + mkdirSync(ancestor); mkdirSync(direct); mkdirSync(foreign); + writeFileSync(join(dir, "auth.json"), "{}"); + const same = lstatSync(ancestor, { bigint: true }); + const entry = { + names: new Set(["auth.json", "direct"]), + ancestors: new Map([ + ["ancestor", { dev: same.dev, ino: same.ino }], + ["unverifiable", null], + ]), + }; + const action = (event: string, name: string | null) => + inventory.credentialWatchEventAction(dir, entry, event, name); + + // A same-identity ancestor reporting only a last-write tick is ordinary child + // churn, however often it repeats: no reconcile, no save, no re-arm. + expect(action("change", "ancestor")).toBe("ignore"); + expect(action("change", "ancestor")).toBe("ignore"); + // A rename is structural even when the entry re-identifies the same afterwards. + expect(action("rename", "ancestor")).toBe("reconcile"); + // Direct names reconcile on any event, including a plain content change. + expect(action("change", "auth.json")).toBe("reconcile"); + expect(action("rename", "auth.json")).toBe("reconcile"); + expect(action("change", "direct")).toBe("reconcile"); + // A name carrying both roles stays direct: direct wins over ancestor. + const overlapping = { names: new Set(["ancestor"]), ancestors: entry.ancestors }; + expect(inventory.credentialWatchEventAction(dir, overlapping, "change", "ancestor")).toBe("reconcile"); + // Credential-write residue is always coverage-relevant. + expect(action("change", "config.json.ocx.7.tmp")).toBe("reconcile"); + // An ancestor that could not be identified at install reconciles on any event. + expect(action("change", "unverifiable")).toBe("reconcile"); + expect(action("rename", "unverifiable")).toBe("reconcile"); + // Unrelated names never reconcile. + expect(action("change", "foreign")).toBe("ignore"); + expect(action("rename", "unknown.tmp")).toBe("ignore"); + // A different real directory at the same name: the original is kept aside so + // its inode stays allocated and the replacement provably has a new identity. + renameSync(ancestor, join(dir, "ancestor-aside")); + mkdirSync(ancestor); + expect(action("change", "ancestor")).toBe("reconcile"); + // Missing, and replaced by a link, cannot prove the tick is noise either. + rmSync(ancestor, { recursive: true, force: true }); + expect(action("change", "ancestor")).toBe("reconcile"); + symlinkSync(foreign, ancestor, "junction"); + expect(action("change", "ancestor")).toBe("reconcile"); + unlinkSync(ancestor); + rmSync(join(dir, "ancestor-aside"), { recursive: true, force: true }); + // A watcher overflow reports a null name and must fail closed. + expect(action("change", null)).toBe("fail"); + expect(action("rename", null)).toBe("fail"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("ordinary churn inside a watched ancestor does not re-arm the observer or advance the generation", async () => { + await withLocalCredentialFixture(async f => { + f.start(); + const before = await generationOf(f.inventory); + expect(before).toBeDefined(); + // Writes like runtime-port.json, journals, and this observer's own save tick the + // ancestor's last-write time on Windows; that tick must not re-arm a reconcile. + for (let i = 0; i < 6; i += 1) { + writeFileSync(join(f.home, `runtime-${i}.json`), "{}"); + if (i % 2 === 1) unlinkSync(join(f.home, `runtime-${i - 1}.json`)); + } + await expectStableGeneration(f.inventory, before); + // Suppression must not deafen the observer: a real target write still reconciles. + f.put(join(f.home, "config.json"), { model: "fixture" }); + const revived = await waitForNewGeneration(f.inventory, before); + expect(revived).toBeDefined(); + expect(revived).not.toBe(before); + }); +}); + +test("a same-identity rename of a watched ancestor still advances the generation", async () => { + await withLocalCredentialFixture(async f => { + f.start(); + const before = await generationOf(f.inventory); + expect(before).toBeDefined(); + const moved = `${f.codex}-moved`; + renameSync(f.codex, moved); + renameSync(moved, f.codex); + const after = await waitForNewGeneration(f.inventory, before); + expect(after).toBeDefined(); + expect(after).not.toBe(before); + }); +}); + +test("a direct target replacing itself with identical bytes still advances the generation", async () => { + await withLocalCredentialFixture(async f => { + f.start(); + const before = await generationOf(f.inventory); + expect(before).toBeDefined(); + const auth = join(f.codex, "auth.json"); + const shadow = join(f.codex, "auth-shadow.tmp"); + f.put(auth, { OPENAI_API_KEY: "fixture-native-key" }); + const settled = await waitForNewGeneration(f.inventory, before); + expect(settled).toBeDefined(); + // Rebind the directory entry to the same inode: the bytes stay identical, so + // only the observed event itself -- never content -- may advance coverage. + linkSync(auth, shadow); + unlinkSync(auth); + renameSync(shadow, auth); + const after = await waitForNewGeneration(f.inventory, settled); + expect(after).toBeDefined(); + expect(after).not.toBe(settled); + expect(readFileSync(auth, "utf8")).toBe(JSON.stringify({ OPENAI_API_KEY: "fixture-native-key" })); + }); +}); + +test("an unknown entry in the shared native-profile vault still reaches the observer", async () => { + await withLocalCredentialFixture(async f => { + const shared = join(f.codex, ".opencodex-native-main-profiles"); + // The vault listing itself is verified, so the directory must be a direct watch + // target; otherwise an unknown create/delete pair hides behind an ancestor tick. + expect(f.inventory.scanLocalCredentials().targets).toContain(shared); + f.start(); + const before = await generationOf(f.inventory); + expect(before).toBeDefined(); + mkdirSync(shared); + const created = await waitForNewGeneration(f.inventory, before); + expect(created).toBeDefined(); + // Let residual notifications from the mkdir drain: the ABA below must be + // measured against a settled token, not one still racing earlier events. + const settled = await settleGeneration(f.inventory); + expect(settled).toBeDefined(); + const vectorBefore = f.inventory.scanLocalCredentials().vector; + const unknown = join(shared, "unknown.json"); + writeFileSync(unknown, "{}"); + // Coverage fails closed while the entry is present: no attestation for an + // unverifiable vault listing. + expect(await f.inventory.collectLocalCredentials()).toBeUndefined(); + unlinkSync(unknown); + // The ABA pair leaves the vault listing -- and therefore the scan vector -- + // exactly as it was, so any advance can only come from observed events. + expect(f.inventory.scanLocalCredentials().vector).toBe(vectorBefore); + if (process.platform === "win32") { + // On Windows the pair ticks the vault's parent and is itself observed, so + // the previous token cannot be reused. Platforms that report no event for + // this pair cannot force the advance, so this is a win32-only check, not a + // platform-common verification. + const after = await waitForNewGeneration(f.inventory, settled); + expect(after).toBeDefined(); + expect(after).not.toBe(settled); + } else { + expect(await generationOf(f.inventory)).toBeDefined(); + } + }); +}); From 3ac8c988e5c4ba3fbe6d9ffc5eb3906eb1f3e93a Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 30 Oct 2026 14:41:36 +0900 Subject: [PATCH 2/8] test(credentials): settle writes before hardlink replacement --- tests/config/local-credential-generation.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/config/local-credential-generation.test.ts b/tests/config/local-credential-generation.test.ts index c4d93d6d192..be4d49df1c6 100644 --- a/tests/config/local-credential-generation.test.ts +++ b/tests/config/local-credential-generation.test.ts @@ -449,7 +449,9 @@ test("a direct target replacing itself with identical bytes still advances the g const auth = join(f.codex, "auth.json"); const shadow = join(f.codex, "auth-shadow.tmp"); f.put(auth, { OPENAI_API_KEY: "fixture-native-key" }); - const settled = await waitForNewGeneration(f.inventory, before); + const writeObserved = await waitForNewGeneration(f.inventory, before); + expect(writeObserved).toBeDefined(); + const settled = await settleGeneration(f.inventory); expect(settled).toBeDefined(); // Rebind the directory entry to the same inode: the bytes stay identical, so // only the observed event itself -- never content -- may advance coverage. From c8babd3524ebc9e3495fe9f83d505b557ab10dd8 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:17:36 +0900 Subject: [PATCH 3/8] fix(credentials): fold watcher names the way the volume resolves them A case-insensitive volume reports a notification under the writer's spelling. With same-identity ancestor ticks now ignored, an event for AUTH.JSON no longer had an incidental reconcile path. Compare target, ancestor and residue names case-insensitively with trailing dots and spaces removed, and always reconcile an 8.3 short name. --- src/local-credentials/inventory.ts | 26 ++++++++++++++++--- structure/remote-link.md | 2 +- .../local-credential-generation.test.ts | 15 +++++++++++ 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/local-credentials/inventory.ts b/src/local-credentials/inventory.ts index 38107c54557..09976c3c2ee 100644 --- a/src/local-credentials/inventory.ts +++ b/src/local-credentials/inventory.ts @@ -60,7 +60,22 @@ export type WatchEntry = { */ ancestors: Map; }; -const CREDENTIAL_TMP_NAME = /^(auth|config|codex-accounts)\.json\.ocx\./; +const CREDENTIAL_TMP_NAME = /^(auth|config|codex-accounts)\.json\.ocx\./i; +/** + * Watcher names are compared the way a case-insensitive volume resolves them. Windows and + * default macOS volumes report a notification under the spelling the writer used, so a + * credential created as AUTH.JSON reaches the watcher under that name while the scan reads + * it as auth.json. Folding can only add reconciles on a case-sensitive volume, never drop one. + */ +function foldWatchName(name: string): string { + return name.toLowerCase().replace(/[. ]+$/, ""); +} +/** An 8.3 short name (AUTH~1.JSO) cannot be mapped back to its long name without a lookup. */ +const SHORT_NAME_ALIAS = /~\d/; +function foldedLookup(keys: Iterable, key: (item: T) => string, folded: string): T | undefined { + for (const item of keys) if (foldWatchName(key(item)) === folded) return item; + return undefined; +} function ancestorIdentity(parent: string, child: string): { dev: bigint; ino: bigint } | null { try { const stat = lstatSync(join(parent, child), { bigint: true }); @@ -76,9 +91,12 @@ export function credentialWatchEventAction( parent: string, entry: WatchEntry, event: string, name: string | null, ): WatchEventAction { if (name === null) return "fail"; - if (entry.names.has(name) || CREDENTIAL_TMP_NAME.test(name)) return "reconcile"; - const ancestor = entry.ancestors.get(name); - if (ancestor === undefined) return "ignore"; + if (CREDENTIAL_TMP_NAME.test(name) || SHORT_NAME_ALIAS.test(name)) return "reconcile"; + const folded = foldWatchName(name); + if (foldedLookup(entry.names, n => n, folded) !== undefined) return "reconcile"; + const match = foldedLookup(entry.ancestors.entries(), ([n]) => n, folded); + if (match === undefined) return "ignore"; + const ancestor = match[1]; // Renames (create/delete/move, including a same-identity rename) are structural and // always reconcile; only "change" may be child-list noise from a same-identity dir. if (event !== "change" || ancestor === null) return "reconcile"; diff --git a/structure/remote-link.md b/structure/remote-link.md index d939b996712..28c31f16b27 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -119,7 +119,7 @@ The explicit `ocx link revoke --force --yes` branch in `src/cli/link.ts` sends o `ocx status --json` reports `localCredentials: {count, generation}` from `src/local-credentials/` on any runtime. `inventory.ts` counts this installation's stored provider credentials passively: OAuth accounts and the pre-multiauth backup, provider API-key slots including disabled entries and keychain, environment and GCP references, Codex pool records, the native Codex `auth.json` and inactive native profiles. It never counts link, management or incoming API keys, so a Child that holds only its link key reports zero, and accounts its Home serves are never local. A link-mode Child relays every model request to its Home and never dispatches to a provider itself, so a provider that needs no stored credential cannot serve a Child request and has nothing to count. An unreadable or unfamiliar source, a write residue, a pending native-profile transaction or a native Codex credential store other than `file` throws, and status omits the field instead of reporting zero. -`generation.ts` keeps `local-credentials.json` (epoch, sequence, pending tickets, touched keychain accounts and the observer's file-identity vector; never a credential value). Instrumented writers publish a ticket before credential bytes change and advance the sequence afterwards: `src/oauth/store.ts`, `src/codex/account-store.ts`, `src/config/persist-unlocked.ts`, `src/codex/main-account.ts`, the native-profile writers and the provider keychain factory in `src/providers/api-key-resolve.ts`. The token `lc1::` therefore changes on every add, replacement or removal, including one that ends at the same count. Every instrumented publication runs inside the credential-publication fence (`publication-fence.ts`), the OS lock of `local-credential-publication.sqlite`, separate from the config mutation lock: a writer holds it exclusively across its ticket, the write and the ticket's close, and fails if it cannot take it within ten seconds. Once coordination metadata exists, a publication whose ticket cannot be persisted (unreadable or unwritable metadata) aborts before it changes any credential bytes, so a completed write can never leave an earlier token attestable. Status holds it shared across both generation reads and the source scan, retries briefly while a publication holds it, and otherwise omits the field, so no snapshot can interleave with an instrumented write. Ticket and observer metadata are updated only under the fence, so contention on the config lock cannot drop a ticket. OS-store accounts are retained the same way: the observer seeds every `keychain:` and `gcp-sa:` account `config.json` references at start and on every reconcile, config persistence registers the references on both sides of a write before it lands (`references.ts`), and a keychain write or deletion registers its account before it is attempted. An account leaves the count only after a read proves it absent, so a secret whose config slot was removed while its deletion failed or never ran keeps counting. A proven absence is remembered in the metadata: a reference that still names that account is not registered again (it counts only as a configured slot while it stays in config), and only a new keychain write or deletion attempt for the account clears the mark. `restoreProviderKeyFromKeychain` in `src/providers/key-store.ts` saves the plaintext config before it deletes the OS entries; the other deletions remove secrets the config never referenced or run after the removal is committed. `runtime-observation.ts` gives `startServer` and `startClientRuntime` the same observer, which takes an OS lease (`observation-lease.ts`), watches the sources, rescans on every event and starts a new epoch at each runtime start; the client runtime releases it before a restart hands over to a replacement. Status attests only while that lease is held, no ticket is pending and the vector matches a fresh scan, so a stopped runtime, a lost watcher or an in-flight write omits the field. "Every event" above means every coverage-relevant event, classified per (watched parent, event, entry name) by `credentialWatchEventAction` in `inventory.ts`: an event on a scanned file, on a whole-listing-verified directory such as `.opencodex-native-main-profiles`, or on credential-write residue, and a `rename` on a tracked target or ancestor entry, always reconcile, while an ancestor directory's `change` is skipped only when the entry re-identifies as the same real directory recorded at install -- ordinary child churn, including this observer's own save, would otherwise re-arm the reconcile forever. A missing, replaced, symlinked or unidentifiable ancestor still reconciles, and a watcher error or a null event name fails the observation closed. The vault directory's target status means a child event inside it reaches the observer wherever the platform delivers one -- Windows reports the parent's last-write tick for child churn, while other platforms may deliver no event for the pair -- but the fail-closed part is platform-common: a fresh scan still throws on an unknown entry and status omits the attestation while it remains. There is no debounce, coalescing or scan-vector shortcut: every coverage-relevant event advances the generation even when the rescan is unchanged, because the advance is what proves the observation ran. +`generation.ts` keeps `local-credentials.json` (epoch, sequence, pending tickets, touched keychain accounts and the observer's file-identity vector; never a credential value). Instrumented writers publish a ticket before credential bytes change and advance the sequence afterwards: `src/oauth/store.ts`, `src/codex/account-store.ts`, `src/config/persist-unlocked.ts`, `src/codex/main-account.ts`, the native-profile writers and the provider keychain factory in `src/providers/api-key-resolve.ts`. The token `lc1::` therefore changes on every add, replacement or removal, including one that ends at the same count. Every instrumented publication runs inside the credential-publication fence (`publication-fence.ts`), the OS lock of `local-credential-publication.sqlite`, separate from the config mutation lock: a writer holds it exclusively across its ticket, the write and the ticket's close, and fails if it cannot take it within ten seconds. Once coordination metadata exists, a publication whose ticket cannot be persisted (unreadable or unwritable metadata) aborts before it changes any credential bytes, so a completed write can never leave an earlier token attestable. Status holds it shared across both generation reads and the source scan, retries briefly while a publication holds it, and otherwise omits the field, so no snapshot can interleave with an instrumented write. Ticket and observer metadata are updated only under the fence, so contention on the config lock cannot drop a ticket. OS-store accounts are retained the same way: the observer seeds every `keychain:` and `gcp-sa:` account `config.json` references at start and on every reconcile, config persistence registers the references on both sides of a write before it lands (`references.ts`), and a keychain write or deletion registers its account before it is attempted. An account leaves the count only after a read proves it absent, so a secret whose config slot was removed while its deletion failed or never ran keeps counting. A proven absence is remembered in the metadata: a reference that still names that account is not registered again (it counts only as a configured slot while it stays in config), and only a new keychain write or deletion attempt for the account clears the mark. `restoreProviderKeyFromKeychain` in `src/providers/key-store.ts` saves the plaintext config before it deletes the OS entries; the other deletions remove secrets the config never referenced or run after the removal is committed. `runtime-observation.ts` gives `startServer` and `startClientRuntime` the same observer, which takes an OS lease (`observation-lease.ts`), watches the sources, rescans on every event and starts a new epoch at each runtime start; the client runtime releases it before a restart hands over to a replacement. Status attests only while that lease is held, no ticket is pending and the vector matches a fresh scan, so a stopped runtime, a lost watcher or an in-flight write omits the field. "Every event" above means every coverage-relevant event, classified per (watched parent, event, entry name) by `credentialWatchEventAction` in `inventory.ts`: an event on a scanned file, on a whole-listing-verified directory such as `.opencodex-native-main-profiles`, or on credential-write residue, and a `rename` on a tracked target or ancestor entry, always reconcile, while an ancestor directory's `change` is skipped only when the entry re-identifies as the same real directory recorded at install -- ordinary child churn, including this observer's own save, would otherwise re-arm the reconcile forever. Entry names are compared case-insensitively with trailing dots and spaces removed, as Windows and default macOS volumes resolve them, and an 8.3 short name (a tilde followed by a digit) always reconciles, so a differently spelled event on a credential path never falls through as unrelated. A missing, replaced, symlinked or unidentifiable ancestor still reconciles, and a watcher error or a null event name fails the observation closed. The vault directory's target status means a child event inside it reaches the observer wherever the platform delivers one -- Windows reports the parent's last-write tick for child churn, while other platforms may deliver no event for the pair -- but the fail-closed part is platform-common: a fresh scan still throws on an unknown entry and status omits the attestation while it remains. There is no debounce, coalescing or scan-vector shortcut: every coverage-relevant event advances the generation even when the rescan is unchanged, because the advance is what proves the observation ran. A link-mode Child reports its current tunnel connection as `link.connectionGeneration` in a ready `/readyz`; `src/cli/status-link.ts` reads it from the live client runtime and omits it whenever that runtime does not answer ready with a valid value. Names have the form `:` and come from one process-wide sequence (`nextLinkConnectionGeneration` in `src/link/relay-auth.ts`), so a supervisor rebuilt in the same process, such as after a restart rollback, never repeats one. A Child-initiated link takes a new name from its supervisor on every transition into connected. For a Home-initiated link the Home supervisor names each ssh child it spawns, and its relay-auth answer carries that name with its own proof over the nonce, key and link ids (`linkConnectionProof` in `src/link/relay-auth.ts`), and the Child keeps the value from the latest authenticated answer (`src/client/link-connection.ts`). Every connect and every runtime restart changes it. An authenticated answer without a generation, or with one whose proof does not match, makes it unknown, so a Child that reconnects to an older Home never keeps reporting the previous connection. GOAT Ready requires client role, zero local credentials and equal generations before and after a completed Home inference; linked and healthy alone do not prove attribution. Coverage: `tests/cli/local-credentials.test.ts`, `tests/config/local-credential-generation.test.ts`, `tests/clients/link-connection-generation.test.ts`, `tests/clients/client-link-tunnel.test.ts`, `tests/clients/link-readiness.test.ts`. diff --git a/tests/config/local-credential-generation.test.ts b/tests/config/local-credential-generation.test.ts index be4d49df1c6..d4cd406ed19 100644 --- a/tests/config/local-credential-generation.test.ts +++ b/tests/config/local-credential-generation.test.ts @@ -387,6 +387,21 @@ test("the watch seam classifies direct, ancestor, identity, and failure cases de // Unrelated names never reconcile. expect(action("change", "foreign")).toBe("ignore"); expect(action("rename", "unknown.tmp")).toBe("ignore"); + // A case-insensitive volume reports the writer's spelling: a differently cased or + // trailing-dot spelling of a target, its write residue, or an unresolvable 8.3 short + // name must still reconcile rather than fall through as an unrelated name. + expect(action("rename", "AUTH.JSON")).toBe("reconcile"); + expect(action("change", "Auth.Json")).toBe("reconcile"); + expect(action("change", "auth.json.")).toBe("reconcile"); + expect(action("rename", "Direct")).toBe("reconcile"); + expect(action("rename", "CONFIG.JSON.OCX.7.tmp")).toBe("reconcile"); + expect(action("rename", "AUTH~1.JSO")).toBe("reconcile"); + // A differently cased ancestor is still an ancestor: renames reconcile, and a change is + // ignored only when that spelling resolves to the recorded directory itself. + expect(action("rename", "ANCESTOR")).toBe("reconcile"); + const upper = (() => { try { return lstatSync(join(dir, "ANCESTOR"), { bigint: true }); } catch { return null; } })(); + const sameDir = upper !== null && upper.dev === same.dev && upper.ino === same.ino; + expect(action("change", "ANCESTOR")).toBe(sameDir ? "ignore" : "reconcile"); // A different real directory at the same name: the original is kept aside so // its inode stays allocated and the replacement provably has a new identity. renameSync(ancestor, join(dir, "ancestor-aside")); From 9e9271b52adf025d5f26559a063bcebce9fdac38 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:24:07 +0900 Subject: [PATCH 4/8] fix(credentials): identity-check ancestors whose names look like short names An ancestor such as OPENCODEX_HOME=C:\ocx~1 took the unconditional short-name reconcile, so the observer's own save could re-arm the loop. Only an unmatched alias is ambiguous. --- src/local-credentials/inventory.ts | 6 ++++-- structure/remote-link.md | 2 +- tests/config/local-credential-generation.test.ts | 8 ++++++++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/local-credentials/inventory.ts b/src/local-credentials/inventory.ts index 09976c3c2ee..2050f237aed 100644 --- a/src/local-credentials/inventory.ts +++ b/src/local-credentials/inventory.ts @@ -91,11 +91,13 @@ export function credentialWatchEventAction( parent: string, entry: WatchEntry, event: string, name: string | null, ): WatchEventAction { if (name === null) return "fail"; - if (CREDENTIAL_TMP_NAME.test(name) || SHORT_NAME_ALIAS.test(name)) return "reconcile"; + if (CREDENTIAL_TMP_NAME.test(name)) return "reconcile"; const folded = foldWatchName(name); if (foldedLookup(entry.names, n => n, folded) !== undefined) return "reconcile"; const match = foldedLookup(entry.ancestors.entries(), ([n]) => n, folded); - if (match === undefined) return "ignore"; + // A known ancestor whose real name merely looks like a short name (C:\ocx~1) goes through + // the identity check below; only an unmatched alias is ambiguous. + if (match === undefined) return SHORT_NAME_ALIAS.test(name) ? "reconcile" : "ignore"; const ancestor = match[1]; // Renames (create/delete/move, including a same-identity rename) are structural and // always reconcile; only "change" may be child-list noise from a same-identity dir. diff --git a/structure/remote-link.md b/structure/remote-link.md index 28c31f16b27..0daa36455ee 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -119,7 +119,7 @@ The explicit `ocx link revoke --force --yes` branch in `src/cli/link.ts` sends o `ocx status --json` reports `localCredentials: {count, generation}` from `src/local-credentials/` on any runtime. `inventory.ts` counts this installation's stored provider credentials passively: OAuth accounts and the pre-multiauth backup, provider API-key slots including disabled entries and keychain, environment and GCP references, Codex pool records, the native Codex `auth.json` and inactive native profiles. It never counts link, management or incoming API keys, so a Child that holds only its link key reports zero, and accounts its Home serves are never local. A link-mode Child relays every model request to its Home and never dispatches to a provider itself, so a provider that needs no stored credential cannot serve a Child request and has nothing to count. An unreadable or unfamiliar source, a write residue, a pending native-profile transaction or a native Codex credential store other than `file` throws, and status omits the field instead of reporting zero. -`generation.ts` keeps `local-credentials.json` (epoch, sequence, pending tickets, touched keychain accounts and the observer's file-identity vector; never a credential value). Instrumented writers publish a ticket before credential bytes change and advance the sequence afterwards: `src/oauth/store.ts`, `src/codex/account-store.ts`, `src/config/persist-unlocked.ts`, `src/codex/main-account.ts`, the native-profile writers and the provider keychain factory in `src/providers/api-key-resolve.ts`. The token `lc1::` therefore changes on every add, replacement or removal, including one that ends at the same count. Every instrumented publication runs inside the credential-publication fence (`publication-fence.ts`), the OS lock of `local-credential-publication.sqlite`, separate from the config mutation lock: a writer holds it exclusively across its ticket, the write and the ticket's close, and fails if it cannot take it within ten seconds. Once coordination metadata exists, a publication whose ticket cannot be persisted (unreadable or unwritable metadata) aborts before it changes any credential bytes, so a completed write can never leave an earlier token attestable. Status holds it shared across both generation reads and the source scan, retries briefly while a publication holds it, and otherwise omits the field, so no snapshot can interleave with an instrumented write. Ticket and observer metadata are updated only under the fence, so contention on the config lock cannot drop a ticket. OS-store accounts are retained the same way: the observer seeds every `keychain:` and `gcp-sa:` account `config.json` references at start and on every reconcile, config persistence registers the references on both sides of a write before it lands (`references.ts`), and a keychain write or deletion registers its account before it is attempted. An account leaves the count only after a read proves it absent, so a secret whose config slot was removed while its deletion failed or never ran keeps counting. A proven absence is remembered in the metadata: a reference that still names that account is not registered again (it counts only as a configured slot while it stays in config), and only a new keychain write or deletion attempt for the account clears the mark. `restoreProviderKeyFromKeychain` in `src/providers/key-store.ts` saves the plaintext config before it deletes the OS entries; the other deletions remove secrets the config never referenced or run after the removal is committed. `runtime-observation.ts` gives `startServer` and `startClientRuntime` the same observer, which takes an OS lease (`observation-lease.ts`), watches the sources, rescans on every event and starts a new epoch at each runtime start; the client runtime releases it before a restart hands over to a replacement. Status attests only while that lease is held, no ticket is pending and the vector matches a fresh scan, so a stopped runtime, a lost watcher or an in-flight write omits the field. "Every event" above means every coverage-relevant event, classified per (watched parent, event, entry name) by `credentialWatchEventAction` in `inventory.ts`: an event on a scanned file, on a whole-listing-verified directory such as `.opencodex-native-main-profiles`, or on credential-write residue, and a `rename` on a tracked target or ancestor entry, always reconcile, while an ancestor directory's `change` is skipped only when the entry re-identifies as the same real directory recorded at install -- ordinary child churn, including this observer's own save, would otherwise re-arm the reconcile forever. Entry names are compared case-insensitively with trailing dots and spaces removed, as Windows and default macOS volumes resolve them, and an 8.3 short name (a tilde followed by a digit) always reconciles, so a differently spelled event on a credential path never falls through as unrelated. A missing, replaced, symlinked or unidentifiable ancestor still reconciles, and a watcher error or a null event name fails the observation closed. The vault directory's target status means a child event inside it reaches the observer wherever the platform delivers one -- Windows reports the parent's last-write tick for child churn, while other platforms may deliver no event for the pair -- but the fail-closed part is platform-common: a fresh scan still throws on an unknown entry and status omits the attestation while it remains. There is no debounce, coalescing or scan-vector shortcut: every coverage-relevant event advances the generation even when the rescan is unchanged, because the advance is what proves the observation ran. +`generation.ts` keeps `local-credentials.json` (epoch, sequence, pending tickets, touched keychain accounts and the observer's file-identity vector; never a credential value). Instrumented writers publish a ticket before credential bytes change and advance the sequence afterwards: `src/oauth/store.ts`, `src/codex/account-store.ts`, `src/config/persist-unlocked.ts`, `src/codex/main-account.ts`, the native-profile writers and the provider keychain factory in `src/providers/api-key-resolve.ts`. The token `lc1::` therefore changes on every add, replacement or removal, including one that ends at the same count. Every instrumented publication runs inside the credential-publication fence (`publication-fence.ts`), the OS lock of `local-credential-publication.sqlite`, separate from the config mutation lock: a writer holds it exclusively across its ticket, the write and the ticket's close, and fails if it cannot take it within ten seconds. Once coordination metadata exists, a publication whose ticket cannot be persisted (unreadable or unwritable metadata) aborts before it changes any credential bytes, so a completed write can never leave an earlier token attestable. Status holds it shared across both generation reads and the source scan, retries briefly while a publication holds it, and otherwise omits the field, so no snapshot can interleave with an instrumented write. Ticket and observer metadata are updated only under the fence, so contention on the config lock cannot drop a ticket. OS-store accounts are retained the same way: the observer seeds every `keychain:` and `gcp-sa:` account `config.json` references at start and on every reconcile, config persistence registers the references on both sides of a write before it lands (`references.ts`), and a keychain write or deletion registers its account before it is attempted. An account leaves the count only after a read proves it absent, so a secret whose config slot was removed while its deletion failed or never ran keeps counting. A proven absence is remembered in the metadata: a reference that still names that account is not registered again (it counts only as a configured slot while it stays in config), and only a new keychain write or deletion attempt for the account clears the mark. `restoreProviderKeyFromKeychain` in `src/providers/key-store.ts` saves the plaintext config before it deletes the OS entries; the other deletions remove secrets the config never referenced or run after the removal is committed. `runtime-observation.ts` gives `startServer` and `startClientRuntime` the same observer, which takes an OS lease (`observation-lease.ts`), watches the sources, rescans on every event and starts a new epoch at each runtime start; the client runtime releases it before a restart hands over to a replacement. Status attests only while that lease is held, no ticket is pending and the vector matches a fresh scan, so a stopped runtime, a lost watcher or an in-flight write omits the field. "Every event" above means every coverage-relevant event, classified per (watched parent, event, entry name) by `credentialWatchEventAction` in `inventory.ts`: an event on a scanned file, on a whole-listing-verified directory such as `.opencodex-native-main-profiles`, or on credential-write residue, and a `rename` on a tracked target or ancestor entry, always reconcile, while an ancestor directory's `change` is skipped only when the entry re-identifies as the same real directory recorded at install -- ordinary child churn, including this observer's own save, would otherwise re-arm the reconcile forever. Entry names are compared case-insensitively with trailing dots and spaces removed, as Windows and default macOS volumes resolve them, and a name that matches no target or ancestor but looks like an 8.3 short name (a tilde followed by a digit) reconciles, while a real ancestor so named keeps its identity check, so a differently spelled event on a credential path never falls through as unrelated. A missing, replaced, symlinked or unidentifiable ancestor still reconciles, and a watcher error or a null event name fails the observation closed. The vault directory's target status means a child event inside it reaches the observer wherever the platform delivers one -- Windows reports the parent's last-write tick for child churn, while other platforms may deliver no event for the pair -- but the fail-closed part is platform-common: a fresh scan still throws on an unknown entry and status omits the attestation while it remains. There is no debounce, coalescing or scan-vector shortcut: every coverage-relevant event advances the generation even when the rescan is unchanged, because the advance is what proves the observation ran. A link-mode Child reports its current tunnel connection as `link.connectionGeneration` in a ready `/readyz`; `src/cli/status-link.ts` reads it from the live client runtime and omits it whenever that runtime does not answer ready with a valid value. Names have the form `:` and come from one process-wide sequence (`nextLinkConnectionGeneration` in `src/link/relay-auth.ts`), so a supervisor rebuilt in the same process, such as after a restart rollback, never repeats one. A Child-initiated link takes a new name from its supervisor on every transition into connected. For a Home-initiated link the Home supervisor names each ssh child it spawns, and its relay-auth answer carries that name with its own proof over the nonce, key and link ids (`linkConnectionProof` in `src/link/relay-auth.ts`), and the Child keeps the value from the latest authenticated answer (`src/client/link-connection.ts`). Every connect and every runtime restart changes it. An authenticated answer without a generation, or with one whose proof does not match, makes it unknown, so a Child that reconnects to an older Home never keeps reporting the previous connection. GOAT Ready requires client role, zero local credentials and equal generations before and after a completed Home inference; linked and healthy alone do not prove attribution. Coverage: `tests/cli/local-credentials.test.ts`, `tests/config/local-credential-generation.test.ts`, `tests/clients/link-connection-generation.test.ts`, `tests/clients/client-link-tunnel.test.ts`, `tests/clients/link-readiness.test.ts`. diff --git a/tests/config/local-credential-generation.test.ts b/tests/config/local-credential-generation.test.ts index d4cd406ed19..569c29db022 100644 --- a/tests/config/local-credential-generation.test.ts +++ b/tests/config/local-credential-generation.test.ts @@ -354,12 +354,16 @@ test("the watch seam classifies direct, ancestor, identity, and failure cases de const direct = join(dir, "direct"); const foreign = join(dir, "foreign"); mkdirSync(ancestor); mkdirSync(direct); mkdirSync(foreign); + const shortLooking = join(dir, "ocx~1"); + mkdirSync(shortLooking); writeFileSync(join(dir, "auth.json"), "{}"); const same = lstatSync(ancestor, { bigint: true }); + const shortSame = lstatSync(shortLooking, { bigint: true }); const entry = { names: new Set(["auth.json", "direct"]), ancestors: new Map([ ["ancestor", { dev: same.dev, ino: same.ino }], + ["ocx~1", { dev: shortSame.dev, ino: shortSame.ino }], ["unverifiable", null], ]), }; @@ -396,6 +400,10 @@ test("the watch seam classifies direct, ancestor, identity, and failure cases de expect(action("rename", "Direct")).toBe("reconcile"); expect(action("rename", "CONFIG.JSON.OCX.7.tmp")).toBe("reconcile"); expect(action("rename", "AUTH~1.JSO")).toBe("reconcile"); + // A real ancestor whose name only looks like a short name (OPENCODEX_HOME=C:\ocx~1) is + // identity-checked like any other ancestor, so its own-save tick cannot re-arm the loop. + expect(action("change", "ocx~1")).toBe("ignore"); + expect(action("rename", "ocx~1")).toBe("reconcile"); // A differently cased ancestor is still an ancestor: renames reconcile, and a change is // ignored only when that spelling resolves to the recorded directory itself. expect(action("rename", "ANCESTOR")).toBe("reconcile"); From ffdf9642d7dac34e454f31b2b073b6a67720ba82 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:28:28 +0900 Subject: [PATCH 5/8] fix(credentials): identity-check ancestors whose names look like write residue OPENCODEX_HOME=C:\config.json.ocx.home matched the residue pattern before the ancestor lookup, so the observer's own save re-armed the reconcile. Residue and short-name shapes now only decide names that match no target or ancestor. --- src/local-credentials/inventory.ts | 8 ++++---- structure/remote-link.md | 2 +- tests/config/local-credential-generation.test.ts | 6 ++++++ 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/local-credentials/inventory.ts b/src/local-credentials/inventory.ts index 2050f237aed..e5fcc4ac8ee 100644 --- a/src/local-credentials/inventory.ts +++ b/src/local-credentials/inventory.ts @@ -91,13 +91,13 @@ export function credentialWatchEventAction( parent: string, entry: WatchEntry, event: string, name: string | null, ): WatchEventAction { if (name === null) return "fail"; - if (CREDENTIAL_TMP_NAME.test(name)) return "reconcile"; const folded = foldWatchName(name); if (foldedLookup(entry.names, n => n, folded) !== undefined) return "reconcile"; const match = foldedLookup(entry.ancestors.entries(), ([n]) => n, folded); - // A known ancestor whose real name merely looks like a short name (C:\ocx~1) goes through - // the identity check below; only an unmatched alias is ambiguous. - if (match === undefined) return SHORT_NAME_ALIAS.test(name) ? "reconcile" : "ignore"; + // A known ancestor whose real name merely looks like write residue or a short name + // (C:\config.json.ocx.home, C:\ocx~1) goes through the identity check below; only an + // unmatched name of either shape is coverage-relevant on its own. + if (match === undefined) return CREDENTIAL_TMP_NAME.test(name) || SHORT_NAME_ALIAS.test(name) ? "reconcile" : "ignore"; const ancestor = match[1]; // Renames (create/delete/move, including a same-identity rename) are structural and // always reconcile; only "change" may be child-list noise from a same-identity dir. diff --git a/structure/remote-link.md b/structure/remote-link.md index 0daa36455ee..38ab652e59f 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -119,7 +119,7 @@ The explicit `ocx link revoke --force --yes` branch in `src/cli/link.ts` sends o `ocx status --json` reports `localCredentials: {count, generation}` from `src/local-credentials/` on any runtime. `inventory.ts` counts this installation's stored provider credentials passively: OAuth accounts and the pre-multiauth backup, provider API-key slots including disabled entries and keychain, environment and GCP references, Codex pool records, the native Codex `auth.json` and inactive native profiles. It never counts link, management or incoming API keys, so a Child that holds only its link key reports zero, and accounts its Home serves are never local. A link-mode Child relays every model request to its Home and never dispatches to a provider itself, so a provider that needs no stored credential cannot serve a Child request and has nothing to count. An unreadable or unfamiliar source, a write residue, a pending native-profile transaction or a native Codex credential store other than `file` throws, and status omits the field instead of reporting zero. -`generation.ts` keeps `local-credentials.json` (epoch, sequence, pending tickets, touched keychain accounts and the observer's file-identity vector; never a credential value). Instrumented writers publish a ticket before credential bytes change and advance the sequence afterwards: `src/oauth/store.ts`, `src/codex/account-store.ts`, `src/config/persist-unlocked.ts`, `src/codex/main-account.ts`, the native-profile writers and the provider keychain factory in `src/providers/api-key-resolve.ts`. The token `lc1::` therefore changes on every add, replacement or removal, including one that ends at the same count. Every instrumented publication runs inside the credential-publication fence (`publication-fence.ts`), the OS lock of `local-credential-publication.sqlite`, separate from the config mutation lock: a writer holds it exclusively across its ticket, the write and the ticket's close, and fails if it cannot take it within ten seconds. Once coordination metadata exists, a publication whose ticket cannot be persisted (unreadable or unwritable metadata) aborts before it changes any credential bytes, so a completed write can never leave an earlier token attestable. Status holds it shared across both generation reads and the source scan, retries briefly while a publication holds it, and otherwise omits the field, so no snapshot can interleave with an instrumented write. Ticket and observer metadata are updated only under the fence, so contention on the config lock cannot drop a ticket. OS-store accounts are retained the same way: the observer seeds every `keychain:` and `gcp-sa:` account `config.json` references at start and on every reconcile, config persistence registers the references on both sides of a write before it lands (`references.ts`), and a keychain write or deletion registers its account before it is attempted. An account leaves the count only after a read proves it absent, so a secret whose config slot was removed while its deletion failed or never ran keeps counting. A proven absence is remembered in the metadata: a reference that still names that account is not registered again (it counts only as a configured slot while it stays in config), and only a new keychain write or deletion attempt for the account clears the mark. `restoreProviderKeyFromKeychain` in `src/providers/key-store.ts` saves the plaintext config before it deletes the OS entries; the other deletions remove secrets the config never referenced or run after the removal is committed. `runtime-observation.ts` gives `startServer` and `startClientRuntime` the same observer, which takes an OS lease (`observation-lease.ts`), watches the sources, rescans on every event and starts a new epoch at each runtime start; the client runtime releases it before a restart hands over to a replacement. Status attests only while that lease is held, no ticket is pending and the vector matches a fresh scan, so a stopped runtime, a lost watcher or an in-flight write omits the field. "Every event" above means every coverage-relevant event, classified per (watched parent, event, entry name) by `credentialWatchEventAction` in `inventory.ts`: an event on a scanned file, on a whole-listing-verified directory such as `.opencodex-native-main-profiles`, or on credential-write residue, and a `rename` on a tracked target or ancestor entry, always reconcile, while an ancestor directory's `change` is skipped only when the entry re-identifies as the same real directory recorded at install -- ordinary child churn, including this observer's own save, would otherwise re-arm the reconcile forever. Entry names are compared case-insensitively with trailing dots and spaces removed, as Windows and default macOS volumes resolve them, and a name that matches no target or ancestor but looks like an 8.3 short name (a tilde followed by a digit) reconciles, while a real ancestor so named keeps its identity check, so a differently spelled event on a credential path never falls through as unrelated. A missing, replaced, symlinked or unidentifiable ancestor still reconciles, and a watcher error or a null event name fails the observation closed. The vault directory's target status means a child event inside it reaches the observer wherever the platform delivers one -- Windows reports the parent's last-write tick for child churn, while other platforms may deliver no event for the pair -- but the fail-closed part is platform-common: a fresh scan still throws on an unknown entry and status omits the attestation while it remains. There is no debounce, coalescing or scan-vector shortcut: every coverage-relevant event advances the generation even when the rescan is unchanged, because the advance is what proves the observation ran. +`generation.ts` keeps `local-credentials.json` (epoch, sequence, pending tickets, touched keychain accounts and the observer's file-identity vector; never a credential value). Instrumented writers publish a ticket before credential bytes change and advance the sequence afterwards: `src/oauth/store.ts`, `src/codex/account-store.ts`, `src/config/persist-unlocked.ts`, `src/codex/main-account.ts`, the native-profile writers and the provider keychain factory in `src/providers/api-key-resolve.ts`. The token `lc1::` therefore changes on every add, replacement or removal, including one that ends at the same count. Every instrumented publication runs inside the credential-publication fence (`publication-fence.ts`), the OS lock of `local-credential-publication.sqlite`, separate from the config mutation lock: a writer holds it exclusively across its ticket, the write and the ticket's close, and fails if it cannot take it within ten seconds. Once coordination metadata exists, a publication whose ticket cannot be persisted (unreadable or unwritable metadata) aborts before it changes any credential bytes, so a completed write can never leave an earlier token attestable. Status holds it shared across both generation reads and the source scan, retries briefly while a publication holds it, and otherwise omits the field, so no snapshot can interleave with an instrumented write. Ticket and observer metadata are updated only under the fence, so contention on the config lock cannot drop a ticket. OS-store accounts are retained the same way: the observer seeds every `keychain:` and `gcp-sa:` account `config.json` references at start and on every reconcile, config persistence registers the references on both sides of a write before it lands (`references.ts`), and a keychain write or deletion registers its account before it is attempted. An account leaves the count only after a read proves it absent, so a secret whose config slot was removed while its deletion failed or never ran keeps counting. A proven absence is remembered in the metadata: a reference that still names that account is not registered again (it counts only as a configured slot while it stays in config), and only a new keychain write or deletion attempt for the account clears the mark. `restoreProviderKeyFromKeychain` in `src/providers/key-store.ts` saves the plaintext config before it deletes the OS entries; the other deletions remove secrets the config never referenced or run after the removal is committed. `runtime-observation.ts` gives `startServer` and `startClientRuntime` the same observer, which takes an OS lease (`observation-lease.ts`), watches the sources, rescans on every event and starts a new epoch at each runtime start; the client runtime releases it before a restart hands over to a replacement. Status attests only while that lease is held, no ticket is pending and the vector matches a fresh scan, so a stopped runtime, a lost watcher or an in-flight write omits the field. "Every event" above means every coverage-relevant event, classified per (watched parent, event, entry name) by `credentialWatchEventAction` in `inventory.ts`: an event on a scanned file, on a whole-listing-verified directory such as `.opencodex-native-main-profiles`, or on credential-write residue, and a `rename` on a tracked target or ancestor entry, always reconcile, while an ancestor directory's `change` is skipped only when the entry re-identifies as the same real directory recorded at install -- ordinary child churn, including this observer's own save, would otherwise re-arm the reconcile forever. Entry names are compared case-insensitively with trailing dots and spaces removed, as Windows and default macOS volumes resolve them, and a name that matches no target or ancestor but looks like an 8.3 short name (a tilde followed by a digit) reconciles, while a real ancestor whose name looks like a short name or like write residue keeps its identity check, so a differently spelled event on a credential path never falls through as unrelated. A missing, replaced, symlinked or unidentifiable ancestor still reconciles, and a watcher error or a null event name fails the observation closed. The vault directory's target status means a child event inside it reaches the observer wherever the platform delivers one -- Windows reports the parent's last-write tick for child churn, while other platforms may deliver no event for the pair -- but the fail-closed part is platform-common: a fresh scan still throws on an unknown entry and status omits the attestation while it remains. There is no debounce, coalescing or scan-vector shortcut: every coverage-relevant event advances the generation even when the rescan is unchanged, because the advance is what proves the observation ran. A link-mode Child reports its current tunnel connection as `link.connectionGeneration` in a ready `/readyz`; `src/cli/status-link.ts` reads it from the live client runtime and omits it whenever that runtime does not answer ready with a valid value. Names have the form `:` and come from one process-wide sequence (`nextLinkConnectionGeneration` in `src/link/relay-auth.ts`), so a supervisor rebuilt in the same process, such as after a restart rollback, never repeats one. A Child-initiated link takes a new name from its supervisor on every transition into connected. For a Home-initiated link the Home supervisor names each ssh child it spawns, and its relay-auth answer carries that name with its own proof over the nonce, key and link ids (`linkConnectionProof` in `src/link/relay-auth.ts`), and the Child keeps the value from the latest authenticated answer (`src/client/link-connection.ts`). Every connect and every runtime restart changes it. An authenticated answer without a generation, or with one whose proof does not match, makes it unknown, so a Child that reconnects to an older Home never keeps reporting the previous connection. GOAT Ready requires client role, zero local credentials and equal generations before and after a completed Home inference; linked and healthy alone do not prove attribution. Coverage: `tests/cli/local-credentials.test.ts`, `tests/config/local-credential-generation.test.ts`, `tests/clients/link-connection-generation.test.ts`, `tests/clients/client-link-tunnel.test.ts`, `tests/clients/link-readiness.test.ts`. diff --git a/tests/config/local-credential-generation.test.ts b/tests/config/local-credential-generation.test.ts index 569c29db022..ec6a607329b 100644 --- a/tests/config/local-credential-generation.test.ts +++ b/tests/config/local-credential-generation.test.ts @@ -356,14 +356,18 @@ test("the watch seam classifies direct, ancestor, identity, and failure cases de mkdirSync(ancestor); mkdirSync(direct); mkdirSync(foreign); const shortLooking = join(dir, "ocx~1"); mkdirSync(shortLooking); + const residueLooking = join(dir, "config.json.ocx.home"); + mkdirSync(residueLooking); writeFileSync(join(dir, "auth.json"), "{}"); const same = lstatSync(ancestor, { bigint: true }); const shortSame = lstatSync(shortLooking, { bigint: true }); + const residueSame = lstatSync(residueLooking, { bigint: true }); const entry = { names: new Set(["auth.json", "direct"]), ancestors: new Map([ ["ancestor", { dev: same.dev, ino: same.ino }], ["ocx~1", { dev: shortSame.dev, ino: shortSame.ino }], + ["config.json.ocx.home", { dev: residueSame.dev, ino: residueSame.ino }], ["unverifiable", null], ]), }; @@ -404,6 +408,8 @@ test("the watch seam classifies direct, ancestor, identity, and failure cases de // identity-checked like any other ancestor, so its own-save tick cannot re-arm the loop. expect(action("change", "ocx~1")).toBe("ignore"); expect(action("rename", "ocx~1")).toBe("reconcile"); + expect(action("change", "config.json.ocx.home")).toBe("ignore"); + expect(action("rename", "config.json.ocx.home")).toBe("reconcile"); // A differently cased ancestor is still an ancestor: renames reconcile, and a change is // ignored only when that spelling resolves to the recorded directory itself. expect(action("rename", "ANCESTOR")).toBe("reconcile"); From 4120a9a06d2aca49b1a20f684f98d64e5a6317f2 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:31:31 +0900 Subject: [PATCH 6/8] fix(credentials): resolve unmatched short names to tracked ancestors by identity A Windows 8.3 alias such as OPENC~1 for a tracked ancestor took the unconditional short-name reconcile, so the observer's own save could re-arm the loop. A change that resolves to a tracked ancestor's recorded directory is that ancestor's tick; any other short name still reconciles. --- src/local-credentials/inventory.ts | 19 +++++++++++++++++-- structure/remote-link.md | 2 +- .../local-credential-generation.test.ts | 11 +++++++++++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/local-credentials/inventory.ts b/src/local-credentials/inventory.ts index e5fcc4ac8ee..a8c41ddf754 100644 --- a/src/local-credentials/inventory.ts +++ b/src/local-credentials/inventory.ts @@ -70,7 +70,10 @@ const CREDENTIAL_TMP_NAME = /^(auth|config|codex-accounts)\.json\.ocx\./i; function foldWatchName(name: string): string { return name.toLowerCase().replace(/[. ]+$/, ""); } -/** An 8.3 short name (AUTH~1.JSO) cannot be mapped back to its long name without a lookup. */ +/** + * An 8.3 short name (AUTH~1.JSO, OPENC~1) cannot be mapped back to its long name by + * spelling; it is resolved by identity instead (see credentialWatchEventAction). + */ const SHORT_NAME_ALIAS = /~\d/; function foldedLookup(keys: Iterable, key: (item: T) => string, folded: string): T | undefined { for (const item of keys) if (foldWatchName(key(item)) === folded) return item; @@ -97,7 +100,19 @@ export function credentialWatchEventAction( // A known ancestor whose real name merely looks like write residue or a short name // (C:\config.json.ocx.home, C:\ocx~1) goes through the identity check below; only an // unmatched name of either shape is coverage-relevant on its own. - if (match === undefined) return CREDENTIAL_TMP_NAME.test(name) || SHORT_NAME_ALIAS.test(name) ? "reconcile" : "ignore"; + if (match === undefined) { + if (CREDENTIAL_TMP_NAME.test(name)) return "reconcile"; + if (!SHORT_NAME_ALIAS.test(name)) return "ignore"; + // An unmatched short name may alias a target (reconcile) or a tracked ancestor + // (OPENC~1 for OpenCodex). Only a change that resolves to the very directory a tracked + // ancestor recorded at install is that ancestor's child-churn tick. + if (event !== "change") return "reconcile"; + const resolved = ancestorIdentity(parent, name); + for (const recorded of entry.ancestors.values()) { + if (resolved && recorded && recorded.dev === resolved.dev && recorded.ino === resolved.ino) return "ignore"; + } + return "reconcile"; + } const ancestor = match[1]; // Renames (create/delete/move, including a same-identity rename) are structural and // always reconcile; only "change" may be child-list noise from a same-identity dir. diff --git a/structure/remote-link.md b/structure/remote-link.md index 38ab652e59f..e131922e78e 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -119,7 +119,7 @@ The explicit `ocx link revoke --force --yes` branch in `src/cli/link.ts` sends o `ocx status --json` reports `localCredentials: {count, generation}` from `src/local-credentials/` on any runtime. `inventory.ts` counts this installation's stored provider credentials passively: OAuth accounts and the pre-multiauth backup, provider API-key slots including disabled entries and keychain, environment and GCP references, Codex pool records, the native Codex `auth.json` and inactive native profiles. It never counts link, management or incoming API keys, so a Child that holds only its link key reports zero, and accounts its Home serves are never local. A link-mode Child relays every model request to its Home and never dispatches to a provider itself, so a provider that needs no stored credential cannot serve a Child request and has nothing to count. An unreadable or unfamiliar source, a write residue, a pending native-profile transaction or a native Codex credential store other than `file` throws, and status omits the field instead of reporting zero. -`generation.ts` keeps `local-credentials.json` (epoch, sequence, pending tickets, touched keychain accounts and the observer's file-identity vector; never a credential value). Instrumented writers publish a ticket before credential bytes change and advance the sequence afterwards: `src/oauth/store.ts`, `src/codex/account-store.ts`, `src/config/persist-unlocked.ts`, `src/codex/main-account.ts`, the native-profile writers and the provider keychain factory in `src/providers/api-key-resolve.ts`. The token `lc1::` therefore changes on every add, replacement or removal, including one that ends at the same count. Every instrumented publication runs inside the credential-publication fence (`publication-fence.ts`), the OS lock of `local-credential-publication.sqlite`, separate from the config mutation lock: a writer holds it exclusively across its ticket, the write and the ticket's close, and fails if it cannot take it within ten seconds. Once coordination metadata exists, a publication whose ticket cannot be persisted (unreadable or unwritable metadata) aborts before it changes any credential bytes, so a completed write can never leave an earlier token attestable. Status holds it shared across both generation reads and the source scan, retries briefly while a publication holds it, and otherwise omits the field, so no snapshot can interleave with an instrumented write. Ticket and observer metadata are updated only under the fence, so contention on the config lock cannot drop a ticket. OS-store accounts are retained the same way: the observer seeds every `keychain:` and `gcp-sa:` account `config.json` references at start and on every reconcile, config persistence registers the references on both sides of a write before it lands (`references.ts`), and a keychain write or deletion registers its account before it is attempted. An account leaves the count only after a read proves it absent, so a secret whose config slot was removed while its deletion failed or never ran keeps counting. A proven absence is remembered in the metadata: a reference that still names that account is not registered again (it counts only as a configured slot while it stays in config), and only a new keychain write or deletion attempt for the account clears the mark. `restoreProviderKeyFromKeychain` in `src/providers/key-store.ts` saves the plaintext config before it deletes the OS entries; the other deletions remove secrets the config never referenced or run after the removal is committed. `runtime-observation.ts` gives `startServer` and `startClientRuntime` the same observer, which takes an OS lease (`observation-lease.ts`), watches the sources, rescans on every event and starts a new epoch at each runtime start; the client runtime releases it before a restart hands over to a replacement. Status attests only while that lease is held, no ticket is pending and the vector matches a fresh scan, so a stopped runtime, a lost watcher or an in-flight write omits the field. "Every event" above means every coverage-relevant event, classified per (watched parent, event, entry name) by `credentialWatchEventAction` in `inventory.ts`: an event on a scanned file, on a whole-listing-verified directory such as `.opencodex-native-main-profiles`, or on credential-write residue, and a `rename` on a tracked target or ancestor entry, always reconcile, while an ancestor directory's `change` is skipped only when the entry re-identifies as the same real directory recorded at install -- ordinary child churn, including this observer's own save, would otherwise re-arm the reconcile forever. Entry names are compared case-insensitively with trailing dots and spaces removed, as Windows and default macOS volumes resolve them, and a name that matches no target or ancestor but looks like an 8.3 short name (a tilde followed by a digit) reconciles, while a real ancestor whose name looks like a short name or like write residue keeps its identity check, so a differently spelled event on a credential path never falls through as unrelated. A missing, replaced, symlinked or unidentifiable ancestor still reconciles, and a watcher error or a null event name fails the observation closed. The vault directory's target status means a child event inside it reaches the observer wherever the platform delivers one -- Windows reports the parent's last-write tick for child churn, while other platforms may deliver no event for the pair -- but the fail-closed part is platform-common: a fresh scan still throws on an unknown entry and status omits the attestation while it remains. There is no debounce, coalescing or scan-vector shortcut: every coverage-relevant event advances the generation even when the rescan is unchanged, because the advance is what proves the observation ran. +`generation.ts` keeps `local-credentials.json` (epoch, sequence, pending tickets, touched keychain accounts and the observer's file-identity vector; never a credential value). Instrumented writers publish a ticket before credential bytes change and advance the sequence afterwards: `src/oauth/store.ts`, `src/codex/account-store.ts`, `src/config/persist-unlocked.ts`, `src/codex/main-account.ts`, the native-profile writers and the provider keychain factory in `src/providers/api-key-resolve.ts`. The token `lc1::` therefore changes on every add, replacement or removal, including one that ends at the same count. Every instrumented publication runs inside the credential-publication fence (`publication-fence.ts`), the OS lock of `local-credential-publication.sqlite`, separate from the config mutation lock: a writer holds it exclusively across its ticket, the write and the ticket's close, and fails if it cannot take it within ten seconds. Once coordination metadata exists, a publication whose ticket cannot be persisted (unreadable or unwritable metadata) aborts before it changes any credential bytes, so a completed write can never leave an earlier token attestable. Status holds it shared across both generation reads and the source scan, retries briefly while a publication holds it, and otherwise omits the field, so no snapshot can interleave with an instrumented write. Ticket and observer metadata are updated only under the fence, so contention on the config lock cannot drop a ticket. OS-store accounts are retained the same way: the observer seeds every `keychain:` and `gcp-sa:` account `config.json` references at start and on every reconcile, config persistence registers the references on both sides of a write before it lands (`references.ts`), and a keychain write or deletion registers its account before it is attempted. An account leaves the count only after a read proves it absent, so a secret whose config slot was removed while its deletion failed or never ran keeps counting. A proven absence is remembered in the metadata: a reference that still names that account is not registered again (it counts only as a configured slot while it stays in config), and only a new keychain write or deletion attempt for the account clears the mark. `restoreProviderKeyFromKeychain` in `src/providers/key-store.ts` saves the plaintext config before it deletes the OS entries; the other deletions remove secrets the config never referenced or run after the removal is committed. `runtime-observation.ts` gives `startServer` and `startClientRuntime` the same observer, which takes an OS lease (`observation-lease.ts`), watches the sources, rescans on every event and starts a new epoch at each runtime start; the client runtime releases it before a restart hands over to a replacement. Status attests only while that lease is held, no ticket is pending and the vector matches a fresh scan, so a stopped runtime, a lost watcher or an in-flight write omits the field. "Every event" above means every coverage-relevant event, classified per (watched parent, event, entry name) by `credentialWatchEventAction` in `inventory.ts`: an event on a scanned file, on a whole-listing-verified directory such as `.opencodex-native-main-profiles`, or on credential-write residue, and a `rename` on a tracked target or ancestor entry, always reconcile, while an ancestor directory's `change` is skipped only when the entry re-identifies as the same real directory recorded at install -- ordinary child churn, including this observer's own save, would otherwise re-arm the reconcile forever. Entry names are compared case-insensitively with trailing dots and spaces removed, as Windows and default macOS volumes resolve them, and a name that matches no target or ancestor but looks like an 8.3 short name (a tilde followed by a digit) reconciles, while a real ancestor whose name looks like a short name or like write residue keeps its identity check, as does an unmatched short name whose change resolves to a tracked ancestor recorded directory, so a differently spelled event on a credential path never falls through as unrelated. A missing, replaced, symlinked or unidentifiable ancestor still reconciles, and a watcher error or a null event name fails the observation closed. The vault directory's target status means a child event inside it reaches the observer wherever the platform delivers one -- Windows reports the parent's last-write tick for child churn, while other platforms may deliver no event for the pair -- but the fail-closed part is platform-common: a fresh scan still throws on an unknown entry and status omits the attestation while it remains. There is no debounce, coalescing or scan-vector shortcut: every coverage-relevant event advances the generation even when the rescan is unchanged, because the advance is what proves the observation ran. A link-mode Child reports its current tunnel connection as `link.connectionGeneration` in a ready `/readyz`; `src/cli/status-link.ts` reads it from the live client runtime and omits it whenever that runtime does not answer ready with a valid value. Names have the form `:` and come from one process-wide sequence (`nextLinkConnectionGeneration` in `src/link/relay-auth.ts`), so a supervisor rebuilt in the same process, such as after a restart rollback, never repeats one. A Child-initiated link takes a new name from its supervisor on every transition into connected. For a Home-initiated link the Home supervisor names each ssh child it spawns, and its relay-auth answer carries that name with its own proof over the nonce, key and link ids (`linkConnectionProof` in `src/link/relay-auth.ts`), and the Child keeps the value from the latest authenticated answer (`src/client/link-connection.ts`). Every connect and every runtime restart changes it. An authenticated answer without a generation, or with one whose proof does not match, makes it unknown, so a Child that reconnects to an older Home never keeps reporting the previous connection. GOAT Ready requires client role, zero local credentials and equal generations before and after a completed Home inference; linked and healthy alone do not prove attribution. Coverage: `tests/cli/local-credentials.test.ts`, `tests/config/local-credential-generation.test.ts`, `tests/clients/link-connection-generation.test.ts`, `tests/clients/client-link-tunnel.test.ts`, `tests/clients/link-readiness.test.ts`. diff --git a/tests/config/local-credential-generation.test.ts b/tests/config/local-credential-generation.test.ts index ec6a607329b..8e8ef680371 100644 --- a/tests/config/local-credential-generation.test.ts +++ b/tests/config/local-credential-generation.test.ts @@ -358,16 +358,22 @@ test("the watch seam classifies direct, ancestor, identity, and failure cases de mkdirSync(shortLooking); const residueLooking = join(dir, "config.json.ocx.home"); mkdirSync(residueLooking); + // Stands in for the 8.3 alias of a tracked ancestor: the event spelling matches no + // tracked name, but resolves to the directory the ancestor recorded at install. + const aliased = join(dir, "longan~1"); + mkdirSync(aliased); writeFileSync(join(dir, "auth.json"), "{}"); const same = lstatSync(ancestor, { bigint: true }); const shortSame = lstatSync(shortLooking, { bigint: true }); const residueSame = lstatSync(residueLooking, { bigint: true }); + const aliasedSame = lstatSync(aliased, { bigint: true }); const entry = { names: new Set(["auth.json", "direct"]), ancestors: new Map([ ["ancestor", { dev: same.dev, ino: same.ino }], ["ocx~1", { dev: shortSame.dev, ino: shortSame.ino }], ["config.json.ocx.home", { dev: residueSame.dev, ino: residueSame.ino }], + ["longancestor", { dev: aliasedSame.dev, ino: aliasedSame.ino }], ["unverifiable", null], ]), }; @@ -410,6 +416,11 @@ test("the watch seam classifies direct, ancestor, identity, and failure cases de expect(action("rename", "ocx~1")).toBe("reconcile"); expect(action("change", "config.json.ocx.home")).toBe("ignore"); expect(action("rename", "config.json.ocx.home")).toBe("reconcile"); + // A short alias of a tracked ancestor is identity-checked; one that resolves to anything + // else (a target file, an unknown or missing entry) still reconciles. + expect(action("change", "longan~1")).toBe("ignore"); + expect(action("rename", "longan~1")).toBe("reconcile"); + expect(action("change", "missin~1")).toBe("reconcile"); // A differently cased ancestor is still an ancestor: renames reconcile, and a change is // ignored only when that spelling resolves to the recorded directory itself. expect(action("rename", "ANCESTOR")).toBe("reconcile"); From 4de6612c1e5c2b4ce0ef65171911263c02520848 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:38:38 +0900 Subject: [PATCH 7/8] fix(credentials): resolve unmatched short names against the install snapshot Windows may report either name of an entry, and a removed entry's short name cannot be looked up. An unmatched 8.3-shaped name now reconciles only when it aliases a target, when a target or ancestor of that parent lost its install identity, or when it renames a tracked ancestor; the observer's own metadata save under a short spelling is ignored. --- src/local-credentials/inventory.ts | 60 ++++++++++++---- structure/remote-link.md | 2 +- .../local-credential-generation.test.ts | 70 +++++++++++++++---- 3 files changed, 103 insertions(+), 29 deletions(-) diff --git a/src/local-credentials/inventory.ts b/src/local-credentials/inventory.ts index a8c41ddf754..c7349f1a175 100644 --- a/src/local-credentials/inventory.ts +++ b/src/local-credentials/inventory.ts @@ -59,6 +59,12 @@ export type WatchEntry = { * identified at install, and an unverifiable ancestor reconciles on any event. */ ancestors: Map; + /** + * Identity of each target name at install (watchEntryIdentity). An unmatched 8.3 short + * name is resolved against these: it reconciles when a target here changed or when the + * alias is a target. Absent means the entry is treated as unresolvable and reconciles. + */ + targetIdentities?: Map; }; const CREDENTIAL_TMP_NAME = /^(auth|config|codex-accounts)\.json\.ocx\./i; /** @@ -72,9 +78,43 @@ function foldWatchName(name: string): string { } /** * An 8.3 short name (AUTH~1.JSO, OPENC~1) cannot be mapped back to its long name by - * spelling; it is resolved by identity instead (see credentialWatchEventAction). + * spelling; it is resolved by identity instead (see shortNameAction). */ const SHORT_NAME_ALIAS = /~\d/; +/** ":", "absent", or "unreadable" for one entry of a watched parent. */ +export function watchEntryIdentity(parent: string, child: string): string { + try { + const stat = lstatSync(join(parent, child), { bigint: true }); + return `${stat.dev}:${stat.ino}`; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "ENOENT" ? "absent" : "unreadable"; + } +} +/** + * Windows may report either name of an entry that has both, and a removed entry's short + * name can no longer be looked up. Such an event is coverage-relevant when a target or + * ancestor in this parent no longer has its install identity, or when the alias resolves to + * a target; a change that resolves to a tracked ancestor is that ancestor's tick. Anything + * else -- this observer's own metadata save under a short spelling -- changed no target. + */ +function shortNameAction(parent: string, entry: WatchEntry, event: string, name: string): WatchEventAction { + const recorded = entry.targetIdentities; + if (!recorded) return "reconcile"; + const resolved = watchEntryIdentity(parent, name); + if (resolved === "unreadable") return "reconcile"; + for (const [target, identity] of recorded) { + const now = watchEntryIdentity(parent, target); + if (now === "unreadable" || now !== identity) return "reconcile"; + if (resolved !== "absent" && resolved === now) return "reconcile"; + } + let aliasOfAncestor = false; + for (const [ancestorName, identity] of entry.ancestors) { + const now = ancestorIdentity(parent, ancestorName); + if (identity === null || now === null || now.dev !== identity.dev || now.ino !== identity.ino) return "reconcile"; + if (resolved === `${identity.dev}:${identity.ino}`) aliasOfAncestor = true; + } + return aliasOfAncestor && event !== "change" ? "reconcile" : "ignore"; +} function foldedLookup(keys: Iterable, key: (item: T) => string, folded: string): T | undefined { for (const item of keys) if (foldWatchName(key(item)) === folded) return item; return undefined; @@ -98,20 +138,10 @@ export function credentialWatchEventAction( if (foldedLookup(entry.names, n => n, folded) !== undefined) return "reconcile"; const match = foldedLookup(entry.ancestors.entries(), ([n]) => n, folded); // A known ancestor whose real name merely looks like write residue or a short name - // (C:\config.json.ocx.home, C:\ocx~1) goes through the identity check below; only an - // unmatched name of either shape is coverage-relevant on its own. + // (C:\config.json.ocx.home, C:\ocx~1) goes through the identity check below. if (match === undefined) { if (CREDENTIAL_TMP_NAME.test(name)) return "reconcile"; - if (!SHORT_NAME_ALIAS.test(name)) return "ignore"; - // An unmatched short name may alias a target (reconcile) or a tracked ancestor - // (OPENC~1 for OpenCodex). Only a change that resolves to the very directory a tracked - // ancestor recorded at install is that ancestor's child-churn tick. - if (event !== "change") return "reconcile"; - const resolved = ancestorIdentity(parent, name); - for (const recorded of entry.ancestors.values()) { - if (resolved && recorded && recorded.dev === resolved.dev && recorded.ino === resolved.ino) return "ignore"; - } - return "reconcile"; + return SHORT_NAME_ALIAS.test(name) ? shortNameAction(parent, entry, event, name) : "ignore"; } const ancestor = match[1]; // Renames (create/delete/move, including a same-identity rename) are structural and @@ -319,9 +349,9 @@ export function startLocalCredentialObservation(): { stop(): void; reconcile(): const parent = dirname(path); if (!exists(parent)) continue; assertParents(path); - const entry = watched.get(parent) ?? { names: new Set(), ancestors: new Map() }; + const entry = watched.get(parent) ?? { names: new Set(), ancestors: new Map(), targetIdentities: new Map() }; const child = path.slice(parent.length + (parent.endsWith("/") || parent.endsWith("\\") ? 0 : 1)); - if (leaf) entry.names.add(child); + if (leaf) { entry.names.add(child); entry.targetIdentities?.set(child, watchEntryIdentity(parent, child)); } else if (!entry.ancestors.has(child)) entry.ancestors.set(child, ancestorIdentity(parent, child)); watched.set(parent, entry); leaf = false; diff --git a/structure/remote-link.md b/structure/remote-link.md index e131922e78e..c14a1b0cba3 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -119,7 +119,7 @@ The explicit `ocx link revoke --force --yes` branch in `src/cli/link.ts` sends o `ocx status --json` reports `localCredentials: {count, generation}` from `src/local-credentials/` on any runtime. `inventory.ts` counts this installation's stored provider credentials passively: OAuth accounts and the pre-multiauth backup, provider API-key slots including disabled entries and keychain, environment and GCP references, Codex pool records, the native Codex `auth.json` and inactive native profiles. It never counts link, management or incoming API keys, so a Child that holds only its link key reports zero, and accounts its Home serves are never local. A link-mode Child relays every model request to its Home and never dispatches to a provider itself, so a provider that needs no stored credential cannot serve a Child request and has nothing to count. An unreadable or unfamiliar source, a write residue, a pending native-profile transaction or a native Codex credential store other than `file` throws, and status omits the field instead of reporting zero. -`generation.ts` keeps `local-credentials.json` (epoch, sequence, pending tickets, touched keychain accounts and the observer's file-identity vector; never a credential value). Instrumented writers publish a ticket before credential bytes change and advance the sequence afterwards: `src/oauth/store.ts`, `src/codex/account-store.ts`, `src/config/persist-unlocked.ts`, `src/codex/main-account.ts`, the native-profile writers and the provider keychain factory in `src/providers/api-key-resolve.ts`. The token `lc1::` therefore changes on every add, replacement or removal, including one that ends at the same count. Every instrumented publication runs inside the credential-publication fence (`publication-fence.ts`), the OS lock of `local-credential-publication.sqlite`, separate from the config mutation lock: a writer holds it exclusively across its ticket, the write and the ticket's close, and fails if it cannot take it within ten seconds. Once coordination metadata exists, a publication whose ticket cannot be persisted (unreadable or unwritable metadata) aborts before it changes any credential bytes, so a completed write can never leave an earlier token attestable. Status holds it shared across both generation reads and the source scan, retries briefly while a publication holds it, and otherwise omits the field, so no snapshot can interleave with an instrumented write. Ticket and observer metadata are updated only under the fence, so contention on the config lock cannot drop a ticket. OS-store accounts are retained the same way: the observer seeds every `keychain:` and `gcp-sa:` account `config.json` references at start and on every reconcile, config persistence registers the references on both sides of a write before it lands (`references.ts`), and a keychain write or deletion registers its account before it is attempted. An account leaves the count only after a read proves it absent, so a secret whose config slot was removed while its deletion failed or never ran keeps counting. A proven absence is remembered in the metadata: a reference that still names that account is not registered again (it counts only as a configured slot while it stays in config), and only a new keychain write or deletion attempt for the account clears the mark. `restoreProviderKeyFromKeychain` in `src/providers/key-store.ts` saves the plaintext config before it deletes the OS entries; the other deletions remove secrets the config never referenced or run after the removal is committed. `runtime-observation.ts` gives `startServer` and `startClientRuntime` the same observer, which takes an OS lease (`observation-lease.ts`), watches the sources, rescans on every event and starts a new epoch at each runtime start; the client runtime releases it before a restart hands over to a replacement. Status attests only while that lease is held, no ticket is pending and the vector matches a fresh scan, so a stopped runtime, a lost watcher or an in-flight write omits the field. "Every event" above means every coverage-relevant event, classified per (watched parent, event, entry name) by `credentialWatchEventAction` in `inventory.ts`: an event on a scanned file, on a whole-listing-verified directory such as `.opencodex-native-main-profiles`, or on credential-write residue, and a `rename` on a tracked target or ancestor entry, always reconcile, while an ancestor directory's `change` is skipped only when the entry re-identifies as the same real directory recorded at install -- ordinary child churn, including this observer's own save, would otherwise re-arm the reconcile forever. Entry names are compared case-insensitively with trailing dots and spaces removed, as Windows and default macOS volumes resolve them, and a name that matches no target or ancestor but looks like an 8.3 short name (a tilde followed by a digit) reconciles, while a real ancestor whose name looks like a short name or like write residue keeps its identity check, as does an unmatched short name whose change resolves to a tracked ancestor recorded directory, so a differently spelled event on a credential path never falls through as unrelated. A missing, replaced, symlinked or unidentifiable ancestor still reconciles, and a watcher error or a null event name fails the observation closed. The vault directory's target status means a child event inside it reaches the observer wherever the platform delivers one -- Windows reports the parent's last-write tick for child churn, while other platforms may deliver no event for the pair -- but the fail-closed part is platform-common: a fresh scan still throws on an unknown entry and status omits the attestation while it remains. There is no debounce, coalescing or scan-vector shortcut: every coverage-relevant event advances the generation even when the rescan is unchanged, because the advance is what proves the observation ran. +`generation.ts` keeps `local-credentials.json` (epoch, sequence, pending tickets, touched keychain accounts and the observer's file-identity vector; never a credential value). Instrumented writers publish a ticket before credential bytes change and advance the sequence afterwards: `src/oauth/store.ts`, `src/codex/account-store.ts`, `src/config/persist-unlocked.ts`, `src/codex/main-account.ts`, the native-profile writers and the provider keychain factory in `src/providers/api-key-resolve.ts`. The token `lc1::` therefore changes on every add, replacement or removal, including one that ends at the same count. Every instrumented publication runs inside the credential-publication fence (`publication-fence.ts`), the OS lock of `local-credential-publication.sqlite`, separate from the config mutation lock: a writer holds it exclusively across its ticket, the write and the ticket's close, and fails if it cannot take it within ten seconds. Once coordination metadata exists, a publication whose ticket cannot be persisted (unreadable or unwritable metadata) aborts before it changes any credential bytes, so a completed write can never leave an earlier token attestable. Status holds it shared across both generation reads and the source scan, retries briefly while a publication holds it, and otherwise omits the field, so no snapshot can interleave with an instrumented write. Ticket and observer metadata are updated only under the fence, so contention on the config lock cannot drop a ticket. OS-store accounts are retained the same way: the observer seeds every `keychain:` and `gcp-sa:` account `config.json` references at start and on every reconcile, config persistence registers the references on both sides of a write before it lands (`references.ts`), and a keychain write or deletion registers its account before it is attempted. An account leaves the count only after a read proves it absent, so a secret whose config slot was removed while its deletion failed or never ran keeps counting. A proven absence is remembered in the metadata: a reference that still names that account is not registered again (it counts only as a configured slot while it stays in config), and only a new keychain write or deletion attempt for the account clears the mark. `restoreProviderKeyFromKeychain` in `src/providers/key-store.ts` saves the plaintext config before it deletes the OS entries; the other deletions remove secrets the config never referenced or run after the removal is committed. `runtime-observation.ts` gives `startServer` and `startClientRuntime` the same observer, which takes an OS lease (`observation-lease.ts`), watches the sources, rescans on every event and starts a new epoch at each runtime start; the client runtime releases it before a restart hands over to a replacement. Status attests only while that lease is held, no ticket is pending and the vector matches a fresh scan, so a stopped runtime, a lost watcher or an in-flight write omits the field. "Every event" above means every coverage-relevant event, classified per (watched parent, event, entry name) by `credentialWatchEventAction` in `inventory.ts`: an event on a scanned file, on a whole-listing-verified directory such as `.opencodex-native-main-profiles`, or on credential-write residue, and a `rename` on a tracked target or ancestor entry, always reconcile, while an ancestor directory's `change` is skipped only when the entry re-identifies as the same real directory recorded at install -- ordinary child churn, including this observer's own save, would otherwise re-arm the reconcile forever. Entry names are compared case-insensitively with trailing dots and spaces removed, as Windows and default macOS volumes resolve them, and a real ancestor whose name looks like a short name or like write residue keeps its identity check. A name that matches no target or ancestor but looks like an 8.3 short name (a tilde followed by a digit; Windows may report either name of an entry, and a removed entry's short name cannot be looked up) is resolved by identity against the install snapshot: it reconciles when the alias is a target, when any target or ancestor of that parent no longer has its install identity, or when it renames a tracked ancestor, and is otherwise ignored, so the observer's own save under a short spelling cannot re-arm the loop. A differently spelled event on a credential path therefore never falls through as unrelated. A missing, replaced, symlinked or unidentifiable ancestor still reconciles, and a watcher error or a null event name fails the observation closed. The vault directory's target status means a child event inside it reaches the observer wherever the platform delivers one -- Windows reports the parent's last-write tick for child churn, while other platforms may deliver no event for the pair -- but the fail-closed part is platform-common: a fresh scan still throws on an unknown entry and status omits the attestation while it remains. There is no debounce, coalescing or scan-vector shortcut: every coverage-relevant event advances the generation even when the rescan is unchanged, because the advance is what proves the observation ran. A link-mode Child reports its current tunnel connection as `link.connectionGeneration` in a ready `/readyz`; `src/cli/status-link.ts` reads it from the live client runtime and omits it whenever that runtime does not answer ready with a valid value. Names have the form `:` and come from one process-wide sequence (`nextLinkConnectionGeneration` in `src/link/relay-auth.ts`), so a supervisor rebuilt in the same process, such as after a restart rollback, never repeats one. A Child-initiated link takes a new name from its supervisor on every transition into connected. For a Home-initiated link the Home supervisor names each ssh child it spawns, and its relay-auth answer carries that name with its own proof over the nonce, key and link ids (`linkConnectionProof` in `src/link/relay-auth.ts`), and the Child keeps the value from the latest authenticated answer (`src/client/link-connection.ts`). Every connect and every runtime restart changes it. An authenticated answer without a generation, or with one whose proof does not match, makes it unknown, so a Child that reconnects to an older Home never keeps reporting the previous connection. GOAT Ready requires client role, zero local credentials and equal generations before and after a completed Home inference; linked and healthy alone do not prove attribution. Coverage: `tests/cli/local-credentials.test.ts`, `tests/config/local-credential-generation.test.ts`, `tests/clients/link-connection-generation.test.ts`, `tests/clients/client-link-tunnel.test.ts`, `tests/clients/link-readiness.test.ts`. diff --git a/tests/config/local-credential-generation.test.ts b/tests/config/local-credential-generation.test.ts index 8e8ef680371..135f669a358 100644 --- a/tests/config/local-credential-generation.test.ts +++ b/tests/config/local-credential-generation.test.ts @@ -358,22 +358,16 @@ test("the watch seam classifies direct, ancestor, identity, and failure cases de mkdirSync(shortLooking); const residueLooking = join(dir, "config.json.ocx.home"); mkdirSync(residueLooking); - // Stands in for the 8.3 alias of a tracked ancestor: the event spelling matches no - // tracked name, but resolves to the directory the ancestor recorded at install. - const aliased = join(dir, "longan~1"); - mkdirSync(aliased); writeFileSync(join(dir, "auth.json"), "{}"); const same = lstatSync(ancestor, { bigint: true }); const shortSame = lstatSync(shortLooking, { bigint: true }); const residueSame = lstatSync(residueLooking, { bigint: true }); - const aliasedSame = lstatSync(aliased, { bigint: true }); const entry = { names: new Set(["auth.json", "direct"]), ancestors: new Map([ ["ancestor", { dev: same.dev, ino: same.ino }], ["ocx~1", { dev: shortSame.dev, ino: shortSame.ino }], ["config.json.ocx.home", { dev: residueSame.dev, ino: residueSame.ino }], - ["longancestor", { dev: aliasedSame.dev, ino: aliasedSame.ino }], ["unverifiable", null], ]), }; @@ -402,13 +396,14 @@ test("the watch seam classifies direct, ancestor, identity, and failure cases de expect(action("change", "foreign")).toBe("ignore"); expect(action("rename", "unknown.tmp")).toBe("ignore"); // A case-insensitive volume reports the writer's spelling: a differently cased or - // trailing-dot spelling of a target, its write residue, or an unresolvable 8.3 short - // name must still reconcile rather than fall through as an unrelated name. + // trailing-dot spelling of a target or its write residue must still reconcile rather + // than fall through as an unrelated name. expect(action("rename", "AUTH.JSON")).toBe("reconcile"); expect(action("change", "Auth.Json")).toBe("reconcile"); expect(action("change", "auth.json.")).toBe("reconcile"); expect(action("rename", "Direct")).toBe("reconcile"); expect(action("rename", "CONFIG.JSON.OCX.7.tmp")).toBe("reconcile"); + // An entry without recorded target identities cannot resolve a short name: reconcile. expect(action("rename", "AUTH~1.JSO")).toBe("reconcile"); // A real ancestor whose name only looks like a short name (OPENCODEX_HOME=C:\ocx~1) is // identity-checked like any other ancestor, so its own-save tick cannot re-arm the loop. @@ -416,11 +411,6 @@ test("the watch seam classifies direct, ancestor, identity, and failure cases de expect(action("rename", "ocx~1")).toBe("reconcile"); expect(action("change", "config.json.ocx.home")).toBe("ignore"); expect(action("rename", "config.json.ocx.home")).toBe("reconcile"); - // A short alias of a tracked ancestor is identity-checked; one that resolves to anything - // else (a target file, an unknown or missing entry) still reconciles. - expect(action("change", "longan~1")).toBe("ignore"); - expect(action("rename", "longan~1")).toBe("reconcile"); - expect(action("change", "missin~1")).toBe("reconcile"); // A differently cased ancestor is still an ancestor: renames reconcile, and a change is // ignored only when that spelling resolves to the recorded directory itself. expect(action("rename", "ANCESTOR")).toBe("reconcile"); @@ -447,6 +437,60 @@ test("the watch seam classifies direct, ancestor, identity, and failure cases de } }); +test("unmatched 8.3 short names resolve by identity against the install snapshot", async () => { + const inventory = await import("../../src/local-credentials/inventory"); + const dir = mkdtempSync(join(tmpdir(), "ocx-watch-short-")); + try { + // Literal tilde names stand in for the short spellings Windows may report for files. + mkdirSync(join(dir, "longancestor")); + writeFileSync(join(dir, "auth.json"), "{}"); + writeFileSync(join(dir, "localc~1.jso"), "{}"); + const ancestorStat = lstatSync(join(dir, "longancestor"), { bigint: true }); + const entry = { + names: new Set(["auth.json", "config.json"]), + ancestors: new Map([ + ["longancestor", { dev: ancestorStat.dev, ino: ancestorStat.ino }], + ]), + targetIdentities: new Map([ + ["auth.json", inventory.watchEntryIdentity(dir, "auth.json")], + ["config.json", inventory.watchEntryIdentity(dir, "config.json")], + ]), + }; + expect(entry.targetIdentities.get("config.json")).toBe("absent"); + const action = (event: string, name: string) => inventory.credentialWatchEventAction(dir, entry, event, name); + // The observer's own metadata save under a short spelling, live or already renamed + // away, changed no target or ancestor, so it must not re-arm the reconcile. + expect(action("change", "localc~1.jso")).toBe("ignore"); + expect(action("rename", "localc~2.tmp")).toBe("ignore"); + // A short alias of a tracked ancestor: its change is that ancestor's tick, a rename is + // structural. Whether LONGAN~1 resolves depends on the volume's 8.3 setting; either way + // the change cannot re-arm the loop. + const alias = (() => { try { return lstatSync(join(dir, "LONGAN~1"), { bigint: true }); } catch { return null; } })(); + const resolvesToAncestor = alias !== null && alias.dev === ancestorStat.dev && alias.ino === ancestorStat.ino; + expect(action("change", "LONGAN~1")).toBe("ignore"); + expect(action("rename", "LONGAN~1")).toBe(resolvesToAncestor ? "reconcile" : "ignore"); + // A short alias that resolves to a target is that target. + linkSync(join(dir, "auth.json"), join(dir, "authjs~1.jso")); + expect(action("change", "authjs~1.jso")).toBe("reconcile"); + unlinkSync(join(dir, "authjs~1.jso")); + // A removed or newly created target makes any unresolvable short name relevant. + writeFileSync(join(dir, "config.json"), "{}"); + expect(action("rename", "config~1.jso")).toBe("reconcile"); + unlinkSync(join(dir, "config.json")); + expect(action("rename", "config~1.jso")).toBe("ignore"); + unlinkSync(join(dir, "auth.json")); + expect(action("rename", "auth~1.jso")).toBe("reconcile"); + // A replaced ancestor does too. + writeFileSync(join(dir, "auth.json"), "{}"); + entry.targetIdentities.set("auth.json", inventory.watchEntryIdentity(dir, "auth.json")); + expect(action("rename", "other~1")).toBe("ignore"); + renameSync(join(dir, "longancestor"), join(dir, "aside")); + expect(action("change", "LONGAN~1")).toBe("reconcile"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("ordinary churn inside a watched ancestor does not re-arm the observer or advance the generation", async () => { await withLocalCredentialFixture(async f => { f.start(); From 81ffded37367045e431d44b1c4ab0222ed910e80 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:41:51 +0900 Subject: [PATCH 8/8] docs(structure): state the transient short-name residual of the credential observer --- structure/remote-link.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/structure/remote-link.md b/structure/remote-link.md index c14a1b0cba3..44f0dcb2004 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -119,7 +119,7 @@ The explicit `ocx link revoke --force --yes` branch in `src/cli/link.ts` sends o `ocx status --json` reports `localCredentials: {count, generation}` from `src/local-credentials/` on any runtime. `inventory.ts` counts this installation's stored provider credentials passively: OAuth accounts and the pre-multiauth backup, provider API-key slots including disabled entries and keychain, environment and GCP references, Codex pool records, the native Codex `auth.json` and inactive native profiles. It never counts link, management or incoming API keys, so a Child that holds only its link key reports zero, and accounts its Home serves are never local. A link-mode Child relays every model request to its Home and never dispatches to a provider itself, so a provider that needs no stored credential cannot serve a Child request and has nothing to count. An unreadable or unfamiliar source, a write residue, a pending native-profile transaction or a native Codex credential store other than `file` throws, and status omits the field instead of reporting zero. -`generation.ts` keeps `local-credentials.json` (epoch, sequence, pending tickets, touched keychain accounts and the observer's file-identity vector; never a credential value). Instrumented writers publish a ticket before credential bytes change and advance the sequence afterwards: `src/oauth/store.ts`, `src/codex/account-store.ts`, `src/config/persist-unlocked.ts`, `src/codex/main-account.ts`, the native-profile writers and the provider keychain factory in `src/providers/api-key-resolve.ts`. The token `lc1::` therefore changes on every add, replacement or removal, including one that ends at the same count. Every instrumented publication runs inside the credential-publication fence (`publication-fence.ts`), the OS lock of `local-credential-publication.sqlite`, separate from the config mutation lock: a writer holds it exclusively across its ticket, the write and the ticket's close, and fails if it cannot take it within ten seconds. Once coordination metadata exists, a publication whose ticket cannot be persisted (unreadable or unwritable metadata) aborts before it changes any credential bytes, so a completed write can never leave an earlier token attestable. Status holds it shared across both generation reads and the source scan, retries briefly while a publication holds it, and otherwise omits the field, so no snapshot can interleave with an instrumented write. Ticket and observer metadata are updated only under the fence, so contention on the config lock cannot drop a ticket. OS-store accounts are retained the same way: the observer seeds every `keychain:` and `gcp-sa:` account `config.json` references at start and on every reconcile, config persistence registers the references on both sides of a write before it lands (`references.ts`), and a keychain write or deletion registers its account before it is attempted. An account leaves the count only after a read proves it absent, so a secret whose config slot was removed while its deletion failed or never ran keeps counting. A proven absence is remembered in the metadata: a reference that still names that account is not registered again (it counts only as a configured slot while it stays in config), and only a new keychain write or deletion attempt for the account clears the mark. `restoreProviderKeyFromKeychain` in `src/providers/key-store.ts` saves the plaintext config before it deletes the OS entries; the other deletions remove secrets the config never referenced or run after the removal is committed. `runtime-observation.ts` gives `startServer` and `startClientRuntime` the same observer, which takes an OS lease (`observation-lease.ts`), watches the sources, rescans on every event and starts a new epoch at each runtime start; the client runtime releases it before a restart hands over to a replacement. Status attests only while that lease is held, no ticket is pending and the vector matches a fresh scan, so a stopped runtime, a lost watcher or an in-flight write omits the field. "Every event" above means every coverage-relevant event, classified per (watched parent, event, entry name) by `credentialWatchEventAction` in `inventory.ts`: an event on a scanned file, on a whole-listing-verified directory such as `.opencodex-native-main-profiles`, or on credential-write residue, and a `rename` on a tracked target or ancestor entry, always reconcile, while an ancestor directory's `change` is skipped only when the entry re-identifies as the same real directory recorded at install -- ordinary child churn, including this observer's own save, would otherwise re-arm the reconcile forever. Entry names are compared case-insensitively with trailing dots and spaces removed, as Windows and default macOS volumes resolve them, and a real ancestor whose name looks like a short name or like write residue keeps its identity check. A name that matches no target or ancestor but looks like an 8.3 short name (a tilde followed by a digit; Windows may report either name of an entry, and a removed entry's short name cannot be looked up) is resolved by identity against the install snapshot: it reconciles when the alias is a target, when any target or ancestor of that parent no longer has its install identity, or when it renames a tracked ancestor, and is otherwise ignored, so the observer's own save under a short spelling cannot re-arm the loop. A differently spelled event on a credential path therefore never falls through as unrelated. A missing, replaced, symlinked or unidentifiable ancestor still reconciles, and a watcher error or a null event name fails the observation closed. The vault directory's target status means a child event inside it reaches the observer wherever the platform delivers one -- Windows reports the parent's last-write tick for child churn, while other platforms may deliver no event for the pair -- but the fail-closed part is platform-common: a fresh scan still throws on an unknown entry and status omits the attestation while it remains. There is no debounce, coalescing or scan-vector shortcut: every coverage-relevant event advances the generation even when the rescan is unchanged, because the advance is what proves the observation ran. +`generation.ts` keeps `local-credentials.json` (epoch, sequence, pending tickets, touched keychain accounts and the observer's file-identity vector; never a credential value). Instrumented writers publish a ticket before credential bytes change and advance the sequence afterwards: `src/oauth/store.ts`, `src/codex/account-store.ts`, `src/config/persist-unlocked.ts`, `src/codex/main-account.ts`, the native-profile writers and the provider keychain factory in `src/providers/api-key-resolve.ts`. The token `lc1::` therefore changes on every add, replacement or removal, including one that ends at the same count. Every instrumented publication runs inside the credential-publication fence (`publication-fence.ts`), the OS lock of `local-credential-publication.sqlite`, separate from the config mutation lock: a writer holds it exclusively across its ticket, the write and the ticket's close, and fails if it cannot take it within ten seconds. Once coordination metadata exists, a publication whose ticket cannot be persisted (unreadable or unwritable metadata) aborts before it changes any credential bytes, so a completed write can never leave an earlier token attestable. Status holds it shared across both generation reads and the source scan, retries briefly while a publication holds it, and otherwise omits the field, so no snapshot can interleave with an instrumented write. Ticket and observer metadata are updated only under the fence, so contention on the config lock cannot drop a ticket. OS-store accounts are retained the same way: the observer seeds every `keychain:` and `gcp-sa:` account `config.json` references at start and on every reconcile, config persistence registers the references on both sides of a write before it lands (`references.ts`), and a keychain write or deletion registers its account before it is attempted. An account leaves the count only after a read proves it absent, so a secret whose config slot was removed while its deletion failed or never ran keeps counting. A proven absence is remembered in the metadata: a reference that still names that account is not registered again (it counts only as a configured slot while it stays in config), and only a new keychain write or deletion attempt for the account clears the mark. `restoreProviderKeyFromKeychain` in `src/providers/key-store.ts` saves the plaintext config before it deletes the OS entries; the other deletions remove secrets the config never referenced or run after the removal is committed. `runtime-observation.ts` gives `startServer` and `startClientRuntime` the same observer, which takes an OS lease (`observation-lease.ts`), watches the sources, rescans on every event and starts a new epoch at each runtime start; the client runtime releases it before a restart hands over to a replacement. Status attests only while that lease is held, no ticket is pending and the vector matches a fresh scan, so a stopped runtime, a lost watcher or an in-flight write omits the field. "Every event" above means every coverage-relevant event, classified per (watched parent, event, entry name) by `credentialWatchEventAction` in `inventory.ts`: an event on a scanned file, on a whole-listing-verified directory such as `.opencodex-native-main-profiles`, or on credential-write residue, and a `rename` on a tracked target or ancestor entry, always reconcile, while an ancestor directory's `change` is skipped only when the entry re-identifies as the same real directory recorded at install -- ordinary child churn, including this observer's own save, would otherwise re-arm the reconcile forever. Entry names are compared case-insensitively with trailing dots and spaces removed, as Windows and default macOS volumes resolve them, and a real ancestor whose name looks like a short name or like write residue keeps its identity check. A name that matches no target or ancestor but looks like an 8.3 short name (a tilde followed by a digit; Windows may report either name of an entry, and a removed entry's short name cannot be looked up) is resolved by identity against the install snapshot: it reconciles when the alias is a target, when any target or ancestor of that parent no longer has its install identity, or when it renames a tracked ancestor, and is otherwise ignored, so the observer's own save under a short spelling cannot re-arm the loop. The residual is transient: an add-then-remove of a target, or a rename-and-restore of an ancestor, that completes before its notifications are handled and whose notifications carry only short names that no longer resolve leaves the generation where it was. A differently spelled event on a credential path therefore never falls through as unrelated. A missing, replaced, symlinked or unidentifiable ancestor still reconciles, and a watcher error or a null event name fails the observation closed. The vault directory's target status means a child event inside it reaches the observer wherever the platform delivers one -- Windows reports the parent's last-write tick for child churn, while other platforms may deliver no event for the pair -- but the fail-closed part is platform-common: a fresh scan still throws on an unknown entry and status omits the attestation while it remains. There is no debounce, coalescing or scan-vector shortcut: every coverage-relevant event advances the generation even when the rescan is unchanged, because the advance is what proves the observation ran. A link-mode Child reports its current tunnel connection as `link.connectionGeneration` in a ready `/readyz`; `src/cli/status-link.ts` reads it from the live client runtime and omits it whenever that runtime does not answer ready with a valid value. Names have the form `:` and come from one process-wide sequence (`nextLinkConnectionGeneration` in `src/link/relay-auth.ts`), so a supervisor rebuilt in the same process, such as after a restart rollback, never repeats one. A Child-initiated link takes a new name from its supervisor on every transition into connected. For a Home-initiated link the Home supervisor names each ssh child it spawns, and its relay-auth answer carries that name with its own proof over the nonce, key and link ids (`linkConnectionProof` in `src/link/relay-auth.ts`), and the Child keeps the value from the latest authenticated answer (`src/client/link-connection.ts`). Every connect and every runtime restart changes it. An authenticated answer without a generation, or with one whose proof does not match, makes it unknown, so a Child that reconnects to an older Home never keeps reporting the previous connection. GOAT Ready requires client role, zero local credentials and equal generations before and after a completed Home inference; linked and healthy alone do not prove attribution. Coverage: `tests/cli/local-credentials.test.ts`, `tests/config/local-credential-generation.test.ts`, `tests/clients/link-connection-generation.test.ts`, `tests/clients/client-link-tunnel.test.ts`, `tests/clients/link-readiness.test.ts`.