agl 40.4.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 (156) hide show
  1. package/changelog.md +33 -0
  2. package/dist_serve/bundle.js +2228 -2177
  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.accountsactions.d.ts +11 -1
  7. package/dist_ts/classes.accountsactions.js +59 -2
  8. package/dist_ts/classes.controller.js +5 -5
  9. package/dist_ts/classes.flexaccountaccess.js +3 -2
  10. package/dist_ts/classes.harnessreleases.d.ts +14 -4
  11. package/dist_ts/classes.harnessreleases.js +28 -4
  12. package/dist_ts/classes.inferencegateway.d.ts +28 -3
  13. package/dist_ts/classes.inferencegateway.js +177 -61
  14. package/dist_ts/classes.inferenceplacement.d.ts +5 -3
  15. package/dist_ts/classes.inferenceplacement.js +2 -2
  16. package/dist_ts/classes.inferencerouter.d.ts +349 -91
  17. package/dist_ts/classes.inferencerouter.js +804 -297
  18. package/dist_ts/classes.inferenceservice.d.ts +14 -2
  19. package/dist_ts/classes.inferenceservice.js +53 -14
  20. package/dist_ts/classes.inferencespend.d.ts +339 -0
  21. package/dist_ts/classes.inferencespend.js +815 -0
  22. package/dist_ts/classes.inferenceusagecache.d.ts +8 -0
  23. package/dist_ts/classes.inferenceusagecache.js +9 -5
  24. package/dist_ts/classes.inferenceusagetap.d.ts +50 -9
  25. package/dist_ts/classes.inferenceusagetap.js +185 -31
  26. package/dist_ts/classes.proxycontrol.d.ts +3 -2
  27. package/dist_ts/classes.proxycontrol.js +1 -2
  28. package/dist_ts/classes.proxydaemon.js +3 -2
  29. package/dist_ts/classes.proxystore.d.ts +6 -0
  30. package/dist_ts/classes.proxystore.js +11 -2
  31. package/dist_ts/fleet/classes.claudeworkers.d.ts +5 -4
  32. package/dist_ts/fleet/classes.claudeworkers.js +10 -9
  33. package/dist_ts/functions.claudecoderelease.d.ts +12 -6
  34. package/dist_ts/functions.claudecoderelease.js +13 -7
  35. package/dist_ts/functions.inferencecli.js +12 -8
  36. package/dist_ts/interfaces.auth.d.ts +1 -1
  37. package/dist_ts/interfaces.auth.js +3 -1
  38. package/dist_ts/plugins.d.ts +3 -1
  39. package/dist_ts/plugins.js +7 -2
  40. package/dist_ts_authswitch/authority-apikeys.d.ts +32 -0
  41. package/dist_ts_authswitch/authority-apikeys.js +65 -0
  42. package/dist_ts_authswitch/authority-contract.d.ts +172 -33
  43. package/dist_ts_authswitch/authority-contract.js +54 -1
  44. package/dist_ts_authswitch/authority-overview.js +8 -1
  45. package/dist_ts_authswitch/authority-placement.d.ts +7 -2
  46. package/dist_ts_authswitch/authority-placement.js +10 -3
  47. package/dist_ts_authswitch/authority-runtime-contract.d.ts +2 -2
  48. package/dist_ts_authswitch/authority-spend.d.ts +103 -0
  49. package/dist_ts_authswitch/authority-spend.js +110 -0
  50. package/dist_ts_authswitch/authority-subscription.d.ts +12 -1
  51. package/dist_ts_authswitch/authority-subscription.js +24 -1
  52. package/dist_ts_authswitch/authority-usagelimit.d.ts +7 -4
  53. package/dist_ts_authswitch/authority-usagelimit.js +89 -39
  54. package/dist_ts_authswitch/authority-usagewindows.d.ts +4 -1
  55. package/dist_ts_authswitch/authority-usagewindows.js +41 -16
  56. package/dist_ts_authswitch/classes.authoritybroker.d.ts +30 -19
  57. package/dist_ts_authswitch/classes.authoritybroker.js +152 -61
  58. package/dist_ts_authswitch/classes.authoritycli.d.ts +9 -4
  59. package/dist_ts_authswitch/classes.authoritycli.js +99 -30
  60. package/dist_ts_authswitch/classes.authorityclient.d.ts +24 -7
  61. package/dist_ts_authswitch/classes.authorityclient.js +45 -15
  62. package/dist_ts_authswitch/classes.authoritydaemon.js +39 -12
  63. package/dist_ts_authswitch/classes.authoritydatabase.d.ts +28 -5
  64. package/dist_ts_authswitch/classes.authoritydatabase.js +171 -9
  65. package/dist_ts_authswitch/classes.authorityimport.js +2 -2
  66. package/dist_ts_authswitch/classes.authoritymodels.d.ts +71 -3
  67. package/dist_ts_authswitch/classes.authoritymodels.js +249 -20
  68. package/dist_ts_authswitch/classes.authorityregistry.js +4 -2
  69. package/dist_ts_authswitch/classes.authorityusage.d.ts +69 -7
  70. package/dist_ts_authswitch/classes.authorityusage.js +118 -4
  71. package/dist_ts_authswitch/classes.claudenative.d.ts +23 -8
  72. package/dist_ts_authswitch/classes.claudenative.js +49 -21
  73. package/dist_ts_authswitch/classes.claudesignin.d.ts +14 -2
  74. package/dist_ts_authswitch/classes.claudesignin.js +72 -23
  75. package/dist_ts_authswitch/classes.codexmanaged.d.ts +8 -0
  76. package/dist_ts_authswitch/classes.codexmanaged.js +33 -18
  77. package/dist_ts_authswitch/classes.harnessprocesses.d.ts +8 -0
  78. package/dist_ts_authswitch/classes.harnessprocesses.js +41 -9
  79. package/dist_ts_authswitch/testsupport.d.ts +24 -5
  80. package/dist_ts_authswitch/testsupport.js +56 -12
  81. package/dist_ts_authswitch/ts_migration/0011_account_api_key_fields.d.ts +12 -0
  82. package/dist_ts_authswitch/ts_migration/0011_account_api_key_fields.js +21 -0
  83. package/dist_ts_authswitch/ts_migration/index.js +3 -1
  84. package/dist_ts_interfaces/functions.authorityscope.d.ts +18 -7
  85. package/dist_ts_interfaces/functions.authorityscope.js +35 -8
  86. package/dist_ts_interfaces/interfaces.d.ts +1 -1
  87. package/dist_ts_interfaces/interfaces.js +1 -1
  88. package/dist_ts_interfaces/plugins.d.ts +2 -1
  89. package/dist_ts_interfaces/plugins.js +7 -4
  90. package/dist_ts_interfaces/requests.accounts.d.ts +56 -0
  91. package/dist_ts_interfaces/requests.accounts.js +11 -1
  92. package/dist_ts_interfaces/requests.d.ts +2 -2
  93. package/dist_ts_interfaces/requests.js +1 -1
  94. package/docs/authswitch.md +75 -12
  95. package/package.json +5 -5
  96. package/readme.md +199 -22
  97. package/ts/00_commitinfo_data.ts +1 -1
  98. package/ts/aglapi/aglapisurface.document.ts +2 -2
  99. package/ts/classes.accountsactions.ts +75 -1
  100. package/ts/classes.controller.ts +4 -4
  101. package/ts/classes.flexaccountaccess.ts +2 -1
  102. package/ts/classes.harnessreleases.ts +25 -4
  103. package/ts/classes.inferencegateway.ts +184 -64
  104. package/ts/classes.inferenceplacement.ts +6 -4
  105. package/ts/classes.inferencerouter.ts +1026 -314
  106. package/ts/classes.inferenceservice.ts +56 -13
  107. package/ts/classes.inferencespend.ts +870 -0
  108. package/ts/classes.inferenceusagecache.ts +16 -4
  109. package/ts/classes.inferenceusagetap.ts +159 -34
  110. package/ts/classes.proxycontrol.ts +3 -3
  111. package/ts/classes.proxydaemon.ts +2 -1
  112. package/ts/classes.proxystore.ts +11 -1
  113. package/ts/fleet/classes.claudeworkers.ts +9 -8
  114. package/ts/functions.claudecoderelease.ts +13 -6
  115. package/ts/functions.inferencecli.ts +11 -7
  116. package/ts/interfaces.auth.ts +2 -0
  117. package/ts/plugins.ts +7 -0
  118. package/ts_authswitch/authority-apikeys.ts +85 -0
  119. package/ts_authswitch/authority-contract.ts +201 -34
  120. package/ts_authswitch/authority-overview.ts +7 -0
  121. package/ts_authswitch/authority-placement.ts +12 -4
  122. package/ts_authswitch/authority-runtime-contract.ts +2 -2
  123. package/ts_authswitch/authority-spend.ts +168 -0
  124. package/ts_authswitch/authority-subscription.ts +28 -1
  125. package/ts_authswitch/authority-usagelimit.ts +95 -40
  126. package/ts_authswitch/authority-usagewindows.ts +42 -17
  127. package/ts_authswitch/classes.authoritybroker.ts +161 -68
  128. package/ts_authswitch/classes.authoritycli.ts +112 -32
  129. package/ts_authswitch/classes.authorityclient.ts +66 -19
  130. package/ts_authswitch/classes.authoritydaemon.ts +43 -13
  131. package/ts_authswitch/classes.authoritydatabase.ts +178 -8
  132. package/ts_authswitch/classes.authorityimport.ts +1 -1
  133. package/ts_authswitch/classes.authoritymodels.ts +165 -19
  134. package/ts_authswitch/classes.authorityregistry.ts +3 -0
  135. package/ts_authswitch/classes.authorityusage.ts +171 -10
  136. package/ts_authswitch/classes.claudenative.ts +51 -22
  137. package/ts_authswitch/classes.claudesignin.ts +74 -24
  138. package/ts_authswitch/classes.codexmanaged.ts +30 -16
  139. package/ts_authswitch/classes.harnessprocesses.ts +39 -8
  140. package/ts_authswitch/native-contracts.md +82 -21
  141. package/ts_authswitch/readme.md +1 -1
  142. package/ts_authswitch/testsupport.ts +58 -11
  143. package/ts_authswitch/ts_migration/0011_account_api_key_fields.ts +20 -0
  144. package/ts_authswitch/ts_migration/index.ts +2 -0
  145. package/ts_interfaces/functions.authorityscope.ts +43 -8
  146. package/ts_interfaces/interfaces.ts +1 -1
  147. package/ts_interfaces/plugins.ts +6 -3
  148. package/ts_interfaces/requests.accounts.ts +48 -0
  149. package/ts_interfaces/requests.ts +4 -0
  150. package/ts_web/00_commitinfo_data.ts +1 -1
  151. package/ts_web/accounts.plugins.ts +7 -1
  152. package/ts_web/accounts.templates.ts +20 -11
  153. package/ts_web/classes.accountsreadmodel.ts +326 -71
  154. package/ts_web/elements.accounts.ts +219 -20
  155. package/ts_web/elements.codexconnections.ts +3 -1
  156. package/ts_web/plugins.ts +4 -2
