agl 40.5.0 → 40.6.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/changelog.md +16 -0
- package/dist_serve/bundle.js +598 -598
- package/dist_serve/bundle.js.map +2 -2
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/aglapi/aglapisurface.document.js +2 -2
- package/dist_ts/classes.aglhome.d.ts +5 -0
- package/dist_ts/classes.aglhome.js +2 -1
- package/dist_ts/classes.authstore.js +5 -1
- package/dist_ts/classes.controller.js +11 -2
- package/dist_ts/classes.inferencegateway.d.ts +35 -1
- package/dist_ts/classes.inferencegateway.js +86 -26
- package/dist_ts/classes.inferenceservice.d.ts +4 -2
- package/dist_ts/classes.inferenceservice.js +5 -4
- package/dist_ts/classes.proxycontrol.d.ts +58 -4
- package/dist_ts/classes.proxycontrol.js +23 -44
- package/dist_ts/classes.proxycontrolclient.d.ts +15 -5
- package/dist_ts/classes.proxycontrolclient.js +67 -17
- package/dist_ts/classes.proxydaemon.d.ts +22 -3
- package/dist_ts/classes.proxydaemon.js +73 -12
- package/dist_ts/classes.proxyleaseholder.d.ts +43 -14
- package/dist_ts/classes.proxyleaseholder.js +66 -22
- package/dist_ts/classes.proxystore.d.ts +9 -0
- package/dist_ts/classes.proxystore.js +15 -7
- package/dist_ts/classes.workercallerbindings.d.ts +66 -0
- package/dist_ts/classes.workercallerbindings.js +211 -0
- package/dist_ts/classes.workercallers.d.ts +63 -2
- package/dist_ts/classes.workercallers.js +430 -65
- package/dist_ts/fleet/classes.workerlaunches.d.ts +12 -5
- package/dist_ts/fleet/classes.workerlaunches.js +25 -19
- package/dist_ts/fleet/classes.workers.d.ts +17 -0
- package/dist_ts/fleet/classes.workers.js +74 -1
- package/dist_ts/functions.inferencecli.d.ts +3 -2
- package/dist_ts/functions.inferencecli.js +67 -5
- package/dist_ts/functions.inferencecliarguments.d.ts +6 -2
- package/dist_ts/functions.inferencecliarguments.js +29 -2
- package/dist_ts/functions.inferencelaunch.d.ts +24 -0
- package/dist_ts/functions.inferencelaunch.js +42 -2
- package/dist_ts/functions.proxycontract.d.ts +11 -0
- package/dist_ts/functions.proxycontract.js +12 -1
- package/dist_ts/functions.proxysocket.d.ts +6 -0
- package/dist_ts/functions.proxysocket.js +46 -0
- package/dist_ts/functions.workerprocessproof.d.ts +6 -0
- package/dist_ts/functions.workerprocessproof.js +28 -4
- package/dist_ts_authswitch/authority-contract.d.ts +30 -5
- package/dist_ts_authswitch/authority-contract.js +7 -1
- package/dist_ts_authswitch/classes.authoritycli.js +6 -2
- package/dist_ts_authswitch/classes.authorityclient.js +15 -5
- package/dist_ts_authswitch/classes.claudenative.d.ts +7 -1
- package/dist_ts_authswitch/classes.claudenative.js +21 -6
- package/dist_ts_authswitch/classes.claudesignin.d.ts +14 -2
- package/dist_ts_authswitch/classes.claudesignin.js +68 -22
- package/dist_ts_authswitch/classes.codexmanaged.d.ts +8 -0
- package/dist_ts_authswitch/classes.codexmanaged.js +33 -18
- package/dist_ts_authswitch/classes.harnessprocesses.d.ts +8 -0
- package/dist_ts_authswitch/classes.harnessprocesses.js +41 -9
- package/dist_ts_interfaces/interfaces.d.ts +1 -1
- package/dist_ts_interfaces/interfaces.js +1 -1
- package/dist_ts_interfaces/workercallers.d.ts +43 -4
- package/package.json +1 -1
- package/readme.md +103 -14
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/aglapi/aglapisurface.document.ts +2 -2
- package/ts/classes.aglhome.ts +6 -0
- package/ts/classes.authstore.ts +4 -0
- package/ts/classes.controller.ts +10 -1
- package/ts/classes.inferencegateway.ts +104 -23
- package/ts/classes.inferenceservice.ts +6 -3
- package/ts/classes.proxycontrol.ts +76 -39
- package/ts/classes.proxycontrolclient.ts +72 -20
- package/ts/classes.proxydaemon.ts +85 -15
- package/ts/classes.proxyleaseholder.ts +84 -28
- package/ts/classes.proxystore.ts +16 -5
- package/ts/classes.workercallerbindings.ts +147 -0
- package/ts/classes.workercallers.ts +410 -59
- package/ts/fleet/classes.workerlaunches.ts +23 -15
- package/ts/fleet/classes.workers.ts +51 -1
- package/ts/functions.inferencecli.ts +61 -7
- package/ts/functions.inferencecliarguments.ts +32 -1
- package/ts/functions.inferencelaunch.ts +49 -2
- package/ts/functions.proxycontract.ts +12 -0
- package/ts/functions.proxysocket.ts +39 -0
- package/ts/functions.workerprocessproof.ts +27 -4
- package/ts_authswitch/authority-contract.ts +34 -5
- package/ts_authswitch/classes.authoritycli.ts +5 -1
- package/ts_authswitch/classes.authorityclient.ts +25 -9
- package/ts_authswitch/classes.claudenative.ts +23 -7
- package/ts_authswitch/classes.claudesignin.ts +70 -23
- package/ts_authswitch/classes.codexmanaged.ts +30 -16
- package/ts_authswitch/classes.harnessprocesses.ts +39 -8
- package/ts_authswitch/native-contracts.md +1 -1
- package/ts_interfaces/interfaces.ts +1 -1
- package/ts_interfaces/workercallers.ts +32 -4
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/ts_web/accounts.plugins.ts +5 -1
- package/ts_web/classes.accountsreadmodel.ts +4 -1
|
@@ -24,11 +24,14 @@ type TRevokeReason = Parameters<IFleetWorkerCallerHooks['revokeWorkerCaller']>[0
|
|
|
24
24
|
/**
|
|
25
25
|
* The verified caller launch each fleet worker's native process holds. Every native start, revival or relaunch gets a
|
|
26
26
|
* fresh launch that replaces the worker's previous one, and the process proves it holds it once the fleet identified
|
|
27
|
-
* the process.
|
|
28
|
-
* a
|
|
27
|
+
* the process. The controller keeps a Claude launch's binding to its background job (never a credential): a process
|
|
28
|
+
* Claude Code starts for the same job later, and a bridge that outlived a controller restart, prove themselves again
|
|
29
|
+
* when they enroll. Codex launches stay in memory and are bound again before the next dispatch.
|
|
29
30
|
*/
|
|
30
31
|
export class FleetWorkerLaunches {
|
|
31
32
|
private readonly launches = new Map<string, ILaunch>();
|
|
33
|
+
/** Workers whose launch this fleet revoked and did not prepare again: the controller holds none for them to revoke. */
|
|
34
|
+
private readonly revoked = new Set<string>();
|
|
32
35
|
private readonly codexRuntimes = new Map<string, IFleetCodexCallerRuntime>();
|
|
33
36
|
constructor(private readonly options: IFleetWorkerLaunchesOptions) {}
|
|
34
37
|
/** The owner part of a worker id, `${owner}:${name}`: the launch's project. */
|
|
@@ -41,7 +44,9 @@ export class FleetWorkerLaunches {
|
|
|
41
44
|
return plugins.path.join(this.options.overlays, `${plugins.crypto.createHash('sha256').update(worker.id).digest('base64url')}.json`);
|
|
42
45
|
}
|
|
43
46
|
private async prepare(worker: IFleetWorker, launch: TFleetWorkerCallerLaunch): Promise<Awaited<ReturnType<IFleetWorkerCallerHooks['prepareWorkerCaller']>>> {
|
|
44
|
-
|
|
47
|
+
// The controller replaces any other launch of the worker as it prepares this one, also one it kept from before a restart.
|
|
48
|
+
if (this.launches.has(worker.id)) await this.revoke(worker, 'replacement');
|
|
49
|
+
this.revoked.delete(worker.id);
|
|
45
50
|
const ready = await this.options.hooks().prepareWorkerCaller(launch);
|
|
46
51
|
this.launches.set(worker.id, { launchId: launch.launchId, projectId: launch.projectId, harnessId: launch.harnessId, bound: false,
|
|
47
52
|
...(launch.harnessId === 'codex' ? { supervisorGeneration: launch.codexOrigin.supervisorGeneration } : {}) });
|
|
@@ -62,7 +67,7 @@ export class FleetWorkerLaunches {
|
|
|
62
67
|
* this start and at every later wake. Returns the overlay's path.
|
|
63
68
|
*/
|
|
64
69
|
public async prepareClaude(worker: IFleetWorker): Promise<string> {
|
|
65
|
-
const ready = await this.prepare(worker, { launchId: plugins.crypto.randomUUID(), projectId: this.project(worker), harnessId: 'claude' });
|
|
70
|
+
const ready = await this.prepare(worker, { launchId: plugins.crypto.randomUUID(), projectId: this.project(worker), workerId: worker.id, harnessId: 'claude' });
|
|
66
71
|
try {
|
|
67
72
|
if (!ready.claudeMcpConfig) throw new Error('The Claude caller launch has no MCP configuration.');
|
|
68
73
|
await this.overlayDirectory();
|
|
@@ -75,18 +80,19 @@ export class FleetWorkerLaunches {
|
|
|
75
80
|
} catch (error) { await this.revoke(worker, 'failure'); throw error; }
|
|
76
81
|
}
|
|
77
82
|
/**
|
|
78
|
-
* Binds the worker's Claude launch to the background job the inventory shows for `nativeId
|
|
79
|
-
* already exited
|
|
83
|
+
* Binds the worker's Claude launch to the background job the inventory shows for `nativeId`, and to the job's process.
|
|
84
|
+
* A job whose process already exited (a quick turn) is bound without one: the process Claude Code continues the job
|
|
85
|
+
* in later proves itself when its bridge enrolls.
|
|
80
86
|
*/
|
|
81
87
|
public async bindClaude(worker: IFleetWorker, nativeId: string, agent: { id?: string; pid?: number }): Promise<void> {
|
|
82
88
|
const launch = this.launches.get(worker.id);
|
|
83
89
|
if (!launch || launch.harnessId !== 'claude' || launch.bound) throw new Error('The Claude worker has no prepared caller launch.');
|
|
84
|
-
if (agent.pid === undefined) { await this.revoke(worker, 'stop'); return; }
|
|
85
90
|
try {
|
|
86
91
|
if (!agent.id) throw new Error('The Claude background job has no agent id.');
|
|
87
|
-
const
|
|
92
|
+
const claudeProcess = agent.pid === undefined ? undefined
|
|
93
|
+
: { jobId: agent.id, pid: agent.pid, startTicks: (await readWorkerProcessSnapshot(agent.pid)).startTicks };
|
|
88
94
|
await this.options.hooks().bindWorkerCaller({ launchId: launch.launchId, projectId: launch.projectId, harnessId: 'claude', nativeSessionId: nativeId,
|
|
89
|
-
expectedBindingRevision: 0,
|
|
95
|
+
expectedBindingRevision: 0, claudeJobId: agent.id, ...(claudeProcess ? { claudeProcess } : {}) });
|
|
90
96
|
launch.bound = true;
|
|
91
97
|
} catch (error) { await this.revoke(worker, 'failure'); throw error; }
|
|
92
98
|
}
|
|
@@ -100,7 +106,7 @@ export class FleetWorkerLaunches {
|
|
|
100
106
|
public async prepareCodex(worker: IFleetWorker, runtime: IFleetCodexCallerRuntime): Promise<NonNullable<Awaited<ReturnType<IFleetWorkerCallerHooks['prepareWorkerCaller']>>['codexConfig']>> {
|
|
101
107
|
this.codexRuntimes.set(runtime.supervisor.generation, runtime);
|
|
102
108
|
for (const [generation, known] of this.codexRuntimes) if (!known.isCurrent()) this.codexRuntimes.delete(generation);
|
|
103
|
-
const ready = await this.prepare(worker, { launchId: plugins.crypto.randomUUID(), projectId: this.project(worker), harnessId: 'codex',
|
|
109
|
+
const ready = await this.prepare(worker, { launchId: plugins.crypto.randomUUID(), projectId: this.project(worker), workerId: worker.id, harnessId: 'codex',
|
|
104
110
|
codexOrigin: { source: 'fleet', supervisorGeneration: runtime.supervisor.generation } });
|
|
105
111
|
if (!ready.codexConfig) {
|
|
106
112
|
await this.revoke(worker, 'failure');
|
|
@@ -122,12 +128,14 @@ export class FleetWorkerLaunches {
|
|
|
122
128
|
const runtime = this.codexRuntimes.get(supervisorGeneration);
|
|
123
129
|
return runtime?.isCurrent() ? runtime : undefined;
|
|
124
130
|
}
|
|
125
|
-
/**
|
|
131
|
+
/**
|
|
132
|
+
* Revokes the worker's current launch for good: the worker stopped or is archived, its launch failed, or another
|
|
133
|
+
* replaces it. That is also a launch the controller kept from before its restart, which this fleet never prepared.
|
|
134
|
+
*/
|
|
126
135
|
public async revoke(worker: IFleetWorker, reason: TRevokeReason): Promise<void> {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
this.
|
|
130
|
-
await this.options.hooks().revokeWorkerCaller({ launchId: launch.launchId, reason });
|
|
136
|
+
if (!this.launches.delete(worker.id) && this.revoked.has(worker.id)) return;
|
|
137
|
+
await this.options.hooks().revokeWorkerCaller({ workerId: worker.id, reason });
|
|
138
|
+
this.revoked.add(worker.id);
|
|
131
139
|
}
|
|
132
140
|
/** Revokes the worker's launch and removes its overlay, if it has one, once the worker is archived. */
|
|
133
141
|
public async retire(worker: IFleetWorker): Promise<void> {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as plugins from '../plugins.js';
|
|
2
|
-
import { controllerRuntimeIdKey } from '../../ts_interfaces/index.js';
|
|
2
|
+
import { controllerRuntimeIdKey, type IWorkerClaudeJobRequest, type TWorkerClaudeJobObservation } from '../../ts_interfaces/index.js';
|
|
3
3
|
import type { IFleetWorker, IFleetWorkerProfile, IFleetWorkerJob, IFleetNativeMessageReceipt, TFleetEventKind, TFleetWorkerAction, TFleetWorkerRequest, TFleetWorkerResponse, TFleetWorkerJobAction, IFleetWorkerReport, IFleetWorkerDispatchHolder } from '../../ts_interfaces/fleetworkers.js';
|
|
4
4
|
import type { IFleetConfiguration, IFleetOperation, IFleetTicketSync } from '../../ts_interfaces/fleet.js';
|
|
5
5
|
import type { ITicketActor } from '../../ts_interfaces/tickets.js';
|
|
@@ -14,6 +14,7 @@ import { FleetDispatchError, fleetMessageDigest, fleetReportMarker, type IFleetW
|
|
|
14
14
|
import { assertFleetWorkerRequest, validateFleetWorkerProfiles } from './functions.workervalidation.js';
|
|
15
15
|
import { readControllerProcessIdentity, controllerProcessIdentityIsLive } from '../classes.processinspection.js';
|
|
16
16
|
import { ticketActorSchema } from '../functions.ticketvalidation.js';
|
|
17
|
+
import { isWorkerCallerProofRefusal, readWorkerProcessSnapshot } from '../functions.workerprocessproof.js';
|
|
17
18
|
|
|
18
19
|
/** A binding that stayed on `workerName` though its native session lives on `nextName` now. */
|
|
19
20
|
export interface IFleetSessionBindingFailure { workerName: string; nextName: string; error: string }
|
|
@@ -80,6 +81,10 @@ export class FleetWorkers {
|
|
|
80
81
|
private lastLoad = 0;
|
|
81
82
|
private readonly controllerId = plugins.crypto.randomUUID();
|
|
82
83
|
private holder?: Promise<IFleetWorkerDispatchHolder>;
|
|
84
|
+
/** The chain the caller job re-proofs read their inventories on, one at a time. */
|
|
85
|
+
private callerJobs: Promise<unknown> = Promise.resolve();
|
|
86
|
+
/** Set once the controller began to stop: no new worker job is admitted. */
|
|
87
|
+
private admissionClosed = false;
|
|
83
88
|
constructor(private readonly options: IFleetWorkersOptions = {}) {
|
|
84
89
|
this.store = options.store ?? new FleetWorkerStore(); this.events = options.events ?? new FleetEvents();
|
|
85
90
|
this.backends = options.backends ?? { claude: new FleetClaudeWorkers(this.abort.signal, undefined, undefined, options.callers, options.claudeReleases),
|
|
@@ -298,11 +303,43 @@ export class FleetWorkers {
|
|
|
298
303
|
public async nativeAgents(home: string | undefined, maxAgeMs: number): Promise<IFleetNativeAgent[]> {
|
|
299
304
|
return await this.backends.claude.inventory?.(home, plugins.os.homedir(), maxAgeMs) ?? [];
|
|
300
305
|
}
|
|
306
|
+
/**
|
|
307
|
+
* What a fresh inventory shows of the Claude background job a worker's caller launch is bound to, for the controller
|
|
308
|
+
* to prove the job's next process. One read at a time, however many bridges ask.
|
|
309
|
+
*/
|
|
310
|
+
public observeCallerJob(request: IWorkerClaudeJobRequest): Promise<TWorkerClaudeJobObservation> {
|
|
311
|
+
const task = this.callerJobs.then(() => this.readCallerJob(request));
|
|
312
|
+
this.callerJobs = task.then(() => undefined, () => undefined);
|
|
313
|
+
return task;
|
|
314
|
+
}
|
|
315
|
+
private async readCallerJob(request: IWorkerClaudeJobRequest): Promise<TWorkerClaudeJobObservation> {
|
|
316
|
+
if (this.abort.signal.aborted) return { state: 'unproven', reason: 'the fleet is closing' };
|
|
317
|
+
if (!request.workerId.startsWith(`${request.projectId}:`)) return { state: 'ended', reason: 'its launch names no worker of its project' };
|
|
318
|
+
let worker: IFleetWorker | undefined;
|
|
319
|
+
try { worker = (await this.store.list(request.projectId)).find((doc) => doc.id === request.workerId)?.worker; }
|
|
320
|
+
catch (error) { return { state: 'unproven', reason: `the fleet registry could not be read (${this.error(error)})` }; }
|
|
321
|
+
if (!worker || worker.state === 'archived') return { state: 'ended', reason: `fleet worker ${request.workerId} is ${worker ? 'archived' : 'gone'}` };
|
|
322
|
+
if (worker.harness !== 'claude' || worker.nativeId !== request.nativeSessionId) return { state: 'ended', reason: 'its worker continues in another session' };
|
|
323
|
+
let agents: IFleetNativeAgent[] | undefined;
|
|
324
|
+
try { agents = await this.backends.claude.inventory?.(worker.profile.home, plugins.os.homedir(), 0); }
|
|
325
|
+
catch (error) { return { state: 'unproven', reason: `the Claude inventory could not be read (${this.error(error)})` }; }
|
|
326
|
+
const listed = (agents ?? []).filter((agent) => agent.sessionId === request.nativeSessionId);
|
|
327
|
+
if (listed.length !== 1) return { state: 'unproven', reason: `the Claude inventory lists its session ${listed.length ? 'more than once' : 'nowhere'}` };
|
|
328
|
+
const agent = listed[0]!;
|
|
329
|
+
if (agent.kind !== 'background' || agent.id !== request.jobId) return { state: 'ended', reason: 'its session continues as another Claude job' };
|
|
330
|
+
if (agent.pid === undefined) return { state: 'settled' };
|
|
331
|
+
try { return { state: 'running', process: { jobId: request.jobId, pid: agent.pid, startTicks: (await readWorkerProcessSnapshot(agent.pid)).startTicks } }; }
|
|
332
|
+
catch (error) {
|
|
333
|
+
if (!isWorkerCallerProofRefusal(error)) throw error;
|
|
334
|
+
return { state: 'unproven', reason: 'the process of its Claude job could not be read' };
|
|
335
|
+
}
|
|
336
|
+
}
|
|
301
337
|
/** The newest root threads on the host's shared Codex app-server, read at most `maxAgeMs` ago. */
|
|
302
338
|
public async nativeThreads(maxAgeMs: number): Promise<IFleetNativeThread[]> {
|
|
303
339
|
return await this.backends.codex.threads?.(plugins.os.homedir(), maxAgeMs) ?? [];
|
|
304
340
|
}
|
|
305
341
|
private admitJob(): void {
|
|
342
|
+
if (this.admissionClosed || this.abort.signal.aborted) throw new Error('The fleet is closing and admits no new worker job.');
|
|
306
343
|
if (this.jobs.size < 512) return;
|
|
307
344
|
for (const [id, job] of this.jobs) if (['succeeded', 'failed'].includes(job.snapshot.state)) { this.jobs.delete(id); break; }
|
|
308
345
|
if (this.jobs.size >= 512) throw new Error('Fleet worker job capacity reached.');
|
|
@@ -779,6 +816,19 @@ export class FleetWorkers {
|
|
|
779
816
|
throw error;
|
|
780
817
|
}
|
|
781
818
|
}
|
|
819
|
+
/**
|
|
820
|
+
* Admits no new worker job and lets the running ones finish, for at most `waitMs`; the ones still running then are
|
|
821
|
+
* aborted and settle. The controller calls it before it tears down the caller hooks the jobs prepare, bind and
|
|
822
|
+
* revoke launches through, so no job meets them gone.
|
|
823
|
+
*/
|
|
824
|
+
public async closeAdmission(waitMs = 10_000): Promise<void> {
|
|
825
|
+
this.admissionClosed = true;
|
|
826
|
+
const settled = Promise.allSettled([...this.jobs.values()].map((job) => job.completion));
|
|
827
|
+
let timer: NodeJS.Timeout | undefined;
|
|
828
|
+
const waited = await Promise.race([settled.then(() => true), new Promise<false>((resolve) => { timer = setTimeout(() => resolve(false), waitMs); })]);
|
|
829
|
+
if (timer) clearTimeout(timer);
|
|
830
|
+
if (!waited) { this.abort.abort(); await settled; }
|
|
831
|
+
}
|
|
782
832
|
public async close(): Promise<void> {
|
|
783
833
|
if (this.pollTimer) clearInterval(this.pollTimer); this.abort.abort(); this.events.close(); this.viewListeners.clear();
|
|
784
834
|
await Promise.allSettled([...this.jobs.values()].map((job) => job.completion)); await Promise.allSettled([this.polling]);
|
|
@@ -3,13 +3,13 @@ import { commitinfo } from './00_commitinfo_data.js';
|
|
|
3
3
|
import { ensureControllerDataRoot, preflightAGLHomeMigration } from './functions.controllerdataroot.js';
|
|
4
4
|
import { resolveAGLHomePaths } from './classes.aglhome.js';
|
|
5
5
|
import { resolveProductionAuthoritySocketPaths } from './classes.authorityclient.js';
|
|
6
|
-
import { inferenceHarnessLaunch } from './functions.inferencelaunch.js';
|
|
6
|
+
import { confinedDockerLaunch, inferenceHarnessLaunch } from './functions.inferencelaunch.js';
|
|
7
7
|
import type { IAglProxyStatus } from './classes.proxycontrol.js';
|
|
8
8
|
import { AglProxyControlClient, AglProxyUnavailableError } from './classes.proxycontrolclient.js';
|
|
9
|
-
import { AglProxyLeaseHolder } from './classes.proxyleaseholder.js';
|
|
9
|
+
import { AglProxyConfinedHolder, AglProxyLeaseHolder, type AglProxyLeaseLostError } from './classes.proxyleaseholder.js';
|
|
10
10
|
import { AglProxyDaemon } from './classes.proxydaemon.js';
|
|
11
11
|
import { AglProxyService, resolveAglExecutable } from './classes.proxyservice.js';
|
|
12
|
-
import { aglProxyUnitName } from './functions.proxycontract.js';
|
|
12
|
+
import { aglProxyConfinedMountPoint, aglProxyUnitName } from './functions.proxycontract.js';
|
|
13
13
|
import { inferenceCliHelp, parseInferenceCliArguments, type IInferenceCliArguments } from './functions.inferencecliarguments.js';
|
|
14
14
|
import { runProxyCredentialCommand } from './functions.proxycredentialcli.js';
|
|
15
15
|
import { currentProxyCredentialHelper, writeProxyCredentialHelper } from './functions.proxycredentialhelper.js';
|
|
@@ -19,11 +19,12 @@ type TAccountPlacementKey = 'lifecycle' | 'lapsesAt' | 'placementScore' | 'place
|
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* The status a running proxy answers with: one started from an AGL before 38.3.0 reports no accounts and no usage
|
|
22
|
-
* observations,
|
|
22
|
+
* observations, one before 38.4.0 no lifecycle, placement or placement journal, and one before confined runs neither
|
|
23
|
+
* its confined listener nor its runs.
|
|
23
24
|
*/
|
|
24
25
|
export type TReportedProxyStatus = Omit<IAglProxyStatus, 'accounts' | 'usageObservations' | 'nextPlacement'
|
|
25
|
-
| 'recentPlacements'>
|
|
26
|
-
& Partial<Pick<IAglProxyStatus, 'usageObservations' | 'nextPlacement' | 'recentPlacements'>>
|
|
26
|
+
| 'recentPlacements' | 'confinedSocketPath' | 'confinedRuns'>
|
|
27
|
+
& Partial<Pick<IAglProxyStatus, 'usageObservations' | 'nextPlacement' | 'recentPlacements' | 'confinedSocketPath' | 'confinedRuns'>>
|
|
27
28
|
& { accounts?: Array<Omit<IAglProxyStatus['accounts'][number], TAccountPlacementKey>
|
|
28
29
|
& Partial<Pick<IAglProxyStatus['accounts'][number], TAccountPlacementKey>>> };
|
|
29
30
|
|
|
@@ -69,6 +70,14 @@ export const formatProxyStatus = (unitArg: string, statusArg: TReportedProxyStat
|
|
|
69
70
|
+ `${client.generation}${client.delivery === 'file' ? ' (kept in a file)' : ''}, ${client.pending} in flight, `
|
|
70
71
|
+ `last used ${client.lastUsedAt ?? 'never'}`);
|
|
71
72
|
}
|
|
73
|
+
if (statusArg.confinedSocketPath !== undefined) {
|
|
74
|
+
lines.push(`Confined listener: ${statusArg.confinedSocketPath ?? 'none'}`);
|
|
75
|
+
for (const run of statusArg.confinedRuns ?? []) {
|
|
76
|
+
lines.push(`Confined run ${run.id}: issued ${run.createdAt}`
|
|
77
|
+
+ `${run.held ? '' : ', waiting for its launcher to take it back after a proxy restart'}`
|
|
78
|
+
+ ` (end it with \`agl proxy confined end ${run.id}\`)`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
72
81
|
if (!statusArg.accounts) {
|
|
73
82
|
// The proxy runs the older AGL it was started from until it restarts, as after an upgrade it was not restarted by.
|
|
74
83
|
lines.push(`Accounts: not reported, as the proxy runs AGL ${statusArg.version} and this command AGL ${commitinfo.version}. `
|
|
@@ -130,7 +139,7 @@ const serve = (portArg: number): Promise<void> => withSignals(async (signalArg)
|
|
|
130
139
|
const credentialHelper = await currentProxyCredentialHelper(paths.root,
|
|
131
140
|
() => resolveAglExecutable(process.env, plugins.os.homedir()));
|
|
132
141
|
const daemon = new AglProxyDaemon({ socketPath: paths.proxySocket, dataDirectory: plugins.path.join(paths.proxy, 'nosqldb'),
|
|
133
|
-
credentialDirectory: paths.proxyCredentials,
|
|
142
|
+
credentialDirectory: paths.proxyCredentials, confinedSocketPath: paths.proxyConfinedSocket,
|
|
134
143
|
authorityPaths: resolveProductionAuthoritySocketPaths(), port: portArg });
|
|
135
144
|
const stopping = () => { void daemon.stop().catch(() => undefined); };
|
|
136
145
|
signalArg.addEventListener('abort', stopping, { once: true });
|
|
@@ -196,6 +205,45 @@ const run = (optionsArg: IInferenceCliArguments): Promise<void> => withSignals(a
|
|
|
196
205
|
if (lost) throw new Error(`${lost.message} Run \`agl proxy run ${optionsArg.harness}\` again to continue.`);
|
|
197
206
|
});
|
|
198
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Runs a confined run's container (see `confinedDockerLaunch`) on a credential of its own, which ends when the container
|
|
210
|
+
* exits. A proxy that restarts meanwhile hands the credential back; one the proxy no longer hands back, or the operator
|
|
211
|
+
* ended, stops the container (`docker run` passes the signal on to it), and the run ends with the reason.
|
|
212
|
+
*/
|
|
213
|
+
const confinedRun = (optionsArg: IInferenceCliArguments): Promise<void> => withSignals(async (signalArg) => {
|
|
214
|
+
await preflightAGLHomeMigration();
|
|
215
|
+
const paths = resolveAGLHomePaths();
|
|
216
|
+
const run = await AglProxyConfinedHolder.take({ socketPath: paths.proxySocket }, AbortSignal.any([signalArg, AbortSignal.timeout(90_000)]));
|
|
217
|
+
let lost: AglProxyLeaseLostError | undefined;
|
|
218
|
+
try {
|
|
219
|
+
const launch = confinedDockerLaunch({ args: optionsArg.args, socketPath: run.confinedSocketPath, baseUrl: run.baseUrl,
|
|
220
|
+
token: run.token, environment: process.env });
|
|
221
|
+
process.stderr.write(`AGL proxy confined run ${run.id}: ${plugins.path.dirname(run.confinedSocketPath)} is mounted read-only `
|
|
222
|
+
+ `at ${aglProxyConfinedMountPoint}; end it at once with \`agl proxy confined end ${run.id}\`.\n`);
|
|
223
|
+
signalArg.throwIfAborted();
|
|
224
|
+
const child = plugins.childProcess.spawn(launch.command, launch.args, { env: launch.environment, stdio: 'inherit', shell: false });
|
|
225
|
+
let killTimer: ReturnType<typeof setTimeout> | undefined;
|
|
226
|
+
const terminate = () => {
|
|
227
|
+
child.kill('SIGTERM');
|
|
228
|
+
killTimer ??= setTimeout(() => child.kill('SIGKILL'), 10_000);
|
|
229
|
+
killTimer.unref();
|
|
230
|
+
};
|
|
231
|
+
signalArg.addEventListener('abort', terminate, { once: true });
|
|
232
|
+
void run.lost.then((errorArg) => { lost = errorArg; terminate(); });
|
|
233
|
+
try {
|
|
234
|
+
if (signalArg.aborted) terminate();
|
|
235
|
+
process.exitCode = await new Promise<number>((resolve, reject) => {
|
|
236
|
+
child.once('error', reject);
|
|
237
|
+
child.once('exit', (codeArg) => resolve(codeArg ?? 1));
|
|
238
|
+
});
|
|
239
|
+
} finally {
|
|
240
|
+
signalArg.removeEventListener('abort', terminate);
|
|
241
|
+
if (killTimer) clearTimeout(killTimer);
|
|
242
|
+
}
|
|
243
|
+
} finally { run.close(); }
|
|
244
|
+
if (lost) throw new Error(`${lost.message} The container was stopped; start the run again to continue.`);
|
|
245
|
+
});
|
|
246
|
+
|
|
199
247
|
const unitState = async (serviceArg: AglProxyService): Promise<string> => {
|
|
200
248
|
try {
|
|
201
249
|
const state = await serviceArg.inspect();
|
|
@@ -212,8 +260,14 @@ export const runInferenceCli = async (argvArg: readonly string[]): Promise<void>
|
|
|
212
260
|
const options = parseInferenceCliArguments(argvArg);
|
|
213
261
|
if (options.command === 'serve') { await preflightAGLHomeMigration(); await serve(options.port); return; }
|
|
214
262
|
if (options.command === 'run') { await run(options); return; }
|
|
263
|
+
if (options.command === 'confined' && options.confinedAction === 'run') { await confinedRun(options); return; }
|
|
215
264
|
const paths = resolveAGLHomePaths();
|
|
216
265
|
const client = new AglProxyControlClient(paths.proxySocket);
|
|
266
|
+
if (options.command === 'confined') {
|
|
267
|
+
await client.endConfined(options.confinedId!, AbortSignal.timeout(10_000));
|
|
268
|
+
process.stdout.write(`Ended confined run ${options.confinedId}: the proxy refuses its credential from now on.\n`);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
217
271
|
if (options.command === 'credential') {
|
|
218
272
|
await runProxyCredentialCommand(options, (textArg) => process.stdout.write(textArg));
|
|
219
273
|
return;
|
|
@@ -2,6 +2,8 @@ import * as path from 'node:path';
|
|
|
2
2
|
import type { IInferenceRoutingPolicy } from './classes.inferencerouter.js';
|
|
3
3
|
import type { TInferenceHarness } from './functions.inferencelaunch.js';
|
|
4
4
|
import {
|
|
5
|
+
aglProxyConfinedIdPattern,
|
|
6
|
+
aglProxyConfinedMountPoint,
|
|
5
7
|
aglProxyDefaultPort,
|
|
6
8
|
aglProxyUnitName,
|
|
7
9
|
defaultProxyClientProvider,
|
|
@@ -24,6 +26,8 @@ export const inferenceCliHelp = `Usage:
|
|
|
24
26
|
agl proxy status [--json]
|
|
25
27
|
agl proxy credential <client> [--provider openai|anthropic] [--delivery file|none] [--write <path>] [--rotate] | --version
|
|
26
28
|
agl proxy run codex|claude|opencode [--provider openai|anthropic] [--account <id>] [--plan <name>] [--model <id>] -- [harness arguments]
|
|
29
|
+
agl proxy confined run -- docker run [docker options] <image> [command]
|
|
30
|
+
agl proxy confined end <run id>
|
|
27
31
|
|
|
28
32
|
The proxy runs as the ${aglProxyUnitName} user unit (serve runs it in a terminal instead).
|
|
29
33
|
Every client (codex, claude, opencode, or a name of your own) has one credential, sealed by the proxy.
|
|
@@ -35,6 +39,13 @@ Each client is served from one pool: ChatGPT accounts (openai) or Claude account
|
|
|
35
39
|
client starts on Claude accounts, every other client on ChatGPT accounts; --provider moves a client.
|
|
36
40
|
Run gives one child a private credential that ends when the child exits; run claude uses Claude accounts
|
|
37
41
|
unless --provider openai asks for ChatGPT models behind Claude Code.
|
|
42
|
+
confined run starts Claude Code's container on a credential of its own, accepted only on the proxy's confined
|
|
43
|
+
listener: a Unix socket whose directory the run mounts read-only at ${aglProxyConfinedMountPoint}, served the Claude pool's
|
|
44
|
+
three Claude Code endpoints alone. It adds the mount and Claude Code's settings (ANTHROPIC_UNIX_SOCKET, ANTHROPIC_BASE_URL,
|
|
45
|
+
ANTHROPIC_AUTH_TOKEN) right after docker run, prints the run's id on standard error, and ends the credential when the
|
|
46
|
+
container exits. The socket is the proxy user's (0600): run the container as that uid (--user), or as root under
|
|
47
|
+
rootless Docker. Root of a rootful daemon connects as well while it keeps CAP_DAC_OVERRIDE (--cap-drop ALL takes it).
|
|
48
|
+
confined end ends a run's credential at once, and its launcher stops the container; agl proxy status lists the runs.
|
|
38
49
|
Repeat --account or --plan to restrict automatic routing. Only accounts the account service holds are eligible.
|
|
39
50
|
ChatGPT pools: /openai/v1/responses, /anthropic/v1/messages (translated), and each prefix's /models.
|
|
40
51
|
Claude pools: /anthropic/v1/messages, /anthropic/v1/messages/count_tokens and /anthropic/v1/models, sent on as Claude Code sent them.
|
|
@@ -44,14 +55,18 @@ Use x-agl-session-id for custom clients; native session headers and prompt_cache
|
|
|
44
55
|
Switches use full history with a cold cache. Provider-only references require a full-history retry; opaque account state never crosses accounts.
|
|
45
56
|
`;
|
|
46
57
|
|
|
47
|
-
export type TInferenceCommand = 'serve' | 'run' | 'credential' | 'status' | 'service';
|
|
58
|
+
export type TInferenceCommand = 'serve' | 'run' | 'credential' | 'status' | 'service' | 'confined';
|
|
48
59
|
export type TInferenceServiceAction = 'install' | 'enable' | 'start' | 'stop' | 'status';
|
|
60
|
+
export type TInferenceConfinedAction = 'run' | 'end';
|
|
49
61
|
|
|
50
62
|
export interface IInferenceCliArguments {
|
|
51
63
|
command: TInferenceCommand;
|
|
52
64
|
harness?: TInferenceHarness;
|
|
53
65
|
client?: string;
|
|
54
66
|
serviceAction?: TInferenceServiceAction;
|
|
67
|
+
confinedAction?: TInferenceConfinedAction;
|
|
68
|
+
/** `confined end`: the run to end. */
|
|
69
|
+
confinedId?: string;
|
|
55
70
|
port: number;
|
|
56
71
|
policy: IInferenceRoutingPolicy;
|
|
57
72
|
/** `--provider`, when given. */
|
|
@@ -72,6 +87,7 @@ const commandOptions: Record<TInferenceCommand, readonly string[]> = {
|
|
|
72
87
|
credential: ['--provider', '--delivery', '--write', '--rotate'],
|
|
73
88
|
status: ['--json'],
|
|
74
89
|
service: ['--port'],
|
|
90
|
+
confined: [],
|
|
75
91
|
};
|
|
76
92
|
const flags = new Set(['--rotate', '--json']);
|
|
77
93
|
|
|
@@ -97,6 +113,21 @@ export const parseInferenceCliArguments = (argvArg: readonly string[]): IInferen
|
|
|
97
113
|
const action = argv.shift();
|
|
98
114
|
if (!['install', 'enable', 'start', 'stop', 'status'].includes(action ?? '')) throw new Error(inferenceCliHelp);
|
|
99
115
|
result.serviceAction = action as TInferenceServiceAction;
|
|
116
|
+
} else if (command === 'confined') {
|
|
117
|
+
const action = argv.shift();
|
|
118
|
+
if (action === 'end') {
|
|
119
|
+
const id = argv.shift();
|
|
120
|
+
if (id === undefined || !aglProxyConfinedIdPattern.test(id) || argv.length) {
|
|
121
|
+
throw new Error('Name the confined run to end by its id: 12 hex characters, as `agl proxy status` lists it.');
|
|
122
|
+
}
|
|
123
|
+
result.confinedAction = 'end';
|
|
124
|
+
result.confinedId = id;
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
if (action !== 'run' || argv.shift() !== '--' || !argv.length) throw new Error(inferenceCliHelp);
|
|
128
|
+
result.confinedAction = 'run';
|
|
129
|
+
result.args = argv;
|
|
130
|
+
return result;
|
|
100
131
|
}
|
|
101
132
|
const accounts: string[] = [];
|
|
102
133
|
const plans: string[] = [];
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import * as path from 'node:path';
|
|
1
2
|
import type { TInferenceModel } from './classes.inferencerouter.js';
|
|
2
3
|
import type { TAglProxyLeaseCatalog } from './classes.proxycontrol.js';
|
|
3
|
-
import {
|
|
4
|
-
|
|
4
|
+
import { aglProxyConfinedMountPoint } from './functions.proxycontract.js';
|
|
5
|
+
import { aglProxyProviderId, claudeProxyEnvironment, claudeProxyReservedVariables, claudeRoutingVariables, codexProxyProvider,
|
|
6
|
+
openCodeProxyProvider, proxyHarnessModels } from './functions.proxyharnessconfig.js';
|
|
5
7
|
|
|
6
8
|
export type TInferenceHarness = 'codex' | 'claude' | 'opencode';
|
|
7
9
|
export interface IInferenceLaunchOptions {
|
|
@@ -137,3 +139,48 @@ export const inferenceHarnessLaunch = (optionsArg: IInferenceLaunchOptions): { a
|
|
|
137
139
|
if (args[0] === 'run') return { environment, args: ['run', ...selection, ...args.slice(1)] };
|
|
138
140
|
return { environment, args: args[0] !== undefined && openCodeCommands.has(args[0]) ? args : [...selection, ...args] };
|
|
139
141
|
};
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* A confined run's `docker run`: the given command, with the confined listener's directory mounted read-only at
|
|
145
|
+
* `aglProxyConfinedMountPoint` and Claude Code's settings for it added right after `run`, before the launcher's own
|
|
146
|
+
* options and image. Claude Code inside reaches the proxy through the socket alone (`ANTHROPIC_UNIX_SOCKET`, which
|
|
147
|
+
* Claude Code sends its API requests through as plain HTTP to `ANTHROPIC_BASE_URL`'s path), on the run's credential
|
|
148
|
+
* (`ANTHROPIC_AUTH_TOKEN`), reads the pool's models from the proxy and sends nothing beyond its model requests. The
|
|
149
|
+
* container gets each variable by name from the docker command's environment, so the credential never appears on a
|
|
150
|
+
* command line; that environment drops every variable that would give Claude Code another login or route, so `-e NAME`
|
|
151
|
+
* of one of them passes nothing. The mount is the socket's own directory, which holds nothing else and survives a proxy
|
|
152
|
+
* restart, unlike a mount of the socket file. Only `docker run` and `docker container run` are taken.
|
|
153
|
+
*/
|
|
154
|
+
export const confinedDockerLaunch = (optionsArg: {
|
|
155
|
+
args: readonly string[];
|
|
156
|
+
/** The confined listener's socket on the host. */
|
|
157
|
+
socketPath: string;
|
|
158
|
+
/** The origin Claude Code names; see `IAglProxyConfinedGrant.baseUrl`. */
|
|
159
|
+
baseUrl: string;
|
|
160
|
+
token: string;
|
|
161
|
+
environment: NodeJS.ProcessEnv;
|
|
162
|
+
}): { command: string; args: string[]; environment: NodeJS.ProcessEnv } => {
|
|
163
|
+
const [command, ...args] = optionsArg.args;
|
|
164
|
+
const runAt = args[0] === 'run' ? 1 : args[0] === 'container' && args[1] === 'run' ? 2 : 0;
|
|
165
|
+
if (command === undefined || path.basename(command) !== 'docker' || runAt === 0) {
|
|
166
|
+
throw new Error('A confined run starts its container with `docker run` (or `docker container run`) and its arguments.');
|
|
167
|
+
}
|
|
168
|
+
const directory = path.dirname(optionsArg.socketPath);
|
|
169
|
+
// `--mount` separates its fields with commas and reads quotes; a directory naming either cannot be passed safely.
|
|
170
|
+
if (/[,"'\n]/u.test(directory)) throw new Error(`The confined listener's directory ${directory} cannot be mounted: it holds a comma, quote or line break.`);
|
|
171
|
+
const variables: Record<string, string> = {
|
|
172
|
+
...claudeProxyEnvironment(optionsArg.baseUrl),
|
|
173
|
+
ANTHROPIC_UNIX_SOCKET: `${aglProxyConfinedMountPoint}/${path.basename(optionsArg.socketPath)}`,
|
|
174
|
+
ANTHROPIC_AUTH_TOKEN: optionsArg.token,
|
|
175
|
+
};
|
|
176
|
+
const environment = { ...optionsArg.environment };
|
|
177
|
+
for (const name of claudeProxyReservedVariables) delete environment[name];
|
|
178
|
+
return {
|
|
179
|
+
command,
|
|
180
|
+
args: [...args.slice(0, runAt),
|
|
181
|
+
'--mount', `type=bind,source=${directory},target=${aglProxyConfinedMountPoint},readonly`,
|
|
182
|
+
...Object.keys(variables).flatMap((nameArg) => ['--env', nameArg]),
|
|
183
|
+
...args.slice(runAt)],
|
|
184
|
+
environment: { ...environment, ...variables },
|
|
185
|
+
};
|
|
186
|
+
};
|
|
@@ -33,3 +33,15 @@ export const proxyCredentialDeliveries: readonly TProxyCredentialDelivery[] = ['
|
|
|
33
33
|
|
|
34
34
|
export const aglProxyUnitName = 'agl-proxy.service';
|
|
35
35
|
export const aglProxyDefaultPort = 4100;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Confined runs (`agl proxy confined run`): Claude Code in a container that reaches the proxy only through the confined
|
|
39
|
+
* listener, a Unix socket alone in its directory, which the run mounts read-only at `aglProxyConfinedMountPoint`. Their
|
|
40
|
+
* sessions are those of the client `aglProxyConfinedClient`, and Claude Code names `aglProxyConfinedOrigin` as its
|
|
41
|
+
* endpoint's origin, whose host the socket makes irrelevant.
|
|
42
|
+
*/
|
|
43
|
+
export const aglProxyConfinedClient = 'confined';
|
|
44
|
+
export const aglProxyConfinedOrigin = 'http://localhost';
|
|
45
|
+
export const aglProxyConfinedMountPoint = '/run/agl-proxy';
|
|
46
|
+
/** A confined run's id: the first 12 hex characters of its credential's SHA-256 digest. */
|
|
47
|
+
export const aglProxyConfinedIdPattern = /^[a-f0-9]{12}$/;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Claims the pathname of one of the proxy's Unix sockets: its directory is made private (0700, this user's, not a link),
|
|
5
|
+
* and a socket a killed proxy left there is removed, but only while it is an unchanged, owned socket nobody answers on.
|
|
6
|
+
* `nameArg` names the socket in the refusals; `busyArg` is the refusal for a socket another process answers on.
|
|
7
|
+
*/
|
|
8
|
+
export const claimProxySocketPath = async (socketPathArg: string, nameArg: string, busyArg: string): Promise<void> => {
|
|
9
|
+
const directory = plugins.path.dirname(socketPathArg);
|
|
10
|
+
await plugins.fs.promises.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
11
|
+
const stat = await plugins.fs.promises.lstat(directory);
|
|
12
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid?.()) {
|
|
13
|
+
throw new Error(`The ${nameArg} directory is not private.`);
|
|
14
|
+
}
|
|
15
|
+
await plugins.fs.promises.chmod(directory, 0o700);
|
|
16
|
+
let before: plugins.fs.Stats;
|
|
17
|
+
try { before = await plugins.fs.promises.lstat(socketPathArg); }
|
|
18
|
+
catch (errorArg) {
|
|
19
|
+
if ((errorArg as NodeJS.ErrnoException).code === 'ENOENT') return;
|
|
20
|
+
throw errorArg;
|
|
21
|
+
}
|
|
22
|
+
if (!before.isSocket() || before.uid !== process.getuid?.()) throw new Error(`The ${nameArg} path is unsafe.`);
|
|
23
|
+
const refused = await new Promise<boolean>((resolve, reject) => {
|
|
24
|
+
const probe = plugins.net.createConnection(socketPathArg);
|
|
25
|
+
probe.setTimeout(1_000, () => { probe.destroy(); reject(new Error(`The ${nameArg} probe timed out.`)); });
|
|
26
|
+
probe.once('connect', () => { probe.destroy(); resolve(false); });
|
|
27
|
+
probe.once('error', (errorArg) => {
|
|
28
|
+
probe.destroy();
|
|
29
|
+
if ((errorArg as NodeJS.ErrnoException).code === 'ECONNREFUSED') resolve(true);
|
|
30
|
+
else reject(errorArg);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
if (!refused) throw new Error(busyArg);
|
|
34
|
+
const after = await plugins.fs.promises.lstat(socketPathArg);
|
|
35
|
+
if (!after.isSocket() || after.dev !== before.dev || after.ino !== before.ino) {
|
|
36
|
+
throw new Error(`The ${nameArg} path changed during startup.`);
|
|
37
|
+
}
|
|
38
|
+
await plugins.fs.promises.unlink(socketPathArg);
|
|
39
|
+
};
|
|
@@ -142,21 +142,44 @@ const processOwnsLoopbackConnection = async (connectionArg: IWorkerCallerConnect
|
|
|
142
142
|
return matches.length === 1 && await processOwnsInode(pidArg, matches[0][9]);
|
|
143
143
|
};
|
|
144
144
|
|
|
145
|
-
|
|
145
|
+
/**
|
|
146
|
+
* The bridge process `bridgePidArg`, proved to hold the caller's end of the direct loopback connection and to run the
|
|
147
|
+
* approved command, whatever its parent. The command is the one recorded when the launch was prepared: an upgrade
|
|
148
|
+
* installs AGL under another path, and a command whose files are gone is refused as such.
|
|
149
|
+
*/
|
|
150
|
+
export const readWorkerBridge = async (
|
|
146
151
|
connectionArg: IWorkerCallerConnection | undefined,
|
|
147
152
|
bridgePidArg: number,
|
|
148
153
|
commandArg: IWorkerBridgeCommand,
|
|
149
|
-
parentArg: IWorkerProcessSnapshot,
|
|
150
154
|
): Promise<IWorkerProcessSnapshot> => {
|
|
155
|
+
for (const file of [commandArg.executable, commandArg.entry]) {
|
|
156
|
+
let resolved: string;
|
|
157
|
+
try { resolved = await plugins.fs.promises.realpath(file); } catch (errorArg) {
|
|
158
|
+
if (isWorkerCallerProofRefusal(errorArg)) throw new WorkerCallerProofError('The approved bridge command no longer exists.');
|
|
159
|
+
throw errorArg;
|
|
160
|
+
}
|
|
161
|
+
if (resolved !== file) throw new WorkerCallerProofError('The approved bridge command no longer exists.');
|
|
162
|
+
}
|
|
151
163
|
if (!await processOwnsLoopbackConnection(connectionArg, bridgePidArg)) {
|
|
152
164
|
throw new WorkerCallerProofError('Worker transport ownership could not be proved.');
|
|
153
165
|
}
|
|
154
166
|
const bridge = await readWorkerProcessSnapshot(bridgePidArg);
|
|
155
167
|
const entry = await plugins.fs.promises.realpath(bridge.arguments[1] ?? '');
|
|
156
|
-
if (bridge.
|
|
157
|
-
||
|
|
168
|
+
if (bridge.executable !== commandArg.executable || entry !== commandArg.entry
|
|
169
|
+
|| !plugins.util.isDeepStrictEqual(bridge.arguments.slice(2), commandArg.arguments)) {
|
|
158
170
|
throw new WorkerCallerProofError('Worker bridge does not match the approved launch.');
|
|
159
171
|
}
|
|
172
|
+
return bridge;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
export const verifyWorkerBridge = async (
|
|
176
|
+
connectionArg: IWorkerCallerConnection | undefined,
|
|
177
|
+
bridgePidArg: number,
|
|
178
|
+
commandArg: IWorkerBridgeCommand,
|
|
179
|
+
parentArg: IWorkerProcessSnapshot,
|
|
180
|
+
): Promise<IWorkerProcessSnapshot> => {
|
|
181
|
+
const bridge = await readWorkerBridge(connectionArg, bridgePidArg, commandArg);
|
|
182
|
+
if (bridge.parentPid !== parentArg.pid) throw new WorkerCallerProofError('Worker bridge does not match the approved launch.');
|
|
160
183
|
if (!workerSnapshotsEqual(parentArg, await readWorkerProcessSnapshot(parentArg.pid))
|
|
161
184
|
|| !workerSnapshotsEqual(bridge, await readWorkerProcessSnapshot(bridge.pid))) {
|
|
162
185
|
throw new WorkerCallerProofError('Worker transport or process incarnation could not be proved.');
|
|
@@ -767,6 +767,27 @@ export interface IAuthSwitchClaudeSignInPrompt {
|
|
|
767
767
|
/** The arguments a Claude sign-in runs Claude Code with, in the home its prompt names. */
|
|
768
768
|
export const authSwitchClaudeSignInArguments: readonly string[] = ['auth', 'login'];
|
|
769
769
|
|
|
770
|
+
/**
|
|
771
|
+
* A Claude Code process that keeps a sign-in from importing the login in its home, as the process table lists it:
|
|
772
|
+
* `on_home` when its home or credential store is the sign-in home, so it may still write there; `uninspectable` when
|
|
773
|
+
* its home or store could not be resolved and it did not start before the home was made, so it may run on it.
|
|
774
|
+
*/
|
|
775
|
+
export interface IAuthSwitchClaudeSignInHolder {
|
|
776
|
+
/** Null where the process table names none. */
|
|
777
|
+
pid: number | null;
|
|
778
|
+
/** When it started, to the second; null when the process table did not say. */
|
|
779
|
+
startedAt: string | null;
|
|
780
|
+
reason: 'on_home' | 'uninspectable';
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* The processes holding a sign-in, in the words the authswitch command line, the sign-in's own error and AGL's
|
|
785
|
+
* Accounts screen share: each by pid and start, and why it holds the sign-in.
|
|
786
|
+
*/
|
|
787
|
+
export const authSwitchClaudeSignInHoldersText = (holders: readonly IAuthSwitchClaudeSignInHolder[]): string =>
|
|
788
|
+
holders.map(holder => `pid ${holder.pid ?? 'unknown'}${holder.startedAt === null ? '' : ` (started ${holder.startedAt})`} `
|
|
789
|
+
+ (holder.reason === 'on_home' ? 'runs on the sign-in home' : 'could not be inspected')).join('; ');
|
|
790
|
+
|
|
770
791
|
/**
|
|
771
792
|
* One Claude sign-in: Claude Code signs in to a Claude home the authority created for it alone, and once the login
|
|
772
793
|
* is there and no Claude Code process runs on that home, the authority imports it with one rotating refresh -- the
|
|
@@ -775,16 +796,24 @@ export const authSwitchClaudeSignInArguments: readonly string[] = ['auth', 'logi
|
|
|
775
796
|
* authority holds and which has ended (`needs_reauth`) signs that account in again; a working login, or one Claude
|
|
776
797
|
* Code holds, is kept, and the sign-in fails saying so.
|
|
777
798
|
*
|
|
778
|
-
* `pending` while the authority waits for the login, with `prompt
|
|
779
|
-
*
|
|
780
|
-
*
|
|
781
|
-
*
|
|
782
|
-
*
|
|
799
|
+
* `pending` while the authority waits for the login, with `prompt`, and with `heldBy` once the login is there but
|
|
800
|
+
* Claude Code processes keep the authority from importing it; `committing` while it imports it; then `complete` with
|
|
801
|
+
* `result`, `failed` or `outcome_unknown` with `error`, `cancelled`, or `interrupted` when the authority stopped. A
|
|
802
|
+
* pending sign-in still pending at `expiresAt`, fifteen minutes after it started, fails and imports nothing: its error
|
|
803
|
+
* says whether a login had arrived and, if processes held it, names them. A finished sign-in answers for ten minutes
|
|
804
|
+
* after `finishedAt`; after that its id is unknown (`not_found`). Sign-ins live in the running authority only: after a
|
|
805
|
+
* restart their ids are unknown and their homes are gone.
|
|
783
806
|
*/
|
|
784
807
|
export interface IAuthSwitchClaudeSignInOperation {
|
|
785
808
|
id: string;
|
|
786
809
|
state: 'pending' | 'committing' | 'complete' | 'failed' | 'cancelled' | 'interrupted' | 'outcome_unknown';
|
|
787
810
|
prompt: IAuthSwitchClaudeSignInPrompt | null;
|
|
811
|
+
/**
|
|
812
|
+
* While `pending` with Claude Code's login in the home: the processes that keep the authority from importing it, as
|
|
813
|
+
* its last look found them; never empty. Null while no login is there, while nothing holds it, and once the sign-in
|
|
814
|
+
* is no longer pending.
|
|
815
|
+
*/
|
|
816
|
+
heldBy: IAuthSwitchClaudeSignInHolder[] | null;
|
|
788
817
|
result: { accountId: string; loginId: string } | null;
|
|
789
818
|
error: string | null;
|
|
790
819
|
createdAt: string;
|
|
@@ -2,7 +2,7 @@ import type { IAuthSwitchAccount, IAuthSwitchClaudeHostLogin, IAuthSwitchClaudeH
|
|
|
2
2
|
IAuthSwitchDoctorPage, IAuthSwitchUsageRecheck, IAuthSwitchUsageSnapshot, IAuthSwitchOperation, IAuthSwitchPreuseOperation, IAuthSwitchSnapshot, IReq_AuthSwitchDoctor,
|
|
3
3
|
IReq_AuthSwitchAddApiKey, IReq_AuthSwitchRemoveAccount, IReq_AuthSwitchStartPreuse,
|
|
4
4
|
IReq_AuthSwitchSwitchCodexNative, IReq_AuthSwitchReleaseCodexNative } from './authority-contract.js';
|
|
5
|
-
import { asAuthSwitchRefusal, authSwitchClaudeSignInArguments, isAuthSwitchLapsesAt,
|
|
5
|
+
import { asAuthSwitchRefusal, authSwitchClaudeSignInArguments, authSwitchClaudeSignInHoldersText, isAuthSwitchLapsesAt,
|
|
6
6
|
type TAuthSwitchApiKeyProvider } from './authority-contract.js';
|
|
7
7
|
import { authSwitchParseUsd, authSwitchUsdText } from './authority-spend.js';
|
|
8
8
|
import type { IAuthSwitchImportInventory, IAuthSwitchImportResult, IAuthSwitchImportStatus,
|
|
@@ -812,6 +812,10 @@ const renderClaudeSignIn = (operation: IAuthSwitchClaudeSignInOperation, json: b
|
|
|
812
812
|
if (json) return JSON.stringify(operation) + '\n';
|
|
813
813
|
const lines = [`Claude sign-in ${safe(operation.id)} — ${safe(operation.state)}, revision ${operation.revision}`];
|
|
814
814
|
if (operation.prompt) lines.push(` Claude Code signs in with: ${safe(operation.prompt.command)}`);
|
|
815
|
+
if (operation.heldBy) {
|
|
816
|
+
lines.push(' Claude Code signed in; the login is added once these Claude Code processes have ended: '
|
|
817
|
+
+ safe(authSwitchClaudeSignInHoldersText(operation.heldBy)));
|
|
818
|
+
}
|
|
815
819
|
if (operation.result) {
|
|
816
820
|
lines.push(` signed in account=${safe(operation.result.accountId)}; login=${safe(operation.result.loginId)}`);
|
|
817
821
|
}
|