@event4u/agent-switch 1.7.0 → 2.0.0

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.
package/dist/rebind.js ADDED
@@ -0,0 +1,715 @@
1
+ /**
2
+ * `rebind` — the ONE sanctioned write path into Claude Code's credential store
3
+ * (ADR-003). It points a *running* profile's store at another account so the
4
+ * live Claude session adopts it on its next message — no relaunch, any terminal.
5
+ *
6
+ * Mechanism proven by the Phase-0 spikes against CC 2.1.218 (macOS): a swap of
7
+ * the hashed Keychain entry under Claude Code's own lock is picked up by the
8
+ * running session (r02); the lock is a real mutex and a MOVE keeps one token
9
+ * family in one live store (r03).
10
+ *
11
+ * Scope of THIS slice (macOS Keychain only): `rebind` refuses on Linux/Windows —
12
+ * the `.credentials.json` backend (spike R0.1) is not yet validated there.
13
+ *
14
+ * Move-semantics + safety: the target account's credential is moved INTO the
15
+ * running profile's store and the target's own store is emptied, so the family
16
+ * is live in exactly one store. Both original credentials are stashed in a
17
+ * per-profile binding-marker BEFORE any mutation, so `--restore` fully reverses
18
+ * it and a crash leaves a recoverable marker. Nothing is deleted without a stash.
19
+ *
20
+ * Deferred to later Phase-2 slices (see road-to-live-rebind.md), not built here:
21
+ * live pid/process auto-detection, a global cross-profile binding registry +
22
+ * global lock, provenance-fingerprint mismatch states, a rollback feature-flag /
23
+ * circuit-breaker, and the Linux/Win backend.
24
+ */
25
+ import { execFileSync } from "node:child_process";
26
+ import * as crypto from "node:crypto";
27
+ import * as fs from "node:fs";
28
+ import * as nodePath from "node:path";
29
+ import { fetchProfile } from "./api.js";
30
+ import * as keychain from "./keychain.js";
31
+ import { withProperLock } from "./locks.js";
32
+ import { configDir, ROOT, readRebindKillSwitch, writeRebindKillSwitch } from "./profiles.js";
33
+ import { freshenToken } from "./token-freshen.js";
34
+ /** <10 min to expiry → refuse (2× Claude Code's own 5-min refresh buffer). */
35
+ export const FRESHEN_FLOOR_MS = 10 * 60 * 1000;
36
+ const MARKER_NAME = ".agent-switch-rebind.json";
37
+ const LENT_SUFFIX = ".rebind-lent";
38
+ const QUARANTINE_SUFFIX = ".rebind-quarantine";
39
+ /** Claude Code's own per-profile config file — carries the `oauthAccount` stamp. */
40
+ const CC_CONFIG_NAME = ".claude.json";
41
+ /** Circuit-breaker trip point: N consecutive `rebind()` failures → auto-disable. */
42
+ export const REBIND_FAILURE_LIMIT = 3;
43
+ /**
44
+ * Global cross-profile binding registry (Council finding 2). The per-profile
45
+ * marker cannot enforce the GLOBAL "one account bound to at most one profile"
46
+ * invariant — two concurrent `rebind`s could bind one account to two profiles.
47
+ * This registry (keyed by the target account/profile name) is read-checked and
48
+ * written under a GLOBAL lock acquired OUTER to Claude Code's per-profile lock.
49
+ */
50
+ const REGISTRY_NAME = ".agent-switch-rebind-registry.json";
51
+ /** Lock TARGET for the registry (withProperLock guards `<target>.lock`). */
52
+ const REGISTRY_LOCK_BASENAME = ".agent-switch-rebind-registry";
53
+ /**
54
+ * Account ids already resolved in this process, keyed by a hash of the access
55
+ * token (never the token itself). Only successes are cached, so an offline
56
+ * failure is retried rather than remembered as "unknown".
57
+ */
58
+ const resolvedAccounts = new Map();
59
+ /**
60
+ * Resolve the account behind a credential against Anthropic's profile endpoint —
61
+ * the same read-only GET Claude Code itself uses. This is the ONLY way to tell
62
+ * two accounts apart: a Claude Code credential carries no account claim at all
63
+ * (just tokens, scopes, subscription tier), so nothing local identifies its owner.
64
+ */
65
+ async function resolveAccountViaApi(cred) {
66
+ const tok = accessToken(cred);
67
+ if (!tok)
68
+ return null;
69
+ const key = crypto.createHash("sha256").update(tok).digest("hex");
70
+ const hit = resolvedAccounts.get(key);
71
+ if (hit)
72
+ return hit;
73
+ let profile = null;
74
+ try {
75
+ profile = await fetchProfile(tok);
76
+ }
77
+ catch {
78
+ return null;
79
+ }
80
+ const account = profile?.account ?? null;
81
+ const id = [account?.uuid, account?.email_address, account?.email].find((v) => typeof v === "string" && v.length > 0);
82
+ if (typeof id !== "string")
83
+ return null;
84
+ resolvedAccounts.set(key, id);
85
+ return id;
86
+ }
87
+ export function defaultDeps() {
88
+ return {
89
+ platform: process.platform,
90
+ now: () => Date.now(),
91
+ serviceNameFor: keychain.serviceNameFor,
92
+ kcGet: keychain.getPassword,
93
+ kcAdd: keychain.addPassword,
94
+ kcDelete: keychain.deletePassword,
95
+ readFile: (p) => {
96
+ try {
97
+ return fs.readFileSync(p, "utf8");
98
+ }
99
+ catch {
100
+ return null;
101
+ }
102
+ },
103
+ writeFile: (p, data) => fs.writeFileSync(p, data, { mode: 0o600 }),
104
+ removeFile: (p) => {
105
+ try {
106
+ fs.rmSync(p, { force: true });
107
+ }
108
+ catch {
109
+ /* best-effort */
110
+ }
111
+ },
112
+ renameFile: (from, to) => fs.renameSync(from, to),
113
+ exists: (p) => fs.existsSync(p),
114
+ withLock: withProperLock,
115
+ readRebindState: readRebindKillSwitch,
116
+ writeRebindState: writeRebindKillSwitch,
117
+ freshen: (cfg) => {
118
+ freshenToken(cfg);
119
+ },
120
+ claudeVersion: () => {
121
+ try {
122
+ return execFileSync("claude", ["--version"], { encoding: "utf8", timeout: 5000 }).trim() || null;
123
+ }
124
+ catch {
125
+ return null;
126
+ }
127
+ },
128
+ resolveAccount: resolveAccountViaApi,
129
+ };
130
+ }
131
+ function credFile(cfgDir) {
132
+ return nodePath.join(cfgDir, ".credentials.json");
133
+ }
134
+ function markerFile(cfgDir) {
135
+ return nodePath.join(cfgDir, MARKER_NAME);
136
+ }
137
+ function accessToken(cred) {
138
+ if (!cred)
139
+ return null;
140
+ try {
141
+ return JSON.parse(cred)?.claudeAiOauth?.accessToken ?? null;
142
+ }
143
+ catch {
144
+ return null;
145
+ }
146
+ }
147
+ function expiresAt(cred) {
148
+ if (!cred)
149
+ return null;
150
+ try {
151
+ const v = JSON.parse(cred)?.claudeAiOauth?.expiresAt;
152
+ return typeof v === "number" ? v : null;
153
+ }
154
+ catch {
155
+ return null;
156
+ }
157
+ }
158
+ /** Read a profile's live credential: Keychain first, then the plaintext file. */
159
+ function readCred(cfgDir, d) {
160
+ return d.kcGet(d.serviceNameFor(cfgDir)) ?? d.readFile(credFile(cfgDir));
161
+ }
162
+ // ---------- identity stamp (`.claude.json` oauthAccount) ----------
163
+ function ccConfigFile(cfgDir) {
164
+ return nodePath.join(cfgDir, CC_CONFIG_NAME);
165
+ }
166
+ /**
167
+ * The `oauthAccount` block Claude Code stamps into a config dir's `.claude.json`
168
+ * whenever it fetches the account profile. This is the field `agent-switch list`
169
+ * reports as the profile's owner (providers.ts `readIdentity`).
170
+ */
171
+ function readStampedIdentity(cfgDir, d) {
172
+ const raw = d.readFile(ccConfigFile(cfgDir));
173
+ if (!raw)
174
+ return null;
175
+ try {
176
+ const stamp = JSON.parse(raw)?.oauthAccount;
177
+ return stamp && typeof stamp === "object" ? stamp : null;
178
+ }
179
+ catch {
180
+ return null;
181
+ }
182
+ }
183
+ /**
184
+ * Put a profile's OWN identity stamp back into its `.claude.json`.
185
+ *
186
+ * While a profile is rebound, Claude Code fetches the BORROWED account's profile
187
+ * under the running profile's config dir and stamps that account into this dir's
188
+ * `oauthAccount`. Nothing used to un-stamp it, so a restored profile kept
189
+ * reporting the lender's e-mail in `list` forever — the profile looked like it
190
+ * belonged to the wrong account long after the credential was correct again.
191
+ *
192
+ * Claude Code guards `.claude.json` writes with a lock on the FILE (locks.ts),
193
+ * not on the config dir, so the read-modify-write runs under that file lock —
194
+ * acquired INSIDE the caller's dir lock (distinct targets, no reentrancy).
195
+ * Best-effort by design: this runs on the recovery path, so a missing, corrupt,
196
+ * or already-correct config file is skipped and never throws.
197
+ */
198
+ async function restoreStampedIdentity(cfgDir, own, d) {
199
+ if (!own || typeof own !== "object")
200
+ return false;
201
+ const file = ccConfigFile(cfgDir);
202
+ try {
203
+ return await d.withLock(file, () => {
204
+ const raw = d.readFile(file);
205
+ if (!raw)
206
+ return false;
207
+ const cfg = JSON.parse(raw);
208
+ if (!cfg || typeof cfg !== "object")
209
+ return false;
210
+ if (stableStringify(cfg.oauthAccount ?? null) === stableStringify(own))
211
+ return false;
212
+ cfg.oauthAccount = own;
213
+ d.writeFile(file, JSON.stringify(cfg, null, 2) + "\n");
214
+ return true;
215
+ });
216
+ }
217
+ catch {
218
+ return false;
219
+ }
220
+ }
221
+ // ---------- global binding registry (Council finding 2) ----------
222
+ function registryFile() {
223
+ return nodePath.join(ROOT, REGISTRY_NAME);
224
+ }
225
+ /** Lock target for the global registry (outer to CC's per-profile lock). */
226
+ function registryLockTarget() {
227
+ return nodePath.join(ROOT, REGISTRY_LOCK_BASENAME);
228
+ }
229
+ /** Read the global binding registry (corrupt / absent → empty). */
230
+ function readRegistry(d) {
231
+ const raw = d.readFile(registryFile());
232
+ if (!raw)
233
+ return {};
234
+ try {
235
+ const parsed = JSON.parse(raw);
236
+ if (parsed && typeof parsed === "object") {
237
+ const out = {};
238
+ for (const [k, v] of Object.entries(parsed)) {
239
+ const e = v;
240
+ if (e && typeof e.runningProfile === "string" && typeof e.boundAt === "string") {
241
+ out[k] = { runningProfile: e.runningProfile, boundAt: e.boundAt };
242
+ }
243
+ }
244
+ return out;
245
+ }
246
+ }
247
+ catch {
248
+ /* corrupt registry → treat as empty */
249
+ }
250
+ return {};
251
+ }
252
+ function writeRegistry(reg, d) {
253
+ d.writeFile(registryFile(), JSON.stringify(reg, null, 2) + "\n");
254
+ }
255
+ /** Read the whole registry (test/observability helper). */
256
+ export function readBindingRegistry(d = defaultDeps()) {
257
+ return readRegistry(d);
258
+ }
259
+ // ---------- provenance fingerprint (Council finding 5) ----------
260
+ /** Deterministic JSON: object keys sorted recursively, so the hash is stable. */
261
+ function stableStringify(value) {
262
+ if (value === null || typeof value !== "object")
263
+ return JSON.stringify(value);
264
+ if (Array.isArray(value))
265
+ return "[" + value.map(stableStringify).join(",") + "]";
266
+ const keys = Object.keys(value).sort();
267
+ return "{" + keys.map((k) => JSON.stringify(k) + ":" + stableStringify(value[k])).join(",") + "}";
268
+ }
269
+ /**
270
+ * Stable account identity for a credential. Prefers an explicit stable claim in
271
+ * `claudeAiOauth` (account/subscription id, email); otherwise a sha256 of the
272
+ * `claudeAiOauth` object EXCLUDING the volatile token material
273
+ * (`accessToken`/`refreshToken`/`expiresAt`) — so a token ROTATION keeps the same
274
+ * fingerprint while a different ACCOUNT changes it. `id` is null only when the
275
+ * credential is missing / unparseable / has no `claudeAiOauth` object.
276
+ */
277
+ export function accountFingerprint(cred) {
278
+ if (!cred)
279
+ return { id: null };
280
+ let oauth;
281
+ try {
282
+ const parsed = JSON.parse(cred);
283
+ oauth = parsed && typeof parsed.claudeAiOauth === "object" ? parsed.claudeAiOauth : null;
284
+ }
285
+ catch {
286
+ return { id: null };
287
+ }
288
+ if (!oauth)
289
+ return { id: null };
290
+ // Prefer a stable identity claim, in precedence order.
291
+ const account = (oauth.account ?? null);
292
+ const org = (oauth.organization ?? null);
293
+ const candidates = [
294
+ oauth.accountUuid,
295
+ account?.uuid,
296
+ account?.account_uuid,
297
+ oauth.accountId,
298
+ account?.email_address,
299
+ oauth.emailAddress,
300
+ oauth.email,
301
+ oauth.subscriptionId,
302
+ oauth.organizationUuid,
303
+ org?.uuid,
304
+ ];
305
+ for (const c of candidates) {
306
+ if (typeof c === "string" && c.length > 0)
307
+ return { id: "claim:" + c };
308
+ }
309
+ // Fallback: hash the account-stable subset of the oauth object.
310
+ // refreshTokenExpiresAt moves on every refresh-token rotation, so it is
311
+ // token material, not account identity — including it made a rotated
312
+ // credential of the SAME account fingerprint as "different".
313
+ const rest = { ...oauth };
314
+ delete rest.accessToken;
315
+ delete rest.refreshToken;
316
+ delete rest.expiresAt;
317
+ delete rest.refreshTokenExpiresAt;
318
+ const digest = crypto.createHash("sha256").update(stableStringify(rest), "utf8").digest("hex").slice(0, 16);
319
+ return { id: "sha256:" + digest };
320
+ }
321
+ /** Classify `actual` (what a store holds NOW) against `recorded` (what we expect). */
322
+ function fingerprintCompare(recorded, actual) {
323
+ const rid = accountFingerprint(recorded).id;
324
+ const aid = accountFingerprint(actual).id;
325
+ if (rid === null || aid === null)
326
+ return "unparseable";
327
+ if (rid !== aid)
328
+ return "different";
329
+ return accessToken(recorded) === accessToken(actual) ? "same" : "rotated";
330
+ }
331
+ /**
332
+ * The provenance verdict the write path acts on: {@link fingerprintCompare},
333
+ * escalated to the account endpoint where the local answer cannot be trusted.
334
+ *
335
+ * Why the escalation exists: the local "rotated" verdict means *same fingerprint,
336
+ * different access token*. That is conclusive only when the fingerprint came from
337
+ * a stable account CLAIM. Real Claude Code credentials carry NO claim, so the
338
+ * fallback hash covers only `scopes` + `subscriptionType` + `rateLimitTier` — every
339
+ * account on the same plan tier hashes IDENTICALLY. A re-login to a DIFFERENT
340
+ * account of the same tier was therefore indistinguishable from a token rotation,
341
+ * and "rotated" authorizes moving a credential (swap) or writing it into another
342
+ * profile's store (restore) — i.e. the blind spot let a foreign account's
343
+ * credential be handed to the wrong profile.
344
+ *
345
+ * `"indeterminate"` when the accounts cannot be established (offline, endpoint
346
+ * unreachable): callers must refuse WITHOUT mutating any store. Guessing "rotated"
347
+ * there is precisely the unsafe assumption this replaces.
348
+ */
349
+ async function compareAccounts(recorded, actual, d) {
350
+ const local = fingerprintCompare(recorded, actual);
351
+ if (local !== "rotated")
352
+ return local;
353
+ // A claim-backed fingerprint already proves the account — no lookup needed.
354
+ if (accountFingerprint(recorded).id?.startsWith("claim:"))
355
+ return "rotated";
356
+ const [rid, aid] = await Promise.all([d.resolveAccount(recorded), d.resolveAccount(actual)]);
357
+ if (!rid || !aid)
358
+ return "indeterminate";
359
+ return rid === aid ? "rotated" : "different";
360
+ }
361
+ /** The active binding on a running profile's config dir, or null. */
362
+ export function readBinding(cfgDir, d = defaultDeps()) {
363
+ const raw = d.readFile(markerFile(cfgDir));
364
+ if (!raw)
365
+ return null;
366
+ try {
367
+ const m = JSON.parse(raw);
368
+ if (m && typeof m.boundToProfile === "string" && typeof m.runningOwnCredential === "string" && typeof m.targetOrigCredential === "string") {
369
+ return m;
370
+ }
371
+ }
372
+ catch {
373
+ /* corrupt marker → treat as none */
374
+ }
375
+ return null;
376
+ }
377
+ export class RebindError extends Error {
378
+ }
379
+ /**
380
+ * A guard REFUSAL: rebind declined before/without mutating any credential store
381
+ * (wrong platform, not logged in, spent token, registry conflict, provenance
382
+ * mismatch, …). Distinct from a real swap FAILURE so the circuit-breaker only
383
+ * counts genuine write-path breakage — three refusals of a user retrying the
384
+ * same doomed switch must never disable rebind entirely (that was the failure
385
+ * mode: "already rebound" ×3 → breaker tripped → switching dead until --reset).
386
+ */
387
+ export class RebindRefused extends RebindError {
388
+ }
389
+ // ---------- credential-store contract canary (Council finding 3) ----------
390
+ /**
391
+ * Pinned Keychain service-name shape from the Phase-0 spikes (see keychain.ts):
392
+ * `"Claude Code-credentials-" + sha256(NFC(configDir)).hex[:8]`. rebind's whole
393
+ * swap targets the entry under this name — if Claude Code's naming drifted, the
394
+ * swap would read/write the wrong (or a non-existent) entry.
395
+ */
396
+ export const PINNED_SERVICE_NAME_RE = /^Claude Code-credentials-[0-9a-f]{8}$/;
397
+ /**
398
+ * Fail loud if Claude Code's credential-store naming contract drifted from what
399
+ * the Phase-0 spikes pinned. Runs BEFORE any read or mutation in {@link rebind} —
400
+ * a drift here means the swap could target the wrong Keychain entry, so we refuse
401
+ * rather than mutate blind. Deliberately bounded: it checks the keychain-naming
402
+ * shape only (the core Phase-0 contract), never Claude Code's version or install.
403
+ *
404
+ * Drift-response matrix (drift type → action → user message):
405
+ * keychain-name format changed → rebind disabled pending re-verification →
406
+ * "re-run the Phase-0 spikes to re-pin the contract before rebinding".
407
+ */
408
+ export function canaryCheck(cfgDir, d) {
409
+ const svc = d.serviceNameFor(cfgDir);
410
+ if (!PINNED_SERVICE_NAME_RE.test(svc)) {
411
+ throw new RebindError(`credential-store canary tripped: keychain-name format changed (${svc}) → ` +
412
+ `rebind disabled pending re-verification (Claude Code's credential layout may have changed; ` +
413
+ `re-run the Phase-0 spikes to re-pin the contract before rebinding).`);
414
+ }
415
+ }
416
+ // ---------- CC-version-skew canary (Council finding 4 / R0.6) ----------
417
+ /**
418
+ * Claude Code versions whose credential-store contract the Phase-0 spikes
419
+ * (R0.1–R0.4) actually verified. rebind's whole mechanism relies on CC's
420
+ * *undocumented, internal* store contract (keychain-name shape, refresh-lock
421
+ * protocol, cache TTL), which can change on ANY CC release — and CC auto-updates.
422
+ * So the pin is a **verified set**, not a range: only add a version here after
423
+ * re-running the Phase-0 spikes against it. (No major.minor tolerance — there is
424
+ * no evidence CC applies semver semantics to its internal keychain contract.)
425
+ */
426
+ export const PINNED_CC_VERSIONS = new Set(["2.1.218"]);
427
+ /** Env var an operator sets, AFTER manually re-running the Phase-0 spikes
428
+ * against the running CC version, to proceed without a code release. It bypasses
429
+ * ONLY the verified-set membership check — never the keychain-name canary. */
430
+ export const CC_VERSION_OVERRIDE_ENV = "AGENT_SWITCH_CC_VERSION_OVERRIDE";
431
+ /** Canonical `major.minor.patch` from `claude --version` output
432
+ * ("2.1.220 (Claude Code)" → "2.1.220"); null when no semver is present. Pure. */
433
+ export function parseCCVersion(raw) {
434
+ if (!raw)
435
+ return null;
436
+ const m = raw.match(/\b(\d+\.\d+\.\d+)\b/);
437
+ return m ? m[1] : null;
438
+ }
439
+ /**
440
+ * Pure verified-set gate for the running CC version (R0.6). Fail-closed:
441
+ * - unparsable / undetectable version → refuse (indistinguishable from risk);
442
+ * - an `override` naming the SAME running version → pass (set-membership only);
443
+ * - version in the verified set → pass; otherwise → refuse with a re-verify path.
444
+ * Kept pure (no deps, no env) so every branch is unit-tested directly.
445
+ */
446
+ export function versionGuard(running, pinned = PINNED_CC_VERSIONS, override = null) {
447
+ const v = parseCCVersion(running);
448
+ if (!v) {
449
+ return {
450
+ ok: false,
451
+ reason: `could not determine the running Claude Code version (\`claude --version\` failed or was unparsable) — rebind refuses (fail-closed).`,
452
+ };
453
+ }
454
+ if (override && parseCCVersion(override) === v)
455
+ return { ok: true };
456
+ if (pinned.has(v))
457
+ return { ok: true };
458
+ return {
459
+ ok: false,
460
+ reason: `Claude Code ${v} is not in rebind's verified set {${[...pinned].join(", ")}}. ` +
461
+ `rebind's credential-store contract is only proven for those versions, and a CC auto-update may have changed it. ` +
462
+ `Re-run the Phase-0 spikes against ${v}, then set ${CC_VERSION_OVERRIDE_ENV}=${v} to proceed (or add ${v} to PINNED_CC_VERSIONS).`,
463
+ };
464
+ }
465
+ /**
466
+ * Fail loud if the running Claude Code version is not one the Phase-0 spikes
467
+ * verified (R0.6). Runs BEFORE any read or mutation in {@link rebind}, alongside
468
+ * the keychain-name {@link canaryCheck} — a version the contract was never proven
469
+ * against is a config-drift stop, not a rebind failure, so it does not advance the
470
+ * circuit-breaker. The env override ({@link CC_VERSION_OVERRIDE_ENV}) bypasses only
471
+ * the set-membership check.
472
+ */
473
+ export function versionCanaryCheck(d) {
474
+ const r = versionGuard(d.claudeVersion(), PINNED_CC_VERSIONS, process.env[CC_VERSION_OVERRIDE_ENV] ?? null);
475
+ if (!r.ok)
476
+ throw new RebindError(`credential-store canary tripped: ${r.reason}`);
477
+ }
478
+ /**
479
+ * Rebind the running profile `profile` to serve `account`'s credential. macOS
480
+ * only; both must be logged-in claude profiles. Throws {@link RebindError} on any
481
+ * precondition failure — the store is never touched unless every guard passes.
482
+ *
483
+ * Wrapped by the ADR-003 rollback/circuit-breaker: a tripped or manually-disabled
484
+ * kill-switch hard-refuses here; every failure of {@link performRebind} advances
485
+ * the breaker (auto-disable at {@link REBIND_FAILURE_LIMIT}), and success resets
486
+ * it. `--restore` is deliberately exempt — it is the recovery path.
487
+ */
488
+ export async function rebind(opts, d = defaultDeps()) {
489
+ // Council finding 3: verify Claude Code's credential-store naming contract has
490
+ // not drifted BEFORE any read or mutation (incl. the kill-switch read). A drift
491
+ // hard-refuses here, independent of the circuit-breaker — it is a config-drift
492
+ // stop, not a rebind failure.
493
+ canaryCheck(configDir("claude", opts.profile), d);
494
+ // R0.6: also refuse if the running CC version is outside the Phase-0-verified
495
+ // set — the store contract is unproven there. Overridable per-version via env
496
+ // after manual re-verification; never advances the breaker (config-drift stop).
497
+ versionCanaryCheck(d);
498
+ const ks = d.readRebindState();
499
+ if (ks.disabled) {
500
+ throw new RebindError(`rebind is disabled — the circuit-breaker tripped after ${ks.consecutiveFailures} consecutive failure(s). ` +
501
+ `Re-enable with \`agent-switch rebind --reset\` once the underlying issue is resolved. ` +
502
+ `(\`rebind --restore\` still works — it is the recovery path.)`);
503
+ }
504
+ try {
505
+ const result = await performRebind(opts, d);
506
+ if (ks.consecutiveFailures !== 0)
507
+ d.writeRebindState({ disabled: false, consecutiveFailures: 0 });
508
+ return result;
509
+ }
510
+ catch (e) {
511
+ // A guard REFUSAL never advances the breaker — nothing was mutated, and a
512
+ // user retrying a refused switch must not talk the breaker into disabling
513
+ // rebind. Only genuine write-path failures count.
514
+ if (e instanceof RebindRefused)
515
+ throw e;
516
+ const consecutiveFailures = ks.consecutiveFailures + 1;
517
+ const disabled = consecutiveFailures >= REBIND_FAILURE_LIMIT;
518
+ d.writeRebindState({ disabled, consecutiveFailures });
519
+ if (disabled) {
520
+ const base = e instanceof Error ? e.message : String(e);
521
+ throw new RebindError(`${base}\n\nCircuit-breaker tripped: ${consecutiveFailures} consecutive rebind failures — rebind is now DISABLED. ` +
522
+ `Re-enable with \`agent-switch rebind --reset\`.`);
523
+ }
524
+ throw e;
525
+ }
526
+ }
527
+ /** The actual rebind write path (preconditions → global lock → CC lock → swap). */
528
+ async function performRebind(opts, d) {
529
+ const { account, profile } = opts;
530
+ if (d.platform !== "darwin") {
531
+ throw new RebindRefused(`rebind is validated on macOS only — the Linux/Windows credential backend (spike R0.1) is not yet proven, so rebind refuses on ${d.platform}.`);
532
+ }
533
+ if (account === profile) {
534
+ throw new RebindRefused(`cannot rebind "${profile}" to itself`);
535
+ }
536
+ const pConfig = configDir("claude", profile);
537
+ const aConfig = configDir("claude", account);
538
+ // Already rebound → RE-SWITCH: put both profiles back on their own accounts
539
+ // first, then bind to the new target. Dead-ending in "already rebound" made
540
+ // every second switch from the GUI fail (and, before the RebindRefused split,
541
+ // three such refusals tripped the breaker and disabled switching entirely).
542
+ // restore runs BEFORE any lock below (the mkdir locks are not reentrant).
543
+ if (readBinding(pConfig, d)) {
544
+ await restoreRebind({ profile }, d);
545
+ }
546
+ const pCred = readCred(pConfig, d);
547
+ let aCred = readCred(aConfig, d);
548
+ if (!pCred)
549
+ throw new RebindRefused(`running profile "${profile}" has no readable credential — is it logged in?`);
550
+ if (!aCred)
551
+ throw new RebindRefused(`target profile "${account}" has no readable credential — is it logged in?`);
552
+ if (accessToken(pCred) && accessToken(pCred) === accessToken(aCred)) {
553
+ throw new RebindRefused(`profile "${profile}" is already running "${account}"'s account — nothing to do.`);
554
+ }
555
+ let exp = expiresAt(aCred);
556
+ if (exp !== null && exp - d.now() < FRESHEN_FLOOR_MS) {
557
+ // Target token is spent / near-spent. Rather than send the operator to the
558
+ // CLI, delegate the refresh to Claude Code (`claude doctor` — no session, no
559
+ // quota; ADR-004): it rotates the token under its own lock, in its own store.
560
+ // rebind still never mints a token itself — it asks the owner process to.
561
+ // Re-read the target credential and re-check the floor afterwards.
562
+ d.freshen(aConfig);
563
+ aCred = readCred(aConfig, d);
564
+ if (!aCred) {
565
+ throw new RebindRefused(`target profile "${account}" has no readable credential after a refresh attempt — is it logged in?`);
566
+ }
567
+ exp = expiresAt(aCred);
568
+ if (exp !== null && exp - d.now() < FRESHEN_FLOOR_MS) {
569
+ const mins = Math.max(0, Math.round((exp - d.now()) / 60000));
570
+ throw new RebindRefused(`target "${account}"'s token still expires in ${mins} min after a refresh attempt — the login may be expired. ` +
571
+ `Run \`agent-switch run ${account}\` to re-authenticate, then retry.`);
572
+ }
573
+ }
574
+ const pSvc = d.serviceNameFor(pConfig);
575
+ const aSvc = d.serviceNameFor(aConfig);
576
+ const aFile = credFile(aConfig);
577
+ // Lock order: GLOBAL registry lock (outer) → Claude Code's per-profile lock
578
+ // (inner) → swap. The registry check + record + swap all run under BOTH locks,
579
+ // so a concurrent rebind cannot bind the same account into a second profile.
580
+ await d.withLock(registryLockTarget(), async () => {
581
+ await d.withLock(pConfig, async () => {
582
+ // Global invariant (Council finding 2): one account → at most one profile.
583
+ // Read-check the registry now; RECORD it only once every swap guard below
584
+ // has passed, so a quarantine/refuse leaves no phantom registry entry.
585
+ const reg = readRegistry(d);
586
+ const existing = reg[account];
587
+ if (existing && existing.runningProfile !== profile) {
588
+ throw new RebindRefused(`account "${account}" is already bound into running profile "${existing.runningProfile}" ` +
589
+ `(one account → one profile). Restore it first: ` +
590
+ `\`agent-switch rebind --restore --profile ${existing.runningProfile}\`.`);
591
+ }
592
+ // Provenance re-read under the lock (Council finding 5): compare what the
593
+ // target store holds NOW against the credential we read before locking.
594
+ const liveTarget = readCred(aConfig, d);
595
+ const branch = await compareAccounts(aCred, liveTarget, d);
596
+ if (branch === "unparseable") {
597
+ throw new RebindRefused(`target "${account}"'s live credential is missing or unparseable at swap time — refusing (no credential mutation).`);
598
+ }
599
+ if (branch === "indeterminate") {
600
+ throw new RebindRefused(`target "${account}"'s token changed since rebind started and its account could not be verified ` +
601
+ `(the profile endpoint is unreachable — offline?). A rotation and a re-login to a DIFFERENT account ` +
602
+ `look identical locally, so rebind refuses rather than risk moving a foreign account's credential. ` +
603
+ `No credential was touched — retry once the account can be verified.`);
604
+ }
605
+ if (branch === "different") {
606
+ // Someone re-logged the target profile between our read and the swap.
607
+ // Do NOT move its (now foreign) credential — quarantine it and abort.
608
+ d.writeFile(aFile + QUARANTINE_SUFFIX, liveTarget);
609
+ throw new RebindRefused(`target "${account}" now holds a DIFFERENT account than when rebind started — someone re-logged it. ` +
610
+ `Quarantined the unexpected credential to ${aFile + QUARANTINE_SUFFIX}; no swap performed.`);
611
+ }
612
+ // "rotated": a background refresh replaced the token in place — move the
613
+ // FRESH credential (same account), not the stale pre-lock read.
614
+ const credToMove = branch === "rotated" ? liveTarget : aCred;
615
+ // All guards passed — commit the global binding record.
616
+ reg[account] = { runningProfile: profile, boundAt: new Date(d.now()).toISOString() };
617
+ writeRegistry(reg, d);
618
+ // 1. Recovery point BEFORE any store mutation: stash both originals.
619
+ const marker = {
620
+ boundToProfile: account,
621
+ boundAt: new Date(d.now()).toISOString(),
622
+ runningOwnCredential: pCred,
623
+ targetOrigCredential: credToMove,
624
+ targetFileMoved: false,
625
+ // Snapshot the running profile's OWN identity stamp before the swap: from
626
+ // here on Claude Code will overwrite it with the borrowed account's.
627
+ runningOwnIdentity: readStampedIdentity(pConfig, d) ?? undefined,
628
+ };
629
+ // 2. Empty the target's store so its family is live in exactly one store:
630
+ // delete its Keychain entry and move its plaintext file aside (never
631
+ // delete — a rename is reversible).
632
+ if (d.exists(aFile)) {
633
+ d.renameFile(aFile, aFile + LENT_SUFFIX);
634
+ marker.targetFileMoved = true;
635
+ }
636
+ d.writeFile(markerFile(pConfig), JSON.stringify(marker, null, 2) + "\n");
637
+ d.kcDelete(aSvc);
638
+ // 3. Point the running profile's store at the target account.
639
+ if (!d.kcAdd(pSvc, credToMove)) {
640
+ throw new RebindError(`failed to write the Keychain entry for "${profile}" — swap aborted (marker left for --restore).`);
641
+ }
642
+ });
643
+ });
644
+ return {
645
+ boundToProfile: account,
646
+ uxNote: `Rebound "${profile}" → "${account}". A running Claude session adopts it on its next message ` +
647
+ `(fresh process: instant; long-lived session: within ~30s, the Keychain read-cache). ` +
648
+ `Restore with: agent-switch rebind --restore --profile ${profile}`,
649
+ };
650
+ }
651
+ /** Reverse an active rebind on `profile`: restore both profiles' own credentials. */
652
+ export async function restoreRebind(opts, d = defaultDeps()) {
653
+ const { profile } = opts;
654
+ if (d.platform !== "darwin") {
655
+ throw new RebindRefused(`rebind --restore is macOS only (nothing to restore on ${d.platform}).`);
656
+ }
657
+ const pConfig = configDir("claude", profile);
658
+ const marker = readBinding(pConfig, d);
659
+ if (!marker)
660
+ throw new RebindRefused(`no active rebind on profile "${profile}".`);
661
+ const aConfig = configDir("claude", marker.boundToProfile);
662
+ const pSvc = d.serviceNameFor(pConfig);
663
+ const aSvc = d.serviceNameFor(aConfig);
664
+ const aFile = credFile(aConfig);
665
+ const pFile = credFile(pConfig);
666
+ // Same lock order as rebind: GLOBAL registry lock (outer) → CC's per-profile
667
+ // lock (inner). Restore is exempt from the kill-switch by design — it is the
668
+ // recovery path and must work even when `rebind` is disabled.
669
+ await d.withLock(registryLockTarget(), async () => {
670
+ await d.withLock(pConfig, async () => {
671
+ // Provenance (Council finding 5): the running store should still hold the
672
+ // account we bound INTO it. If someone re-logged the running profile, its
673
+ // store now holds a DIFFERENT account — never clobber that fresh login.
674
+ const liveRunning = readCred(pConfig, d);
675
+ const branch = await compareAccounts(marker.targetOrigCredential, liveRunning, d);
676
+ if (branch === "unparseable") {
677
+ throw new RebindRefused(`running profile "${profile}"'s live credential is missing or unparseable — refusing restore (no mutation). ` +
678
+ `The binding marker is left in place for manual recovery.`);
679
+ }
680
+ if (branch === "indeterminate") {
681
+ throw new RebindRefused(`running profile "${profile}"'s token changed since the rebind and its account could not be verified ` +
682
+ `(the profile endpoint is unreachable — offline?). A rotation and a re-login to a DIFFERENT account ` +
683
+ `look identical locally, so restore refuses rather than risk writing a foreign credential into ` +
684
+ `"${marker.boundToProfile}"'s store. Nothing was touched; the marker is left in place — retry when online.`);
685
+ }
686
+ if (branch === "different") {
687
+ d.writeFile(pFile + QUARANTINE_SUFFIX, liveRunning);
688
+ throw new RebindRefused(`running profile "${profile}" now holds a DIFFERENT account than the one it was rebound to — someone re-logged it. ` +
689
+ `Quarantined the unexpected credential to ${pFile + QUARANTINE_SUFFIX}; restore aborted to avoid clobbering it. ` +
690
+ `Resolve manually, then remove ${MARKER_NAME} from the profile's config dir.`);
691
+ }
692
+ // "same" / "rotated" → the bound account is still there → restore normally.
693
+ // Restore the running profile to its own account.
694
+ d.kcAdd(pSvc, marker.runningOwnCredential);
695
+ // Restore the target profile's own store. On "rotated" the live credential
696
+ // is the same account with a FRESHER (rotated) refresh token — hand that
697
+ // back instead of the stale stash, or the target profile logs out.
698
+ d.kcAdd(aSvc, branch === "rotated" ? liveRunning : marker.targetOrigCredential);
699
+ if (marker.targetFileMoved && d.exists(aFile + LENT_SUFFIX)) {
700
+ d.renameFile(aFile + LENT_SUFFIX, aFile);
701
+ }
702
+ // The credential is the running profile's own again — so must be the identity
703
+ // Claude Code stamped into its `.claude.json` while it served the other account.
704
+ await restoreStampedIdentity(pConfig, marker.runningOwnIdentity, d);
705
+ d.removeFile(markerFile(pConfig));
706
+ // Release the global binding-registry entry for the bound account.
707
+ const reg = readRegistry(d);
708
+ if (reg[marker.boundToProfile]) {
709
+ delete reg[marker.boundToProfile];
710
+ writeRegistry(reg, d);
711
+ }
712
+ });
713
+ });
714
+ return { restoredProfile: profile, wasBoundTo: marker.boundToProfile };
715
+ }