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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/oauth/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,14 @@ function backupLegacyOnce(): void {
try {
copyFileSync(path, backup);
try { chmodSync(backup, 0o600); } catch { /* best-effort */ }
try {
// Register only the copy we just created. An unowned home still needs downgrade recovery.
if (!recordOwnedConfigPath(getConfigDir(), backup)) {
console.warn("[oauth] Recovery backup created, but uninstall ownership registration failed.");
}
} catch {
console.warn("[oauth] Recovery backup created, but uninstall ownership registration failed.");
}
} catch { /* best-effort */ }
}

Expand Down
11 changes: 11 additions & 0 deletions structure/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,17 @@ and removes only normalized manifest entries. Manifest-owned directory links are
traversing their targets. Unknown files remain in place and make the command report a partial
uninstall with their exact paths.

The newly created OAuth downgrade copy is registered after copying, so owned uninstall
includes it. Invalid-config recovery copies are deliberately NOT registered: their names carry
a timestamp, so one entry per invalid load would grow the uninstall manifest without bound, and
the manifest stops validating past its path ceiling. A manifest that stops validating makes
uninstall refuse outright, which would leave credentials on disk. Sweeping those copies by name
pattern at removal time is the shape that fits; it is not in this change. Registration is best-effort: an intentionally
unowned legacy home or a metadata-write failure must not suppress the recovery copy. Existing
OAuth downgrade copies are neither rewritten nor retroactively claimed. Both a `false` registration
result and a thrown registration error emit the same fixed warning without error details. Unregistered copies
remain subject to the existing partial/refused uninstall result.