@@ -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'], {
@@ -312,10 +312,11 @@ custody item, so it is on `CLAUDE_NATIVE_CUSTODY_RELEASES`:
312
312
  - Logout `performLogout` (@34545518) clears two account caches more than 2.1.282's, `metricsStatusCache` and
313
313
  `metricsStatusCacheByPrincipal`. Both are now on `CLAUDE_NATIVE_ACCOUNT_CACHES`.
314
314
 
315
- 2.1.288 is not on `CLAUDE_NATIVE_VERIFIED_RELEASES`. The newest entry of that list is the fleet's floor
316
- (`claudeCodeVerifiedRelease`), and the fleet items of the check need Claude Code run. The fleet never runs the SDK's
317
- binary, and the usage read already takes 2.1.288 as 2.1.282. The custody list names releases, so a natively installed
318
- 2.1.288 is admitted like the SDK's build; only the SDK's `linux-x64` binary was read. The SDK's binary is named through
315
+ Its lock table, live pickup and selector are the usage read's items too, so 2.1.288 is on
316
+ `CLAUDE_NATIVE_VERIFIED_RELEASES` (AGL-686) for the usage read only. The fleet never runs the SDK's binary, so its fleet
317
+ items were never confirmed: 2.1.288 is never the fleet's floor (`claudeCodeFleetFloor`) or the verified release
318
+ (`claudeCodeVerifiedRelease`). Both lists name releases, so a natively installed 2.1.288 is admitted like the SDK's
319
+ build; only the SDK's `linux-x64` binary was read. The SDK's binary is named through
319
320
  `CLAUDE_NATIVE_AGENT_SDK_RELEASES` (SDK 0.3.288 bundles 2.1.288), see process detection below.
320
321
 
321
322
  Claude Code 2.1.306 (AGL-686, read 2026-10-22). Linux ELF bundle `~/.local/share/claude/versions/2.1.306`, BuildID
