@modelprofile.com/authswitch 6.4.0 → 7.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.
Files changed (98) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/authority-contract.d.ts +302 -6
  3. package/dist_ts/authority-contract.js +1 -1
  4. package/dist_ts/authority-runtime-contract.d.ts +15 -0
  5. package/dist_ts/authorityrehearsal.child.d.ts +1 -0
  6. package/dist_ts/authorityrehearsal.child.js +395 -0
  7. package/dist_ts/classes.accountlist.js +4 -3
  8. package/dist_ts/classes.authoritybackup.d.ts +134 -0
  9. package/dist_ts/classes.authoritybackup.js +769 -0
  10. package/dist_ts/classes.authoritybroker.d.ts +29 -6
  11. package/dist_ts/classes.authoritybroker.js +413 -122
  12. package/dist_ts/classes.authoritycli.d.ts +24 -0
  13. package/dist_ts/classes.authoritycli.js +675 -0
  14. package/dist_ts/classes.authorityclient.d.ts +34 -9
  15. package/dist_ts/classes.authorityclient.js +136 -38
  16. package/dist_ts/classes.authoritydaemon.d.ts +47 -0
  17. package/dist_ts/classes.authoritydaemon.js +705 -16
  18. package/dist_ts/classes.authoritydatabase.d.ts +182 -8
  19. package/dist_ts/classes.authoritydatabase.js +1188 -41
  20. package/dist_ts/classes.authoritymodels.d.ts +247 -1
  21. package/dist_ts/classes.authoritymodels.js +870 -6
  22. package/dist_ts/classes.authoritypreuse.d.ts +47 -0
  23. package/dist_ts/classes.authoritypreuse.js +278 -0
  24. package/dist_ts/classes.authorityregistry.d.ts +24 -0
  25. package/dist_ts/classes.authorityregistry.js +88 -0
  26. package/dist_ts/classes.authorityrehearsal.d.ts +127 -0
  27. package/dist_ts/classes.authorityrehearsal.js +632 -0
  28. package/dist_ts/classes.authorityusage.d.ts +160 -0
  29. package/dist_ts/classes.authorityusage.js +429 -0
  30. package/dist_ts/classes.claudeauthority.d.ts +66 -0
  31. package/dist_ts/classes.claudeauthority.js +532 -0
  32. package/dist_ts/classes.claudecodeharness.js +3 -2
  33. package/dist_ts/classes.claudecodelocks.d.ts +13 -0
  34. package/dist_ts/classes.claudecodelocks.js +75 -3
  35. package/dist_ts/classes.claudenative.d.ts +137 -0
  36. package/dist_ts/classes.claudenative.js +731 -0
  37. package/dist_ts/classes.claudestatus.d.ts +9 -0
  38. package/dist_ts/classes.claudestatus.js +26 -9
  39. package/dist_ts/classes.claudetokenrefresh.d.ts +15 -1
  40. package/dist_ts/classes.claudetokenrefresh.js +74 -21
  41. package/dist_ts/classes.codexharness.js +4 -2
  42. package/dist_ts/classes.codexmanaged.d.ts +91 -0
  43. package/dist_ts/classes.codexmanaged.js +404 -0
  44. package/dist_ts/classes.codexpreuse.d.ts +8 -0
  45. package/dist_ts/classes.codexpreuse.js +24 -6
  46. package/dist_ts/classes.fileharness.d.ts +2 -0
  47. package/dist_ts/classes.fileharness.js +6 -4
  48. package/dist_ts/classes.limits.js +12 -7
  49. package/dist_ts/classes.opencodeharness.js +2 -2
  50. package/dist_ts/claudehttp.d.ts +10 -0
  51. package/dist_ts/claudehttp.js +29 -3
  52. package/dist_ts/index.d.ts +2 -0
  53. package/dist_ts/index.js +8 -1
  54. package/dist_ts/interfaces.harness.d.ts +6 -0
  55. package/dist_ts/interfaces.list.d.ts +9 -3
  56. package/dist_ts/plugins.d.ts +7 -3
  57. package/dist_ts/plugins.js +9 -4
  58. package/dist_ts/ts_migration/0001_authority_meta.d.ts +3 -0
  59. package/dist_ts/ts_migration/0001_authority_meta.js +32 -0
  60. package/dist_ts/ts_migration/index.d.ts +3 -0
  61. package/dist_ts/ts_migration/index.js +6 -0
  62. package/package.json +12 -2
  63. package/readme.md +205 -13
  64. package/ts/00_commitinfo_data.ts +1 -1
  65. package/ts/authority-contract.ts +264 -9
  66. package/ts/authority-runtime-contract.ts +21 -1
  67. package/ts/authorityrehearsal.child.ts +330 -0
  68. package/ts/classes.accountlist.ts +3 -2
  69. package/ts/classes.authoritybackup.ts +780 -0
  70. package/ts/classes.authoritybroker.ts +410 -120
  71. package/ts/classes.authoritycli.ts +719 -0
  72. package/ts/classes.authorityclient.ts +164 -38
  73. package/ts/classes.authoritydaemon.ts +614 -17
  74. package/ts/classes.authoritydatabase.ts +1229 -44
  75. package/ts/classes.authoritymodels.ts +579 -4
  76. package/ts/classes.authoritypreuse.ts +297 -0
  77. package/ts/classes.authorityregistry.ts +118 -0
  78. package/ts/classes.authorityrehearsal.ts +625 -0
  79. package/ts/classes.authorityusage.ts +527 -0
  80. package/ts/classes.claudeauthority.ts +541 -0
  81. package/ts/classes.claudecodeharness.ts +2 -1
  82. package/ts/classes.claudecodelocks.ts +70 -1
  83. package/ts/classes.claudenative.ts +816 -0
  84. package/ts/classes.claudestatus.ts +38 -9
  85. package/ts/classes.claudetokenrefresh.ts +82 -19
  86. package/ts/classes.codexharness.ts +3 -1
  87. package/ts/classes.codexmanaged.ts +402 -0
  88. package/ts/classes.codexpreuse.ts +33 -5
  89. package/ts/classes.fileharness.ts +7 -3
  90. package/ts/classes.limits.ts +11 -6
  91. package/ts/classes.opencodeharness.ts +1 -1
  92. package/ts/claudehttp.ts +31 -2
  93. package/ts/index.ts +7 -0
  94. package/ts/interfaces.harness.ts +6 -0
  95. package/ts/interfaces.list.ts +9 -3
  96. package/ts/plugins.ts +9 -3
  97. package/ts/ts_migration/0001_authority_meta.ts +33 -0
  98. package/ts/ts_migration/index.ts +7 -0
