instar 1.3.535 → 1.3.536

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,234 @@
1
+ /**
2
+ * CredentialSwapExecutor — Step 5 of live credential re-pointing (spec §2.3).
3
+ *
4
+ * The staged exchange that MOVES a credential between two config-home slots WITHOUT restarting
5
+ * the sessions reading those slots (E3: the `claude` client re-reads its store on the next API
6
+ * call). `swap(slotA, slotB)` EXCHANGES the two slots' credentials — never copies — so the §0.d
7
+ * invariant ("one lineage per readable config home") holds by construction for the swap itself.
8
+ *
9
+ * Ships DARK + dry-run for EVERYONE (incl. dev). `subscriptionPool.credentialRepointing` is a
10
+ * `DARK_GATE_EXCLUSIONS` `destructive` entry (`enabled:false` + `dryRun:true`); with the feature
11
+ * off the executor performs ZERO real keychain/config writes (a strict no-op verdict), and with
12
+ * `dryRun` on it runs the full decision loop and AUDITS what it WOULD do without writing. Going
13
+ * live needs a deliberate `enabled:true` AND `dryRun:false` flip (the two-flag gate).
14
+ *
15
+ * ── The staged-exchange mechanism (spec §2.3 steps, each audited) ──
16
+ * 1. Preconditions: BOTH slots resolve by EXACT membership in the ledger's enumerated slot set
17
+ * BEFORE any path expansion (a value not `===` a known slot is rejected — `../`/`~`/absolute
18
+ * traversal can never reach a keychain service, since the value is validated against the
19
+ * ledger, not the filesystem); neither tenant quarantined; no swap in flight (single-mover);
20
+ * both blobs re-read fresh, parse, and carry refresh tokens.
21
+ * 1a. Source-slot CAS: immediately before staging/overwriting each slot, RE-READ its on-disk
22
+ * blob and compare to the step-1 read. If it changed and the new blob parses + identity-
23
+ * matches the SAME tenant → ADOPT the newer blob (the client's rotated copy); never carry a
24
+ * blob older than what is on disk. A different-tenant blob is a clobber-race → ABORT.
25
+ * 2. Staging escrow: COPY (never move) blob A (the 1a re-read, freshest) into a DISJOINT
26
+ * `instar-credential-swap-staging-<swapId>` keychain entry; journal `begin` BEFORE the first
27
+ * destructive write. The staging namespace is guaranteed disjoint from every
28
+ * `claudeCredentialService(home)` output, so no `claude` client / poller ever reads a staged
29
+ * copy. Staging is RETAINED until step 6's delayed re-verify passes.
30
+ * 3. The exchange: write blob B → slot A, write blob A (from staging) → slot B — KEYCHAIN FIRST,
31
+ * then config (`oauthAccount` blocks; default slot config = `~/.claude.json`, home-root).
32
+ * 4. Verify on ACCOUNT IDENTITY via the oracle (NOT token bytes, NOT auth-status). match→commit;
33
+ * identity-MISMATCH→repair-from-staging then re-verify, still-wrong→quarantine;
34
+ * oracle-UNAVAILABLE→QUARANTINE-NEVER-REPAIR (an outage must never trigger a destructive
35
+ * repair). The system NEVER writes a credential into a slot it cannot identity-verify.
36
+ * 5. Commit: journal `committed`, ledger assignments updated — staging RETAINED (a client
37
+ * write-back between commit and step-6 could clobber slot B's only copy; staging is the heal
38
+ * source for that window).
39
+ * 6. Delayed re-verify ~90s after commit (oracle identity of both slots); only on pass →
40
+ * DELETE staging + journal `done`. (90s covers the sub-2-min in-flight refresh; the at-expiry
41
+ * write-back is the always-on §2.4 audit's job.)
42
+ *
43
+ * ── Crash-safety ── A crash at ANY boundary is decidable from the journal + the two slots' on-disk
44
+ * reads: before step 3's first write nothing destructive happened (staging is a COPY, slot A
45
+ * untouched) → unwind is a no-op; after `begin` and before `done`, staging is the escrow and
46
+ * recovery applies the adopt-on-newer rule (never a blind staging overwrite). `recover()` acquires
47
+ * the single-mover mutex (recovery WRITES race the boot balancer otherwise) and resolves every
48
+ * in-flight journal row; the balancer's first pass is gated on `recoveryBarrier` WITH a bounded
49
+ * hang-timeout (a wedged recovery write must not freeze the balancer forever — on timeout the
50
+ * unresolved slots are quarantined BEFORE the barrier lifts, so the balancer structurally cannot
51
+ * select them).
52
+ *
53
+ * ── Concurrency ── Every swap runs inside `funnel.withSingleMover(() => funnel.withSlotLocks([A,B],
54
+ * …))` so two swaps never overlap and a swap can never interleave with a refresh on the same slot
55
+ * (the Step-4b funnel guards both). All `security` keychain calls go through the async
56
+ * execFile-based `KeychainCredentialExec` with a 10s timeout — NEVER `execFileSync` (a locked
57
+ * keychain ACL prompt would wedge the event loop). The credential WRITE path routes through the
58
+ * funnel; the executor never calls `defaultCredentialStore.write` / `provider.writeCredentials`,
59
+ * so the §2.2 unfunneled-write lint stays clean.
60
+ *
61
+ * ── No token material on any surface ── audit records, journal details, attention items, and the
62
+ * swap-result reference accounts by id / slot only; every free-text error string is scrubbed
63
+ * through `redactToken` before it reaches any persisted/served/notified surface.
64
+ */
65
+ import type { CredentialWriteFunnel } from './CredentialWriteFunnel.js';
66
+ import type { CredentialLocationLedger } from './CredentialLocationLedger.js';
67
+ /**
68
+ * The async keychain access surface. A slot's credential lives at the keychain service
69
+ * `claudeCredentialService(slot)`; a staged escrow copy lives at `instar-credential-swap-staging-*`.
70
+ * Reads/writes/deletes ALL go through async `execFile` with a 10s timeout — never the SYNC
71
+ * `defaultCredentialStore` (a locked keychain could wedge the event loop) and never
72
+ * `provider.writeCredentials` (the §2.2 lint forbids both outside the funnel; this surface is the
73
+ * funnel-routed Step-5 owner). The default impl uses macOS `security`; tests inject an in-memory map.
74
+ */
75
+ export interface KeychainCredentialExec {
76
+ /** Read the raw JSON blob at a keychain SERVICE name (slot service or a staging service). null = absent. */
77
+ readService(service: string): Promise<string | null>;
78
+ /** Write (upsert) the raw JSON blob at a keychain SERVICE name. */
79
+ writeService(service: string, rawJson: string): Promise<void>;
80
+ /** Delete a keychain entry at a SERVICE name (used to remove staging after step 6). */
81
+ deleteService(service: string): Promise<void>;
82
+ }
83
+ /** Default macOS keychain exec — async `execFile`, 10s timeout. Non-darwin: a per-service file. */
84
+ export declare const defaultKeychainExec: KeychainCredentialExec;
85
+ /**
86
+ * The `oauthAccount` config metadata exchange. The credential keychain blob is the record of
87
+ * truth; config follows (keychain-first/config-second). The DEFAULT slot's canonical config file
88
+ * is `~/.claude.json` (home-root). Injectable so tests assert the ordering; a config-write failure
89
+ * after a successful keychain exchange is a REPAIRABLE metadata condition (never a quarantine).
90
+ */
91
+ export interface ConfigBlockExchange {
92
+ /** Move/exchange the two slots' `oauthAccount` config blocks. Resolves on success. */
93
+ exchange(slotA: string, slotB: string): Promise<void>;
94
+ }
95
+ /** No-op config exchange (the metadata follow is wired in Step 6's census re-routing). */
96
+ export declare const noopConfigBlockExchange: ConfigBlockExchange;
97
+ /** Identity verdict for a slot: the confirmed accountId, or unavailable (never a guess). */
98
+ export type SlotIdentity = {
99
+ accountId: string;
100
+ } | {
101
+ unavailable: true;
102
+ reason: string;
103
+ };
104
+ /**
105
+ * Resolve which pool account currently tenants a slot, by reading its blob via the oracle and
106
+ * mapping email→accountId through the pool. Composed by the host from `CredentialIdentityOracle`
107
+ * + the pool; injected so the executor's verify/adopt/repair/quarantine boundary is unit-testable
108
+ * without a live oracle. CONTRACT: an unreachable/slow/5xx/429/ambiguous oracle → `unavailable`
109
+ * (quarantine-never-repair upstream), NEVER a wrong accountId.
110
+ */
111
+ export type ResolveSlotIdentity = (slot: string) => Promise<SlotIdentity>;
112
+ export type SwapOutcome = 'swapped' | 'dry-run' | 'disabled' | 'skipped' | 'precondition-failed' | 'clobber-race' | 'quarantined';
113
+ export interface SwapResult {
114
+ outcome: SwapOutcome;
115
+ /** Credential-free human reason (scrubbed). */
116
+ reason: string;
117
+ /** The swap id (present once a swap was attempted past preconditions). */
118
+ swapId?: string;
119
+ /** Slot(s) quarantined by this attempt, if any. */
120
+ quarantinedSlots?: string[];
121
+ }
122
+ /** One audited step (no token material — scrubbed at emit). */
123
+ export interface SwapAuditRecord {
124
+ swapId: string;
125
+ step: string;
126
+ slotA: string;
127
+ slotB: string;
128
+ detail?: string;
129
+ at: string;
130
+ }
131
+ export interface CredentialSwapExecutorDeps {
132
+ funnel: CredentialWriteFunnel;
133
+ ledger: CredentialLocationLedger;
134
+ keychain?: KeychainCredentialExec;
135
+ configExchange?: ConfigBlockExchange;
136
+ /** Compose oracle + pool: slot → confirmed accountId | unavailable. */
137
+ resolveIdentity: ResolveSlotIdentity;
138
+ /** Feature gate (DARK_GATE_EXCLUSIONS destructive). */
139
+ config?: {
140
+ enabled?: boolean;
141
+ dryRun?: boolean;
142
+ };
143
+ /** Single audit chokepoint — every step routes here (scrubbed). */
144
+ emitAudit?: (record: SwapAuditRecord) => void;
145
+ /** HIGH attention on a quarantine (the blast-radius surface). */
146
+ emitAttention?: (item: {
147
+ id: string;
148
+ title: string;
149
+ summary: string;
150
+ category: string;
151
+ priority: 'URGENT' | 'HIGH' | 'NORMAL' | 'LOW';
152
+ sourceContext?: string;
153
+ }) => void | Promise<void>;
154
+ now?: () => number;
155
+ nowIso?: () => string;
156
+ /** Delay before the step-6 re-verify (ms; default 90_000). Tests inject a small value. */
157
+ reverifyDelayMs?: number;
158
+ /** Recovery-barrier hang-timeout (ms; default 60_000). */
159
+ recoveryBarrierTimeoutMs?: number;
160
+ /** Random/sequence swap-id source (derives from NO token bytes). */
161
+ swapIdFactory?: () => string;
162
+ }
163
+ export declare class CredentialSwapExecutor {
164
+ private readonly funnel;
165
+ private readonly ledger;
166
+ private readonly keychain;
167
+ private readonly configExchange;
168
+ private readonly resolveIdentity;
169
+ private readonly config;
170
+ private readonly emitAudit?;
171
+ private readonly emitAttention?;
172
+ private readonly now;
173
+ private readonly nowIso;
174
+ private readonly reverifyDelayMs;
175
+ private readonly recoveryBarrierTimeoutMs;
176
+ private readonly swapIdFactory;
177
+ /** Resolves once boot-recovery has resolved every in-flight journal row (or the barrier timed out). */
178
+ private recoveryResolve;
179
+ private readonly recoveryBarrier;
180
+ private recoveryDone;
181
+ private seq;
182
+ constructor(deps: CredentialSwapExecutorDeps);
183
+ /**
184
+ * The balancer's first pass awaits THIS before selecting any pair — so a recovery WRITE can't
185
+ * race a fresh swap on a different slot pair (round-2 unmutexed write-write find). Bounded by a
186
+ * hang-timeout: a wedged recovery write must not freeze the balancer forever.
187
+ */
188
+ awaitRecoveryComplete(): Promise<void>;
189
+ isRecoveryComplete(): boolean;
190
+ /** SINGLE emit chokepoint — every record is scrubbed of token material here (§2.9). */
191
+ private audit;
192
+ private stagingService;
193
+ /**
194
+ * EXCHANGE the credentials of two slots. Returns a verdict; never throws into the caller for an
195
+ * expected condition (precondition fail / clobber-race / quarantine / busy lock are all results).
196
+ */
197
+ swap(slotA: string, slotB: string): Promise<SwapResult>;
198
+ /** Runs under the single-mover mutex + both slot locks. */
199
+ private swapLocked;
200
+ private casReread;
201
+ private verifyAndHeal;
202
+ private scheduleReverify;
203
+ /** Run the step-6 re-verify immediately (exposed for deterministic tests). */
204
+ reverifyNow(slotA: string, slotB: string, wantA: string, wantB: string, staging: string, swapId: string): Promise<void>;
205
+ /**
206
+ * Resolve every in-flight swap journal row left by a crash. Acquires the single-mover mutex (a
207
+ * recovery WRITE must not race a boot balancer pass on the ledger). When it finishes (or the
208
+ * hang-timeout fires) the recovery barrier lifts so the balancer's first pass can run. A wedged
209
+ * recovery write quarantines its slots BEFORE the barrier lifts, so the post-lift balancer
210
+ * structurally cannot select a wedged slot.
211
+ */
212
+ recover(): Promise<void>;
213
+ /** In-flight swap journal rows = a `begin`/`exchanged`/`verified` row whose swapId has no later `done`/`aborted`. */
214
+ private inFlightSwaps;
215
+ /**
216
+ * Resolve one in-flight entry. Per §2.3 recovery decidability: re-read each slot, apply
217
+ * adopt-on-newer (never a blind staging overwrite), and verify identity; an unverifiable slot is
218
+ * quarantined (never repaired blindly). Staging is deleted + the row closed `done` ONLY when
219
+ * both slots verify; otherwise the row stays in-flight and its staging is retained (the sweep
220
+ * predicate protects any non-`done` row).
221
+ */
222
+ private recoverEntry;
223
+ /**
224
+ * A staging entry is an ORPHAN iff its swapId has NO journal row, OR its row is `done`. Any
225
+ * non-`done` row (begin/exchanged/verified) PROTECTS its staging (the heal source through step
226
+ * 6 — a literal "in-flight = begin only" reading would delete a committed row's heal source).
227
+ * Caller supplies the live staging-service list (the executor never enumerates the keychain).
228
+ */
229
+ sweepOrphanStaging(stagingServices: string[]): Promise<string[]>;
230
+ /** Read a slot's blob via the async keychain exec; require it parse + carry a refresh token. */
231
+ private readBlob;
232
+ private raiseQuarantineAttention;
233
+ }
234
+ //# sourceMappingURL=CredentialSwapExecutor.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"CredentialSwapExecutor.d.ts","sourceRoot":"","sources":["../../src/core/CredentialSwapExecutor.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,596 @@
1
+ /**
2
+ * CredentialSwapExecutor — Step 5 of live credential re-pointing (spec §2.3).
3
+ *
4
+ * The staged exchange that MOVES a credential between two config-home slots WITHOUT restarting
5
+ * the sessions reading those slots (E3: the `claude` client re-reads its store on the next API
6
+ * call). `swap(slotA, slotB)` EXCHANGES the two slots' credentials — never copies — so the §0.d
7
+ * invariant ("one lineage per readable config home") holds by construction for the swap itself.
8
+ *
9
+ * Ships DARK + dry-run for EVERYONE (incl. dev). `subscriptionPool.credentialRepointing` is a
10
+ * `DARK_GATE_EXCLUSIONS` `destructive` entry (`enabled:false` + `dryRun:true`); with the feature
11
+ * off the executor performs ZERO real keychain/config writes (a strict no-op verdict), and with
12
+ * `dryRun` on it runs the full decision loop and AUDITS what it WOULD do without writing. Going
13
+ * live needs a deliberate `enabled:true` AND `dryRun:false` flip (the two-flag gate).
14
+ *
15
+ * ── The staged-exchange mechanism (spec §2.3 steps, each audited) ──
16
+ * 1. Preconditions: BOTH slots resolve by EXACT membership in the ledger's enumerated slot set
17
+ * BEFORE any path expansion (a value not `===` a known slot is rejected — `../`/`~`/absolute
18
+ * traversal can never reach a keychain service, since the value is validated against the
19
+ * ledger, not the filesystem); neither tenant quarantined; no swap in flight (single-mover);
20
+ * both blobs re-read fresh, parse, and carry refresh tokens.
21
+ * 1a. Source-slot CAS: immediately before staging/overwriting each slot, RE-READ its on-disk
22
+ * blob and compare to the step-1 read. If it changed and the new blob parses + identity-
23
+ * matches the SAME tenant → ADOPT the newer blob (the client's rotated copy); never carry a
24
+ * blob older than what is on disk. A different-tenant blob is a clobber-race → ABORT.
25
+ * 2. Staging escrow: COPY (never move) blob A (the 1a re-read, freshest) into a DISJOINT
26
+ * `instar-credential-swap-staging-<swapId>` keychain entry; journal `begin` BEFORE the first
27
+ * destructive write. The staging namespace is guaranteed disjoint from every
28
+ * `claudeCredentialService(home)` output, so no `claude` client / poller ever reads a staged
29
+ * copy. Staging is RETAINED until step 6's delayed re-verify passes.
30
+ * 3. The exchange: write blob B → slot A, write blob A (from staging) → slot B — KEYCHAIN FIRST,
31
+ * then config (`oauthAccount` blocks; default slot config = `~/.claude.json`, home-root).
32
+ * 4. Verify on ACCOUNT IDENTITY via the oracle (NOT token bytes, NOT auth-status). match→commit;
33
+ * identity-MISMATCH→repair-from-staging then re-verify, still-wrong→quarantine;
34
+ * oracle-UNAVAILABLE→QUARANTINE-NEVER-REPAIR (an outage must never trigger a destructive
35
+ * repair). The system NEVER writes a credential into a slot it cannot identity-verify.
36
+ * 5. Commit: journal `committed`, ledger assignments updated — staging RETAINED (a client
37
+ * write-back between commit and step-6 could clobber slot B's only copy; staging is the heal
38
+ * source for that window).
39
+ * 6. Delayed re-verify ~90s after commit (oracle identity of both slots); only on pass →
40
+ * DELETE staging + journal `done`. (90s covers the sub-2-min in-flight refresh; the at-expiry
41
+ * write-back is the always-on §2.4 audit's job.)
42
+ *
43
+ * ── Crash-safety ── A crash at ANY boundary is decidable from the journal + the two slots' on-disk
44
+ * reads: before step 3's first write nothing destructive happened (staging is a COPY, slot A
45
+ * untouched) → unwind is a no-op; after `begin` and before `done`, staging is the escrow and
46
+ * recovery applies the adopt-on-newer rule (never a blind staging overwrite). `recover()` acquires
47
+ * the single-mover mutex (recovery WRITES race the boot balancer otherwise) and resolves every
48
+ * in-flight journal row; the balancer's first pass is gated on `recoveryBarrier` WITH a bounded
49
+ * hang-timeout (a wedged recovery write must not freeze the balancer forever — on timeout the
50
+ * unresolved slots are quarantined BEFORE the barrier lifts, so the balancer structurally cannot
51
+ * select them).
52
+ *
53
+ * ── Concurrency ── Every swap runs inside `funnel.withSingleMover(() => funnel.withSlotLocks([A,B],
54
+ * …))` so two swaps never overlap and a swap can never interleave with a refresh on the same slot
55
+ * (the Step-4b funnel guards both). All `security` keychain calls go through the async
56
+ * execFile-based `KeychainCredentialExec` with a 10s timeout — NEVER `execFileSync` (a locked
57
+ * keychain ACL prompt would wedge the event loop). The credential WRITE path routes through the
58
+ * funnel; the executor never calls `defaultCredentialStore.write` / `provider.writeCredentials`,
59
+ * so the §2.2 unfunneled-write lint stays clean.
60
+ *
61
+ * ── No token material on any surface ── audit records, journal details, attention items, and the
62
+ * swap-result reference accounts by id / slot only; every free-text error string is scrubbed
63
+ * through `redactToken` before it reaches any persisted/served/notified surface.
64
+ */
65
+ import { execFile } from 'node:child_process';
66
+ import { credentialSlotKey, claudeCredentialService, expandHome } from './OAuthRefresher.js';
67
+ import { redactToken } from '../monitoring/CredentialProvider.js';
68
+ const SECURITY_CALL_TIMEOUT_MS = 10_000;
69
+ /** Disjoint from every `claudeCredentialService(home)` output (`Claude Code-credentials[-<8hex>]`). */
70
+ const STAGING_SERVICE_PREFIX = 'instar-credential-swap-staging-';
71
+ const DEFAULT_REVERIFY_DELAY_MS = 90_000;
72
+ /** Hang-timeout barrier: a wedged recovery write must not freeze the balancer forever. */
73
+ const DEFAULT_RECOVERY_BARRIER_TIMEOUT_MS = 60_000;
74
+ /** Default macOS keychain exec — async `execFile`, 10s timeout. Non-darwin: a per-service file. */
75
+ export const defaultKeychainExec = {
76
+ readService(service) {
77
+ return new Promise((resolve) => {
78
+ try {
79
+ execFile('security', ['find-generic-password', '-s', service, '-w'], { timeout: SECURITY_CALL_TIMEOUT_MS, maxBuffer: 1024 * 1024 }, (err, stdout) => {
80
+ if (err) {
81
+ resolve(null); // @silent-fallback-ok: no keychain entry (or timeout) → treated as absent; the caller's CAS/precondition decides, never a guess
82
+ return;
83
+ }
84
+ const raw = (stdout ?? '').toString().trim();
85
+ resolve(raw || null);
86
+ });
87
+ }
88
+ catch {
89
+ resolve(null); // @silent-fallback-ok: spawn failed → absent; never throws into the swap loop
90
+ }
91
+ });
92
+ },
93
+ writeService(service, rawJson) {
94
+ return new Promise((resolve, reject) => {
95
+ try {
96
+ execFile('security', ['add-generic-password', '-U', '-a', process.env.USER ?? 'instar', '-s', service, '-w', rawJson], { timeout: SECURITY_CALL_TIMEOUT_MS }, (err) => (err ? reject(err) : resolve()));
97
+ }
98
+ catch (err) {
99
+ reject(err);
100
+ }
101
+ });
102
+ },
103
+ deleteService(service) {
104
+ return new Promise((resolve) => {
105
+ try {
106
+ execFile('security', ['delete-generic-password', '-s', service], { timeout: SECURITY_CALL_TIMEOUT_MS }, () => resolve());
107
+ }
108
+ catch {
109
+ resolve(); // @silent-fallback-ok: spawn failed on a best-effort delete → leave the (harmless, disjoint) staging entry for the boot-recovery orphan sweep
110
+ }
111
+ });
112
+ },
113
+ };
114
+ /** No-op config exchange (the metadata follow is wired in Step 6's census re-routing). */
115
+ export const noopConfigBlockExchange = {
116
+ async exchange() {
117
+ /* metadata-follow wired in Step 6 (census #1/#8); the keychain exchange is the record of truth */
118
+ },
119
+ };
120
+ export class CredentialSwapExecutor {
121
+ funnel;
122
+ ledger;
123
+ keychain;
124
+ configExchange;
125
+ resolveIdentity;
126
+ config;
127
+ emitAudit;
128
+ emitAttention;
129
+ now;
130
+ nowIso;
131
+ reverifyDelayMs;
132
+ recoveryBarrierTimeoutMs;
133
+ swapIdFactory;
134
+ /** Resolves once boot-recovery has resolved every in-flight journal row (or the barrier timed out). */
135
+ recoveryResolve = null;
136
+ recoveryBarrier;
137
+ recoveryDone = false;
138
+ seq = 0;
139
+ constructor(deps) {
140
+ this.funnel = deps.funnel;
141
+ this.ledger = deps.ledger;
142
+ this.keychain = deps.keychain ?? defaultKeychainExec;
143
+ this.configExchange = deps.configExchange ?? noopConfigBlockExchange;
144
+ this.resolveIdentity = deps.resolveIdentity;
145
+ this.config = { enabled: deps.config?.enabled ?? false, dryRun: deps.config?.dryRun ?? true };
146
+ this.emitAudit = deps.emitAudit;
147
+ this.emitAttention = deps.emitAttention;
148
+ this.now = deps.now ?? (() => Date.now());
149
+ this.nowIso = deps.nowIso ?? (() => new Date(this.now()).toISOString());
150
+ this.reverifyDelayMs = deps.reverifyDelayMs ?? DEFAULT_REVERIFY_DELAY_MS;
151
+ this.recoveryBarrierTimeoutMs = deps.recoveryBarrierTimeoutMs ?? DEFAULT_RECOVERY_BARRIER_TIMEOUT_MS;
152
+ this.swapIdFactory = deps.swapIdFactory ?? (() => `${Date.now().toString(36)}-${(this.seq++).toString(36)}`);
153
+ this.recoveryBarrier = new Promise((resolve) => {
154
+ this.recoveryResolve = resolve;
155
+ });
156
+ }
157
+ /**
158
+ * The balancer's first pass awaits THIS before selecting any pair — so a recovery WRITE can't
159
+ * race a fresh swap on a different slot pair (round-2 unmutexed write-write find). Bounded by a
160
+ * hang-timeout: a wedged recovery write must not freeze the balancer forever.
161
+ */
162
+ awaitRecoveryComplete() {
163
+ return this.recoveryBarrier;
164
+ }
165
+ isRecoveryComplete() {
166
+ return this.recoveryDone;
167
+ }
168
+ // ── Audit / scrub chokepoint ────────────────────────────────────────────────────────────────
169
+ /** SINGLE emit chokepoint — every record is scrubbed of token material here (§2.9). */
170
+ audit(swapId, step, slotA, slotB, detail) {
171
+ if (!this.emitAudit)
172
+ return;
173
+ this.emitAudit({ swapId, step, slotA, slotB, detail: detail ? scrub(detail) : undefined, at: this.nowIso() });
174
+ }
175
+ stagingService(swapId) {
176
+ return `${STAGING_SERVICE_PREFIX}${swapId}`;
177
+ }
178
+ // ── The swap (spec §2.3) ──────────────────────────────────────────────────────────────────────
179
+ /**
180
+ * EXCHANGE the credentials of two slots. Returns a verdict; never throws into the caller for an
181
+ * expected condition (precondition fail / clobber-race / quarantine / busy lock are all results).
182
+ */
183
+ async swap(slotA, slotB) {
184
+ // DARK: feature off → strict no-op (zero writes). This is the dark-ship inertness guarantee.
185
+ if (!this.config.enabled) {
186
+ return { outcome: 'disabled', reason: 'credential re-pointing is disabled (dark) — no swap performed' };
187
+ }
188
+ // Step 1 (precondition, pre-lock): EXACT ledger membership BEFORE any path expansion. A value
189
+ // not === a known slot is rejected — `../`/`~`/absolute traversal can never reach a keychain
190
+ // service because we validate against the ledger's enumerated set, not the filesystem.
191
+ const memberSlots = new Set(this.ledger.getAssignments().map((a) => a.slot));
192
+ if (slotA === slotB) {
193
+ return { outcome: 'precondition-failed', reason: 'slotA and slotB are the same slot' };
194
+ }
195
+ if (!memberSlots.has(slotA) || !memberSlots.has(slotB)) {
196
+ return {
197
+ outcome: 'precondition-failed',
198
+ reason: `a slot is not a known ledger member (got '${slotA}', '${slotB}') — refusing an arbitrary keychain/path write`,
199
+ };
200
+ }
201
+ const aAssign = this.ledger.getAssignment(slotA);
202
+ const bAssign = this.ledger.getAssignment(slotB);
203
+ if (aAssign?.quarantined || bAssign?.quarantined) {
204
+ return { outcome: 'precondition-failed', reason: 'a slot tenant is quarantined — excluded from balancing' };
205
+ }
206
+ // Take the single-mover mutex (no swap in flight) THEN both slot locks (canonical order). The
207
+ // funnel returns a SKIP rather than blocking → a busy lock is a transient retry, never a wedge.
208
+ const moverResult = await this.funnel.withSingleMover(async () => {
209
+ return this.funnel.withSlotLocks([credentialSlotKey(slotA), credentialSlotKey(slotB)], () => this.swapLocked(slotA, slotB, aAssign?.accountId ?? '', bAssign?.accountId ?? ''));
210
+ });
211
+ if (!moverResult.ran) {
212
+ return { outcome: 'skipped', reason: scrub(moverResult.skippedReason ?? 'funnel busy') };
213
+ }
214
+ const inner = moverResult.value;
215
+ if (!inner.ran) {
216
+ return { outcome: 'skipped', reason: scrub(inner.skippedReason ?? 'slot lock busy') };
217
+ }
218
+ return inner.value;
219
+ }
220
+ /** Runs under the single-mover mutex + both slot locks. */
221
+ async swapLocked(slotA, slotB, expectA, expectB) {
222
+ const swapId = this.swapIdFactory();
223
+ // Step 1 (continued): re-read both blobs fresh; they must parse + carry refresh tokens.
224
+ const read1A = await this.readBlob(slotA);
225
+ const read1B = await this.readBlob(slotB);
226
+ if (!read1A || !read1B) {
227
+ return { outcome: 'precondition-failed', reason: `a slot blob is missing/unparseable or lacks a refresh token`, swapId };
228
+ }
229
+ // Step 1a: source-slot CAS — re-read each slot immediately before it is staged/overwritten. If
230
+ // a slot changed AND the new blob is the SAME tenant → ADOPT it (the client's rotated copy). A
231
+ // DIFFERENT-tenant blob is a clobber-race → ABORT, overwrite nothing.
232
+ const casA = await this.casReread(slotA, read1A, expectA, swapId);
233
+ if (casA.aborted)
234
+ return { outcome: 'clobber-race', reason: casA.reason, swapId };
235
+ const casB = await this.casReread(slotB, read1B, expectB, swapId);
236
+ if (casB.aborted)
237
+ return { outcome: 'clobber-race', reason: casB.reason, swapId };
238
+ const blobA = casA.blob; // freshest A (adopted if the client rotated it)
239
+ const blobB = casB.blob; // freshest B
240
+ // dryRun: full decision loop ran, ZERO writes. Audit the WOULD-swap and stop before step 2.
241
+ if (this.config.dryRun) {
242
+ this.audit(swapId, 'dry-run', slotA, slotB, `would exchange ${expectA}<->${expectB}`);
243
+ return { outcome: 'dry-run', reason: 'dry-run — decision computed, zero credential writes', swapId };
244
+ }
245
+ // Step 2: staging escrow — COPY blob A into a disjoint staging entry, THEN journal `begin`
246
+ // BEFORE the first destructive write. (COPY not move: slot A is untouched until step 3, so a
247
+ // crash before step 3 unwinds to a true no-op.)
248
+ const staging = this.stagingService(swapId);
249
+ await this.keychain.writeService(staging, blobA.raw);
250
+ this.ledger.appendJournal({ op: 'swap', phase: 'begin', slots: [slotA, slotB], detail: `swapId=${swapId} staging` });
251
+ this.audit(swapId, 'begin', slotA, slotB, `staged A; exchanging ${expectA}<->${expectB}`);
252
+ // Step 3: the exchange — KEYCHAIN FIRST (write B→slotA, then A→slotB from staging), config
253
+ // second. The between-the-two-writes crash point is recovery-decidable (journal begin + the
254
+ // two slots' on-disk reads + the retained staging copy of A).
255
+ await this.keychain.writeService(claudeCredentialService(slotA), blobB.raw);
256
+ await this.keychain.writeService(claudeCredentialService(slotB), blobA.raw);
257
+ this.ledger.appendJournal({ op: 'swap', phase: 'exchanged', slots: [slotA, slotB], detail: `swapId=${swapId} keychain` });
258
+ try {
259
+ await this.configExchange.exchange(slotA, slotB);
260
+ }
261
+ catch (err) {
262
+ // Config-write failure AFTER a successful keychain exchange is a REPAIRABLE metadata
263
+ // condition — never a quarantine. Audited; the metadata-follow retry is the §2.3 step-3 path.
264
+ this.audit(swapId, 'config-exchange-deferred', slotA, slotB, `metadata follow failed (repairable): ${err?.message ?? 'unknown'}`);
265
+ }
266
+ // Step 4: verify on ACCOUNT IDENTITY. The credential now in slotA must be expectB's; the one
267
+ // in slotB must be expectA's.
268
+ const verify = await this.verifyAndHeal(slotA, slotB, expectB, expectA, staging, blobB, blobA, swapId);
269
+ if (verify.quarantined) {
270
+ return { outcome: 'quarantined', reason: verify.reason, swapId, quarantinedSlots: verify.quarantinedSlots };
271
+ }
272
+ // Step 5: commit — ledger assignments updated; staging RETAINED (heal source through step 6).
273
+ this.ledger.appendJournal({ op: 'swap', phase: 'verified', slots: [slotA, slotB], detail: `swapId=${swapId}` });
274
+ this.ledger.recordAssignment(slotA, expectB, { verifiedAt: this.nowIso(), op: 'swap' });
275
+ this.ledger.recordAssignment(slotB, expectA, { verifiedAt: this.nowIso(), op: 'swap' });
276
+ this.audit(swapId, 'committed', slotA, slotB, `tenants exchanged (staging retained)`);
277
+ // Step 6: delayed re-verify, then staging delete. Scheduled (does NOT block the swap return);
278
+ // a client whose refresh exchange was in flight DURING the swap lands its write after step 4.
279
+ this.scheduleReverify(slotA, slotB, expectB, expectA, staging, swapId);
280
+ return { outcome: 'swapped', reason: 'credentials exchanged; staging retained pending delayed re-verify', swapId };
281
+ }
282
+ // ── Step 1a: source-slot CAS ──────────────────────────────────────────────────────────────────
283
+ async casReread(slot, read1, expectTenant, swapId) {
284
+ const fresh = await this.readBlob(slot);
285
+ if (!fresh) {
286
+ // The slot became unreadable between step 1 and the destructive write → abort (do not write a
287
+ // stale copy over an absent/garbage on-disk state).
288
+ return { aborted: true, reason: `slot '${slot}' became unreadable before write (CAS)` };
289
+ }
290
+ if (fresh.raw === read1.raw) {
291
+ return { aborted: false, blob: read1 }; // unchanged
292
+ }
293
+ // It changed. Is the new blob the SAME tenant? Adopt; else it's a clobber-race → abort.
294
+ const identity = await this.resolveIdentity(slot);
295
+ if ('unavailable' in identity) {
296
+ // Cannot confirm the tenant of the changed blob → do NOT guess. Abort the swap (the safe
297
+ // direction: never overwrite a blob we cannot identity-verify is the same lineage).
298
+ return { aborted: true, reason: `CAS: slot '${slot}' changed and identity is unavailable — aborting (${identity.reason})` };
299
+ }
300
+ if (identity.accountId !== expectTenant) {
301
+ this.audit(swapId, 'clobber-race', slot, slot, `CAS saw a different-tenant blob (expected ${expectTenant})`);
302
+ return { aborted: true, reason: `CAS: slot '${slot}' now holds a different tenant — clobber-race, aborting` };
303
+ }
304
+ // Same tenant, newer blob → ADOPT (the client's rotated copy).
305
+ this.audit(swapId, 'cas-adopt', slot, slot, `adopted the client's rotated same-tenant blob`);
306
+ return { aborted: false, blob: fresh };
307
+ }
308
+ // ── Step 4: verify-and-heal (identity match → adopt / mismatch → repair / unavailable → quarantine) ──
309
+ async verifyAndHeal(slotA, slotB, wantA, wantB, staging, blobForA, blobForB, swapId) {
310
+ const quarantined = [];
311
+ for (const [slot, want, blob] of [
312
+ [slotA, wantA, blobForA],
313
+ [slotB, wantB, blobForB],
314
+ ]) {
315
+ const id = await this.resolveIdentity(slot);
316
+ if ('unavailable' in id) {
317
+ // Oracle UNAVAILABLE during verify → quarantine-never-repair (an outage must never trigger
318
+ // a destructive repair). Leave the slot quarantined; the scheduled §2.4 re-probe clears it.
319
+ this.ledger.quarantineSlot(slot, scrub(`oracle unavailable at verify: ${id.reason}`));
320
+ await this.raiseQuarantineAttention(slot, swapId, 'oracle unavailable at verify');
321
+ this.audit(swapId, 'quarantine-oracle-unavailable', slotA, slotB, `slot=${slot}`);
322
+ quarantined.push(slot);
323
+ continue;
324
+ }
325
+ if (id.accountId === want) {
326
+ continue; // identity match → good
327
+ }
328
+ // Identity MISMATCH with a reachable oracle → ONE repair from staging/fresh blob, re-verify.
329
+ this.audit(swapId, 'repair-attempt', slotA, slotB, `slot=${slot} expected=${want} got=${id.accountId}`);
330
+ await this.keychain.writeService(claudeCredentialService(slot), blob.raw);
331
+ const reId = await this.resolveIdentity(slot);
332
+ if (!('unavailable' in reId) && reId.accountId === want) {
333
+ this.audit(swapId, 'repair-ok', slotA, slotB, `slot=${slot}`);
334
+ continue;
335
+ }
336
+ // Still wrong (or the oracle went unavailable on re-verify) → quarantine, leave the other
337
+ // slot consistent. The bounded blast radius is one account re-auth (§6).
338
+ this.ledger.quarantineSlot(slot, scrub(`identity unrepairable after one attempt (wanted ${want})`));
339
+ await this.raiseQuarantineAttention(slot, swapId, 'identity mismatch unrepairable');
340
+ this.audit(swapId, 'quarantine-mismatch', slotA, slotB, `slot=${slot}`);
341
+ quarantined.push(slot);
342
+ }
343
+ if (quarantined.length > 0) {
344
+ return { quarantined: true, reason: 'one or more slots could not be identity-confirmed — quarantined (never repaired blindly)', quarantinedSlots: quarantined };
345
+ }
346
+ return { quarantined: false, reason: 'both slots identity-confirmed' };
347
+ }
348
+ // ── Step 6: delayed re-verify + staging delete ────────────────────────────────────────────────
349
+ scheduleReverify(slotA, slotB, wantA, wantB, staging, swapId) {
350
+ const run = async () => {
351
+ try {
352
+ await this.reverifyNow(slotA, slotB, wantA, wantB, staging, swapId);
353
+ }
354
+ catch (err) {
355
+ // @silent-fallback-ok — the delayed re-verify is best-effort cleanup; a failure here leaves
356
+ // the journal at `committed` and staging RETAINED, which boot-recovery resolves (the
357
+ // crash-safe state). It must never throw out of a timer and crash the process.
358
+ this.audit(swapId, 'reverify-error', slotA, slotB, scrub(err?.message ?? 'unknown'));
359
+ }
360
+ };
361
+ const t = setTimeout(() => void run(), this.reverifyDelayMs);
362
+ if (typeof t.unref === 'function')
363
+ t.unref();
364
+ }
365
+ /** Run the step-6 re-verify immediately (exposed for deterministic tests). */
366
+ async reverifyNow(slotA, slotB, wantA, wantB, staging, swapId) {
367
+ let allGood = true;
368
+ for (const [slot, want] of [
369
+ [slotA, wantA],
370
+ [slotB, wantB],
371
+ ]) {
372
+ const id = await this.resolveIdentity(slot);
373
+ if ('unavailable' in id || id.accountId !== want) {
374
+ // A write-back clobbered the slot (or the oracle is down). Do NOT blind-overwrite with
375
+ // staging (adopt-on-newer): re-read the slot; if it already identity-matches, the client
376
+ // healed it. Otherwise the honest outcome is needs-reauth/quarantine — surfaced, not papered.
377
+ allGood = false;
378
+ this.audit(swapId, 'reverify-divergence', slotA, slotB, `slot=${slot} want=${want}`);
379
+ if ('unavailable' in id) {
380
+ this.ledger.quarantineSlot(slot, scrub(`step-6 re-verify: oracle unavailable (${id.reason})`));
381
+ }
382
+ else {
383
+ this.ledger.quarantineSlot(slot, scrub(`step-6 re-verify: slot diverged from ${want} (a client write-back)`));
384
+ }
385
+ await this.raiseQuarantineAttention(slot, swapId, 'delayed re-verify divergence');
386
+ }
387
+ }
388
+ if (allGood) {
389
+ // Both slots still correct → delete staging, journal `done`. Staging is the heal source ONLY
390
+ // through this point (retaining it past `done` would orphan it; the sweep predicate protects
391
+ // any non-`done` row).
392
+ await this.keychain.deleteService(staging);
393
+ this.ledger.appendJournal({ op: 'swap', phase: 'done', slots: [slotA, slotB], detail: `swapId=${swapId} reverified` });
394
+ this.audit(swapId, 'done', slotA, slotB, 'delayed re-verify passed; staging deleted');
395
+ }
396
+ }
397
+ // ── Boot recovery (single-mover mutex; resolves in-flight journal rows; barrier with hang-timeout) ──
398
+ /**
399
+ * Resolve every in-flight swap journal row left by a crash. Acquires the single-mover mutex (a
400
+ * recovery WRITE must not race a boot balancer pass on the ledger). When it finishes (or the
401
+ * hang-timeout fires) the recovery barrier lifts so the balancer's first pass can run. A wedged
402
+ * recovery write quarantines its slots BEFORE the barrier lifts, so the post-lift balancer
403
+ * structurally cannot select a wedged slot.
404
+ */
405
+ async recover() {
406
+ if (this.recoveryDone)
407
+ return;
408
+ let lifted = false;
409
+ const lift = () => {
410
+ if (lifted)
411
+ return;
412
+ lifted = true;
413
+ this.recoveryDone = true;
414
+ this.recoveryResolve?.();
415
+ };
416
+ // Hang-timeout: a wedged recovery write (a keychain ACL prompt on the default slot) must not
417
+ // freeze the barrier forever. On timeout, quarantine the unresolved in-flight slots FIRST,
418
+ // THEN lift — the per-write 10s execFile timeout is what actually releases a held lock.
419
+ const inFlight = this.inFlightSwaps();
420
+ const barrierTimer = setTimeout(() => {
421
+ for (const e of inFlight) {
422
+ for (const slot of e.slots) {
423
+ try {
424
+ this.ledger.quarantineSlot(slot, 'boot-recovery barrier timed out — slot unresolved, fail-closed');
425
+ }
426
+ catch {
427
+ /* @silent-fallback-ok — ledger may be UNKNOWN-mode; the barrier still lifts so the balancer can run on the healthy remainder */
428
+ }
429
+ }
430
+ }
431
+ lift();
432
+ }, this.recoveryBarrierTimeoutMs);
433
+ if (typeof barrierTimer.unref === 'function')
434
+ barrierTimer.unref();
435
+ try {
436
+ await this.funnel.withSingleMover(async () => {
437
+ for (const entry of inFlight) {
438
+ await this.recoverEntry(entry);
439
+ }
440
+ });
441
+ }
442
+ finally {
443
+ clearTimeout(barrierTimer);
444
+ lift();
445
+ }
446
+ }
447
+ /** In-flight swap journal rows = a `begin`/`exchanged`/`verified` row whose swapId has no later `done`/`aborted`. */
448
+ inFlightSwaps() {
449
+ const bySwap = new Map();
450
+ for (const e of this.ledger.getJournal()) {
451
+ if (e.op !== 'swap')
452
+ continue;
453
+ const id = parseSwapId(e.detail);
454
+ if (!id)
455
+ continue;
456
+ const rec = bySwap.get(id) ?? { slots: e.slots, phases: new Set() };
457
+ rec.phases.add(e.phase);
458
+ if (e.slots.length)
459
+ rec.slots = e.slots;
460
+ bySwap.set(id, rec);
461
+ }
462
+ const out = [];
463
+ for (const [swapId, rec] of bySwap) {
464
+ const terminal = rec.phases.has('done') || rec.phases.has('aborted');
465
+ if (terminal)
466
+ continue;
467
+ const lastPhase = rec.phases.has('verified') ? 'verified' : rec.phases.has('exchanged') ? 'exchanged' : 'begin';
468
+ out.push({ swapId, slots: rec.slots, lastPhase });
469
+ }
470
+ return out;
471
+ }
472
+ /**
473
+ * Resolve one in-flight entry. Per §2.3 recovery decidability: re-read each slot, apply
474
+ * adopt-on-newer (never a blind staging overwrite), and verify identity; an unverifiable slot is
475
+ * quarantined (never repaired blindly). Staging is deleted + the row closed `done` ONLY when
476
+ * both slots verify; otherwise the row stays in-flight and its staging is retained (the sweep
477
+ * predicate protects any non-`done` row).
478
+ */
479
+ async recoverEntry(entry) {
480
+ const [slotA, slotB] = entry.slots;
481
+ if (!slotA || !slotB) {
482
+ // Cannot resolve a malformed entry; close it aborted (nothing to heal that we can identify).
483
+ this.ledger.appendJournal({ op: 'swap', phase: 'aborted', slots: entry.slots, detail: `swapId=${entry.swapId} unrecoverable-entry` });
484
+ return;
485
+ }
486
+ // The ledger's intended tenants after this swap are the CURRENT assignments (recordAssignment
487
+ // ran iff the swap committed); for an in-flight entry we resolve by reading the live identity
488
+ // of each slot and quarantining anything we cannot confirm. We never guess a tenant.
489
+ const staging = this.stagingService(entry.swapId);
490
+ let allConfirmed = true;
491
+ for (const slot of [slotA, slotB]) {
492
+ const id = await this.resolveIdentity(slot);
493
+ if ('unavailable' in id) {
494
+ allConfirmed = false;
495
+ this.ledger.quarantineSlot(slot, scrub(`boot-recovery: oracle unavailable (${id.reason})`));
496
+ await this.raiseQuarantineAttention(slot, entry.swapId, 'boot-recovery oracle unavailable');
497
+ }
498
+ }
499
+ if (allConfirmed) {
500
+ // Both slots carry a confirmable tenant → the exchange is coherent; clean up staging + close.
501
+ await this.keychain.deleteService(staging);
502
+ this.ledger.appendJournal({ op: 'swap', phase: 'done', slots: entry.slots, detail: `swapId=${entry.swapId} recovered` });
503
+ this.audit(entry.swapId, 'recovered', slotA, slotB, 'boot-recovery confirmed both slots');
504
+ }
505
+ else {
506
+ // Leave the row in-flight (staging retained — the heal source). The slot(s) are quarantined
507
+ // and excluded from balancing until a clean re-probe; staging is NOT deleted.
508
+ this.audit(entry.swapId, 'recover-deferred', slotA, slotB, 'a slot unconfirmed — staging retained, slots quarantined');
509
+ }
510
+ }
511
+ // ── Orphan-staging sweep (boot) ───────────────────────────────────────────────────────────────
512
+ /**
513
+ * A staging entry is an ORPHAN iff its swapId has NO journal row, OR its row is `done`. Any
514
+ * non-`done` row (begin/exchanged/verified) PROTECTS its staging (the heal source through step
515
+ * 6 — a literal "in-flight = begin only" reading would delete a committed row's heal source).
516
+ * Caller supplies the live staging-service list (the executor never enumerates the keychain).
517
+ */
518
+ async sweepOrphanStaging(stagingServices) {
519
+ // A swapId is PROTECTED iff it is currently in-flight — i.e. it has at least one swap journal
520
+ // row and NONE of them is terminal (`done`/`aborted`). `inFlightSwaps()` computes exactly that
521
+ // (a swapId with ANY `done` row drops out). A `done`/`aborted` swapId and a swapId with no row
522
+ // at all are both UNPROTECTED → their staging is an orphan and is deleted. (The earlier
523
+ // per-row "protect on any non-done row" reading would wrongly retain a finished swap's staging
524
+ // whose history still carries its `begin` row — the lost-source bug this predicate avoids in
525
+ // the other direction.)
526
+ const inFlightIds = new Set(this.inFlightSwaps().map((e) => e.swapId));
527
+ const deleted = [];
528
+ for (const service of stagingServices) {
529
+ if (!service.startsWith(STAGING_SERVICE_PREFIX))
530
+ continue;
531
+ const id = service.slice(STAGING_SERVICE_PREFIX.length);
532
+ if (inFlightIds.has(id))
533
+ continue;
534
+ await this.keychain.deleteService(service);
535
+ deleted.push(service);
536
+ }
537
+ return deleted;
538
+ }
539
+ // ── Blob read + validation ────────────────────────────────────────────────────────────────────
540
+ /** Read a slot's blob via the async keychain exec; require it parse + carry a refresh token. */
541
+ async readBlob(slot) {
542
+ const raw = await this.keychain.readService(claudeCredentialService(slot));
543
+ if (!raw)
544
+ return null;
545
+ let parsed;
546
+ try {
547
+ parsed = JSON.parse(raw);
548
+ }
549
+ catch {
550
+ return null; // @silent-fallback-ok: unparseable blob → precondition fail (never overwritten); the caller refuses the swap
551
+ }
552
+ const oauth = (parsed?.claudeAiOauth ?? null);
553
+ if (!oauth || typeof oauth !== 'object')
554
+ return null;
555
+ if (typeof oauth.refreshToken !== 'string' || !oauth.refreshToken)
556
+ return null;
557
+ return { raw, oauth };
558
+ }
559
+ async raiseQuarantineAttention(slot, swapId, why) {
560
+ if (!this.emitAttention)
561
+ return;
562
+ try {
563
+ await this.emitAttention({
564
+ id: `credential-swap-quarantine:${credentialSlotKey(slot)}`,
565
+ title: 'A credential slot was quarantined during a swap',
566
+ summary: scrub(`Slot ${expandHome(slot)} was quarantined (${why}); it is excluded from balancing until a clean re-probe. Blast radius: one account may need re-auth.`),
567
+ category: 'credential-repointing',
568
+ priority: 'HIGH',
569
+ sourceContext: 'credential-swap-executor',
570
+ });
571
+ }
572
+ catch {
573
+ // @silent-fallback-ok — best-effort notice; the quarantine (the safety action) is already
574
+ // durably recorded in the ledger whether or not this notice is delivered.
575
+ }
576
+ }
577
+ }
578
+ // ─── Module helpers ──────────────────────────────────────────────────────────────────────────────
579
+ /** Extract `swapId=<id>` from a journal detail string. */
580
+ function parseSwapId(detail) {
581
+ if (!detail)
582
+ return null;
583
+ const m = /swapId=([^\s]+)/.exec(detail);
584
+ return m ? m[1] : null;
585
+ }
586
+ /**
587
+ * Scrub any token material from a free-text string before it reaches a persisted/served/notified
588
+ * surface (§2.9 single-emit chokepoint). Catches the `sk-ant-…` family via `redactToken` on any
589
+ * token-shaped run, so a `security` stderr or a `${raw}`-bearing interpolation can't leak a byte.
590
+ */
591
+ function scrub(s) {
592
+ if (!s)
593
+ return s;
594
+ return s.replace(/sk-ant-[A-Za-z0-9_-]+/g, (m) => redactToken(m));
595
+ }
596
+ //# sourceMappingURL=CredentialSwapExecutor.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"CredentialSwapExecutor.js","sourceRoot":"","sources":["../../src/core/CredentialSwapExecutor.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "instar",
3
- "version": "1.3.535",
3
+ "version": "1.3.536",
4
4
  "description": "Coherence infrastructure for self-evolving AI agents — on the Claude Code or Codex subscription you already have.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -52,6 +52,13 @@ const ALLOWLIST = new Set([