@@ -407,6 +408,11 @@ process to a home by both directories (`claudeNativeHomeRelation`):
407
408
  as `override`.
408
409
  - A process whose home or store cannot be resolved is refused as uninspectable (`running_session` in the usage read).
409
410
  - The container-home and sign-in checks count a process whose store is their home.
411
+ - The usage read requires the accounts service's own environment to keep its store on the home as well
412
+ (`assertNativeHome` applies `claudeNativeCredentialStoreDirectory` to it): a store elsewhere is refused as `override`.
413
+ - A sign-in pins the variable to its home, in the command it prints and when `account add claude` starts Claude Code.
414
+ Inherited, the variable would send the new login to another store, over the login that store holds, while the sign-in
415
+ home never gets a `.credentials.json` and the sign-in expires.
410
416
 
411
417
  Which credential a 2.1.282 process uses (`jc` @16151504, `Cc` @16150518): `ANTHROPIC_AUTH_TOKEN` and `apiKeyHelper`
412
418
  outrank the stored claude.ai login and disable it for requests, though the stored login is still read (`dF`) and side
@@ -423,10 +429,12 @@ Until 38.0.0 a switch of an adopted home proved an installed release above the 2
423
429
  switch: from 38.0.0 on authswitch never moves a login into Claude Code's own home, and reads an adopted home's login for
424
430
  usage only while no session of such a release runs there.
425
431
 
