@modelprofile.com/authswitch 8.2.0 → 9.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/authority-contract.d.ts +18 -1
- package/dist_ts/authority-contract.js +3 -2
- package/dist_ts/authority-paths.d.ts +37 -0
- package/dist_ts/authority-paths.js +46 -0
- package/dist_ts/authority-runtime-contract.d.ts +11 -1
- package/dist_ts/classes.authoritybroker.d.ts +9 -0
- package/dist_ts/classes.authoritybroker.js +70 -10
- package/dist_ts/classes.authorityclient.js +20 -1
- package/dist_ts/classes.authoritydaemon.d.ts +14 -3
- package/dist_ts/classes.authoritydaemon.js +33 -2
- package/dist_ts/classes.authoritydatabase.js +11 -2
- package/dist_ts/classes.authorityimport.d.ts +16 -4
- package/dist_ts/classes.authorityimport.js +74 -23
- package/dist_ts/classes.authoritypreuse.js +8 -3
- package/dist_ts/classes.authorityservice.d.ts +10 -11
- package/dist_ts/classes.authorityservice.js +14 -23
- package/dist_ts/classes.cli.d.ts +10 -2
- package/dist_ts/classes.cli.js +12 -4
- package/dist_ts/classes.codexmanaged.d.ts +8 -0
- package/dist_ts/classes.codexmanaged.js +19 -12
- package/dist_ts/classes.legacyfence.d.ts +53 -0
- package/dist_ts/classes.legacyfence.js +189 -0
- package/dist_ts/classes.operations.d.ts +15 -3
- package/dist_ts/classes.operations.js +22 -4
- package/dist_ts/classes.service.d.ts +21 -2
- package/dist_ts/classes.service.js +35 -8
- package/dist_ts/classes.tui.d.ts +2 -1
- package/dist_ts/classes.tui.js +3 -2
- package/dist_ts/ts_migration/legacysources/authswitchstores.js +5 -2
- package/package.json +7 -7
- package/readme.md +129 -17
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/authority-contract.ts +20 -1
- package/ts/authority-paths.ts +69 -0
- package/ts/authority-runtime-contract.ts +11 -1
- package/ts/classes.authoritybroker.ts +68 -10
- package/ts/classes.authorityclient.ts +16 -0
- package/ts/classes.authoritydaemon.ts +45 -4
- package/ts/classes.authoritydatabase.ts +10 -1
- package/ts/classes.authorityimport.ts +101 -24
- package/ts/classes.authoritypreuse.ts +7 -1
- package/ts/classes.authorityservice.ts +15 -30
- package/ts/classes.cli.ts +14 -3
- package/ts/classes.codexmanaged.ts +18 -7
- package/ts/classes.legacyfence.ts +219 -0
- package/ts/classes.operations.ts +22 -3
- package/ts/classes.service.ts +45 -8
- package/ts/classes.tui.ts +3 -1
- package/ts/ts_migration/legacysources/authswitchstores.ts +4 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
2
|
import type { IAuthSwitchAccount, IAuthSwitchAccountEvent, IAuthSwitchBinding, IAuthSwitchLogin,
|
|
3
|
-
IAuthSwitchOperation, IAuthSwitchSnapshot } from './authority-contract.js';
|
|
3
|
+
IAuthSwitchOperation, IAuthSwitchSnapshot, TAuthSwitchLoginOwnerTool } from './authority-contract.js';
|
|
4
4
|
import { AuthSwitchRefusal } from './authority-contract.js';
|
|
5
5
|
import { AuthSwitchAuthorityDatabase } from './classes.authoritydatabase.js';
|
|
6
6
|
import type { IStoredAuthorityAccount, IStoredAuthorityBinding, IStoredAuthorityGrant,
|
|
@@ -51,14 +51,57 @@ const tokenExpiry = (credential: TOpenAiCredential): string | null => {
|
|
|
51
51
|
catch { return null; }
|
|
52
52
|
};
|
|
53
53
|
const openAiGrantId = (accountId: string): string => idHash('authswitch-grant-v1', accountId, 'openai_managed', 'chatgpt');
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* What a caller is told when the answer is decided. Each states the repair and names no id, path or host.
|
|
57
|
+
*
|
|
58
|
+
* They are written once because several sites answer the same question: an operation id this authority does
|
|
59
|
+
* not hold is the same answer whether a read, a cancel or a start met it, and a client that branches on the
|
|
60
|
+
* code must not have to tell those apart.
|
|
61
|
+
*/
|
|
62
|
+
const unknownOperation = 'This authority knows no device sign-in by that operation ID. '
|
|
63
|
+
+ 'Start a new sign-in and follow that one.';
|
|
64
|
+
const loginNotBindable = 'This login cannot back a runtime binding. The authority must hold the login '
|
|
65
|
+
+ 'itself, it must be the account\'s selected OpenAI login, and it must be ready. Reauthenticate the '
|
|
66
|
+
+ 'account or choose another one.';
|
|
67
|
+
const bindingUnauthorized = 'This runtime binding is not authorized. Bind the account again to obtain a '
|
|
68
|
+
+ 'capability for it.';
|
|
69
|
+
const bindingMoved = 'This runtime binding changed while its credential was being resolved. Bind the '
|
|
70
|
+
+ 'account again.';
|
|
71
|
+
const bindingNeedsReauth = 'The account no longer holds the login this binding was authorized for. '
|
|
72
|
+
+ 'Reauthenticate the account, then bind it again.';
|
|
54
73
|
const publicAccount = (account: IStoredAuthorityAccount): IAuthSwitchAccount => ({
|
|
55
74
|
id: account.id, providerId: account.providerId, label: account.label, email: account.email,
|
|
56
75
|
plan: account.plan, removed: account.removed, revision: account.revision,
|
|
57
76
|
statusObservedAt: account.statusObservedAt,
|
|
58
77
|
});
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Which native tool refreshes a grant, decided here because this is the one place that publishes a login.
|
|
81
|
+
*
|
|
82
|
+
* `owner` and `purpose` are what the authority stores; the tool is their meaning, and naming it here keeps
|
|
83
|
+
* every consumer from re-deriving it -- the derivation that is wrong as soon as two purposes share a tool,
|
|
84
|
+
* or one tool gains a second purpose. `daemon` and `none` have no native refresher at all, which is a value
|
|
85
|
+
* of its own rather than a missing one.
|
|
86
|
+
*/
|
|
87
|
+
const loginOwnerTool = (grant: IStoredAuthorityGrant): TAuthSwitchLoginOwnerTool | null => {
|
|
88
|
+
if (grant.owner === 'claude_native') return 'claude_code';
|
|
89
|
+
if (grant.owner !== 'legacy_native') return null;
|
|
90
|
+
switch (grant.purpose) {
|
|
91
|
+
case 'opencode_native': return 'opencode';
|
|
92
|
+
case 'openai_managed': return 'codex';
|
|
93
|
+
case 'claude_host_native': return 'claude_code';
|
|
94
|
+
// A container setup token is held for a container, not refreshed by a tool on this host.
|
|
95
|
+
case 'claude_container_setup': return null;
|
|
96
|
+
// A purpose added without deciding its tool fails the type check here rather than publishing `null`,
|
|
97
|
+
// which would claim the authority refreshes a login some tool actually holds.
|
|
98
|
+
default: { const unhandledPurpose: never = grant.purpose; return unhandledPurpose; }
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
59
102
|
const publicLogin = (grant: IStoredAuthorityGrant): IAuthSwitchLogin => ({
|
|
60
103
|
id: grant.id, accountId: grant.accountId, providerId: grant.providerId, purpose: grant.purpose,
|
|
61
|
-
owner: grant.owner,
|
|
104
|
+
owner: grant.owner, ownerTool: loginOwnerTool(grant),
|
|
62
105
|
health: grant.state === 'exchange_may_have_been_sent' ? 'refreshing'
|
|
63
106
|
: grant.state === 'native' ? 'unverified'
|
|
64
107
|
: grant.state === 'legacy_native_pending' || grant.state === 'handoff_pending' ? 'pending_handoff' : grant.state,
|
|
@@ -341,7 +384,7 @@ export class AuthSwitchAuthorityBroker {
|
|
|
341
384
|
private async finishStartingOperation(operationId: string, state: 'failed' | 'interrupted',
|
|
342
385
|
error: string): Promise<IStoredAuthorityDeviceOperation> {
|
|
343
386
|
const current = await this.database.readOperation(operationId);
|
|
344
|
-
if (!current || current.kind === 'preuse_openai') throw new
|
|
387
|
+
if (!current || current.kind === 'preuse_openai') throw new AuthSwitchRefusal('not_found', unknownOperation);
|
|
345
388
|
if (deviceOperationTerminal(current)) return current;
|
|
346
389
|
if (current.state !== 'starting') throw new Error('Account operation already started its provider login.');
|
|
347
390
|
const updateId = plugins.crypto.randomUUID();
|
|
@@ -407,7 +450,7 @@ export class AuthSwitchAuthorityBroker {
|
|
|
407
450
|
let current: IStoredAuthorityDeviceOperation;
|
|
408
451
|
try {
|
|
409
452
|
const stored = await this.database.readOperation(id);
|
|
410
|
-
if (!stored || stored.kind === 'preuse_openai') throw new
|
|
453
|
+
if (!stored || stored.kind === 'preuse_openai') throw new AuthSwitchRefusal('not_found', unknownOperation);
|
|
411
454
|
current = stored;
|
|
412
455
|
} catch (error) {
|
|
413
456
|
await Promise.allSettled([handle.cancel(), handle.close()]);
|
|
@@ -514,7 +557,7 @@ export class AuthSwitchAuthorityBroker {
|
|
|
514
557
|
public async getOperation(operationId: string): Promise<IAuthSwitchOperation> {
|
|
515
558
|
if (!isUuid(operationId)) throw new Error('Invalid operation ID.');
|
|
516
559
|
const operation = await this.database.readOperation(operationId);
|
|
517
|
-
if (!operation || operation.kind === 'preuse_openai') throw new
|
|
560
|
+
if (!operation || operation.kind === 'preuse_openai') throw new AuthSwitchRefusal('not_found', unknownOperation);
|
|
518
561
|
return publicOperation(operation);
|
|
519
562
|
}
|
|
520
563
|
|
|
@@ -538,7 +581,7 @@ export class AuthSwitchAuthorityBroker {
|
|
|
538
581
|
public async cancelOperation(operationId: string): Promise<IAuthSwitchOperation> {
|
|
539
582
|
if (!isUuid(operationId)) throw new Error('Invalid operation ID.');
|
|
540
583
|
const current = await this.database.readOperation(operationId);
|
|
541
|
-
if (!current || current.kind === 'preuse_openai') throw new
|
|
584
|
+
if (!current || current.kind === 'preuse_openai') throw new AuthSwitchRefusal('not_found', unknownOperation);
|
|
542
585
|
if (current.state === 'committing') throw new Error('Login is completing; check its operation status.');
|
|
543
586
|
if (current.state !== 'starting' && current.state !== 'pending') return publicOperation(current);
|
|
544
587
|
const updateId = plugins.crypto.randomUUID();
|
|
@@ -586,7 +629,7 @@ export class AuthSwitchAuthorityBroker {
|
|
|
586
629
|
|| grant.owner !== 'daemon'
|
|
587
630
|
|| grant.purpose !== input.purpose || account.providerId !== 'openai'
|
|
588
631
|
|| input.runtime === 'claude') {
|
|
589
|
-
throw new
|
|
632
|
+
throw new AuthSwitchRefusal('login_unavailable', loginNotBindable);
|
|
590
633
|
}
|
|
591
634
|
return { id, accountId: input.accountId, grantId: grant.id, runtime: input.runtime, scopeId: input.scopeId,
|
|
592
635
|
incarnationId: input.incarnationId, revision: (existing?.revision ?? 0) + 1,
|
|
@@ -624,21 +667,27 @@ export class AuthSwitchAuthorityBroker {
|
|
|
624
667
|
const current = await this.database.readBindingContext(bindingId);
|
|
625
668
|
const binding = current.binding;
|
|
626
669
|
const expected = Buffer.from(binding?.capabilityHash ?? '0'.repeat(64), 'hex');
|
|
670
|
+
// A missing binding and a wrong capability are compared against the same zero hash on purpose, so
|
|
671
|
+
// nothing here tells a caller which it was. One code preserves exactly that, and it is truthful for
|
|
672
|
+
// both: this capability does not authorize anything, and binding again is the only repair.
|
|
627
673
|
if (!binding || !plugins.crypto.timingSafeEqual(supplied, expected)) {
|
|
628
|
-
throw new
|
|
674
|
+
throw new AuthSwitchRefusal('binding_unauthorized', bindingUnauthorized);
|
|
629
675
|
}
|
|
630
676
|
if (originalBinding && (binding.id !== originalBinding.id
|
|
631
677
|
|| binding.capabilityHash !== originalBinding.capabilityHash
|
|
632
678
|
|| binding.accountId !== originalBinding.accountId || binding.grantId !== originalBinding.grantId
|
|
633
679
|
|| binding.incarnationId !== originalBinding.incarnationId
|
|
634
680
|
|| binding.grantAuthorizationGeneration !== originalBinding.grantAuthorizationGeneration)) {
|
|
635
|
-
|
|
681
|
+
// The capability still matched, so what moved is the binding itself: another incarnation took the
|
|
682
|
+
// scope, or the account was reauthorized under it. The caller's repair is the one above -- bind
|
|
683
|
+
// again -- so it is the same code; a second one would name a difference no runtime can act on.
|
|
684
|
+
throw new AuthSwitchRefusal('binding_unauthorized', bindingMoved);
|
|
636
685
|
}
|
|
637
686
|
originalBinding ??= binding;
|
|
638
687
|
if (!current.account || current.account.removed || !current.grant
|
|
639
688
|
|| current.grant.id !== binding.grantId
|
|
640
689
|
|| current.grant.authorizationGeneration !== binding.grantAuthorizationGeneration) {
|
|
641
|
-
throw new
|
|
690
|
+
throw new AuthSwitchRefusal('login_unavailable', bindingNeedsReauth);
|
|
642
691
|
}
|
|
643
692
|
return { account: current.account, grant: current.grant };
|
|
644
693
|
}, minValidityMs, rejectedGrantGeneration);
|
|
@@ -660,6 +709,15 @@ export class AuthSwitchAuthorityBroker {
|
|
|
660
709
|
}, minValidityMs, rejectedGrantGeneration);
|
|
661
710
|
}
|
|
662
711
|
|
|
712
|
+
/**
|
|
713
|
+
* The shared managed-access loop, whose refusals are deliberately still unmarked faults.
|
|
714
|
+
*
|
|
715
|
+
* Its two callers repair differently: a runtime holding a binding binds again, while the usage reader has
|
|
716
|
+
* no binding at all and collapses everything into one usage problem. Naming a code here would either say
|
|
717
|
+
* "binding" to a caller that has none, or invent a second meaning for one that does, so the decision waits
|
|
718
|
+
* for the slice that gives the usage answer its own vocabulary. What a bound runtime can act on is decided
|
|
719
|
+
* before this point, in the view `resolveAccess` supplies.
|
|
720
|
+
*/
|
|
663
721
|
private async resolveManagedAccess(readView: () => Promise<{
|
|
664
722
|
account: IStoredAuthorityAccount; grant: IStoredAuthorityGrant;
|
|
665
723
|
}>, minValidityMs: number, rejectedGrantGeneration?: number): Promise<IAuthSwitchResolvedAccess> {
|
|
@@ -174,8 +174,24 @@ export class AuthSwitchClient {
|
|
|
174
174
|
if (!Number.isSafeInteger(heartbeatMs) || heartbeatMs < 50 || heartbeatMs > 30_000) {
|
|
175
175
|
throw new Error('Authswitch subscription heartbeat must be 50–30000 ms.');
|
|
176
176
|
}
|
|
177
|
+
/**
|
|
178
|
+
* The pause between reconnection attempts, which never keeps its consumer's process alive.
|
|
179
|
+
*
|
|
180
|
+
* A subscription that cannot reach the daemon re-arms this timer for as long as that lasts, and during
|
|
181
|
+
* each pause it is the only handle this client holds -- the failed request already destroyed its socket.
|
|
182
|
+
* Referenced, that chain of 250 ms waits would hold an otherwise finished process open indefinitely,
|
|
183
|
+
* and it has nothing to finish: the wait itself does no work, and an abort clears it.
|
|
184
|
+
*
|
|
185
|
+
* An abort that already happened is checked before the timer is armed, because a signal fires once: a
|
|
186
|
+
* consumer that stops from inside its own disconnect callback aborts between this loop's last check and
|
|
187
|
+
* this call, and a listener added afterwards would never run. The pause would then be waited out in
|
|
188
|
+
* full, and since it holds nothing, a process whose only remaining handle it is can exit inside it --
|
|
189
|
+
* dropping the closing status this loop owes its consumer.
|
|
190
|
+
*/
|
|
177
191
|
const retry = () => new Promise<void>(resolve => {
|
|
192
|
+
if (signal.aborted) { resolve(); return; }
|
|
178
193
|
const timer = setTimeout(() => { signal.removeEventListener('abort', aborted); resolve(); }, 250);
|
|
194
|
+
timer.unref();
|
|
179
195
|
const aborted = () => { clearTimeout(timer); resolve(); };
|
|
180
196
|
signal.addEventListener('abort', aborted, { once: true });
|
|
181
197
|
});
|
|
@@ -37,8 +37,39 @@ export interface IAuthSwitchAuthorityDaemonOptions extends IAuthSwitchAuthorityD
|
|
|
37
37
|
claudeStatus?: ClaudeAccountStatus;
|
|
38
38
|
/** Activated only after a verified backend source receipt has registered the native home. */
|
|
39
39
|
claudeNative?: IClaudeNativeAuthorityOptions;
|
|
40
|
-
/** Where the one-time import reads the legacy stores
|
|
41
|
-
legacyImport
|
|
40
|
+
/** Where the one-time import reads the legacy stores, or `'none'`. Never derived from this host here. */
|
|
41
|
+
legacyImport: TAuthSwitchAuthorityLegacyImport;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Where a daemon's one-time import reads, stated by whoever builds it.
|
|
46
|
+
*
|
|
47
|
+
* The legacy stores are this user's real credentials, so which host they are read from is never something a
|
|
48
|
+
* daemon decides for its caller: it is named, or the daemon is built with no import surface at all.
|
|
49
|
+
* `AuthSwitchAuthorityService`'s entry point is the one place that may name this host's own locations,
|
|
50
|
+
* because that is the daemon that exists to serve this host. The importer requires those two locations of
|
|
51
|
+
* every caller, so naming its options here is already the whole statement.
|
|
52
|
+
*/
|
|
53
|
+
export type TAuthSwitchAuthorityLegacyImport = IAuthSwitchAuthorityImportOptions | 'none';
|
|
54
|
+
|
|
55
|
+
/** What a caller that named neither is told. It names the two answers and who may take the third. */
|
|
56
|
+
const legacyImportRequired = 'Authswitch authority requires explicit legacy source locations: pass '
|
|
57
|
+
+ 'legacyImport with an env and an absolute homeDirectory, or legacyImport: "none" for a daemon with no '
|
|
58
|
+
+ 'account import. Only the authswitch service entry point may take them from this host.';
|
|
59
|
+
|
|
60
|
+
/** What the import routes answer on a daemon that was built with no import surface. */
|
|
61
|
+
const legacyImportAbsent = 'This authority was started without legacy source locations, so it serves no '
|
|
62
|
+
+ 'account import.';
|
|
63
|
+
|
|
64
|
+
/** The same check for a caller with types and one without: stated locations, or stated absence. */
|
|
65
|
+
function assertStatedLegacyImport(value: unknown): asserts value is TAuthSwitchAuthorityLegacyImport {
|
|
66
|
+
if (value === 'none') return;
|
|
67
|
+
if (value === null || typeof value !== 'object'
|
|
68
|
+
|| typeof (value as { env?: unknown }).env !== 'object' || (value as { env?: unknown }).env === null
|
|
69
|
+
|| typeof (value as { homeDirectory?: unknown }).homeDirectory !== 'string'
|
|
70
|
+
|| !plugins.path.isAbsolute((value as { homeDirectory: string }).homeDirectory)) {
|
|
71
|
+
throw new Error(legacyImportRequired);
|
|
72
|
+
}
|
|
42
73
|
}
|
|
43
74
|
|
|
44
75
|
const hasKeys = (value: unknown, keys: readonly string[]): value is Record<string, unknown> =>
|
|
@@ -83,7 +114,8 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
83
114
|
public readonly broker: AuthSwitchAuthorityBroker;
|
|
84
115
|
public readonly usage: AuthSwitchAuthorityUsage;
|
|
85
116
|
public readonly preuse: AuthSwitchAuthorityPreuse;
|
|
86
|
-
|
|
117
|
+
/** Absent on a daemon built with `legacyImport: 'none'`; its import routes then refuse. */
|
|
118
|
+
public readonly legacyImport?: AuthSwitchAuthorityImport;
|
|
87
119
|
public readonly claudeNative?: ClaudeNativeAuthority;
|
|
88
120
|
private readonly managementRouter = new plugins.typedrequest.TypedRouter();
|
|
89
121
|
private readonly runtimeRouter = new plugins.typedrequest.TypedRouter();
|
|
@@ -106,6 +138,10 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
106
138
|
private closing = false;
|
|
107
139
|
|
|
108
140
|
constructor(private readonly options: IAuthSwitchAuthorityDaemonOptions) {
|
|
141
|
+
// First, before anything is constructed: a daemon that was going to read the wrong host's credentials
|
|
142
|
+
// must not come into existence at all. The type states it and this states it again, because a caller
|
|
143
|
+
// without types is exactly the caller that would have defaulted here.
|
|
144
|
+
assertStatedLegacyImport(options.legacyImport);
|
|
109
145
|
this.database = new AuthSwitchAuthorityDatabase(options);
|
|
110
146
|
const provider = options.broker?.provider ?? new plugins.flexAccounts.OpenAiProviderAdapter();
|
|
111
147
|
this.broker = new AuthSwitchAuthorityBroker(this.database, { ...options.broker, provider });
|
|
@@ -134,7 +170,9 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
134
170
|
provider,
|
|
135
171
|
});
|
|
136
172
|
this.preuse = new AuthSwitchAuthorityPreuse(this.database, this.broker, options.preuse);
|
|
137
|
-
|
|
173
|
+
if (options.legacyImport !== 'none') {
|
|
174
|
+
this.legacyImport = new AuthSwitchAuthorityImport(this.database, options.legacyImport);
|
|
175
|
+
}
|
|
138
176
|
if (options.claudeNative) {
|
|
139
177
|
this.claudeNative = new ClaudeNativeAuthority(this.database, {
|
|
140
178
|
...options.claudeNative, onChanged: () => this.broker.notifyChanged(),
|
|
@@ -309,6 +347,7 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
309
347
|
if (request === null || typeof request !== 'object' || Array.isArray(request)
|
|
310
348
|
|| Object.keys(request).length !== 0) invalid();
|
|
311
349
|
if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
|
|
350
|
+
if (!this.legacyImport) throw new AuthSwitchRefusal('import_refusal', legacyImportAbsent);
|
|
312
351
|
return { inventory: await this.legacyImport.inventory() };
|
|
313
352
|
}));
|
|
314
353
|
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchImportStatus>(
|
|
@@ -319,6 +358,7 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
319
358
|
|| (request.limit !== undefined && (!Number.isSafeInteger(request.limit)
|
|
320
359
|
|| request.limit < 1 || request.limit > 128))) invalid();
|
|
321
360
|
if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
|
|
361
|
+
if (!this.legacyImport) throw new AuthSwitchRefusal('import_refusal', legacyImportAbsent);
|
|
322
362
|
return { status: await this.legacyImport.status(request) };
|
|
323
363
|
}));
|
|
324
364
|
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchEvents>(
|
|
@@ -461,6 +501,7 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
461
501
|
|| request.callerQuiescent !== true || request.acknowledgeRunOrder !== true
|
|
462
502
|
|| !validImportSubmission(request.submission)) invalid();
|
|
463
503
|
if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
|
|
504
|
+
if (!this.legacyImport) throw new AuthSwitchRefusal('import_refusal', legacyImportAbsent);
|
|
464
505
|
// An importer refusal is an `AuthSwitchRefusal` like every other, so the one conversion in
|
|
465
506
|
// `handler()` marks it. What stays special is its meaning: an UNMARKED failure of this route
|
|
466
507
|
// leaves the import outcome unknown, and the source must be read with `import.status` rather
|
|
@@ -1815,9 +1815,18 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
1815
1815
|
throw new Error('Migration completion requires a verified import of the same account and grant.');
|
|
1816
1816
|
}
|
|
1817
1817
|
if (current) {
|
|
1818
|
+
// A row may follow its source's current bytes in exactly one state. `pending_native_owner` is a
|
|
1819
|
+
// projection whose own tool still refreshes it: nothing rotating was ever sent for such a row and
|
|
1820
|
+
// the authority holds no copy of its credential, so the file that tool has since rewritten is the
|
|
1821
|
+
// same login and the row must be able to reach it -- otherwise the retry its own refusal asks for
|
|
1822
|
+
// is impossible. It may never change the account or grant it named while doing so, and no other
|
|
1823
|
+
// status may move its digest at all.
|
|
1824
|
+
const followsSource = current.sourceDigest !== draft.sourceDigest;
|
|
1818
1825
|
if (current.status === 'complete' || current.version !== draft.version
|
|
1819
1826
|
|| current.sourceKind !== draft.sourceKind || current.sourcePathHash !== draft.sourcePathHash
|
|
1820
|
-
|| current.
|
|
1827
|
+
|| (followsSource && (current.status !== 'pending_native_owner' || current.accountId === null
|
|
1828
|
+
|| current.accountId !== draft.accountId || current.grantId !== draft.grantId))
|
|
1829
|
+
|| draft.revision !== current.revision + 1) {
|
|
1821
1830
|
throw new Error('Migration source identity or terminal status cannot change.');
|
|
1822
1831
|
}
|
|
1823
1832
|
const result = await AuthSwitchAuthorityMigrationLedgerModel.exact.transition({
|
|
@@ -76,9 +76,15 @@ export interface IAuthSwitchImportClaudeTokens {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
export interface IAuthSwitchAuthorityImportOptions {
|
|
79
|
-
/**
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
/**
|
|
80
|
+
* Locates the legacy stores, exactly as each owning class locates its own -- and always stated.
|
|
81
|
+
*
|
|
82
|
+
* These are the two inputs that decide whose real credentials an import reads, so the importer never
|
|
83
|
+
* reaches for the host's own environment: a caller that could not name them has no business importing.
|
|
84
|
+
* `AuthSwitchAuthorityService`'s entry point is the one place that takes them from this host.
|
|
85
|
+
*/
|
|
86
|
+
env: NodeJS.ProcessEnv;
|
|
87
|
+
homeDirectory: string;
|
|
82
88
|
now?: () => number;
|
|
83
89
|
/** The one rotating refresh of an imported ChatGPT login, and the non-rotating read that proves one. */
|
|
84
90
|
openAi?: IAuthSwitchImportOpenAiOperations;
|
|
@@ -189,6 +195,58 @@ const RUN_ORDER_REFUSAL = 'Importing needs the run-order acknowledgement: an imp
|
|
|
189
195
|
+ 'exercise exactly that copy. Import only once those commands run on the authority and the native '
|
|
190
196
|
+ 'projections exist.';
|
|
191
197
|
|
|
198
|
+
/**
|
|
199
|
+
* A row keeps the login it named, permanently: no route forgets a row or re-points it at another account,
|
|
200
|
+
* and one would be a way to import a login the owner never approved under an approval given for a different
|
|
201
|
+
* one. The same is true of a settled row, which answers that it is already imported.
|
|
202
|
+
*/
|
|
203
|
+
const FOREIGN_SOURCE_REFUSAL = 'This source now holds a different account\'s login than the one this import '
|
|
204
|
+
+ 'registered for it. Restore the login this source was registered with, then import it again.';
|
|
205
|
+
const CHANGED_SOURCE_REFUSAL =
|
|
206
|
+
'The source changed after its migration ledger row was written; it is a different source now.';
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Whether a row may take the bytes its source holds now.
|
|
210
|
+
*
|
|
211
|
+
* A projection that could not be proven waits for its own tool, and that tool proves itself by refreshing
|
|
212
|
+
* its store -- which rewrites the file. The row has to be able to follow those bytes, or the retry its own
|
|
213
|
+
* refusal asks for could never be made at all. It follows them only this far: the row is unsettled, nothing
|
|
214
|
+
* rotating was ever sent for it, and the authority holds no copy of its credential. That the bytes are still
|
|
215
|
+
* the same login is a separate question, and `ledgerObjection` answers it first.
|
|
216
|
+
*/
|
|
217
|
+
const followsRefreshedSource = (ledger: IStoredAuthorityMigrationLedger,
|
|
218
|
+
record: Pick<ILegacySourceRecord, 'treatment'>): boolean =>
|
|
219
|
+
ledger.status === 'pending_native_owner' && record.treatment === 'project' && ledger.grantId !== null;
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* What this source's own row objects to, asked once and answered for both the report and the submit.
|
|
223
|
+
*
|
|
224
|
+
* The runbook tells the owner not to submit a source the inventory lists a problem for, which is only true
|
|
225
|
+
* while the two agree, so they are the same four questions in one place. The identity question is the one
|
|
226
|
+
* the provider cannot answer: a row is found by the hash of its location, so the same row answers for
|
|
227
|
+
* whatever login that location holds later -- `codex login` under the same path, a replaced saved record --
|
|
228
|
+
* and the non-rotating proof asks the provider about the identity the newly read bytes themselves claim. It
|
|
229
|
+
* therefore proves those bytes are self-consistent and live, never that they are the login this row was
|
|
230
|
+
* approved for. A source with no derivable identity is left to its reader's own problem.
|
|
231
|
+
*/
|
|
232
|
+
type TLedgerObjection = 'imported' | 'quarantined' | 'foreign' | 'changed';
|
|
233
|
+
|
|
234
|
+
const ledgerObjection = (ledger: IStoredAuthorityMigrationLedger,
|
|
235
|
+
record: Pick<ILegacySourceRecord, 'treatment' | 'accountId' | 'sourceDigest'>,
|
|
236
|
+
grantId: string | null): TLedgerObjection | null => {
|
|
237
|
+
// A quarantined row is terminal whatever its location holds now, and its answer -- sign in to that
|
|
238
|
+
// account again -- is the next step either way. Everything after it is about the login that is there.
|
|
239
|
+
if (ledger.status === 'quarantined') return 'quarantined';
|
|
240
|
+
// Before "already imported": a settled row whose location now holds another account would otherwise
|
|
241
|
+
// answer that a login it never touched is imported.
|
|
242
|
+
if (record.accountId !== null && grantId !== null
|
|
243
|
+
&& ((ledger.accountId !== null && ledger.accountId !== record.accountId)
|
|
244
|
+
|| (ledger.grantId !== null && ledger.grantId !== grantId))) return 'foreign';
|
|
245
|
+
if (ledger.status === 'complete') return 'imported';
|
|
246
|
+
if (ledger.sourceDigest !== record.sourceDigest && !followsRefreshedSource(ledger, record)) return 'changed';
|
|
247
|
+
return null;
|
|
248
|
+
};
|
|
249
|
+
|
|
192
250
|
/** Strips the source location: a ledger row stores its hash, and no response may carry the path. */
|
|
193
251
|
const publicSource = (record: ILegacySourceRecord,
|
|
194
252
|
ledgerStatus: IAuthSwitchImportSource['ledgerStatus']): IAuthSwitchImportSource => ({
|
|
@@ -227,9 +285,9 @@ export class AuthSwitchAuthorityImport {
|
|
|
227
285
|
private readonly quiescence: IAuthSwitchImportQuiescence;
|
|
228
286
|
|
|
229
287
|
constructor(private readonly database: AuthSwitchAuthorityDatabase,
|
|
230
|
-
options: IAuthSwitchAuthorityImportOptions
|
|
231
|
-
this.env = options.env
|
|
232
|
-
this.homeDirectory = options.homeDirectory
|
|
288
|
+
options: IAuthSwitchAuthorityImportOptions) {
|
|
289
|
+
this.env = options.env;
|
|
290
|
+
this.homeDirectory = options.homeDirectory;
|
|
233
291
|
this.now = options.now ?? Date.now;
|
|
234
292
|
this.openAi = options.openAi ?? new plugins.flexAccounts.OpenAiProviderAdapter();
|
|
235
293
|
this.claudeStatus = options.claudeStatus ?? new ClaudeAccountStatus();
|
|
@@ -248,12 +306,20 @@ export class AuthSwitchAuthorityImport {
|
|
|
248
306
|
for (const record of report.sources) {
|
|
249
307
|
const ledger = await this.database.readMigrationLedger(record.id);
|
|
250
308
|
const source = publicSource(record, ledger?.status ?? null);
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
309
|
+
// The same four questions the submit decides on, so a source the report invites is one the submit
|
|
310
|
+
// takes. New bytes of the same login under a row that is still waiting for them are not an objection:
|
|
311
|
+
// they are the retry that row asks for, and no problem line tells the owner to stop.
|
|
312
|
+
const objection = ledger === null ? null : ledgerObjection(ledger, record,
|
|
313
|
+
record.accountId === null ? null : importGrantId(record.accountId, grantPlan(record)));
|
|
314
|
+
if (objection === 'foreign') {
|
|
315
|
+
source.problems.push('this source now holds a different account\'s login than the one it was registered with');
|
|
316
|
+
}
|
|
317
|
+
if (objection === 'changed') {
|
|
318
|
+
// A later import must not treat the approved bytes and the current bytes as the same source; the
|
|
319
|
+
// ledger deliberately refuses to move that row.
|
|
254
320
|
source.problems.push('the source changed after its migration ledger row was written');
|
|
255
321
|
}
|
|
256
|
-
if (
|
|
322
|
+
if (objection === 'imported') {
|
|
257
323
|
source.problems.push('this source is already imported; its legacy copy is retired');
|
|
258
324
|
}
|
|
259
325
|
sources.push(source);
|
|
@@ -308,17 +374,6 @@ export class AuthSwitchAuthorityImport {
|
|
|
308
374
|
const record = this.resolveSource(request.submission);
|
|
309
375
|
const plan = grantPlan(record);
|
|
310
376
|
this.assertQuiescent(record);
|
|
311
|
-
const ledger = await this.database.readMigrationLedger(record.id);
|
|
312
|
-
if (ledger !== null) {
|
|
313
|
-
if (ledger.sourceDigest !== record.sourceDigest) {
|
|
314
|
-
refuse('The source changed after its migration ledger row was written; it is a different source now.');
|
|
315
|
-
}
|
|
316
|
-
if (ledger.status === 'complete') refuse('This source is already imported.');
|
|
317
|
-
if (ledger.status === 'quarantined') {
|
|
318
|
-
refuse('This source is quarantined: its outcome could not be established, so it needs a device '
|
|
319
|
-
+ 'sign-in for that account rather than another import.');
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
377
|
const accountId = record.accountId;
|
|
323
378
|
const subject = record.subject;
|
|
324
379
|
const workspaceId = record.workspaceId;
|
|
@@ -326,6 +381,18 @@ export class AuthSwitchAuthorityImport {
|
|
|
326
381
|
refuse('The source carries no provider identity the authority can bind an account to.');
|
|
327
382
|
}
|
|
328
383
|
const grantId = importGrantId(accountId, plan);
|
|
384
|
+
const ledger = await this.database.readMigrationLedger(record.id);
|
|
385
|
+
// Every one of these is decided from the durable row alone, before a single write: a projection
|
|
386
|
+
// registers its account and grant before the provider is asked anything, so an objection that arrived
|
|
387
|
+
// later would arrive after the record it objects to had been written.
|
|
388
|
+
switch (ledger === null ? null : ledgerObjection(ledger, record, grantId)) {
|
|
389
|
+
case 'imported': refuse('This source is already imported.');
|
|
390
|
+
case 'quarantined':
|
|
391
|
+
refuse('This source is quarantined: its outcome could not be established, so it needs a device '
|
|
392
|
+
+ 'sign-in for that account rather than another import.');
|
|
393
|
+
case 'foreign': refuse(FOREIGN_SOURCE_REFUSAL);
|
|
394
|
+
case 'changed': refuse(CHANGED_SOURCE_REFUSAL);
|
|
395
|
+
}
|
|
329
396
|
if (ledger === null) await this.beginLedger(record);
|
|
330
397
|
const material = this.material(record, request.submission);
|
|
331
398
|
return record.treatment === 'project'
|
|
@@ -428,12 +495,20 @@ export class AuthSwitchAuthorityImport {
|
|
|
428
495
|
}));
|
|
429
496
|
}
|
|
430
497
|
|
|
498
|
+
/**
|
|
499
|
+
* Moves the row, and -- only where a caller states it -- the bytes that move with the status.
|
|
500
|
+
*
|
|
501
|
+
* The digest is part of the same write as the status it was proven under, so no row can record a proof of
|
|
502
|
+
* bytes it does not name. The database holds the rule about which row may carry a new digest at all.
|
|
503
|
+
*/
|
|
431
504
|
private async moveLedger(ledgerId: string, status: IStoredAuthorityMigrationLedger['status'],
|
|
432
|
-
accountId: string | null, grantId: string | null
|
|
505
|
+
accountId: string | null, grantId: string | null,
|
|
506
|
+
sourceDigest?: string): Promise<IStoredAuthorityMigrationLedger> {
|
|
433
507
|
const updateId = plugins.crypto.randomUUID();
|
|
434
508
|
return this.database.changeMigrationLedger(updateId, ledgerId, current => {
|
|
435
509
|
if (!current) throw new Error('The migration ledger row disappeared during the import.');
|
|
436
|
-
return { ...current, status, accountId, grantId,
|
|
510
|
+
return { ...current, status, accountId, grantId,
|
|
511
|
+
sourceDigest: sourceDigest ?? current.sourceDigest, revision: current.revision + 1,
|
|
437
512
|
statusObservedAt: this.stamp(), updateId };
|
|
438
513
|
});
|
|
439
514
|
}
|
|
@@ -513,7 +588,9 @@ export class AuthSwitchAuthorityImport {
|
|
|
513
588
|
await this.moveLedger(record.id, 'pending_native_owner', identity.accountId, identity.grantId);
|
|
514
589
|
throw error;
|
|
515
590
|
}
|
|
516
|
-
|
|
591
|
+
// The bytes that were just proven are the bytes this row records, written with the proof itself: a row
|
|
592
|
+
// that followed its tool's refresh names the store the provider answered for, never the one before it.
|
|
593
|
+
await this.moveLedger(record.id, 'verified', identity.accountId, identity.grantId, record.sourceDigest);
|
|
517
594
|
if (record.sourceKind === 'claude_native') await this.adoptClaudeHome(record, identity);
|
|
518
595
|
await this.moveLedger(record.id, 'complete', identity.accountId, identity.grantId);
|
|
519
596
|
return this.result(record, 'complete', identity, []);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
2
|
import type { IAuthSwitchPreuseOperation } from './authority-contract.js';
|
|
3
|
+
import { AuthSwitchRefusal } from './authority-contract.js';
|
|
3
4
|
import { AuthSwitchAuthorityBroker } from './classes.authoritybroker.js';
|
|
4
5
|
import { AuthSwitchAuthorityDatabase } from './classes.authoritydatabase.js';
|
|
5
6
|
import type { IStoredAuthorityPreuseOperation, TStoredAuthorityPreuseProblem } from './classes.authoritymodels.js';
|
|
@@ -250,7 +251,12 @@ export class AuthSwitchAuthorityPreuse {
|
|
|
250
251
|
private async readStored(operationId: string): Promise<IStoredAuthorityPreuseOperation> {
|
|
251
252
|
if (!uuid(operationId)) throw new Error('Invalid preuse operation ID.');
|
|
252
253
|
const operation = await this.database.readOperation(operationId);
|
|
253
|
-
|
|
254
|
+
// One site answers both routes that take a preuse operation id, so a read and a cancel of something
|
|
255
|
+
// this authority does not hold say the same thing in the same code.
|
|
256
|
+
if (!operation || operation.kind !== 'preuse_openai') {
|
|
257
|
+
throw new AuthSwitchRefusal('not_found', 'This authority knows no preuse by that operation ID. '
|
|
258
|
+
+ 'Start a new preuse and follow that one.');
|
|
259
|
+
}
|
|
254
260
|
return operation;
|
|
255
261
|
}
|
|
256
262
|
|
|
@@ -1,37 +1,12 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
|
+
import { authSwitchAuthorityUnitDirectory, authSwitchAuthorityUnitName,
|
|
3
|
+
resolveAuthSwitchAuthorityPaths } from './authority-paths.js';
|
|
2
4
|
import { AuthSwitchAuthorityDaemon } from './classes.authoritydaemon.js';
|
|
3
5
|
import type { IClaudeNativeAuthorityOptions } from './classes.claudeauthority.js';
|
|
4
6
|
import { ClaudeNativeAdapter, claudeNativeUserSettingsFiles,
|
|
5
7
|
resolveClaudeNativeHome } from './classes.claudenative.js';
|
|
6
8
|
|
|
7
|
-
export
|
|
8
|
-
runtimeDirectory: string;
|
|
9
|
-
dataDirectory: string;
|
|
10
|
-
databaseSocketPath: string;
|
|
11
|
-
authoritySocketPath: string;
|
|
12
|
-
runtimeSocketPath: string;
|
|
13
|
-
runtimeSocketDirectory: string;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/** Canonical per-user paths. An explicit runtime directory is also usable by a trusted container host. */
|
|
17
|
-
export const resolveAuthSwitchAuthorityPaths = (runtimeDirectory = process.env.XDG_RUNTIME_DIR): IAuthSwitchAuthorityPaths => {
|
|
18
|
-
if (!runtimeDirectory || !plugins.path.isAbsolute(runtimeDirectory)) {
|
|
19
|
-
throw new Error('A private XDG_RUNTIME_DIR is required for authswitch authority.');
|
|
20
|
-
}
|
|
21
|
-
const home = plugins.os.userInfo().homedir;
|
|
22
|
-
const dataHome = process.env.XDG_DATA_HOME || plugins.path.join(home, '.local/share');
|
|
23
|
-
if (!plugins.path.isAbsolute(dataHome)) throw new Error('XDG_DATA_HOME must be an absolute path.');
|
|
24
|
-
const socketDirectory = plugins.path.join(runtimeDirectory, 'authswitch');
|
|
25
|
-
const runtimeSocketDirectory = plugins.path.join(socketDirectory, 'runtime');
|
|
26
|
-
return {
|
|
27
|
-
runtimeDirectory,
|
|
28
|
-
dataDirectory: plugins.path.join(dataHome, 'authswitch', 'authority'),
|
|
29
|
-
databaseSocketPath: plugins.path.join(socketDirectory, 'internal', 'db.sock'),
|
|
30
|
-
authoritySocketPath: plugins.path.join(socketDirectory, 'management', 'authority.sock'),
|
|
31
|
-
runtimeSocketPath: plugins.path.join(runtimeSocketDirectory, 'runtime.sock'),
|
|
32
|
-
runtimeSocketDirectory,
|
|
33
|
-
};
|
|
34
|
-
};
|
|
9
|
+
export { resolveAuthSwitchAuthorityPaths, type IAuthSwitchAuthorityPaths } from './authority-paths.js';
|
|
35
10
|
|
|
36
11
|
/** Explicit installer and lifecycle operations for a user-scoped systemd service. */
|
|
37
12
|
export class AuthSwitchAuthorityService {
|
|
@@ -39,9 +14,11 @@ export class AuthSwitchAuthorityService {
|
|
|
39
14
|
public readonly unitFile: plugins.smartdaemon.SystemdUnitFile;
|
|
40
15
|
public readonly definition: plugins.smartdaemon.SystemdServiceDefinition;
|
|
41
16
|
|
|
42
|
-
|
|
17
|
+
/** The unit directory defaults to the one the legacy fence looks in for this user (`./authority-paths`). */
|
|
18
|
+
constructor(runtimeDirectory: string,
|
|
19
|
+
unitDirectory = authSwitchAuthorityUnitDirectory(process.env, plugins.os.userInfo().homedir)) {
|
|
43
20
|
const packageRoot = plugins.path.dirname(plugins.path.dirname(plugins.fileURLToPath(import.meta.url)));
|
|
44
|
-
const options = { unitName:
|
|
21
|
+
const options = { unitName: authSwitchAuthorityUnitName, scope: 'user' as const, runtimeDirectory };
|
|
45
22
|
this.unit = new plugins.smartdaemon.SystemdUnit(options);
|
|
46
23
|
this.unitFile = new plugins.smartdaemon.SystemdUnitFile({ ...options, unitDirectory });
|
|
47
24
|
this.definition = new plugins.smartdaemon.SystemdServiceDefinition({
|
|
@@ -86,6 +63,13 @@ export const resolveAuthSwitchClaudeNativeAuthority = (env: NodeJS.ProcessEnv =
|
|
|
86
63
|
settingsFiles: claudeNativeUserSettingsFiles(configDir) }) };
|
|
87
64
|
};
|
|
88
65
|
|
|
66
|
+
/**
|
|
67
|
+
* The per-user daemon this package installs, and the one place that names this host's own legacy stores.
|
|
68
|
+
*
|
|
69
|
+
* Every other construction of `AuthSwitchAuthorityDaemon` -- a test, a consumer's harness, an embedding
|
|
70
|
+
* backend -- states its own locations or asks for no import surface, because a daemon that defaulted here
|
|
71
|
+
* would read the credentials of whichever user happened to start it.
|
|
72
|
+
*/
|
|
89
73
|
export const runAuthSwitchAuthorityDaemon = async (paths = resolveAuthSwitchAuthorityPaths()): Promise<void> => {
|
|
90
74
|
const daemon = new AuthSwitchAuthorityDaemon({
|
|
91
75
|
dataDirectory: paths.dataDirectory,
|
|
@@ -93,6 +77,7 @@ export const runAuthSwitchAuthorityDaemon = async (paths = resolveAuthSwitchAuth
|
|
|
93
77
|
authoritySocketPath: paths.authoritySocketPath,
|
|
94
78
|
runtimeSocketPath: paths.runtimeSocketPath,
|
|
95
79
|
claudeNative: resolveAuthSwitchClaudeNativeAuthority(),
|
|
80
|
+
legacyImport: { env: process.env, homeDirectory: plugins.os.homedir() },
|
|
96
81
|
});
|
|
97
82
|
await daemon.start();
|
|
98
83
|
await new Promise<void>((resolve, reject) => {
|
package/ts/classes.cli.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { CodexHarness } from './classes.codexharness.js';
|
|
|
4
4
|
import { OpenCodeHarness } from './classes.opencodeharness.js';
|
|
5
5
|
import { ClaudeCodeHarness } from './classes.claudecodeharness.js';
|
|
6
6
|
import { AuthSwitchTui } from './classes.tui.js';
|
|
7
|
-
import { AglAuthSwitchCoordinator, AuthSwitchOperations, authSwitchMutationReplacesLogin, type TAuthSwitchCoordinator, type TAuthSwitchMutation } from './classes.operations.js';
|
|
7
|
+
import { AglAuthSwitchCoordinator, AuthSwitchOperations, authSwitchHostLegacyFence, authSwitchMutationReplacesLogin, type TAuthSwitchCoordinator, type TAuthSwitchLegacyFence, type TAuthSwitchMutation } from './classes.operations.js';
|
|
8
8
|
import { readAccountList } from './classes.accountlist.js';
|
|
9
9
|
import { AccountListRenderer } from './classes.listrenderer.js';
|
|
10
10
|
import { accountLimits, activeAccounts, CondensedRenderer } from './classes.limits.js';
|
|
@@ -98,9 +98,20 @@ export class AuthSwitchCli {
|
|
|
98
98
|
private readonly operations: AuthSwitchOperations;
|
|
99
99
|
private get out(): plugins.smartconsole.SmartConsole { return this.console ??= new plugins.smartconsole.SmartConsole(); }
|
|
100
100
|
|
|
101
|
-
|
|
101
|
+
/**
|
|
102
|
+
* `legacyFenceArg` states which authority fences the mutations of injected harnesses.
|
|
103
|
+
*
|
|
104
|
+
* With no harnesses this command line is this host's, exactly as its default harnesses and its AGL
|
|
105
|
+
* coordinator are, and it is fenced by this host's authority. Injected harnesses write whatever stores
|
|
106
|
+
* their caller chose, so -- like the coordinator -- nothing of this host is assumed for them: they are
|
|
107
|
+
* fenced only by what the caller states, and by nothing when it states nothing.
|
|
108
|
+
*/
|
|
109
|
+
constructor(harnessesArg?: IAuthHarness[] | CodexSwitcher, coordinatorArg?: TAuthSwitchCoordinator,
|
|
110
|
+
legacyFenceArg?: TAuthSwitchLegacyFence) {
|
|
102
111
|
const coordinator = new AglAuthSwitchCoordinator();
|
|
103
|
-
this.operations = new AuthSwitchOperations(
|
|
112
|
+
this.operations = new AuthSwitchOperations(
|
|
113
|
+
coordinatorArg ?? (harnessesArg === undefined ? request => coordinator.coordinate(request) : undefined),
|
|
114
|
+
legacyFenceArg ?? (harnessesArg === undefined ? authSwitchHostLegacyFence() : 'none'));
|
|
104
115
|
harnessesArg ??= [new CodexHarness(), new OpenCodeHarness(), new ClaudeCodeHarness()];
|
|
105
116
|
// Preserve the published single-Codex constructor at the composition boundary.
|
|
106
117
|
const harnesses = Array.isArray(harnessesArg) ? harnessesArg : [new CodexHarness(harnessesArg)];
|