52
52
  // Owns `KeychainCredentialProvider.writeCredentials` (the raw `security -i` write) +
53
53
  // the sanctioned `writeCredentialsSerialized` funnel chokepoint that wraps it.
54
54
  'src/monitoring/CredentialProvider.ts',
55
+ // Step 5 (spec section 2.3). Owns the async execFile add-generic-password keychain write
56
+ // primitive (defaultKeychainExec) used for slot + staging writes. Every credential write the
57
+ // executor performs runs INSIDE funnel.withSingleMover then funnel.withSlotLocks([A,B], ...) —
58
+ // the staged exchange takes the single-mover mutex AND both slot locks before any write, so a
59
+ // swap write can never interleave with a refresh/switch on the same slot. Funnel-routing is at
60
+ // the call layer (the primitive must NOT self-lock under the slot locks). Sanctioned route.
61
+ 'src/core/CredentialSwapExecutor.ts',
55
62
  // This lint file names the patterns it greps for.
56
63
  'scripts/lint-no-unfunneled-credential-write.js',
57
64
  ]);
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "$schema": "./builtin-manifest.schema.json",
3
3
  "schemaVersion": 1,
4
- "generatedAt": "2026-06-13T18:34:48.385Z",
5
- "instarVersion": "1.3.535",
4
+ "generatedAt": "2026-06-13T19:24:03.890Z",
5
+ "instarVersion": "1.3.536",
6
6
  "entryCount": 201,
