@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/README.md +3 -1
- package/dist/api.js +5 -4
- package/dist/codex-reset.js +2 -2
- package/dist/config-transfer.js +184 -0
- package/dist/credentials.js +6 -4
- package/dist/daemon.js +68 -34
- package/dist/gui-launch.js +45 -3
- package/dist/history.js +23 -0
- package/dist/hooks.js +5 -2
- package/dist/index.js +233 -51
- package/dist/keychain.js +14 -0
- package/dist/notifications.js +7 -2
- package/dist/profiles.js +26 -14
- package/dist/rebind-dialog.js +65 -0
- package/dist/rebind.js +715 -0
- package/dist/token-freshen.js +154 -0
- package/dist/usage.js +8 -7
- package/package.json +1 -1
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
|
+
}
|