agl 40.5.0 → 40.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/changelog.md +6 -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_authswitch/authority-contract.d.ts +30 -5
  7. package/dist_ts_authswitch/authority-contract.js +7 -1
  8. package/dist_ts_authswitch/classes.authoritycli.js +6 -2
  9. package/dist_ts_authswitch/classes.authorityclient.js +15 -5
  10. package/dist_ts_authswitch/classes.claudenative.d.ts +7 -1
  11. package/dist_ts_authswitch/classes.claudenative.js +21 -6
  12. package/dist_ts_authswitch/classes.claudesignin.d.ts +14 -2
  13. package/dist_ts_authswitch/classes.claudesignin.js +68 -22
  14. package/dist_ts_authswitch/classes.codexmanaged.d.ts +8 -0
  15. package/dist_ts_authswitch/classes.codexmanaged.js +33 -18
  16. package/dist_ts_authswitch/classes.harnessprocesses.d.ts +8 -0
  17. package/dist_ts_authswitch/classes.harnessprocesses.js +41 -9
  18. package/dist_ts_interfaces/interfaces.d.ts +1 -1
  19. package/dist_ts_interfaces/interfaces.js +1 -1
  20. package/package.json +1 -1
  21. package/readme.md +1 -1
  22. package/ts/00_commitinfo_data.ts +1 -1
  23. package/ts/aglapi/aglapisurface.document.ts +2 -2
  24. package/ts_authswitch/authority-contract.ts +34 -5
  25. package/ts_authswitch/classes.authoritycli.ts +5 -1
  26. package/ts_authswitch/classes.authorityclient.ts +25 -9
  27. package/ts_authswitch/classes.claudenative.ts +23 -7
  28. package/ts_authswitch/classes.claudesignin.ts +70 -23
  29. package/ts_authswitch/classes.codexmanaged.ts +30 -16
  30. package/ts_authswitch/classes.harnessprocesses.ts +39 -8
  31. package/ts_authswitch/native-contracts.md +1 -1
  32. package/ts_interfaces/interfaces.ts +1 -1
  33. package/ts_web/00_commitinfo_data.ts +1 -1
  34. package/ts_web/accounts.plugins.ts +5 -1
  35. package/ts_web/classes.accountsreadmodel.ts +4 -1
@@ -3783,7 +3783,7 @@ export const AGL_API_SURFACE: plugins.flexTypedRequest.IFlexTypedRequestSurface
3783
3783
  },
3784
3784
  "protocolVersion": {
3785
3785
  "type": "number",
3786
- "const": 44
3786
+ "const": 45
3787
3787
  },
3788
3788
  "upgradeManagementVersion": {
3789
3789
  "type": "number"
@@ -3905,7 +3905,7 @@ export const AGL_API_SURFACE: plugins.flexTypedRequest.IFlexTypedRequestSurface
3905
3905
  ],
3906
3906
  "additionalProperties": false
3907
3907
  },
3908
- "declaration": "interface IControllerStatus {\n packageName: string;\n packageVersion: string;\n protocolVersion: 44;\n upgradeManagementVersion: number;\n controllerPid: number;\n processGroupId: number;\n processFingerprint: string;\n processMode: TControllerProcessMode;\n lifecycleState: TControllerLifecycleState;\n startedAt: number;\n setupRequired: boolean;\n /** Present when the controller runs as the systemd user unit (`agl service install`); absent for a detached or foreground one. */\n serviceUnit?: \"agl-controller.service\";\n /** The TLS mode of the runtime configuration the controller runs with; absent until it is resolved. */\n tlsMode?: TControllerTlsMode;\n upgrade?: IControllerUpgradeStatus;\n /** AGL's inference proxy, by state only; details need a signed-in owner (`controller.proxy.status.get`). */\n proxy?: { state: TControllerProxyState };\n harnesses: Array<{\n harnessId: TControllerSessionHarnessId;\n pid?: number;\n state: TControllerHarnessLifecycleState;\n healthy: boolean;\n version?: string;\n supportsLocalAttachments?: boolean;\n connectionMode?: 'local' | 'shared' | 'remote';\n diagnostic?: IControllerCodexDiagnostic;\n /** Codex only: automatic reconnection of this entry's profile to the local shared app-server. */\n rejoin?: IControllerCodexRejoinStatus;\n /**\n * Codex only, on the controller status: every connected or required profile. The entry's\n * `healthy` is then true only when each of them is, apart from a profile on an account Codex\n * cannot run on now, listed `stopped` with an `account_unavailable` diagnostic that says why;\n * its other members describe the default profile.\n */\n profiles?: IControllerCodexProfileStatus[];\n }>;\n}"
3908
+ "declaration": "interface IControllerStatus {\n packageName: string;\n packageVersion: string;\n protocolVersion: 45;\n upgradeManagementVersion: number;\n controllerPid: number;\n processGroupId: number;\n processFingerprint: string;\n processMode: TControllerProcessMode;\n lifecycleState: TControllerLifecycleState;\n startedAt: number;\n setupRequired: boolean;\n /** Present when the controller runs as the systemd user unit (`agl service install`); absent for a detached or foreground one. */\n serviceUnit?: \"agl-controller.service\";\n /** The TLS mode of the runtime configuration the controller runs with; absent until it is resolved. */\n tlsMode?: TControllerTlsMode;\n upgrade?: IControllerUpgradeStatus;\n /** AGL's inference proxy, by state only; details need a signed-in owner (`controller.proxy.status.get`). */\n proxy?: { state: TControllerProxyState };\n harnesses: Array<{\n harnessId: TControllerSessionHarnessId;\n pid?: number;\n state: TControllerHarnessLifecycleState;\n healthy: boolean;\n version?: string;\n supportsLocalAttachments?: boolean;\n connectionMode?: 'local' | 'shared' | 'remote';\n diagnostic?: IControllerCodexDiagnostic;\n /** Codex only: automatic reconnection of this entry's profile to the local shared app-server. */\n rejoin?: IControllerCodexRejoinStatus;\n /**\n * Codex only, on the controller status: every connected or required profile. The entry's\n * `healthy` is then true only when each of them is, apart from a profile on an account Codex\n * cannot run on now, listed `stopped` with an `account_unavailable` diagnostic that says why;\n * its other members describe the default profile.\n */\n profiles?: IControllerCodexProfileStatus[];\n }>;\n}"
3909
3909
  },