@@ -0,0 +1,816 @@
1
+ import * as plugins from './plugins.js';
2
+ import { ClaudeCodeLockOutcomeUnknownError, ClaudeCodeLocks,
3
+ type IClaudeCodeLockLease } from './classes.claudecodelocks.js';
4
+ import { claudeRequest } from './claudehttp.js';
5
+ import { writeSecretFileAtomically } from './helpers.js';
6
+ import { HarnessProcesses } from './classes.harnessprocesses.js';
7
+
8
+ /** The private file and lock contract verified for this Claude Code release. */
9
+ export const CLAUDE_NATIVE_HANDOFF_VERSION = '2.1.273';
10
+ const MAX_NATIVE_FILE_BYTES = 4 * 1024 * 1024;
11
+ const MAX_PROCESS_CONTEXT_BYTES = 1024 * 1024;
12
+ const PROFILE_TIMEOUT_MS = 10_000;
13
+ const ACCOUNT_CACHES = [
14
+ 'additionalModelOptionsCache', 'additionalModelOptionsAnsweredAt', 'additionalModelCostsCache',
15
+ 'modelAccessCache', 'orgModelDefaultCache', 'lastSeenOrgDefaultUpdatedAt', 'clientDataCache',
16
+ 'clientDataCacheSlots', 'autoCompactWindowsCache', 'cachedUsageUtilization',
17
+ ] as const;
18
+ const CREDENTIAL_OVERRIDES = [
19
+ 'CLAUDE_CODE_OAUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR', 'ANTHROPIC_API_KEY',
20
+ 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_PROFILE', 'CLAUDE_CODE_USE_BEDROCK',
21
+ 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY', 'ANTHROPIC_BASE_URL',
22
+ 'ANTHROPIC_FEDERATION_RULE_ID', 'ANTHROPIC_ORGANIZATION_ID',
23
+ 'ANTHROPIC_IDENTITY_TOKEN_FILE', 'ANTHROPIC_IDENTITY_TOKEN', 'ANTHROPIC_WORKSPACE_ID',
24
+ 'ANTHROPIC_UNIX_SOCKET', 'CLAUDE_CODE_REMOTE', 'CLAUDE_CODE_SIMPLE',
25
+ 'CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST', 'CLAUDE_CODE_HOST_AUTH_ENV_VAR',
26
+ 'CLAUDE_CODE_HOST_CREDS_FILE', 'CLAUDE_CODE_SESSION_ACCESS_TOKEN',
27
+ 'CLAUDE_CODE_OAUTH_REFRESH_TOKEN', 'CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR',
28
+ 'CLAUDE_CODE_USE_GATEWAY',
29
+ 'CLAUDE_CODE_GATEWAY_TOKEN_FILE_DESCRIPTOR', 'CLAUDE_CODE_HFI_BEARER_TOKEN',
30
+ 'CLAUDE_BG_AUTH_SNAPSHOT_PATH',
31
+ ] as const;
32
+
33
+ /** Refusal is distinct from a malformed login: Claude may use a different auth source. */
34
+ export class ClaudeNativeEffectiveAuthUnsupportedError extends Error {
35
+ public readonly code = 'unsupported_effective_auth';
36
+
37
+ constructor(public readonly reason: 'override' | 'profile' | 'profile_unreadable' | 'settings'
38
+ | 'subscription' | 'running_session') {
39
+ super(`Cannot prove that Claude Code ${CLAUDE_NATIVE_HANDOFF_VERSION} uses its native claude.ai login (${reason}).`);
40
+ }
41
+ }
42
+
43
+ export interface IClaudeNativeIdentity {
44
+ accountUuid: string;
45
+ organizationUuid: string;
46
+ }
47
+
48
+ /** Backend-only, secret-bearing source for sealing by the caller's authority journal. */
49
+ export interface IClaudeNativeLogin {
50
+ claudeAiOauth: Record<string, unknown>;
51
+ oauthAccount: Record<string, unknown>;
52
+ }
53
+
54
+ export interface IClaudeNativeDigests {
55
+ credentialDigest: string | null;
56
+ configDigest: string | null;
57
+ }
58
+
59
+ export interface IClaudeNativeSnapshot extends IClaudeNativeDigests {
60
+ /** The stored native login, not an assertion about an already-running process. */
61
+ login: IClaudeNativeLogin | null;
62
+ identity: IClaudeNativeIdentity | null;
63
+ /** A scoped process's current effective source may change after /cd and cannot be proved externally. */
64
+ runningEffectiveAuth: 'no_scoped_sessions' | 'unsupported_effective_auth';
65
+ }
66
+
67
+ /** Provider UUID proof for the token stored in one native snapshot, not for a process's active request. */
68
+ export interface IClaudeNativeProfileProof extends IClaudeNativeIdentity {
69
+ /** Binds this provider proof to the exact access token that was later written and read back. */
70
+ accessTokenDigest: string;
71
+ }
72
+
73
+ export type TClaudeNativeWriteResult =
74
+ | { kind: 'not_written'; reason: 'source_changed' | 'source_identity_mismatch' | 'source_unreadable'
75
+ | 'target_unverified' | 'target_oversized';
76
+ observed: IClaudeNativeDigests | null }
77
+ /** Exact native pair readback and target-token identity proof; runtimeEffectiveAuth remains separate. */
78
+ | { kind: 'written'; snapshot: IClaudeNativeSnapshot; profile: IClaudeNativeProfileProof }
79
+ | { kind: 'uncertain'; reason: 'native_write_or_readback_failed'; observed: IClaudeNativeDigests | null };
80
+
81
+ /** Exact bytes and provider proof are retained privately by the locked session until the write. */
82
+ export interface IClaudeNativePreparedWrite {
83
+ readonly before: IClaudeNativeDigests;
84
+ readonly after: IClaudeNativeDigests;
85
+ readonly sourceIdentity: IClaudeNativeIdentity | null;
86
+ readonly targetIdentity: IClaudeNativeIdentity;
87
+ readonly profile: IClaudeNativeProfileProof;
88
+ }
89
+
90
+ /** Caller-owned copies of the original native bytes before a prepared write. Null means absent. */
91
+ export interface IClaudeNativePreparedSource {
92
+ readonly before: IClaudeNativeDigests;
93
+ readonly credentialBytes: Uint8Array | null;
94
+ readonly configBytes: Uint8Array | null;
95
+ }
96
+
97
+ export type TClaudeNativePrepareResult =
98
+ | { kind: 'prepared'; plan: IClaudeNativePreparedWrite }
99
+ | Extract<TClaudeNativeWriteResult, { kind: 'not_written' }>;
100
+
101
+ export interface IClaudeNativeLockedSession {
102
+ readStable(): IClaudeNativeSnapshot;
103
+ verifyProfileIdentity(snapshot: IClaudeNativeSnapshot,
104
+ expected: IClaudeNativeIdentity): Promise<IClaudeNativeProfileProof>;
105
+ prepareWrite(input: {
106
+ expected: IClaudeNativeDigests;
107
+ expectedSourceIdentity: IClaudeNativeIdentity | null;
108
+ targetLogin: IClaudeNativeLogin;
109
+ expectedIdentity: IClaudeNativeIdentity;
110
+ }): Promise<TClaudeNativePrepareResult>;
111
+ exportPreparedSource(plan: IClaudeNativePreparedWrite): IClaudeNativePreparedSource;
112
+ writePreparedIfCurrent(plan: IClaudeNativePreparedWrite): TClaudeNativeWriteResult;
113
+ writeIfCurrent(input: {
114
+ expected: IClaudeNativeDigests;
115
+ expectedSourceIdentity: IClaudeNativeIdentity | null;
116
+ targetLogin: IClaudeNativeLogin;
117
+ expectedIdentity: IClaudeNativeIdentity;
118
+ }): Promise<TClaudeNativeWriteResult>;
119
+ }
120
+
121
+ export interface IClaudeNativeVersionProbe {
122
+ installedVersion(): string;
123
+ runningContexts(): readonly IClaudeNativeRunningContext[];
124
+ }
125
+
126
+ /** Only selector keys and paths, never a process's credential values, leave the /proc reader. */
127
+ export interface IClaudeNativeRunningContext {
128
+ version: string | null;
129
+ env: NodeJS.ProcessEnv | null;
130
+ cwd: string | null;
131
+ hasSettingsOverride: boolean | null;
132
+ /** The selected native home as seen through this process's mount namespace. */
133
+ nativeHomeIdentity: IClaudeNativeDirectoryIdentity | null;
134
+ /** The directory containing the vendor `.claude.json` and its write lock. */
135
+ configParentIdentity: IClaudeNativeDirectoryIdentity | null;
136
+ /** Informational only; a different namespace can still bind the same native home. */
137
+ mountNamespace: string | null;
138
+ /** Used to inspect profile and project settings in the process's own namespace. */
139
+ processRoot: string | null;
140
+ }
141
+
142
+ export interface IClaudeNativeDirectoryIdentity {
143
+ device: bigint;
144
+ inode: bigint;
145
+ }
146
+
147
+ export interface IClaudeNativeAdapterOptions {
148
+ configDir: string;
149
+ configFile: string;
150
+ executable?: string;
151
+ platform?: NodeJS.Platform;
152
+ env?: NodeJS.ProcessEnv;
153
+ settingsFiles?: readonly string[];
154
+ /** Internal test seam; production uses Claude's Linux managed-settings directory. */
155
+ managedSettingsDirectory?: string;
156
+ fetch?: typeof fetch;
157
+ versionProbe?: IClaudeNativeVersionProbe;
158
+ lockTimeoutMs?: number;
159
+ }
160
+
161
+ const hash = (value: string | Uint8Array): string => plugins.crypto.createHash('sha256').update(value).digest('hex');
162
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
163
+ value !== null && typeof value === 'object' && !Array.isArray(value);
164
+ const text = (value: unknown): value is string => typeof value === 'string' && value.length > 0;
165
+ const sameDigests = (left: IClaudeNativeDigests, right: IClaudeNativeDigests): boolean =>
166
+ left.credentialDigest === right.credentialDigest && left.configDigest === right.configDigest;
167
+ const onlyDigests = (value: IClaudeNativeDigests): IClaudeNativeDigests => ({
168
+ credentialDigest: value.credentialDigest, configDigest: value.configDigest,
169
+ });
170
+ const preparedWrites = new WeakMap<IClaudeNativePreparedWrite, {
171
+ session: ClaudeNativeLockedSession; credentialRaw: string; configRaw: string;
172
+ originalConfigRaw: Buffer | null; targetAccessToken: string;
173
+ }>();
174
+ const sameIdentity = (left: IClaudeNativeIdentity, right: IClaudeNativeIdentity): boolean =>
175
+ left.accountUuid === right.accountUuid && left.organizationUuid === right.organizationUuid;
176
+
177
+ const pathEnvironment = ['HOME', 'XDG_CONFIG_HOME', 'ANTHROPIC_CONFIG_DIR', 'CLAUDE_CONFIG_DIR'] as const;
178
+ const contextKeys = new Set<string>([...CREDENTIAL_OVERRIDES, ...pathEnvironment]);
179
+ const readProcessContext = (file: string): string => {
180
+ const descriptor = plugins.fs.openSync(file,
181
+ plugins.fs.constants.O_RDONLY | plugins.fs.constants.O_NOFOLLOW | plugins.fs.constants.O_NONBLOCK);
182
+ try {
183
+ const buffer = Buffer.alloc(MAX_PROCESS_CONTEXT_BYTES + 1);
184
+ let length = 0;
185
+ while (length < buffer.length) {
186
+ const count = plugins.fs.readSync(descriptor, buffer, length, buffer.length - length, null);
187
+ if (count === 0) break;
188
+ length += count;
189
+ }
190
+ if (length === buffer.length) throw new Error('Claude process context is too large to verify.');
191
+ return new TextDecoder('utf-8', { fatal: true }).decode(buffer.subarray(0, length));
192
+ } finally { plugins.fs.closeSync(descriptor); }
193
+ };
194
+
195
+ const selectorEnvironment = (raw: string): NodeJS.ProcessEnv => {
196
+ const selected: NodeJS.ProcessEnv = {};
197
+ for (const entry of raw.split('\0')) {
198
+ const separator = entry.indexOf('=');
199
+ if (separator < 1) continue;
200
+ const key = entry.slice(0, separator);
201
+ if (!contextKeys.has(key)) continue;
202
+ if (Object.hasOwn(selected, key)) throw new Error('Claude process environment selector is ambiguous.');
203
+ // Presence is enough for credential selectors. Never retain their secret value.
204
+ selected[key] = pathEnvironment.includes(key as typeof pathEnvironment[number])
205
+ ? entry.slice(separator + 1) : entry.length > separator + 1 ? 'present' : '';
206
+ }
207
+ return selected;
208
+ };
209
+
210
+ const processViewPath = (absolutePath: string, processRoot?: string): string => {
211
+ if (!plugins.path.isAbsolute(absolutePath)
212
+ || plugins.path.normalize(absolutePath) !== absolutePath) {
213
+ throw new ClaudeNativeEffectiveAuthUnsupportedError('running_session');
214
+ }
215
+ return processRoot ? plugins.path.join(processRoot, absolutePath.slice(1)) : absolutePath;
216
+ };
217
+
218
+ /** 2.1.273's `le` / `dse` / `H` selector in the installed bundle, offset 190076222. */
219
+ const assertNoKnownNativeLoginOverride = (env: NodeJS.ProcessEnv, processRoot?: string): void => {
220
+ for (const key of CREDENTIAL_OVERRIDES) {
221
+ if (env[key]) throw new ClaudeNativeEffectiveAuthUnsupportedError('override');
222
+ }
223
+ const configDir = env.ANTHROPIC_CONFIG_DIR?.trim()
224
+ || (env.XDG_CONFIG_HOME?.trim()
225
+ ? plugins.path.join(env.XDG_CONFIG_HOME.trim(), 'anthropic')
226
+ : env.HOME?.trim() ? plugins.path.join(env.HOME.trim(), '.config', 'anthropic') : null);
227
+ if (configDir === null) return;
228
+ if (!plugins.path.isAbsolute(configDir) || plugins.path.normalize(configDir) !== configDir) {
229
+ throw new ClaudeNativeEffectiveAuthUnsupportedError('profile_unreadable');
230
+ }
231
+ let selected = 'default';
232
+ try {
233
+ selected = readNativeRaw(processViewPath(plugins.path.join(configDir, 'active_config'), processRoot))?.trim() || 'default';
234
+ // The vendor's profile resolver otherwise forms `configs/${selected}.json` directly.
235
+ if (!/^[A-Za-z0-9._-]+$/.test(selected) || selected === '.' || selected === '..') {
236
+ throw new ClaudeNativeEffectiveAuthUnsupportedError('profile_unreadable');
237
+ }
238
+ plugins.fs.lstatSync(processViewPath(plugins.path.join(configDir, 'configs', `${selected}.json`), processRoot));
239
+ } catch (error) {
240
+ if (error instanceof ClaudeNativeEffectiveAuthUnsupportedError) throw error;
241
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return;
242
+ throw new ClaudeNativeEffectiveAuthUnsupportedError('profile_unreadable');
243
+ }
244
+ // Even an implicit user_oauth profile is refused: its credential source is outside this journal.
245
+ throw new ClaudeNativeEffectiveAuthUnsupportedError('profile');
246
+ };
247
+
248
+ const assertNativeHome = (env: NodeJS.ProcessEnv, configDir: string): void => {
249
+ const selected = env.CLAUDE_CONFIG_DIR?.trim()
250
+ || (env.HOME?.trim() ? plugins.path.join(env.HOME.trim(), '.claude') : null);
251
+ if (!selected || !plugins.path.isAbsolute(selected) || plugins.path.normalize(selected) !== configDir) {
252
+ throw new ClaudeNativeEffectiveAuthUnsupportedError('override');
253
+ }
254
+ };
255
+
256
+ const selectedNativeConfigParent = (env: NodeJS.ProcessEnv): string | null =>
257
+ env.CLAUDE_CONFIG_DIR?.trim() || env.HOME?.trim() || null;
258
+
259
+ const assertSettingsSelectNative = (file: string): void => {
260
+ let raw: string | null;
261
+ try { raw = readNativeRaw(file); }
262
+ catch { throw new ClaudeNativeEffectiveAuthUnsupportedError('settings'); }
263
+ if (raw === null) return;
264
+ let settings: Record<string, unknown>;
265
+ try { settings = document(raw); }
266
+ catch { throw new ClaudeNativeEffectiveAuthUnsupportedError('settings'); }
267
+ if (text(settings.apiKeyHelper) || settings.forceLoginMethod || settings.forceLoginGatewayUrl) {
268
+ throw new ClaudeNativeEffectiveAuthUnsupportedError('settings');
269
+ }
270
+ if (settings.env !== undefined) {
271
+ if (!isRecord(settings.env)) throw new ClaudeNativeEffectiveAuthUnsupportedError('settings');
272
+ for (const key of contextKeys) {
273
+ if (settings.env[key]) throw new ClaudeNativeEffectiveAuthUnsupportedError('settings');
274
+ }
275
+ }
276
+ };
277
+
278
+ const assertNoManagedAuthSource = (root: string): void => {
279
+ try {
280
+ plugins.fs.lstatSync(plugins.path.join(root, 'managed-settings.json'));
281
+ throw new ClaudeNativeEffectiveAuthUnsupportedError('settings');
282
+ } catch (error) {
283
+ if (error instanceof ClaudeNativeEffectiveAuthUnsupportedError) throw error;
284
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
285
+ throw new ClaudeNativeEffectiveAuthUnsupportedError('settings');
286
+ }
287
+ }
288
+ try {
289
+ if (plugins.fs.readdirSync(plugins.path.join(root, 'managed-settings.d'))
290
+ .some(name => !name.startsWith('.') && name.endsWith('.json'))) {
291
+ throw new ClaudeNativeEffectiveAuthUnsupportedError('settings');
292
+ }
293
+ } catch (error) {
294
+ if (error instanceof ClaudeNativeEffectiveAuthUnsupportedError) throw error;
295
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
296
+ throw new ClaudeNativeEffectiveAuthUnsupportedError('settings');
297
+ }
298
+ }
299
+ };
300
+
301
+ const assertProjectSettingsSelectNative = (cwd: string, processRoot?: string): void => {
302
+ for (let directory = cwd;; directory = plugins.path.dirname(directory)) {
303
+ for (const name of ['settings.json', 'settings.local.json']) {
304
+ assertSettingsSelectNative(processViewPath(plugins.path.join(directory, '.claude', name), processRoot));
305
+ }
306
+ if (plugins.path.dirname(directory) === directory) break;
307
+ }
308
+ };
309
+
310
+ /** Read native files without following a replacement symlink or accepting an unbounded/partial file. */
311
+ const readNativeBytes = (file: string): Buffer | null => {
312
+ let descriptor: number | undefined;
313
+ try {
314
+ descriptor = plugins.fs.openSync(file,
315
+ plugins.fs.constants.O_RDONLY | plugins.fs.constants.O_NONBLOCK | plugins.fs.constants.O_NOFOLLOW);
316
+ const stat = plugins.fs.fstatSync(descriptor);
317
+ if (!stat.isFile() || stat.size > MAX_NATIVE_FILE_BYTES || stat.uid !== process.getuid?.()) {
318
+ throw new Error('Native Claude credential file is not a bounded owner-only regular file.');
319
+ }
320
+ const buffer = Buffer.alloc(MAX_NATIVE_FILE_BYTES + 1);
321
+ let length = 0;
322
+ while (length < buffer.length) {
323
+ const count = plugins.fs.readSync(descriptor, buffer, length, buffer.length - length, null);
324
+ if (count === 0) break;
325
+ length += count;
326
+ }
327
+ if (length === buffer.length) throw new Error('Native Claude credential file exceeds the supported size.');
328
+ // Keep the original bytes: TextDecoder accepts a BOM but strips it from text.
329
+ const bytes = buffer.subarray(0, length);
330
+ new TextDecoder('utf-8', { fatal: true }).decode(bytes);
331
+ return bytes;
332
+ } catch (error) {
333
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
334
+ throw new Error('Native Claude credential file is unreadable or changed; no new credential was written.');
335
+ } finally {
336
+ if (descriptor !== undefined) plugins.fs.closeSync(descriptor);
337
+ }
338
+ };
339
+
340
+ const decodeNativeRaw = (bytes: Uint8Array | null): string | null => bytes === null ? null
341
+ : new TextDecoder('utf-8', { fatal: true }).decode(bytes);
342
+ const readNativeRaw = (file: string): string | null => decodeNativeRaw(readNativeBytes(file));
343
+
344
+ const document = (raw: Uint8Array | string | null): Record<string, unknown> => {
345
+ if (raw === null) return {};
346
+ try {
347
+ const value: unknown = JSON.parse(typeof raw === 'string' ? raw : decodeNativeRaw(raw)!);
348
+ if (isRecord(value)) return value;
349
+ } catch { /* Report one fixed, secret-free error. */ }
350
+ throw new Error('Native Claude credential file is incomplete or malformed.');
351
+ };
352
+
353
+ const validatedLogin = (credential: Record<string, unknown>, config: Record<string, unknown>): {
354
+ login: IClaudeNativeLogin | null; identity: IClaudeNativeIdentity | null;
355
+ } => {
356
+ if (text(config.primaryApiKey)) {
357
+ // 2.1.273 `qh` / `lge` reads this /login-managed API key before subscriber OAuth.
358
+ throw new ClaudeNativeEffectiveAuthUnsupportedError('override');
359
+ }
360
+ const oauthValue = credential.claudeAiOauth;
361
+ const accountValue = config.oauthAccount;
362
+ if (oauthValue === undefined && accountValue === undefined) return { login: null, identity: null };
363
+ if (!isRecord(oauthValue) || !isRecord(accountValue)
364
+ || !text(oauthValue.accessToken) || !text(oauthValue.refreshToken)
365
+ || typeof oauthValue.expiresAt !== 'number' || !Number.isFinite(oauthValue.expiresAt)
366
+ || !Array.isArray(oauthValue.scopes) || !oauthValue.scopes.every(scope => typeof scope === 'string')
367
+ || !text(accountValue.accountUuid) || !text(accountValue.organizationUuid)) {
368
+ throw new Error('Native Claude login or account metadata is incomplete.');
369
+ }
370
+ // `Sc` / `CO` and the full native subscription gate in 2.1.273 require these fields.
371
+ if (!oauthValue.scopes.includes('user:inference')
372
+ || (oauthValue.subscriptionType !== 'pro' && oauthValue.subscriptionType !== 'max')) {
373
+ throw new ClaudeNativeEffectiveAuthUnsupportedError('subscription');
374
+ }
375
+ return {
376
+ login: { claudeAiOauth: structuredClone(oauthValue), oauthAccount: structuredClone(accountValue) },
377
+ identity: { accountUuid: accountValue.accountUuid, organizationUuid: accountValue.organizationUuid },
378
+ };
379
+ };
380
+
381
+ const readPair = (credentialFile: string, configFile: string): {
382
+ credentialRaw: Buffer | null; configRaw: Buffer | null;
383
+ } => ({ credentialRaw: readNativeBytes(credentialFile), configRaw: readNativeBytes(configFile) });
384
+
385
+ const sameRaw = (left: Buffer | null, right: Buffer | null): boolean =>
386
+ left === null ? right === null : right !== null && left.equals(right);
387
+
388
+ const samePair = (left: ReturnType<typeof readPair>, right: ReturnType<typeof readPair>): boolean =>
389
+ sameRaw(left.credentialRaw, right.credentialRaw) && sameRaw(left.configRaw, right.configRaw);
390
+
391
+ const digests = (pair: ReturnType<typeof readPair>): IClaudeNativeDigests => ({
392
+ credentialDigest: pair.credentialRaw === null ? null : hash(pair.credentialRaw),
393
+ configDigest: pair.configRaw === null ? null : hash(pair.configRaw),
394
+ });
395
+
396
+ const directoryIdentity = (directory: string): IClaudeNativeDirectoryIdentity => {
397
+ const stat = plugins.fs.statSync(directory, { bigint: true });
398
+ if (!stat.isDirectory()) throw new Error('Claude native home is not a directory.');
399
+ return { device: stat.dev, inode: stat.ino };
400
+ };
401
+
402
+ const sameDirectory = (left: IClaudeNativeDirectoryIdentity, right: IClaudeNativeDirectoryIdentity): boolean =>
403
+ left.device === right.device && left.inode === right.inode;
404
+
405
+ const processHasExited = (pid: number): boolean => {
406
+ try { process.kill(pid, 0); return false; }
407
+ catch (error) { return (error as NodeJS.ErrnoException).code === 'ESRCH'; }
408
+ };
409
+
410
+ /** Default probe is conservative: every same-user Claude executable must prove the pinned release. */
411
+ class ClaudeNativeVersionProbe implements IClaudeNativeVersionProbe {
412
+ constructor(private readonly executable: string) {}
413
+
414
+ public installedVersion(): string {
415
+ try {
416
+ const output = plugins.childProcess.execFileSync(this.executable, ['--version'], {
417
+ encoding: 'utf8', timeout: 5_000, maxBuffer: 1024, windowsHide: true,
418
+ }).trim();
419
+ const matched = /^(\d+\.\d+\.\d+) \(Claude Code\)$/.exec(output);
420
+ return matched?.[1] ?? 'unknown';
421
+ } catch { return 'unknown'; }
422
+ }
423
+
424
+ public runningContexts(): readonly IClaudeNativeRunningContext[] {
425
+ const contexts: IClaudeNativeRunningContext[] = [];
426
+ // Keep candidates from every mount namespace: a bind mount can expose the same inode.
427
+ const candidates = new HarnessProcesses('claude').list();
428
+ for (const running of candidates) {
429
+ let version: string | null = null;
430
+ let env: NodeJS.ProcessEnv | null = null;
431
+ let cwd: string | null = null;
432
+ let hasSettingsOverride: boolean | null = null;
433
+ let nativeHomeIdentity: IClaudeNativeDirectoryIdentity | null = null;
434
+ let configParentIdentity: IClaudeNativeDirectoryIdentity | null = null;
435
+ let mountNamespace: string | null = null;
436
+ let processRoot: string | null = null;
437
+ try {
438
+ const executable = plugins.fs.readlinkSync(`/proc/${running.pid}/exe`);
439
+ version = /(?:\/claude\/versions\/|\/remote\/ccd-cli\/)(\d+\.\d+\.\d+)$/.exec(executable)?.[1] ?? null;
440
+ mountNamespace = plugins.fs.readlinkSync(`/proc/${running.pid}/ns/mnt`);
441
+ env = selectorEnvironment(readProcessContext(`/proc/${running.pid}/environ`));
442
+ cwd = plugins.fs.readlinkSync(`/proc/${running.pid}/cwd`);
443
+ processRoot = `/proc/${running.pid}/root`;
444
+ if (!plugins.path.isAbsolute(cwd) || !sameDirectory(
445
+ directoryIdentity(`/proc/${running.pid}/cwd`),
446
+ directoryIdentity(processViewPath(cwd, processRoot)))) {
447
+ throw new Error('Claude process working directory cannot be resolved in its mount namespace.');
448
+ }
449
+ const selectedHome = env.CLAUDE_CONFIG_DIR?.trim()
450
+ || (env.HOME?.trim() ? plugins.path.join(env.HOME.trim(), '.claude') : null);
451
+ if (!selectedHome) throw new Error('Claude process native home is unknown.');
452
+ if (plugins.path.isAbsolute(selectedHome)
453
+ && plugins.path.normalize(selectedHome) !== selectedHome) {
454
+ throw new Error('Claude process native home path is ambiguous.');
455
+ }
456
+ const processHome = plugins.path.isAbsolute(selectedHome)
457
+ ? processViewPath(selectedHome, processRoot)
458
+ // Preserve the proc magic symlink before `..` is resolved by the target's VFS.
459
+ : `/proc/${running.pid}/cwd/${selectedHome}`;
460
+ nativeHomeIdentity = directoryIdentity(processHome);
461
+ const selectedConfigParent = selectedNativeConfigParent(env);
462
+ if (!selectedConfigParent) throw new Error('Claude process config parent is unknown.');
463
+ const processConfigParent = plugins.path.isAbsolute(selectedConfigParent)
464
+ ? processViewPath(selectedConfigParent, processRoot)
465
+ : `/proc/${running.pid}/cwd/${selectedConfigParent}`;
466
+ configParentIdentity = directoryIdentity(processConfigParent);
467
+ const argv = readProcessContext(`/proc/${running.pid}/cmdline`).split('\0').filter(Boolean);
468
+ hasSettingsOverride = argv.some(arg => ['--settings', '--managed-settings', '--setting-sources', '--bare'].some(
469
+ flag => arg === flag || arg.startsWith(`${flag}=`)));
470
+ } catch {
471
+ // A vanished process no longer owns a grant; an unreadable live candidate is ambiguous.
472
+ if (processHasExited(running.pid)) continue;
473
+ }
474
+ contexts.push({ version, env, cwd, hasSettingsOverride, nativeHomeIdentity,
475
+ configParentIdentity, mountNamespace, processRoot });
476
+ }
477
+ return contexts;
478
+ }
479
+ }
480
+
481
+ /** Explicitly limited to Claude Code's installed 2.1.273 Linux native-file contract. */
482
+ export class ClaudeNativeAdapter {
483
+ private readonly credentialFile: string;
484
+ private readonly locks: ClaudeCodeLocks;
485
+ private readonly versionProbe: IClaudeNativeVersionProbe;
486
+ private readonly fetcher: typeof fetch;
487
+ private readonly env: NodeJS.ProcessEnv;
488
+ private readonly settingsFiles: readonly string[];
489
+ private readonly platform: NodeJS.Platform;
490
+
491
+ constructor(private readonly options: IClaudeNativeAdapterOptions) {
492
+ if (!plugins.path.isAbsolute(options.configDir) || !plugins.path.isAbsolute(options.configFile)
493
+ || plugins.path.normalize(options.configDir) !== options.configDir
494
+ || plugins.path.normalize(options.configFile) !== options.configFile) {
495
+ throw new Error('Native Claude paths must be normalized absolute paths.');
496
+ }
497
+ if (![plugins.path.join(options.configDir, '.claude.json'),
498
+ plugins.path.join(plugins.os.homedir(), '.claude.json')].includes(options.configFile)) {
499
+ throw new Error('Native Claude account metadata must use its vendor config file.');
500
+ }
501
+ this.credentialFile = plugins.path.join(options.configDir, '.credentials.json');
502
+ this.locks = new ClaudeCodeLocks(options.configDir, options.configFile,
503
+ { timeoutMs: options.lockTimeoutMs });
504
+ this.versionProbe = options.versionProbe ?? new ClaudeNativeVersionProbe(
505
+ options.executable ?? 'claude');
506
+ this.fetcher = options.fetch ?? globalThis.fetch;
507
+ this.env = options.env ?? process.env;
508
+ this.settingsFiles = options.settingsFiles ?? [plugins.path.join(options.configDir, 'settings.json'),
509
+ plugins.path.join(process.cwd(), '.claude/settings.json'),
510
+ plugins.path.join(process.cwd(), '.claude/settings.local.json')];
511
+ this.platform = options.platform ?? process.platform;
512
+ }
513
+
514
+ private assertSupported(): number {
515
+ if (this.platform !== 'linux') throw new Error('Native Claude handoff requires the verified Linux file backend.');
516
+ const home = plugins.fs.lstatSync(this.options.configDir);
517
+ if (!home.isDirectory() || home.isSymbolicLink() || home.uid !== process.getuid?.()
518
+ || plugins.fs.realpathSync(this.options.configDir) !== this.options.configDir) {
519
+ throw new Error('Native Claude home must be a real directory owned by this user.');
520
+ }
521
+ const configParent = plugins.path.dirname(this.options.configFile);
522
+ const parent = plugins.fs.lstatSync(configParent);
523
+ if (!parent.isDirectory() || parent.isSymbolicLink() || parent.uid !== process.getuid?.()
524
+ || plugins.fs.realpathSync(configParent) !== configParent) {
525
+ throw new Error('Native Claude account metadata parent must be a real directory owned by this user.');
526
+ }
527
+ assertNoKnownNativeLoginOverride(this.env);
528
+ assertNativeHome(this.env, this.options.configDir);
529
+ const daemonConfigParent = selectedNativeConfigParent(this.env);
530
+ if (!daemonConfigParent || !plugins.path.isAbsolute(daemonConfigParent)
531
+ || plugins.path.join(daemonConfigParent, '.claude.json') !== this.options.configFile) {
532
+ throw new ClaudeNativeEffectiveAuthUnsupportedError('override');
533
+ }
534
+ assertNoManagedAuthSource(this.options.managedSettingsDirectory ?? '/etc/claude-code');
535
+ for (const file of this.settingsFiles) {
536
+ assertSettingsSelectNative(file);
537
+ }
538
+ if (this.versionProbe.installedVersion() !== CLAUDE_NATIVE_HANDOFF_VERSION) {
539
+ throw new Error(`Native Claude handoff requires Claude Code ${CLAUDE_NATIVE_HANDOFF_VERSION} for every active session.`);
540
+ }
541
+ let runningSessions = 0;
542
+ const nativeHomeIdentity = directoryIdentity(this.options.configDir);
543
+ const configParentIdentity = directoryIdentity(configParent);
544
+ for (const running of this.versionProbe.runningContexts()) {
545
+ if (!running.nativeHomeIdentity) {
546
+ throw new ClaudeNativeEffectiveAuthUnsupportedError('running_session');
547
+ }
548
+ if (!sameDirectory(running.nativeHomeIdentity, nativeHomeIdentity)) continue;
549
+ if (!running.configParentIdentity
550
+ || !sameDirectory(running.configParentIdentity, configParentIdentity)) {
551
+ throw new ClaudeNativeEffectiveAuthUnsupportedError('running_session');
552
+ }
553
+ if (running.version !== CLAUDE_NATIVE_HANDOFF_VERSION) {
554
+ throw new Error(`Native Claude handoff requires Claude Code ${CLAUDE_NATIVE_HANDOFF_VERSION} for every active session.`);
555
+ }
556
+ if (!running.env || !running.cwd || running.hasSettingsOverride === null
557
+ || !running.mountNamespace || !running.processRoot) {
558
+ throw new ClaudeNativeEffectiveAuthUnsupportedError('running_session');
559
+ }
560
+ if (running.hasSettingsOverride) throw new ClaudeNativeEffectiveAuthUnsupportedError('override');
561
+ assertNoKnownNativeLoginOverride(running.env, running.processRoot);
562
+ assertNoManagedAuthSource(processViewPath(
563
+ this.options.managedSettingsDirectory ?? '/etc/claude-code', running.processRoot));
564
+ assertProjectSettingsSelectNative(running.cwd, running.processRoot);
565
+ runningSessions++;
566
+ }
567
+ return runningSessions;
568
+ }
569
+
570
+ /** The caller keeps its SmartData journal and both ownership transactions inside this callback. */
571
+ public async withExclusiveHome<T>(action: (session: IClaudeNativeLockedSession) => Promise<T>): Promise<T> {
572
+ this.assertSupported();
573
+ return this.locks.holdAsync(async lease => {
574
+ this.assertSupported();
575
+ const session = new ClaudeNativeLockedSession(this.credentialFile, this.options.configFile,
576
+ this.fetcher, lease, () => this.assertSupported());
577
+ const firstSnapshot = session.readStable();
578
+ const result = await action(session);
579
+ try {
580
+ lease.assertHeld();
581
+ const afterRunningSessions = this.assertSupported();
582
+ if (firstSnapshot.runningEffectiveAuth === 'no_scoped_sessions' && afterRunningSessions > 0) {
583
+ throw new ClaudeNativeEffectiveAuthUnsupportedError('running_session');
584
+ }
585
+ } catch {
586
+ // The caller may already have committed a journal or changed one native file.
587
+ throw new ClaudeCodeLockOutcomeUnknownError(true);
588
+ }
589
+ return result;
590
+ });
591
+ }
592
+ }
593
+
594
+ class ClaudeNativeLockedSession implements IClaudeNativeLockedSession {
595
+ constructor(private readonly credentialFile: string, private readonly configFile: string,
596
+ private readonly fetcher: typeof fetch, private readonly lease: IClaudeCodeLockLease,
597
+ private readonly assertSupported: () => number) {}
598
+
599
+ private observation(): IClaudeNativeDigests | null {
600
+ try { return digests(readPair(this.credentialFile, this.configFile)); }
601
+ catch { return null; }
602
+ }
603
+
604
+ public readStable(): IClaudeNativeSnapshot {
605
+ this.lease.assertHeld();
606
+ const runningSessions = this.assertSupported();
607
+ let lastError: unknown;
608
+ for (let attempt = 0; attempt < 5; attempt++) {
609
+ try {
610
+ const before = readPair(this.credentialFile, this.configFile);
611
+ const credential = document(before.credentialRaw);
612
+ const config = document(before.configRaw);
613
+ const observed = validatedLogin(credential, config);
614
+ const after = readPair(this.credentialFile, this.configFile);
615
+ if (samePair(before, after)) {
616
+ this.lease.assertHeld();
617
+ return { ...digests(after), ...observed,
618
+ runningEffectiveAuth: runningSessions === 0 ? 'no_scoped_sessions' : 'unsupported_effective_auth' };
619
+ }
620
+ } catch (error) {
621
+ if (error instanceof ClaudeCodeLockOutcomeUnknownError) throw error;
622
+ lastError = error;
623
+ }
624
+ if (attempt < 4) {
625
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
626
+ }
627
+ }
628
+ if (lastError instanceof ClaudeNativeEffectiveAuthUnsupportedError) throw lastError;
629
+ throw new Error(lastError instanceof Error ? lastError.message
630
+ : 'Native Claude files changed while being read; no credential was written.');
631
+ }
632
+
633
+ public async verifyProfileIdentity(snapshot: IClaudeNativeSnapshot,
634
+ expected: IClaudeNativeIdentity): Promise<IClaudeNativeProfileProof> {
635
+ this.lease.assertHeld();
636
+ this.assertSupported();
637
+ const token = snapshot.login?.claudeAiOauth.accessToken;
638
+ if (!text(token) || !snapshot.identity || !sameIdentity(snapshot.identity, expected)) {
639
+ throw new Error('Native Claude login identity differs from the selected account.');
640
+ }
641
+ const response = await claudeRequest(this.fetcher, {
642
+ url: 'https://api.anthropic.com/api/oauth/profile', method: 'GET',
643
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' },
644
+ subject: 'Claude account', timeoutMs: PROFILE_TIMEOUT_MS, now: Date.now,
645
+ });
646
+ this.lease.assertHeld();
647
+ const current = this.observation();
648
+ if (!current || !sameDigests(current, snapshot)) {
649
+ throw new Error('Native Claude login changed during provider identity verification.');
650
+ }
651
+ if (response.status !== 200 || !isRecord(response.body)
652
+ || !isRecord(response.body.account) || !isRecord(response.body.organization)
653
+ || response.body.account.uuid !== expected.accountUuid
654
+ || response.body.organization.uuid !== expected.organizationUuid) {
655
+ throw new Error('Claude account profile did not verify the selected immutable identity.');
656
+ }
657
+ return { ...expected, accessTokenDigest: hash(token) };
658
+ }
659
+
660
+ public async prepareWrite(input: { expected: IClaudeNativeDigests;
661
+ expectedSourceIdentity: IClaudeNativeIdentity | null; targetLogin: IClaudeNativeLogin;
662
+ expectedIdentity: IClaudeNativeIdentity }): Promise<TClaudeNativePrepareResult> {
663
+ this.lease.assertHeld();
664
+ this.assertSupported();
665
+ let before: IClaudeNativeSnapshot;
666
+ try { before = this.readStable(); }
667
+ catch { return { kind: 'not_written', reason: 'source_unreadable', observed: this.observation() }; }
668
+ if (!sameDigests(before, input.expected)) {
669
+ return { kind: 'not_written', reason: 'source_changed', observed: onlyDigests(before) };
670
+ }
671
+ if (input.expectedSourceIdentity === null ? before.identity !== null
672
+ : before.identity === null || !sameIdentity(before.identity, input.expectedSourceIdentity)) {
673
+ return { kind: 'not_written', reason: 'source_identity_mismatch', observed: onlyDigests(before) };
674
+ }
675
+ let target: ReturnType<typeof validatedLogin>;
676
+ try {
677
+ target = validatedLogin({ claudeAiOauth: input.targetLogin.claudeAiOauth },
678
+ { oauthAccount: input.targetLogin.oauthAccount });
679
+ } catch {
680
+ return { kind: 'not_written', reason: 'target_unverified', observed: onlyDigests(before) };
681
+ }
682
+ if (!target.login || !target.identity || !sameIdentity(target.identity, input.expectedIdentity)) {
683
+ return { kind: 'not_written', reason: 'target_unverified', observed: onlyDigests(before) };
684
+ }
685
+ let profile: IClaudeNativeProfileProof;
686
+ try {
687
+ profile = await this.verifyProfileIdentity({ ...before, ...target }, input.expectedIdentity);
688
+ } catch {
689
+ this.lease.assertHeld();
690
+ this.assertSupported();
691
+ const observed = this.observation();
692
+ if (!observed) return { kind: 'not_written', reason: 'source_unreadable', observed: null };
693
+ if (!sameDigests(observed, input.expected)) {
694
+ return { kind: 'not_written', reason: 'source_changed', observed };
695
+ }
696
+ return { kind: 'not_written', reason: 'target_unverified', observed: onlyDigests(before) };
697
+ }
698
+ const current = this.observation();
699
+ if (!current || !sameDigests(current, input.expected)) {
700
+ return { kind: 'not_written', reason: 'source_changed', observed: current };
701
+ }
702
+ this.lease.assertHeld();
703
+ this.assertSupported();
704
+ let original: ReturnType<typeof readPair>;
705
+ try { original = readPair(this.credentialFile, this.configFile); }
706
+ catch { return { kind: 'not_written', reason: 'source_unreadable', observed: this.observation() }; }
707
+ if (!sameDigests(digests(original), input.expected)) {
708
+ return { kind: 'not_written', reason: 'source_changed', observed: digests(original) };
709
+ }
710
+ const credential = document(original.credentialRaw);
711
+ const config = document(original.configRaw);
712
+ const nextCredential = { ...credential, claudeAiOauth: target.login.claudeAiOauth };
713
+ const nextConfig: Record<string, unknown> = { ...config, oauthAccount: target.login.oauthAccount };
714
+ for (const key of ACCOUNT_CACHES) delete nextConfig[key];
715
+ const credentialRaw = JSON.stringify(nextCredential, null, 2) + '\n';
716
+ const configRaw = JSON.stringify(nextConfig, null, 2) + '\n';
717
+ if (Buffer.byteLength(credentialRaw) > MAX_NATIVE_FILE_BYTES
718
+ || Buffer.byteLength(configRaw) > MAX_NATIVE_FILE_BYTES) {
719
+ return { kind: 'not_written', reason: 'target_oversized', observed: onlyDigests(before) };
720
+ }
721
+ const plan: IClaudeNativePreparedWrite = Object.freeze({
722
+ before: Object.freeze(onlyDigests(input.expected)),
723
+ after: Object.freeze(digests({ credentialRaw: Buffer.from(credentialRaw), configRaw: Buffer.from(configRaw) })),
724
+ sourceIdentity: input.expectedSourceIdentity === null ? null : Object.freeze({ ...input.expectedSourceIdentity }),
725
+ targetIdentity: Object.freeze({ ...input.expectedIdentity }),
726
+ profile: Object.freeze({ ...profile }),
727
+ });
728
+ preparedWrites.set(plan, { session: this, credentialRaw, configRaw,
729
+ originalConfigRaw: original.configRaw, targetAccessToken: String(target.login.claudeAiOauth.accessToken) });
730
+ return { kind: 'prepared', plan };
731
+ }
732
+
733
+ public exportPreparedSource(plan: IClaudeNativePreparedWrite): IClaudeNativePreparedSource {
734
+ this.lease.assertHeld();
735
+ this.assertSupported();
736
+ const prepared = preparedWrites.get(plan);
737
+ if (!prepared || prepared.session !== this) throw new Error('Claude native write plan is not held by this lease.');
738
+ const stable = this.readStable();
739
+ if (!sameDigests(stable, plan.before)
740
+ || (plan.sourceIdentity === null ? stable.identity !== null
741
+ : stable.identity === null || !sameIdentity(stable.identity, plan.sourceIdentity))) {
742
+ throw new Error('Claude native source changed before backup export.');
743
+ }
744
+ const original = readPair(this.credentialFile, this.configFile);
745
+ if (!sameDigests(digests(original), plan.before)
746
+ || !sameRaw(original.configRaw, prepared.originalConfigRaw)) {
747
+ throw new Error('Claude native source changed before backup export.');
748
+ }
749
+ this.lease.assertHeld();
750
+ this.assertSupported();
751
+ return { before: Object.freeze({ ...plan.before }),
752
+ credentialBytes: original.credentialRaw === null ? null : Buffer.from(original.credentialRaw),
753
+ configBytes: original.configRaw === null ? null : Buffer.from(original.configRaw) };
754
+ }
755
+
756
+ public writePreparedIfCurrent(plan: IClaudeNativePreparedWrite): TClaudeNativeWriteResult {
757
+ this.lease.assertHeld();
758
+ this.assertSupported();
759
+ const prepared = preparedWrites.get(plan);
760
+ if (!prepared || prepared.session !== this) throw new Error('Claude native write plan is not held by this lease.');
761
+ preparedWrites.delete(plan);
762
+ let before: IClaudeNativeSnapshot;
763
+ try { before = this.readStable(); }
764
+ catch { return { kind: 'not_written', reason: 'source_unreadable', observed: this.observation() }; }
765
+ if (!sameDigests(before, plan.before)) {
766
+ return { kind: 'not_written', reason: 'source_changed', observed: onlyDigests(before) };
767
+ }
768
+ if (plan.sourceIdentity === null ? before.identity !== null
769
+ : before.identity === null || !sameIdentity(before.identity, plan.sourceIdentity)) {
770
+ return { kind: 'not_written', reason: 'source_identity_mismatch', observed: onlyDigests(before) };
771
+ }
772
+ let original: ReturnType<typeof readPair>;
773
+ try { original = readPair(this.credentialFile, this.configFile); }
774
+ catch { return { kind: 'not_written', reason: 'source_unreadable', observed: this.observation() }; }
775
+ if (!sameDigests(digests(original), plan.before)
776
+ || !sameRaw(original.configRaw, prepared.originalConfigRaw)) {
777
+ return { kind: 'not_written', reason: 'source_changed', observed: digests(original) };
778
+ }
779
+ // The first write is the uncertainty boundary; no automatic rollback can prove ownership after it.
780
+ try {
781
+ this.lease.assertHeld();
782
+ writeSecretFileAtomically(this.credentialFile, prepared.credentialRaw);
783
+ this.lease.assertHeld();
784
+ const middle = readPair(this.credentialFile, this.configFile);
785
+ if (!sameRaw(middle.credentialRaw, Buffer.from(prepared.credentialRaw))
786
+ || !sameRaw(middle.configRaw, original.configRaw)) {
787
+ return { kind: 'uncertain', reason: 'native_write_or_readback_failed', observed: digests(middle) };
788
+ }
789
+ writeSecretFileAtomically(this.configFile, prepared.configRaw);
790
+ this.lease.assertHeld();
791
+ const after = readPair(this.credentialFile, this.configFile);
792
+ if (!sameRaw(after.credentialRaw, Buffer.from(prepared.credentialRaw))
793
+ || !sameRaw(after.configRaw, Buffer.from(prepared.configRaw))
794
+ || !sameDigests(digests(after), plan.after)) {
795
+ return { kind: 'uncertain', reason: 'native_write_or_readback_failed', observed: digests(after) };
796
+ }
797
+ const snapshot = this.readStable();
798
+ if (!snapshot.login || !snapshot.identity || !sameIdentity(snapshot.identity, plan.targetIdentity)
799
+ || !sameDigests(snapshot, plan.after)
800
+ || snapshot.login.claudeAiOauth.accessToken !== prepared.targetAccessToken
801
+ || plan.profile.accessTokenDigest !== hash(String(snapshot.login.claudeAiOauth.accessToken))) {
802
+ return { kind: 'uncertain', reason: 'native_write_or_readback_failed', observed: onlyDigests(snapshot) };
803
+ }
804
+ return { kind: 'written', snapshot, profile: plan.profile };
805
+ } catch {
806
+ return { kind: 'uncertain', reason: 'native_write_or_readback_failed', observed: this.observation() };
807
+ }
808
+ }
809
+
810
+ public async writeIfCurrent(input: { expected: IClaudeNativeDigests;
811
+ expectedSourceIdentity: IClaudeNativeIdentity | null; targetLogin: IClaudeNativeLogin;
812
+ expectedIdentity: IClaudeNativeIdentity }): Promise<TClaudeNativeWriteResult> {
813
+ const prepared = await this.prepareWrite(input);
814
+ return prepared.kind === 'prepared' ? this.writePreparedIfCurrent(prepared.plan) : prepared;
815
+ }
816
+ }