@modelprofile.com/authswitch 10.0.0 → 10.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.claudeauthority.d.ts +5 -0
- package/dist_ts/classes.claudeauthority.js +47 -4
- package/dist_ts/classes.claudecodelocks.d.ts +6 -0
- package/dist_ts/classes.claudecodelocks.js +30 -2
- package/dist_ts/classes.claudenative.d.ts +37 -2
- package/dist_ts/classes.claudenative.js +142 -13
- package/package.json +1 -1
- package/readme.md +16 -5
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.claudeauthority.ts +37 -3
- package/ts/classes.claudecodelocks.ts +31 -1
- package/ts/classes.claudenative.ts +161 -14
|
@@ -17,8 +17,18 @@ interface IClaudeCodeLock {
|
|
|
17
17
|
interface IHeldLock {
|
|
18
18
|
lockfilePath: string;
|
|
19
19
|
release: () => Promise<void>;
|
|
20
|
+
/** After how long another process may take this lock over when its time is not renewed. */
|
|
21
|
+
stale: number;
|
|
22
|
+
/** The lock directory as it was created for this hold: a takeover removes it and creates another. */
|
|
23
|
+
identity: { device: number; inode: number; birthtimeMs: number };
|
|
20
24
|
}
|
|
21
25
|
|
|
26
|
+
/**
|
|
27
|
+
* How much of a lock's stale time must remain when a lease is asserted. Everything a caller does after an
|
|
28
|
+
* assertion is a synchronous file write, so no other process can find the lock stale before it completes.
|
|
29
|
+
*/
|
|
30
|
+
const HOLD_MARGIN_MS = 2_000;
|
|
31
|
+
|
|
22
32
|
export interface IClaudeCodeLocksOptions {
|
|
23
33
|
/** How long an operation waits for a lock that Claude Code holds. */
|
|
24
34
|
timeoutMs?: number;
|
|
@@ -26,6 +36,12 @@ export interface IClaudeCodeLocksOptions {
|
|
|
26
36
|
|
|
27
37
|
/** The vendor locks may be lost while an awaited database operation is in flight. */
|
|
28
38
|
export interface IClaudeCodeLockLease {
|
|
39
|
+
/**
|
|
40
|
+
* Proves, at this moment, that every lock is still this hold's: its directory is the one this hold created,
|
|
41
|
+
* and its time was renewed recently enough that no other process may take it over before a synchronous write
|
|
42
|
+
* that follows completes. A lock whose renewal stalled -- an event loop blocked past its update interval --
|
|
43
|
+
* fails this even when nobody has taken it yet.
|
|
44
|
+
*/
|
|
29
45
|
assertHeld(): void;
|
|
30
46
|
}
|
|
31
47
|
|
|
@@ -134,6 +150,16 @@ export class ClaudeCodeLocks {
|
|
|
134
150
|
const lease: IClaudeCodeLockLease = {
|
|
135
151
|
assertHeld: () => {
|
|
136
152
|
if (!active || compromised) throw new ClaudeCodeLockOutcomeUnknownError(false);
|
|
153
|
+
for (const lock of held) {
|
|
154
|
+
let current: plugins.fs.Stats;
|
|
155
|
+
try { current = plugins.fs.lstatSync(lock.lockfilePath); }
|
|
156
|
+
catch { throw new ClaudeCodeLockOutcomeUnknownError(false); }
|
|
157
|
+
if (!current.isDirectory() || current.dev !== lock.identity.device || current.ino !== lock.identity.inode
|
|
158
|
+
|| current.birthtimeMs !== lock.identity.birthtimeMs
|
|
159
|
+
|| Date.now() - current.mtimeMs > lock.stale - HOLD_MARGIN_MS) {
|
|
160
|
+
throw new ClaudeCodeLockOutcomeUnknownError(false);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
137
163
|
},
|
|
138
164
|
};
|
|
139
165
|
const releaseAll = async (): Promise<void> => {
|
|
@@ -189,7 +215,11 @@ export class ClaudeCodeLocks {
|
|
|
189
215
|
const release = await lockfileArg.lock(lock.file, {
|
|
190
216
|
lockfilePath: lock.lockfilePath, realpath: false, stale: lock.stale, update: lock.update, onCompromised: onCompromisedArg,
|
|
191
217
|
});
|
|
192
|
-
|
|
218
|
+
let created: plugins.fs.Stats;
|
|
219
|
+
try { created = plugins.fs.lstatSync(lock.lockfilePath); }
|
|
220
|
+
catch (error) { await release().catch(() => undefined); throw error; }
|
|
221
|
+
heldArg.push({ lockfilePath: lock.lockfilePath, release, stale: lock.stale,
|
|
222
|
+
identity: { device: created.dev, inode: created.ino, birthtimeMs: created.birthtimeMs } });
|
|
193
223
|
} catch (error) {
|
|
194
224
|
if (errorCode(error) === 'ELOCKED') return lock;
|
|
195
225
|
if (requireAllArg || !lock.bestEffort) {
|
|
@@ -6,9 +6,25 @@ import { claudeRequest } from './claudehttp.js';
|
|
|
6
6
|
import { writeSecretFileAtomically } from './helpers.js';
|
|
7
7
|
import { HarnessProcesses } from './classes.harnessprocesses.js';
|
|
8
8
|
|
|
9
|
-
/**
|
|
10
|
-
|
|
9
|
+
/**
|
|
10
|
+
* The earliest Claude Code whose native file contract this adapter writes: `.credentials.json` beside
|
|
11
|
+
* `.claude.json`, and the lock protocol and live credential pickup verified for 2.1.273 (hints.md).
|
|
12
|
+
*/
|
|
13
|
+
export const CLAUDE_NATIVE_MINIMUM_VERSION = '2.1.273';
|
|
14
|
+
/**
|
|
15
|
+
* The releases whose lock table, live credential pickup and refresh compare-and-swap were read from their own
|
|
16
|
+
* bundle (hints.md). A Claude Code session may keep running in the home a handoff writes only when it runs one
|
|
17
|
+
* of these; an installed release above the floor that is not listed is proven by `claude auth status` instead,
|
|
18
|
+
* and only while no session runs in that home.
|
|
19
|
+
*/
|
|
20
|
+
export const CLAUDE_NATIVE_VERIFIED_RELEASES: readonly string[] = ['2.1.273', '2.1.282'];
|
|
21
|
+
/**
|
|
22
|
+
* Token files Claude Code reads at fixed paths when no descriptor variable names one (2.1.282 `KI`, `YGn`):
|
|
23
|
+
* the remote container's hand-off of an OAuth token or an API key. Either one outranks the native login.
|
|
24
|
+
*/
|
|
25
|
+
const REMOTE_CREDENTIAL_FILES = ['/home/claude/.claude/remote/.oauth_token', '/home/claude/.claude/remote/.api_key'] as const;
|
|
11
26
|
const MAX_NATIVE_FILE_BYTES = 4 * 1024 * 1024;
|
|
27
|
+
const AUTH_STATUS_TIMEOUT_MS = 4_000;
|
|
12
28
|
const MAX_PROCESS_CONTEXT_BYTES = 1024 * 1024;
|
|
13
29
|
const PROFILE_TIMEOUT_MS = 10_000;
|
|
14
30
|
const ACCOUNT_CACHES = [
|
|
@@ -19,7 +35,8 @@ const ACCOUNT_CACHES = [
|
|
|
19
35
|
const CREDENTIAL_OVERRIDES = [
|
|
20
36
|
'CLAUDE_CODE_OAUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR', 'ANTHROPIC_API_KEY',
|
|
21
37
|
'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_PROFILE', 'CLAUDE_CODE_USE_BEDROCK',
|
|
22
|
-
'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY', '
|
|
38
|
+
'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY', 'CLAUDE_CODE_USE_ANTHROPIC_AWS',
|
|
39
|
+
'CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD', 'CLAUDE_CODE_USE_MANTLE', 'ANTHROPIC_BASE_URL',
|
|
23
40
|
'ANTHROPIC_FEDERATION_RULE_ID', 'ANTHROPIC_ORGANIZATION_ID',
|
|
24
41
|
'ANTHROPIC_IDENTITY_TOKEN_FILE', 'ANTHROPIC_IDENTITY_TOKEN', 'ANTHROPIC_WORKSPACE_ID',
|
|
25
42
|
'ANTHROPIC_UNIX_SOCKET', 'CLAUDE_CODE_REMOTE', 'CLAUDE_CODE_SIMPLE',
|
|
@@ -37,8 +54,9 @@ export class ClaudeNativeEffectiveAuthUnsupportedError extends Error {
|
|
|
37
54
|
|
|
38
55
|
constructor(public readonly reason: TAuthSwitchClaudeProofFailure) {
|
|
39
56
|
super(reason === 'unsupported_release'
|
|
40
|
-
? `Native Claude handoff requires Claude Code ${
|
|
41
|
-
|
|
57
|
+
? `Native Claude handoff requires Claude Code ${CLAUDE_NATIVE_MINIMUM_VERSION} or newer, and a session `
|
|
58
|
+
+ `running in this home must run a verified release (${CLAUDE_NATIVE_VERIFIED_RELEASES.join(', ')}).`
|
|
59
|
+
: `Cannot prove that Claude Code uses its native claude.ai login (${reason}).`);
|
|
42
60
|
}
|
|
43
61
|
}
|
|
44
62
|
|
|
@@ -95,6 +113,20 @@ export type TClaudeNativePrepareResult =
|
|
|
95
113
|
|
|
96
114
|
export interface IClaudeNativeLockedSession {
|
|
97
115
|
readStable(): IClaudeNativeSnapshot;
|
|
116
|
+
/**
|
|
117
|
+
* Proves that Claude Code itself selects the native login of `expected` in this home. A verified release is
|
|
118
|
+
* proven by the adapter's own reading of its selector; any other release above the floor is asked through
|
|
119
|
+
* `claude auth status --json`, which names the organization and the account's email, the only account
|
|
120
|
+
* identifiers it reports. Throws `ClaudeNativeEffectiveAuthUnsupportedError` when it cannot be proven, and
|
|
121
|
+
* `ClaudeCodeLockOutcomeUnknownError` when the locks were lost while it asked.
|
|
122
|
+
*/
|
|
123
|
+
proveEffectiveLogin(expected: IClaudeNativeLogin): Promise<void>;
|
|
124
|
+
/**
|
|
125
|
+
* Writes back the exact bytes a written plan replaced, while the locks that guarded the write are still held.
|
|
126
|
+
* True once both files read back as they were; false, writing nothing, when the pair is no longer exactly
|
|
127
|
+
* what the plan wrote.
|
|
128
|
+
*/
|
|
129
|
+
restorePreparedSource(plan: IClaudeNativePreparedWrite): boolean;
|
|
98
130
|
verifyProfileIdentity(snapshot: IClaudeNativeSnapshot,
|
|
99
131
|
expected: IClaudeNativeIdentity): Promise<IClaudeNativeProfileProof>;
|
|
100
132
|
prepareWrite(input: {
|
|
@@ -115,6 +147,12 @@ export interface IClaudeNativeLockedSession {
|
|
|
115
147
|
export interface IClaudeNativeVersionProbe {
|
|
116
148
|
installedVersion(): string;
|
|
117
149
|
runningContexts(): readonly IClaudeNativeRunningContext[];
|
|
150
|
+
/**
|
|
151
|
+
* What `claude auth status --json` prints under `env`, whatever its exit code, or null when it does not
|
|
152
|
+
* answer. Asynchronous on purpose: it runs while the vendor locks are held, and their renewal timers must keep
|
|
153
|
+
* running.
|
|
154
|
+
*/
|
|
155
|
+
authStatus(env: NodeJS.ProcessEnv): Promise<string | null>;
|
|
118
156
|
}
|
|
119
157
|
|
|
120
158
|
/** Only selector keys and paths, never a process's credential values, leave the /proc reader. */
|
|
@@ -171,8 +209,30 @@ const onlyDigests = (value: IClaudeNativeDigests): IClaudeNativeDigests => ({
|
|
|
171
209
|
});
|
|
172
210
|
const preparedWrites = new WeakMap<IClaudeNativePreparedWrite, {
|
|
173
211
|
session: ClaudeNativeLockedSession; credentialRaw: string; configRaw: string;
|
|
174
|
-
originalConfigRaw: Buffer | null; targetAccessToken: string;
|
|
212
|
+
originalCredentialRaw: Buffer | null; originalConfigRaw: Buffer | null; targetAccessToken: string;
|
|
175
213
|
}>();
|
|
214
|
+
/** A written plan's original bytes, kept until the handoff proves the write or restores them. */
|
|
215
|
+
const writtenPlans = new WeakMap<IClaudeNativePreparedWrite, {
|
|
216
|
+
session: ClaudeNativeLockedSession; credentialRaw: string; configRaw: string;
|
|
217
|
+
originalCredentialRaw: Buffer; originalConfigRaw: Buffer;
|
|
218
|
+
}>();
|
|
219
|
+
|
|
220
|
+
const parseClaudeVersion = (value: string): [number, number, number] | null => {
|
|
221
|
+
const match = /^(\d{1,6})\.(\d{1,6})\.(\d{1,6})$/.exec(value);
|
|
222
|
+
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
|
|
223
|
+
};
|
|
224
|
+
const atLeast = (version: [number, number, number], minimum: [number, number, number]): boolean => {
|
|
225
|
+
for (let index = 0; index < 3; index++) {
|
|
226
|
+
if (version[index] !== minimum[index]) return version[index] > minimum[index];
|
|
227
|
+
}
|
|
228
|
+
return true;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
/** Which proof applies to one operation: the adapter's own selector reading, or Claude Code's own answer. */
|
|
232
|
+
interface IClaudeNativeSupport {
|
|
233
|
+
runningSessions: number;
|
|
234
|
+
proof: 'verified_release' | 'auth_status';
|
|
235
|
+
}
|
|
176
236
|
const sameIdentity = (left: IClaudeNativeIdentity, right: IClaudeNativeIdentity): boolean =>
|
|
177
237
|
left.accountUuid === right.accountUuid && left.organizationUuid === right.organizationUuid;
|
|
178
238
|
|
|
@@ -303,6 +363,15 @@ const assertNoKnownNativeLoginOverride = (env: NodeJS.ProcessEnv, processRoot?:
|
|
|
303
363
|
for (const key of CREDENTIAL_OVERRIDES) {
|
|
304
364
|
if (env[key]) throw new ClaudeNativeEffectiveAuthUnsupportedError('override');
|
|
305
365
|
}
|
|
366
|
+
for (const file of REMOTE_CREDENTIAL_FILES) {
|
|
367
|
+
try {
|
|
368
|
+
plugins.fs.lstatSync(processViewPath(file, processRoot));
|
|
369
|
+
} catch (error) {
|
|
370
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue;
|
|
371
|
+
throw new ClaudeNativeEffectiveAuthUnsupportedError('override');
|
|
372
|
+
}
|
|
373
|
+
throw new ClaudeNativeEffectiveAuthUnsupportedError('override');
|
|
374
|
+
}
|
|
306
375
|
const configDir = env.ANTHROPIC_CONFIG_DIR?.trim()
|
|
307
376
|
|| (env.XDG_CONFIG_HOME?.trim()
|
|
308
377
|
? plugins.path.join(env.XDG_CONFIG_HOME.trim(), 'anthropic')
|
|
@@ -504,6 +573,20 @@ class ClaudeNativeVersionProbe implements IClaudeNativeVersionProbe {
|
|
|
504
573
|
} catch { return 'unknown'; }
|
|
505
574
|
}
|
|
506
575
|
|
|
576
|
+
public authStatus(env: NodeJS.ProcessEnv): Promise<string | null> {
|
|
577
|
+
// Well inside the shortest lock renewal window (the config lock renews every 5 s); it answers in ~200 ms.
|
|
578
|
+
return new Promise(resolve => {
|
|
579
|
+
const child = plugins.childProcess.execFile(this.executable, ['auth', 'status', '--json'], {
|
|
580
|
+
encoding: 'utf8', timeout: AUTH_STATUS_TIMEOUT_MS, maxBuffer: 64 * 1024, windowsHide: true, env,
|
|
581
|
+
}, (error, stdout) => {
|
|
582
|
+
// A logged-out status still prints its JSON and exits 1; only no answer at all is null.
|
|
583
|
+
const exited = error === null || typeof error.code === 'number';
|
|
584
|
+
resolve(exited && stdout ? stdout : null);
|
|
585
|
+
});
|
|
586
|
+
child.stdin?.end();
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
|
|
507
590
|
public runningContexts(): readonly IClaudeNativeRunningContext[] {
|
|
508
591
|
const contexts: IClaudeNativeRunningContext[] = [];
|
|
509
592
|
// Keep candidates from every mount namespace: a bind mount can expose the same inode.
|
|
@@ -597,7 +680,7 @@ export class ClaudeNativeAdapter {
|
|
|
597
680
|
this.platform = options.platform ?? process.platform;
|
|
598
681
|
}
|
|
599
682
|
|
|
600
|
-
private assertSupported():
|
|
683
|
+
private assertSupported(): IClaudeNativeSupport {
|
|
601
684
|
if (this.platform !== 'linux') throw new Error('Native Claude handoff requires the verified Linux file backend.');
|
|
602
685
|
const home = plugins.fs.lstatSync(this.options.configDir);
|
|
603
686
|
if (!home.isDirectory() || home.isSymbolicLink() || home.uid !== process.getuid?.()
|
|
@@ -621,9 +704,12 @@ export class ClaudeNativeAdapter {
|
|
|
621
704
|
for (const file of this.settingsFiles) {
|
|
622
705
|
assertSettingsSelectNative(file);
|
|
623
706
|
}
|
|
624
|
-
|
|
707
|
+
const installedText = this.versionProbe.installedVersion();
|
|
708
|
+
const installed = parseClaudeVersion(installedText);
|
|
709
|
+
if (!installed || !atLeast(installed, parseClaudeVersion(CLAUDE_NATIVE_MINIMUM_VERSION)!)) {
|
|
625
710
|
throw new ClaudeNativeEffectiveAuthUnsupportedError('unsupported_release');
|
|
626
711
|
}
|
|
712
|
+
const installedVerified = CLAUDE_NATIVE_VERIFIED_RELEASES.includes(installedText);
|
|
627
713
|
let runningSessions = 0;
|
|
628
714
|
const nativeHomeIdentity = directoryIdentity(this.options.configDir);
|
|
629
715
|
const configParentIdentity = directoryIdentity(configParent);
|
|
@@ -636,7 +722,9 @@ export class ClaudeNativeAdapter {
|
|
|
636
722
|
|| !sameDirectory(running.configParentIdentity, configParentIdentity)) {
|
|
637
723
|
throw new ClaudeNativeEffectiveAuthUnsupportedError('running_session');
|
|
638
724
|
}
|
|
639
|
-
|
|
725
|
+
// A session keeps taking its own locks and picking up the swapped login while the handoff runs, so
|
|
726
|
+
// it must be a release whose lock and pickup contract was read from its bundle.
|
|
727
|
+
if (running.version === null || !CLAUDE_NATIVE_VERIFIED_RELEASES.includes(running.version)) {
|
|
640
728
|
throw new ClaudeNativeEffectiveAuthUnsupportedError('unsupported_release');
|
|
641
729
|
}
|
|
642
730
|
if (!running.env || !running.cwd || running.hasSettingsOverride === null
|
|
@@ -650,7 +738,34 @@ export class ClaudeNativeAdapter {
|
|
|
650
738
|
assertProjectSettingsSelectNative(running.cwd, running.processRoot);
|
|
651
739
|
runningSessions++;
|
|
652
740
|
}
|
|
653
|
-
|
|
741
|
+
// An installed release nobody verified is proven by its own answer, which says nothing about how a session
|
|
742
|
+
// already running would take the locks or pick up the swap; it is written only while none runs here.
|
|
743
|
+
if (!installedVerified && runningSessions > 0) {
|
|
744
|
+
throw new ClaudeNativeEffectiveAuthUnsupportedError('unsupported_release');
|
|
745
|
+
}
|
|
746
|
+
return { runningSessions, proof: installedVerified ? 'verified_release' : 'auth_status' };
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Claude Code's own account of the login it selects in this home: `claude auth status --json` under the
|
|
751
|
+
* adapter's environment. It must be the claude.ai login of `expected`, read from this home, with no API key.
|
|
752
|
+
*/
|
|
753
|
+
private async proveEffectiveLogin(expected: IClaudeNativeLogin): Promise<void> {
|
|
754
|
+
if (this.assertSupported().proof === 'verified_release') return;
|
|
755
|
+
const organizationUuid = expected.oauthAccount.organizationUuid;
|
|
756
|
+
const email = typeof expected.oauthAccount.emailAddress === 'string' ? expected.oauthAccount.emailAddress : null;
|
|
757
|
+
const raw = await this.versionProbe.authStatus(this.env);
|
|
758
|
+
let status: unknown;
|
|
759
|
+
try { status = raw === null ? null : JSON.parse(raw); } catch { status = null; }
|
|
760
|
+
if (!isRecord(status) || typeof status.loggedIn !== 'boolean' || typeof status.authMethod !== 'string') {
|
|
761
|
+
throw new ClaudeNativeEffectiveAuthUnsupportedError('unsupported_release');
|
|
762
|
+
}
|
|
763
|
+
if (status.loggedIn !== true || status.authMethod !== 'claude.ai' || status.apiProvider !== 'firstParty'
|
|
764
|
+
|| (status.apiKeySource !== undefined && status.apiKeySource !== null)
|
|
765
|
+
|| status.configDirectory !== this.options.configDir || status.orgId !== organizationUuid
|
|
766
|
+
|| (status.email ?? null) !== email) {
|
|
767
|
+
throw new ClaudeNativeEffectiveAuthUnsupportedError('override');
|
|
768
|
+
}
|
|
654
769
|
}
|
|
655
770
|
|
|
656
771
|
/** The caller keeps its NoSQLDB journal and both ownership transactions inside this callback. */
|
|
@@ -659,12 +774,13 @@ export class ClaudeNativeAdapter {
|
|
|
659
774
|
return this.locks.holdAsync(async lease => {
|
|
660
775
|
this.assertSupported();
|
|
661
776
|
const session = new ClaudeNativeLockedSession(this.credentialFile, this.options.configFile,
|
|
662
|
-
this.fetcher, lease, () => this.assertSupported()
|
|
777
|
+
this.fetcher, lease, () => this.assertSupported().runningSessions,
|
|
778
|
+
expected => this.proveEffectiveLogin(expected));
|
|
663
779
|
const firstSnapshot = session.readStable();
|
|
664
780
|
const result = await action(session);
|
|
665
781
|
try {
|
|
666
782
|
lease.assertHeld();
|
|
667
|
-
const afterRunningSessions = this.assertSupported();
|
|
783
|
+
const afterRunningSessions = this.assertSupported().runningSessions;
|
|
668
784
|
if (firstSnapshot.runningEffectiveAuth === 'no_scoped_sessions' && afterRunningSessions > 0) {
|
|
669
785
|
throw new ClaudeNativeEffectiveAuthUnsupportedError('running_session');
|
|
670
786
|
}
|
|
@@ -680,7 +796,34 @@ export class ClaudeNativeAdapter {
|
|
|
680
796
|
class ClaudeNativeLockedSession implements IClaudeNativeLockedSession {
|
|
681
797
|
constructor(private readonly credentialFile: string, private readonly configFile: string,
|
|
682
798
|
private readonly fetcher: typeof fetch, private readonly lease: IClaudeCodeLockLease,
|
|
683
|
-
private readonly assertSupported: () => number
|
|
799
|
+
private readonly assertSupported: () => number,
|
|
800
|
+
private readonly proveEffective: (expected: IClaudeNativeLogin) => Promise<void>) {}
|
|
801
|
+
|
|
802
|
+
public async proveEffectiveLogin(expected: IClaudeNativeLogin): Promise<void> {
|
|
803
|
+
this.lease.assertHeld();
|
|
804
|
+
await this.proveEffective(expected);
|
|
805
|
+
// Asserted after the answer, before anything is decided or written on it.
|
|
806
|
+
this.lease.assertHeld();
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
public restorePreparedSource(plan: IClaudeNativePreparedWrite): boolean {
|
|
810
|
+
this.lease.assertHeld();
|
|
811
|
+
const written = writtenPlans.get(plan);
|
|
812
|
+
if (!written || written.session !== this) throw new Error('Claude native write plan is not held by this lease.');
|
|
813
|
+
writtenPlans.delete(plan);
|
|
814
|
+
let current: ReturnType<typeof readPair>;
|
|
815
|
+
try { current = readPair(this.credentialFile, this.configFile); } catch { return false; }
|
|
816
|
+
if (!sameRaw(current.credentialRaw, Buffer.from(written.credentialRaw))
|
|
817
|
+
|| !sameRaw(current.configRaw, Buffer.from(written.configRaw))) return false;
|
|
818
|
+
this.lease.assertHeld();
|
|
819
|
+
writeSecretFileAtomically(this.credentialFile, written.originalCredentialRaw.toString('utf8'));
|
|
820
|
+
writeSecretFileAtomically(this.configFile, written.originalConfigRaw.toString('utf8'));
|
|
821
|
+
this.lease.assertHeld();
|
|
822
|
+
const restored = readPair(this.credentialFile, this.configFile);
|
|
823
|
+
return sameRaw(restored.credentialRaw, written.originalCredentialRaw)
|
|
824
|
+
&& sameRaw(restored.configRaw, written.originalConfigRaw)
|
|
825
|
+
&& sameDigests(digests(restored), plan.before);
|
|
826
|
+
}
|
|
684
827
|
|
|
685
828
|
private observation(): IClaudeNativeDigests | null {
|
|
686
829
|
try { return digests(readPair(this.credentialFile, this.configFile)); }
|
|
@@ -811,7 +954,7 @@ class ClaudeNativeLockedSession implements IClaudeNativeLockedSession {
|
|
|
811
954
|
targetIdentity: Object.freeze({ ...input.expectedIdentity }),
|
|
812
955
|
profile: Object.freeze({ ...profile }),
|
|
813
956
|
});
|
|
814
|
-
preparedWrites.set(plan, { session: this, credentialRaw, configRaw,
|
|
957
|
+
preparedWrites.set(plan, { session: this, credentialRaw, configRaw, originalCredentialRaw: original.credentialRaw,
|
|
815
958
|
originalConfigRaw: original.configRaw, targetAccessToken: String(target.login.claudeAiOauth.accessToken) });
|
|
816
959
|
return { kind: 'prepared', plan };
|
|
817
960
|
}
|
|
@@ -864,6 +1007,10 @@ class ClaudeNativeLockedSession implements IClaudeNativeLockedSession {
|
|
|
864
1007
|
|| plan.profile.accessTokenDigest !== hash(String(snapshot.login.claudeAiOauth.accessToken))) {
|
|
865
1008
|
return { kind: 'uncertain', reason: 'native_write_or_readback_failed', observed: onlyDigests(snapshot) };
|
|
866
1009
|
}
|
|
1010
|
+
if (prepared.originalCredentialRaw !== null && prepared.originalConfigRaw !== null) {
|
|
1011
|
+
writtenPlans.set(plan, { session: this, credentialRaw: prepared.credentialRaw, configRaw: prepared.configRaw,
|
|
1012
|
+
originalCredentialRaw: prepared.originalCredentialRaw, originalConfigRaw: prepared.originalConfigRaw });
|
|
1013
|
+
}
|
|
867
1014
|
return { kind: 'written', snapshot, profile: plan.profile };
|
|
868
1015
|
} catch {
|
|
869
1016
|
return { kind: 'uncertain', reason: 'native_write_or_readback_failed', observed: this.observation() };
|