3910
3910
  {
3911
3911
  "name": "IControllerTerminalResource",
@@ -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
  }
@@ -14,7 +14,8 @@ import type {
14
14
  IReq_AuthSwitchSetAccountSpendLimit,
15
15
  IReq_AuthSwitchRecheckUsage, IReq_AuthSwitchMoveClaudeHomeIn, IReq_AuthSwitchMoveClaudeHomeOut,
16
16
  IReq_AuthSwitchRegisterClaudeHome, IReq_AuthSwitchResolveClaudeHome, IReq_AuthSwitchUnregisterClaudeHome,
17
- IReq_AuthSwitchCancelClaudeSignIn, IReq_AuthSwitchGetClaudeSignIn, IReq_AuthSwitchStartClaudeSignIn,
17
+ IAuthSwitchClaudeSignInOperation, IReq_AuthSwitchCancelClaudeSignIn, IReq_AuthSwitchGetClaudeSignIn,
18
+ IReq_AuthSwitchStartClaudeSignIn,
18
19
  IReq_AuthSwitchCodexNativeHandoff, IReq_AuthSwitchCodexNativeHandoffs, IReq_AuthSwitchSwitchCodexNative,
19
20
  IReq_AuthSwitchReleaseCodexNative,
20
21
  } from './authority-contract.js';
@@ -137,6 +138,17 @@ const assertCurrentSnapshot = (snapshot: IAuthSwitchSnapshot): IAuthSwitchSnapsh
137
138
  return snapshot;
138
139
  };
139
140
 
141
+ /**
142
+ * A Claude sign-in carries every member of this release's contract; one without `heldBy`, which names the processes
143
+ * holding a sign-in, comes from a daemon older than this client.
144
+ */
145
+ const assertCurrentClaudeSignIn = (operation: IAuthSwitchClaudeSignInOperation): IAuthSwitchClaudeSignInOperation => {
146
+ if ((operation as Partial<IAuthSwitchClaudeSignInOperation>).heldBy === undefined) {
147
+ throw new AuthSwitchDaemonOutdatedError();
148
+ }
149
+ return operation;
150
+ };
151
+
140
152
  /** A snapshot is usable only while state is current; lastVerifiedAt is the last successful daemon proof. */
