@zgeoff/atc 2.19.0 → 2.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/package.json +1 -1
  2. package/src/agents/agent-adapter.ts +5 -0
  3. package/src/agents/gateway-adapter.ts +25 -2
  4. package/src/cli.ts +187 -113
  5. package/src/client/collect-agent-picks.ts +5 -2
  6. package/src/client/daemon-client.ts +29 -18
  7. package/src/daemon/build-agent-list.ts +8 -6
  8. package/src/daemon/build-auth-binding.ts +58 -0
  9. package/src/daemon/build-imp-name.ts +11 -0
  10. package/src/daemon/build-imp-provider.ts +25 -2
  11. package/src/daemon/build-payload-hash.ts +4 -3
  12. package/src/daemon/daemon-connection.ts +277 -20
  13. package/src/daemon/daemon-context.ts +8 -0
  14. package/src/daemon/daemon.ts +186 -32
  15. package/src/daemon/find-token-fingerprint.ts +23 -0
  16. package/src/daemon/handshake-throttle.ts +47 -0
  17. package/src/daemon/idempotency-ledger.ts +20 -2
  18. package/src/daemon/imp-port.ts +4 -2
  19. package/src/daemon/imp-provider.ts +24 -19
  20. package/src/daemon/is-allowed-listen-host.ts +63 -0
  21. package/src/daemon/load-listener-tokens.ts +46 -0
  22. package/src/daemon/parse-listen-address.ts +33 -0
  23. package/src/daemon/restore-fleet.ts +2 -1
  24. package/src/daemon/sessions.ts +6 -0
  25. package/src/daemon/start-tcp-listener.ts +165 -0
  26. package/src/federation/build-binding-payload-hash.ts +34 -0
  27. package/src/federation/build-daemon-outdated-error.ts +14 -0
  28. package/src/federation/build-events-filter-hash.ts +16 -0
  29. package/src/federation/build-gateway-error.ts +45 -0
  30. package/src/federation/build-gateway-id.ts +14 -0
  31. package/src/federation/build-gateway-result.ts +31 -0
  32. package/src/federation/build-ruled-value.ts +53 -0
  33. package/src/federation/collect-unruled-id-paths.ts +46 -0
  34. package/src/federation/daemon-caller.ts +473 -0
  35. package/src/federation/daemon-pool.ts +56 -0
  36. package/src/federation/decode-gateway-cursor.ts +73 -0
  37. package/src/federation/encode-gateway-cursor.ts +15 -0
  38. package/src/federation/gateway-error.ts +25 -0
  39. package/src/federation/gateway-store.ts +253 -0
  40. package/src/federation/id-rules.ts +85 -0
  41. package/src/federation/load-gateway-registry.ts +29 -0
  42. package/src/federation/max-events-cursor-bytes.ts +4 -0
  43. package/src/federation/max-registry-daemons.ts +26 -0
  44. package/src/federation/merge-event-pages.ts +228 -0
  45. package/src/federation/open-gateway-caller.ts +55 -0
  46. package/src/federation/parse-gateway-id.ts +28 -0
  47. package/src/federation/parse-gateway-registry.ts +123 -0
  48. package/src/federation/pick-daemon-state.ts +46 -0
  49. package/src/federation/plan-event-reads.ts +54 -0
  50. package/src/federation/read-fleet-events.ts +279 -0
  51. package/src/federation/require-serving-daemon.ts +27 -0
  52. package/src/federation/resolve-daemon-request.ts +59 -0
  53. package/src/federation/routing-caller.ts +450 -0
  54. package/src/federation/types.ts +33 -0
  55. package/src/federation/wait-for-outcome.ts +38 -0
  56. package/src/mcp/answer-rpc-request.ts +14 -1
  57. package/src/mcp/build-tool-list.ts +6 -5
  58. package/src/mcp/mcp-tools.ts +41 -8
  59. package/src/mcp/require-daemon-features.ts +2 -0
  60. package/src/mcp/run-tool.ts +15 -1
  61. package/src/mcp/start-mcp-http-server.ts +53 -9
  62. package/src/mcp/types.ts +8 -1
  63. package/src/protocol/daemon-features.ts +9 -0
  64. package/src/protocol/protocol.ts +2 -0
  65. package/src/protocol/request-param-schemas.ts +6 -0
  66. package/src/run-daemon-id.ts +52 -0
  67. package/src/shared/collect-auth-profiles.ts +122 -0
  68. package/src/shared/collect-gateways.ts +223 -6
  69. package/src/shared/config.ts +32 -4
  70. package/src/shared/find-daemon-record.ts +8 -3
  71. package/src/shared/resolve-auth-profiles.ts +186 -0
  72. package/src/store/run-migrations.ts +77 -0
  73. package/src/store/runtime-auth-binding.ts +110 -0
  74. package/src/store/state-store.ts +202 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "2.19.0",