426
- Newer releases (owner ruling 2026-10-22, AGL-685): the usage read takes a release at or after the newest verified one
427
- (2.1.282) as that one (`claudeNativeReleaseReadsAsVerified`), installed or running, and a remote agent's hashed build
428
- (`2.1.295-8b2928c330c4`) is ordered by its release there. An unlisted release between the floor and the newest verified
429
- one stays refused, since its selector was never read. Custody does not follow: it matches whole build names against
432
+ Newer releases (owner ruling 2026-10-22, AGL-685): the usage read takes a listed release and every release from
433
+ 2.1.282 on as verified (`claudeNativeReleaseReadsAsVerified`, line `CLAUDE_NATIVE_READ_AS_VERIFIED_FROM`, held equal to
434
+ the fleet's floor by a test), installed or running, and a remote agent's hashed build (`2.1.295-8b2928c330c4`) is
435
+ ordered by its release there. The line does not move when a newer release is verified (arbiter ruling 2026-10-23,
436
+ AGL-686). An unlisted release below it (2.1.274-2.1.281) stays refused, since its selector was never read. Custody does
437
+ not follow: it matches whole build names against
430
438
  `CLAUDE_NATIVE_CUSTODY_RELEASES`, so a newer release and every hashed build stay refused until their custody contract is
431
439
  read, and the refusal names each process by pid, start and build. AGL mentions each newer release it meets once to the
432
440
  driver, who files the compatibility check below.
@@ -438,7 +446,7 @@ remote agent from `~/.claude/remote/ccd-cli/<version>` (since 2.1.295 a build na
438
446
  number as the command name while argv[0] carries the identity -- and a forked helper can
439
447
  carry the same `comm` while running something else entirely (`ugrep`). `HarnessProcesses`
440
448
  therefore matches argv[0] and those install paths (`ps -u <uid> -o pid=,ppid=,uid=,lstart=,comm=,args=`
441
- 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
442
450
  command line, which would count an unrelated process that merely mentions `claude`. The
443
451
  consequence is a known blind spot: an instance started through a wrapper that execs
444
452
  `node <path>/cli.js` has argv[0] `node`, so it is not listed. Listing signals nothing; the
@@ -504,17 +512,20 @@ can invalidate the grant. Requests to Claude account endpoints report an HTTP 42
504
512
 
505
513
  ## Claude Code compatibility check
506
514
 
507
- AGL verified Claude Code 2.1.282 (`claudeCodeVerifiedRelease` in `ts/functions.claudecoderelease.ts`, the newest of
508
- `CLAUDE_NATIVE_VERIFIED_RELEASES`). It runs a newer release optimistically -- fleet workers, Claude Code sessions in the
509
- session feed and the usage read -- refuses an older one in the fleet, and keeps custody (handing Claude Code's own
515
+ AGL verified Claude Code 2.1.306 (`claudeCodeVerifiedRelease` in `ts/functions.claudecoderelease.ts`, on
516
+ `CLAUDE_NATIVE_VERIFIED_RELEASES`; 2.1.282 before it). Its fleet's floor is a separate constant, `claudeCodeFleetFloor`
517
+ (2.1.282): the oldest release whose fleet output the tests still pin. It rises only when the adapter stops handling an
518
+ older release's output, never because a newer release was verified (arbiter ruling 2026-10-23, AGL-686). AGL runs a
519
+ release newer than the verified one optimistically -- fleet workers, Claude Code sessions in the session feed and the
520
+ usage read -- refuses one older than the floor in the fleet, and keeps custody (handing Claude Code's own
510
521
  sign-in to the accounts service) refused on it until its custody items were read (`CLAUDE_NATIVE_CUSTODY_RELEASES`, which
511
522
  may take a release before its fleet items are confirmed, as it took 2.1.306). The first time the controller's fleet or configuration probe meets a
512
523
  newer release it records it (`agl_harness_releases`) and publishes one `harness.release.discovered` fleet event to every
513
524
  owner's watch and the built-in Driver, so the driver files a compatibility check for that release on the board. The
514
525
  check works through this list against the release's own bundle (`~/.local/share/claude/versions/<release>`) and
515
- disposable homes, never a credential file. A release that keeps every item moves the verified releases (the controller
516
- constant, `CLAUDE_NATIVE_VERIFIED_RELEASES`, and `CLAUDE_NATIVE_CUSTODY_RELEASES` for the custody items), with its BuildID
517
- and SHA-256 recorded here and the tests that pin 2.1.282 output moved to it.
526
+ disposable homes, never a credential file. A release that keeps every item moves the verified release (the controller
527
+ constant `claudeCodeVerifiedRelease`, `CLAUDE_NATIVE_VERIFIED_RELEASES`, and `CLAUDE_NATIVE_CUSTODY_RELEASES` for the
528
+ custody items), with its BuildID and SHA-256 recorded here and its output added to the tests beside the floor's.
518
529
 
519
530
  An Agent SDK update gets the same check against the binary its platform package ships, the custody and usage-read items
520
531
  at least, since AGL's own chats run that binary on the controller's home. Record the SDK version and the release it
@@ -542,9 +553,9 @@ are marked:
542
553
  - Relay stream-json: `system`/`init` and `result` (`subtype`, `is_error`, `permission_denials`, `api_error_status`,
543
554
  `terminal_reason`, `errors`).
544
555
  - Transcript growth against the 4 MiB window: the fleet reads a transcript's last 4 MiB and refuses an incremental read
545
- of more as an observation gap. 2.1.306 writes `api-request`, `api-request-shape` and `api-request-blob` records, about
546
- 41 % of the bytes after its first record in a small sample on 2026-10-22; measure the share again and check that the
547
- Agent SDK's chain reader (`getSessionMessages`, used for history) skips record types it does not know.
556
+ of more as an observation gap. Measure what the release adds and how much time and how many turns the window still
557
+ covers, and check that the Agent SDK's chain reader (`getSessionMessages`, used for history) skips record types it does
558
+ not know.
548
559
 
549
560
  The managed policy hook (`ts/configuration/policyhook/claude.ts`): the PreToolUse payload's `tool_name` (`Bash`,
550
561
  `mcp__agl__job_start`), `tool_input.command` and `tool_input.argv`, and exit code 2 as a denial. A renamed `tool_name`,
@@ -587,9 +598,59 @@ offsets, as for 2.1.280-2.1.282 above:
587
598
  "diskless" session (`ee()`, a launch option a hosting server sets; off on the host). A new mode like it must not let a
588
599
  process that has a disk refresh without the lock.
589
600
 
590
- 2.1.306's bundle (AGL-686): its custody and usage-read items were read on 2026-10-22, and its fleet items wait for
591
- transcripts that 2.1.306 processes write under AGL 40.4.0. It is on `CLAUDE_NATIVE_CUSTODY_RELEASES`, not yet on
592
- `CLAUDE_NATIVE_VERIFIED_RELEASES` or the fleet's floor.
601
+ 2.1.306's fleet items (AGL-686, 2026-10-23). Sources: its bundle's `strings -n 6` dump (offsets as in its entry above),
602
+ compared with 2.1.282's after renaming minified names; 56 transcripts 2.1.306 processes wrote on this host (6 sessions,
603
+ 50 subagents, 18,732 records with `version` 2.1.306); and the live controller on AGL 40.4.0, which watched the fleet's two
604
+ 2.1.306 driver workers from 23:35 UTC on 2026-10-22. AGL's own parsers (`parseFleetClaudeTranscript`,
605
+ `fleetClaudeAssistantTexts`, `fleetClaudeCredentialFailures`, `fleetClaudePermissionMode`) and the history projection
606
+ read every one of those records, and each file's 4 MiB window, without an error. Every item holds, so 2.1.306 is the
607
+ verified release and on `CLAUDE_NATIVE_VERIFIED_RELEASES`; the fleet's floor stays 2.1.282. Items marked bundle only
608
+ did not occur on 2.1.306 after the upgrade; the first natural occurrence gets a confirming line here, and one that
609
+ contradicts the bundle read gets a fix ticket:
610
+ - Inventory. `printAgentsJson` (@56964267) is 2.1.282's after renaming. A status goes through `p` (@56963968): `idle`,
611
+ `waiting`, anything else `busy`, so the session registry's `shell` reads `busy`. A pid and a status come from the same
612
+ live session entry, and a job's kind is `background`. The controller read the inventory at every poll after the
613
+ upgrade and published no Claude `worker.observation.failed`.
614
+ - `--bg` and wake output, all 2.1.282's:
615
+ - `backgrounded · <id>[ · <name>]` (`lqe` @31980520);
616
+ - the wake note (@31978597), printed only when the job saved options;
617
+ - the copy note (`xn` @31973173, case `own-options`);
618
+ - the message saved for the job's next start (@31977716);
619
+ - `--mcp-config <configs...>` (@35482194), still variadic;
620
+ - the trust key `hasTrustDialogAccepted`.
621
+
622
+ New: `Couldn't tell whether <flag> is a flag or text here, so nothing was started` (@31974605, exit 1) when no `--bg`
623
+ flag can be stripped. The fleet passes `--bg` first, and exit 1 fails visibly. A wake's text is recorded as
624
+ `origin.kind` `human` with string content (three wakes on 2026-10-22). The output itself is bundle only: the fleet
625
+ started, woke and relayed nothing on 2.1.306 after the upgrade.
626
+ - Held and delivery notices (bundle only). `Held peer message — from …` (@47687878) is unchanged. It is still appended to the session's
627
+ transcript as a `system`/`informational` record with `level: "warning"`, built like the release's other transcript
628
+ notices, and the loggable check `Mw` (@25426540) keeps a system record unless it says `persist:false`. Delivery notices (@47169054) still start
629
+ with `[Cross-session delivery notice]`. The hold now reads `… was held by that session. NOT delivered: …` instead of
630
+ `… held for the recipient user's approval …`. No 2.1.306 session held a message here, so neither text is in a 2.1.306
631
+ transcript yet.
632
+ - SendMessage receipts. A peer send by name answers `{success: true, msg_id, message}` (6 in 2.1.306 transcripts) or
633
+ `{success: false, message}` (1). The caveat (@50880854) now reads `; in that session's inbox, not yet read by its
634
+ Claude — a [Cross-session delivery notice] follows if …`. It sits in the tool result, which the relay check skips. The
635
+ tool's input gained `recipient_kind`; `to` and `message` are unchanged.
636
+ - API error records: `isApiErrorMessage: true` with `error` (9 records, `rate_limit`), built at @29824350. The error list
637
+ (@17985651) is 2.1.282's, `authentication_failed` and `oauth_org_not_allowed` included. `apiErrorStatus` and
638
+ `apiErrorIsTransient` are new beside them.
639
+ - `system`/`turn_duration` records: 342 written. The 15 the two driver workers wrote after the upgrade matched the
640
+ controller's 15 `worker.turn.ended` events one for one.
641
+ - `permission-mode` records (no `version` field; 435, one per turn as before), and `user.origin.kind` `human` and `peer`
642
+ with `origin.body` (4 peer messages). Copies keeping their uuids are bundle only: no copy was seen, and the copy path
643
+ of the dispatcher is 2.1.282's.
644
+ - Relay stream-json (bundle only): `system`/`init` (@33617187), and the `result` fields `permission_denials`, `api_error_status`,
645
+ `terminal_reason`, `errors` and `is_api_error_message` are in the bundle. No relay ran on 2.1.306.
646
+ - Transcript growth. `api-request`, `api-request-shape` and `api-request-blob` records are 27 % of the bytes written after
647
+ each file's first 2.1.306 record (26 of 95 MB, three quarters of it blobs), and 26 % of the two driver workers' 4 MiB
648
+ windows.
649
+ - Those windows still cover 4.5 h and 105 turns, and 3.7 h and 61 turns. Without the new records they would cover 4.9 h
650
+ and 4.2 h; two 2.1.282 drivers' windows covered 2.4 h and 7.8 h.
651
+ - The most written within 2 s was 0.7 MB, far from the 4 MiB gap, and every window held a `permission-mode` record.
652
+ - The Agent SDK's chain reader (`jM` in SDK 0.3.288) keeps only `user`, `assistant`, `progress`, `system` and
653
+ `attachment` records with a `uuid`, so it skips the new records.
593
654
 
594
655
  ## Managed Codex app-server contract (Codex 0.157.0, 2026-09-25)
595
656
 
@@ -8,6 +8,6 @@ Keep the authority in its own process. The controller never owns refresh grants.
8
8
 
9
9
  Native credential files belong to their tools: the authority writes one only through a verified, journaled handoff, and never destroys or overwrites a native credential it has not first proven it can restore. Every operation that stops the owned Codex app-server restarts it on both success and failure. Structured authority state uses the existing NoSQLDB models and versioned migrations.
10
10
 
11
- Tests isolate all harness homes and use injected provider/process adapters. Never run account-mutating tests against the developer's own installations. Module import, CLI help/version and the browser leaves (`authority-contract.ts`, `authority-usagewindows.ts`, `authority-usagelimit.ts`, `authority-usageheaders.ts`, `authority-usagetokens.ts`, `authority-placement.ts`, `authority-subscription.ts`, `preuse-text.ts`) must not initialize a controller or touch credentials. `authority-subscription.ts` holds the one map of what Claude's `subscription_status` values mean (empty until a value is confirmed) and the words of a payment problem and a re-check, which the command line and the Accounts screen share; `classes.authorityusage.ts` holds a payment problem on the usage record (`reportPaymentProblem`) and clears it on a read that finds a changed subscription or a healthy re-check (`recheck`); a problem held without a subscription takes the one the next healthy read finds, and that read clears nothing. `authority-placement.ts` is the one rule of an account's lifecycle against its `lapsesAt` and of the placement score the proxy places new sessions by. `authority-usageheaders.ts` reads the usage a provider's answer headers state and joins it to the authority's own reading; the authority holds such observations in memory and answers every usage read with them (`authswitch.authority.usage.observe`). `classes.authorityusage.ts` owns the one usage-read schedule: each account is read from the provider every 10 minutes on the authority's timer and at once after an inference 429, a failed read keeps the held reading `stale` and waits for `retry-after` or the next slot, and no question waits for a read or an unseal. `authority-usagetokens.ts` (browser-safe) weighs the tokens the proxy reports by Anthropic's published prices, and `classes.authorityusageestimate.ts` carries each Claude window forward between reads with a ratio learned per account and window, in memory only. `authority-usagelimit.ts` is the one rule of whether an account's usage, against its `maxUsagePercent`, lets it take work; the proxy, Flex, the CLI and the Accounts screen all call it rather than restating it.
11
+ Tests isolate all harness homes and use injected provider/process adapters. Never run account-mutating tests against the developer's own installations. Module import, CLI help/version and the browser leaves (`authority-contract.ts`, `authority-spend.ts`, `authority-usagewindows.ts`, `authority-usagelimit.ts`, `authority-usageheaders.ts`, `authority-usagetokens.ts`, `authority-placement.ts`, `authority-subscription.ts`, `preuse-text.ts`) must not initialize a controller or touch credentials. `authority-subscription.ts` holds the one map of what Claude's `subscription_status` values mean (empty until a value is confirmed) and the words of a payment problem and a re-check, which the command line and the Accounts screen share; `classes.authorityusage.ts` holds a payment problem on the usage record (`reportPaymentProblem`) and clears it on a read that finds a changed subscription or a healthy re-check (`recheck`); a problem held without a subscription takes the one the next healthy read finds, and that read clears nothing. `authority-placement.ts` is the one rule of an account's lifecycle against its `lapsesAt` and of the placement score the proxy places new sessions by. `authority-usageheaders.ts` reads the usage a provider's answer headers state and joins it to the authority's own reading; the authority holds such observations in memory and answers every usage read with them (`authswitch.authority.usage.observe`). `classes.authorityusage.ts` owns the one usage-read schedule: each account is read from the provider every 10 minutes on the authority's timer and at once after an inference 429, a failed read keeps the held reading `stale` and waits for `retry-after` or the next slot, and no question waits for a read or an unseal. `authority-usagetokens.ts` (browser-safe) weighs the tokens the proxy reports by Anthropic's published prices, and `classes.authorityusageestimate.ts` carries each Claude window forward between reads with a ratio learned per account and window, in memory only. `authority-usagelimit.ts` is the one rule of whether an account's usage, against its `maxUsagePercent`, lets it take work -- a switch on the reading's `kind`, where an API key's `spend` reading is judged by its spend limit alone; the proxy, Flex, the CLI and the Accounts screen all call it rather than restating it. `authority-spend.ts` (imports nothing) is the calendar month in UTC a key's spend counts in (`authSwitchSpendPeriod`, the one calendar function), the spend reading, the spend limit rule and dollar amounts. `authority-apikeys.ts` holds the key verifier (`authSwitchApiKeyVerifier`, a read of the provider's model list) and the guard against a key of the other provider; `AuthSwitchAuthorityDatabase.recordSpend` / `readSpend` keep the monthly ledger (`authswitch_authority_spend_entries`, `authswitch_authority_spend_periods`), and `classes.authorityusage.ts` answers a key's usage from it and holds a key's payment problem, by cause, on the key's usage record. A sealed key is `{ kind: 'anthropicApiKey' | 'openaiApiKey', apiKey }`, one kind per provider; never add a kind a reader must keep forever.
12
12
 
