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.
Files changed (95) hide show
  1. package/changelog.md +16 -0
  2. package/dist_serve/bundle.js +598 -598
  3. package/dist_serve/bundle.js.map +2 -2
  4. package/dist_ts/00_commitinfo_data.js +1 -1
  5. package/dist_ts/aglapi/aglapisurface.document.js +2 -2
  6. package/dist_ts/classes.aglhome.d.ts +5 -0
  7. package/dist_ts/classes.aglhome.js +2 -1
  8. package/dist_ts/classes.authstore.js +5 -1
  9. package/dist_ts/classes.controller.js +11 -2
  10. package/dist_ts/classes.inferencegateway.d.ts +35 -1
  11. package/dist_ts/classes.inferencegateway.js +86 -26
  12. package/dist_ts/classes.inferenceservice.d.ts +4 -2
  13. package/dist_ts/classes.inferenceservice.js +5 -4
  14. package/dist_ts/classes.proxycontrol.d.ts +58 -4
  15. package/dist_ts/classes.proxycontrol.js +23 -44
  16. package/dist_ts/classes.proxycontrolclient.d.ts +15 -5
  17. package/dist_ts/classes.proxycontrolclient.js +67 -17
  18. package/dist_ts/classes.proxydaemon.d.ts +22 -3
  19. package/dist_ts/classes.proxydaemon.js +73 -12
  20. package/dist_ts/classes.proxyleaseholder.d.ts +43 -14
  21. package/dist_ts/classes.proxyleaseholder.js +66 -22
  22. package/dist_ts/classes.proxystore.d.ts +9 -0
  23. package/dist_ts/classes.proxystore.js +15 -7
  24. package/dist_ts/classes.workercallerbindings.d.ts +66 -0
  25. package/dist_ts/classes.workercallerbindings.js +211 -0
  26. package/dist_ts/classes.workercallers.d.ts +63 -2
  27. package/dist_ts/classes.workercallers.js +430 -65
  28. package/dist_ts/fleet/classes.workerlaunches.d.ts +12 -5
  29. package/dist_ts/fleet/classes.workerlaunches.js +25 -19
  30. package/dist_ts/fleet/classes.workers.d.ts +17 -0
  31. package/dist_ts/fleet/classes.workers.js +74 -1
  32. package/dist_ts/functions.inferencecli.d.ts +3 -2
  33. package/dist_ts/functions.inferencecli.js +67 -5
  34. package/dist_ts/functions.inferencecliarguments.d.ts +6 -2
  35. package/dist_ts/functions.inferencecliarguments.js +29 -2
  36. package/dist_ts/functions.inferencelaunch.d.ts +24 -0
  37. package/dist_ts/functions.inferencelaunch.js +42 -2
  38. package/dist_ts/functions.proxycontract.d.ts +11 -0
  39. package/dist_ts/functions.proxycontract.js +12 -1
  40. package/dist_ts/functions.proxysocket.d.ts +6 -0
  41. package/dist_ts/functions.proxysocket.js +46 -0
  42. package/dist_ts/functions.workerprocessproof.d.ts +6 -0
  43. package/dist_ts/functions.workerprocessproof.js +28 -4
  44. package/dist_ts_authswitch/authority-contract.d.ts +30 -5
  45. package/dist_ts_authswitch/authority-contract.js +7 -1
  46. package/dist_ts_authswitch/classes.authoritycli.js +6 -2
  47. package/dist_ts_authswitch/classes.authorityclient.js +15 -5
  48. package/dist_ts_authswitch/classes.claudenative.d.ts +7 -1
  49. package/dist_ts_authswitch/classes.claudenative.js +21 -6
  50. package/dist_ts_authswitch/classes.claudesignin.d.ts +14 -2
  51. package/dist_ts_authswitch/classes.claudesignin.js +68 -22
  52. package/dist_ts_authswitch/classes.codexmanaged.d.ts +8 -0
  53. package/dist_ts_authswitch/classes.codexmanaged.js +33 -18
  54. package/dist_ts_authswitch/classes.harnessprocesses.d.ts +8 -0
  55. package/dist_ts_authswitch/classes.harnessprocesses.js +41 -9
  56. package/dist_ts_interfaces/interfaces.d.ts +1 -1
  57. package/dist_ts_interfaces/interfaces.js +1 -1
  58. package/dist_ts_interfaces/workercallers.d.ts +43 -4
  59. package/package.json +1 -1
  60. package/readme.md +103 -14
  61. package/ts/00_commitinfo_data.ts +1 -1
  62. package/ts/aglapi/aglapisurface.document.ts +2 -2
  63. package/ts/classes.aglhome.ts +6 -0
  64. package/ts/classes.authstore.ts +4 -0
  65. package/ts/classes.controller.ts +10 -1
  66. package/ts/classes.inferencegateway.ts +104 -23
  67. package/ts/classes.inferenceservice.ts +6 -3
  68. package/ts/classes.proxycontrol.ts +76 -39
  69. package/ts/classes.proxycontrolclient.ts +72 -20
  70. package/ts/classes.proxydaemon.ts +85 -15
  71. package/ts/classes.proxyleaseholder.ts +84 -28
  72. package/ts/classes.proxystore.ts +16 -5
  73. package/ts/classes.workercallerbindings.ts +147 -0
  74. package/ts/classes.workercallers.ts +410 -59
  75. package/ts/fleet/classes.workerlaunches.ts +23 -15
  76. package/ts/fleet/classes.workers.ts +51 -1
  77. package/ts/functions.inferencecli.ts +61 -7
  78. package/ts/functions.inferencecliarguments.ts +32 -1
  79. package/ts/functions.inferencelaunch.ts +49 -2
  80. package/ts/functions.proxycontract.ts +12 -0
  81. package/ts/functions.proxysocket.ts +39 -0
  82. package/ts/functions.workerprocessproof.ts +27 -4
  83. package/ts_authswitch/authority-contract.ts +34 -5
  84. package/ts_authswitch/classes.authoritycli.ts +5 -1
  85. package/ts_authswitch/classes.authorityclient.ts +25 -9
  86. package/ts_authswitch/classes.claudenative.ts +23 -7
  87. package/ts_authswitch/classes.claudesignin.ts +70 -23
  88. package/ts_authswitch/classes.codexmanaged.ts +30 -16
  89. package/ts_authswitch/classes.harnessprocesses.ts +39 -8
  90. package/ts_authswitch/native-contracts.md +1 -1
  91. package/ts_interfaces/interfaces.ts +1 -1
  92. package/ts_interfaces/workercallers.ts +32 -4
  93. package/ts_web/00_commitinfo_data.ts +1 -1
  94. package/ts_web/accounts.plugins.ts +5 -1
  95. 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. Launches are in memory, as the controller's caller records are: a controller restart forgets them, and