3
+ "version": "2.22.0",
4
4
  "description": "Terminal control tower for coding-agent sessions",
5
5
  "homepage": "https://github.com/zgeoff/atc#readme",
6
6
  "bugs": "https://github.com/zgeoff/atc/issues",
@@ -1,4 +1,5 @@
1
1
  import type { AdapterEvent } from '../protocol/adapter-event';
2
+ import type { DaemonError } from '../protocol/daemon-error';
2
3
  import type { HookEvent } from '../protocol/hook-event';
3
4
  import type { AgentID } from '../shared/agent-id';
4
5
  import type { AgentSessionID } from '../shared/agent-session-id';
@@ -205,6 +206,10 @@ export interface AgentAdapter {
205
206
  // Absent: the agent runs there as a local spawn plans it, with no files.
206
207
  readonly planGuestSpawn?: (opts: SpawnOptions, guest: GuestPaths) => GuestSpawnPlan | null;
207
208
 
209
+ // The refusal every start of this agent's harness gets, on any target,
210
+ // or null when it may start. Absent: no start is refused.
211
+ readonly findSpawnRefusal?: () => DaemonError | null;
212
+
208
213
  // The command that exits 0 inside a remote host when the agent there can
209
214
  // sign in without a person. Absent: atc runs no check.
210
215
  readonly planAuthCheck?: () => readonly string[];
@@ -1,4 +1,5 @@
1
1
  import type { AdapterEvent } from '../protocol/adapter-event';
2
+ import { DaemonError } from '../protocol/daemon-error';
2
3
  import type { HookEvent } from '../protocol/hook-event';
3
4
  import type { AgentID } from '../shared/agent-id';
4
5
  import type { AgentSessionID } from '../shared/agent-session-id';
@@ -39,7 +40,8 @@ export class GatewayAdapter implements AgentAdapter {
39
40
  readonly id: AgentID;
40
41
 
41
42
  // A headless turn carries the same settings file the terminal spawn does,
42
- // so it reaches this backend rather than the default one.
43
+ // so it reaches this backend rather than the default one. A gateway whose
44
+ // credential comes through impd's broker runs no headless turn.
43
45
  readonly headlessRunner: HeadlessRunner | null;
44
46
 
45
47
  // The CLI's hooks are authoritative; no screen heuristics needed.
@@ -90,7 +92,7 @@ export class GatewayAdapter implements AgentAdapter {
90
92
  this.claude = new ClaudeAdapter(config);
91
93
 
92
94
  this.headlessRunner =
93
- headlessRun === null
95
+ headlessRun === null || gateway.auth !== undefined
94
96
  ? null
95
97
  : makeClaudeHeadlessRunner(headlessRun, {
96
98
  claudeBin: gateway.bin,
@@ -100,6 +102,21 @@ export class GatewayAdapter implements AgentAdapter {
100
102
  });
101
103
  }
102
104
 
105
+ // A gateway whose credential comes through impd's broker never starts
106
+ // until the broker path exists: started without it, the CLI would send
107
+ // whatever credential it holds to the gateway's host.
108
+ findSpawnRefusal(): DaemonError | null {
109
+ if (this.gateway.auth === undefined) {
110
+ return null;
111
+ }
112
+
113
+ return new DaemonError(
114
+ 'auth_target_unsupported',
115
+ `gateway '${this.id}' takes its credential from impd's broker, and brokered credentials are not wired yet`,
116
+ { agent: this.id },
117
+ );
118
+ }
119
+
103
120
  planSpawn(opts: SpawnOptions): SpawnPlan {
104
121
  return {
105
122
  bin: this.gateway.bin,
@@ -142,7 +159,13 @@ export class GatewayAdapter implements AgentAdapter {
142
159
  // explicit flag, so that mode overrides the one the CLI would restore, and
143
160
  // the generated settings file, because
144
161
  // without it the CLI would resume the session against the default backend.
162
+ // A gateway whose credential comes through impd's broker has none, since
163
+ // outside atc the broker never reaches it.
145
164
  buildResumeCommand(cwd: string, agentSessionID: AgentSessionID | undefined): string | null {
165
+ if (this.gateway.auth !== undefined) {
166
+ return null;
167
+ }
168
+
146
169
  const args = [
147
170
  ...this.gateway.args,
148
171
  ...buildRestoreModeArgs(this.gateway.args, this.gateway.settings),
package/src/cli.ts CHANGED
@@ -3,10 +3,23 @@
3
3
  import { defineCommand, runMain } from 'citty';
4
4
  import pkg from '../package.json';
5
5
  import { collectRedirectURIs } from './collect-redirect-uris';
6
+ import { parseListenAddress } from './daemon/parse-listen-address';
6
7
  import { normalizeCLIArgs } from './normalize-cli-args';
7
8
  import { parsePort } from './parse-port';
8
9
  import { getBuild } from './shared/get-build';
9
10
 
11
+ // The flags `atc daemon` takes.
12
+ const DAEMON_ARGS = {
13
+ listen: {
14
+ type: 'string',
15
+ description: 'Serve the client protocol on <host>:<port> too, a loopback or tailnet address',
16
+ },
17
+ 'token-file': {
18
+ type: 'string',
19
+ description: 'File holding the one or two bearer tokens a --listen handshake must present',
20
+ },
21
+ } as const;
22
+
10
23
  const main = defineCommand({
11
24
  meta: {
12
25
  name: 'atc',
@@ -152,122 +165,36 @@ const main = defineCommand({
152
165
  name: 'daemon',
153
166
  description: 'Run the atc daemon in the foreground',
154
167
  },
155
- async run() {
156
- const daemon = await import('./daemon/daemon');
157
- const config = await import('./shared/config');
158
- const claude = await import('./agents/claude-adapter');
159
- const grok = await import('./agents/grok-adapter');
160
- const codex = await import('./agents/codex-adapter');
161
- const gateway = await import('./agents/gateway-adapter');
162
- const headless = await import('./agents/start-claude-headless-run');
163
- const targets = await import('./daemon/build-execution-targets');
164
- const sourceOrder = await import('./sources/build-sources');
165
- const builtinSources = await import('./sources/collect-builtin-sources');
166
- const zoxide = await import('./shared/collect-zoxide-dirs');
167
- const home = await import('./shared/resolve-home-dir');
168
-
169
- // Test harnesses shrink the outbound queue to force overflow
170
- // deterministically; unset means the production default.
171
- const queueBytes = Number(process.env['ATC_QUEUE_BYTES']);
172
- const cfg = config.loadConfig();
173
- const built = targets.buildExecutionTargets(cfg.targets);
174
- const targetErrors = [...cfg.targetErrors, ...built.errors];
175
-
176
- for (const error of targetErrors) {
177
- const line =
178
- error.scope === 'config'
179
- ? `${error.path} cannot be used (${error.problem}: ${error.detail}); every spawn is refused, local ones included, until it is fixed`
180
- : error.problem;
181
-
182
- console.error(`atc daemon: config: ${line}`);
183
- }
184
-
185
- for (const problem of [...cfg.principalErrors, ...cfg.workspaceErrors]) {
186
- console.error(`atc daemon: config: ${problem}`);
187
- }
188
168
 
189
- const sources = sourceOrder.buildSources(
190
- builtinSources.collectBuiltinSources({
191
- roots: cfg.dirs.roots,
192
- githubOwner: cfg.workspaces.githubOwner,
193
- ghBin: 'gh',
194
- homeDir: home.resolveHomeDir(),
195
- collectZoxideDirs: zoxide.collectZoxideDirs,
169
+ // Declared here as well as on `serve`, so the parser skips their
170
+ // values when it looks for a subcommand name.
171
+ args: DAEMON_ARGS,
172
+ default: 'serve',
173
+ subCommands: {
174
+ serve: () =>
175
+ defineCommand({
176
+ meta: {
177
+ name: 'serve',
178
+ description: 'Run the atc daemon in the foreground',
179
+ hidden: true,
180
+ },
181
+ args: DAEMON_ARGS,
182
+ async run(ctx) {
183
+ await runDaemon(ctx.args.listen ?? null, ctx.args['token-file'] ?? null);
184
+ },
196
185
  }),
197
- cfg.workspaces.sources,
198
- );
199
-
200
- for (const id of sources.missing) {
201
- console.error(
202
- `atc daemon: config: workspaces.sources holds '${id}', which this daemon cannot offer; the picker leaves it out`,
203
- );
204
- }
205
-
206
- // Cap on how long a fleet restore waits for one revived session to
207
- // report it has booted before moving to the next. Tests pin it to
208
- // keep timing deterministic; unset means the production default.
209
- const capOverride = Number(process.env['ATC_RESTORE_BOOT_TIMEOUT_MS']);
210
-
211
- const restoreBootTimeoutMs =
212
- Number.isFinite(capOverride) && capOverride >= 0 ? capOverride : 15_000;
213
-
214
- // How long a started session may go without a tap before a message
215
- // to it is refused. Tests pin it to 0 to reach the refusal at once.
216
- const graceOverride = Number(process.env['ATC_TAP_GRACE_MS']);
217
-
218
- const claudeAdapter = new claude.ClaudeAdapter(cfg, headless.startClaudeHeadlessRun);
219
- const grokAdapter = new grok.GrokAdapter(cfg);
220
- const codexAdapter = new codex.CodexAdapter(cfg);
221
-
222
- const gatewayAdapters = cfg.gateways.map(
223
- (entry) => new gateway.GatewayAdapter(entry, cfg, headless.startClaudeHeadlessRun),
224
- );
225
-
226
- let handle: Awaited<ReturnType<typeof daemon.startDaemon>>;
227
-
228
- try {
229
- handle = await daemon.startDaemon({
230
- socketPath: config.daemonSocketPath,
231
- reporterSocketPath: config.socketPath,
232
- eventsSocketPath: config.eventsSocketPath,
233
- build: getBuild(),
234
- adapter: claudeAdapter,
235
- adapters: [claudeAdapter, grokAdapter, codexAdapter, ...gatewayAdapters],
236
- dbPath: config.dbFile,
237
- legacyFleetPath: config.legacyFleetFile,
238
- pidPath: config.daemonPidFile,
239
- hooks: cfg.hooks,
240
- targets: built.targets,
241
- defaultTarget: cfg.defaultTarget,
242
- targetErrors,
243
- principals: cfg.principals,
244
- sources: sources.sources,
245
- gitTransports: cfg.workspaces.gitTransports,
246
- restoreBootTimeoutMs,
247
- ...(Number.isFinite(graceOverride) && graceOverride >= 0
248
- ? { tapGraceMs: graceOverride }
249
- : {}),
250
- ...(Number.isFinite(queueBytes) && queueBytes > 0 ? { queueBytes } : {}),
251
- onQuit: () => process.exit(0),
252
- });
253
- } catch (error) {
254
- // A second daemon on the same state directory refuses to start
255
- // and touches nothing; any other startup failure stays a crash.
256
- if (error instanceof Error && Reflect.get(error, 'code') === 'daemon_locked') {
257
- console.error(error.message);
258
- process.exit(1);
259
- }
260
-
261
- throw error;
262
- }
263
-
264
- process.on('SIGTERM', () => {
265
- void (async () => {
266
- await handle.stop();
186
+ id: () =>
187
+ defineCommand({
188
+ meta: {
189
+ name: 'id',
190
+ description: "Print the running daemon's daemonID",
191
+ },
192
+ async run() {
193
+ const id = await import('./run-daemon-id');
267
194
 
268
- process.exit(0);
269
- })();
270
- });
195
+ await id.runDaemonID(getBuild());
196
+ },
197
+ }),
271
198
  },
272
199
  }),
273
200
  events: () =>
@@ -395,4 +322,151 @@ const main = defineCommand({
395
322
  },
396
323
  });
397
324
 
325
+ // Runs the daemon in the foreground until SIGTERM, with a TCP listener when
326
+ // --listen and --token-file are given.
327
+ async function runDaemon(listenArg: string | null, tokenFile: string | null): Promise<void> {
328
+ if ((listenArg === null) !== (tokenFile === null)) {
329
+ console.error('atc daemon: --listen and --token-file go together');
330
+ process.exit(1);
331
+ }
332
+
333
+ const listen = listenArg === null ? null : parseListenAddress(listenArg);
334
+
335
+ if (listen !== null && !listen.ok) {
336
+ console.error(`atc daemon: ${listen.message}`);
337
+ process.exit(1);
338
+ }
339
+
340
+ const daemon = await import('./daemon/daemon');
341
+ const config = await import('./shared/config');
342
+ const claude = await import('./agents/claude-adapter');
343
+ const grok = await import('./agents/grok-adapter');
344
+ const codex = await import('./agents/codex-adapter');
345
+ const gateway = await import('./agents/gateway-adapter');
346
+ const headless = await import('./agents/start-claude-headless-run');
347
+ const targets = await import('./daemon/build-execution-targets');
348
+ const sourceOrder = await import('./sources/build-sources');
349
+ const builtinSources = await import('./sources/collect-builtin-sources');
350
+ const zoxide = await import('./shared/collect-zoxide-dirs');
351
+ const home = await import('./shared/resolve-home-dir');
352
+
353
+ // Test harnesses shrink the outbound queue to force overflow
354
+ // deterministically; unset means the production default.
355
+ const queueBytes = Number(process.env['ATC_QUEUE_BYTES']);
356
+ const cfg = config.loadConfig();
357
+ const built = targets.buildExecutionTargets(cfg.targets);
358
+ const targetErrors = [...cfg.targetErrors, ...built.errors];
359
+
360
+ for (const error of targetErrors) {
361
+ const line =
362
+ error.scope === 'config'
363
+ ? `${error.path} cannot be used (${error.problem}: ${error.detail}); every spawn is refused, local ones included, until it is fixed`
364
+ : error.problem;
365
+
366
+ console.error(`atc daemon: config: ${line}`);
367
+ }
368
+
369
+ for (const problem of [
370
+ ...cfg.principalErrors,
371
+ ...cfg.workspaceErrors,
372
+ ...cfg.authProfileErrors,
373
+ ...cfg.gatewayErrors,
374
+ ]) {
375
+ console.error(`atc daemon: config: ${problem}`);
376
+ }
377
+
378
+ const sources = sourceOrder.buildSources(
379
+ builtinSources.collectBuiltinSources({
380
+ roots: cfg.dirs.roots,
381
+ githubOwner: cfg.workspaces.githubOwner,
382
+ ghBin: 'gh',
383
+ homeDir: home.resolveHomeDir(),
384
+ collectZoxideDirs: zoxide.collectZoxideDirs,
385
+ }),
386
+ cfg.workspaces.sources,
387
+ );
388
+
389
+ for (const id of sources.missing) {
390
+ console.error(
391
+ `atc daemon: config: workspaces.sources holds '${id}', which this daemon cannot offer; the picker leaves it out`,
392
+ );
393
+ }
394
+
395
+ // Cap on how long a fleet restore waits for one revived session to
396
+ // report it has booted before moving to the next. Tests pin it to
397
+ // keep timing deterministic; unset means the production default.
398
+ const capOverride = Number(process.env['ATC_RESTORE_BOOT_TIMEOUT_MS']);
399
+
400
+ const restoreBootTimeoutMs =
401
+ Number.isFinite(capOverride) && capOverride >= 0 ? capOverride : 15_000;
402
+
403
+ // How long a started session may go without a tap before a message
404
+ // to it is refused. Tests pin it to 0 to reach the refusal at once.
405
+ const graceOverride = Number(process.env['ATC_TAP_GRACE_MS']);
406
+
407
+ const claudeAdapter = new claude.ClaudeAdapter(cfg, headless.startClaudeHeadlessRun);
408
+ const grokAdapter = new grok.GrokAdapter(cfg);
409
+ const codexAdapter = new codex.CodexAdapter(cfg);
410
+
411
+ const gatewayAdapters = cfg.gateways.map(
412
+ (entry) => new gateway.GatewayAdapter(entry, cfg, headless.startClaudeHeadlessRun),
413
+ );
414
+
415
+ let handle: Awaited<ReturnType<typeof daemon.startDaemon>>;
416
+
417
+ try {
418
+ handle = await daemon.startDaemon({
419
+ socketPath: config.daemonSocketPath,
420
+ reporterSocketPath: config.socketPath,
421
+ eventsSocketPath: config.eventsSocketPath,
422
+ build: getBuild(),
423
+ adapter: claudeAdapter,
424
+ adapters: [claudeAdapter, grokAdapter, codexAdapter, ...gatewayAdapters],
425
+ dbPath: config.dbFile,
426
+ legacyFleetPath: config.legacyFleetFile,
427
+ pidPath: config.daemonPidFile,
428
+ hooks: cfg.hooks,
429
+ targets: built.targets,
430
+ defaultTarget: cfg.defaultTarget,
431
+ targetErrors,
432
+ principals: cfg.principals,
433
+ sources: sources.sources,
434
+ gitTransports: cfg.workspaces.gitTransports,
435
+ ...(listen === null || tokenFile === null
436
+ ? {}
437
+ : { listen: { host: listen.host, port: listen.port, tokenFile } }),
438
+ restoreBootTimeoutMs,
439
+ ...(Number.isFinite(graceOverride) && graceOverride >= 0
440
+ ? { tapGraceMs: graceOverride }
441
+ : {}),
442
+ ...(Number.isFinite(queueBytes) && queueBytes > 0 ? { queueBytes } : {}),
443
+ onQuit: () => process.exit(0),
444
+ });
445
+ } catch (error) {
446
+ // A second daemon on the same state directory, or a listener whose
447
+ // address or token file is refused or whose bind fails, stops the start
448
+ // with nothing left held; any other startup failure stays a crash.
449
+ const code: unknown = error instanceof Error ? Reflect.get(error, 'code') : null;
450
+
451
+ if (error instanceof Error && (code === 'daemon_locked' || code === 'listen_refused')) {
452
+ console.error(error.message);
453
+ process.exit(1);
454
+ }
455
+
456
+ throw error;
457
+ }
458
+
459
+ process.on('SIGHUP', () => {
460
+ handle.refreshTokens();
461
+ });
462
+
463
+ process.on('SIGTERM', () => {
464
+ void (async () => {
465
+ await handle.stop();
466
+
467
+ process.exit(0);
468
+ })();
469
+ });
470
+ }
471
+
398
472
  await runMain(main, { rawArgs: normalizeCLIArgs(process.argv.slice(2)) });
@@ -9,7 +9,8 @@ export interface AgentPick {
9
9
  /**
10
10
  * The agent choices the spawn and adopt flows offer, in menu order. An agent
11
11
  * whose configured binary does not resolve is left out, so every row in the
12
- * menu is a session that can start. Resolution follows the rule a spawn
12
+ * menu is a session that can start. A gateway with auth is left out too, since
13
+ * every start of one is refused. Resolution follows the rule a spawn
13
14
  * follows: a bare name comes off PATH, a name carrying a separator is taken
14
15
  * as a path, and either way it has to be executable.
15
16
  */
@@ -22,7 +23,9 @@ export function collectAgentPicks(config: Config): AgentPick[] {
22
23
  { agent: 'claude', label: 'Claude', bin: config.claudeBin },
23
24
  { agent: 'grok', label: 'Grok', bin: config.grokBin },
24
25
  { agent: 'codex', label: 'Codex', bin: config.codexBin },
25
- ...config.gateways.map((g) => ({ agent: g.id, label: g.label, bin: g.bin })),
26
+ ...config.gateways
27
+ .filter((g) => g.auth === undefined)
28
+ .map((g) => ({ agent: g.id, label: g.label, bin: g.bin })),
26
29
  ];
27
30
 
28
31
  return candidates
@@ -1,3 +1,4 @@
1
+ import type { SocketHandler } from 'bun';
1
2
  import type { DaemonChannel } from '../protocol/daemon-channel';
2
3
  import { DaemonError } from '../protocol/daemon-error';
3
4
  import { LineDecoder } from '../protocol/line-decoder';
@@ -34,25 +35,30 @@ export class DaemonClient implements DaemonChannel {
34
35
  // once instead of waiting on a response that can never arrive.
35
36
  private closedReason: string | null = null;
36
37
 
37
- static async open(socketPath: string): Promise<DaemonClient> {
38
+ // Connects to the daemon at a unix socket path, or at a TCP address.
39
+ static async open(
40
+ address: string | { readonly hostname: string; readonly port: number },
41
+ ): Promise<DaemonClient> {
38
42
  const client = new DaemonClient();
39
43
 
40
- const socket = await Bun.connect({
41
- unix: socketPath,
42
- socket: {
43
- data(_s, buf) {
44
- client.applyChunk(buf);
45
- },
46
- drain() {
47
- client.queue?.drain();
48
- },
49
- close() {
50
- client.drainPending('connection closed');
51
- client.onClose();
52
- },
53
- error() {},
44
+ const handlers: SocketHandler = {
45
+ data(_s, buf) {
46
+ client.applyChunk(buf);
54
47
  },
55
- });
48
+ drain() {
49
+ client.queue?.drain();
50
+ },
51
+ close() {
52
+ client.drainPending('connection closed');
53
+ client.onClose();
54
+ },
55
+ error() {},
56
+ };
57
+
58
+ const socket =
59
+ typeof address === 'string'
60
+ ? await Bun.connect({ unix: address, socket: handlers })
61
+ : await Bun.connect({ hostname: address.hostname, port: address.port, socket: handlers });
56
62
 
57
63
  client.socket = socket;
58
64
 
@@ -61,8 +67,13 @@ export class DaemonClient implements DaemonChannel {
61
67
  return client;
62
68
  }
63
69
 
64
- sendHello(build: string): Promise<Readonly<Record<string, unknown>>> {
65
- return this.sendRequest('daemon.hello', { client: build, auth: { scheme: 'none' } });
70
+ // A handshake with a token presents it as a bearer token, which a TCP
71
+ // listener requires.
72
+ sendHello(build: string, token?: string): Promise<Readonly<Record<string, unknown>>> {
73
+ return this.sendRequest('daemon.hello', {
74
+ client: build,
75
+ auth: token === undefined ? { scheme: 'none' } : { scheme: 'bearer', token },
76
+ });
66
77
  }
67
78
 
68
79
  // A request with a principal acts as that principal, within what the
@@ -1,7 +1,8 @@
1
1
  import type { AgentAdapter, SpawnOptionSpec } from '../agents/agent-adapter';
2
2
 
3
3
  interface AgentCapabilities {
4
- // Only an installed agent can start a session.
4
+ // Only an installed agent whose starts are not all refused can start a
5
+ // session.
5
6
  readonly spawn: boolean;
6
7
  readonly readTranscript: boolean;
7
8
 
@@ -51,6 +52,7 @@ export function buildAgentList(
51
52
  return adapters.map((adapter) => {
52
53
  const profile = adapter.profile;
53
54
  const installed = profile === undefined ? false : isInstalled(profile.bin);
55
+ const spawnable = installed && (adapter.findSpawnRefusal?.() ?? null) === null;
54
56
 
55
57
  return {
56
58
  id: adapter.id,
@@ -58,7 +60,7 @@ export function buildAgentList(
58
60
  kind: profile?.kind ?? adapter.id,
59
61
  installed,
60
62
  capabilities: {
61
- spawn: installed,
63
+ spawn: spawnable,
62
64
  readTranscript: adapter.parseTranscriptLine !== undefined,
63
65
  message: adapter.takesMessages,
64
66
  attach: true,
@@ -67,8 +69,8 @@ export function buildAgentList(
67
69
  },
68
70
  models: profile?.models ?? null,
69
71
  spawnOptions: {
70
- model: buildSpawnOptionEntry(profile?.spawnOptions.model, installed),
71
- effort: buildSpawnOptionEntry(profile?.spawnOptions.effort, installed),
72
+ model: buildSpawnOptionEntry(profile?.spawnOptions.model, spawnable),
73
+ effort: buildSpawnOptionEntry(profile?.spawnOptions.effort, spawnable),
72
74
  },
73
75
  };
74
76
  });
@@ -86,9 +88,9 @@ const NO_SPAWN_OPTION: SpawnOptionSpec = {
86
88
 
87
89
  function buildSpawnOptionEntry(
88
90
  spec: SpawnOptionSpec | undefined,
89
- installed: boolean,
91
+ spawnable: boolean,
90
92
  ): SpawnOptionEntry {
91
93
  const resolved = spec ?? NO_SPAWN_OPTION;
92
94
 
93
- return { ...resolved, available: installed && resolved.supported };
95
+ return { ...resolved, available: spawnable && resolved.supported };
94
96
  }
@@ -0,0 +1,58 @@
1
+ import { createHash } from 'node:crypto';
2
+ import type { AgentID } from '../shared/agent-id';
3
+ import type { AuthProfile } from '../shared/collect-auth-profiles';
4
+ import type { GatewayAuth, GatewayConfig } from '../shared/collect-gateways';
5
+ import { resolveAuthProfiles } from '../shared/resolve-auth-profiles';
6
+ import type { AuthProfileProblem, ResolvedAuthSecret } from '../shared/resolve-auth-profiles';
7
+ import { sortJSONKeys } from '../shared/sort-json-keys';
8
+
9
+ /**
10
+ * What a session's runtime is bound to through impd's broker: every
11
+ * profile its gateway's selection reaches, the secrets they need impd to
12
+ * hold with exactly these rules, the placeholder variables in place of a
13
+ * credential, and the endpoint. `hash` covers the secrets and their rules
14
+ * alone, so a change to a profile the binding does not reach leaves it as
15
+ * it was, and a change to one it reaches gives another hash.
16
+ */
17
+ export interface AuthBinding {
18
+ readonly agent: AgentID;
19
+ readonly baseURL: string;
20
+ readonly profiles: readonly string[];
21
+ readonly secrets: readonly ResolvedAuthSecret[];
22
+ readonly placeholderEnv: Readonly<Record<string, string>>;
23
+ readonly hash: string;
24
+ }
25
+
26
+ type AuthGateway = Pick<GatewayConfig, 'id' | 'baseURL'> & { readonly auth: GatewayAuth };
27
+
28
+ /**
29
+ * Plans the binding a gateway's auth asks for against the current auth
30
+ * profiles, or the problem that refuses it. The hash is the SHA-256 of the
31
+ * resolved secrets as canonical JSON, with each list sorted, so the order
32
+ * of the selection or of the config never changes it.
33
+ */
34
+ export function buildAuthBinding(
35
+ gateway: AuthGateway,
36
+ profiles: ReadonlyMap<string, AuthProfile>,
37
+ ): { readonly binding: AuthBinding } | { readonly problem: AuthProfileProblem } {
38
+ const resolution = resolveAuthProfiles(profiles, gateway.auth.profiles);
39
+
40
+ if ('problem' in resolution) {
41
+ return resolution;
42
+ }
43
+
44
+ const secrets = resolution.resolved.secrets;
45
+
46
+ return {
47
+ binding: {
48
+ agent: gateway.id,
49
+ baseURL: gateway.baseURL,
50
+ profiles: resolution.resolved.profiles,
51
+ secrets,
52
+ placeholderEnv: gateway.auth.placeholderEnv,
53
+ hash: createHash('sha256')
54
+ .update(JSON.stringify(sortJSONKeys({ secrets })))
55
+ .digest('hex'),
56
+ },
57
+ };
58
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The imp a host key runs on: the target's imp name prefix and the first
3
+ * 20 letters and digits of the key, which is the atc session id of the
4
+ * session that owns the host.
5
+ */
6
+ export function buildImpName(impPrefix: string, hostKey: string): string {
7
+ return `${impPrefix}${hostKey
8
+ .toLowerCase()
9
+ .replaceAll(/[^a-z0-9]/g, '')
10
+ .slice(0, 20)}`;
11
+ }
@@ -17,8 +17,12 @@ export interface ImpProviderBuild {
17
17
  * so no credential sits in the config. `tokenEnv` holds the name of the
18
18
  * daemon's environment variable that carries the token, read once here.
19
19
  * `tokenFile` holds the path of a file that carries it, read here and again
20
- * before each impd call and connection. `image`, `memoryMib`, `guestDir`,
21
- * and `guestATC` pass through. No provider when `url` is missing, so the
20
+ * before each impd call and connection. `impPrefix` starts every imp name
21
+ * the target builds, `atc-` when absent, and must be a lowercase letter
22
+ * followed by up to 10 lowercase letters, digits or hyphens, so the name
23
+ * it builds with a host key's 20 characters stays within impd's 31. `image`,
24
+ * `memoryMib`, `guestDir`, and `guestATC` pass through. No provider when
25
+ * `url` is missing, so the
22
26
  * target lists and refuses every spawn. Both token options, either one not
23
27
  * a non-empty string, an unset or empty variable, or an empty or unreadable
24
28
  * file is a problem and leaves no provider; a target without either calls
@@ -41,7 +45,17 @@ export function buildImpProvider(
41
45
  return { provider: null, problem: source };
42
46
  }
43
47
 
48
+ const impPrefix = options['impPrefix'];
49
+
50
+ if (impPrefix !== undefined && !isImpPrefix(impPrefix)) {
51
+ return {
52
+ provider: null,
53
+ problem: `target ${JSON.stringify(id)} must give impPrefix as a lowercase letter followed by up to 10 lowercase letters, digits or hyphens, so every imp name it builds is one impd accepts`,
54
+ };
55
+ }
56
+
44
57
  const provider = new ImpProvider(new ImpClientPort({ url, readToken: source.readToken }), {
58
+ ...(impPrefix === undefined ? {} : { impPrefix }),
45
59
  ...pickString(options, 'image'),
46
60
  ...pickString(options, 'guestDir'),
47
61
  ...pickString(options, 'guestATC'),
@@ -105,6 +119,15 @@ function loadTokenSource(
105
119
  return { readToken: () => token };
106
120
  }
107
121
 
122
+ // impd's imp name rule, a lowercase letter then up to 30 lowercase letters,
123
+ // digits or hyphens, with room left for the 20 characters of a host key.
124
+ // It holds no `*`, so the prefix never reads as an imp pattern.
125
+ const IMP_PREFIX = /^[a-z][a-z0-9-]{0,10}$/;
126
+
127
+ function isImpPrefix(value: unknown): value is string {
128
+ return typeof value === 'string' && IMP_PREFIX.test(value);
129
+ }
130
+
108
131
  function pickString(
109
132
  options: Readonly<Record<string, unknown>>,
110
133
  key: 'image' | 'guestDir' | 'guestATC',