13
13
  Imported from authswitch 11.4.1, commit `c3fed26`; future fixes and releases belong to AGL. OpenAI adaptations retain their headers and the notices in the repository's `third-party-notices.md` and `openai-codex-license.txt`.
@@ -1,4 +1,5 @@
1
1
  import * as plugins from './plugins.js';
2
+ import { authSwitchApiKeyVerifier } from './authority-apikeys.js';
2
3
  import { AuthSwitchAuthorityDaemon, type IAuthSwitchAuthorityDaemonOptions } from './classes.authoritydaemon.js';
3
4
  import { AuthSwitchOpenAiProvider } from './classes.openaiprovider.js';
4
5
  import { ClaudeAccountStatus } from './classes.claudestatus.js';
@@ -107,9 +108,9 @@ export interface IAuthSwitchFixtureUsage {
107
108
  }
108
109
 
109
110
  /**
110
- * Anthropic's profile, usage and token endpoints for exactly the fixture tokens it knows, its model list for the
111
- * fixture API keys it issued, and ChatGPT's `wham/usage` for the fixture ChatGPT logins; every other request fails with
112
- * an error, so nothing a test starts reaches a real provider.
111
+ * Anthropic's profile, usage and token endpoints for exactly the fixture tokens it knows, Anthropic's and OpenAI's
112
+ * model lists for the fixture API keys it issued, and ChatGPT's `wham/usage` for the fixture ChatGPT logins; every
113
+ * other request fails with an error, so nothing a test starts reaches a real provider.
113
114
  */