28
- * a Claude worker regains its identity at its next native start.
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
- await this.revoke(worker, 'replacement');
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`. A job whose process
79
- * already exited holds no launch to prove; its launch is revoked and its next wake prepares another.
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 { startTicks } = await readWorkerProcessSnapshot(agent.pid);
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, claudeProcess: { jobId: agent.id, pid: agent.pid, startTicks } });
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
- /** Revokes the worker's launch: its process stopped, failed or was replaced. */
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
- const launch = this.launches.get(worker.id);
128
- if (!launch) return;
129
- this.launches.delete(worker.id);
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, and one before 38.4.0 no lifecycle, placement or placement journal.
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 { aglProxyProviderId, claudeProxyEnvironment, claudeRoutingVariables, codexProxyProvider, openCodeProxyProvider,
4
- proxyHarnessModels } from './functions.proxyharnessconfig.js';
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
- export const verifyWorkerBridge = async (
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.parentPid !== parentArg.pid || bridge.executable !== commandArg.executable
157
- || entry !== commandArg.entry || !plugins.util.isDeepStrictEqual(bridge.arguments.slice(2), commandArg.arguments)) {
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`; `committing` while it imports it; then
779
- * `complete` with `result`, `failed` or `outcome_unknown` with `error`, `cancelled`, or `interrupted` when the
780
- * authority stopped. A pending sign-in that holds no login at `expiresAt`, fifteen minutes after it started, fails.
781
- * A finished sign-in answers for ten minutes after `finishedAt`; after that its id is unknown (`not_found`).
782
- * Sign-ins live in the running authority only: after a restart their ids are unknown and their homes are gone.
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
  }