sidekick-shared 0.27.5 → 0.27.6

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.
Files changed (56) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +35 -34
  3. package/dist/accountHealth.d.ts +14 -0
  4. package/dist/accountHealth.js +61 -14
  5. package/dist/accountLaunch.d.ts +6 -0
  6. package/dist/accountLaunch.js +61 -4
  7. package/dist/accountLease.d.ts +5 -0
  8. package/dist/accountLease.js +98 -0
  9. package/dist/accountManager.d.ts +7 -0
  10. package/dist/accountManager.js +226 -89
  11. package/dist/accountSync.d.ts +1 -5
  12. package/dist/accountSync.js +22 -6
  13. package/dist/accountSyncTypes.d.ts +9 -0
  14. package/dist/accounts.d.ts +75 -18
  15. package/dist/accounts.js +712 -189
  16. package/dist/claudeCredentialDoctor.d.ts +50 -0
  17. package/dist/claudeCredentialDoctor.js +277 -0
  18. package/dist/claudeCredentialLedger.d.ts +15 -0
  19. package/dist/claudeCredentialLedger.js +130 -0
  20. package/dist/claudeCredentials.d.ts +20 -3
  21. package/dist/claudeCredentials.js +50 -3
  22. package/dist/claudeLiveGuard.d.ts +33 -0
  23. package/dist/claudeLiveGuard.js +46 -0
  24. package/dist/claudeProfiles.d.ts +14 -0
  25. package/dist/claudeProfiles.js +22 -0
  26. package/dist/claudeRefreshLock.d.ts +15 -0
  27. package/dist/claudeRefreshLock.js +145 -0
  28. package/dist/codexAuth.d.ts +4 -0
  29. package/dist/codexAuth.js +18 -0
  30. package/dist/codexCredentialDoctor.d.ts +33 -0
  31. package/dist/codexCredentialDoctor.js +302 -0
  32. package/dist/codexCredentialStore.d.ts +29 -0
  33. package/dist/codexCredentialStore.js +213 -0
  34. package/dist/codexKeepAlive.d.ts +2 -0
  35. package/dist/codexKeepAlive.js +116 -0
  36. package/dist/codexPaths.d.ts +8 -2
  37. package/dist/codexPaths.js +51 -7
  38. package/dist/codexProfiles.d.ts +37 -31
  39. package/dist/codexProfiles.js +842 -671
  40. package/dist/credentialIO.d.ts +67 -7
  41. package/dist/credentialIO.js +211 -47
  42. package/dist/ensureDefaultAccounts.js +1 -8
  43. package/dist/index.d.ts +14 -4
  44. package/dist/index.js +29 -6
  45. package/dist/keepAlive.d.ts +2 -1
  46. package/dist/keepAlive.js +116 -29
  47. package/dist/liveCredentialGuard.d.ts +28 -0
  48. package/dist/liveCredentialGuard.js +36 -0
  49. package/dist/processDetection.d.ts +10 -3
  50. package/dist/processDetection.js +44 -11
  51. package/dist/schemas/accountManager.d.ts +22 -0
  52. package/dist/schemas/accountManager.js +4 -0
  53. package/dist/schemas/assistantTurn.d.ts +2 -2
  54. package/dist/terminalSync.d.ts +12 -2
  55. package/dist/terminalSync.js +27 -2
  56. package/package.json +1 -1