114
115
  export class AuthSwitchFakeClaudeProvider {
115
116
  /** Access token -> the account name it belongs to. */
@@ -138,6 +139,27 @@ export class AuthSwitchFakeClaudeProvider {
138
139
  return apiKey;
139
140
  }
140
141
 
142
+ /**
143
+ * The OpenAI API keys OpenAI accepts -> the account name each was issued for, and whether it may read the model list
144
+ * (a restricted key without that permission may not); delete one to revoke it.
145
+ */
146
+ public readonly openAiApiKeys = new Map<string, { name: string; modelRead: boolean }>();
147
+ /** While true, OpenAI's model list answers 503. */
148
+ public openAiModelsDown = false;
149
+ public openAiModelListCalls = 0;
150
+
151
+ /**
152
+ * Issues `name`'s OpenAI API key, which OpenAI accepts from then on, and answers it: `sk-proj-fixture-<name>`, a
153
+ * project key's prefix, so the authority tells it from an Anthropic key as it would a real one. `modelRead: false`
154
+ * issues a restricted key that may not read the model list.
155
+ */
156
+ public issueOpenAiApiKey(name: string, options: { modelRead?: boolean } = {}): string {
157
+ assertFixtureName(name);
158
+ const apiKey = `sk-proj-fixture-${name}`;
159
+ this.openAiApiKeys.set(apiKey, { name, modelRead: options.modelRead ?? true });
160
+ return apiKey;
161
+ }
162
+
141
163
  /** Accepts the access token of `name`'s login at `generation`, and its refresh token for the next generation. */
142
164
  public know(name: string, generation: number): void {
143
165
  assertFixtureName(name);
@@ -168,6 +190,33 @@ export class AuthSwitchFakeClaudeProvider {
168
190
  }
169
191
  return Response.json({ data: [], has_more: false });
170
192
  }
193
+ if (url === 'https://api.openai.com/v1/models') {
194
+ // The model list of an OpenAI API key, which the authority reads to learn whether OpenAI accepts a key.
195
+ this.openAiModelListCalls++;
196
+ if (this.openAiModelsDown) {
197
+ return Response.json({ error: { message: 'overloaded', type: 'server_error' } }, { status: 503 });
198
+ }
199
+ const headers = new Headers(init?.headers);
200
+ // Only the request the authority's verifier sends is a fixture request: anything else is answered as malformed.
201
+ if ((init?.method ?? 'GET') !== 'GET' || init?.redirect !== 'error' || headers.has('x-api-key')
202
+ || headers.has('openai-organization') || headers.has('openai-project')) {
203
+ return Response.json({ error: { message: 'not a fixture request', type: 'invalid_request_error' } },
204
+ { status: 400 });
205
+ }
206
+ const presented = (headers.get('authorization') ?? '').replace(/^Bearer /, '');
207
+ const issued = this.openAiApiKeys.get(presented);
208
+ if (!issued) {
209
+ // OpenAI quotes the key it refused (masked); the fake quotes it whole, so a test proves nothing repeats it.
210
+ return Response.json({ error: { message: `Incorrect API key provided: ${presented}`,
211
+ type: 'invalid_request_error', param: null, code: 'invalid_api_key' } }, { status: 401 });
212
+ }
213
+ if (!issued.modelRead) {
214
+ return Response.json({ error: { message: 'You have insufficient permissions for this operation. Missing scopes:'
215
+ + ' api.model.read.', type: 'invalid_request_error', param: null, code: null } }, { status: 403 });
216
+ }
217
+ return Response.json({ object: 'list', data: [{ id: 'gpt-fixture', object: 'model', created: 1_700_000_000,
218
+ owned_by: 'openai' }] });
219
+ }
171
220
  if (url === 'https://api.anthropic.com/api/oauth/usage') {
172
221
  const token = (new Headers(init?.headers).get('Authorization') ?? '').replace(/^Bearer /, '');
173
222
  const name = this.accessTokens.get(token);
@@ -395,7 +444,7 @@ export const authSwitchSeedClaudeAccount = async (daemon: AuthSwitchAuthorityDae
395
444
  } else {
396
445
  const account: IStoredAuthorityAccount = { id: accountId, providerId: 'anthropic', issuer: 'anthropic',
397
446
  subject: identity.accountUuid, workspaceId: identity.organizationUuid, label: name,
398
- email: identity.emailAddress, plan: 'max', primaryGrantId: loginId, maxUsagePercent: null, reserveLiftUntil: null, lapsesAt: null, removed: false, revision: 1,
447
+ email: identity.emailAddress, plan: 'max', primaryGrantId: loginId, maxUsagePercent: null, reserveLiftUntil: null, lapsesAt: null, spendLimitMicroUsd: null, keyHint: null, removed: false, revision: 1,
399
448
  statusObservedAt: now, updateId };
400
449
  await database.changeAccountAndGrant(updateId, accountId, loginId, () => ({ account, grant }));
401
450
  }
@@ -418,8 +467,8 @@ export const authSwitchSeedClaudeAccount = async (daemon: AuthSwitchAuthorityDae
418
467
  * It serves the management and runtime sockets exactly as the installed authority does, with the Claude native
419
468
  * authority on a fixture host home, container homes confined to `homesDirectory` and Claude sign-ins made in
420
469
  * `claudeSignInDirectory`. Its OpenAI provider, its
421
- * Claude usage reads and its checks of an Anthropic API key go to the fake provider, which refuses everything that is
422
- * not a fixture request, and no Claude Code process is ever seen running on a home.
470
+ * Claude usage reads and its checks of an Anthropic or OpenAI API key go to the fake provider, which refuses everything
471
+ * that is not a fixture request, and no Claude Code process is ever seen running on a home.
423
472
  */
424
473
  export class AuthSwitchTestAuthority {
425
474
  private stopping: Promise<void> | null = null;
@@ -468,9 +517,7 @@ export class AuthSwitchTestAuthority {
468
517
  dataDirectory: plugins.path.join(directory, 'db'), socketPath: databaseSocketPath,
469
518
  authoritySocketPath, runtimeSocketPath,
470
519
  broker: { provider: new AuthSwitchOpenAiProvider({ fetch: provider.fetch }), secrets: plainCodec, proactive: false,
471
- verifyAnthropicApiKey: async (apiKey, signal) => {
472
- await plugins.flexAnthropic.listAnthropicApiKeyModels({ apiKey }, { signal, fetch: provider.fetch });
473
- } },
520
+ verifyApiKey: authSwitchApiKeyVerifier(provider.fetch) },
474
521
  ...claude.options,
475
522
  codexNative: { home: new CodexNativeHome({ codexHome: codexHost.codexHome, executable: codexHost.binary,
476
523
  scope: codexHost.scope }), secrets: plainCodec },
@@ -663,7 +710,7 @@ export class AuthSwitchTestAuthority {
663
710
  await database.changeAccountAndGrant(updateId, account.accountId, account.loginId, () => ({
664
711
  account: { id: account.accountId, providerId: 'anthropic', issuer: 'anthropic', subject: identity.accountUuid,
665
712
  workspaceId: identity.organizationUuid, label: name, email: identity.emailAddress, plan: 'max',
666
- primaryGrantId: account.loginId, maxUsagePercent: null, reserveLiftUntil: null, lapsesAt: null, removed: false, revision: 1, statusObservedAt: now, updateId },
713
+ primaryGrantId: account.loginId, maxUsagePercent: null, reserveLiftUntil: null, lapsesAt: null, spendLimitMicroUsd: null, keyHint: null, removed: false, revision: 1, statusObservedAt: now, updateId },
667
714
  grant: { id: account.loginId, accountId: account.accountId, providerId: 'anthropic', purpose: 'claude_host_native',
668
715
  audience: 'claude-code-host', state: 'native', owner: 'claude_native', grantGeneration: 1, authorizationGeneration: 1,
669
716
  revision: 1, ciphertext: null, accessExpiresAt: null, attemptId: null, retryAt: null, retryCount: 0,
@@ -701,7 +748,7 @@ export class AuthSwitchTestAuthority {
701
748
  const identity = authSwitchFixtureOpenAiIdentity(name);
702
749
  return { id: accountId, providerId: 'openai', issuer: 'openai', subject: identity.userId,
703
750
  workspaceId: identity.chatgptAccountId, label: name, email: identity.email, plan: 'pro', primaryGrantId: loginId,
704
- maxUsagePercent: null, reserveLiftUntil: null, lapsesAt: null, removed: false, revision: 1, statusObservedAt: now, updateId };
751
+ maxUsagePercent: null, reserveLiftUntil: null, lapsesAt: null, spendLimitMicroUsd: null, keyHint: null, removed: false, revision: 1, statusObservedAt: now, updateId };
705
752
  }
706
753
 
707
754
  /**
@@ -0,0 +1,20 @@
1
+ import * as plugins from '../plugins.js';
2
+
3
+ /**
4
+ * Gives every account recorded before API keys carried a spend limit the two members the account record now states.
5
+ *
6
+ * An API key account may carry a monthly spend limit (`spendLimitMicroUsd`) and names how its key ends (`keyHint`);
7
+ * the record states each, or `null` for none. Every account recorded earlier has neither: it gains
8
+ * `spendLimitMicroUsd: null` and `keyHint: null` -- an Anthropic key held from before keeps no hint, since its key is
9
+ * sealed -- and nothing else about it changes, neither its revision nor its update id, since no one changed the
10
+ * account. A record that already carries a member keeps it exactly as it is, which is what makes an interrupted run
11
+ * safe to repeat.
12
+ */
13
+ export const addAccountApiKeyFields = async (db: plugins.nosqldb.SmartdataDb): Promise<void> => {
14
+ const accounts = db.mongoDb.collection('authswitch_authority_accounts');
15
+ for (const member of ['spendLimitMicroUsd', 'keyHint'] as const) {
16
+ const missing = { [member]: { $exists: false } };
17
+ if (!await accounts.findOne(missing)) continue;
18
+ await accounts.updateMany(missing, { $set: { [member]: null } });
19
+ }
20
+ };
@@ -9,6 +9,7 @@ import { addAccountMaxUsage } from './0007_account_max_usage.js';
9
9
  import { addAccountLapse } from './0008_account_lapse.js';
10
10
  import { addAccountReserveLift } from './0009_account_reserve_lift.js';
11
11
  import { prepareCodexHostRelease } from './0010_codex_host_release.js';
12
+ import { addAccountApiKeyFields } from './0011_account_api_key_fields.js';
12
13
 
13
14
  /**
14
15
  * Runs on every start of an existing store, in this fixed order. Each module recognises the published shapes it
@@ -25,4 +26,5 @@ export const runAuthorityMigrations = async (db: plugins.nosqldb.SmartdataDb): P
25
26
  await addAccountLapse(db);
26
27
  await addAccountReserveLift(db);
27
28
  await prepareCodexHostRelease(db);
29
+ await addAccountApiKeyFields(db);
28
30
  };
@@ -147,10 +147,10 @@ export const findAuthorityDaemonLogin = (
147
147
  };
148
148
 
149
149
  /**
150
- * The login AGL's inference proxy binds for an account, and its purpose, like `findAuthorityDaemonLogin`: a
151
- * ChatGPT account's daemon-held `openai_managed` login, or an Anthropic account's daemon-held login -- a Claude
152
- * sign-in (`claude_host_native`), the one that can back a binding when the account holds several, or an API key
153
- * (`anthropic_api_key`).
150
+ * The login AGL's inference proxy binds for an account, and its purpose, like `findAuthorityDaemonLogin`: an OpenAI
151
+ * account's daemon-held login -- a ChatGPT sign-in (`openai_managed`) or an API key (`openai_api_key`) -- or an
152
+ * Anthropic account's daemon-held login -- a Claude sign-in (`claude_host_native`), the one that can back a binding
153
+ * when the account holds several, or an API key (`anthropic_api_key`).
154
154
  */
155
155
  export const findAuthorityGatewayLogin = (
156
156
  projectionArg: IControllerAuthorityProjection,
@@ -159,17 +159,37 @@ export const findAuthorityGatewayLogin = (
159
159
  const snapshot = projectionArg.snapshot;
160
160
  const account = snapshot?.accounts.find((accountArg) => accountArg.id === accountIdArg);
161
161
  if (!snapshot || !account || account.removed) return 'no_account';
162
- if (account.providerId !== 'anthropic') return findAuthorityDaemonLogin(projectionArg, accountIdArg);
162
+ if (account.providerId !== 'anthropic') {
163
+ const managed = findAuthorityDaemonLogin(projectionArg, accountIdArg);
164
+ return managed !== 'no_login' ? managed : snapshot.logins.find((loginArg) => loginArg.accountId === accountIdArg
165
+ && loginArg.purpose === 'openai_api_key' && loginArg.owner === 'daemon') ?? 'no_login';
166
+ }
163
167
  const logins = snapshot.logins.filter((loginArg) => loginArg.accountId === accountIdArg
164
168
  && (loginArg.purpose === 'claude_host_native' || loginArg.purpose === 'anthropic_api_key')
165
169
  && loginArg.owner === 'daemon');
166
170
  return logins.find((loginArg) => loginArg.capabilities.canBind) ?? logins[0] ?? 'no_login';
167
171
  };
168
172
 
173
+ /**
174
+ * The login an API key's answers are reported on (`authswitch.authority.usage.spend`): the key's live gateway login, or
175
+ * once the key was removed the login it had, as the projection publishes removed accounts and their logins too; the
176
+ * accounts service records a removed key's spend. `undefined` when the projection names no API-key login of the account.
177
+ */
178
+ export const findAuthorityApiKeyLogin = (
179
+ projectionArg: IControllerAuthorityProjection,
180
+ accountIdArg: string,
181
+ ): TControllerAccountLogin | undefined => {
182
+ const isKeyLogin = (loginArg: TControllerAccountLogin) => loginArg.accountId === accountIdArg && loginArg.owner === 'daemon'
183
+ && (loginArg.purpose === 'anthropic_api_key' || loginArg.purpose === 'openai_api_key');
184
+ const live = findAuthorityGatewayLogin(projectionArg, accountIdArg);
185
+ if (typeof live !== 'string') return isKeyLogin(live) ? live : undefined;
186
+ return projectionArg.snapshot?.logins.filter(isKeyLogin).at(-1);
187
+ };
188
+
169
189
  /**
170
190
  * A pool of AGL's inference proxy: its provider, and what its accounts authenticate with -- a subscription sign-in
171
- * (a ChatGPT or a Claude account), or an Anthropic API key. Claude Code runs on the Claude subscription pool, Flex's
172
- * Claude models only on the API-key pool.
191
+ * (a ChatGPT or a Claude account), or an API key of the provider. Claude Code runs on the Claude subscription pool,
192
+ * Flex's Claude models only on the Anthropic API-key pool.
173
193
  */
174
194
  export interface IInferencePool {
175
195
  provider: 'openai' | 'anthropic';
@@ -179,7 +199,7 @@ export interface IInferencePool {
179
199
  /**
180
200
  * The pool of AGL's inference proxy an account serves in, or `undefined` for none: an account whose gateway login
181
201
  * (`findAuthorityGatewayLogin`) the authority holds ready, can bind and keeps in no tool's home -- a ChatGPT account
182
- * this host's Codex does not run on, a Claude account, or an Anthropic API key.
202
+ * this host's Codex does not run on, a Claude account, or an Anthropic or OpenAI API key.
183
203
  */
184
204
  export const findInferencePool = (
185
205
  projectionArg: IControllerAuthorityProjection,
@@ -197,6 +217,9 @@ export const findInferencePool = (
197
217
  if (account.providerId === 'anthropic' && login.purpose === 'anthropic_api_key') {
198
218
  return { provider: 'anthropic', credential: 'api_key' };
199
219
  }
220
+ if (account.providerId === 'openai' && login.purpose === 'openai_api_key') {
221
+ return { provider: 'openai', credential: 'api_key' };
222
+ }
200
223
  return undefined;
201
224
  };
202
225
 
@@ -230,6 +253,18 @@ export const isFlexAuthorityConnectionId = (valueArg: unknown): valueArg is stri
230
253
  */
231
254
  export const flexClaudePoolConnectionId = 'flex:proxy:anthropic';
232
255
 
256
+ /**
257
+ * Whether an account serves in the Claude pool Flex's Claude models run on: an Anthropic API key in the proxy's
258
+ * Anthropic API-key pool (`findInferencePool`). An OpenAI key serves the OpenAI API-key pool, which Flex does not use.
259
+ */
260
+ export const isFlexClaudePoolMember = (
261
+ projectionArg: IControllerAuthorityProjection,
262
+ accountIdArg: string,
263
+ ): boolean => {
264
+ const pool = findInferencePool(projectionArg, accountIdArg);
265
+ return pool?.provider === 'anthropic' && pool.credential === 'api_key';
266
+ };
267
+
233
268
  /** Whether a value is a Flex connection id: an authority account's, or the Claude pool's. */
234
269
  export const isFlexConnectionId = (valueArg: unknown): valueArg is string =>
235
270
  valueArg === flexClaudePoolConnectionId || isFlexAuthorityConnectionId(valueArg);
@@ -5,7 +5,7 @@ import type {
5
5
  TControllerBrowserPaintAvailability,
6
6
  TControllerBrowserPaintTermination,
7
7
  } from './browserpaint.js';
8
- export const controllerProtocolVersion = 43 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. */
@@ -2,9 +2,12 @@ import * as typedrequestInterfaces from '@api.global/typedrequest-interfaces';
2
2
  import type * as browserRuntime from '@push.rocks/smartbrowser/runtime';
3
3
  // The account-management contract on its own. Its own doc comment declares it credential-free and
4
4
  // safe for browser code, while the package root is Node-only, so the browser-facing interfaces take
5
- // the subpath rather than the root export. It is a leaf that imports nothing, so the rules it
6
- // publishes -- the account label rule -- are taken from it as values rather than restated.
5
+ // the subpath rather than the root export. Besides types it imports only the model id rule, itself a
6
+ // leaf, so the rules it publishes -- the account label rule -- are taken from it as values rather than
7
+ // restated.
7
8
  import * as authswitchAuthority from '../ts_authswitch/authority-contract.js';
9
+ // An API key's monthly spend: the spend limit rule and dollar amounts, a leaf that imports nothing.
10
+ import * as authswitchSpend from '../ts_authswitch/authority-spend.js';
8
11
  import type {
9
12
  AuthenticationResponseJSON,
10
13
  PublicKeyCredentialCreationOptionsJSON,
@@ -14,7 +17,7 @@ import type {
14
17
 
15
18
  export { typedrequestInterfaces };
16
19
  export type { browserRuntime };
17
- export { authswitchAuthority };
20
+ export { authswitchAuthority, authswitchSpend };
18
21
 
19
22
  export type {
20
23
  AuthenticationResponseJSON,