@modelprofile.com/authswitch 8.0.0 → 8.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/authority-contract.d.ts +56 -0
- package/dist_ts/authority-contract.js +54 -2
- package/dist_ts/authority-import-contract.d.ts +211 -0
- package/dist_ts/authority-import-contract.js +17 -0
- package/dist_ts/classes.authoritybroker.js +3 -2
- package/dist_ts/classes.authoritycli.d.ts +4 -0
- package/dist_ts/classes.authoritycli.js +148 -11
- package/dist_ts/classes.authorityclient.d.ts +24 -11
- package/dist_ts/classes.authorityclient.js +40 -22
- package/dist_ts/classes.authoritydaemon.d.ts +12 -0
- package/dist_ts/classes.authoritydaemon.js +123 -36
- package/dist_ts/classes.authoritydatabase.d.ts +14 -1
- package/dist_ts/classes.authoritydatabase.js +48 -16
- package/dist_ts/classes.authorityimport.d.ts +176 -0
- package/dist_ts/classes.authorityimport.js +664 -0
- package/dist_ts/classes.authoritymodels.d.ts +16 -1
- package/dist_ts/classes.authoritymodels.js +31 -3
- package/dist_ts/classes.authorityservice.d.ts +10 -0
- package/dist_ts/classes.authorityservice.js +24 -1
- package/dist_ts/classes.claudeauthority.js +19 -11
- package/dist_ts/classes.claudenative.d.ts +63 -3
- package/dist_ts/classes.claudenative.js +68 -8
- package/dist_ts/index.d.ts +1 -0
- package/dist_ts/index.js +6 -3
- package/dist_ts/ts_migration/0003_claude_handoff_proof.d.ts +13 -0
- package/dist_ts/ts_migration/0003_claude_handoff_proof.js +20 -0
- package/dist_ts/ts_migration/index.js +3 -1
- package/dist_ts/ts_migration/legacysources/authswitchstores.d.ts +2 -0
- package/dist_ts/ts_migration/legacysources/authswitchstores.js +244 -0
- package/dist_ts/ts_migration/legacysources/index.d.ts +18 -0
- package/dist_ts/ts_migration/legacysources/index.js +24 -0
- package/dist_ts/ts_migration/legacysources/material.d.ts +37 -0
- package/dist_ts/ts_migration/legacysources/material.js +120 -0
- package/dist_ts/ts_migration/legacysources/nativestores.d.ts +2 -0
- package/dist_ts/ts_migration/legacysources/nativestores.js +214 -0
- package/dist_ts/ts_migration/legacysources/shared.d.ts +125 -0
- package/dist_ts/ts_migration/legacysources/shared.js +140 -0
- package/package.json +9 -1
- package/readme.md +111 -2
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/authority-contract.ts +106 -0
- package/ts/authority-import-contract.ts +217 -0
- package/ts/classes.authoritybroker.ts +2 -1
- package/ts/classes.authoritycli.ts +164 -10
- package/ts/classes.authorityclient.ts +65 -21
- package/ts/classes.authoritydaemon.ts +121 -34
- package/ts/classes.authoritydatabase.ts +49 -15
- package/ts/classes.authorityimport.ts +746 -0
- package/ts/classes.authoritymodels.ts +32 -2
- package/ts/classes.authorityservice.ts +27 -0
- package/ts/classes.claudeauthority.ts +22 -10
- package/ts/classes.claudenative.ts +104 -10
- package/ts/index.ts +5 -2
- package/ts/ts_migration/0003_claude_handoff_proof.ts +19 -0
- package/ts/ts_migration/index.ts +2 -0
- package/ts/ts_migration/legacysources/authswitchstores.ts +211 -0
- package/ts/ts_migration/legacysources/index.ts +35 -0
- package/ts/ts_migration/legacysources/material.ts +134 -0
- package/ts/ts_migration/legacysources/nativestores.ts +212 -0
- package/ts/ts_migration/legacysources/shared.ts +218 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
|
-
import type { TAuthSwitchLoginPrompt } from './authority-contract.js';
|
|
2
|
+
import type { TAuthSwitchClaudeProofFailure, TAuthSwitchLoginPrompt } from './authority-contract.js';
|
|
3
3
|
import type { IAuthSwitchStoredUsage } from './classes.authorityusage.js';
|
|
4
4
|
|
|
5
5
|
export interface IStoredAuthorityMeta {
|
|
@@ -51,6 +51,27 @@ export interface IStoredAuthorityGrant {
|
|
|
51
51
|
updateId: string;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Whether a change of grant owner hands the refresher from one live owner to another.
|
|
56
|
+
*
|
|
57
|
+
* `daemon`, `legacy_native` and `claude_native` each refresh the grant they own, so moving between any two
|
|
58
|
+
* of them is a transfer and needs a verified handoff: doing it in one write would leave two owners believing
|
|
59
|
+
* they may send the same rotating refresh token. `none` is not an owner but the absence of one -- a grant
|
|
60
|
+
* that needs re-authentication has no refresher, and a device login gives it one -- so every transition to
|
|
61
|
+
* or from `none` is an ordinary lifecycle step, not a transfer.
|
|
62
|
+
*/
|
|
63
|
+
export const transfersGrantRefresher = (previous: IStoredAuthorityGrant['owner'],
|
|
64
|
+
next: IStoredAuthorityGrant['owner']): boolean =>
|
|
65
|
+
previous !== next && previous !== 'none' && next !== 'none';
|
|
66
|
+
|
|
67
|
+
/** The refusal that names the direction of a transfer, so a caller learns which handoff it needs. */
|
|
68
|
+
export const grantRefresherTransferRefusal = (previous: IStoredAuthorityGrant['owner'],
|
|
69
|
+
next: IStoredAuthorityGrant['owner']): string => next === 'daemon'
|
|
70
|
+
? 'Native ownership requires a verified handoff.'
|
|
71
|
+
: previous === 'daemon'
|
|
72
|
+
? 'Daemon ownership is released by a verified handoff.'
|
|
73
|
+
: 'Native ownership moves between native owners by a verified handoff.';
|
|
74
|
+
|
|
54
75
|
/** Credential-free provider usage cached for one exact account/login pair. */
|
|
55
76
|
export interface IStoredAuthorityUsage extends IAuthSwitchStoredUsage {
|
|
56
77
|
id: string;
|
|
@@ -134,6 +155,8 @@ export interface IStoredAuthorityClaudeHandoff {
|
|
|
134
155
|
sealedOutgoing: string | null;
|
|
135
156
|
runningEffectiveAuth: 'no_scoped_sessions' | 'unsupported_effective_auth' | null;
|
|
136
157
|
problem: 'none' | 'native_uncertain' | 'foreign_or_torn' | 'unsupported_effective_auth' | 'database_uncertain';
|
|
158
|
+
/** Which condition of the native-login proof failed; stored, so a lost answer stays diagnosable. */
|
|
159
|
+
proofFailure: TAuthSwitchClaudeProofFailure | null;
|
|
137
160
|
startedAt: string;
|
|
138
161
|
updatedAt: string;
|
|
139
162
|
revision: number;
|
|
@@ -481,7 +504,7 @@ export const assertStoredAuthorityClaudeHandoff: (value: unknown) => asserts val
|
|
|
481
504
|
if (!object(value) || !exactKeys(value, ['id', 'homeId', 'outgoingAccountId', 'outgoingGrantId',
|
|
482
505
|
'incomingAccountId', 'incomingGrantId', 'phase', 'before', 'after', 'sourceIdentity',
|
|
483
506
|
'targetIdentity', 'sourceAccessDigest', 'targetAccessDigest', 'sealedOutgoing',
|
|
484
|
-
'runningEffectiveAuth', 'problem', 'startedAt', 'updatedAt', 'revision', 'updateId'])
|
|
507
|
+
'runningEffectiveAuth', 'problem', 'proofFailure', 'startedAt', 'updatedAt', 'revision', 'updateId'])
|
|
485
508
|
|| !uuid(value.id) || !hash(value.homeId) || !hash(value.outgoingAccountId)
|
|
486
509
|
|| !hash(value.outgoingGrantId) || !hash(value.incomingAccountId) || !hash(value.incomingGrantId)
|
|
487
510
|
|| value.outgoingAccountId === value.incomingAccountId || value.outgoingGrantId === value.incomingGrantId
|
|
@@ -493,8 +516,14 @@ export const assertStoredAuthorityClaudeHandoff: (value: unknown) => asserts val
|
|
|
493
516
|
|| !ciphertext(value.sealedOutgoing)
|
|
494
517
|
|| ![null, 'no_scoped_sessions', 'unsupported_effective_auth'].includes(value.runningEffectiveAuth as null)
|
|
495
518
|
|| !['none', 'native_uncertain', 'foreign_or_torn', 'unsupported_effective_auth', 'database_uncertain'].includes(String(value.problem))
|
|
519
|
+
|| ![null, 'unsupported_release', 'override', 'profile', 'profile_unreadable', 'settings',
|
|
520
|
+
'subscription', 'running_session'].includes(value.proofFailure as null)
|
|
496
521
|
|| !iso(value.startedAt) || !iso(value.updatedAt) || !revision(value.revision)
|
|
497
522
|
|| value.revision === 0 || !uuid(value.updateId)) throw new Error('Invalid Claude native handoff.');
|
|
523
|
+
// The detail belongs to exactly the problem it explains, so a client can trust one without the other.
|
|
524
|
+
if (value.proofFailure !== null && value.problem !== 'unsupported_effective_auth') {
|
|
525
|
+
throw new Error('Claude handoff names a proof failure without the problem it explains.');
|
|
526
|
+
}
|
|
498
527
|
const hasProof = value.before !== null && value.after !== null
|
|
499
528
|
&& value.sourceAccessDigest !== null && value.targetAccessDigest !== null;
|
|
500
529
|
const hasNoProof = value.before === null && value.after === null
|
|
@@ -817,6 +846,7 @@ export class AuthSwitchAuthorityClaudeHandoffModel extends plugins.nosqldb.Smart
|
|
|
817
846
|
@plugins.nosqldb.svDb() public sealedOutgoing!: string | null;
|
|
818
847
|
@plugins.nosqldb.svDb() public runningEffectiveAuth!: IStoredAuthorityClaudeHandoff['runningEffectiveAuth'];
|
|
819
848
|
@plugins.nosqldb.svDb() public problem!: IStoredAuthorityClaudeHandoff['problem'];
|
|
849
|
+
@plugins.nosqldb.svDb() public proofFailure!: IStoredAuthorityClaudeHandoff['proofFailure'];
|
|
820
850
|
@plugins.nosqldb.svDb() public startedAt!: string;
|
|
821
851
|
@plugins.nosqldb.svDb() public updatedAt!: string;
|
|
822
852
|
@plugins.nosqldb.svDb() public revision!: number;
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
2
|
import { AuthSwitchAuthorityDaemon } from './classes.authoritydaemon.js';
|
|
3
|
+
import type { IClaudeNativeAuthorityOptions } from './classes.claudeauthority.js';
|
|
4
|
+
import { ClaudeNativeAdapter, claudeNativeUserSettingsFiles,
|
|
5
|
+
resolveClaudeNativeHome } from './classes.claudenative.js';
|
|
3
6
|
|
|
4
7
|
export interface IAuthSwitchAuthorityPaths {
|
|
5
8
|
runtimeDirectory: string;
|
|
@@ -60,12 +63,36 @@ export class AuthSwitchAuthorityService {
|
|
|
60
63
|
public stop(): Promise<plugins.smartdaemon.ISystemdUnitState> { return this.unit.stop(); }
|
|
61
64
|
}
|
|
62
65
|
|
|
66
|
+
/**
|
|
67
|
+
* The Claude native authority this user's daemon serves.
|
|
68
|
+
*
|
|
69
|
+
* It is wired whether or not Claude Code is installed: nothing here touches the filesystem, and every
|
|
70
|
+
* operation that needs the native home refuses until a verified import receipt has registered it. An
|
|
71
|
+
* environment that names a home this daemon cannot resolve is a named startup failure rather than a
|
|
72
|
+
* silently missing capability, because the same environment also decides which file the import reads.
|
|
73
|
+
*/
|
|
74
|
+
export const resolveAuthSwitchClaudeNativeAuthority = (env: NodeJS.ProcessEnv = process.env,
|
|
75
|
+
homeDirectory: string = plugins.os.homedir()): IClaudeNativeAuthorityOptions => {
|
|
76
|
+
const resolution = resolveClaudeNativeHome(env, homeDirectory);
|
|
77
|
+
if (resolution.kind === 'unusable') {
|
|
78
|
+
throw new Error(`Authswitch authority cannot locate Claude Code's native home: ${resolution.defect.problem}.`);
|
|
79
|
+
}
|
|
80
|
+
const { configDir, configFile, homeId } = resolution.home;
|
|
81
|
+
// The adapter re-derives which home Claude Code itself selects; it must read the environment this
|
|
82
|
+
// home was resolved from, or the two could disagree about the same host. Its settings sources are
|
|
83
|
+
// stated from that same home: this daemon's working directory is the service unit's own (the package
|
|
84
|
+
// root, see `AuthSwitchAuthorityService`), so a project settings file beside it selects nothing.
|
|
85
|
+
return { homeId, adapter: new ClaudeNativeAdapter({ configDir, configFile, env,
|
|
86
|
+
settingsFiles: claudeNativeUserSettingsFiles(configDir) }) };
|
|
87
|
+
};
|
|
88
|
+
|
|
63
89
|
export const runAuthSwitchAuthorityDaemon = async (paths = resolveAuthSwitchAuthorityPaths()): Promise<void> => {
|
|
64
90
|
const daemon = new AuthSwitchAuthorityDaemon({
|
|
65
91
|
dataDirectory: paths.dataDirectory,
|
|
66
92
|
socketPath: paths.databaseSocketPath,
|
|
67
93
|
authoritySocketPath: paths.authoritySocketPath,
|
|
68
94
|
runtimeSocketPath: paths.runtimeSocketPath,
|
|
95
|
+
claudeNative: resolveAuthSwitchClaudeNativeAuthority(),
|
|
69
96
|
});
|
|
70
97
|
await daemon.start();
|
|
71
98
|
await new Promise<void>((resolve, reject) => {
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
2
|
import type { IAuthSwitchClaudeNativeHandoff } from './authority-contract.js';
|
|
3
|
+
import { AuthSwitchRefusal } from './authority-contract.js';
|
|
3
4
|
import { AuthSwitchAuthorityDatabase } from './classes.authoritydatabase.js';
|
|
4
5
|
import type { IStoredAuthorityAccount, IStoredAuthorityClaudeHandoff, IStoredAuthorityClaudeHome,
|
|
5
6
|
IStoredAuthorityGrant, IStoredAuthorityNativePair } from './classes.authoritymodels.js';
|
|
6
|
-
import { ClaudeNativeAdapter,
|
|
7
|
+
import { ClaudeNativeAdapter, ClaudeNativeEffectiveAuthUnsupportedError,
|
|
8
|
+
type IClaudeNativeIdentity, type IClaudeNativeLogin,
|
|
7
9
|
type IClaudeNativeLockedSession, type IClaudeNativeSnapshot } from './classes.claudenative.js';
|
|
8
10
|
import { ClaudeTokenRefresh } from './classes.claudetokenrefresh.js';
|
|
9
11
|
import { AuthSwitchTpmSecretCodec, type IAuthSwitchSecretCodec } from './classes.authoritysecrets.js';
|
|
@@ -34,6 +36,7 @@ const publicHandoff = (handoff: IStoredAuthorityClaudeHandoff): IAuthSwitchClaud
|
|
|
34
36
|
id: handoff.id, homeId: handoff.homeId, outgoingAccountId: handoff.outgoingAccountId,
|
|
35
37
|
incomingAccountId: handoff.incomingAccountId, phase: handoff.phase, problem: handoff.problem,
|
|
36
38
|
runningEffectiveAuth: handoff.runningEffectiveAuth, updatedAt: handoff.updatedAt,
|
|
39
|
+
...(handoff.proofFailure === null ? {} : { proofFailure: handoff.proofFailure }),
|
|
37
40
|
});
|
|
38
41
|
|
|
39
42
|
/** Daemon-owned Claude refresh and a journaled transfer of one native home between two grants. */
|
|
@@ -137,7 +140,7 @@ export class ClaudeNativeAuthority {
|
|
|
137
140
|
const account = await this.database.readAccount(grant.accountId);
|
|
138
141
|
if (!account || account.removed) return;
|
|
139
142
|
if (!(await this.database.hasVerifiedClaudeGrantReceipt(account.id, grant.id))) {
|
|
140
|
-
throw new
|
|
143
|
+
throw new AuthSwitchRefusal('claude_receipt_missing', 'Claude refresh requires a verified import receipt.');
|
|
141
144
|
}
|
|
142
145
|
const login = await this.unsealLogin(account, grant);
|
|
143
146
|
if (rejectedGrantGeneration !== undefined && rejectedGrantGeneration > grant.grantGeneration) {
|
|
@@ -229,7 +232,7 @@ export class ClaudeNativeAuthority {
|
|
|
229
232
|
throw new Error('Inactive Claude status authority changed.');
|
|
230
233
|
}
|
|
231
234
|
if (!(await this.database.hasVerifiedClaudeGrantReceipt(view.account.id, view.grant.id))) {
|
|
232
|
-
throw new
|
|
235
|
+
throw new AuthSwitchRefusal('claude_receipt_missing', 'Claude status requires a verified import receipt.');
|
|
233
236
|
}
|
|
234
237
|
const currentLogin = this.statusLogin(view.account, await this.unsealLogin(view.account, view.grant));
|
|
235
238
|
if (!currentLogin.scopes.includes('user:profile')) {
|
|
@@ -342,7 +345,7 @@ export class ClaudeNativeAuthority {
|
|
|
342
345
|
incomingAccountId: incoming.accountId, incomingGrantId: incoming.id, phase: 'reserved',
|
|
343
346
|
before: null, after: null, sourceIdentity: identityFor(outgoingAccount),
|
|
344
347
|
targetIdentity: identityFor(incomingAccount), sourceAccessDigest: null, targetAccessDigest: null,
|
|
345
|
-
sealedOutgoing: null, runningEffectiveAuth: null, problem: 'none', startedAt: now,
|
|
348
|
+
sealedOutgoing: null, runningEffectiveAuth: null, problem: 'none', proofFailure: null, startedAt: now,
|
|
346
349
|
updatedAt: now, revision: 1, updateId,
|
|
347
350
|
};
|
|
348
351
|
return { home: this.homeStep(home, updateId, { pendingOperationId: operationId }), handoff,
|
|
@@ -369,7 +372,8 @@ export class ClaudeNativeAuthority {
|
|
|
369
372
|
|
|
370
373
|
private async settle(operationId: string, phase: 'committed' | 'aborted' | 'quarantined',
|
|
371
374
|
runningEffectiveAuth: IClaudeNativeSnapshot['runningEffectiveAuth'] | null,
|
|
372
|
-
problem: IStoredAuthorityClaudeHandoff['problem'] = 'none'
|
|
375
|
+
problem: IStoredAuthorityClaudeHandoff['problem'] = 'none',
|
|
376
|
+
proofFailure: IStoredAuthorityClaudeHandoff['proofFailure'] = null): Promise<IAuthSwitchClaudeNativeHandoff> {
|
|
373
377
|
const context = await this.database.readClaudeHandoffContext(operationId);
|
|
374
378
|
if (!context) throw new Error('Claude handoff operation is missing.');
|
|
375
379
|
if (['committed', 'aborted', 'quarantined'].includes(context.handoff.phase)) return publicHandoff(context.handoff);
|
|
@@ -395,7 +399,7 @@ export class ClaudeNativeAuthority {
|
|
|
395
399
|
: quarantined ? { status: 'quarantined' }
|
|
396
400
|
: { pendingOperationId: null, status: 'ready' }),
|
|
397
401
|
handoff: { ...handoff, phase, sealedOutgoing: quarantined ? handoff.sealedOutgoing : null,
|
|
398
|
-
runningEffectiveAuth, problem, updatedAt: now, revision: handoff.revision + 1, updateId },
|
|
402
|
+
runningEffectiveAuth, problem, proofFailure, updatedAt: now, revision: handoff.revision + 1, updateId },
|
|
399
403
|
outgoing: this.grantStep(outgoing, updateId, now, committed
|
|
400
404
|
? { state: 'ready', owner: 'daemon', ciphertext: handoff.sealedOutgoing,
|
|
401
405
|
accessExpiresAt: outgoingExpiry, problem: 'none',
|
|
@@ -448,8 +452,11 @@ export class ClaudeNativeAuthority {
|
|
|
448
452
|
if (['committed', 'aborted', 'quarantined'].includes(context.handoff.phase)) return publicHandoff(context.handoff);
|
|
449
453
|
try {
|
|
450
454
|
return await this.options.adapter.withExclusiveHome(session => this.classify(operationId, session));
|
|
451
|
-
} catch {
|
|
452
|
-
|
|
455
|
+
} catch (error) {
|
|
456
|
+
// Which condition of the proof failed is the one thing an owner can act on, so it is journaled
|
|
457
|
+
// rather than collapsed into the problem it explains.
|
|
458
|
+
return this.settle(operationId, 'quarantined', null, 'unsupported_effective_auth',
|
|
459
|
+
error instanceof ClaudeNativeEffectiveAuthUnsupportedError ? error.reason : null);
|
|
453
460
|
}
|
|
454
461
|
}
|
|
455
462
|
|
|
@@ -479,8 +486,13 @@ export class ClaudeNativeAuthority {
|
|
|
479
486
|
|
|
480
487
|
private async performSwitch(accountId: string, loginId: string): Promise<IAuthSwitchClaudeNativeHandoff> {
|
|
481
488
|
const home = await this.database.readClaudeHome(this.options.homeId);
|
|
489
|
+
// Two different answers for the owner: there is no adopted home yet, or one move is already running.
|
|
490
|
+
if (home && home.status === 'ready' && home.pendingOperationId !== null) {
|
|
491
|
+
throw new AuthSwitchRefusal('claude_handoff_pending',
|
|
492
|
+
'A Claude handoff is already running for this home; wait for it to finish, then switch again.');
|
|
493
|
+
}
|
|
482
494
|
if (!home || home.status !== 'ready' || home.pendingOperationId !== null) {
|
|
483
|
-
throw new
|
|
495
|
+
throw new AuthSwitchRefusal('claude_home_unregistered', 'Verified Claude native home is unavailable or has a pending handoff.');
|
|
484
496
|
}
|
|
485
497
|
await this.refreshInactiveGrant(loginId);
|
|
486
498
|
const operationId = plugins.crypto.randomUUID();
|
|
@@ -517,7 +529,7 @@ export class ClaudeNativeAuthority {
|
|
|
517
529
|
|
|
518
530
|
public async getHandoff(operationId: string): Promise<IAuthSwitchClaudeNativeHandoff> {
|
|
519
531
|
const handoff = await this.database.readClaudeHandoff(operationId);
|
|
520
|
-
if (!handoff || handoff.homeId !== this.options.homeId) throw new
|
|
532
|
+
if (!handoff || handoff.homeId !== this.options.homeId) throw new AuthSwitchRefusal('not_found', 'Claude handoff operation was not found.');
|
|
521
533
|
return publicHandoff(handoff);
|
|
522
534
|
}
|
|
523
535
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
|
+
import type { TAuthSwitchClaudeProofFailure } from './authority-contract.js';
|
|
2
3
|
import { ClaudeCodeLockOutcomeUnknownError, ClaudeCodeLocks,
|
|
3
4
|
type IClaudeCodeLockLease } from './classes.claudecodelocks.js';
|
|
4
5
|
import { claudeRequest } from './claudehttp.js';
|
|
@@ -34,9 +35,10 @@ const CREDENTIAL_OVERRIDES = [
|
|
|
34
35
|
export class ClaudeNativeEffectiveAuthUnsupportedError extends Error {
|
|
35
36
|
public readonly code = 'unsupported_effective_auth';
|
|
36
37
|
|
|
37
|
-
constructor(public readonly reason:
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
constructor(public readonly reason: TAuthSwitchClaudeProofFailure) {
|
|
39
|
+
super(reason === 'unsupported_release'
|
|
40
|
+
? `Native Claude handoff requires Claude Code ${CLAUDE_NATIVE_HANDOFF_VERSION} for every active session.`
|
|
41
|
+
: `Cannot prove that Claude Code ${CLAUDE_NATIVE_HANDOFF_VERSION} uses its native claude.ai login (${reason}).`);
|
|
40
42
|
}
|
|
41
43
|
}
|
|
42
44
|
|
|
@@ -142,7 +144,15 @@ export interface IClaudeNativeAdapterOptions {
|
|
|
142
144
|
executable?: string;
|
|
143
145
|
platform?: NodeJS.Platform;
|
|
144
146
|
env?: NodeJS.ProcessEnv;
|
|
145
|
-
|
|
147
|
+
/**
|
|
148
|
+
* The settings sources this proof reads, stated by the caller because only it knows which they are.
|
|
149
|
+
*
|
|
150
|
+
* There is no default: this adapter runs inside the daemon, whose working directory is the service
|
|
151
|
+
* unit's and never a Claude session's project, so a list derived from `process.cwd()` would prove a
|
|
152
|
+
* directory nobody selected. `claudeNativeUserSettingsFiles` is the daemon's list; the settings a
|
|
153
|
+
* running session selected are proven from that session's own working directory instead.
|
|
154
|
+
*/
|
|
155
|
+
settingsFiles: readonly string[];
|
|
146
156
|
/** Internal test seam; production uses Claude's Linux managed-settings directory. */
|
|
147
157
|
managedSettingsDirectory?: string;
|
|
148
158
|
fetch?: typeof fetch;
|
|
@@ -166,6 +176,87 @@ const preparedWrites = new WeakMap<IClaudeNativePreparedWrite, {
|
|
|
166
176
|
const sameIdentity = (left: IClaudeNativeIdentity, right: IClaudeNativeIdentity): boolean =>
|
|
167
177
|
left.accountUuid === right.accountUuid && left.organizationUuid === right.organizationUuid;
|
|
168
178
|
|
|
179
|
+
/** Claude Code's own paths on this host, and the authority's id for that one native home. */
|
|
180
|
+
export interface IClaudeNativeHome {
|
|
181
|
+
configDir: string;
|
|
182
|
+
configFile: string;
|
|
183
|
+
credentialFile: string;
|
|
184
|
+
homeId: string;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* A home this host cannot have: the location it was pointed at, and why it is not usable.
|
|
189
|
+
*
|
|
190
|
+
* The inventory reports such a source with its defect rather than skipping it or reading a guessed
|
|
191
|
+
* location, so `declaredCredentialFile` names what the environment asked for, unresolved.
|
|
192
|
+
*/
|
|
193
|
+
export interface IClaudeNativeHomeDefect {
|
|
194
|
+
declaredCredentialFile: string;
|
|
195
|
+
problem: string;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export type TClaudeNativeHomeResolution =
|
|
199
|
+
| { kind: 'resolved'; home: IClaudeNativeHome }
|
|
200
|
+
| { kind: 'unusable'; defect: IClaudeNativeHomeDefect };
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The authority's id for one native Claude home: the hash of the credential file Claude Code owns.
|
|
204
|
+
*
|
|
205
|
+
* The import ledger publishes the same value as a source's `sourcePathHash`, and
|
|
206
|
+
* `registerClaudeHomeFromReceipt` adopts a home only when the two are equal, so both sides must derive it
|
|
207
|
+
* here and nowhere else.
|
|
208
|
+
*/
|
|
209
|
+
export const claudeNativeHomeId = (credentialFile: string): string => hash(credentialFile);
|
|
210
|
+
|
|
211
|
+
/** An absolute, normalized directory without a trailing separator, or null for anything else. */
|
|
212
|
+
const canonicalDirectory = (value: string): string | null => {
|
|
213
|
+
if (!plugins.path.isAbsolute(value)) return null;
|
|
214
|
+
const normalized = plugins.path.normalize(value);
|
|
215
|
+
return normalized.length > 1 && normalized.endsWith(plugins.path.sep)
|
|
216
|
+
? normalized.slice(0, -1) : normalized;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Where Claude Code keeps this user's login, resolved once for the daemon and the import inventory alike.
|
|
221
|
+
*
|
|
222
|
+
* `CLAUDE_CONFIG_DIR` selects the home; Claude Code keeps its account metadata beside the home directory
|
|
223
|
+
* unless that variable moves it. A relative directory is never resolved against the current working
|
|
224
|
+
* directory: a daemon and a CLI run from different places, so guessing would make them disagree about
|
|
225
|
+
* which file is the login.
|
|
226
|
+
*/
|
|
227
|
+
export const resolveClaudeNativeHome = (env: NodeJS.ProcessEnv,
|
|
228
|
+
homeDirectory: string): TClaudeNativeHomeResolution => {
|
|
229
|
+
const declared = env.CLAUDE_CONFIG_DIR ? canonicalDirectory(env.CLAUDE_CONFIG_DIR) : null;
|
|
230
|
+
if (env.CLAUDE_CONFIG_DIR && declared === null) {
|
|
231
|
+
return { kind: 'unusable', defect: {
|
|
232
|
+
declaredCredentialFile: plugins.path.join(env.CLAUDE_CONFIG_DIR, '.credentials.json'),
|
|
233
|
+
problem: 'CLAUDE_CONFIG_DIR is not an absolute path, so Claude Code\'s own login cannot be located' } };
|
|
234
|
+
}
|
|
235
|
+
const home = canonicalDirectory(homeDirectory);
|
|
236
|
+
if (home === null) {
|
|
237
|
+
return { kind: 'unusable', defect: {
|
|
238
|
+
declaredCredentialFile: plugins.path.join(homeDirectory, '.claude', '.credentials.json'),
|
|
239
|
+
problem: 'the home directory is not an absolute path, so Claude Code\'s own login cannot be located' } };
|
|
240
|
+
}
|
|
241
|
+
const configDir = declared ?? plugins.path.join(home, '.claude');
|
|
242
|
+
const credentialFile = plugins.path.join(configDir, '.credentials.json');
|
|
243
|
+
return { kind: 'resolved', home: { configDir,
|
|
244
|
+
configFile: plugins.path.join(declared ?? home, '.claude.json'),
|
|
245
|
+
credentialFile, homeId: claudeNativeHomeId(credentialFile) } };
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The settings sources Claude Code reads at user scope for one resolved home.
|
|
250
|
+
*
|
|
251
|
+
* This is what a host-wide owner -- the daemon -- may prove: the user settings file beside the credential
|
|
252
|
+
* file the home owns. Claude Code's other two scopes are proven where they are selected: the managed
|
|
253
|
+
* settings directory is checked on its own, and a project's `.claude/settings.json` and
|
|
254
|
+
* `.claude/settings.local.json` belong to the working directory of a running session, which is read from
|
|
255
|
+
* that session rather than from this process.
|
|
256
|
+
*/
|
|
257
|
+
export const claudeNativeUserSettingsFiles = (configDir: string): readonly string[] =>
|
|
258
|
+
[plugins.path.join(configDir, 'settings.json')];
|
|
259
|
+
|
|
169
260
|
const pathEnvironment = ['HOME', 'XDG_CONFIG_HOME', 'ANTHROPIC_CONFIG_DIR', 'CLAUDE_CONFIG_DIR'] as const;
|
|
170
261
|
const contextKeys = new Set<string>([...CREDENTIAL_OVERRIDES, ...pathEnvironment]);
|
|
171
262
|
const readProcessContext = (file: string): string => {
|
|
@@ -486,8 +577,13 @@ export class ClaudeNativeAdapter {
|
|
|
486
577
|
|| plugins.path.normalize(options.configFile) !== options.configFile) {
|
|
487
578
|
throw new Error('Native Claude paths must be normalized absolute paths.');
|
|
488
579
|
}
|
|
580
|
+
// Claude Code keeps its account metadata inside a configured home and beside an unconfigured one.
|
|
581
|
+
// Which home that is follows from the environment this adapter was given, the same environment
|
|
582
|
+
// `assertSupported` re-checks before every operation, never from the running process's own account.
|
|
583
|
+
const configParent = selectedNativeConfigParent(options.env ?? process.env);
|
|
489
584
|
if (![plugins.path.join(options.configDir, '.claude.json'),
|
|
490
|
-
plugins.path.join(
|
|
585
|
+
...(configParent === null ? [] : [plugins.path.join(configParent, '.claude.json')]),
|
|
586
|
+
].includes(options.configFile)) {
|
|
491
587
|
throw new Error('Native Claude account metadata must use its vendor config file.');
|
|
492
588
|
}
|
|
493
589
|
this.credentialFile = plugins.path.join(options.configDir, '.credentials.json');
|
|
@@ -497,9 +593,7 @@ export class ClaudeNativeAdapter {
|
|
|
497
593
|
options.executable ?? 'claude');
|
|
498
594
|
this.fetcher = options.fetch ?? globalThis.fetch;
|
|
499
595
|
this.env = options.env ?? process.env;
|
|
500
|
-
this.settingsFiles = options.settingsFiles
|
|
501
|
-
plugins.path.join(process.cwd(), '.claude/settings.json'),
|
|
502
|
-
plugins.path.join(process.cwd(), '.claude/settings.local.json')];
|
|
596
|
+
this.settingsFiles = options.settingsFiles;
|
|
503
597
|
this.platform = options.platform ?? process.platform;
|
|
504
598
|
}
|
|
505
599
|
|
|
@@ -528,7 +622,7 @@ export class ClaudeNativeAdapter {
|
|
|
528
622
|
assertSettingsSelectNative(file);
|
|
529
623
|
}
|
|
530
624
|
if (this.versionProbe.installedVersion() !== CLAUDE_NATIVE_HANDOFF_VERSION) {
|
|
531
|
-
throw new
|
|
625
|
+
throw new ClaudeNativeEffectiveAuthUnsupportedError('unsupported_release');
|
|
532
626
|
}
|
|
533
627
|
let runningSessions = 0;
|
|
534
628
|
const nativeHomeIdentity = directoryIdentity(this.options.configDir);
|
|
@@ -543,7 +637,7 @@ export class ClaudeNativeAdapter {
|
|
|
543
637
|
throw new ClaudeNativeEffectiveAuthUnsupportedError('running_session');
|
|
544
638
|
}
|
|
545
639
|
if (running.version !== CLAUDE_NATIVE_HANDOFF_VERSION) {
|
|
546
|
-
throw new
|
|
640
|
+
throw new ClaudeNativeEffectiveAuthUnsupportedError('unsupported_release');
|
|
547
641
|
}
|
|
548
642
|
if (!running.env || !running.cwd || running.hasSettingsOverride === null
|
|
549
643
|
|| !running.mountNamespace || !running.processRoot) {
|
package/ts/index.ts
CHANGED
|
@@ -26,6 +26,7 @@ export * from './watchpolicy.js';
|
|
|
26
26
|
export * from './classes.authorityclient.js';
|
|
27
27
|
export * from './classes.authoritycli.js';
|
|
28
28
|
export * from './classes.authoritydaemon.js';
|
|
29
|
+
export * from './classes.authorityimport.js';
|
|
29
30
|
export * from './classes.authorityservice.js';
|
|
30
31
|
|
|
31
32
|
import { AuthSwitchCli } from './classes.cli.js';
|
|
@@ -33,7 +34,8 @@ import { runAuthSwitchAuthorityCli } from './classes.authoritycli.js';
|
|
|
33
34
|
import { AuthSwitchAuthorityService, resolveAuthSwitchAuthorityPaths, runAuthSwitchAuthorityDaemon } from './classes.authorityservice.js';
|
|
34
35
|
|
|
35
36
|
export const runCli = async (argvArg: string[] = process.argv.slice(2)): Promise<void> => {
|
|
36
|
-
if (argvArg[0] === 'account'
|
|
37
|
+
if (argvArg[0] === 'account'
|
|
38
|
+
|| (argvArg[0] === 'authority' && (argvArg[1] === 'doctor' || argvArg[1] === 'import'))) {
|
|
37
39
|
process.exitCode = await runAuthSwitchAuthorityCli(argvArg);
|
|
38
40
|
return;
|
|
39
41
|
}
|
|
@@ -57,7 +59,8 @@ export const runCli = async (argvArg: string[] = process.argv.slice(2)): Promise
|
|
|
57
59
|
return;
|
|
58
60
|
}
|
|
59
61
|
}
|
|
60
|
-
throw new Error('Usage: authswitch authority daemon | service install|status|enable|start|stop'
|
|
62
|
+
throw new Error('Usage: authswitch authority daemon | service install|status|enable|start|stop '
|
|
63
|
+
+ '| doctor | import inventory');
|
|
61
64
|
}
|
|
62
65
|
const exitCode = await new AuthSwitchCli().run(argvArg);
|
|
63
66
|
process.exitCode = exitCode;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import * as plugins from '../plugins.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Gives every Claude native handoff journalled before 8.2.0 its `proofFailure` field.
|
|
5
|
+
*
|
|
6
|
+
* The handoff record is exact-persistence: a document with a key the shape does not declare, or without one
|
|
7
|
+
* it does, is refused rather than read loosely. A published release therefore cannot gain a field by
|
|
8
|
+
* declaring it -- the rows that already exist have to carry it too, and this is where that happens.
|
|
9
|
+
*
|
|
10
|
+
* Only the absence is filled, with `null`: no release before this one could say which condition of the
|
|
11
|
+
* native-login proof failed, so there is nothing to infer and nothing to overwrite. A row that already has
|
|
12
|
+
* the field is left exactly as it is, which is what makes an interrupted run safe to repeat.
|
|
13
|
+
*/
|
|
14
|
+
export const addClaudeHandoffProofFailure = async (db: plugins.nosqldb.SmartdataDb): Promise<void> => {
|
|
15
|
+
const handoffs = db.mongoDb.collection('authswitch_authority_claude_handoffs');
|
|
16
|
+
const missing = { proofFailure: { $exists: false } };
|
|
17
|
+
if (!await handoffs.findOne(missing)) return;
|
|
18
|
+
await handoffs.updateMany(missing, { $set: { proofFailure: null } });
|
|
19
|
+
};
|
package/ts/ts_migration/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as plugins from '../plugins.js';
|
|
2
2
|
import { migrateAuthorityMeta } from './0001_authority_meta.js';
|
|
3
3
|
import { removeAuthorityBackupRecords } from './0002_remove_backup_records.js';
|
|
4
|
+
import { addClaudeHandoffProofFailure } from './0003_claude_handoff_proof.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Runs on every start of an existing store, in this fixed order. Each module recognises the published shapes it
|
|
@@ -9,4 +10,5 @@ import { removeAuthorityBackupRecords } from './0002_remove_backup_records.js';
|
|
|
9
10
|
export const runAuthorityMigrations = async (db: plugins.nosqldb.SmartdataDb): Promise<void> => {
|
|
10
11
|
await migrateAuthorityMeta(db);
|
|
11
12
|
await removeAuthorityBackupRecords(db);
|
|
13
|
+
await addClaudeHandoffProofFailure(db);
|
|
12
14
|
};
|