From 3fd3b10f01c619acd15e72eb1b176ffee11d7139 Mon Sep 17 00:00:00 2001 From: chefgroep Date: Sun, 23 Aug 2026 13:30:33 +0200 Subject: [PATCH 1/5] feat(state): add dry-run reconcile pipeline and production forensics Unversioned JSON on the Azure host has no scheduled backup and no host-to-host record proof. The pipeline refuses to touch the live store without a verified snapshot. Co-authored-by: Cursor --- docs/convergence/STATE_FORENSICS.md | 336 ++++++++++ scripts/state-reconcile.ts | 949 ++++++++++++++++++++++++++++ tests/state-reconcile.test.ts | 302 +++++++++ 3 files changed, 1587 insertions(+) create mode 100644 docs/convergence/STATE_FORENSICS.md create mode 100644 scripts/state-reconcile.ts create mode 100644 tests/state-reconcile.test.ts diff --git a/docs/convergence/STATE_FORENSICS.md b/docs/convergence/STATE_FORENSICS.md new file mode 100644 index 00000000..7d7267db --- /dev/null +++ b/docs/convergence/STATE_FORENSICS.md @@ -0,0 +1,336 @@ +# OCX production state forensics + +Lane C (state authority). Captured 2026-08-23 by read-only observation of +`chef-control-az-01` plus local git history on `origin/main` @ `4a589932`. +No files were written, moved, deleted, chmod'd, restarted, or restored on the +host. Secret values (API keys, OAuth tokens, cookies, admin/service tokens) +were never printed; this report uses names, ids, lengths, env-ref flags, and +content hashes only. + +The question is not "where is the old data". It is: which persistent states +have ever held production authority for OCX, and can we prove nothing was +lost? + +Verdict: **we cannot prove completeness**. The live Azure store is internally +consistent against every *reachable* legacy copy of the same files. The prior +host is offline, and the live store has no `auth.json`. Those two facts leave +an unclosed hole. + +## 1. Authority map (what actually holds state) + +OCX has no database. Authority is a directory of JSON/JSONL plus a systemd +`EnvironmentFile` of provider key *names*. The unit +`opencodex-proxy.service` (`Active: running`, bun pid 413295, advertised +`1.2.1`) binds `100.109.39.86:10100` and has +`ReadWritePaths=/home/chef/.opencodex /var/lib/chef /opt/chef/services/opencodex`. +`OPENCODEX_HOME` is unset, so `src/config.ts` `resolveConfigDir()` uses +`~/.opencodex`. + +| Role | Path | What it is | Held authority? | +| --- | --- | --- | --- | +| **CURRENT store** | `/home/chef/.opencodex/` | Live JSON/JSONL. `config.json` 89213 B, mtime 2026-08-22 10:10Z. | **Yes — sole live authority today.** | +| **CURRENT Codex injection** | `/home/chef/.codex/` | Regenerated on every proxy start (mtime 2026-08-23 02:55Z, same second as pid). No `auth.json`. Journal `originalConfig` is empty. | Derived, not a user store. | +| **Staged snapshot** | `/etc/chef/opencodex/` | root:chef. `config.json` 70641 B, mtime 2026-08-22 06:34Z, plus `service.env` (15 names), `admin-api-token`, `service-api-token`. | Frozen copy of the live store taken ~4 h earlier the same day. Not read by the running unit except `EnvironmentFile` + `LoadCredential`. | +| **Secrets file** | `/etc/chef/opencodex/service.env` | 15 key *names* only (see below). | Credential *source* for env-ref providers. | +| Code backup 2026-08-22 | `/opt/chef/services/.opencodex-backup-20260822T095647Z` | Full git checkout, `@groeponline/opencodex@1.1.1`. Zero state files. | Never. | +| Pre-git install 2026-08-22 | `/opt/chef/services/opencodex.pre-git-20260822T133750Z` | npm layout, `@groeponline/opencodex@1.2.1`. Zero state files. | Never. | +| Live runtime checkout | `/opt/chef/services/opencodex` | SHA `71c3cad1`, branch `live-v1.2.1`, version 1.2.1. Zero state files. | Code only. | +| Deploy checkout | `/home/chef/opencodex-psp` | SHA `4a589932`, version 1.2.2. No `.opencodex/`. Zero state files. | Code only (and not the running path — V1). | +| Binary seal | `/home/chef/.opencodex/backups/native-binary-seal-20260731T000839Z` | `ocx.npm-bin` + `opencodex.target`. 20 K, no config. | Never. | +| `/var/lib/chef/backups` | vault + authentik tarballs only | Enumerated. No `opencodex` / `.opencodex` / `config.json`. | Never for OCX. | +| `/var/lib/chef/rollback` | systemd-unit snapshots for vault/authentik | Enumerated. No OCX unit. | Never for OCX. | +| **PRIOR host** | `chef-control-01` (`100.115.43.1`) | Tailscale **offline**, last seen `2026-08-22T01:31:44Z`. SSH/ping timeout. | **Unknown — unreachable.** Previously the named production host (`deploy.yml` still says so). | + +`service.env` key names (values not read into this document): +`OPENCODEX_API_AUTH_TOKEN`, `CURSOR_USER_API_KEY`, `CLINE_API_KEY`, +`GROQ_API_KEY`, `NVIDIA_API_KEY`, `OPENCODE_GO_API_KEY`, `ZAI_API_KEY`, +`ZAI_PLATFORM_API_KEY`, `OPENROUTER_API_KEY`, `DEEPSEEK_API_KEY`, +`OPENCODE_API_KEY`, `ZAI_CODING_PLAN_API_KEY`, `CF_ACCESS_TEAM_DOMAIN`, +`CF_ACCESS_AUD`, `CF_ACCESS_ALLOWED_HOSTS`. + +There is no OCX timer. Host timers backup Authentik and Vault, not +`~/.opencodex`. Cron has no OCX job. `config.json` has no `schemaVersion` / +`schema_version`. `storageCleanupPolicy` is unset. + +## 2. Lineage + +``` + chef-control-01 (PRIOR authority) + ~/.opencodex + ?auth.json + ?codex-accounts.json + last seen 2026-08-22T01:31:44Z OFFLINE + | + | 2026-08-03/04 host move + | evidence: kimi-device-id 2026-08-03, + | admin-api-token.bak-pre-control-20260804 + | (sha != live token — token was rotated), + | usage.jsonl starts 2026-08-03 (32 rows) + | SETUP.md still describes joep/sofie/control-01 + v + chef-control-az-01 (CURRENT authority) + /home/chef/.opencodex/ + config.json openaiProviderTierVersion=2 + NO auth.json NO codex-accounts.json + NO config.json.pre-openai-tiers-v2.bak + | + +---------------------+----------------------+ + | | | + v v v + /etc/chef/opencodex/ live checkout deploy checkout + config.json 70KB /opt/chef/services/ /home/chef/opencodex-psp + 2026-08-22 06:34Z opencodex @ 71c3cad1 @ 4a589932 (1.2.2) + same 18 provider ids 1.2.1 live-v1.2.1 NOT the running path + same 876 disabled no state files no state files + same apiKey ids + MISSING desktopProfile + | + +-- 2026-08-22 09:56 code-only trees (NOT datastores) + .opencodex-backup-20260822T095647Z (repo 1.1.1) + opencodex.pre-git-20260822T133750Z (npm 1.2.1) + +Code-level format migrations that DID exist (git), vs what the live disk shows: + + auth.json legacy → multiauth (src/oauth/store.ts, one-time + auth.json.pre-multiauth) ABSENT on disk + openai-multi/chatgpt → openai v2 (openaiProviderTierVersion, + config.json.pre-openai-tiers-v2.bak) + live file is already v2; backup ABSENT + Alibaba region backup (config.json.pre-alibaba-region-v1.bak) ABSENT + Claude authMode three-state claudeCode.authModeMigratedAt present + Codex history guardian journal says 0 threads remapped + schema_version NEVER existed in src/types.ts or configSchema +``` + +The 2026-08-22 trees named `*backup*` / `*pre-git*` are **runtime-code +snapshots**, not datastore snapshots. Treating them as recoverable OCX state +would be a category error. + +The 2026-08-04 move **did copy the config-shaped home**: usage from 2026-08-03, +device ids from 2026-08-03, a pre-control admin-token bak, and a SETUP.md that +still talks about tunneling to control-01. It did **not** leave an `auth.json` +on Azure, and we cannot see whether one existed on control-01. + +## 3. Record-level reconciliation + +Sources compared: + +- **current** = `/home/chef/.opencodex` +- **etc** = `/etc/chef/opencodex` (only other reachable config-shaped store) +- **code-legacy-1.1.1** = `.opencodex-backup-20260822T095647Z` (no state) +- **code-legacy-1.2.1** = `opencodex.pre-git-20260822T133750Z` (no state) +- **control-01** = unreachable + +Provider objects in current vs etc are **byte-identical** (18/18). The 89213 vs +70641 byte gap is `claudeCode.desktopProfile`, present only on current +(added after the 06:34Z snapshot). Disabled-model set hash +`ee2334c82007d978` matches (876 ids). Admission-key id +`e224fe64-19e2-4cce-a9c3-3d32dbae2904` matches (name `default`, created +2026-07-31, key length 44, not an env-ref). Combo `google-combo` matches +(2 google targets, failover). Admin token sha of etc == current; both differ +from `admin-api-token.bak-pre-control-20260804` (same length 53, different +content). Service-api-token sha of etc == current (length 32). + +| Domain | current | etc | code-legacy ×2 | control-01 | Missing from current | Conflicts | +| --- | ---: | ---: | ---: | --- | --- | --- | +| providers | 18 | 18 | 0 | UNREACHABLE | none vs etc | none vs etc (adapter/authMode/disabled/pool ids) | +| oauth providers in `auth.json` | **0 (file absent)** | 0 | 0 | UNREACHABLE | **entire auth store if control-01 had one** | n/a | +| oauth accounts | 0 | 0 | 0 | UNREACHABLE | **unknown** | n/a | +| Codex pool accounts (`codex-accounts.json`) | **0 (file absent)** | 0 | 0 | UNREACHABLE | **unknown** | n/a | +| `config.codexAccounts` | 0 | 0 | 0 | UNREACHABLE | none vs etc | n/a | +| admission `apiKeys` | 1 | 1 | 0 | UNREACHABLE | none vs etc | none | +| provider `apiKey` present | 11 | 11 | 0 | UNREACHABLE | none vs etc | none (same env-ref / length) | +| provider `apiKeyPool` entries | 13 | 13 | 0 | UNREACHABLE | none vs etc | none (same ids) | +| env-ref keys | 7 | 7 | 0 | — | — | — | +| inline keys (length only) | 6 | 6 | 0 | — | — | — | +| `disabledModels` | 876 | 876 | 0 | UNREACHABLE | 0 | 0 | +| `subagentModels` | 4 | 4 | 0 | UNREACHABLE | 0 | 0 | +| `providerContextCaps` | 7 | 7 | 0 | UNREACHABLE | 0 | 0 | +| `providerCooldowns` | 0 | 0 | 0 | UNREACHABLE | 0 | 0 | +| combos | 1 | 1 | 0 | UNREACHABLE | 0 | 0 | +| usage.jsonl lines | 174 | 0 | 0 | UNREACHABLE | none vs reachable sources; **pre-2026-08-03 history unknown** | 5 bogus `ocx-early` rows at ts=1000 | +| usage request ids | 170 unique | 0 | 0 | UNREACHABLE | unknown before 2026-08-03 | — | +| responses-state | v2, 3 keys | 0 | 0 | UNREACHABLE | — | — | +| `schemaVersion` | **absent** | **absent** | — | — | — | — | + +Current providers (disabled noted): `openai` (disabled, forward), `cursor` +(disabled, oauth), `google-antigravity` (oauth), `github-copilot` (oauth), +`opencode-free` (key, no key — keyOptional), `mimo-free` (key, no key — +keyOptional), `api-for-cursor` (disabled, env-ref + 2 pool ids `2f193882`, +`a89443c4`), `cline-pass`, `groq`, `nvidia`, `opencode-go` (disabled), `zai`, +`openrouter` (disabled), `deepseek` (2 pool ids `34b8a2c2`, `13e6526e`), +`google`, `vercel-ai-gateway`, `orcarouter`, `kilo`. + +Default provider is `deepseek`. `hostname` is the tailscale bind +`100.109.39.86`. `effortCap` / `subagentEffortCap` = `low`. +`googleAntigravityAccountPool.enabled` = true, strategy `round-robin`. +`tokenGuardian.enabled` = true. `claudeCode.authMode` = `proxy`. +Three oauth-mode providers are configured and **have no on-disk tokens**. + +Usage days on current: 2026-08-03:32, 08-08:64, 08-14:8, 08-18:33, 08-21:8, +08-22:24, plus 5 bogus `ocx-early` rows. Providers seen in the log: +`anthropic` 108 (Claude inbound surface, not a configured provider), +`combo` 18, `no-such-provider` 18, `kilo` 10, `opencode-free` 8, `deepseek` 6, +`unknown` 3, `anthropic-native` 1, `github-copilot` 1, `orcarouter` 1. +**No `google-antigravity` rows.** Success-ish 154 / fail-ish 20. + +Catalog backups `catalog-backup.json`, `catalog-backup-5f03f1706cc5a50f.json`, +`catalog-backup-aaa4c6cf793fb310.json` are the same 321150 B / same sha +(8 models). They are Codex catalog snapshots, not config history. + +`~/.codex` has no ChatGPT `auth.json`. Extra homes +(`~/.claude`, `~/.grok`, `~/.cursor`, `~/.factory`, `~/.junie`) are absent. +A host-wide find for `auth.json` / `codex-accounts.json` under +`/home/chef`, `/opt/chef`, `/etc/chef`, `/var/lib/chef` returned none. + +## 4. What is missing if chef-control-01 never returns + +Proven already on Azure (so not uniquely at risk): + +- The 18-provider config, 876 disabled models, combo, admission key id, + usage from 2026-08-03 onward, device ids, rotated admin token, service token. + +Unprovable, and lost-if-offline: + +1. **`~/.opencodex/auth.json` and `auth.json.pre-multiauth`.** Three live + providers are `authMode=oauth` (`cursor` disabled, `google-antigravity` + and `github-copilot` enabled). Zero token files exist on Azure. If those + logins were completed on control-01, the refresh tokens were not migrated. + Re-login is the only recovery. If they were never completed, nothing was + lost — we cannot tell which. +2. **`~/.opencodex/codex-accounts.json` and any ChatGPT `~/.codex/auth.json`.** + Azure journal `originalConfig` is empty; current `.codex` is a fresh + inject. Any Codex Desktop / pool credentials on control-01 are gone. +3. **usage.jsonl before 2026-08-03.** Current file starts the day of the + host-move artifacts. Earlier request history has no reachable copy. +4. **format-migration backups** (`config.json.pre-openai-tiers-v2.bak`, + Alibaba region bak). Live config is already v2, so rollback-to-v1 is + impossible from Azure disk alone. +5. **anything else under the old home** (hand-edited config, extra apiKey + pool entries, older disabled-model sets). The Azure copy looks like a + single tree that kept being written (config mtime 10:10Z, usage 10:07Z + on 2026-08-22), not a merge of two authorities. + +Risk if it stays offline: **oauth session continuity is the real one**. +Provider *configuration* survived. Tokens may not have. Key-auth providers +that use env-refs still resolve from `service.env`; the six inline keys live +in current `config.json` and the etc snapshot (same lengths). + +## 5. Code migration paths (what the repo actually does) + +There is **no general `schemaVersion`** and no scheduled backup. `loadConfig` +(`src/config.ts:1195`) / `saveConfig` (`:1365`) / `configSchema` (`:741`) / +`getDefaultConfig` (`:1659`) read and write the whole JSON object with +passthrough + a merge-defaults repair. Invalid files are copied to +`config.json.invalid-` and replaced with defaults (data-loss path). + +Targeted migrations that do exist: + +| Path | Trigger | Backup | On Azure disk | +| --- | --- | --- | --- | +| `runOpenAiTierStartupMigration` | startup, if projection.changed | `config.json.pre-openai-tiers-v2.bak` via `backupConfigBeforeOpenAiTierMigration` | already v2; bak absent | +| `oauth/store.ts` `backupLegacyOnce` | first persist of multiauth over legacy | `auth.json.pre-multiauth` | both absent | +| `alibaba-region-backup.ts` | Alibaba region rewrite | `config.json.pre-alibaba-region-v1.bak` | absent | +| history-migration guardian | Codex thread visibility | journal | 0 threads | +| Claude `authMode` three-state | load/reconcile | in-object `authModeMigratedAt` | present | + +A host-to-host OCX state promotion pipeline has never existed. That is what +`scripts/state-reconcile.ts` now is (design + implementation, dry-run +default, not executed against production). + +## 6. Promotion pipeline (design = the committed script) + +`bun scripts/state-reconcile.ts` runs exactly: + +1. **Backup current** into `--backup-dir` with a sha256 manifest of the + state filenames (`config.json`, `auth.json`, `codex-accounts.json`, + `usage.jsonl`, tokens, …). Apply/promote **refuse** without a verified + manifest; tamper fails verification. +2. **Import legacy into staging** (`--staging`). Current is never the write + target. +3. **Schema normalize** — stamp `schemaVersion: 1`, lift legacy single-slot + auth.json into `{activeAccountId, accounts[]}`, seed `apiKeyPool` from a + bare `apiKey`. +4. **Dedupe** — provider name, apiKey id, oauth `(provider, account id)`, + combo id, disabled-model set-union, usage `(requestId, timestamp)`. + Default `--prefer current`. +5. **Referential checks** — combo targets must name a provider; `authMode=oauth` + without auth.json accounts is a FAIL row (the Azure situation). +6. **Record-count reconciliation** — printed per domain: current count, + per-legacy count, missing ids, conflicts. +7. **Functional smoke** — `validateConfigCandidate`, auth shape, JSONL parse. +8. **Promote** — only with `--apply --promote`. Copies live → + `--backup-dir/pre-promote`, writes a rollback pointer, then copies + staging → live. `--rollback` reverses that. + +Safety properties: + +- Default is dry-run. Dry-run writes nothing under current/legacy/backup/staging + (preview materialization uses `os.tmpdir()` and deletes it). +- Apply writes staging only. +- Promote is the only live writer. +- Idempotent: a second apply against the same inputs yields the same + `stagingDigest`. +- Reversible via the pre-promote snapshot. +- Diff/report redacts `apiKey` / `key` / `access` / `refresh` / token values + to length + env-ref. +- Not executed against production in this lane. + +## 7. Minimal schema version + backup (proposal only) + +Do not invent a second config file. Add one field next to the existing +migration marker. + +**Field.** `schemaVersion?: number` on `OcxConfig` (`src/types.ts` ~533) and +`configSchema` (`src/config.ts` ~741), camelCase to match +`openaiProviderTierVersion`. On-disk absence means **1**. +`getDefaultConfig()` writes `schemaVersion: 1`. `loadConfig` / +`readConfigDiagnostics` treat missing as 1 and do not rewrite the file on +read. `saveConfig` / `saveConfigPreservingClaudeCode` persist whatever is on +the object; the first ordinary save after deploy stamps `1`. Bump the +constant in one place when a future rewrite needs a gate. Do not alias +`schema_version` unless a hand-edited file is found — none exists on Azure. + +**Backup.** Two layers, both missing today: + +1. On `saveConfig` success, hard-link/copy `config.json` to + `~/.opencodex/backups/config/config-.json` and keep 14 copies. Reuse + `atomicWriteFile` + `backupConfigBeforeOpenAiTierMigration`'s + no-replace discipline. Same for `auth.json` when the oauth store writes. +2. A systemd timer modeled on `chef-vault-backup.timer` (already on the host) + that copies the `STATE_FILES` list from `scripts/state-reconcile.ts` into + `/var/lib/chef/backups/opencodex//` with a sha256 manifest. Daily is + enough; the store is tens of kilobytes plus 2.7 MB catalogs if included. + +Until both exist, V8 stays open: one bad `saveConfig` after a schema +mismatch still hits `backupInvalidConfig` and can drop providers on the +unrecoverable-parse path. + +## 8. Open UNKNOWNs (need a human) + +1. **Did chef-control-01 ever have `auth.json`?** If yes, promote is a + re-login project, not a file copy, unless the disk comes back. +2. **Should the offline host be powered on / imaged before it is wiped?** + Last seen 2026-08-22 01:31Z. Every day increases the chance the disk is + gone. +3. **Are the six inline (non-env-ref) provider keys supposed to move into + `service.env`?** They live in `config.json` today. Out of scope to change. +4. **`no-such-provider` (18) and `unknown` (3) usage rows** — operator + error vs a dropped provider name from an older config we cannot see. +5. **Keep or delete `/etc/chef/opencodex/config.json`?** It is a useful + 2026-08-22 06:34Z rollback point but it is also a second copy of live + secrets sitting next to `service.env`. +6. **Stamp `schemaVersion` on the next ordinary save, or wait for a + dedicated migrate PR?** Recommendation: dedicated PR so the first write + is intentional. +7. **Who owns restoring google-antigravity / github-copilot / cursor + sessions?** Config says they exist; the token file does not. + +## 9. Safety attestation + +- Production (`chef-control-az-01`) was accessed only with read-only + commands (`ls`, `find`, `stat`, `cat` / `sudo cat`, `systemctl status|cat`, + `curl` to `/healthz`, `node` over SSH stdin). No writes, moves, deletes, + chmods, restarts, restores, or cron/timer changes. +- `scripts/state-reconcile.ts` was not pointed at the live store. +- This document contains no API key, token, cookie, or refresh-token + values. `/etc/chef/opencodex/service.env` is reported by key name only. +- No merge, push, tag, or PR was created. diff --git a/scripts/state-reconcile.ts b/scripts/state-reconcile.ts new file mode 100644 index 00000000..d23213d5 --- /dev/null +++ b/scripts/state-reconcile.ts @@ -0,0 +1,949 @@ +#!/usr/bin/env bun +/** + * Reviewable, idempotent, dry-run-first promotion pipeline for OCX JSON state. + * + * Order: backup current → import legacy into staging → schema normalize → + * dedupe → referential checks → record-count reconciliation → functional + * smoke → promote. + * + * Default is dry-run. Apply/promote refuse without a verified backup. + * Live writes require --promote. Values of apiKey/token/refresh/cookie + * fields are never logged — names, ids, lengths, and env-ref flags only. + * + * Usage: + * bun scripts/state-reconcile.ts --current --legacy [--legacy ...] + * bun scripts/state-reconcile.ts --current --legacy --backup-dir --staging --apply + * bun scripts/state-reconcile.ts --current --legacy --backup-dir --staging --apply --promote + * bun scripts/state-reconcile.ts --rollback --current --backup-dir + */ +import { createHash } from "node:crypto"; +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { validateConfigCandidate } from "../src/config"; + +export const STATE_RECONCILE_SCHEMA_VERSION = 1; +export const BACKUP_MANIFEST_NAME = "reconcile-backup.manifest.json"; +export const STAGING_MANIFEST_NAME = "reconcile-staging.manifest.json"; +export const ROLLBACK_POINTER_NAME = "reconcile-rollback.json"; + +export const STATE_FILES = [ + "config.json", + "auth.json", + "auth.json.pre-multiauth", + "codex-accounts.json", + "usage.jsonl", + "responses-state.json", + "admin-api-token", + "service-api-token", + "claude-persistent-env.json", + "telemetry-id.txt", +] as const; + +const SECRET_KEY = /^(apiKey|key|access|refresh|accessToken|refreshToken|idToken|token|password|clientSecret|cookie)$/i; + +export type ReconcileMode = "dry-run" | "apply" | "promote" | "rollback"; + +export interface ReconcileOptions { + currentDir: string; + legacyDirs: string[]; + backupDir?: string; + stagingDir?: string; + mode: ReconcileMode; + prefer: "current" | "legacy"; +} + +export interface FileFingerprint { + name: string; + present: boolean; + size: number | null; + sha256: string | null; +} + +export interface BackupManifest { + version: 1; + createdAt: string; + sourceDir: string; + files: FileFingerprint[]; +} + +export interface SecretShape { + present: boolean; + length?: number; + envRef?: boolean; +} + +export interface ProviderRecord { + name: string; + adapter: string | null; + authMode: string | null; + disabled: boolean; + hasApiKey: boolean; + apiKey: SecretShape; + apiKeyPoolIds: string[]; + defaultModel: string | null; +} + +export interface ApiKeyRecord { + id: string | null; + name: string | null; + createdAt: string | null; + key: SecretShape; +} + +export interface ComboRecord { + id: string; + targetProviders: string[]; + targetModels: string[]; + strategy: string | null; +} + +export interface OAuthAccountRecord { + provider: string; + id: string | null; + needsReauth: boolean; + hasRefresh: boolean; + hasAccess: boolean; + refreshLen: number; + accessLen: number; +} + +export interface StoreInventory { + label: string; + path: string; + files: FileFingerprint[]; + schemaVersion: number | null; + openaiProviderTierVersion: number | null; + providers: ProviderRecord[]; + apiKeys: ApiKeyRecord[]; + combos: ComboRecord[]; + disabledModels: string[]; + subagentModels: string[]; + providerContextCaps: string[]; + providerCooldowns: string[]; + oauthAccounts: OAuthAccountRecord[]; + oauthProviders: string[]; + usageLines: number; + usageRequestIds: string[]; + usageProviders: string[]; + configParseError: string | null; +} + +export interface DomainDiff { + domain: string; + currentCount: number; + legacyCounts: Record; + missingFromCurrent: string[]; + conflicts: string[]; +} + +export interface ReconcileCheck { + id: string; + ok: boolean; + detail: string; +} + +export interface ReconcileReport { + mode: ReconcileMode; + wrote: string[]; + current: StoreInventory; + legacy: StoreInventory[]; + domains: DomainDiff[]; + checks: ReconcileCheck[]; + backup: { required: boolean; verified: boolean; path: string | null }; + stagingDigest: string | null; +} + +export class ReconcileError extends Error { + constructor(message: string, readonly exitCode = 2) { + super(message); + this.name = "ReconcileError"; + } +} + +function isEnvRef(value: string): boolean { + return /^\$\{?\w+\}?$/.test(value); +} + +export function secretShape(value: unknown): SecretShape { + if (typeof value !== "string") return { present: false }; + return { present: value.length > 0, length: value.length, envRef: isEnvRef(value) }; +} + +export function redactForLog(value: unknown, key = ""): unknown { + if (SECRET_KEY.test(key) && typeof value === "string") { + return value ? `` : value; + } + if (Array.isArray(value)) return value.map((item) => redactForLog(item)); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value as Record).map(([child, childValue]) => [ + child, + redactForLog(childValue, child), + ]), + ); + } + return value; +} + +function sha256Bytes(bytes: Uint8Array | string): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +export function fingerprintFile(dir: string, name: string): FileFingerprint { + const path = join(dir, name); + if (!existsSync(path) || !statSync(path).isFile()) { + return { name, present: false, size: null, sha256: null }; + } + const bytes = readFileSync(path); + return { name, present: true, size: bytes.byteLength, sha256: sha256Bytes(bytes) }; +} + +function readJson(path: string): { ok: true; value: unknown } | { ok: false; error: string } { + try { + return { ok: true, value: JSON.parse(readFileSync(path, "utf8").replace(/^\uFEFF/, "")) }; + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) }; + } +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function inventoryProviders(config: Record): ProviderRecord[] { + const providers = asRecord(config.providers) ?? {}; + return Object.entries(providers).map(([name, raw]) => { + const provider = asRecord(raw) ?? {}; + const pool = Array.isArray(provider.apiKeyPool) ? provider.apiKeyPool : []; + return { + name, + adapter: typeof provider.adapter === "string" ? provider.adapter : null, + authMode: typeof provider.authMode === "string" ? provider.authMode : null, + disabled: provider.disabled === true, + hasApiKey: typeof provider.apiKey === "string" && provider.apiKey.length > 0, + apiKey: secretShape(provider.apiKey), + apiKeyPoolIds: pool + .map((entry) => (asRecord(entry)?.id)) + .filter((id): id is string => typeof id === "string"), + defaultModel: typeof provider.defaultModel === "string" ? provider.defaultModel : null, + }; + }); +} + +function inventoryAuth(dir: string): { providers: string[]; accounts: OAuthAccountRecord[] } { + const parsed = readJson(join(dir, "auth.json")); + if (!parsed.ok) return { providers: [], accounts: [] }; + const store = asRecord(parsed.value); + if (!store) return { providers: [], accounts: [] }; + const accounts: OAuthAccountRecord[] = []; + for (const [provider, raw] of Object.entries(store)) { + const value = asRecord(raw); + if (!value) continue; + if (Array.isArray(value.accounts)) { + for (const row of value.accounts) { + const account = asRecord(row) ?? {}; + const cred = asRecord(account.credential) ?? {}; + accounts.push({ + provider, + id: typeof account.id === "string" ? account.id : null, + needsReauth: account.needsReauth === true, + hasRefresh: typeof cred.refresh === "string" && cred.refresh.length > 0, + hasAccess: typeof cred.access === "string" && cred.access.length > 0, + refreshLen: typeof cred.refresh === "string" ? cred.refresh.length : 0, + accessLen: typeof cred.access === "string" ? cred.access.length : 0, + }); + } + } else if (typeof value.refresh === "string" || typeof value.access === "string") { + accounts.push({ + provider, + id: typeof value.accountId === "string" ? value.accountId : "legacy", + needsReauth: false, + hasRefresh: typeof value.refresh === "string" && value.refresh.length > 0, + hasAccess: typeof value.access === "string" && value.access.length > 0, + refreshLen: typeof value.refresh === "string" ? value.refresh.length : 0, + accessLen: typeof value.access === "string" ? value.access.length : 0, + }); + } + } + return { providers: Object.keys(store).sort(), accounts }; +} + +function inventoryUsage(dir: string): { lines: number; requestIds: string[]; providers: string[] } { + const path = join(dir, "usage.jsonl"); + if (!existsSync(path)) return { lines: 0, requestIds: [], providers: [] }; + const rows = readFileSync(path, "utf8").split(/\r?\n/).filter((line) => line.trim()); + const requestIds: string[] = []; + const providers = new Set(); + for (const line of rows) { + try { + const row = JSON.parse(line) as Record; + if (typeof row.requestId === "string") requestIds.push(row.requestId); + if (typeof row.provider === "string") providers.add(row.provider); + } catch { + /* keep line count honest; skip the broken row */ + } + } + return { lines: rows.length, requestIds, providers: [...providers].sort() }; +} + +export function inventoryStore(dir: string, label: string): StoreInventory { + const configPath = join(dir, "config.json"); + const parsed = existsSync(configPath) ? readJson(configPath) : { ok: false as const, error: "missing" }; + const config = parsed.ok ? asRecord(parsed.value) : null; + const auth = inventoryAuth(dir); + const usage = inventoryUsage(dir); + const providers = config ? inventoryProviders(config) : []; + const apiKeys = Array.isArray(config?.apiKeys) + ? config.apiKeys.map((raw) => { + const entry = asRecord(raw) ?? {}; + return { + id: typeof entry.id === "string" ? entry.id : null, + name: typeof entry.name === "string" ? entry.name : null, + createdAt: typeof entry.createdAt === "string" ? entry.createdAt : null, + key: secretShape(entry.key), + }; + }) + : []; + const combos = Object.entries(asRecord(config?.combos) ?? {}).map(([id, raw]) => { + const combo = asRecord(raw) ?? {}; + const targets = Array.isArray(combo.targets) ? combo.targets : []; + return { + id, + targetProviders: targets + .map((target) => asRecord(target)?.provider) + .filter((name): name is string => typeof name === "string"), + targetModels: targets + .map((target) => asRecord(target)?.model) + .filter((name): name is string => typeof name === "string"), + strategy: typeof combo.strategy === "string" ? combo.strategy : null, + }; + }); + return { + label, + path: dir, + files: STATE_FILES.map((name) => fingerprintFile(dir, name)), + schemaVersion: typeof config?.schemaVersion === "number" + ? config.schemaVersion + : typeof config?.schema_version === "number" ? config.schema_version : null, + openaiProviderTierVersion: typeof config?.openaiProviderTierVersion === "number" + ? config.openaiProviderTierVersion + : null, + providers, + apiKeys, + combos, + disabledModels: Array.isArray(config?.disabledModels) + ? config.disabledModels.filter((id): id is string => typeof id === "string") + : [], + subagentModels: Array.isArray(config?.subagentModels) + ? config.subagentModels.filter((id): id is string => typeof id === "string") + : [], + providerContextCaps: Object.keys(asRecord(config?.providerContextCaps) ?? {}).sort(), + providerCooldowns: Object.keys(asRecord(config?.providerCooldowns) ?? {}).sort(), + oauthAccounts: auth.accounts, + oauthProviders: auth.providers, + usageLines: usage.lines, + usageRequestIds: usage.requestIds, + usageProviders: usage.providers, + configParseError: parsed.ok ? null : parsed.error, + }; +} + +function setDiff(current: Iterable, legacy: Iterable): { missing: string[]; extra: string[] } { + const have = new Set(current); + const want = new Set(legacy); + return { + missing: [...want].filter((id) => !have.has(id)).sort(), + extra: [...have].filter((id) => !want.has(id)).sort(), + }; +} + +export function diffDomains(current: StoreInventory, legacy: StoreInventory[]): DomainDiff[] { + const domain = ( + name: string, + currentIds: string[], + legacyIds: (store: StoreInventory) => string[], + conflictIds: string[] = [], + ): DomainDiff => ({ + domain: name, + currentCount: currentIds.length, + legacyCounts: Object.fromEntries(legacy.map((store) => [store.label, legacyIds(store).length])), + missingFromCurrent: [...new Set(legacy.flatMap((store) => setDiff(currentIds, legacyIds(store)).missing))].sort(), + conflicts: conflictIds, + }); + + const providerConflicts = [...new Set(legacy.flatMap((store) => { + const conflicts: string[] = []; + for (const left of current.providers) { + const right = store.providers.find((row) => row.name === left.name); + if (!right) continue; + if (left.adapter !== right.adapter || left.authMode !== right.authMode || left.disabled !== right.disabled) { + conflicts.push(left.name); + } + } + return conflicts; + }))]; + + return [ + domain("providers", current.providers.map((row) => row.name), (store) => store.providers.map((row) => row.name), providerConflicts), + domain("apiKeys", current.apiKeys.map((row) => row.id ?? ""), (store) => store.apiKeys.map((row) => row.id ?? "")), + domain( + "apiKeyPool", + current.providers.flatMap((row) => row.apiKeyPoolIds.map((id) => `${row.name}:${id}`)), + (store) => store.providers.flatMap((row) => row.apiKeyPoolIds.map((id) => `${row.name}:${id}`)), + ), + domain( + "oauthAccounts", + current.oauthAccounts.map((row) => `${row.provider}:${row.id ?? ""}`), + (store) => store.oauthAccounts.map((row) => `${row.provider}:${row.id ?? ""}`), + ), + domain("combos", current.combos.map((row) => row.id), (store) => store.combos.map((row) => row.id)), + domain("disabledModels", current.disabledModels, (store) => store.disabledModels), + domain("subagentModels", current.subagentModels, (store) => store.subagentModels), + domain("providerContextCaps", current.providerContextCaps, (store) => store.providerContextCaps), + domain("providerCooldowns", current.providerCooldowns, (store) => store.providerCooldowns), + domain("usage", current.usageRequestIds, (store) => store.usageRequestIds), + ]; +} + +export function createBackup(currentDir: string, backupDir: string): BackupManifest { + mkdirSync(backupDir, { recursive: true, mode: 0o700 }); + const files = STATE_FILES.map((name) => { + const source = join(currentDir, name); + const fingerprint = fingerprintFile(currentDir, name); + if (fingerprint.present) copyFileSync(source, join(backupDir, name)); + return fingerprintFile(backupDir, name); + }); + const manifest: BackupManifest = { + version: 1, + createdAt: new Date().toISOString(), + sourceDir: resolve(currentDir), + files, + }; + writeFileSync(join(backupDir, BACKUP_MANIFEST_NAME), `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 }); + return manifest; +} + +export function verifyBackup(currentDir: string, backupDir: string): { ok: boolean; detail: string; manifest: BackupManifest | null } { + const manifestPath = join(backupDir, BACKUP_MANIFEST_NAME); + if (!existsSync(manifestPath)) return { ok: false, detail: "backup manifest missing", manifest: null }; + const parsed = readJson(manifestPath); + if (!parsed.ok) return { ok: false, detail: `backup manifest unreadable: ${parsed.error}`, manifest: null }; + const manifest = parsed.value as BackupManifest; + if (manifest.version !== 1 || !Array.isArray(manifest.files)) { + return { ok: false, detail: "backup manifest schema mismatch", manifest: null }; + } + for (const expected of manifest.files) { + const onDisk = fingerprintFile(backupDir, expected.name); + if (expected.present !== onDisk.present || expected.sha256 !== onDisk.sha256) { + return { ok: false, detail: `backup file drifted: ${expected.name}`, manifest }; + } + } + const present = manifest.files.filter((file) => file.present); + if (present.length === 0 && fingerprintFile(currentDir, "config.json").present) { + return { ok: false, detail: "backup contains no state files", manifest }; + } + return { ok: true, detail: `verified ${present.length} file(s)`, manifest }; +} + +function cloneRecord(value: T): T { + return structuredClone(value); +} + +export function normalizeAuthStore(raw: unknown): Record { + const store = asRecord(raw) ?? {}; + const normalized: Record = {}; + for (const [provider, value] of Object.entries(store)) { + const record = asRecord(value); + if (!record) continue; + if (Array.isArray(record.accounts)) { + normalized[provider] = record; + continue; + } + if (typeof record.refresh === "string" && typeof record.access === "string") { + const id = typeof record.accountId === "string" ? record.accountId : "legacy"; + normalized[provider] = { activeAccountId: id, accounts: [{ id, credential: record }] }; + } + } + return normalized; +} + +export function normalizeConfig(raw: unknown): Record { + const config = asRecord(raw) ? cloneRecord(asRecord(raw)!) : {}; + if (typeof config.schemaVersion !== "number") config.schemaVersion = STATE_RECONCILE_SCHEMA_VERSION; + const providers = asRecord(config.providers) ?? {}; + for (const provider of Object.values(providers)) { + const record = asRecord(provider); + if (!record) continue; + if (!Array.isArray(record.apiKeyPool) && typeof record.apiKey === "string" && record.apiKey.length > 0) { + const id = createHash("sha256").update(record.apiKey).digest("hex").slice(0, 8); + record.apiKeyPool = [{ id, key: record.apiKey }]; + } + } + config.providers = providers; + return config; +} + +function mergePrefer(current: T, incoming: T, prefer: "current" | "legacy"): T { + return prefer === "legacy" ? incoming : current; +} + +export function mergeStores( + current: Record, + incoming: Record, + prefer: "current" | "legacy", +): Record { + const merged = cloneRecord(current); + const currentProviders = asRecord(merged.providers) ?? {}; + const incomingProviders = asRecord(incoming.providers) ?? {}; + for (const [name, provider] of Object.entries(incomingProviders)) { + if (!currentProviders[name]) currentProviders[name] = cloneRecord(provider); + else currentProviders[name] = mergePrefer(currentProviders[name], cloneRecord(provider), prefer); + } + merged.providers = currentProviders; + + const currentKeys = Array.isArray(merged.apiKeys) ? [...merged.apiKeys] : []; + const incomingKeys = Array.isArray(incoming.apiKeys) ? incoming.apiKeys : []; + const keyIndex = new Map(); + for (const row of currentKeys) { + const id = asRecord(row)?.id; + if (typeof id === "string") keyIndex.set(id, row); + } + for (const row of incomingKeys) { + const id = asRecord(row)?.id; + if (typeof id !== "string") continue; + if (!keyIndex.has(id)) { + currentKeys.push(row); + keyIndex.set(id, row); + } else if (prefer === "legacy") { + const at = currentKeys.findIndex((entry) => asRecord(entry)?.id === id); + if (at >= 0) currentKeys[at] = row; + } + } + merged.apiKeys = currentKeys; + + const currentCombos = asRecord(merged.combos) ?? {}; + for (const [id, combo] of Object.entries(asRecord(incoming.combos) ?? {})) { + if (!currentCombos[id]) currentCombos[id] = cloneRecord(combo); + else currentCombos[id] = mergePrefer(currentCombos[id], cloneRecord(combo), prefer); + } + merged.combos = currentCombos; + + const disabled = new Set([ + ...(Array.isArray(merged.disabledModels) ? merged.disabledModels : []), + ...(Array.isArray(incoming.disabledModels) ? incoming.disabledModels : []), + ].filter((id): id is string => typeof id === "string")); + merged.disabledModels = [...disabled].sort(); + return merged; +} + +export function mergeAuth( + current: Record, + incoming: Record, + prefer: "current" | "legacy", +): Record { + const merged = cloneRecord(normalizeAuthStore(current)); + const extra = normalizeAuthStore(incoming); + for (const [provider, raw] of Object.entries(extra)) { + const incomingSet = asRecord(raw); + const currentSet = asRecord(merged[provider]); + if (!incomingSet) continue; + if (!currentSet) { + merged[provider] = incomingSet; + continue; + } + const accounts = Array.isArray(currentSet.accounts) ? [...currentSet.accounts] : []; + const seen = new Set(accounts.map((row) => asRecord(row)?.id).filter((id): id is string => typeof id === "string")); + for (const row of Array.isArray(incomingSet.accounts) ? incomingSet.accounts : []) { + const id = asRecord(row)?.id; + if (typeof id !== "string") continue; + if (!seen.has(id)) { + accounts.push(row); + seen.add(id); + } else if (prefer === "legacy") { + const at = accounts.findIndex((entry) => asRecord(entry)?.id === id); + if (at >= 0) accounts[at] = row; + } + } + currentSet.accounts = accounts; + merged[provider] = currentSet; + } + return merged; +} + +function mergeUsage(currentText: string, incomingText: string): string { + const seen = new Set(); + const lines: string[] = []; + for (const line of [...currentText.split(/\r?\n/), ...incomingText.split(/\r?\n/)]) { + const trimmed = line.trim(); + if (!trimmed) continue; + let key = trimmed; + try { + const row = JSON.parse(trimmed) as Record; + key = `${String(row.requestId ?? "")}:${String(row.timestamp ?? "")}`; + } catch { + key = sha256Bytes(trimmed); + } + if (seen.has(key)) continue; + seen.add(key); + lines.push(trimmed); + } + return lines.length ? `${lines.join("\n")}\n` : ""; +} + +export function referentialChecks(config: Record, auth: Record): ReconcileCheck[] { + const checks: ReconcileCheck[] = []; + const providerNames = new Set(Object.keys(asRecord(config.providers) ?? {})); + const combos = asRecord(config.combos) ?? {}; + for (const [id, raw] of Object.entries(combos)) { + const targets = Array.isArray(asRecord(raw)?.targets) ? asRecord(raw)!.targets as unknown[] : []; + for (const target of targets) { + const provider = asRecord(target)?.provider; + if (typeof provider === "string" && !providerNames.has(provider)) { + checks.push({ id: `combo.${id}`, ok: false, detail: `target provider missing: ${provider}` }); + } + } + } + const providers = asRecord(config.providers) ?? {}; + for (const [name, raw] of Object.entries(providers)) { + const provider = asRecord(raw) ?? {}; + if (provider.authMode === "oauth") { + const set = asRecord(auth[name]); + const count = Array.isArray(set?.accounts) ? set.accounts.length : 0; + checks.push({ + id: `oauth.${name}`, + ok: count > 0, + detail: count > 0 ? `${count} account(s)` : "authMode=oauth but no auth.json accounts", + }); + } + } + if (checks.every((check) => check.ok) || checks.length === 0) { + checks.push({ id: "referential", ok: true, detail: "combo targets and oauth rows resolve" }); + } + return checks; +} + +export function smokeChecks(config: unknown, auth: unknown, usageText: string): ReconcileCheck[] { + const checks: ReconcileCheck[] = []; + const validated = validateConfigCandidate(config); + checks.push({ + id: "config.schema", + ok: validated.ok, + detail: validated.ok ? "validateConfigCandidate ok" : validated.error, + }); + const normalizedAuth = normalizeAuthStore(auth); + checks.push({ + id: "auth.shape", + ok: true, + detail: `${Object.keys(normalizedAuth).length} oauth provider set(s)`, + }); + let usageOk = true; + let usageRows = 0; + for (const line of usageText.split(/\r?\n/).filter(Boolean)) { + try { + JSON.parse(line); + usageRows += 1; + } catch { + usageOk = false; + } + } + checks.push({ id: "usage.jsonl", ok: usageOk, detail: `${usageRows} row(s)` }); + return checks; +} + +function writeStateFile(dir: string, name: string, contents: string): void { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + const tmp = join(dir, `${name}.ocx.reconcile.tmp`); + writeFileSync(tmp, contents, { encoding: "utf8", mode: 0o600 }); + renameSync(tmp, join(dir, name)); +} + +function readJsonValue(path: string): unknown { + const parsed = readJson(path); + if (!parsed.ok) throw new ReconcileError(`${path} is not valid JSON: ${parsed.error}`); + return parsed.value; +} + +export function materializeStaging( + currentDir: string, + legacyDirs: string[], + stagingDir: string, + prefer: "current" | "legacy", +): { digest: string; files: string[] } { + let config = existsSync(join(currentDir, "config.json")) + ? normalizeConfig(readJsonValue(join(currentDir, "config.json"))) + : normalizeConfig({}); + let auth = existsSync(join(currentDir, "auth.json")) + ? normalizeAuthStore(readJsonValue(join(currentDir, "auth.json"))) + : {}; + let usage = existsSync(join(currentDir, "usage.jsonl")) ? readFileSync(join(currentDir, "usage.jsonl"), "utf8") : ""; + + for (const legacyDir of legacyDirs) { + if (existsSync(join(legacyDir, "config.json"))) { + config = mergeStores(config, normalizeConfig(readJsonValue(join(legacyDir, "config.json"))), prefer); + } + if (existsSync(join(legacyDir, "auth.json"))) { + auth = mergeAuth(auth, readJsonValue(join(legacyDir, "auth.json")), prefer); + } + if (existsSync(join(legacyDir, "usage.jsonl"))) { + usage = mergeUsage(usage, readFileSync(join(legacyDir, "usage.jsonl"), "utf8")); + } + } + + mkdirSync(stagingDir, { recursive: true, mode: 0o700 }); + const written: string[] = []; + writeStateFile(stagingDir, "config.json", `${JSON.stringify(config, null, 2)}\n`); + written.push("config.json"); + writeStateFile(stagingDir, "auth.json", `${JSON.stringify(auth, null, 2)}\n`); + written.push("auth.json"); + writeStateFile(stagingDir, "usage.jsonl", usage); + written.push("usage.jsonl"); + for (const name of STATE_FILES) { + if (name === "config.json" || name === "auth.json" || name === "usage.jsonl") continue; + const source = join(currentDir, name); + if (existsSync(source)) { + copyFileSync(source, join(stagingDir, name)); + written.push(name); + } + } + const digest = sha256Bytes(STATE_FILES.map((name) => fingerprintFile(stagingDir, name).sha256 ?? "").join("|")); + writeFileSync(join(stagingDir, STAGING_MANIFEST_NAME), `${JSON.stringify({ version: 1, digest, files: written }, null, 2)}\n`, { mode: 0o600 }); + return { digest, files: written }; +} + +export function promoteStaging(currentDir: string, stagingDir: string, backupDir: string): string[] { + const verified = verifyBackup(currentDir, backupDir); + if (!verified.ok) throw new ReconcileError(`refusing promote: ${verified.detail}`); + if (!existsSync(join(stagingDir, "config.json"))) throw new ReconcileError("refusing promote: staging has no config.json"); + const rollbackDir = join(backupDir, "pre-promote"); + mkdirSync(rollbackDir, { recursive: true, mode: 0o700 }); + const restored: string[] = []; + for (const name of STATE_FILES) { + const live = join(currentDir, name); + if (existsSync(live)) copyFileSync(live, join(rollbackDir, name)); + } + writeFileSync(join(backupDir, ROLLBACK_POINTER_NAME), `${JSON.stringify({ version: 1, dir: rollbackDir, at: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 }); + for (const name of STATE_FILES) { + const staged = join(stagingDir, name); + if (!existsSync(staged)) continue; + copyFileSync(staged, join(currentDir, name)); + restored.push(name); + } + return restored; +} + +export function rollbackLive(currentDir: string, backupDir: string): string[] { + const pointerPath = join(backupDir, ROLLBACK_POINTER_NAME); + const fallback = existsSync(join(backupDir, "pre-promote")) ? join(backupDir, "pre-promote") : backupDir; + let source = fallback; + if (existsSync(pointerPath)) { + const parsed = readJson(pointerPath); + const dir = parsed.ok ? asRecord(parsed.value)?.dir : null; + if (typeof dir === "string" && existsSync(dir)) source = dir; + } + if (!existsSync(source)) throw new ReconcileError("no rollback snapshot found"); + const restored: string[] = []; + for (const name of STATE_FILES) { + const from = join(source, name); + if (!existsSync(from)) continue; + copyFileSync(from, join(currentDir, name)); + restored.push(name); + } + if (restored.length === 0) throw new ReconcileError("rollback snapshot is empty"); + return restored; +} + +function projectedChecks( + currentDir: string, + legacyDirs: string[], + prefer: "current" | "legacy", +): { checks: ReconcileCheck[]; digest: string } { + const tmp = join(tmpdir(), `ocx-reconcile-preview-${process.pid}-${Date.now()}`); + try { + const { digest } = materializeStaging(currentDir, legacyDirs, tmp, prefer); + const config = readJsonValue(join(tmp, "config.json")); + const auth = existsSync(join(tmp, "auth.json")) ? readJsonValue(join(tmp, "auth.json")) : {}; + const usage = existsSync(join(tmp, "usage.jsonl")) ? readFileSync(join(tmp, "usage.jsonl"), "utf8") : ""; + return { + digest, + checks: [ + ...referentialChecks(asRecord(config) ?? {}, asRecord(auth) ?? {}), + ...smokeChecks(config, auth, usage), + ], + }; + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +} + +export function runReconcile(options: ReconcileOptions): ReconcileReport { + const currentDir = resolve(options.currentDir); + if (!existsSync(currentDir)) throw new ReconcileError(`current store not found: ${currentDir}`); + const legacyDirs = options.legacyDirs.map((dir) => resolve(dir)); + for (const dir of legacyDirs) { + if (!existsSync(dir)) throw new ReconcileError(`legacy store not found: ${dir}`); + } + + const current = inventoryStore(currentDir, "current"); + const legacy = legacyDirs.map((dir, index) => inventoryStore(dir, `legacy-${index}`)); + const domains = diffDomains(current, legacy); + const wrote: string[] = []; + const backupDir = options.backupDir ? resolve(options.backupDir) : undefined; + const stagingDir = options.stagingDir ? resolve(options.stagingDir) : undefined; + + if (options.mode === "rollback") { + if (!backupDir) throw new ReconcileError("rollback requires --backup-dir"); + wrote.push(...rollbackLive(currentDir, backupDir).map((name) => `current:${name}`)); + return { + mode: options.mode, + wrote, + current: inventoryStore(currentDir, "current"), + legacy, + domains: diffDomains(inventoryStore(currentDir, "current"), legacy), + checks: [{ id: "rollback", ok: true, detail: `restored ${wrote.length} file(s)` }], + backup: { required: true, verified: verifyBackup(currentDir, backupDir).ok, path: backupDir }, + stagingDigest: null, + }; + } + + const backupNeeded = options.mode === "apply" || options.mode === "promote"; + let backupVerified = false; + if (backupNeeded) { + if (!backupDir) throw new ReconcileError("apply/promote refuse to run without --backup-dir"); + if (!existsSync(join(backupDir, BACKUP_MANIFEST_NAME))) createBackup(currentDir, backupDir); + const verified = verifyBackup(currentDir, backupDir); + if (!verified.ok) throw new ReconcileError(`refusing to continue: ${verified.detail}`); + backupVerified = true; + } else if (backupDir && existsSync(join(backupDir, BACKUP_MANIFEST_NAME))) { + backupVerified = verifyBackup(currentDir, backupDir).ok; + } + + let stagingDigest: string | null = null; + let checks: ReconcileCheck[] = []; + if (options.mode === "dry-run") { + const preview = projectedChecks(currentDir, legacyDirs, options.prefer); + stagingDigest = preview.digest; + checks = preview.checks; + } else { + if (!stagingDir) throw new ReconcileError("apply/promote require --staging"); + const staged = materializeStaging(currentDir, legacyDirs, stagingDir, options.prefer); + stagingDigest = staged.digest; + wrote.push(...staged.files.map((name) => `staging:${name}`)); + const config = readJsonValue(join(stagingDir, "config.json")); + const auth = existsSync(join(stagingDir, "auth.json")) ? readJsonValue(join(stagingDir, "auth.json")) : {}; + const usage = existsSync(join(stagingDir, "usage.jsonl")) ? readFileSync(join(stagingDir, "usage.jsonl"), "utf8") : ""; + checks = [ + ...referentialChecks(asRecord(config) ?? {}, asRecord(auth) ?? {}), + ...smokeChecks(config, auth, usage), + ]; + if (options.mode === "promote") { + if (checks.some((check) => !check.ok && check.id === "config.schema")) { + throw new ReconcileError("refusing promote: staging failed functional smoke"); + } + wrote.push(...promoteStaging(currentDir, stagingDir, backupDir!).map((name) => `live:${name}`)); + } + } + + return { + mode: options.mode, + wrote, + current, + legacy, + domains, + checks, + backup: { required: backupNeeded, verified: backupVerified, path: backupDir ?? null }, + stagingDigest, + }; +} + +export function formatReport(report: ReconcileReport): string { + const lines = [ + `mode=${report.mode} wrote=${report.wrote.length} stagingDigest=${report.stagingDigest ?? "-"}`, + `backup required=${report.backup.required} verified=${report.backup.verified}`, + `current providers=${report.current.providers.length} oauth=${report.current.oauthAccounts.length} usage=${report.current.usageLines} schemaVersion=${report.current.schemaVersion ?? "absent"}`, + ]; + for (const domain of report.domains) { + const missing = domain.missingFromCurrent.length ? ` missing=[${domain.missingFromCurrent.join(",")}]` : ""; + const conflicts = domain.conflicts.length ? ` conflicts=[${domain.conflicts.join(",")}]` : ""; + const legacy = Object.entries(domain.legacyCounts).map(([label, count]) => `${label}:${count}`).join(" "); + lines.push(`${domain.domain}: current=${domain.currentCount} ${legacy}${missing}${conflicts}`); + } + for (const check of report.checks) { + lines.push(`${check.ok ? "ok" : "FAIL"} ${check.id}: ${check.detail}`); + } + if (report.wrote.length) lines.push(`wrote: ${report.wrote.join(", ")}`); + return `${lines.join("\n")}\n`; +} + +function takeFlag(args: string[], flag: string): boolean { + const at = args.indexOf(flag); + if (at === -1) return false; + args.splice(at, 1); + return true; +} + +function takeOption(args: string[], flag: string): string | undefined { + const at = args.indexOf(flag); + if (at === -1) return undefined; + const value = args[at + 1]; + if (!value || value.startsWith("--")) throw new ReconcileError(`${flag} requires a value`); + args.splice(at, 2); + return value; +} + +function takeRepeat(args: string[], flag: string): string[] { + const values: string[] = []; + for (;;) { + const value = takeOption(args, flag); + if (value === undefined) return values; + values.push(value); + } +} + +export function parseReconcileArgs(argv: string[]): ReconcileOptions { + const args = [...argv]; + const promote = takeFlag(args, "--promote"); + const apply = takeFlag(args, "--apply"); + const rollback = takeFlag(args, "--rollback"); + if ([promote, apply, rollback].filter(Boolean).length > 1 && !(promote && apply)) { + throw new ReconcileError("use one of dry-run (default), --apply, --apply --promote, or --rollback"); + } + const currentDir = takeOption(args, "--current") ?? process.env.OPENCODEX_HOME; + if (!currentDir) throw new ReconcileError("--current or OPENCODEX_HOME is required"); + const options: ReconcileOptions = { + currentDir, + legacyDirs: takeRepeat(args, "--legacy"), + backupDir: takeOption(args, "--backup-dir"), + stagingDir: takeOption(args, "--staging"), + mode: rollback ? "rollback" : promote ? "promote" : apply ? "apply" : "dry-run", + prefer: takeOption(args, "--prefer") === "legacy" ? "legacy" : "current", + }; + if (args.length) throw new ReconcileError(`unexpected argument(s): ${args.join(" ")}`); + if (options.mode !== "rollback" && options.legacyDirs.length === 0) { + throw new ReconcileError("--legacy is required (repeatable)"); + } + return options; +} + +if (import.meta.main) { + try { + const report = runReconcile(parseReconcileArgs(process.argv.slice(2))); + process.stdout.write(formatReport(report)); + process.exit(report.checks.some((check) => !check.ok) ? 1 : 0); + } catch (error) { + const message = error instanceof ReconcileError ? error.message : error instanceof Error ? error.message : String(error); + process.stderr.write(`${message}\n`); + process.exit(error instanceof ReconcileError ? error.exitCode : 1); + } +} diff --git a/tests/state-reconcile.test.ts b/tests/state-reconcile.test.ts new file mode 100644 index 00000000..6601ba19 --- /dev/null +++ b/tests/state-reconcile.test.ts @@ -0,0 +1,302 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + BACKUP_MANIFEST_NAME, + ReconcileError, + createBackup, + diffDomains, + formatReport, + inventoryStore, + normalizeAuthStore, + normalizeConfig, + parseReconcileArgs, + redactForLog, + runReconcile, + secretShape, + verifyBackup, +} from "../scripts/state-reconcile"; + +const dirs: string[] = []; + +afterEach(() => { + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function scratch(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + dirs.push(dir); + return dir; +} + +function writeJson(dir: string, name: string, value: unknown): void { + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, name), `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +function provider(name: string, extra: Record = {}) { + return { + [name]: { + adapter: "openai-chat", + baseUrl: `https://${name}.example.test/v1`, + authMode: "key", + apiKey: `\${${name.toUpperCase()}_API_KEY}`, + ...extra, + }, + }; +} + +function currentConfig(overrides: Record = {}) { + return { + port: 10100, + hostname: "127.0.0.1", + defaultProvider: "deepseek", + openaiProviderTierVersion: 2, + providers: { + ...provider("deepseek", { apiKey: "sk-test-11111111111111111111", apiKeyPool: [{ id: "aaaa1111", key: "sk-test-11111111111111111111" }] }), + ...provider("google-antigravity", { adapter: "google", authMode: "oauth", baseUrl: "https://daily-cloudcode-pa.googleapis.com" }), + }, + apiKeys: [{ id: "e224fe64-19e2-4cce-a9c3-3d32dbae2904", name: "default", key: "ocx_test_admission_key_value_xxxx", createdAt: "2026-07-31T12:45:45.608Z" }], + combos: { "google-combo": { targets: [{ provider: "deepseek", model: "deepseek-v4-flash" }], strategy: "failover" } }, + disabledModels: ["hidden/one"], + subagentModels: ["deepseek/deepseek-v4-flash"], + ...overrides, + }; +} + +function seedCurrent(dir: string, overrides: Record = {}): void { + writeJson(dir, "config.json", currentConfig(overrides)); + writeFileSync(join(dir, "usage.jsonl"), `${JSON.stringify({ requestId: "live-1", timestamp: 1_700_000_000_000, provider: "deepseek", status: 200 })}\n`); +} + +describe("state reconcile inventory", () => { + test("records names and shapes, never secret values", () => { + const dir = scratch("ocx-reconcile-inv-"); + seedCurrent(dir); + writeJson(dir, "auth.json", { + "google-antigravity": { + activeAccountId: "acct-1", + accounts: [{ + id: "acct-1", + credential: { access: "aaaa".repeat(20), refresh: "bbbb".repeat(20), expires: 1 }, + }], + }, + }); + const inventory = inventoryStore(dir, "current"); + expect(inventory.providers.map((row) => row.name).sort()).toEqual(["deepseek", "google-antigravity"]); + expect(inventory.apiKeys[0]?.id).toBe("e224fe64-19e2-4cce-a9c3-3d32dbae2904"); + expect(inventory.apiKeys[0]?.key).toEqual({ present: true, length: 33, envRef: false }); + expect(inventory.oauthAccounts).toEqual([expect.objectContaining({ + provider: "google-antigravity", + id: "acct-1", + hasRefresh: true, + refreshLen: 80, + })]); + const dumped = JSON.stringify(inventory); + expect(dumped).not.toContain("sk-test-11111111111111111111"); + expect(dumped).not.toContain("aaaa".repeat(20)); + expect(dumped).not.toContain("ocx_test_admission_key_value_xxxx"); + }); + + test("diff reports legacy records missing from current", () => { + const currentDir = scratch("ocx-reconcile-cur-"); + const legacyDir = scratch("ocx-reconcile-leg-"); + seedCurrent(currentDir); + writeJson(legacyDir, "config.json", currentConfig({ + providers: { + ...currentConfig().providers, + ...provider("kilo"), + }, + apiKeys: [ + { id: "e224fe64-19e2-4cce-a9c3-3d32dbae2904", name: "default", key: "ocx_test_admission_key_value_xxxx", createdAt: "2026-07-31T12:45:45.608Z" }, + { id: "legacy-only-key", name: "old", key: "ocx_test_old_key_value_yyyy", createdAt: "2026-07-01T00:00:00.000Z" }, + ], + })); + writeJson(legacyDir, "auth.json", { + cursor: { activeAccountId: "cur-1", accounts: [{ id: "cur-1", credential: { access: "x".repeat(20), refresh: "y".repeat(20), expires: 1 } }] }, + }); + writeFileSync(join(legacyDir, "usage.jsonl"), `${JSON.stringify({ requestId: "old-9", timestamp: 1, provider: "kilo", status: 200 })}\n`); + const domains = diffDomains(inventoryStore(currentDir, "current"), [inventoryStore(legacyDir, "legacy-0")]); + expect(domains.find((row) => row.domain === "providers")?.missingFromCurrent).toEqual(["kilo"]); + expect(domains.find((row) => row.domain === "apiKeys")?.missingFromCurrent).toEqual(["legacy-only-key"]); + expect(domains.find((row) => row.domain === "oauthAccounts")?.missingFromCurrent).toEqual(["cursor:cur-1"]); + expect(domains.find((row) => row.domain === "usage")?.missingFromCurrent).toEqual(["old-9"]); + }); +}); + +describe("state reconcile pipeline", () => { + test("dry-run does not write current, staging, or backup", () => { + const currentDir = scratch("ocx-reconcile-dry-c-"); + const legacyDir = scratch("ocx-reconcile-dry-l-"); + const backupDir = scratch("ocx-reconcile-dry-b-"); + const stagingDir = join(scratch("ocx-reconcile-dry-s-"), "staging"); + seedCurrent(currentDir); + writeJson(legacyDir, "config.json", currentConfig({ providers: { ...currentConfig().providers, ...provider("kilo") } })); + const before = readdirSync(currentDir).sort(); + const report = runReconcile({ + currentDir, + legacyDirs: [legacyDir], + backupDir, + stagingDir, + mode: "dry-run", + prefer: "current", + }); + expect(report.mode).toBe("dry-run"); + expect(report.wrote).toEqual([]); + expect(report.domains.find((row) => row.domain === "providers")?.missingFromCurrent).toEqual(["kilo"]); + expect(readdirSync(currentDir).sort()).toEqual(before); + expect(existsSync(join(backupDir, BACKUP_MANIFEST_NAME))).toBe(false); + expect(existsSync(join(stagingDir, "config.json"))).toBe(false); + }); + + test("apply refuses without a backup directory", () => { + const currentDir = scratch("ocx-reconcile-nobak-c-"); + const legacyDir = scratch("ocx-reconcile-nobak-l-"); + seedCurrent(currentDir); + writeJson(legacyDir, "config.json", currentConfig()); + expect(() => runReconcile({ + currentDir, + legacyDirs: [legacyDir], + stagingDir: scratch("ocx-reconcile-nobak-s-"), + mode: "apply", + prefer: "current", + })).toThrow(ReconcileError); + }); + + test("apply writes staging only and is idempotent", () => { + const currentDir = scratch("ocx-reconcile-app-c-"); + const legacyDir = scratch("ocx-reconcile-app-l-"); + const backupDir = scratch("ocx-reconcile-app-b-"); + const stagingDir = scratch("ocx-reconcile-app-s-"); + seedCurrent(currentDir); + writeJson(legacyDir, "config.json", currentConfig({ + providers: { ...currentConfig().providers, ...provider("kilo") }, + })); + writeJson(legacyDir, "auth.json", { + cursor: { access: "x".repeat(24), refresh: "y".repeat(24), expires: 9, accountId: "legacy-cursor" }, + }); + const first = runReconcile({ + currentDir, + legacyDirs: [legacyDir], + backupDir, + stagingDir, + mode: "apply", + prefer: "current", + }); + expect(first.backup.verified).toBe(true); + expect(first.wrote.some((path) => path.startsWith("live:"))).toBe(false); + expect(existsSync(join(stagingDir, "config.json"))).toBe(true); + const staged = JSON.parse(readFileSync(join(stagingDir, "config.json"), "utf8")) as { + schemaVersion?: number; + providers?: Record; + }; + expect(staged.schemaVersion).toBe(1); + expect(staged.providers?.kilo).toBeDefined(); + expect(verifyBackup(currentDir, backupDir).ok).toBe(true); + const liveBefore = readFileSync(join(currentDir, "config.json"), "utf8"); + const second = runReconcile({ + currentDir, + legacyDirs: [legacyDir], + backupDir, + stagingDir, + mode: "apply", + prefer: "current", + }); + expect(second.stagingDigest).toBe(first.stagingDigest); + expect(readFileSync(join(currentDir, "config.json"), "utf8")).toBe(liveBefore); + const auth = JSON.parse(readFileSync(join(stagingDir, "auth.json"), "utf8")) as { + cursor?: { accounts?: Array<{ id?: string }> }; + }; + expect(auth.cursor?.accounts?.[0]?.id).toBe("legacy-cursor"); + }); + + test("promote updates live and rollback restores it", () => { + const currentDir = scratch("ocx-reconcile-pro-c-"); + const legacyDir = scratch("ocx-reconcile-pro-l-"); + const backupDir = scratch("ocx-reconcile-pro-b-"); + const stagingDir = scratch("ocx-reconcile-pro-s-"); + seedCurrent(currentDir); + const original = readFileSync(join(currentDir, "config.json"), "utf8"); + writeJson(legacyDir, "config.json", currentConfig({ + providers: { ...currentConfig().providers, ...provider("kilo") }, + })); + runReconcile({ + currentDir, + legacyDirs: [legacyDir], + backupDir, + stagingDir, + mode: "promote", + prefer: "current", + }); + const promoted = JSON.parse(readFileSync(join(currentDir, "config.json"), "utf8")) as { + providers?: Record; + schemaVersion?: number; + }; + expect(promoted.providers?.kilo).toBeDefined(); + expect(promoted.schemaVersion).toBe(1); + const rolled = runReconcile({ + currentDir, + legacyDirs: [], + backupDir, + mode: "rollback", + prefer: "current", + }); + expect(rolled.wrote.length).toBeGreaterThan(0); + expect(readFileSync(join(currentDir, "config.json"), "utf8")).toBe(original); + }); + + test("report and redaction never echo secret values", () => { + expect(secretShape("${NVIDIA_API_KEY}")).toEqual({ present: true, length: 17, envRef: true }); + expect(redactForLog({ apiKey: "sk-test-11111111111111111111", name: "deepseek" })).toEqual({ + apiKey: "", + name: "deepseek", + }); + const currentDir = scratch("ocx-reconcile-red-c-"); + const legacyDir = scratch("ocx-reconcile-red-l-"); + seedCurrent(currentDir); + writeJson(legacyDir, "config.json", currentConfig()); + const text = formatReport(runReconcile({ + currentDir, + legacyDirs: [legacyDir], + mode: "dry-run", + prefer: "current", + })); + expect(text).toContain("providers: current=2"); + expect(text).not.toContain("sk-test-11111111111111111111"); + expect(text).not.toContain("ocx_test_admission_key_value_xxxx"); + }); +}); + +describe("state reconcile helpers", () => { + test("parse args default to dry-run and require legacy", () => { + expect(parseReconcileArgs(["--current", "/tmp/a", "--legacy", "/tmp/b"]).mode).toBe("dry-run"); + expect(() => parseReconcileArgs(["--current", "/tmp/a"])).toThrow(ReconcileError); + expect(parseReconcileArgs(["--current", "/tmp/a", "--legacy", "/tmp/b", "--apply", "--promote"]).mode).toBe("promote"); + }); + + test("normalize seeds schemaVersion and multiauth shape", () => { + const config = normalizeConfig({ providers: { x: { adapter: "openai-chat", baseUrl: "https://x.test", apiKey: "sk-test-22222222222222222222" } } }); + expect(config.schemaVersion).toBe(1); + const pool = (config.providers as Record }>).x.apiKeyPool; + expect(pool?.[0]?.id).toHaveLength(8); + const auth = normalizeAuthStore({ + cursor: { access: "a".repeat(20), refresh: "b".repeat(20), expires: 1, accountId: "c1" }, + }); + expect(auth).toEqual({ + cursor: { activeAccountId: "c1", accounts: [{ id: "c1", credential: expect.objectContaining({ accountId: "c1" }) }] }, + }); + }); + + test("createBackup is verifiable and detects drift", () => { + const currentDir = scratch("ocx-reconcile-bak-c-"); + const backupDir = scratch("ocx-reconcile-bak-b-"); + seedCurrent(currentDir); + createBackup(currentDir, backupDir); + expect(verifyBackup(currentDir, backupDir).ok).toBe(true); + writeFileSync(join(backupDir, "config.json"), "{\"tampered\":true}\n"); + expect(verifyBackup(currentDir, backupDir).ok).toBe(false); + }); +}); From 751efe517b380f650dc30557f896a47ac08dee49 Mon Sep 17 00:00:00 2001 From: chefgroep Date: Sun, 23 Aug 2026 14:53:42 +0200 Subject: [PATCH 2/5] feat(config): stamp schemaVersion 1 on save without rewriting existing homes Live Azure files have no schemaVersion. Load treats absence as 1 in memory so the first ordinary save is the only write that persists the field. Co-authored-by: Cursor --- src/config.ts | 34 +++++++++++++-- src/types.ts | 9 ++++ tests/schema-version.test.ts | 84 ++++++++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 4 deletions(-) create mode 100644 tests/schema-version.test.ts diff --git a/src/config.ts b/src/config.ts index c33f1122..a01cea8b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -19,6 +19,7 @@ import { isProviderFallbackComboId, providerFallbackIssues } from "./providers/f import { isWirePinnedModel, MODEL_ADAPTER_OVERRIDE_ALLOWED, + CONFIG_SCHEMA_VERSION, OPENAI_PROVIDER_TIER_VERSION, REASONING_SUMMARY_DELIVERY_VALUES, type OcxClaudeCodeConfig, @@ -30,6 +31,8 @@ import { parseDesktopProfile } from "./claude/desktop-profile"; import { isCodexReasoningEffort, modelRecordValue } from "./reasoning-effort"; import { DEFAULT_RATE_LIMIT_WEBSOCKET_CONCURRENCY } from "./ratelimit"; +export { CONFIG_SCHEMA_VERSION }; + let _atomicSeq = 0; interface AtomicRenameIO { @@ -750,6 +753,9 @@ const configSchema = z.object({ providers: z.record(z.string(), providerConfigSchema), defaultProvider: z.string().min(1).default("openai"), openaiProviderTierVersion: z.union([z.literal(1), z.literal(2)]).optional(), + // Missing on every live file. Invalid values degrade instead of failing the + // whole parse — a typo must never trip backup-and-defaults and wipe providers. + schemaVersion: z.number().int().positive().optional().catch(undefined), providerContextCaps: z.record(z.string(), z.number().int().positive()).optional(), contextCapValue: z.number().int().positive().optional(), multiAgentGuidanceEnabled: z.boolean().optional(), @@ -1192,6 +1198,18 @@ function warnDegradedNativeSubagentConfig(rawParsed: unknown, config: OcxConfig) } } +/** + * Live files have no `schemaVersion`. Absence (or a non-numeric typo the schema + * already caught) means generation 1. Mutates the in-memory object only — callers + * must not treat this as a license to rewrite the file on read. + */ +export function applySchemaVersionDefault(config: OcxConfig): OcxConfig { + if (typeof config.schemaVersion !== "number") { + config.schemaVersion = CONFIG_SCHEMA_VERSION; + } + return config; +} + export function loadConfig(): OcxConfig { const dir = getConfigDir(); const configPath = getConfigPath(); @@ -1211,7 +1229,9 @@ export function loadConfig(): OcxConfig { warnDegradedHostname(parsed, config); warnDegradedClaudeSubagentEffort(parsed); warnDegradedNativeSubagentConfig(parsed, config); - return normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed); + return applySchemaVersionDefault( + normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed), + ); } // Schema validation failed — merge defaults into the raw object instead of // discarding it entirely, so pool accounts and providers survive a missing @@ -1229,7 +1249,9 @@ export function loadConfig(): OcxConfig { warnDegradedHostname(parsed, config); warnDegradedClaudeSubagentEffort(parsed); warnDegradedNativeSubagentConfig(parsed, config); - return normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed); + return applySchemaVersionDefault( + normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed), + ); } // Merge couldn't fix it — truly broken config warnAndBackupInvalidConfig(configPath, result.error); @@ -1265,7 +1287,9 @@ function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): Conf // ordinary save persists the normalized absence. const syncDisabledReason = nativeSubagentSyncDisabledReason(config, rawParsed); const rawEffort = rawClaudeSubagentEffort(rawParsed); - const normalized = normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, rawParsed), rawParsed); + const normalized = applySchemaVersionDefault( + normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, rawParsed), rawParsed), + ); const warnings = configPlaceholderWarnings(normalized); if (rawEffort !== undefined && !isClaudeSubagentEffort(rawEffort)) { warnings.push(`claudeCode.subagentEffort ignored: expected one of ${CLAUDE_SUBAGENT_EFFORTS.join(", ")}`); @@ -1334,7 +1358,7 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx const boundaryError = blankHostnameError(value) ?? claudeSubagentEffortError(value); if (boundaryError) return { ok: false, error: boundaryError }; const result = configSchema.safeParse(value); - if (result.success) return { ok: true, config: result.data as OcxConfig }; + if (result.success) return { ok: true, config: applySchemaVersionDefault(result.data as OcxConfig) }; return { ok: false, error: schemaDiagnosticsError(result.error) }; } @@ -1387,6 +1411,7 @@ function persistableConfig(config: OcxConfig): OcxConfig { } export function saveConfig(config: OcxConfig): void { + applySchemaVersionDefault(config); const dir = getConfigDir(); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true, mode: 0o700 }); @@ -1691,6 +1716,7 @@ export function getDefaultConfig(): OcxConfig { // OpenAI shape. Mark them as such so startup does not mistake them for a // legacy config and collide with an immutable backup from an earlier setup. openaiProviderTierVersion: OPENAI_PROVIDER_TIER_VERSION, + schemaVersion: CONFIG_SCHEMA_VERSION, providers: { openai: { adapter: "openai-responses", diff --git a/src/types.ts b/src/types.ts index 35648df4..aaaef6a2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -551,6 +551,12 @@ export interface OcxConfig { keyPoolCooldowns?: Record>; /** OpenAI provider-contract migration marker (v2 = single `openai` provider with account mode). */ openaiProviderTierVersion?: 1 | 2; + /** + * Datastore schema generation. Absent on disk means 1 (every live Azure file today). + * `loadConfig` applies that default in memory and does not rewrite the file. + * `saveConfig` stamps {@link CONFIG_SCHEMA_VERSION} on the first ordinary write. + */ + schemaVersion?: number; /** Claude Code inbound + launcher settings. */ claudeCode?: OcxClaudeCodeConfig; /** @@ -1289,6 +1295,9 @@ export type CodexAccountMode = "direct" | "pool"; export const OPENAI_PROVIDER_TIER_VERSION = 2 as const; +/** On-disk config schema generation. Absent means 1. Bump when a rewrite needs a gate. */ +export const CONFIG_SCHEMA_VERSION = 1; + /** * Wires that a per-model `modelAdapters` override may select. * diff --git a/tests/schema-version.test.ts b/tests/schema-version.test.ts new file mode 100644 index 00000000..885acac0 --- /dev/null +++ b/tests/schema-version.test.ts @@ -0,0 +1,84 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + CONFIG_SCHEMA_VERSION, + getConfigPath, + getDefaultConfig, + loadConfig, + readConfigDiagnostics, + saveConfig, +} from "../src/config"; +import type { OcxConfig } from "../src/types"; + +let testDir = ""; + +beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), "ocx-schema-version-")); + process.env.OPENCODEX_HOME = testDir; +}); + +afterEach(() => { + delete process.env.OPENCODEX_HOME; + if (testDir && existsSync(testDir)) rmSync(testDir, { recursive: true, force: true }); + testDir = ""; +}); + +function writeLiveShapedConfig(overrides: Record = {}): string { + const body = JSON.stringify({ + port: 12345, + providers: { + custom: { adapter: "openai-chat", baseUrl: "https://example.test/v1" }, + }, + defaultProvider: "custom", + ...overrides, + }, null, 2); + writeFileSync(getConfigPath(), `${body}\n`, "utf-8"); + return readFileSync(getConfigPath(), "utf-8"); +} + +describe("schemaVersion", () => { + test("load without schemaVersion succeeds and does not rewrite the file", () => { + const before = writeLiveShapedConfig(); + expect(JSON.parse(before).schemaVersion).toBeUndefined(); + + const loaded = loadConfig(); + expect(loaded.schemaVersion).toBe(CONFIG_SCHEMA_VERSION); + expect(loaded.port).toBe(12345); + expect(loaded.defaultProvider).toBe("custom"); + expect(readFileSync(getConfigPath(), "utf-8")).toBe(before); + }); + + test("readConfigDiagnostics treats missing schemaVersion as 1 without rewriting", () => { + const before = writeLiveShapedConfig(); + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.source).toBe("file"); + expect(diagnostics.error).toBeNull(); + expect(diagnostics.config.schemaVersion).toBe(1); + expect(readFileSync(getConfigPath(), "utf-8")).toBe(before); + }); + + test("save writes schemaVersion 1 when the field is absent", () => { + const config = { + port: 10100, + defaultProvider: "openai", + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + }, + } as OcxConfig; + expect(config.schemaVersion).toBeUndefined(); + saveConfig(config); + expect(config.schemaVersion).toBe(1); + const written = JSON.parse(readFileSync(getConfigPath(), "utf-8")) as { schemaVersion?: number }; + expect(written.schemaVersion).toBe(1); + }); + + test("getDefaultConfig stamps schemaVersion 1", () => { + expect(getDefaultConfig().schemaVersion).toBe(1); + }); +}); From 02252a3108b689cb15c9412d29584d0994597941 Mon Sep 17 00:00:00 2001 From: chefgroep Date: Sun, 23 Aug 2026 14:53:44 +0200 Subject: [PATCH 3/5] feat(state): add timestamped OCX home backup with sha256 manifest Production has no scheduled OCX datastore backup. This copies the home files into $OPENCODEX_HOME/backups/, refuses overwrite, and records absences so a missing auth.json (the live case) is not a failure. Co-authored-by: Cursor --- scripts/backup-state.ts | 180 +++++++++++++++++++++++++++++++++++++ tests/backup-state.test.ts | 136 ++++++++++++++++++++++++++++ 2 files changed, 316 insertions(+) create mode 100644 scripts/backup-state.ts create mode 100644 tests/backup-state.test.ts diff --git a/scripts/backup-state.ts b/scripts/backup-state.ts new file mode 100644 index 00000000..13008c3e --- /dev/null +++ b/scripts/backup-state.ts @@ -0,0 +1,180 @@ +#!/usr/bin/env bun +/** + * Copy the OCX home state files into a timestamped directory with a sha256 + * manifest. Default destination: `$OPENCODEX_HOME/backups/`. + * + * Copies config.json as-is. Inline provider API keys in that file are therefore + * present in the backup — treat every backup as secret-bearing. + * + * Missing optional files (auth.json, codex-accounts.json, account-runtime.json) + * are recorded as absent and do not fail the run. That is the live Azure case. + * + * Usage: + * bun scripts/backup-state.ts + * bun scripts/backup-state.ts --home --dest + */ +import { createHash } from "node:crypto"; +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + statSync, + writeFileSync, +} from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { getConfigDir } from "../src/config"; +import { redactSecrets, redactUserPath } from "../src/lib/redact"; + +export const BACKUP_MANIFEST_NAME = "backup.manifest.json"; +export const BACKUP_STATE_FILES = [ + "config.json", + "usage.jsonl", + "auth.json", + "codex-accounts.json", + "account-runtime.json", +] as const; + +export type BackupStateFile = (typeof BACKUP_STATE_FILES)[number]; + +export interface BackupFileFingerprint { + name: BackupStateFile; + present: boolean; + size: number | null; + sha256: string | null; +} + +export interface BackupManifest { + version: 1; + createdAt: string; + sourceDir: string; + destDir: string; + files: BackupFileFingerprint[]; +} + +export interface BackupOptions { + home: string; + dest?: string; + now?: Date; +} + +export interface BackupResult { + dest: string; + manifest: BackupManifest; + copied: BackupStateFile[]; + absent: BackupStateFile[]; +} + +export class BackupError extends Error { + constructor(message: string, readonly exitCode = 2) { + super(message); + this.name = "BackupError"; + } +} + +function sha256Bytes(bytes: Uint8Array | string): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +export function backupIsoStamp(date = new Date()): string { + return date.toISOString().replace(/[:.]/g, "-"); +} + +export function defaultBackupDest(home: string, now = new Date()): string { + return join(resolve(home), "backups", backupIsoStamp(now)); +} + +export function fingerprintBackupFile(dir: string, name: BackupStateFile): BackupFileFingerprint { + const path = join(dir, name); + if (!existsSync(path) || !statSync(path).isFile()) { + return { name, present: false, size: null, sha256: null }; + } + const bytes = readFileSync(path); + return { name, present: true, size: bytes.byteLength, sha256: sha256Bytes(bytes) }; +} + +export function formatBackupReport(result: BackupResult): string { + const lines = [ + `backed up ${result.copied.length} file(s) dest=${redactUserPath(result.dest)}`, + `source=${redactUserPath(result.manifest.sourceDir)} createdAt=${result.manifest.createdAt}`, + ]; + for (const file of result.manifest.files) { + if (file.present) { + lines.push(`${file.name} present size=${file.size} sha256=${file.sha256}`); + } else { + lines.push(`${file.name} absent`); + } + } + return `${lines.join("\n")}\n`; +} + +function takeOption(args: string[], flag: string): string | undefined { + const at = args.indexOf(flag); + if (at === -1) return undefined; + const value = args[at + 1]; + if (!value || value.startsWith("--")) throw new BackupError(`${flag} requires a value`); + args.splice(at, 2); + return value; +} + +export function parseBackupArgs(argv: string[]): BackupOptions { + const args = [...argv]; + const options: BackupOptions = { + home: takeOption(args, "--home") ?? process.env.OPENCODEX_HOME ?? getConfigDir(), + dest: takeOption(args, "--dest"), + }; + if (args.length) throw new BackupError(`unexpected argument(s): ${args.join(" ")}`); + return options; +} + +export function backupState(options: BackupOptions): BackupResult { + const home = resolve(options.home); + if (!existsSync(home) || !statSync(home).isDirectory()) { + throw new BackupError(`OCX home not found: ${redactUserPath(home)}`); + } + const dest = resolve(options.dest ?? defaultBackupDest(home, options.now)); + if (existsSync(dest)) { + throw new BackupError(`refusing to overwrite existing backup: ${redactUserPath(dest)}`); + } + + mkdirSync(dirname(dest), { recursive: true, mode: 0o700 }); + mkdirSync(dest, { recursive: false, mode: 0o700 }); + + const copied: BackupStateFile[] = []; + const absent: BackupStateFile[] = []; + const files = BACKUP_STATE_FILES.map((name) => { + const source = join(home, name); + const before = fingerprintBackupFile(home, name); + if (!before.present) { + absent.push(name); + return before; + } + copyFileSync(source, join(dest, name)); + copied.push(name); + return fingerprintBackupFile(dest, name); + }); + + const manifest: BackupManifest = { + version: 1, + createdAt: (options.now ?? new Date()).toISOString(), + sourceDir: home, + destDir: dest, + files, + }; + writeFileSync(join(dest, BACKUP_MANIFEST_NAME), `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 }); + return { dest, manifest, copied, absent }; +} + +if (import.meta.main) { + try { + const result = backupState(parseBackupArgs(process.argv.slice(2))); + process.stdout.write(String(redactSecrets(formatBackupReport(result)))); + process.exit(0); + } catch (error) { + const message = error instanceof BackupError + ? error.message + : error instanceof Error ? error.message : String(error); + process.stderr.write(`${redactSecrets(message)}\n`); + process.exit(error instanceof BackupError ? error.exitCode : 1); + } +} diff --git a/tests/backup-state.test.ts b/tests/backup-state.test.ts new file mode 100644 index 00000000..9fffcc67 --- /dev/null +++ b/tests/backup-state.test.ts @@ -0,0 +1,136 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + BACKUP_MANIFEST_NAME, + BACKUP_STATE_FILES, + BackupError, + backupState, + formatBackupReport, + parseBackupArgs, +} from "../scripts/backup-state"; + +const dirs: string[] = []; + +afterEach(() => { + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + delete process.env.OPENCODEX_HOME; +}); + +function scratch(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + dirs.push(dir); + return dir; +} + +function writeJson(dir: string, name: string, value: unknown): void { + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, name), `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +function seedHome(dir: string, opts: { auth?: boolean } = {}): void { + writeJson(dir, "config.json", { + port: 10100, + defaultProvider: "deepseek", + providers: { + deepseek: { + adapter: "openai-chat", + baseUrl: "https://deepseek.example.test/v1", + authMode: "key", + apiKey: "sk-inline-secret-do-not-print", + }, + }, + }); + writeFileSync( + join(dir, "usage.jsonl"), + `${JSON.stringify({ requestId: "live-1", timestamp: 1_700_000_000_000, provider: "deepseek", status: 200 })}\n`, + ); + if (opts.auth) { + writeJson(dir, "auth.json", { + "google-antigravity": { + activeAccountId: "acct-1", + accounts: [{ id: "acct-1", credential: { access: "aaaa".repeat(20), refresh: "bbbb".repeat(20) } }], + }, + }); + } +} + +describe("backup-state", () => { + test("creates a sha256 manifest and copies present files including inline keys", () => { + const home = scratch("ocx-backup-home-"); + const dest = join(scratch("ocx-backup-dest-"), "2026-08-23T00-00-00-000Z"); + seedHome(home, { auth: true }); + writeJson(home, "codex-accounts.json", { accounts: [] }); + writeJson(home, "account-runtime.json", { needsReauth: [] }); + + const result = backupState({ home, dest }); + expect(result.copied).toEqual([...BACKUP_STATE_FILES]); + expect(result.absent).toEqual([]); + expect(existsSync(join(dest, BACKUP_MANIFEST_NAME))).toBe(true); + + const manifest = JSON.parse(readFileSync(join(dest, BACKUP_MANIFEST_NAME), "utf8")) as { + version: number; + files: Array<{ name: string; present: boolean; sha256: string | null }>; + }; + expect(manifest.version).toBe(1); + expect(manifest.files).toHaveLength(BACKUP_STATE_FILES.length); + for (const file of BACKUP_STATE_FILES) { + const row = manifest.files.find((entry) => entry.name === file); + expect(row?.present).toBe(true); + expect(row?.sha256).toMatch(/^[0-9a-f]{64}$/); + expect(readFileSync(join(dest, file), "utf8")).toBe(readFileSync(join(home, file), "utf8")); + } + expect(readFileSync(join(dest, "config.json"), "utf8")).toContain("sk-inline-secret-do-not-print"); + + const report = formatBackupReport(result); + expect(report).toContain("backed up 5 file(s)"); + expect(report).not.toContain("sk-inline-secret-do-not-print"); + expect(report).not.toContain("aaaa".repeat(20)); + }); + + test("refuses to overwrite an existing destination", () => { + const home = scratch("ocx-backup-ow-home-"); + const dest = join(scratch("ocx-backup-ow-dest-"), "already"); + seedHome(home); + backupState({ home, dest }); + expect(() => backupState({ home, dest })).toThrow(BackupError); + expect(() => backupState({ home, dest })).toThrow(/refusing to overwrite/); + }); + + test("does not fail when auth.json is missing (the live case)", () => { + const home = scratch("ocx-backup-live-"); + const dest = join(scratch("ocx-backup-live-dest-"), "2026-08-23T01-00-00-000Z"); + seedHome(home); + expect(existsSync(join(home, "auth.json"))).toBe(false); + + const result = backupState({ home, dest }); + expect(result.copied).toEqual(["config.json", "usage.jsonl"]); + expect(result.absent).toEqual(["auth.json", "codex-accounts.json", "account-runtime.json"]); + expect(existsSync(join(dest, "config.json"))).toBe(true); + expect(existsSync(join(dest, "usage.jsonl"))).toBe(true); + expect(existsSync(join(dest, "auth.json"))).toBe(false); + + const manifest = JSON.parse(readFileSync(join(dest, BACKUP_MANIFEST_NAME), "utf8")) as { + files: Array<{ name: string; present: boolean; sha256: string | null }>; + }; + expect(manifest.files.find((file) => file.name === "auth.json")).toEqual({ + name: "auth.json", + present: false, + size: null, + sha256: null, + }); + }); + + test("defaults dest to $OPENCODEX_HOME/backups/ and parses --home/--dest", () => { + const home = scratch("ocx-backup-cli-"); + const dest = join(home, "explicit-dest"); + expect(parseBackupArgs(["--home", home, "--dest", dest])).toEqual({ home, dest }); + process.env.OPENCODEX_HOME = home; + const parsed = parseBackupArgs([]); + expect(parsed.home).toBe(home); + expect(parsed.dest).toBeUndefined(); + }); +}); From f1e846a21c80b2d1aa62fce30ba9f44a4f8f7439 Mon Sep 17 00:00:00 2001 From: chefadmin-netizen Date: Tue, 8 Sep 2026 14:32:14 +0200 Subject: [PATCH 4/5] test(backup): use non-token-shaped fixture values for privacy scan --- tests/backup-state.test.ts | 55 ++++++++++++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 11 deletions(-) diff --git a/tests/backup-state.test.ts b/tests/backup-state.test.ts index 9fffcc67..e1f68ebc 100644 --- a/tests/backup-state.test.ts +++ b/tests/backup-state.test.ts @@ -1,5 +1,12 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -40,7 +47,7 @@ function seedHome(dir: string, opts: { auth?: boolean } = {}): void { adapter: "openai-chat", baseUrl: "https://deepseek.example.test/v1", authMode: "key", - apiKey: "sk-inline-secret-do-not-print", + apiKey: "tokenless-fixture-value", }, }, }); @@ -52,7 +59,15 @@ function seedHome(dir: string, opts: { auth?: boolean } = {}): void { writeJson(dir, "auth.json", { "google-antigravity": { activeAccountId: "acct-1", - accounts: [{ id: "acct-1", credential: { access: "aaaa".repeat(20), refresh: "bbbb".repeat(20) } }], + accounts: [ + { + id: "acct-1", + credential: { + access: "aaaa".repeat(20), + refresh: "bbbb".repeat(20), + }, + }, + ], }, }); } @@ -71,7 +86,9 @@ describe("backup-state", () => { expect(result.absent).toEqual([]); expect(existsSync(join(dest, BACKUP_MANIFEST_NAME))).toBe(true); - const manifest = JSON.parse(readFileSync(join(dest, BACKUP_MANIFEST_NAME), "utf8")) as { + const manifest = JSON.parse( + readFileSync(join(dest, BACKUP_MANIFEST_NAME), "utf8"), + ) as { version: number; files: Array<{ name: string; present: boolean; sha256: string | null }>; }; @@ -81,13 +98,17 @@ describe("backup-state", () => { const row = manifest.files.find((entry) => entry.name === file); expect(row?.present).toBe(true); expect(row?.sha256).toMatch(/^[0-9a-f]{64}$/); - expect(readFileSync(join(dest, file), "utf8")).toBe(readFileSync(join(home, file), "utf8")); + expect(readFileSync(join(dest, file), "utf8")).toBe( + readFileSync(join(home, file), "utf8"), + ); } - expect(readFileSync(join(dest, "config.json"), "utf8")).toContain("sk-inline-secret-do-not-print"); + expect(readFileSync(join(dest, "config.json"), "utf8")).toContain( + "tokenless-fixture-value", + ); const report = formatBackupReport(result); expect(report).toContain("backed up 5 file(s)"); - expect(report).not.toContain("sk-inline-secret-do-not-print"); + expect(report).not.toContain("tokenless-fixture-value"); expect(report).not.toContain("aaaa".repeat(20)); }); @@ -102,18 +123,27 @@ describe("backup-state", () => { test("does not fail when auth.json is missing (the live case)", () => { const home = scratch("ocx-backup-live-"); - const dest = join(scratch("ocx-backup-live-dest-"), "2026-08-23T01-00-00-000Z"); + const dest = join( + scratch("ocx-backup-live-dest-"), + "2026-08-23T01-00-00-000Z", + ); seedHome(home); expect(existsSync(join(home, "auth.json"))).toBe(false); const result = backupState({ home, dest }); expect(result.copied).toEqual(["config.json", "usage.jsonl"]); - expect(result.absent).toEqual(["auth.json", "codex-accounts.json", "account-runtime.json"]); + expect(result.absent).toEqual([ + "auth.json", + "codex-accounts.json", + "account-runtime.json", + ]); expect(existsSync(join(dest, "config.json"))).toBe(true); expect(existsSync(join(dest, "usage.jsonl"))).toBe(true); expect(existsSync(join(dest, "auth.json"))).toBe(false); - const manifest = JSON.parse(readFileSync(join(dest, BACKUP_MANIFEST_NAME), "utf8")) as { + const manifest = JSON.parse( + readFileSync(join(dest, BACKUP_MANIFEST_NAME), "utf8"), + ) as { files: Array<{ name: string; present: boolean; sha256: string | null }>; }; expect(manifest.files.find((file) => file.name === "auth.json")).toEqual({ @@ -127,7 +157,10 @@ describe("backup-state", () => { test("defaults dest to $OPENCODEX_HOME/backups/ and parses --home/--dest", () => { const home = scratch("ocx-backup-cli-"); const dest = join(home, "explicit-dest"); - expect(parseBackupArgs(["--home", home, "--dest", dest])).toEqual({ home, dest }); + expect(parseBackupArgs(["--home", home, "--dest", dest])).toEqual({ + home, + dest, + }); process.env.OPENCODEX_HOME = home; const parsed = parseBackupArgs([]); expect(parsed.home).toBe(home); From ddd0f81db20fb2c0cbdd6ea7c1d11512e753b4b0 Mon Sep 17 00:00:00 2001 From: chefadmin-netizen Date: Tue, 8 Sep 2026 14:50:44 +0200 Subject: [PATCH 5/5] fix(state): derive pool id from provider name, not the api key CodeQL js/insufficient-password-hash taint: hashing the raw apiKey (even truncated to an 8-char label) feeds secret material into a fast unsalted hash. The id is a stable label, not a credential store: hash the provider name instead. Same length, deterministic, idempotent; test contract (8-char id) unchanged, 6881 pass / 0 fail. --- scripts/state-reconcile.ts | 656 ++++++++++++++++++++++++++++--------- 1 file changed, 498 insertions(+), 158 deletions(-) diff --git a/scripts/state-reconcile.ts b/scripts/state-reconcile.ts index d23213d5..c09d6d8b 100644 --- a/scripts/state-reconcile.ts +++ b/scripts/state-reconcile.ts @@ -49,7 +49,8 @@ export const STATE_FILES = [ "telemetry-id.txt", ] as const; -const SECRET_KEY = /^(apiKey|key|access|refresh|accessToken|refreshToken|idToken|token|password|clientSecret|cookie)$/i; +const SECRET_KEY = + /^(apiKey|key|access|refresh|accessToken|refreshToken|idToken|token|password|clientSecret|cookie)$/i; export type ReconcileMode = "dry-run" | "apply" | "promote" | "rollback"; @@ -164,7 +165,10 @@ export interface ReconcileReport { } export class ReconcileError extends Error { - constructor(message: string, readonly exitCode = 2) { + constructor( + message: string, + readonly exitCode = 2, + ) { super(message); this.name = "ReconcileError"; } @@ -176,20 +180,25 @@ function isEnvRef(value: string): boolean { export function secretShape(value: unknown): SecretShape { if (typeof value !== "string") return { present: false }; - return { present: value.length > 0, length: value.length, envRef: isEnvRef(value) }; + return { + present: value.length > 0, + length: value.length, + envRef: isEnvRef(value), + }; } export function redactForLog(value: unknown, key = ""): unknown { if (SECRET_KEY.test(key) && typeof value === "string") { - return value ? `` : value; + return value + ? `` + : value; } if (Array.isArray(value)) return value.map((item) => redactForLog(item)); if (value && typeof value === "object") { return Object.fromEntries( - Object.entries(value as Record).map(([child, childValue]) => [ - child, - redactForLog(childValue, child), - ]), + Object.entries(value as Record).map( + ([child, childValue]) => [child, redactForLog(childValue, child)], + ), ); } return value; @@ -205,20 +214,33 @@ export function fingerprintFile(dir: string, name: string): FileFingerprint { return { name, present: false, size: null, sha256: null }; } const bytes = readFileSync(path); - return { name, present: true, size: bytes.byteLength, sha256: sha256Bytes(bytes) }; + return { + name, + present: true, + size: bytes.byteLength, + sha256: sha256Bytes(bytes), + }; } -function readJson(path: string): { ok: true; value: unknown } | { ok: false; error: string } { +function readJson( + path: string, +): { ok: true; value: unknown } | { ok: false; error: string } { try { - return { ok: true, value: JSON.parse(readFileSync(path, "utf8").replace(/^\uFEFF/, "")) }; + return { + ok: true, + value: JSON.parse(readFileSync(path, "utf8").replace(/^\uFEFF/, "")), + }; } catch (error) { - return { ok: false, error: error instanceof Error ? error.message : String(error) }; + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + }; } } function asRecord(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) - ? value as Record + ? (value as Record) : null; } @@ -230,19 +252,27 @@ function inventoryProviders(config: Record): ProviderRecord[] { return { name, adapter: typeof provider.adapter === "string" ? provider.adapter : null, - authMode: typeof provider.authMode === "string" ? provider.authMode : null, + authMode: + typeof provider.authMode === "string" ? provider.authMode : null, disabled: provider.disabled === true, - hasApiKey: typeof provider.apiKey === "string" && provider.apiKey.length > 0, + hasApiKey: + typeof provider.apiKey === "string" && provider.apiKey.length > 0, apiKey: secretShape(provider.apiKey), apiKeyPoolIds: pool - .map((entry) => (asRecord(entry)?.id)) + .map((entry) => asRecord(entry)?.id) .filter((id): id is string => typeof id === "string"), - defaultModel: typeof provider.defaultModel === "string" ? provider.defaultModel : null, + defaultModel: + typeof provider.defaultModel === "string" + ? provider.defaultModel + : null, }; }); } -function inventoryAuth(dir: string): { providers: string[]; accounts: OAuthAccountRecord[] } { +function inventoryAuth(dir: string): { + providers: string[]; + accounts: OAuthAccountRecord[]; +} { const parsed = readJson(join(dir, "auth.json")); if (!parsed.ok) return { providers: [], accounts: [] }; const store = asRecord(parsed.value); @@ -259,20 +289,27 @@ function inventoryAuth(dir: string): { providers: string[]; accounts: OAuthAccou provider, id: typeof account.id === "string" ? account.id : null, needsReauth: account.needsReauth === true, - hasRefresh: typeof cred.refresh === "string" && cred.refresh.length > 0, + hasRefresh: + typeof cred.refresh === "string" && cred.refresh.length > 0, hasAccess: typeof cred.access === "string" && cred.access.length > 0, - refreshLen: typeof cred.refresh === "string" ? cred.refresh.length : 0, + refreshLen: + typeof cred.refresh === "string" ? cred.refresh.length : 0, accessLen: typeof cred.access === "string" ? cred.access.length : 0, }); } - } else if (typeof value.refresh === "string" || typeof value.access === "string") { + } else if ( + typeof value.refresh === "string" || + typeof value.access === "string" + ) { accounts.push({ provider, id: typeof value.accountId === "string" ? value.accountId : "legacy", needsReauth: false, - hasRefresh: typeof value.refresh === "string" && value.refresh.length > 0, + hasRefresh: + typeof value.refresh === "string" && value.refresh.length > 0, hasAccess: typeof value.access === "string" && value.access.length > 0, - refreshLen: typeof value.refresh === "string" ? value.refresh.length : 0, + refreshLen: + typeof value.refresh === "string" ? value.refresh.length : 0, accessLen: typeof value.access === "string" ? value.access.length : 0, }); } @@ -280,10 +317,16 @@ function inventoryAuth(dir: string): { providers: string[]; accounts: OAuthAccou return { providers: Object.keys(store).sort(), accounts }; } -function inventoryUsage(dir: string): { lines: number; requestIds: string[]; providers: string[] } { +function inventoryUsage(dir: string): { + lines: number; + requestIds: string[]; + providers: string[]; +} { const path = join(dir, "usage.jsonl"); if (!existsSync(path)) return { lines: 0, requestIds: [], providers: [] }; - const rows = readFileSync(path, "utf8").split(/\r?\n/).filter((line) => line.trim()); + const rows = readFileSync(path, "utf8") + .split(/\r?\n/) + .filter((line) => line.trim()); const requestIds: string[] = []; const providers = new Set(); for (const line of rows) { @@ -300,7 +343,9 @@ function inventoryUsage(dir: string): { lines: number; requestIds: string[]; pro export function inventoryStore(dir: string, label: string): StoreInventory { const configPath = join(dir, "config.json"); - const parsed = existsSync(configPath) ? readJson(configPath) : { ok: false as const, error: "missing" }; + const parsed = existsSync(configPath) + ? readJson(configPath) + : { ok: false as const, error: "missing" }; const config = parsed.ok ? asRecord(parsed.value) : null; const auth = inventoryAuth(dir); const usage = inventoryUsage(dir); @@ -311,46 +356,61 @@ export function inventoryStore(dir: string, label: string): StoreInventory { return { id: typeof entry.id === "string" ? entry.id : null, name: typeof entry.name === "string" ? entry.name : null, - createdAt: typeof entry.createdAt === "string" ? entry.createdAt : null, + createdAt: + typeof entry.createdAt === "string" ? entry.createdAt : null, key: secretShape(entry.key), }; }) : []; - const combos = Object.entries(asRecord(config?.combos) ?? {}).map(([id, raw]) => { - const combo = asRecord(raw) ?? {}; - const targets = Array.isArray(combo.targets) ? combo.targets : []; - return { - id, - targetProviders: targets - .map((target) => asRecord(target)?.provider) - .filter((name): name is string => typeof name === "string"), - targetModels: targets - .map((target) => asRecord(target)?.model) - .filter((name): name is string => typeof name === "string"), - strategy: typeof combo.strategy === "string" ? combo.strategy : null, - }; - }); + const combos = Object.entries(asRecord(config?.combos) ?? {}).map( + ([id, raw]) => { + const combo = asRecord(raw) ?? {}; + const targets = Array.isArray(combo.targets) ? combo.targets : []; + return { + id, + targetProviders: targets + .map((target) => asRecord(target)?.provider) + .filter((name): name is string => typeof name === "string"), + targetModels: targets + .map((target) => asRecord(target)?.model) + .filter((name): name is string => typeof name === "string"), + strategy: typeof combo.strategy === "string" ? combo.strategy : null, + }; + }, + ); return { label, path: dir, files: STATE_FILES.map((name) => fingerprintFile(dir, name)), - schemaVersion: typeof config?.schemaVersion === "number" - ? config.schemaVersion - : typeof config?.schema_version === "number" ? config.schema_version : null, - openaiProviderTierVersion: typeof config?.openaiProviderTierVersion === "number" - ? config.openaiProviderTierVersion - : null, + schemaVersion: + typeof config?.schemaVersion === "number" + ? config.schemaVersion + : typeof config?.schema_version === "number" + ? config.schema_version + : null, + openaiProviderTierVersion: + typeof config?.openaiProviderTierVersion === "number" + ? config.openaiProviderTierVersion + : null, providers, apiKeys, combos, disabledModels: Array.isArray(config?.disabledModels) - ? config.disabledModels.filter((id): id is string => typeof id === "string") + ? config.disabledModels.filter( + (id): id is string => typeof id === "string", + ) : [], subagentModels: Array.isArray(config?.subagentModels) - ? config.subagentModels.filter((id): id is string => typeof id === "string") + ? config.subagentModels.filter( + (id): id is string => typeof id === "string", + ) : [], - providerContextCaps: Object.keys(asRecord(config?.providerContextCaps) ?? {}).sort(), - providerCooldowns: Object.keys(asRecord(config?.providerCooldowns) ?? {}).sort(), + providerContextCaps: Object.keys( + asRecord(config?.providerContextCaps) ?? {}, + ).sort(), + providerCooldowns: Object.keys( + asRecord(config?.providerCooldowns) ?? {}, + ).sort(), oauthAccounts: auth.accounts, oauthProviders: auth.providers, usageLines: usage.lines, @@ -360,7 +420,10 @@ export function inventoryStore(dir: string, label: string): StoreInventory { }; } -function setDiff(current: Iterable, legacy: Iterable): { missing: string[]; extra: string[] } { +function setDiff( + current: Iterable, + legacy: Iterable, +): { missing: string[]; extra: string[] } { const have = new Set(current); const want = new Set(legacy); return { @@ -369,7 +432,10 @@ function setDiff(current: Iterable, legacy: Iterable): { missing }; } -export function diffDomains(current: StoreInventory, legacy: StoreInventory[]): DomainDiff[] { +export function diffDomains( + current: StoreInventory, + legacy: StoreInventory[], +): DomainDiff[] { const domain = ( name: string, currentIds: string[], @@ -378,46 +444,100 @@ export function diffDomains(current: StoreInventory, legacy: StoreInventory[]): ): DomainDiff => ({ domain: name, currentCount: currentIds.length, - legacyCounts: Object.fromEntries(legacy.map((store) => [store.label, legacyIds(store).length])), - missingFromCurrent: [...new Set(legacy.flatMap((store) => setDiff(currentIds, legacyIds(store)).missing))].sort(), + legacyCounts: Object.fromEntries( + legacy.map((store) => [store.label, legacyIds(store).length]), + ), + missingFromCurrent: [ + ...new Set( + legacy.flatMap( + (store) => setDiff(currentIds, legacyIds(store)).missing, + ), + ), + ].sort(), conflicts: conflictIds, }); - const providerConflicts = [...new Set(legacy.flatMap((store) => { - const conflicts: string[] = []; - for (const left of current.providers) { - const right = store.providers.find((row) => row.name === left.name); - if (!right) continue; - if (left.adapter !== right.adapter || left.authMode !== right.authMode || left.disabled !== right.disabled) { - conflicts.push(left.name); - } - } - return conflicts; - }))]; + const providerConflicts = [ + ...new Set( + legacy.flatMap((store) => { + const conflicts: string[] = []; + for (const left of current.providers) { + const right = store.providers.find((row) => row.name === left.name); + if (!right) continue; + if ( + left.adapter !== right.adapter || + left.authMode !== right.authMode || + left.disabled !== right.disabled + ) { + conflicts.push(left.name); + } + } + return conflicts; + }), + ), + ]; return [ - domain("providers", current.providers.map((row) => row.name), (store) => store.providers.map((row) => row.name), providerConflicts), - domain("apiKeys", current.apiKeys.map((row) => row.id ?? ""), (store) => store.apiKeys.map((row) => row.id ?? "")), + domain( + "providers", + current.providers.map((row) => row.name), + (store) => store.providers.map((row) => row.name), + providerConflicts, + ), + domain( + "apiKeys", + current.apiKeys.map((row) => row.id ?? ""), + (store) => store.apiKeys.map((row) => row.id ?? ""), + ), domain( "apiKeyPool", - current.providers.flatMap((row) => row.apiKeyPoolIds.map((id) => `${row.name}:${id}`)), - (store) => store.providers.flatMap((row) => row.apiKeyPoolIds.map((id) => `${row.name}:${id}`)), + current.providers.flatMap((row) => + row.apiKeyPoolIds.map((id) => `${row.name}:${id}`), + ), + (store) => + store.providers.flatMap((row) => + row.apiKeyPoolIds.map((id) => `${row.name}:${id}`), + ), ), domain( "oauthAccounts", current.oauthAccounts.map((row) => `${row.provider}:${row.id ?? ""}`), - (store) => store.oauthAccounts.map((row) => `${row.provider}:${row.id ?? ""}`), + (store) => + store.oauthAccounts.map((row) => `${row.provider}:${row.id ?? ""}`), + ), + domain( + "combos", + current.combos.map((row) => row.id), + (store) => store.combos.map((row) => row.id), + ), + domain( + "disabledModels", + current.disabledModels, + (store) => store.disabledModels, + ), + domain( + "subagentModels", + current.subagentModels, + (store) => store.subagentModels, + ), + domain( + "providerContextCaps", + current.providerContextCaps, + (store) => store.providerContextCaps, + ), + domain( + "providerCooldowns", + current.providerCooldowns, + (store) => store.providerCooldowns, ), - domain("combos", current.combos.map((row) => row.id), (store) => store.combos.map((row) => row.id)), - domain("disabledModels", current.disabledModels, (store) => store.disabledModels), - domain("subagentModels", current.subagentModels, (store) => store.subagentModels), - domain("providerContextCaps", current.providerContextCaps, (store) => store.providerContextCaps), - domain("providerCooldowns", current.providerCooldowns, (store) => store.providerCooldowns), domain("usage", current.usageRequestIds, (store) => store.usageRequestIds), ]; } -export function createBackup(currentDir: string, backupDir: string): BackupManifest { +export function createBackup( + currentDir: string, + backupDir: string, +): BackupManifest { mkdirSync(backupDir, { recursive: true, mode: 0o700 }); const files = STATE_FILES.map((name) => { const source = join(currentDir, name); @@ -431,27 +551,54 @@ export function createBackup(currentDir: string, backupDir: string): BackupManif sourceDir: resolve(currentDir), files, }; - writeFileSync(join(backupDir, BACKUP_MANIFEST_NAME), `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 }); + writeFileSync( + join(backupDir, BACKUP_MANIFEST_NAME), + `${JSON.stringify(manifest, null, 2)}\n`, + { mode: 0o600 }, + ); return manifest; } -export function verifyBackup(currentDir: string, backupDir: string): { ok: boolean; detail: string; manifest: BackupManifest | null } { +export function verifyBackup( + currentDir: string, + backupDir: string, +): { ok: boolean; detail: string; manifest: BackupManifest | null } { const manifestPath = join(backupDir, BACKUP_MANIFEST_NAME); - if (!existsSync(manifestPath)) return { ok: false, detail: "backup manifest missing", manifest: null }; + if (!existsSync(manifestPath)) + return { ok: false, detail: "backup manifest missing", manifest: null }; const parsed = readJson(manifestPath); - if (!parsed.ok) return { ok: false, detail: `backup manifest unreadable: ${parsed.error}`, manifest: null }; + if (!parsed.ok) + return { + ok: false, + detail: `backup manifest unreadable: ${parsed.error}`, + manifest: null, + }; const manifest = parsed.value as BackupManifest; if (manifest.version !== 1 || !Array.isArray(manifest.files)) { - return { ok: false, detail: "backup manifest schema mismatch", manifest: null }; + return { + ok: false, + detail: "backup manifest schema mismatch", + manifest: null, + }; } for (const expected of manifest.files) { const onDisk = fingerprintFile(backupDir, expected.name); - if (expected.present !== onDisk.present || expected.sha256 !== onDisk.sha256) { - return { ok: false, detail: `backup file drifted: ${expected.name}`, manifest }; + if ( + expected.present !== onDisk.present || + expected.sha256 !== onDisk.sha256 + ) { + return { + ok: false, + detail: `backup file drifted: ${expected.name}`, + manifest, + }; } } const present = manifest.files.filter((file) => file.present); - if (present.length === 0 && fingerprintFile(currentDir, "config.json").present) { + if ( + present.length === 0 && + fingerprintFile(currentDir, "config.json").present + ) { return { ok: false, detail: "backup contains no state files", manifest }; } return { ok: true, detail: `verified ${present.length} file(s)`, manifest }; @@ -471,9 +618,16 @@ export function normalizeAuthStore(raw: unknown): Record { normalized[provider] = record; continue; } - if (typeof record.refresh === "string" && typeof record.access === "string") { - const id = typeof record.accountId === "string" ? record.accountId : "legacy"; - normalized[provider] = { activeAccountId: id, accounts: [{ id, credential: record }] }; + if ( + typeof record.refresh === "string" && + typeof record.access === "string" + ) { + const id = + typeof record.accountId === "string" ? record.accountId : "legacy"; + normalized[provider] = { + activeAccountId: id, + accounts: [{ id, credential: record }], + }; } } return normalized; @@ -481,13 +635,23 @@ export function normalizeAuthStore(raw: unknown): Record { export function normalizeConfig(raw: unknown): Record { const config = asRecord(raw) ? cloneRecord(asRecord(raw)!) : {}; - if (typeof config.schemaVersion !== "number") config.schemaVersion = STATE_RECONCILE_SCHEMA_VERSION; + if (typeof config.schemaVersion !== "number") + config.schemaVersion = STATE_RECONCILE_SCHEMA_VERSION; const providers = asRecord(config.providers) ?? {}; - for (const provider of Object.values(providers)) { + for (const [name, provider] of Object.entries(providers)) { const record = asRecord(provider); if (!record) continue; - if (!Array.isArray(record.apiKeyPool) && typeof record.apiKey === "string" && record.apiKey.length > 0) { - const id = createHash("sha256").update(record.apiKey).digest("hex").slice(0, 8); + if ( + !Array.isArray(record.apiKeyPool) && + typeof record.apiKey === "string" && + record.apiKey.length > 0 + ) { + // ponytail: id is een label, geen secret-afgeleide — hash alleen de + // provider-naam zodat CodeQL taint op apiKey verdwijnt en ids stabiel blijven. + const id = createHash("sha256") + .update(`opencodex-pool-id:${name}`) + .digest("hex") + .slice(0, 8); record.apiKeyPool = [{ id, key: record.apiKey }]; } } @@ -495,7 +659,11 @@ export function normalizeConfig(raw: unknown): Record { return config; } -function mergePrefer(current: T, incoming: T, prefer: "current" | "legacy"): T { +function mergePrefer( + current: T, + incoming: T, + prefer: "current" | "legacy", +): T { return prefer === "legacy" ? incoming : current; } @@ -509,7 +677,12 @@ export function mergeStores( const incomingProviders = asRecord(incoming.providers) ?? {}; for (const [name, provider] of Object.entries(incomingProviders)) { if (!currentProviders[name]) currentProviders[name] = cloneRecord(provider); - else currentProviders[name] = mergePrefer(currentProviders[name], cloneRecord(provider), prefer); + else + currentProviders[name] = mergePrefer( + currentProviders[name], + cloneRecord(provider), + prefer, + ); } merged.providers = currentProviders; @@ -536,14 +709,23 @@ export function mergeStores( const currentCombos = asRecord(merged.combos) ?? {}; for (const [id, combo] of Object.entries(asRecord(incoming.combos) ?? {})) { if (!currentCombos[id]) currentCombos[id] = cloneRecord(combo); - else currentCombos[id] = mergePrefer(currentCombos[id], cloneRecord(combo), prefer); + else + currentCombos[id] = mergePrefer( + currentCombos[id], + cloneRecord(combo), + prefer, + ); } merged.combos = currentCombos; - const disabled = new Set([ - ...(Array.isArray(merged.disabledModels) ? merged.disabledModels : []), - ...(Array.isArray(incoming.disabledModels) ? incoming.disabledModels : []), - ].filter((id): id is string => typeof id === "string")); + const disabled = new Set( + [ + ...(Array.isArray(merged.disabledModels) ? merged.disabledModels : []), + ...(Array.isArray(incoming.disabledModels) + ? incoming.disabledModels + : []), + ].filter((id): id is string => typeof id === "string"), + ); merged.disabledModels = [...disabled].sort(); return merged; } @@ -563,9 +745,17 @@ export function mergeAuth( merged[provider] = incomingSet; continue; } - const accounts = Array.isArray(currentSet.accounts) ? [...currentSet.accounts] : []; - const seen = new Set(accounts.map((row) => asRecord(row)?.id).filter((id): id is string => typeof id === "string")); - for (const row of Array.isArray(incomingSet.accounts) ? incomingSet.accounts : []) { + const accounts = Array.isArray(currentSet.accounts) + ? [...currentSet.accounts] + : []; + const seen = new Set( + accounts + .map((row) => asRecord(row)?.id) + .filter((id): id is string => typeof id === "string"), + ); + for (const row of Array.isArray(incomingSet.accounts) + ? incomingSet.accounts + : []) { const id = asRecord(row)?.id; if (typeof id !== "string") continue; if (!seen.has(id)) { @@ -585,7 +775,10 @@ export function mergeAuth( function mergeUsage(currentText: string, incomingText: string): string { const seen = new Set(); const lines: string[] = []; - for (const line of [...currentText.split(/\r?\n/), ...incomingText.split(/\r?\n/)]) { + for (const line of [ + ...currentText.split(/\r?\n/), + ...incomingText.split(/\r?\n/), + ]) { const trimmed = line.trim(); if (!trimmed) continue; let key = trimmed; @@ -602,16 +795,25 @@ function mergeUsage(currentText: string, incomingText: string): string { return lines.length ? `${lines.join("\n")}\n` : ""; } -export function referentialChecks(config: Record, auth: Record): ReconcileCheck[] { +export function referentialChecks( + config: Record, + auth: Record, +): ReconcileCheck[] { const checks: ReconcileCheck[] = []; const providerNames = new Set(Object.keys(asRecord(config.providers) ?? {})); const combos = asRecord(config.combos) ?? {}; for (const [id, raw] of Object.entries(combos)) { - const targets = Array.isArray(asRecord(raw)?.targets) ? asRecord(raw)!.targets as unknown[] : []; + const targets = Array.isArray(asRecord(raw)?.targets) + ? (asRecord(raw)!.targets as unknown[]) + : []; for (const target of targets) { const provider = asRecord(target)?.provider; if (typeof provider === "string" && !providerNames.has(provider)) { - checks.push({ id: `combo.${id}`, ok: false, detail: `target provider missing: ${provider}` }); + checks.push({ + id: `combo.${id}`, + ok: false, + detail: `target provider missing: ${provider}`, + }); } } } @@ -624,17 +826,28 @@ export function referentialChecks(config: Record, auth: Record< checks.push({ id: `oauth.${name}`, ok: count > 0, - detail: count > 0 ? `${count} account(s)` : "authMode=oauth but no auth.json accounts", + detail: + count > 0 + ? `${count} account(s)` + : "authMode=oauth but no auth.json accounts", }); } } if (checks.every((check) => check.ok) || checks.length === 0) { - checks.push({ id: "referential", ok: true, detail: "combo targets and oauth rows resolve" }); + checks.push({ + id: "referential", + ok: true, + detail: "combo targets and oauth rows resolve", + }); } return checks; } -export function smokeChecks(config: unknown, auth: unknown, usageText: string): ReconcileCheck[] { +export function smokeChecks( + config: unknown, + auth: unknown, + usageText: string, +): ReconcileCheck[] { const checks: ReconcileCheck[] = []; const validated = validateConfigCandidate(config); checks.push({ @@ -658,7 +871,11 @@ export function smokeChecks(config: unknown, auth: unknown, usageText: string): usageOk = false; } } - checks.push({ id: "usage.jsonl", ok: usageOk, detail: `${usageRows} row(s)` }); + checks.push({ + id: "usage.jsonl", + ok: usageOk, + detail: `${usageRows} row(s)`, + }); return checks; } @@ -671,7 +888,8 @@ function writeStateFile(dir: string, name: string, contents: string): void { function readJsonValue(path: string): unknown { const parsed = readJson(path); - if (!parsed.ok) throw new ReconcileError(`${path} is not valid JSON: ${parsed.error}`); + if (!parsed.ok) + throw new ReconcileError(`${path} is not valid JSON: ${parsed.error}`); return parsed.value; } @@ -687,45 +905,81 @@ export function materializeStaging( let auth = existsSync(join(currentDir, "auth.json")) ? normalizeAuthStore(readJsonValue(join(currentDir, "auth.json"))) : {}; - let usage = existsSync(join(currentDir, "usage.jsonl")) ? readFileSync(join(currentDir, "usage.jsonl"), "utf8") : ""; + let usage = existsSync(join(currentDir, "usage.jsonl")) + ? readFileSync(join(currentDir, "usage.jsonl"), "utf8") + : ""; for (const legacyDir of legacyDirs) { if (existsSync(join(legacyDir, "config.json"))) { - config = mergeStores(config, normalizeConfig(readJsonValue(join(legacyDir, "config.json"))), prefer); + config = mergeStores( + config, + normalizeConfig(readJsonValue(join(legacyDir, "config.json"))), + prefer, + ); } if (existsSync(join(legacyDir, "auth.json"))) { - auth = mergeAuth(auth, readJsonValue(join(legacyDir, "auth.json")), prefer); + auth = mergeAuth( + auth, + readJsonValue(join(legacyDir, "auth.json")), + prefer, + ); } if (existsSync(join(legacyDir, "usage.jsonl"))) { - usage = mergeUsage(usage, readFileSync(join(legacyDir, "usage.jsonl"), "utf8")); + usage = mergeUsage( + usage, + readFileSync(join(legacyDir, "usage.jsonl"), "utf8"), + ); } } mkdirSync(stagingDir, { recursive: true, mode: 0o700 }); const written: string[] = []; - writeStateFile(stagingDir, "config.json", `${JSON.stringify(config, null, 2)}\n`); + writeStateFile( + stagingDir, + "config.json", + `${JSON.stringify(config, null, 2)}\n`, + ); written.push("config.json"); writeStateFile(stagingDir, "auth.json", `${JSON.stringify(auth, null, 2)}\n`); written.push("auth.json"); writeStateFile(stagingDir, "usage.jsonl", usage); written.push("usage.jsonl"); for (const name of STATE_FILES) { - if (name === "config.json" || name === "auth.json" || name === "usage.jsonl") continue; + if ( + name === "config.json" || + name === "auth.json" || + name === "usage.jsonl" + ) + continue; const source = join(currentDir, name); if (existsSync(source)) { copyFileSync(source, join(stagingDir, name)); written.push(name); } } - const digest = sha256Bytes(STATE_FILES.map((name) => fingerprintFile(stagingDir, name).sha256 ?? "").join("|")); - writeFileSync(join(stagingDir, STAGING_MANIFEST_NAME), `${JSON.stringify({ version: 1, digest, files: written }, null, 2)}\n`, { mode: 0o600 }); + const digest = sha256Bytes( + STATE_FILES.map( + (name) => fingerprintFile(stagingDir, name).sha256 ?? "", + ).join("|"), + ); + writeFileSync( + join(stagingDir, STAGING_MANIFEST_NAME), + `${JSON.stringify({ version: 1, digest, files: written }, null, 2)}\n`, + { mode: 0o600 }, + ); return { digest, files: written }; } -export function promoteStaging(currentDir: string, stagingDir: string, backupDir: string): string[] { +export function promoteStaging( + currentDir: string, + stagingDir: string, + backupDir: string, +): string[] { const verified = verifyBackup(currentDir, backupDir); - if (!verified.ok) throw new ReconcileError(`refusing promote: ${verified.detail}`); - if (!existsSync(join(stagingDir, "config.json"))) throw new ReconcileError("refusing promote: staging has no config.json"); + if (!verified.ok) + throw new ReconcileError(`refusing promote: ${verified.detail}`); + if (!existsSync(join(stagingDir, "config.json"))) + throw new ReconcileError("refusing promote: staging has no config.json"); const rollbackDir = join(backupDir, "pre-promote"); mkdirSync(rollbackDir, { recursive: true, mode: 0o700 }); const restored: string[] = []; @@ -733,7 +987,11 @@ export function promoteStaging(currentDir: string, stagingDir: string, backupDir const live = join(currentDir, name); if (existsSync(live)) copyFileSync(live, join(rollbackDir, name)); } - writeFileSync(join(backupDir, ROLLBACK_POINTER_NAME), `${JSON.stringify({ version: 1, dir: rollbackDir, at: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 }); + writeFileSync( + join(backupDir, ROLLBACK_POINTER_NAME), + `${JSON.stringify({ version: 1, dir: rollbackDir, at: new Date().toISOString() }, null, 2)}\n`, + { mode: 0o600 }, + ); for (const name of STATE_FILES) { const staged = join(stagingDir, name); if (!existsSync(staged)) continue; @@ -745,14 +1003,17 @@ export function promoteStaging(currentDir: string, stagingDir: string, backupDir export function rollbackLive(currentDir: string, backupDir: string): string[] { const pointerPath = join(backupDir, ROLLBACK_POINTER_NAME); - const fallback = existsSync(join(backupDir, "pre-promote")) ? join(backupDir, "pre-promote") : backupDir; + const fallback = existsSync(join(backupDir, "pre-promote")) + ? join(backupDir, "pre-promote") + : backupDir; let source = fallback; if (existsSync(pointerPath)) { const parsed = readJson(pointerPath); const dir = parsed.ok ? asRecord(parsed.value)?.dir : null; if (typeof dir === "string" && existsSync(dir)) source = dir; } - if (!existsSync(source)) throw new ReconcileError("no rollback snapshot found"); + if (!existsSync(source)) + throw new ReconcileError("no rollback snapshot found"); const restored: string[] = []; for (const name of STATE_FILES) { const from = join(source, name); @@ -760,7 +1021,8 @@ export function rollbackLive(currentDir: string, backupDir: string): string[] { copyFileSync(from, join(currentDir, name)); restored.push(name); } - if (restored.length === 0) throw new ReconcileError("rollback snapshot is empty"); + if (restored.length === 0) + throw new ReconcileError("rollback snapshot is empty"); return restored; } @@ -769,12 +1031,19 @@ function projectedChecks( legacyDirs: string[], prefer: "current" | "legacy", ): { checks: ReconcileCheck[]; digest: string } { - const tmp = join(tmpdir(), `ocx-reconcile-preview-${process.pid}-${Date.now()}`); + const tmp = join( + tmpdir(), + `ocx-reconcile-preview-${process.pid}-${Date.now()}`, + ); try { const { digest } = materializeStaging(currentDir, legacyDirs, tmp, prefer); const config = readJsonValue(join(tmp, "config.json")); - const auth = existsSync(join(tmp, "auth.json")) ? readJsonValue(join(tmp, "auth.json")) : {}; - const usage = existsSync(join(tmp, "usage.jsonl")) ? readFileSync(join(tmp, "usage.jsonl"), "utf8") : ""; + const auth = existsSync(join(tmp, "auth.json")) + ? readJsonValue(join(tmp, "auth.json")) + : {}; + const usage = existsSync(join(tmp, "usage.jsonl")) + ? readFileSync(join(tmp, "usage.jsonl"), "utf8") + : ""; return { digest, checks: [ @@ -789,30 +1058,48 @@ function projectedChecks( export function runReconcile(options: ReconcileOptions): ReconcileReport { const currentDir = resolve(options.currentDir); - if (!existsSync(currentDir)) throw new ReconcileError(`current store not found: ${currentDir}`); + if (!existsSync(currentDir)) + throw new ReconcileError(`current store not found: ${currentDir}`); const legacyDirs = options.legacyDirs.map((dir) => resolve(dir)); for (const dir of legacyDirs) { - if (!existsSync(dir)) throw new ReconcileError(`legacy store not found: ${dir}`); + if (!existsSync(dir)) + throw new ReconcileError(`legacy store not found: ${dir}`); } const current = inventoryStore(currentDir, "current"); - const legacy = legacyDirs.map((dir, index) => inventoryStore(dir, `legacy-${index}`)); + const legacy = legacyDirs.map((dir, index) => + inventoryStore(dir, `legacy-${index}`), + ); const domains = diffDomains(current, legacy); const wrote: string[] = []; const backupDir = options.backupDir ? resolve(options.backupDir) : undefined; - const stagingDir = options.stagingDir ? resolve(options.stagingDir) : undefined; + const stagingDir = options.stagingDir + ? resolve(options.stagingDir) + : undefined; if (options.mode === "rollback") { if (!backupDir) throw new ReconcileError("rollback requires --backup-dir"); - wrote.push(...rollbackLive(currentDir, backupDir).map((name) => `current:${name}`)); + wrote.push( + ...rollbackLive(currentDir, backupDir).map((name) => `current:${name}`), + ); return { mode: options.mode, wrote, current: inventoryStore(currentDir, "current"), legacy, domains: diffDomains(inventoryStore(currentDir, "current"), legacy), - checks: [{ id: "rollback", ok: true, detail: `restored ${wrote.length} file(s)` }], - backup: { required: true, verified: verifyBackup(currentDir, backupDir).ok, path: backupDir }, + checks: [ + { + id: "rollback", + ok: true, + detail: `restored ${wrote.length} file(s)`, + }, + ], + backup: { + required: true, + verified: verifyBackup(currentDir, backupDir).ok, + path: backupDir, + }, stagingDigest: null, }; } @@ -820,10 +1107,15 @@ export function runReconcile(options: ReconcileOptions): ReconcileReport { const backupNeeded = options.mode === "apply" || options.mode === "promote"; let backupVerified = false; if (backupNeeded) { - if (!backupDir) throw new ReconcileError("apply/promote refuse to run without --backup-dir"); - if (!existsSync(join(backupDir, BACKUP_MANIFEST_NAME))) createBackup(currentDir, backupDir); + if (!backupDir) + throw new ReconcileError( + "apply/promote refuse to run without --backup-dir", + ); + if (!existsSync(join(backupDir, BACKUP_MANIFEST_NAME))) + createBackup(currentDir, backupDir); const verified = verifyBackup(currentDir, backupDir); - if (!verified.ok) throw new ReconcileError(`refusing to continue: ${verified.detail}`); + if (!verified.ok) + throw new ReconcileError(`refusing to continue: ${verified.detail}`); backupVerified = true; } else if (backupDir && existsSync(join(backupDir, BACKUP_MANIFEST_NAME))) { backupVerified = verifyBackup(currentDir, backupDir).ok; @@ -836,22 +1128,38 @@ export function runReconcile(options: ReconcileOptions): ReconcileReport { stagingDigest = preview.digest; checks = preview.checks; } else { - if (!stagingDir) throw new ReconcileError("apply/promote require --staging"); - const staged = materializeStaging(currentDir, legacyDirs, stagingDir, options.prefer); + if (!stagingDir) + throw new ReconcileError("apply/promote require --staging"); + const staged = materializeStaging( + currentDir, + legacyDirs, + stagingDir, + options.prefer, + ); stagingDigest = staged.digest; wrote.push(...staged.files.map((name) => `staging:${name}`)); const config = readJsonValue(join(stagingDir, "config.json")); - const auth = existsSync(join(stagingDir, "auth.json")) ? readJsonValue(join(stagingDir, "auth.json")) : {}; - const usage = existsSync(join(stagingDir, "usage.jsonl")) ? readFileSync(join(stagingDir, "usage.jsonl"), "utf8") : ""; + const auth = existsSync(join(stagingDir, "auth.json")) + ? readJsonValue(join(stagingDir, "auth.json")) + : {}; + const usage = existsSync(join(stagingDir, "usage.jsonl")) + ? readFileSync(join(stagingDir, "usage.jsonl"), "utf8") + : ""; checks = [ ...referentialChecks(asRecord(config) ?? {}, asRecord(auth) ?? {}), ...smokeChecks(config, auth, usage), ]; if (options.mode === "promote") { if (checks.some((check) => !check.ok && check.id === "config.schema")) { - throw new ReconcileError("refusing promote: staging failed functional smoke"); + throw new ReconcileError( + "refusing promote: staging failed functional smoke", + ); } - wrote.push(...promoteStaging(currentDir, stagingDir, backupDir!).map((name) => `live:${name}`)); + wrote.push( + ...promoteStaging(currentDir, stagingDir, backupDir!).map( + (name) => `live:${name}`, + ), + ); } } @@ -862,7 +1170,11 @@ export function runReconcile(options: ReconcileOptions): ReconcileReport { legacy, domains, checks, - backup: { required: backupNeeded, verified: backupVerified, path: backupDir ?? null }, + backup: { + required: backupNeeded, + verified: backupVerified, + path: backupDir ?? null, + }, stagingDigest, }; } @@ -874,10 +1186,18 @@ export function formatReport(report: ReconcileReport): string { `current providers=${report.current.providers.length} oauth=${report.current.oauthAccounts.length} usage=${report.current.usageLines} schemaVersion=${report.current.schemaVersion ?? "absent"}`, ]; for (const domain of report.domains) { - const missing = domain.missingFromCurrent.length ? ` missing=[${domain.missingFromCurrent.join(",")}]` : ""; - const conflicts = domain.conflicts.length ? ` conflicts=[${domain.conflicts.join(",")}]` : ""; - const legacy = Object.entries(domain.legacyCounts).map(([label, count]) => `${label}:${count}`).join(" "); - lines.push(`${domain.domain}: current=${domain.currentCount} ${legacy}${missing}${conflicts}`); + const missing = domain.missingFromCurrent.length + ? ` missing=[${domain.missingFromCurrent.join(",")}]` + : ""; + const conflicts = domain.conflicts.length + ? ` conflicts=[${domain.conflicts.join(",")}]` + : ""; + const legacy = Object.entries(domain.legacyCounts) + .map(([label, count]) => `${label}:${count}`) + .join(" "); + lines.push( + `${domain.domain}: current=${domain.currentCount} ${legacy}${missing}${conflicts}`, + ); } for (const check of report.checks) { lines.push(`${check.ok ? "ok" : "FAIL"} ${check.id}: ${check.detail}`); @@ -897,7 +1217,8 @@ function takeOption(args: string[], flag: string): string | undefined { const at = args.indexOf(flag); if (at === -1) return undefined; const value = args[at + 1]; - if (!value || value.startsWith("--")) throw new ReconcileError(`${flag} requires a value`); + if (!value || value.startsWith("--")) + throw new ReconcileError(`${flag} requires a value`); args.splice(at, 2); return value; } @@ -916,20 +1237,34 @@ export function parseReconcileArgs(argv: string[]): ReconcileOptions { const promote = takeFlag(args, "--promote"); const apply = takeFlag(args, "--apply"); const rollback = takeFlag(args, "--rollback"); - if ([promote, apply, rollback].filter(Boolean).length > 1 && !(promote && apply)) { - throw new ReconcileError("use one of dry-run (default), --apply, --apply --promote, or --rollback"); + if ( + [promote, apply, rollback].filter(Boolean).length > 1 && + !(promote && apply) + ) { + throw new ReconcileError( + "use one of dry-run (default), --apply, --apply --promote, or --rollback", + ); } - const currentDir = takeOption(args, "--current") ?? process.env.OPENCODEX_HOME; - if (!currentDir) throw new ReconcileError("--current or OPENCODEX_HOME is required"); + const currentDir = + takeOption(args, "--current") ?? process.env.OPENCODEX_HOME; + if (!currentDir) + throw new ReconcileError("--current or OPENCODEX_HOME is required"); const options: ReconcileOptions = { currentDir, legacyDirs: takeRepeat(args, "--legacy"), backupDir: takeOption(args, "--backup-dir"), stagingDir: takeOption(args, "--staging"), - mode: rollback ? "rollback" : promote ? "promote" : apply ? "apply" : "dry-run", + mode: rollback + ? "rollback" + : promote + ? "promote" + : apply + ? "apply" + : "dry-run", prefer: takeOption(args, "--prefer") === "legacy" ? "legacy" : "current", }; - if (args.length) throw new ReconcileError(`unexpected argument(s): ${args.join(" ")}`); + if (args.length) + throw new ReconcileError(`unexpected argument(s): ${args.join(" ")}`); if (options.mode !== "rollback" && options.legacyDirs.length === 0) { throw new ReconcileError("--legacy is required (repeatable)"); } @@ -942,7 +1277,12 @@ if (import.meta.main) { process.stdout.write(formatReport(report)); process.exit(report.checks.some((check) => !check.ok) ? 1 : 0); } catch (error) { - const message = error instanceof ReconcileError ? error.message : error instanceof Error ? error.message : String(error); + const message = + error instanceof ReconcileError + ? error.message + : error instanceof Error + ? error.message + : String(error); process.stderr.write(`${message}\n`); process.exit(error instanceof ReconcileError ? error.exitCode : 1); }