141
153
  export interface IAuthSwitchSubscriptionStatus {
142
154
  state: 'current' | 'unavailable' | 'closed';
@@ -628,13 +640,15 @@ export class AuthSwitchClient {
628
640
  /** Starts a Claude sign-in; see `IAuthSwitchClaudeSignInOperation`. The same operation id answers the same sign-in. */
629
641
  public async startClaudeSignIn(operationId: string,
630
642
  signal?: AbortSignal): Promise<IReq_AuthSwitchStartClaudeSignIn['response']['operation']> {
631
- return (await this.request<IReq_AuthSwitchStartClaudeSignIn>('authswitch.authority.claude.signin.start',
632
- { operationId }, 35_000, false, signal)).operation;
643
+ return assertCurrentClaudeSignIn((await this.request<IReq_AuthSwitchStartClaudeSignIn>(
644
+ 'authswitch.authority.claude.signin.start', { operationId }, 35_000, false,
645
+ signal)).operation);
633
646
  }
634
647
  public async getClaudeSignIn(operationId: string,
635
648
  signal?: AbortSignal): Promise<IReq_AuthSwitchGetClaudeSignIn['response']['operation']> {
636
- return (await this.request<IReq_AuthSwitchGetClaudeSignIn>('authswitch.authority.claude.signin.operation',
637
- { operationId }, 35_000, false, signal)).operation;
649
+ return assertCurrentClaudeSignIn((await this.request<IReq_AuthSwitchGetClaudeSignIn>(
650
+ 'authswitch.authority.claude.signin.operation', { operationId }, 35_000, false,
651
+ signal)).operation);
638
652
  }
639
653
  /**
640
654
  * Waits up to `waitMs` for the Claude sign-in to change past `afterRevision`, as `watchOperation` does for a device
@@ -642,13 +656,15 @@ export class AuthSwitchClient {
642
656
  */
643
657
  public async watchClaudeSignIn(operationId: string, afterRevision: number, waitMs = 30_000,
644
658
  signal?: AbortSignal): Promise<IReq_AuthSwitchGetClaudeSignIn['response']['operation']> {
645
- return (await this.request<IReq_AuthSwitchGetClaudeSignIn>('authswitch.authority.claude.signin.operation',
646
- { operationId, afterRevision, waitMs }, waitMs + 5_000, false, signal)).operation;
659
+ return assertCurrentClaudeSignIn((await this.request<IReq_AuthSwitchGetClaudeSignIn>(
660
+ 'authswitch.authority.claude.signin.operation', { operationId, afterRevision, waitMs }, waitMs + 5_000, false,
661
+ signal)).operation);
647
662
  }
648
663
  public async cancelClaudeSignIn(operationId: string,
649
664
  signal?: AbortSignal): Promise<IReq_AuthSwitchCancelClaudeSignIn['response']['operation']> {
650
- return (await this.request<IReq_AuthSwitchCancelClaudeSignIn>('authswitch.authority.claude.signin.cancel',
651
- { operationId }, 35_000, false, signal)).operation;
665
+ return assertCurrentClaudeSignIn((await this.request<IReq_AuthSwitchCancelClaudeSignIn>(
666
+ 'authswitch.authority.claude.signin.cancel', { operationId }, 35_000, false,
667
+ signal)).operation);
652
668
  }
653
669
  /** Registers a Claude home the caller created for a container; see `IReq_AuthSwitchRegisterClaudeHome`. */
654
670
  public async registerClaudeHome(input: Omit<IReq_AuthSwitchRegisterClaudeHome['request'], 'runtime'>,
@@ -189,6 +189,10 @@ export interface IClaudeNativeRunningContext {
189
189
  /** The Agent SDK version whose platform package holds its executable, for an Agent SDK binary only. */
190
190
  agentSdkVersion?: string;
191
191
  env: NodeJS.ProcessEnv | null;
192
+ /**
193
+ * Its working directory as its own root sees it; null when that cannot be resolved -- deleted under it, for one -- so
194
+ * nothing that needs it (a relative home or store, its project settings) can be proven.
195
+ */
192
196
  cwd: string | null;
193
197
  hasSettingsOverride: boolean | null;
194
198
  /** The selected native home as seen through this process's mount namespace. */
@@ -708,10 +712,24 @@ class ClaudeNativeVersionProbe implements IClaudeNativeVersionProbe {
708
712
  }
709
713
  }
710
714
 
715
+ /**
716
+ * The working directory `/proc/<pid>/cwd` names, when that path is the very directory the process works in as its own
717
+ * root sees it; null otherwise, as for a directory deleted under the process, which the link names `<path> (deleted)`.
718
+ */
719
+ const resolvedProcessCwd = (pid: number, root: string): string | null => {
720
+ try {
721
+ const cwd = plugins.fs.readlinkSync(`/proc/${pid}/cwd`);
722
+ return plugins.path.isAbsolute(cwd) && sameDirectory(directoryIdentity(`/proc/${pid}/cwd`),
723
+ directoryIdentity(processViewPath(cwd, root))) ? cwd : null;
724
+ } catch { return null; }
725
+ };
726
+
711
727
  /**
712
728
  * Every Claude Code process of this user, the home it selects and the directory of its credential store, resolved
713
729
  * through its own mount namespace (`/proc/<pid>/root`), so a process in a container that bind-mounts a home is matched to
714
- * that home's inode. A live process whose context cannot be read is listed with unknown fields rather than skipped.
730
+ * that home's inode. A live process whose context cannot be read is listed with unknown fields rather than skipped. A
731
+ * working directory that cannot be resolved leaves `cwd` unknown and only what depends on it: a home or store the
732
+ * process names by an absolute path is still resolved under its root, one named by a relative path is not.
715
733
  */
716
734
  export const scanClaudeRunningContexts = (): readonly IClaudeNativeRunningContext[] => {
717
735
  const contexts: IClaudeNativeRunningContext[] = [];
@@ -738,20 +756,18 @@ export const scanClaudeRunningContexts = (): readonly IClaudeNativeRunningContex
738
756
  }
739
757
  mountNamespace = plugins.fs.readlinkSync(`/proc/${running.pid}/ns/mnt`);
740
758
  env = selectorEnvironment(readProcessContext(`/proc/${running.pid}/environ`));
741
- cwd = plugins.fs.readlinkSync(`/proc/${running.pid}/cwd`);
742
759
  const root = `/proc/${running.pid}/root`;
743
760
  processRoot = root;
744
- if (!plugins.path.isAbsolute(cwd) || !sameDirectory(
745
- directoryIdentity(`/proc/${running.pid}/cwd`),
746
- directoryIdentity(processViewPath(cwd, root)))) {
747
- throw new Error('Claude process working directory cannot be resolved in its mount namespace.');
748
- }
761
+ cwd = resolvedProcessCwd(running.pid, root);
749
762
  // A directory the process names, as it sees it: an absolute path under its own root, a relative one under its cwd.
750
763
  const processDirectory = (selected: string | null, name: string): IClaudeNativeDirectoryIdentity => {
751
764
  if (!selected) throw new Error(`Claude process ${name} is unknown.`);
752
765
  if (plugins.path.isAbsolute(selected) && plugins.path.normalize(selected) !== selected) {
753
766
  throw new Error(`Claude process ${name} path is ambiguous.`);
754
767
  }
768
+ if (!plugins.path.isAbsolute(selected) && cwd === null) {
769
+ throw new Error(`Claude process ${name} is relative to a working directory that cannot be resolved.`);
770
+ }
755
771
  return directoryIdentity(plugins.path.isAbsolute(selected)
756
772
  ? processViewPath(selected, root)
757
773
  // Preserve the proc magic symlink before `..` is resolved by the target's VFS.
@@ -1,6 +1,6 @@
1
1
  import * as plugins from './plugins.js';
2
- import { AuthSwitchRefusal, authSwitchClaudeSignInArguments,
3
- type IAuthSwitchClaudeSignInOperation } from './authority-contract.js';
2
+ import { AuthSwitchRefusal, authSwitchClaudeSignInArguments, authSwitchClaudeSignInHoldersText,
3
+ type IAuthSwitchClaudeSignInHolder, type IAuthSwitchClaudeSignInOperation } from './authority-contract.js';
4
4
  import type { AuthSwitchGrantImport, IAuthSwitchImportClaudeTokens } from './classes.authorityimport.js';
5
5
  import type { IAuthSwitchSecretCodec } from './classes.authoritysecrets.js';
6
6
  import { canonicalContainerDirectory, ClaudeContainerHome, discardContainerPair, inspectClaudeContainerDirectory,
@@ -38,6 +38,20 @@ const isUuid = (value: string): boolean => /^[a-f0-9-]{36}$/.test(value);
38
38
  const shellWord = (value: string): string => `'${value.replaceAll('\'', '\'\\\'\'')}'`;
39
39
  const finished = (state: IAuthSwitchClaudeSignInOperation['state']): boolean =>
40
40
  state !== 'pending' && state !== 'committing';
41
+ /**
42
+ * How much earlier than a sign-in's home a process must have started, by the process table, to be proven older than
43
+ * it: `ps` names the start to the whole second, cut down, from a boot time the kernel also keeps to the whole second, so
44
+ * a process may have started up to two seconds after the moment the table names. The rest is room for clock slew.
45
+ */
46
+ const startedBeforeMarginMs = 5_000;
47
+ const startedBefore = (startedAt: string | null, madeAtMs: number): boolean => {
48
+ const started = startedAt === null ? Number.NaN : Date.parse(startedAt);
49
+ return Number.isFinite(started) && started + startedBeforeMarginMs <= madeAtMs;
50
+ };
51
+ const sameHolders = (left: readonly IAuthSwitchClaudeSignInHolder[] | null,
52
+ right: readonly IAuthSwitchClaudeSignInHolder[] | null): boolean =>
53
+ left === null || right === null ? left === right : left.length === right.length && left.every((holder, index) =>
54
+ holder.pid === right[index]!.pid && holder.startedAt === right[index]!.startedAt && holder.reason === right[index]!.reason);
41
55
 
42
56
  interface IClaudeSignIn {
43
57
  operation: IAuthSwitchClaudeSignInOperation;
@@ -51,7 +65,8 @@ interface IClaudeSignIn {
51
65
 
52
66
  /**
53
67
  * Claude sign-ins: Claude Code signs in to a home the daemon made for that one sign-in, and the daemon imports the
54
- * login it finds there once no Claude Code process runs on the home any more, then removes the home.
68
+ * login it finds there once no Claude Code process may still run on the home, then removes the home. Until then the
69
+ * sign-in names the processes that hold it (`heldBy`).
55
70
  *
56
71
  * The home is the daemon's own, below a directory of its user that no one else can write, and it is touched only by
57
72
  * descriptor, as a container home is (`ClaudeContainerHome`): a home replaced by a link cannot redirect a read or the
@@ -103,8 +118,9 @@ export class ClaudeSignInAuthority {
103
118
  const directory = plugins.path.join(this.options.directory, operationId);
104
119
  const { parent, leaf } = inspectClaudeContainerDirectory(directory, this.owner);
105
120
  if (leaf !== null) throw new Error('A Claude sign-in home already exists for this operation.');
106
- ClaudeContainerHome.open(directory, parent, { create: true, owner: this.owner }).close();
121
+ // Read before the home is made, so no process that started after it can seem to have started before it.
107
122
  const now = this.now();
123
+ ClaudeContainerHome.open(directory, parent, { create: true, owner: this.owner }).close();
108
124
  const signIn: IClaudeSignIn = {
109
125
  operation: { id: operationId, state: 'pending',
110
126
  // The credential store is pinned to the home as well: one an inherited CLAUDE_SECURESTORAGE_CONFIG_DIR names
@@ -112,6 +128,7 @@ export class ClaudeSignInAuthority {
112
128
  prompt: { flow: 'terminal', directory,
113
129
  command: `CLAUDE_CONFIG_DIR=${shellWord(directory)} CLAUDE_SECURESTORAGE_CONFIG_DIR=${shellWord(directory)} `
114
130
  + `claude ${authSwitchClaudeSignInArguments.join(' ')}` },
131
+ heldBy: null,
115
132
  result: null, error: null, createdAt: new Date(now).toISOString(), updatedAt: new Date(now).toISOString(),
116
133
  expiresAt: new Date(now + signInWindowMs).toISOString(), finishedAt: null, revision: 1 },
117
134
  directory, parent, timer: null, commit: null,
@@ -192,14 +209,13 @@ export class ClaudeSignInAuthority {
192
209
  }, this.pollIntervalMs);
193
210
  }
194
211
 
195
- /** Looks once for the login: imports it when it is there and no Claude Code runs on the home, else waits on. */
212
+ /**
213
+ * Looks once for the login: imports it when it is there and nothing holds it (`holders`), else waits on, keeping in
214
+ * `heldBy` what holds it. At `expiresAt` it ends instead and imports nothing, saying whether a login had arrived and
215
+ * what held it.
216
+ */
196
217
  private poll(signIn: IClaudeSignIn): void {
197
218
  if (this.closed || signIn.operation.state !== 'pending') return;
198
- if (this.now() >= Date.parse(signIn.operation.expiresAt)) {
199
- this.end(signIn, 'failed', 'No Claude login arrived within fifteen minutes, so nothing was added. Start a new '
200
- + 'sign-in.');
201
- return;
202
- }
203
219
  let read: { pair: IClaudeContainerPair; identity: IClaudeContainerDirectoryIdentity | null } | null = null;
204
220
  try {
205
221
  const home = ClaudeContainerHome.open(signIn.directory, signIn.parent, { create: false, owner: this.owner });
@@ -213,29 +229,59 @@ export class ClaudeSignInAuthority {
213
229
  return;
214
230
  }
215
231
  const { pair, identity } = read;
216
- let ready: boolean;
217
- try {
218
- // A process whose home or credential store is the sign-in home, or unknown, may still write the login there.
219
- ready = pair.credentialRaw !== null && identity !== null && !this.runningContexts().some(context =>
220
- context.nativeHomeIdentity === null || context.credentialStoreIdentity === null
221
- || sameContainerDirectory(context.nativeHomeIdentity, identity)
222
- || sameContainerDirectory(context.credentialStoreIdentity, identity));
223
- } catch {
232
+ let holders: IAuthSwitchClaudeSignInHolder[] | null = null;
233
+ if (pair.credentialRaw !== null && identity !== null) {
234
+ try { holders = this.holders(identity, Date.parse(signIn.operation.createdAt)); }
235
+ catch {
236
+ discardContainerPair(pair);
237
+ this.end(signIn, 'failed', 'The authority could not tell whether Claude Code still runs on the sign-in home, '
238
+ + 'so nothing was added. Start a new sign-in.');
239
+ return;
240
+ }
241
+ }
242
+ if (this.now() >= Date.parse(signIn.operation.expiresAt)) {
224
243
  discardContainerPair(pair);
225
- this.end(signIn, 'failed', 'The authority could not tell whether Claude Code still runs on the sign-in home, '
226
- + 'so nothing was added. Start a new sign-in.');
244
+ this.end(signIn, 'failed', holders === null
245
+ ? 'No Claude login arrived within fifteen minutes, so nothing was added. Start a new sign-in.'
246
+ : holders.length > 0
247
+ ? 'A Claude login arrived but was not added: until the sign-in ended, fifteen minutes after it started, these '
248
+ + `Claude Code processes kept it from being imported: ${authSwitchClaudeSignInHoldersText(holders)}. Start a `
249
+ + 'new sign-in once they have ended.'
250
+ : 'A Claude login arrived only as the sign-in ended, fifteen minutes after it started, so it was not added. '
251
+ + 'Start a new sign-in.');
227
252
  return;
228
253
  }
229
- if (!ready) {
254
+ if (holders === null || holders.length > 0) {
230
255
  discardContainerPair(pair);
256
+ if (!sameHolders(signIn.operation.heldBy, holders)) this.change(signIn, { heldBy: holders });
231
257
  this.schedule(signIn);
232
258
  return;
233
259
  }
234
260
  signIn.commit = this.commit(signIn, pair);
235
261
  }
236
262
 
263
+ /**
264
+ * The Claude Code processes that keep a login in the home `identity` from being imported, as the process table lists
265
+ * them: each whose home or credential store is the home, so it may still write the login there, and each whose home
266
+ * or store is unknown, unless it provably started before the home was made at `madeAtMs`. A process's settings are
267
+ * fixed when it starts, and the home's name was random until it was made, so an older process cannot name it.
268
+ */
269
+ private holders(identity: IClaudeContainerDirectoryIdentity, madeAtMs: number): IAuthSwitchClaudeSignInHolder[] {
270
+ const holders: IAuthSwitchClaudeSignInHolder[] = [];
271
+ for (const context of this.runningContexts()) {
272
+ const reason: IAuthSwitchClaudeSignInHolder['reason'] | null =
273
+ (context.nativeHomeIdentity !== null && sameContainerDirectory(context.nativeHomeIdentity, identity))
274
+ || (context.credentialStoreIdentity !== null && sameContainerDirectory(context.credentialStoreIdentity, identity))
275
+ ? 'on_home'
276
+ : (context.nativeHomeIdentity === null || context.credentialStoreIdentity === null)
277
+ && !startedBefore(context.startedAt, madeAtMs) ? 'uninspectable' : null;
278
+ if (reason !== null) holders.push({ pid: context.pid, startedAt: context.startedAt, reason });
279
+ }
280
+ return holders;
281
+ }
282
+
237
283
  private async commit(signIn: IClaudeSignIn, pair: IClaudeContainerPair): Promise<void> {
238
- this.change(signIn, { state: 'committing', prompt: null });
284
+ this.change(signIn, { state: 'committing', prompt: null, heldBy: null });
239
285
  try {
240
286
  const result = await this.grants.importClaudeSignIn({ directory: signIn.directory,
241
287
  credentialRaw: pair.credentialRaw!, configRaw: pair.configRaw });
@@ -262,7 +308,8 @@ export class ClaudeSignInAuthority {
262
308
  signIn.timer = null;
263
309
  try { this.removeHome(signIn.parent, plugins.path.basename(signIn.directory)); }
264
310
  catch { /* The sign-in is over whatever the removal did; the next start removes a home that stayed. */ }
265
- this.change(signIn, { state, prompt: null, error, result, finishedAt: new Date(this.now()).toISOString() });
311
+ this.change(signIn, { state, prompt: null, heldBy: null, error, result,
312
+ finishedAt: new Date(this.now()).toISOString() });
266
313
  }
267
314
 
268
315
  private change(signIn: IClaudeSignIn, patch: Partial<IAuthSwitchClaudeSignInOperation>): void {
@@ -2,6 +2,7 @@ import * as plugins from './plugins.js';
2
2
  import { authSwitchRelease } from './packageidentity.js';
3
3
  import type { AuthSwitchAuthorityBroker } from './classes.authoritybroker.js';
4
4
  import { requireVerifiedCodexServer, verifyManagedCodexContract, type IManagedCodexContract } from './codexcontract.js';
5
+ import { psLstartInstant } from './classes.harnessprocesses.js';
5
6
 
6
7
  export interface IAuthSwitchManagedCodexOptions {
7
8
  /** Private runtime root; only the disposable control socket lives here. */
@@ -71,6 +72,21 @@ const claimsManagedCodexSocket = (argv: readonly Buffer[], socketPath: string):
71
72
  return false;
72
73
  };
73
74
 
75
+ /**
76
+ * Rows of `ps -o pid=,lstart=` run with `TZ=UTC0`: the start is read by the shared `psLstartInstant`, null when it
77
+ * cannot be read (a claimant with a null start fails the inspection).
78
+ */
79
+ export const managedCodexProcessRows = (output: string): Array<{ pid: number; startedAt: string | null }> => {
80
+ const rows: Array<{ pid: number; startedAt: string | null }> = [];
81
+ for (const line of output.split('\n')) {
82
+ const parts = line.trim().split(/\s+/);
83
+ if (parts.length !== 6) continue;
84
+ const pid = Number(parts[0]);
85
+ if (Number.isSafeInteger(pid) && pid > 0) rows.push({ pid, startedAt: psLstartInstant(parts.slice(1, 6)) });
86
+ }
87
+ return rows;
88
+ };
89
+
74
90
  /** Same-user Linux process inspection for a bounded socket set, using exact /proc argv boundaries. */
75
91
  export const observeManagedCodexProcesses = async (
76
92
  socketPaths: readonly string[],
@@ -85,30 +101,28 @@ export const observeManagedCodexProcesses = async (
85
101
  }
86
102
  const uid = process.getuid?.();
87
103
  if (uid === undefined) throw new Error('Managed Codex process owner inspection is unavailable.');
104
+ // ps prints lstart in its TZ; under UTC0 the shared reader takes it as UTC (never Date.parse, which reads local time).
88
105
  const output = await new Promise<string>((resolve, reject) => {
89
106
  plugins.childProcess.execFile('ps', ['-ww', '-u', String(uid), '-o', 'pid=,lstart='],
90
- { timeout: 5_000, maxBuffer: 8 * 1024 * 1024, env: { ...process.env, LC_ALL: 'C' } },
107
+ { timeout: 5_000, maxBuffer: 8 * 1024 * 1024, env: { ...process.env, LC_ALL: 'C', TZ: 'UTC0' } },
91
108
  (error, stdout) => error ? reject(new Error('Managed Codex process inspection failed.')) : resolve(stdout));
92
109
  });
93
- const processes: Array<{ pid: number; startedAt: string; argv: Buffer[] }> = [];
94
- for (const line of output.split('\n')) {
95
- const parts = line.trim().split(/\s+/);
96
- if (parts.length !== 6) continue;
97
- const pid = Number(parts[0]);
98
- const date = Date.parse(parts.slice(1, 6).join(' '));
99
- if (Number.isSafeInteger(pid) && pid > 0 && Number.isFinite(date)) {
100
- const argv = await linuxArgv(pid);
101
- if (argv) processes.push({ pid, startedAt: new Date(date).toISOString(), argv });
102
- }
110
+ const processes: Array<{ pid: number; startedAt: string | null; argv: Buffer[] }> = [];
111
+ for (const row of managedCodexProcessRows(output)) {
112
+ const argv = await linuxArgv(row.pid);
113
+ if (argv) processes.push({ ...row, argv });
103
114
  }
104
115
  const result = new Map<string, TManagedCodexProcessObservation>();
105
116
  for (const socketPath of unique) {
106
117
  const matches = processes.filter(process => claimsManagedCodexSocket(process.argv, socketPath));
107
- result.set(socketPath, matches.length === 0
108
- ? { state: 'absent' }
109
- : matches.length === 1
110
- ? { state: 'observed', pid: matches[0].pid, startedAt: matches[0].startedAt }
111
- : { state: 'multiple_claimants' });
118
+ if (matches.length !== 1) {
119
+ result.set(socketPath, matches.length === 0 ? { state: 'absent' } : { state: 'multiple_claimants' });
120
+ continue;
121
+ }
122
+ const [claimant] = matches;
123
+ // A claimant whose start cannot be read has no identity to compare: the inspection fails, never reads as absent.
124
+ if (claimant.startedAt === null) throw new Error('Managed Codex process inspection failed.');
125
+ result.set(socketPath, { state: 'observed', pid: claimant.pid, startedAt: claimant.startedAt });
112
126
  }
113
127
  return result;
114
128
  };
@@ -41,12 +41,31 @@ const executablePatterns: Record<THarnessBinary, RegExp[]> = {
41
41
  const MAX_COMMAND_LINE = 200;
42
42
  const POSIX_FIELDS = 'pid=,ppid=,uid=,lstart=,comm=,args=';
43
43
 
44
- /** A bounded read of the current user's process table. Never a pattern-based kill list. */
45
- const enumeratePosix = (): IEnumeratedProcess[] => {
46
- const uid = process.getuid!();
47
- const output = plugins.childProcess.execFileSync('ps', ['-u', String(uid), '-o', POSIX_FIELDS], {
48
- encoding: 'utf8', timeout: 5000, maxBuffer: 4 * 1024 * 1024, env: { ...process.env, LC_ALL: 'C' }, windowsHide: true,
49
- });
44
+ const LSTART_WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
45
+ const LSTART_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
46
+
47
+ /**
48
+ * The instant of `ps -o lstart` under `LC_ALL=C` and `TZ=UTC0`: the five tokens `weekday month day hh:mm:ss year`,
49
+ * read as UTC with `Date.UTC`. Never `Date.parse`, which reads them in local time: in the repeated hour of a DST
50
+ * fall-back that dates a process an hour early. Anything that is not exactly that shape, or names no real day, is null.
51
+ */
52
+ export const psLstartInstant = (tokens: readonly string[]): string | null => {
53
+ if (tokens.length !== 5) return null;
54
+ const [weekdayToken, monthToken, dayToken, timeToken, yearToken] = tokens;
55
+ const weekday = LSTART_WEEKDAYS.indexOf(weekdayToken);
56
+ const month = LSTART_MONTHS.indexOf(monthToken);
57
+ const time = /^(\d{2}):(\d{2}):(\d{2})$/.exec(timeToken);
58
+ if (weekday < 0 || month < 0 || !time || !/^\d{1,2}$/.test(dayToken) || !/^\d{4}$/.test(yearToken)) return null;
59
+ const [day, year, hours, minutes, seconds] = [dayToken, yearToken, time[1], time[2], time[3]].map(Number);
60
+ if (hours > 23 || minutes > 59 || seconds > 59) return null;
61
+ const instant = new Date(Date.UTC(year, month, day, hours, minutes, seconds));
62
+ if (instant.getUTCFullYear() !== year || instant.getUTCMonth() !== month || instant.getUTCDate() !== day
63
+ || instant.getUTCDay() !== weekday) return null;
64
+ return instant.toISOString();
65
+ };
66
+
67
+ /** Rows of `ps -o pid=,ppid=,uid=,lstart=,comm=,args=` run with `TZ=UTC0`, for the process owner `uid` only. */
68
+ export const parsePosixProcessTable = (output: string, uid: number): IEnumeratedProcess[] => {
50
69
  const processes: IEnumeratedProcess[] = [];
51
70
  for (const line of output.split('\n')) {
52
71
  // pid ppid uid, then the five fixed tokens of lstart, then comm and the full argument vector.
@@ -54,17 +73,29 @@ const enumeratePosix = (): IEnumeratedProcess[] => {
54
73
  if (parts.length < 9) continue;
55
74
  const [pid, parentPid, owner] = parts.slice(0, 3).map(Number);
56
75
  if (!Number.isInteger(pid) || pid <= 0 || owner !== uid) continue;
57
- const started = Date.parse(parts.slice(3, 8).join(' '));
58
76
  const args = parts.slice(9);
59
77
  processes.push({
60
78
  pid, parentPid: Number.isInteger(parentPid) ? parentPid : null, command: parts[8],
61
79
  executable: args[0] ?? parts[8], commandLine: (args.length ? args.join(' ') : parts[8]).slice(0, MAX_COMMAND_LINE),
62
- startedAt: Number.isFinite(started) ? new Date(started).toISOString() : null,
80
+ startedAt: psLstartInstant(parts.slice(3, 8)),
63
81
  });
64
82
  }
65
83
  return processes;
66
84
  };
67
85
 
86
+ /**
87
+ * A bounded read of the current user's process table. Never a pattern-based kill list. `ps` prints lstart in its
88
+ * `TZ`, so it runs with `TZ=UTC0` and the start is read as UTC (`psLstartInstant`).
89
+ */
90
+ const enumeratePosix = (): IEnumeratedProcess[] => {
91
+ const uid = process.getuid!();
92
+ const output = plugins.childProcess.execFileSync('ps', ['-u', String(uid), '-o', POSIX_FIELDS], {
93
+ encoding: 'utf8', timeout: 5000, maxBuffer: 4 * 1024 * 1024, env: { ...process.env, LC_ALL: 'C', TZ: 'UTC0' },
94
+ windowsHide: true,
95
+ });
96
+ return parsePosixProcessTable(output, uid);
97
+ };
98
+
68
99
  /** Windows reports an image name and a pid, with no owner, parent or start time in this view. */
69
100
  const enumerateWindows = (): IEnumeratedProcess[] => {
70
101
  const output = plugins.childProcess.execFileSync('tasklist', ['/FO', 'CSV', '/NH'], {
@@ -446,7 +446,7 @@ remote agent from `~/.claude/remote/ccd-cli/<version>` (since 2.1.295 a build na
446
446
  number as the command name while argv[0] carries the identity -- and a forked helper can
447
447
  carry the same `comm` while running something else entirely (`ugrep`). `HarnessProcesses`
448
448
  therefore matches argv[0] and those install paths (`ps -u <uid> -o pid=,ppid=,uid=,lstart=,comm=,args=`
449
- under `LC_ALL=C`, filtered on the uid column). It deliberately never matches the rest of the
449
+ under `LC_ALL=C` and `TZ=UTC0`, filtered on the uid column; lstart is read as UTC with `Date.UTC`). It deliberately never matches the rest of the
450
450
  command line, which would count an unrelated process that merely mentions `claude`. The
451
451
  consequence is a known blind spot: an instance started through a wrapper that execs
452
452
  `node <path>/cli.js` has argv[0] `node`, so it is not listed. Listing signals nothing; the
@@ -5,7 +5,7 @@ import type {
5
5
  TControllerBrowserPaintAvailability,
6
6
  TControllerBrowserPaintTermination,
7
7
  } from './browserpaint.js';
8
- export const controllerProtocolVersion = 44 as const;
8
+ export const controllerProtocolVersion = 45 as const;
9
9
  export const controllerUpgradeManagementVersion = 2 as const;
10
10
  export const controllerPackageName = 'agl' as const;
11
11
  /** The systemd user unit `agl service install` writes; a controller it runs reports it in its status. */
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: 'agl',
6
- version: '40.5.0',
6
+ version: '40.5.1',
7
7
  description: 'Agent Gateway Layer for OpenCode, FlexHarness, and Codex sessions'
8
8
  }
@@ -13,5 +13,9 @@ import * as authswitchUsageLimit from '../ts_authswitch/authority-usagelimit.js'
13
13
  import * as authswitchSubscription from '../ts_authswitch/authority-subscription.js';
14
14
  // An API key's month: the spend limit rule and dollar amounts, as the command line states them; it imports nothing.
15
15
  import * as authswitchSpend from '../ts_authswitch/authority-spend.js';
16
+ // The service's contract, which the controller's interfaces load already: the processes holding a Claude sign-in, in
17
+ // the words the command line uses.
18
+ import * as authswitchAuthority from '../ts_authswitch/authority-contract.js';
16
19
 
17
- export { authswitchPreuseText, authswitchSpend, authswitchSubscription, authswitchUsageLimit, authswitchUsageWindows };
20
+ export { authswitchAuthority, authswitchPreuseText, authswitchSpend, authswitchSubscription, authswitchUsageLimit,
21
+ authswitchUsageWindows };
@@ -1163,7 +1163,10 @@ const codexReleaseSlot = (
1163
1163
  const claudeSignInStatusText = (signInArg: interfaces.TControllerClaudeSignIn): string => (
1164
1164
  signInArg.state === 'committing'
1165
1165
  ? 'Adding the account…'
1166
- : 'Waiting for Claude Code to sign in. Run the command in a terminal and follow Claude Code there.'
1166
+ : signInArg.heldBy
1167
+ ? 'Claude Code signed in. The account is added once these Claude Code processes have ended: '
1168
+ + `${plugins.authswitchAuthority.authSwitchClaudeSignInHoldersText(signInArg.heldBy)}.`
1169
+ : 'Waiting for Claude Code to sign in. Run the command in a terminal and follow Claude Code there.'
1167
1170
  );
1168
1171
 
1169
1172
  /** One Claude sign-in in progress, with the command that completes it and a terminal to run it in. */