Legacy nonempty config directories are deliberately not retroactively claimed. If either ownership
file is missing, malformed, or bound to another root, uninstall refuses config deletion and reports
the residual directory for manual review; there is no recursive-delete fallback.
Expand Down
3 changes: 2 additions & 1 deletion structure/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ preserves saved user model selections and historical usage. See the bounded
installed service resolve it the same way (`src/config.ts`). Ownership inside that root is tracked
by the uninstall manifest in `src/lib/config-ownership.ts`, which starts from a declared path list
and grows as opencodex claims further paths at runtime — so the manifest, not this table, is what
bounds uninstall. This table groups the state by purpose; it is not an exhaustive file list, and
bounds uninstall. Newly generated recovery backups follow the [backup ownership contract](config.md#restore)
without suppressing recovery when registration is unavailable. This table groups state by purpose; it is not an exhaustive file list, and
derived files such as `auth.json.pre-multiauth` are covered by the group they belong to.

`$CODEX_HOME` is a separate root with a separate owner, and opencodex writes there too: removing the
Expand Down
2 changes: 2 additions & 0 deletions structure/providers/xai-grok.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ The shared Responses path follows the [bounded multipart recovery contract](../s
`auth.json` load-merge-persist (`src/oauth/store.ts`); generation-guarded persist
(`expectedGeneration` → superseded adoption), conditional `needsReauth`, bounded jittered
retry for transient token-endpoint failures.
Newly created legacy-store recovery copies follow the [backup ownership contract](../config.md#restore);
an ownership-registration failure (a `false` return or thrown error) warns without discarding downgrade recovery.
- **Reactive 401 replay:** both the adapter recovery loop and native Responses passthrough branch
force-refresh once (singleflight, generation-checked) and replay OAuth-backed xAI requests
exactly once with a re-resolved transport; API-key/BYOK paths are excluded
Expand Down
3 changes: 3 additions & 0 deletions structure/runtime.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Runtime

OAuth recovery-copy registration warns on both refusal and exceptions while preserving the copy;
see the [backup ownership contract](config.md#restore).

Responses admission and finalization are composed through the
[core module ownership](transports/responses.md#core-module-ownership). This surface retains its existing behavior.

Expand Down
3 changes: 3 additions & 0 deletions structure/transports/inventory.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Transport Inventory

The shared OAuth store warns on recovery-copy ownership refusal or exceptions without losing
the copy; see [backup ownership](../config.md#restore).

The existing Responses transport is divided by responsibility in the
[core module ownership](responses.md#core-module-ownership). This surface retains its existing behavior.

Expand Down
61 changes: 61 additions & 0 deletions tests/oauth/oauth-store-multi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import * as atomicWrite from "../../src/config/atomic-write";
import * as oauthStore from "../../src/oauth/store";
import * as configOwnership from "../../src/lib/config-ownership";
import { flushConfigDirHardeningForTests } from "../../src/config/paths";
import {
resetHardenedStateForTests,
Expand Down Expand Up @@ -36,6 +37,7 @@ import {
} from "../../src/oauth/store";
import type { OAuthCredentials } from "../../src/oauth/types";
import { removeTreeWithRetry } from "../helpers/remove-tree";
import { recordOwnedConfigPath, removeOwnedConfigState } from "../../src/lib/config-ownership";

const TEST_DIR = join(import.meta.dir, ".tmp-oauth-store-multi-test");
let previousOpencodexHome: string | undefined;
Expand Down Expand Up @@ -152,6 +154,65 @@ describe("multi-account auth store", () => {
expect(existsSync(`${authPath}.pre-multiauth`)).toBe(true);
});

test("uninstall removes a legacy recovery backup from an owned home", async () => {
const dir = join(TEST_DIR, "owned");
const path = join(dir, "auth.json");
process.env.OPENCODEX_HOME = dir;
try {
expect(recordOwnedConfigPath(dir, path)).toBe(true);
const original = JSON.stringify({ xai: cred({ email: "old@example.test" }) });
writeFileSync(path, original);
await saveCredential("xai", cred({ email: "old@example.test", access: "new-access" }));
expect(readFileSync(`${path}.pre-multiauth`, "utf8")).toBe(original);
await flushConfigDirHardeningForTests();
expect(removeOwnedConfigState(dir).status).toBe("removed");
expect(existsSync(`${path}.pre-multiauth`)).toBe(false);
} finally {
process.env.OPENCODEX_HOME = TEST_DIR;
}
});

test.each(["false", "throw"] as const)("recovery survives registration %s and warns without exposing credentials", async (failure) => {
const path = join(TEST_DIR, "auth.json");
const backup = `${path}.pre-multiauth`;
const original = JSON.stringify({ xai: cred({ email: "old@example.test" }) });
writeFileSync(path, original);
const register = configOwnership.recordOwnedConfigPath;
const registration = spyOn(configOwnership, "recordOwnedConfigPath").mockImplementation((dir, candidate) => {
if (candidate !== backup) return register(dir, candidate);
if (failure === "throw") throw new Error("private ownership failure fixture");
return false;
});
const warning = spyOn(console, "warn").mockImplementation(() => {});
try {
await saveCredential("xai", cred({ email: "old@example.test", access: "new-access" }));
expect(registration).toHaveBeenCalledWith(TEST_DIR, backup);
expect(readFileSync(backup, "utf8")).toBe(original);
expect(getCredential("xai")?.access).toBe("new-access");
expect(warning.mock.calls).toEqual([["[oauth] Recovery backup created, but uninstall ownership registration failed."]]);
} finally {
warning.mockRestore();
registration.mockRestore();
}
});

test("migration leaves a pre-existing unregistered backup unchanged and unclaimed", async () => {
const dir = join(TEST_DIR, "existing-backup");
const path = join(dir, "auth.json");
process.env.OPENCODEX_HOME = dir;
try {
expect(recordOwnedConfigPath(dir, path)).toBe(true);
writeFileSync(path, JSON.stringify({ xai: cred({ email: "old@example.test" }) }));
writeFileSync(`${path}.pre-multiauth`, "prior-recovery-fixture");
await saveCredential("xai", cred({ email: "old@example.test" }));
await flushConfigDirHardeningForTests();
expect(removeOwnedConfigState(dir).status).toBe("partial");
expect(readFileSync(`${path}.pre-multiauth`, "utf8")).toBe("prior-recovery-fixture");
} finally {
process.env.OPENCODEX_HOME = TEST_DIR;
}
});

test("legacy credential WITHOUT identity gets a deterministic account id across loads", async () => {
// Legacy stores are re-normalized on EVERY load without being persisted, so the
// derived id must be stable: a time-salted id would make getAccountSet and
Expand Down
Loading