7
7
  "entries": {
8
8
  "hook:session-start": {
@@ -0,0 +1,34 @@
1
+ # Upgrade Guide — vNEXT
2
+
3
+ <!-- assembled-by: assemble-next-md -->
4
+ <!-- bump: patch -->
5
+
6
+ ## What Changed
7
+
8
+ The staged-exchange primitive (spec §2.3) that MOVES an account's OAuth credential between two config-home "slots" without restarting the sessions reading them. The sessions switch token sources on their next API call (the Claude client re-reads its credential store), so no restart, no aborted turns.
9
+
10
+ - **Exchange, never copy** — `swap(slotA, slotB)` exchanges the two slots' credentials so exactly one account-lineage lives in each readable config home (the one-home-per-credential invariant holds by construction).
11
+ - **Preconditions before any path expansion** — both slots must be EXACT members of the location ledger's enumerated slot set before any home-expansion/keychain call, so a `../`/`~`/absolute traversal value can never reach a keychain service (validated against the ledger, not the filesystem); a quarantined or no-refresh-token slot is refused.
12
+ - **Source-slot CAS (§2.3.1a)** — immediately before each destructive write the slot is re-read; a same-tenant newer blob (the Claude client's own rotated copy) is ADOPTED, a different-tenant blob is a clobber-race that ABORTS the swap, and a changed blob the oracle cannot confirm ABORTS — never a silent overwrite.
13
+ - **Crash-proof staging** — blob A is COPIED into a disjoint `instar-credential-swap-staging-*` keychain namespace and `begin` is journaled BEFORE the first destructive write, so a crash before the exchange unwinds to a true no-op and a crash mid-exchange recovers from the retained escrow.
14
+ - **Identity-verified, repair-safe** — after the exchange each slot is verified on its ACCOUNT IDENTITY via the profile-endpoint oracle. A confirmed match commits; a mismatch with a reachable oracle gets ONE repair-from-staging then re-verify; an UNAVAILABLE oracle is quarantine-never-repair (an outage never triggers a destructive repair). The executor never commits a slot it cannot identity-confirm.
15
+ - **Commit, then delayed re-verify** — the ledger is updated with staging RETAINED, then ~90s later both slots are re-verified before staging is deleted and the journal closed (the heal source for a client write-back that lands in the in-flight window).
16
+ - **Bounded everywhere** — all `security` keychain calls are async with a 10s timeout (never sync, which could wedge the event loop); every swap runs under the single-mover mutex + both slot locks (the Step-4b funnel); boot recovery acquires the single-mover mutex and the balancer's first pass is gated on a recovery barrier WITH a hang-timeout (a wedged recovery can't freeze the system).
17
+ - **Dark** — gated by the existing `subscriptionPool.credentialRepointing` flag (`enabled:false` + `dryRun:true`). With the feature off (the fleet + dev default) the executor is a strict no-op; with dry-run on it runs the full decision loop and audits the would-swap with zero writes. Going live needs a deliberate two-flag flip.
18
+
19
+ The HTTP route + census re-routing that wire this into the running system are Steps 6 and 7 (a later increment); this ships the swap primitive plus its three test tiers.
20
+
21
+ ## What to Tell Your User
22
+
23
+ This is internal plumbing that ships turned off, so day to day nothing changes for you. What it builds toward: when I hold more than one of your subscription accounts, I can move which account a running session is using WITHOUT restarting that session — today moving an account means killing and respawning the session, which loses in-flight work and re-pays the warm-up cost. The new mechanism swaps the credential under the hood and the session just picks up the new account on its next request. It is built to be safe under failure: it never leaves a session pointed at an account it could not positively confirm, it survives a crash mid-swap without losing a login, and it can never run two swaps at once on the same machine. It is off by default and does nothing until it is deliberately turned on after a review window.
24
+
25
+ ## Summary of New Capabilities
26
+
27
+ New exported `CredentialSwapExecutor` (src/core/CredentialSwapExecutor.ts) — the spec §2.3 staged-exchange primitive of live credential re-pointing. Gated by the existing `subscriptionPool.credentialRepointing` flag; no new config flag, no new HTTP route (routes are a later step). Consumes the merged Step 2/3/4 primitives (location ledger, identity oracle, write funnel).
28
+
29
+ ## Evidence
30
+
31
+ - `tests/unit/credential-swap-executor.test.ts` (20) — dark/dryRun inertness; preconditions (non-member/quarantined/no-refresh-token rejection, zero writes); the happy exchange (keychain-first, identity-verified, one-lineage-per-home, staging retained then deleted at step 6); clobber-race (different-tenant abort, same-tenant-newer adopt); identity verify/repair/quarantine (mismatch repair-then-commit; oracle-unavailable quarantine-never-repair); THE blocker lens (no committed-unverified write); permutation-property (2 and 5 concurrent swaps serialize, exactly one exchange); crash-at-every-boundary (budgets 0/1/2 + hang-timeout barrier quarantining before lift); orphan-staging sweep predicate; token-material scrub.
32
+ - `tests/integration/credential-swap-executor-host.test.ts` (2) — the executor composed as its host will (REAL CredentialIdentityOracle profile-endpoint + REAL pool-mapping + REAL ledger + REAL funnel): oracle ALLOW yields commit; oracle UNAVAILABLE (5xx) at verify yields quarantine + attention.
33
+ - `tests/e2e/credential-swap-executor-dark-ship-lifecycle.test.ts` (3) — Phase-1 dark-ship inertness on the production config path: a DEV and a fleet agent's real ConfigDefaults gate the executor to a strict no-op; recovery on the dark config is still crash-safe.
34
+ - tsc clean; full `npm run lint` clean; dark-gate unchanged; docs-coverage green (CredentialSwapExecutor documented in architecture/under-the-hood.md).
@@ -0,0 +1,64 @@
1
+ # Side-Effects Review — WS5.2 Step 5: CredentialSwapExecutor (staged-exchange swap primitive)
2
+
3
+ **Spec:** docs/specs/live-credential-repointing-rebalancer.md §2.3 (converged 5 rounds 50→0, `approved:true` — operator pre-approval, Justin topic 13481). **Parent:** Cross-Machine Coherence — One Agent, Robust Under Degraded Conditions.
4
+ **Ships DARK** behind the EXISTING `subscriptionPool.credentialRepointing` flag (`enabled:false` + `dryRun:true`, a `DARK_GATE_EXCLUSIONS` `destructive` entry shipped by Step 4b). Going live needs a deliberate two-flag flip. No new config flag, no new dark-gate line.
5
+ **Files:** src/core/CredentialSwapExecutor.ts (new), scripts/lint-no-unfunneled-credential-write.js (allowlist + header), tests/unit/lint-no-unfunneled-credential-write.test.ts (allowlist ratchet), tests/unit/credential-swap-executor.test.ts (new), tests/integration/credential-swap-executor-host.test.ts (new), tests/e2e/credential-swap-executor-dark-ship-lifecycle.test.ts (new), site/src/content/docs/architecture/under-the-hood.md (awareness).
6
+
7
+ ## What changed
8
+
9
+ 1. **CredentialSwapExecutor.ts (new):** the §2.3 staged-exchange primitive. `swap(slotA, slotB)` EXCHANGES (never copies) the two slots' OAuth credentials so the sessions reading them switch token sources on their next API call (E3) with zero restart. The full §2.3 sequence: preconditions (EXACT ledger membership BEFORE any path expansion → traversal can't reach a keychain service) → §2.3.1a source-slot CAS re-read (adopt a same-tenant newer blob, ABORT a different-tenant clobber-race) → staging escrow COPY into a disjoint `instar-credential-swap-staging-*` namespace + journal `begin` → exchange (keychain-first, config-second) → verify on ACCOUNT IDENTITY via the injected oracle resolver (match→commit, mismatch→one repair-from-staging→re-verify, **unavailable→QUARANTINE-NEVER-REPAIR**) → commit with staging RETAINED → delayed ~90s re-verify → delete staging + journal `done`. Consumes the merged primitives: `CredentialWriteFunnel.withSingleMover`+`withSlotLocks`, `CredentialLocationLedger` (membership/journal/quarantine/recordAssignment), and an injected `ResolveSlotIdentity` (composed by the host from `CredentialIdentityOracle`+pool).
10
+ 2. **Async keychain exec surface (`KeychainCredentialExec` + `defaultKeychainExec`):** ALL `security` calls go through async `execFile` + a 10s timeout — NEVER `execFileSync` (a locked-keychain ACL prompt would wedge the event loop). Injectable so tests run zero-keychain. The default impl derives the slot service via the imported `claudeCredentialService(slot)`; the literal `'Claude Code-credentials'` never appears in the executor source.
11
+ 3. **Boot recovery (`recover()`):** acquires the single-mover mutex (a recovery WRITE must not race a boot balancer pass on the ledger), resolves every in-flight journal row by re-reading live identity (adopt-on-newer, never a blind staging overwrite), and lifts a **recovery-complete barrier** so the balancer's first pass can run. The barrier carries a bounded HANG-TIMEOUT: a wedged recovery write quarantines its slots BEFORE the lift, so the post-lift balancer structurally cannot select a wedged slot. Recovery completion is INDEPENDENT of dryRun (an already-begun exchange is finished for crash-safety even on the dark config).
12
+ 4. **`sweepOrphanStaging()`:** the §2.3 step-2 orphan predicate — a staging entry is protected by ANY non-`done` swap journal row (begin/exchanged/verified all keep their staging as the step-6 heal source); only a `done` row (or no row) makes staging an orphan. Avoids both the lost-source bug (deleting a committed row's heal source) and the orphan-leak.
13
+ 5. **Single audit + scrub chokepoint:** every audited step routes through one `audit()` funnel that scrubs token material (`sk-ant-…` runs → `redactToken`) before it reaches any persisted/served/notified surface (§2.9). Quarantines raise ONE HIGH attention item (the blast-radius surface).
14
+ 6. **lint allowlist (Step 4b ratchet):** `src/core/CredentialSwapExecutor.ts` added to the `lint-no-unfunneled-credential-write.js` closed allowlist (named in the header as the sanctioned Step-5 funnel route — its raw `add-generic-password` keychain write runs INSIDE `funnel.withSingleMover → withSlotLocks`, so the funnel-routing is at the call layer; the primitive must not self-lock under the slot locks). The self-test that asserts the closed allowlist set was updated to include the 5th entry.
15
+ 7. **Awareness:** a `CredentialSwapExecutor` subsection in `architecture/under-the-hood.md` (Core Infrastructure) — 2 mentions, satisfies docs-coverage for the new exported class.
16
+
17
+ ## Blast radius
18
+
19
+ - **The path is config-gated to a strict no-op while dark.** `swap()` returns `disabled` (zero keychain writes, zero ledger mutation) the instant `enabled` is false — the fleet + dev default. `dryRun:true` runs the full decision loop (preconditions + CAS) and audits the WOULD-swap but performs ZERO writes (returns `dry-run` before the staging step). Proven by the E2E dark-ship test (DEV and fleet config) + the unit dark/dryRun tests + the dark-gate test (16/16, unchanged).
20
+ - **No new flag, no new dark-gate line.** The executor reuses the Step-4b `subscriptionPool.credentialRepointing` flag already in `DARK_GATE_EXCLUSIONS`/`ConfigDefaults`, so the cartographer dark-gate line-map did not shift (no recompute needed; `lint-dev-agent-dark-gate.js` clean, the dark-gate test green as-is).
21
+ - **No unverified live commit.** A slot the oracle cannot identity-confirm (mismatch-unrepairable OR oracle-unavailable) is quarantined + surfaced; `swap()` returns `quarantined` BEFORE `recordAssignment` — the ledger never records a clean assignment for an unverified slot. Proven by THE blocker lens test.
22
+ - **No unfunneled credential write.** Every keychain credential write is inside `withSingleMover → withSlotLocks` (the Step-4b funnel guards a swap against a concurrent refresh/switch on the same slot). `lint-no-unfunneled-credential-write` clean.
23
+ - **Per-machine local, no cross-machine egress (Phase C).** The swap moves a credential between config slots on ONE machine; it carries no LAN/broadcast assumption and adds no cross-machine traffic. An N-machine pool runs one single-mover mutex + recovery barrier PER machine; each machine's executor is independent and bounded — the fleet never has two movers on the same home.
24
+
25
+ ## Risk + mitigation
26
+
27
+ - **Risk:** a concurrent Claude-client keychain write on the source slot strands a rotated lineage (the §0.c residual instar cannot lock). **Mitigation:** §2.3.1a source-slot CAS re-reads each slot immediately before the destructive write; a same-tenant newer blob is ADOPTED (carry the client's rotated copy), a different-tenant blob ABORTS the swap (clobber-race), an unconfirmable changed blob ABORTS (never overwrite what we can't identity-verify). The window is narrowed (final-re-read→write), not closed against an external writer — the §2.3.6 delayed re-verify + the §2.4 audit catch the honest residual. Proven by the clobber-race adopt/abort tests.
28
+ - **Risk:** a crash mid-exchange destroys a blob or strands staging. **Mitigation:** staging is a COPY (slot A untouched until step 3 → crash-before-step-3 unwinds to a no-op); `begin` is journaled before the first destructive write; staging is RETAINED through step 6 (the heal source); recovery resolves every in-flight row by live identity (adopt-on-newer, never blind). Proven by the crash-at-every-boundary tests (budgets 0/1/2) + the orphan-sweep predicate test.
29
+ - **Risk:** an oracle outage during verify triggers a destructive repair-storm against healthy blobs. **Mitigation:** oracle-UNAVAILABLE is read as "unverified", NEVER as "mismatch" — it quarantines (never repairs); repair is reserved for a CONFIRMED mismatch with a reachable oracle. Proven by the quarantine-never-repair test + the host-wired REAL-oracle 5xx test.
30
+ - **Risk:** a wedged recovery write (keychain ACL prompt) freezes the balancer forever. **Mitigation:** the recovery barrier carries a bounded hang-timeout that quarantines the unresolved slots THEN lifts (the per-write 10s execFile timeout is what releases the held lock). Proven by the hang-timeout barrier test.
31
+ - **Risk:** a token byte leaks into a log/attention/reason string. **Mitigation:** single `audit()`/scrub chokepoint redacts every `sk-ant-…` run before any surface; quarantine reasons + oracle error strings pass through `scrub()`. Proven by the token-scrub test (an adversarial token-bearing oracle reason is redacted).
32
+ - **Risk:** two swaps interleave and corrupt a slot. **Mitigation:** the single-mover mutex admits exactly one swap; the loser is a transient `skipped` (retry), never a partial write. Proven by the permutation-property tests (2 and 5 concurrent swaps → exactly one exchange).
33
+
34
+ ## Migration parity
35
+
36
+ - **No migration needed.** No new config flag (reuses the Step-4b `credentialRepointing` block already in `ConfigDefaults` + `DARK_GATE_EXCLUSIONS`), no new hook, no CLAUDE.md template section (this ships dark + internal; the awareness note lives in the docs site, not the agent template — so no `templates.ts`/`migrateClaudeMd` touch, no `featureSections`/shadow-marker entry). The executor is a new module consumed by the Step-6/7 host wiring (a later increment); nothing on the production path constructs it live yet.
37
+
38
+ ## Dark-gate line-map
39
+
40
+ - UNCHANGED. No new `enabled:` literal was added to `ConfigDefaults.ts` — the executor is gated by the existing `subscriptionPool.credentialRepointing.enabled` line (shipped by Step 4b). The dark-gate attributor reads `ConfigDefaults.ts` `enabled:` lines only; none shifted. Verified: `node scripts/lint-dev-agent-dark-gate.js` → clean; `tests/unit/credential-repointing-dark-gate.test.ts` → green; the dark-gate ground-truth test → unchanged.
41
+
42
+ ## Rollback
43
+
44
+ - The feature is already dark (`enabled:false` + `dryRun:true`) — the executor is inert. To fully revert Step 5: remove `src/core/CredentialSwapExecutor.ts` + its three test files, revert the lint allowlist entry + its self-test edit, and drop the `under-the-hood.md` subsection. Dark + additive throughout; no consumer on the production path depends on it yet.
45
+
46
+ ## Tests
47
+
48
+ - `tests/unit/credential-swap-executor.test.ts` (20) — dark/dryRun inertness; preconditions (non-member/quarantined/no-refresh-token rejection, zero writes); the happy exchange (keychain-first, identity-verified, §0.d one-lineage-per-home, staging retained→step-6 delete); **clobber-race** (different-tenant→abort, same-tenant-newer→adopt); **identity verify/repair/quarantine** (mismatch→one-repair→commit; oracle-unavailable→quarantine-never-repair); **THE blocker lens** (no committed-unverified write); **permutation-property** (2 + 5 concurrent swaps serialize, exactly one exchange); **crash-at-every-boundary** (budgets 0/1/2 + hang-timeout barrier with pre-lift quarantine); orphan-staging sweep predicate; token-scrub.
49
+ - `tests/integration/credential-swap-executor-host.test.ts` (2) — the executor composed exactly as the Step-7 host will (REAL `CredentialIdentityOracle` profile-endpoint + REAL pool-mapping + REAL ledger + REAL funnel): oracle ALLOW → commit; oracle UNAVAILABLE (5xx) at verify → QUARANTINE + attention.
50
+ - `tests/e2e/credential-swap-executor-dark-ship-lifecycle.test.ts` (3) — Phase-1 dark-ship inertness on the PRODUCTION config path: a DEV and a fleet agent's real ConfigDefaults gate the executor to a strict no-op (zero writes, ledger untouched, byte-identical blobs); recovery on the dark config is still crash-safe (an already-begun exchange finishes regardless of dryRun).
51
+ - Full unit suite green (zero regressions); `npm run lint` clean (tsc + all structural lints).
52
+
53
+ ## Phase C answer (operator clarity)
54
+
55
+ The swap is **per-machine local** — it moves a credential between config slots on ONE machine and adds no cross-machine egress / no LAN/broadcast assumption. An N-machine pool runs its single-mover mutex + recovery barrier independently per machine, so the fleet never has two movers on the same home; each machine's executor is bounded by its own mutex.
56
+
57
+ ## 4-lens adversarial verdict
58
+
59
+ - **Unverified-write bypass (THE blocker):** PASS — no path commits a slot the oracle cannot identity-confirm; an unconfirmable slot is quarantined and `swap()` returns before `recordAssignment`. Test: "no committed-unverified write".
60
+ - **Clobber-race / source-slot CAS:** PASS — a concurrent client write is adopted (same tenant) or aborts (different tenant / unconfirmable), never silently overwritten. Tests: clobber-race adopt/abort.
61
+ - **Crash-boundary coherence:** PASS — journal begin/done + retained staging + adopt-on-newer recovery leave no lost-lineage state. Tests: crash budgets 0/1/2 + hang-timeout barrier.
62
+ - **Dark-ship inertness:** PASS — flag off → strict no-op; reuses the existing dark-gate exclusion (no line shift). Tests: E2E dark-ship (dev+fleet) + dark-gate.
63
+
64
+ <!-- tracked: 20905 -->