@@ -0,0 +1,50 @@
1
+ import type { ProviderSyncReport } from './accountSyncTypes';
2
+ export type ClaudeCredentialFindingKind = 'orphan' | 'live-duplicate' | 'corrupt' | 'stale-entry';
3
+ export interface ClaudeCredentialFinding {
4
+ kind: ClaudeCredentialFindingKind;
5
+ configDir: string;
6
+ service: string;
7
+ /** How Sidekick knows it owns this store. */
8
+ ownership: 'ledger' | 'profile-home' | 'keychain-tag';
9
+ accountId?: string;
10
+ email?: string;
11
+ detail: string;
12
+ /** What repair does about it. */
13
+ action: string;
14
+ }
15
+ /** Attributes of one Keychain item (never its secret). */
16
+ export interface KeychainItemSummary {
17
+ service: string;
18
+ comment?: string;
19
+ }
20
+ export interface InspectClaudeCredentialStoresOptions {
21
+ now?: number;
22
+ /**
23
+ * Enumerate `Claude Code-credentials-*` Keychain items, attributes only.
24
+ * Default on macOS: `security dump-keychain` (no `-d`, so no secret is read
25
+ * and nothing prompts). Elsewhere there is no Keychain to scan.
26
+ */
27
+ listKeychainItems?: () => KeychainItemSummary[];
28
+ }
29
+ export interface ClaudeCredentialInspection {
30
+ findings: ClaudeCredentialFinding[];
31
+ homesChecked: number;
32
+ keychainScanned: boolean;
33
+ }
34
+ export interface ClaudeCredentialRepairOutcome {
35
+ finding: ClaudeCredentialFinding;
36
+ ok: boolean;
37
+ error?: string;
38
+ }
39
+ export interface ClaudeCredentialRepairResult extends ClaudeCredentialInspection {
40
+ dryRun: boolean;
41
+ repaired: ClaudeCredentialRepairOutcome[];
42
+ sync?: ProviderSyncReport;
43
+ }
44
+ /** Parse `security dump-keychain` output (attributes only) into item summaries. */
45
+ export declare function parseKeychainDump(output: string): KeychainItemSummary[];
46
+ export declare function inspectClaudeCredentialStores(options?: InspectClaudeCredentialStoresOptions): ClaudeCredentialInspection;
47
+ /** Inspect, then (unless `dryRun`) repair every finding. Dry run by default. */
48
+ export declare function repairClaudeCredentialStores(options?: InspectClaudeCredentialStoresOptions & {
49
+ dryRun?: boolean;
50
+ }): ClaudeCredentialRepairResult;
@@ -0,0 +1,277 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.parseKeychainDump = parseKeychainDump;
37
+ exports.inspectClaudeCredentialStores = inspectClaudeCredentialStores;
38
+ exports.repairClaudeCredentialStores = repairClaudeCredentialStores;
39
+ /**
40
+ * Inspect and repair the Claude credential stores Sidekick owns.
41
+ *
42
+ * Ownership is proven, never guessed: a store counts only when its directory
43
+ * is in the ledger (claudeCredentialLedger.ts), is a profile home under
44
+ * Sidekick's accounts directory, or its Keychain item carries Sidekick's
45
+ * comment tag naming the directory. Other tools legitimately create
46
+ * `Claude Code-credentials-<hash>` items for their own `CLAUDE_CONFIG_DIR`, so
47
+ * nothing is ever matched by name pattern.
48
+ *
49
+ * Findings:
50
+ * - `orphan`: the store's directory is gone, or it belongs to no saved account
51
+ * and no login younger than a day. It holds a copy of some login nothing
52
+ * will use; repair deletes it.
53
+ * - `live-duplicate`: the live account's profile home holds a CLI-usable copy
54
+ * of its login (a second copy of the live chain); repair runs the sync that
55
+ * removes it (repairing live first when the copy is newer).
56
+ * - `corrupt`: the store does not parse (for example a credential truncated by
57
+ * an older build's Keychain write); repair deletes it, and the account needs
58
+ * a new sign-in unless its backup is still good.
59
+ * - `stale-entry`: a ledger entry whose store is already gone; repair forgets it.
60
+ */
61
+ const child_process_1 = require("child_process");
62
+ const fs = __importStar(require("fs"));
63
+ const path = __importStar(require("path"));
64
+ const accountRegistry_1 = require("./accountRegistry");
65
+ const claudeProfiles_1 = require("./claudeProfiles");
66
+ const claudeCredentialLedger_1 = require("./claudeCredentialLedger");
67
+ const credentialIO_1 = require("./credentialIO");
68
+ const accounts_1 = require("./accounts");
69
+ /** Logins younger than this may still be finalized; the doctor leaves them alone. */
70
+ const PENDING_LOGIN_GRACE_MS = 24 * 60 * 60 * 1000;
71
+ function decodeKeychainValue(raw) {
72
+ const quoted = raw.match(/^"(.*)"$/);
73
+ if (quoted)
74
+ return quoted[1];
75
+ const hex = raw.match(/^0x([0-9A-Fa-f]+)/);
76
+ if (hex)
77
+ return Buffer.from(hex[1], 'hex').toString('utf8').replace(/\0+$/, '');
78
+ return raw;
79
+ }
80
+ /** Parse `security dump-keychain` output (attributes only) into item summaries. */
81
+ function parseKeychainDump(output) {
82
+ const items = [];
83
+ for (const block of output.split(/^keychain: /m)) {
84
+ const service = block.match(/^\s*"svce"<blob>=(.+)$/m)?.[1];
85
+ if (!service)
86
+ continue;
87
+ const comment = block.match(/^\s*"icmt"<blob>=(.+)$/m)?.[1];
88
+ const decodedService = decodeKeychainValue(service.trim());
89
+ const decodedComment = comment && comment.trim() !== '<NULL>' ? decodeKeychainValue(comment.trim()) : undefined;
90
+ items.push({ service: decodedService, comment: decodedComment });
91
+ }
92
+ return items;
93
+ }
94
+ function defaultListKeychainItems() {
95
+ if (process.platform !== 'darwin')
96
+ return [];
97
+ const output = (0, child_process_1.execFileSync)('security', ['dump-keychain'], {
98
+ encoding: 'utf8',
99
+ stdio: ['ignore', 'pipe', 'ignore'],
100
+ timeout: 30_000,
101
+ killSignal: 'SIGKILL',
102
+ maxBuffer: 64 * 1024 * 1024,
103
+ });
104
+ return parseKeychainDump(String(output)).filter((item) => item.service.startsWith('Claude Code-credentials-'));
105
+ }
106
+ function collectOwnedHomes(keychainItems) {
107
+ const homes = new Map();
108
+ const add = (configDir, ownership, inLedger = false) => {
109
+ const resolved = path.resolve(configDir);
110
+ const existing = homes.get(resolved);
111
+ if (existing) {
112
+ existing.inLedger ||= inLedger;
113
+ return;
114
+ }
115
+ homes.set(resolved, {
116
+ configDir: resolved,
117
+ service: (0, claudeProfiles_1.claudeKeychainService)(resolved),
118
+ ownership,
119
+ inLedger,
120
+ });
121
+ };
122
+ for (const entry of (0, claudeCredentialLedger_1.readClaudeCredentialLedger)())
123
+ add(entry.configDir, 'ledger', true);
124
+ try {
125
+ for (const id of fs.readdirSync((0, claudeProfiles_1.getClaudeProfilesDir)())) {
126
+ add((0, claudeProfiles_1.getClaudeProfileHome)(id), 'profile-home');
127
+ }
128
+ }
129
+ catch {
130
+ /* no profiles yet */
131
+ }
132
+ for (const item of keychainItems) {
133
+ if (!item.comment?.startsWith(credentialIO_1.SIDEKICK_KEYCHAIN_COMMENT_PREFIX))
134
+ continue;
135
+ const configDir = item.comment.slice(credentialIO_1.SIDEKICK_KEYCHAIN_COMMENT_PREFIX.length);
136
+ // The tag must name the directory the service was derived from.
137
+ if (configDir && (0, claudeProfiles_1.claudeKeychainService)(configDir) === item.service) {
138
+ add(configDir, 'keychain-tag');
139
+ }
140
+ }
141
+ return [...homes.values()];
142
+ }
143
+ function isRecentPendingLogin(configDir, now) {
144
+ const profilesDir = path.resolve((0, claudeProfiles_1.getClaudeProfilesDir)());
145
+ const loginDir = path.dirname(configDir);
146
+ if (path.dirname(loginDir) !== profilesDir)
147
+ return false;
148
+ try {
149
+ const pending = JSON.parse(fs.readFileSync(path.join((0, claudeProfiles_1.getClaudeProfileDir)(path.basename(loginDir)), 'profile.json'), 'utf8'));
150
+ const addedAt = pending.addedAt ? Date.parse(pending.addedAt) : NaN;
151
+ return !Number.isNaN(addedAt) && now - addedAt < PENDING_LOGIN_GRACE_MS;
152
+ }
153
+ catch {
154
+ return false;
155
+ }
156
+ }
157
+ function inspectClaudeCredentialStores(options = {}) {
158
+ const now = options.now ?? Date.now();
159
+ let keychainItems = [];
160
+ let keychainScanned = false;
161
+ try {
162
+ keychainItems = (options.listKeychainItems ?? defaultListKeychainItems)();
163
+ keychainScanned = Boolean(options.listKeychainItems) || process.platform === 'darwin';
164
+ }
165
+ catch {
166
+ keychainScanned = false;
167
+ }
168
+ const profilesByHome = new Map();
169
+ for (const profile of (0, accountRegistry_1.listSavedAccountProfiles)('claude-code')) {
170
+ profilesByHome.set(path.resolve((0, claudeProfiles_1.getClaudeProfileHome)(profile.providerAccountId ?? profile.id)), profile);
171
+ }
172
+ // Inside a profile-home shell the "live" identity is that profile's own; its
173
+ // store is not a duplicate of anything.
174
+ const liveUuid = (0, claudeProfiles_1.isLiveClaudeHomeSidekickProfile)()
175
+ ? null
176
+ : ((0, claudeProfiles_1.readLiveClaudeIdentity)()?.uuid ?? null);
177
+ const findings = [];
178
+ const homes = collectOwnedHomes(keychainItems);
179
+ for (const home of homes) {
180
+ const store = (0, credentialIO_1.readClaudeCredentialStore)(home.configDir);
181
+ if (store.status === 'error')
182
+ continue;
183
+ const profile = profilesByHome.get(home.configDir);
184
+ const base = {
185
+ configDir: home.configDir,
186
+ service: home.service,
187
+ ownership: home.ownership,
188
+ accountId: profile?.id,
189
+ email: profile?.email ?? profile?.metadata?.email,
190
+ };
191
+ if (store.status === 'absent') {
192
+ if (home.inLedger && !profile && !isRecentPendingLogin(home.configDir, now)) {
193
+ findings.push({
194
+ ...base,
195
+ kind: 'stale-entry',
196
+ detail: 'Ledger entry for a store that no longer exists.',
197
+ action: 'Forget the entry.',
198
+ });
199
+ }
200
+ continue;
201
+ }
202
+ if (!profile) {
203
+ if (isRecentPendingLogin(home.configDir, now))
204
+ continue;
205
+ findings.push({
206
+ ...base,
207
+ kind: 'orphan',
208
+ detail: fs.existsSync(home.configDir)
209
+ ? 'Holds a login but belongs to no saved account.'
210
+ : 'Holds a login but its directory no longer exists.',
211
+ action: 'Delete the stored credential.',
212
+ });
213
+ continue;
214
+ }
215
+ if (liveUuid && (profile.providerAccountId ?? profile.id) === liveUuid) {
216
+ findings.push({
217
+ ...base,
218
+ kind: 'live-duplicate',
219
+ detail: "The live account's profile home holds a second usable copy of its login; whichever refreshes first invalidates the other.",
220
+ action: 'Remove the copy (installing it into live first if it is newer).',
221
+ });
222
+ continue;
223
+ }
224
+ if (store.status === 'corrupt') {
225
+ findings.push({
226
+ ...base,
227
+ kind: 'corrupt',
228
+ detail: 'The stored credential is not valid JSON (possibly truncated); it cannot be used.',
229
+ action: 'Delete the unusable store; sign in to this account again if its backup is expired.',
230
+ });
231
+ }
232
+ }
233
+ return { findings, homesChecked: homes.length, keychainScanned };
234
+ }
235
+ /** Inspect, then (unless `dryRun`) repair every finding. Dry run by default. */
236
+ function repairClaudeCredentialStores(options = {}) {
237
+ const dryRun = options.dryRun ?? true;
238
+ const inspection = inspectClaudeCredentialStores(options);
239
+ const result = { ...inspection, dryRun, repaired: [] };
240
+ if (dryRun)
241
+ return result;
242
+ if (inspection.findings.some((finding) => finding.kind === 'live-duplicate')) {
243
+ result.sync = (0, accounts_1.syncClaudeLiveState)();
244
+ }
245
+ for (const finding of inspection.findings) {
246
+ try {
247
+ if (finding.kind === 'live-duplicate') {
248
+ const still = (0, credentialIO_1.readClaudeCredentialStore)(finding.configDir).status;
249
+ result.repaired.push({
250
+ finding,
251
+ ok: still === 'absent',
252
+ error: still === 'absent' ? undefined : result.sync?.warnings.join(' ') || 'still present',
253
+ });
254
+ continue;
255
+ }
256
+ if (finding.kind === 'stale-entry') {
257
+ (0, claudeCredentialLedger_1.forgetClaudeCredentialHome)(finding.configDir);
258
+ result.repaired.push({ finding, ok: true });
259
+ continue;
260
+ }
261
+ const ok = (0, credentialIO_1.deleteStoredCredentials)(finding.configDir);
262
+ if (ok && finding.kind === 'orphan') {
263
+ // An abandoned login directory under our profiles dir goes with it.
264
+ const loginDir = path.dirname(finding.configDir);
265
+ if (path.dirname(loginDir) === path.resolve((0, claudeProfiles_1.getClaudeProfilesDir)()) &&
266
+ fs.existsSync(loginDir)) {
267
+ fs.rmSync(loginDir, { recursive: true, force: true });
268
+ }
269
+ }
270
+ result.repaired.push({ finding, ok, error: ok ? undefined : 'could not delete' });
271
+ }
272
+ catch (err) {
273
+ result.repaired.push({ finding, ok: false, error: String(err) });
274
+ }
275
+ }
276
+ return result;
277
+ }
@@ -0,0 +1,15 @@
1
+ export type ClaudeCredentialHomePurpose = 'profile' | 'login';
2
+ export interface ClaudeCredentialLedgerEntry {
3
+ configDir: string;
4
+ /** The Keychain service Claude Code derives for this directory (used on macOS). */
5
+ service: string;
6
+ purpose: ClaudeCredentialHomePurpose;
7
+ createdAt: string;
8
+ lastWrittenAt: string;
9
+ }
10
+ export declare function getClaudeCredentialLedgerPath(): string;
11
+ export declare function readClaudeCredentialLedger(): ClaudeCredentialLedgerEntry[];
12
+ /** Record (or refresh) a home Sidekick is about to hold a Claude credential in. */
13
+ export declare function recordClaudeCredentialHome(configDir: string, purpose: ClaudeCredentialHomePurpose, now?: Date): void;
14
+ /** Drop a home whose credential store has been deleted. */
15
+ export declare function forgetClaudeCredentialHome(configDir: string): void;
@@ -0,0 +1,130 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.getClaudeCredentialLedgerPath = getClaudeCredentialLedgerPath;
37
+ exports.readClaudeCredentialLedger = readClaudeCredentialLedger;
38
+ exports.recordClaudeCredentialHome = recordClaudeCredentialHome;
39
+ exports.forgetClaudeCredentialHome = forgetClaudeCredentialHome;
40
+ /**
41
+ * Ledger of the Claude credential homes Sidekick created.
42
+ *
43
+ * Claude Code names a config directory's Keychain item after a hash of the
44
+ * directory, so once the directory is gone nothing ties the item back to it.
45
+ * Every home Sidekick writes a credential into (a saved profile) or points a
46
+ * `claude` login at is recorded here first, which is how `accounts doctor`
47
+ * proves an item is Sidekick's before it deletes anything. Items are never
48
+ * matched by name pattern: other tools legitimately use `CLAUDE_CONFIG_DIR`.
49
+ *
50
+ * Best effort by design: a ledger write failure must never fail the credential
51
+ * operation it describes. The lock is a leaf lock (callers may hold the swap
52
+ * or registry lock; nothing is acquired inside it).
53
+ */
54
+ const fs = __importStar(require("fs"));
55
+ const path = __importStar(require("path"));
56
+ const accountRegistry_1 = require("./accountRegistry");
57
+ const claudeProfiles_1 = require("./claudeProfiles");
58
+ const atomic_1 = require("./writers/atomic");
59
+ function emptyLedger() {
60
+ return { version: 1, entries: [] };
61
+ }
62
+ function normalizeLedger(raw) {
63
+ const entries = raw?.entries;
64
+ if (!Array.isArray(entries))
65
+ return emptyLedger();
66
+ return {
67
+ version: 1,
68
+ entries: entries.filter((entry) => !!entry &&
69
+ typeof entry === 'object' &&
70
+ typeof entry.configDir === 'string' &&
71
+ typeof entry.service === 'string'),
72
+ };
73
+ }
74
+ function getClaudeCredentialLedgerPath() {
75
+ return path.join((0, accountRegistry_1.getAccountsDir)(), 'claude', 'credential-homes.json');
76
+ }
77
+ function readClaudeCredentialLedger() {
78
+ try {
79
+ return normalizeLedger(JSON.parse(fs.readFileSync(getClaudeCredentialLedgerPath(), 'utf8')))
80
+ .entries;
81
+ }
82
+ catch {
83
+ return [];
84
+ }
85
+ }
86
+ /** Record (or refresh) a home Sidekick is about to hold a Claude credential in. */
87
+ function recordClaudeCredentialHome(configDir, purpose, now = new Date()) {
88
+ const resolved = path.resolve(configDir);
89
+ const stamp = now.toISOString();
90
+ try {
91
+ (0, atomic_1.updateJsonStoreAtomicSync)(getClaudeCredentialLedgerPath(), emptyLedger, (latest) => {
92
+ const ledger = normalizeLedger(latest);
93
+ const existing = ledger.entries.find((entry) => entry.configDir === resolved);
94
+ if (existing) {
95
+ existing.lastWrittenAt = stamp;
96
+ // A login home that became a profile keeps the stronger purpose.
97
+ if (purpose === 'profile')
98
+ existing.purpose = 'profile';
99
+ return ledger;
100
+ }
101
+ ledger.entries.push({
102
+ configDir: resolved,
103
+ service: (0, claudeProfiles_1.claudeKeychainService)(resolved),
104
+ purpose,
105
+ createdAt: stamp,
106
+ lastWrittenAt: stamp,
107
+ });
108
+ return ledger;
109
+ });
110
+ }
111
+ catch {
112
+ /* best effort: the doctor still scans the profile homes on disk */
113
+ }
114
+ }
115
+ /** Drop a home whose credential store has been deleted. */
116
+ function forgetClaudeCredentialHome(configDir) {
117
+ const resolved = path.resolve(configDir);
118
+ try {
119
+ if (!fs.existsSync(getClaudeCredentialLedgerPath()))
120
+ return;
121
+ (0, atomic_1.updateJsonStoreAtomicSync)(getClaudeCredentialLedgerPath(), emptyLedger, (latest) => {
122
+ const ledger = normalizeLedger(latest);
123
+ ledger.entries = ledger.entries.filter((entry) => entry.configDir !== resolved);
124
+ return ledger;
125
+ });
126
+ }
127
+ catch {
128
+ /* best effort */
129
+ }
130
+ }
@@ -1,6 +1,3 @@
1
- /**
2
- * Pure helpers over the Claude Code credential blob (`claudeAiOauth`). No I/O.
3
- */
4
1
  export interface ClaudeOauthCredentials {
5
2
  accessToken: string;
6
3
  refreshToken?: string;
@@ -36,3 +33,23 @@ export declare function resolveClaudeRefreshExpiry(credentials: ClaudeOauthCrede
36
33
  refreshExpiresAt: number;
37
34
  estimated: boolean;
38
35
  } | null;
36
+ /**
37
+ * Short, non-reversible fingerprint of the refresh token. Claude Code rotates
38
+ * the refresh token on every refresh, so equal fingerprints mean two stores
39
+ * hold the same link of one chain. Safe to persist and log; tokens never are.
40
+ */
41
+ export declare function claudeRefreshFingerprint(credentials: ClaudeOauthCredentials | null): string | null;
42
+ /**
43
+ * The part of a blob a profile home keeps: the Claude login only. MCP OAuth
44
+ * tokens (`mcpOAuth`) rotate too and follow the live store, so a profile copy
45
+ * never duplicates one of their chains (and stays far below the Keychain
46
+ * line limit).
47
+ */
48
+ export declare function claudeLoginOnly(blob: unknown): Record<string, unknown> | null;
49
+ /**
50
+ * The blob to install into the live store when `candidate`'s login replaces
51
+ * the live one: everything live holds (its MCP tokens are the freshest links
52
+ * of their chains) with the candidate's `claudeAiOauth`, plus any MCP server
53
+ * only the candidate knows. Mirrors what Claude Code's own `/login` keeps.
54
+ */
55
+ export declare function mergeClaudeLoginIntoLive(live: unknown, candidate: unknown): Record<string, unknown>;
@@ -1,13 +1,17 @@
1
1
  "use strict";
2
- /**
3
- * Pure helpers over the Claude Code credential blob (`claudeAiOauth`). No I/O.
4
- */
5
2
  Object.defineProperty(exports, "__esModule", { value: true });
6
3
  exports.CLAUDE_REFRESH_TOKEN_LIFETIME_ESTIMATE_MS = exports.CLAUDE_ACCESS_TOKEN_LIFETIME_MS = void 0;
7
4
  exports.parseClaudeCredentialBlob = parseClaudeCredentialBlob;
8
5
  exports.claudeCredentialFreshness = claudeCredentialFreshness;
9
6
  exports.isNewerClaudeCredential = isNewerClaudeCredential;
10
7
  exports.resolveClaudeRefreshExpiry = resolveClaudeRefreshExpiry;
8
+ exports.claudeRefreshFingerprint = claudeRefreshFingerprint;
9
+ exports.claudeLoginOnly = claudeLoginOnly;
10
+ exports.mergeClaudeLoginIntoLive = mergeClaudeLoginIntoLive;
11
+ /**
12
+ * Pure helpers over the Claude Code credential blob (`claudeAiOauth`). No I/O.
13
+ */
14
+ const crypto_1 = require("crypto");
11
15
  /** Lifetime of a Claude Code access token. */
12
16
  exports.CLAUDE_ACCESS_TOKEN_LIFETIME_MS = 8 * 60 * 60 * 1000;
13
17
  /**
@@ -72,3 +76,46 @@ function resolveClaudeRefreshExpiry(credentials) {
72
76
  }
73
77
  return null;
74
78
  }
79
+ /**
80
+ * Short, non-reversible fingerprint of the refresh token. Claude Code rotates
81
+ * the refresh token on every refresh, so equal fingerprints mean two stores
82
+ * hold the same link of one chain. Safe to persist and log; tokens never are.
83
+ */
84
+ function claudeRefreshFingerprint(credentials) {
85
+ const token = credentials?.refreshToken;
86
+ return token ? (0, crypto_1.createHash)('sha256').update(token).digest('hex').slice(0, 16) : null;
87
+ }
88
+ function recordOrUndefined(value) {
89
+ return value && typeof value === 'object' && !Array.isArray(value)
90
+ ? value
91
+ : undefined;
92
+ }
93
+ /**
94
+ * The part of a blob a profile home keeps: the Claude login only. MCP OAuth
95
+ * tokens (`mcpOAuth`) rotate too and follow the live store, so a profile copy
96
+ * never duplicates one of their chains (and stays far below the Keychain
97
+ * line limit).
98
+ */
99
+ function claudeLoginOnly(blob) {
100
+ const oauth = recordOrUndefined(recordOrUndefined(blob)?.claudeAiOauth);
101
+ return oauth ? { claudeAiOauth: oauth } : null;
102
+ }
103
+ /**
104
+ * The blob to install into the live store when `candidate`'s login replaces
105
+ * the live one: everything live holds (its MCP tokens are the freshest links
106
+ * of their chains) with the candidate's `claudeAiOauth`, plus any MCP server
107
+ * only the candidate knows. Mirrors what Claude Code's own `/login` keeps.
108
+ */
109
+ function mergeClaudeLoginIntoLive(live, candidate) {
110
+ const liveRecord = recordOrUndefined(live) ?? {};
111
+ const candidateRecord = recordOrUndefined(candidate) ?? {};
112
+ const merged = {
113
+ ...liveRecord,
114
+ claudeAiOauth: candidateRecord.claudeAiOauth,
115
+ };
116
+ const liveMcp = recordOrUndefined(liveRecord.mcpOAuth);
117
+ const candidateMcp = recordOrUndefined(candidateRecord.mcpOAuth);
118
+ if (liveMcp || candidateMcp)
119
+ merged.mcpOAuth = { ...candidateMcp, ...liveMcp };
120
+ return merged;
121
+ }
@@ -0,0 +1,33 @@
1
+ export type LiveClaudeWriteIntent = 'switch' | 'relogin' | 'repair' | 'rollback';
2
+ /** The live store as read just before the write. */
3
+ export type LiveClaudeStoreState = {
4
+ status: 'ok';
5
+ blob: unknown;
6
+ } | {
7
+ status: 'absent';
8
+ } | {
9
+ status: 'corrupt';
10
+ } | {
11
+ status: 'error';
12
+ };
13
+ export interface LiveClaudeWriteRequest {
14
+ intent: LiveClaudeWriteIntent;
15
+ live: LiveClaudeStoreState;
16
+ /** Account uuid of the live identity (`oauthAccount`), null when logged out. */
17
+ liveAccountUuid: string | null;
18
+ candidate: unknown;
19
+ candidateAccountUuid: string;
20
+ /** `switch`: the outgoing live credential was saved and verified. */
21
+ capturedOutgoing?: boolean;
22
+ /** `rollback`: the blob the failed switch wrote. */
23
+ written?: unknown;
24
+ }
25
+ export type LiveClaudeWriteRefusal = 'live-unreadable' | 'invalid-candidate' | 'same-account' | 'different-account' | 'same-link' | 'not-captured' | 'not-newer' | 'live-changed';
26
+ export type LiveClaudeWriteDecision = {
27
+ allow: true;
28
+ } | {
29
+ allow: false;
30
+ code: LiveClaudeWriteRefusal;
31
+ reason: string;
32
+ };
33
+ export declare function decideLiveClaudeWrite(request: LiveClaudeWriteRequest): LiveClaudeWriteDecision;
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.decideLiveClaudeWrite = decideLiveClaudeWrite;
4
+ /**
5
+ * The decision every write into the live Claude credential store must pass.
6
+ * Pure: callers read the stores, this decides.
7
+ *
8
+ * Claude Code rotates the refresh token on every refresh, so the live store
9
+ * always holds the newest link of its chain. Writing an older link over it
10
+ * destroys a working login, and installing another account without saving the
11
+ * live one first loses that account's chain. The rules:
12
+ *
13
+ * - `switch`: another account replaces live. Allowed only after the outgoing
14
+ * live credential was captured (whenever live holds one), and never with a
15
+ * candidate that is the same refresh-token link as live.
16
+ * - `relogin` / `repair`: the same account's credential replaces live. Allowed
17
+ * only when the candidate is strictly fresher (a different refresh token
18
+ * with a later expiry); otherwise live is kept.
19
+ * - `rollback`: restore what a failed switch displaced. Allowed only while the
20
+ * live store still holds exactly what that switch wrote (or is empty or
21
+ * unparseable); a write Claude Code made in between is never clobbered.
22
+ * - An unreadable live store (`error`: timeout, locked Keychain) refuses all.
23
+ */
24
+ const liveCredentialGuard_1 = require("./liveCredentialGuard");
25
+ const claudeCredentials_1 = require("./claudeCredentials");
26
+ function decideLiveClaudeWrite(request) {
27
+ const candidate = (0, claudeCredentials_1.parseClaudeCredentialBlob)(request.candidate);
28
+ const live = request.live.status === 'ok' ? (0, claudeCredentials_1.parseClaudeCredentialBlob)(request.live.blob) : null;
29
+ const liveFp = (0, claudeCredentials_1.claudeRefreshFingerprint)(live);
30
+ return (0, liveCredentialGuard_1.decideLiveCredentialWrite)({
31
+ provider: 'Claude',
32
+ intent: request.intent,
33
+ liveStatus: request.live.status,
34
+ candidateValid: candidate !== null,
35
+ liveHasCredential: live !== null,
36
+ liveAccountId: request.liveAccountUuid,
37
+ candidateAccountId: request.candidateAccountUuid,
38
+ sameLink: liveFp !== null && liveFp === (0, claudeCredentials_1.claudeRefreshFingerprint)(candidate),
39
+ strictlyNewer: (0, claudeCredentials_1.claudeCredentialFreshness)(candidate) > (0, claudeCredentials_1.claudeCredentialFreshness)(live),
40
+ capturedOutgoing: request.capturedOutgoing,
41
+ matchesWritten: request.live.status === 'ok' &&
42
+ JSON.stringify(request.live.blob) === JSON.stringify(request.written),
43
+ allowNonOkRollback: true,
44
+ requireDifferentLinkForAdvance: true,
45
+ });
46
+ }
@@ -18,8 +18,22 @@ export declare function isDefaultClaudeHome(): boolean;
18
18
  */
19
19
  export declare function getLiveClaudeHome(): string;
20
20
  export declare function getClaudeProfilesDir(): string;
21
+ /**
22
+ * True when the live home is one of sidekick's own profile homes: a shell
23
+ * that eval'd `sidekick accounts env` or runs inside `sidekick accounts shell`.
24
+ * That profile's store then reads as the live store, so sync, re-login, and
25
+ * switching must not treat it as live: they would delete the shell's only
26
+ * usable copy and re-point the active account at it.
27
+ */
28
+ export declare function isLiveClaudeHomeSidekickProfile(): boolean;
21
29
  export declare function getClaudeProfileDir(uuid: string): string;
22
30
  export declare function getClaudeProfileHome(uuid: string): string;
31
+ /**
32
+ * Sidekick's private backup of a profile's credential. Claude Code never reads
33
+ * it, so it is not a CLI-usable copy: this is where the live account's
34
+ * credential is mirrored while its profile home stays empty.
35
+ */
36
+ export declare function getClaudeCredentialBackupPath(uuid: string): string;
23
37
  export declare function ensureClaudeProfileDirs(uuid: string): void;
24
38
  /**
25
39
  * Claude Code hashes the NFC-normalised config directory; macOS hands paths