@zgeoff/atc 2.24.0 → 2.26.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 (36) hide show
  1. package/package.json +1 -1
  2. package/src/agents/agent-adapter.ts +27 -2
  3. package/src/agents/gateway-adapter.ts +272 -25
  4. package/src/client/collect-agent-picks.ts +2 -5
  5. package/src/client/collect-target-picks.ts +5 -0
  6. package/src/client/spawn-picker.ts +15 -1
  7. package/src/daemon/broker-auth-host.ts +25 -0
  8. package/src/daemon/broker-authority-error.ts +2 -1
  9. package/src/daemon/build-agent-list.ts +16 -3
  10. package/src/daemon/build-scoped-context.ts +2 -0
  11. package/src/daemon/build-target-list.ts +5 -0
  12. package/src/daemon/daemon-connection.ts +66 -7
  13. package/src/daemon/daemon-context.ts +12 -0
  14. package/src/daemon/daemon.ts +84 -1
  15. package/src/daemon/execution-provider.ts +50 -2
  16. package/src/daemon/format-log-field.ts +38 -0
  17. package/src/daemon/imp-client-port.ts +71 -23
  18. package/src/daemon/imp-harness.ts +299 -18
  19. package/src/daemon/imp-port.ts +23 -1
  20. package/src/daemon/imp-provider.ts +161 -40
  21. package/src/daemon/local-pty-provider.ts +11 -0
  22. package/src/daemon/make-non-blocking-log.ts +91 -0
  23. package/src/daemon/refusal-log.ts +163 -0
  24. package/src/daemon/restore-fleet.ts +16 -1
  25. package/src/daemon/runtime-auth-binder.ts +1086 -0
  26. package/src/daemon/sessions.ts +565 -25
  27. package/src/daemon/start-tcp-listener.ts +47 -1
  28. package/src/daemon/verify-broker-authority.ts +10 -5
  29. package/src/mcp/mcp-tools.ts +2 -0
  30. package/src/mcp/require-daemon-features.ts +1 -0
  31. package/src/protocol/daemon-features.ts +4 -0
  32. package/src/protocol/protocol.ts +17 -0
  33. package/src/protocol/request-param-schemas.ts +5 -0
  34. package/src/shared/collect-gateways.ts +1 -22
  35. package/src/shared/is-broker-variable.ts +24 -0
  36. package/src/store/state-store.ts +11 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "2.24.0",
3
+ "version": "2.26.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",
@@ -3,6 +3,8 @@ import type { DaemonError } from '../protocol/daemon-error';
3
3
  import type { HookEvent } from '../protocol/hook-event';
4
4
  import type { AgentID } from '../shared/agent-id';
5
5
  import type { AgentSessionID } from '../shared/agent-session-id';
6
+ import type { AuthProfile } from '../shared/collect-auth-profiles';
7
+ import type { GatewayAuth, GatewayConfig } from '../shared/collect-gateways';
6
8
  import type { SessionID } from '../shared/session-id';
7
9
 
8
10
  export interface SpawnOptions {
@@ -35,19 +37,36 @@ export interface SpawnPlan {
35
37
  /**
36
38
  * Where a session on a remote host finds atc: the atc binary inside the
37
39
  * host, null when the host has none, and the folder the session's own
38
- * files unpack into.
40
+ * files unpack into. `auth` is given when the harness takes its credential
41
+ * from impd's broker: the revision of the host's runtime auth binding it
42
+ * launches under, which keys any settings the agent writes for it, and the
43
+ * placeholder variables the harness holds in place of a credential.
39
44
  */
40
45
  export interface GuestPaths {
41
46
  readonly atc: string | null;
42
47
  readonly dir: string;
48
+ readonly auth?: { readonly revision: number; readonly env: Readonly<Record<string, string>> };
49
+ }
50
+
51
+ /**
52
+ * The credential an agent takes from impd's broker instead of holding it:
53
+ * its gateway's endpoint and auth selection, and the auth profiles that
54
+ * selection resolves against.
55
+ */
56
+ export interface AuthSelection {
57
+ readonly gateway: Pick<GatewayConfig, 'id' | 'baseURL'> & { readonly auth: GatewayAuth };
58
+ readonly profiles: ReadonlyMap<string, AuthProfile>;
43
59
  }
44
60
 
45
61
  /**
46
62
  * A spawn on a remote host, with the files the harness reads there, keyed
47
- * by their path inside the session's guest folder.
63
+ * by their path inside the session's guest folder, and the variables the
64
+ * harness process starts with, which no variable the harness inherits
65
+ * overrides.
48
66
  */
49
67
  export interface GuestSpawnPlan extends SpawnPlan {
50
68
  readonly files: Readonly<Record<string, string>>;
69
+ readonly env?: Readonly<Record<string, string>>;
51
70
  }
52
71
 
53
72
  export interface TranscriptToolUse {
@@ -204,8 +223,14 @@ export interface AgentAdapter {
204
223
  // Plans a spawn on a remote host; null when this agent cannot run there,
205
224
  // such as one whose instrumentation needs atc on a host without it.
206
225
  // Absent: the agent runs there as a local spawn plans it, with no files.
226
+ // A plan for a guest whose `auth` is given that cannot launch behind the
227
+ // broker is null.
207
228
  readonly planGuestSpawn?: (opts: SpawnOptions, guest: GuestPaths) => GuestSpawnPlan | null;
208
229
 
230
+ // The credential this agent takes from impd's broker, or null when it
231
+ // takes none. Absent: it takes none.
232
+ readonly findAuthSelection?: () => AuthSelection | null;
233
+
209
234
  // The refusal every start of this agent's harness gets, on any target,
210
235
  // or null when it may start. Absent: no start is refused.
211
236
  readonly findSpawnRefusal?: () => DaemonError | null;
@@ -5,10 +5,16 @@ import type { AgentID } from '../shared/agent-id';
5
5
  import type { AgentSessionID } from '../shared/agent-session-id';
6
6
  import type { GatewayConfig } from '../shared/collect-gateways';
7
7
  import type { Config } from '../shared/config';
8
+ import { isBrokerVariable } from '../shared/is-broker-variable';
9
+ import { isRecord } from '../shared/report';
10
+ import { resolveAuthProfiles } from '../shared/resolve-auth-profiles';
8
11
  import { toShellArg } from '../shared/to-shell-arg';
9
12
  import type {
10
13
  AgentAdapter,
11
14
  AgentProfile,
15
+ AuthSelection,
16
+ GuestPaths,
17
+ GuestSpawnPlan,
12
18
  HeadlessRunner,
13
19
  NameUpdate,
14
20
  ResumeCheck,
@@ -16,10 +22,13 @@ import type {
16
22
  SpawnOptions,
17
23
  SpawnPlan,
18
24
  } from './agent-adapter';
25
+ import { buildATCBridgeFiles } from './build-atc-bridge-files';
19
26
  import { buildClaudeOverrideArgs } from './build-claude-override-args';
27
+ import { buildHookSettings } from './build-hook-settings';
20
28
  import { buildRestoreModeArgs } from './build-restore-mode-args';
21
29
  import { ClaudeAdapter } from './claude-adapter';
22
30
  import { CLAUDE_EFFORT_LEVELS } from './claude-effort-levels';
31
+ import { findClaudePermissionMode } from './find-claude-permission-mode';
23
32
  import { findFlagValue } from './find-flag-value';
24
33
  import { makeClaudeHeadlessRunner } from './make-claude-headless-runner';
25
34
  import type { ClaudeHeadlessRun } from './make-claude-headless-runner';
@@ -59,6 +68,8 @@ export class GatewayAdapter implements AgentAdapter {
59
68
 
60
69
  private readonly gateway: GatewayConfig;
61
70
 
71
+ private readonly config: Config;
72
+
62
73
  private readonly claude: ClaudeAdapter;
63
74
 
64
75
  // Written on first spawn so constructing the adapter touches no state.
@@ -77,6 +88,7 @@ export class GatewayAdapter implements AgentAdapter {
77
88
  ) {
78
89
  this.bridgeTarget = bridgeTarget;
79
90
  this.gateway = gateway;
91
+ this.config = config;
80
92
  this.id = gateway.id;
81
93
 
82
94
  const models = pickModels(gateway.env);
@@ -102,44 +114,131 @@ export class GatewayAdapter implements AgentAdapter {
102
114
  });
103
115
  }
104
116
 
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.
117
+ // A gateway whose credential comes through impd's broker is refused
118
+ // before anything is prepared for it when its placeholders cannot pair
119
+ // with the broker's header, or when its settings env would route the CLI
120
+ // around the broker.
108
121
  findSpawnRefusal(): DaemonError | null {
109
- if (this.gateway.auth === undefined) {
122
+ const auth = this.gateway.auth;
123
+
124
+ if (auth === undefined) {
110
125
  return null;
111
126
  }
112
127
 
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 },
128
+ return (
129
+ this.findPlaceholderRefusal(auth.placeholderEnv, auth.profiles) ??
130
+ this.findSettingsEnvConflict()
117
131
  );
118
132
  }
119
133
 
134
+ findAuthSelection(): AuthSelection | null {
135
+ const auth = this.gateway.auth;
136
+
137
+ if (auth === undefined) {
138
+ return null;
139
+ }
140
+
141
+ return {
142
+ gateway: { id: this.gateway.id, baseURL: this.gateway.baseURL, auth },
143
+ profiles: this.config.authProfiles,
144
+ };
145
+ }
146
+
147
+ // A gateway whose credential comes through impd's broker never starts on
148
+ // the daemon's machine: started without the broker, the CLI would send
149
+ // whatever credential it holds to the gateway's host.
120
150
  planSpawn(opts: SpawnOptions): SpawnPlan {
151
+ if (this.gateway.auth !== undefined) {
152
+ throw this.buildBrokerRefusal('which only an imp target can give it');
153
+ }
154
+
155
+ const modeArgs =
156
+ opts.resume === false ? [] : buildRestoreModeArgs(this.gateway.args, this.gateway.settings);
157
+
121
158
  return {
122
159
  bin: this.gateway.bin,
123
- args: [
124
- ...buildClaudeOverrideArgs(this.gateway.args, opts),
125
- ...(opts.resume === false
126
- ? []
127
- : buildRestoreModeArgs(this.gateway.args, this.gateway.settings)),
128
- '--settings',
129
- this.writeSettings(),
130
- '--plugin-dir',
131
- this.writeBridge(),
132
- ...(opts.resume === true ? ['--resume'] : []),
133
- ...(typeof opts.resume === 'string' ? ['--resume', opts.resume] : []),
134
- ...(opts.prompt === '' ? [] : [opts.prompt]),
135
- ],
160
+ args: this.buildArgs(opts, modeArgs, this.writeSettings(), this.writeBridge()),
136
161
  };
137
162
  }
138
163
 
139
- // The credential helper runs on the daemon's machine, so a gateway
140
- // session never runs on a remote host.
141
- planGuestSpawn(): null {
142
- return null;
164
+ // A gateway whose credential comes through impd's broker runs on a
165
+ // remote host with a settings file of the session's own per binding
166
+ // revision, a Claude config folder of its own that holds no account,
167
+ // and placeholders in place of the credential, which the broker swaps
168
+ // for the real one on the host's side. A shell seeds the config folder
169
+ // before it runs the CLI, since a transferred file would replace the
170
+ // state an earlier run left. Any other gateway's credential
171
+ // helper runs on the daemon's machine, so it never runs remotely.
172
+ planGuestSpawn(opts: SpawnOptions, guest: GuestPaths): GuestSpawnPlan | null {
173
+ if (this.gateway.auth === undefined) {
174
+ return null;
175
+ }
176
+
177
+ if (guest.auth === undefined) {
178
+ throw this.buildBrokerRefusal('and this session has no broker binding');
179
+ }
180
+
181
+ if (guest.atc === null) {
182
+ return null;
183
+ }
184
+
185
+ const refusal =
186
+ this.findPlaceholderRefusal(guest.auth.env, this.gateway.auth.profiles) ??
187
+ this.findSettingsEnvConflict();
188
+
189
+ if (refusal !== null) {
190
+ throw refusal;
191
+ }
192
+
193
+ const argv = [guest.atc];
194
+ const settingsPath = `auth-r${guest.auth.revision}/settings.json`;
195
+
196
+ const bridge = Object.entries(buildATCBridgeFiles(argv)).map(
197
+ ([path, content]): [string, string] => [`atc-bridge/${path}`, content],
198
+ );
199
+
200
+ const settings = buildHookSettings(
201
+ {
202
+ id: this.id,
203
+ env: {
204
+ ...this.gateway.env,
205
+ ANTHROPIC_BASE_URL: this.gateway.baseURL,
206
+ ...guest.auth.env,
207
+ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
208
+ },
209
+ ...(this.gateway.settings === undefined
210
+ ? {}
211
+ : { settings: buildSettingsWithoutHelper(this.gateway.settings) }),
212
+ },
213
+ 0,
214
+ argv,
215
+ );
216
+
217
+ const configDir = `${guest.dir}/${CLAUDE_CONFIG_FOLDER}`;
218
+
219
+ return {
220
+ bin: 'sh',
221
+ args: [
222
+ '-c',
223
+ SEED_CONFIG_SCRIPT,
224
+ 'sh',
225
+ configDir,
226
+ `${guest.dir}/${CONFIG_SEED_FILE}`,
227
+ this.gateway.bin,
228
+ ...this.buildArgs(
229
+ opts,
230
+ this.buildGuestModeArgs(),
231
+ `${guest.dir}/${settingsPath}`,
232
+ `${guest.dir}/atc-bridge`,
233
+ ),
234
+ ],
235
+ files: {
236
+ [settingsPath]: JSON.stringify(settings, null, 2),
237
+ [CONFIG_SEED_FILE]: JSON.stringify(ONBOARDED_CONFIG, null, 2),
238
+ ...Object.fromEntries(bridge),
239
+ },
240
+ env: { CLAUDE_CONFIG_DIR: configDir, ...guest.auth.env },
241
+ };
143
242
  }
144
243
 
145
244
  normalizeHook(e: HookEvent): AdapterEvent {
@@ -179,6 +278,117 @@ export class GatewayAdapter implements AgentAdapter {
179
278
  return `cd ${toShellArg(cwd)} && ${this.gateway.bin}${args} --settings ${settings} --resume${resume}`;
180
279
  }
181
280
 
281
+ private buildArgs(
282
+ opts: SpawnOptions,
283
+ modeArgs: readonly string[],
284
+ settings: string,
285
+ pluginDir: string,
286
+ ): string[] {
287
+ return [
288
+ ...buildClaudeOverrideArgs(this.gateway.args, opts),
289
+ ...modeArgs,
290
+ '--settings',
291
+ settings,
292
+ '--plugin-dir',
293
+ pluginDir,
294
+ ...(opts.resume === true ? ['--resume'] : []),
295
+ ...(typeof opts.resume === 'string' ? ['--resume', opts.resume] : []),
296
+ ...(opts.prompt === '' ? [] : [opts.prompt]),
297
+ ];
298
+ }
299
+
300
+ // A brokered session's Claude config is fresh, so the CLI's own default
301
+ // mode would apply rather than the one the owner's settings set. Every
302
+ // start therefore names its mode: the one the gateway's arguments or
303
+ // settings set, else the CLI's manual mode, which asks before each
304
+ // action.
305
+ private buildGuestModeArgs(): string[] {
306
+ if (findFlagValue(this.gateway.args, ['--permission-mode']) !== null) {
307
+ return [];
308
+ }
309
+
310
+ return [
311
+ '--permission-mode',
312
+ findClaudePermissionMode([], this.gateway.settings) ?? MANUAL_PERMISSION_MODE,
313
+ ];
314
+ }
315
+
316
+ private buildBrokerRefusal(reason: string): DaemonError {
317
+ return new DaemonError(
318
+ 'auth_target_unsupported',
319
+ `gateway '${this.id}' takes its credential from impd's broker, ${reason}`,
320
+ { agent: this.id },
321
+ );
322
+ }
323
+
324
+ // The Claude CLI sends ANTHROPIC_AUTH_TOKEN as a bearer authorization
325
+ // header, the one pairing atc binds, so the placeholders must be that
326
+ // variable alone, holding the placeholder, for a profile whose rule on
327
+ // the base URL's host sets that header. Any other variable would put the
328
+ // placeholder in a header the broker never fills.
329
+ private findPlaceholderRefusal(
330
+ env: Readonly<Record<string, string>>,
331
+ profiles: readonly string[],
332
+ ): DaemonError | null {
333
+ const keys = Object.keys(env);
334
+
335
+ if (keys.length !== 1 || keys[0] !== BEARER_VARIABLE) {
336
+ return this.buildPlaceholderRefusal(
337
+ `needs ${BEARER_VARIABLE} as its only placeholder variable, which the broker fills as a bearer authorization header; it has ${keys.length === 0 ? 'none' : keys.join(', ')}`,
338
+ );
339
+ }
340
+
341
+ if (env[BEARER_VARIABLE] !== PLACEHOLDER) {
342
+ return this.buildPlaceholderRefusal(`needs ${BEARER_VARIABLE} to hold ${PLACEHOLDER}`);
343
+ }
344
+
345
+ const resolution = resolveAuthProfiles(this.config.authProfiles, profiles);
346
+
347
+ const host = new URL(this.gateway.baseURL).hostname;
348
+
349
+ const rule =
350
+ 'resolved' in resolution
351
+ ? resolution.resolved.secrets.flatMap((s) => s.rules).find((r) => r.host === host)
352
+ : undefined;
353
+
354
+ if (rule?.header !== 'authorization' || rule.scheme !== 'bearer') {
355
+ return this.buildPlaceholderRefusal(
356
+ `needs a profile that sets a bearer authorization header for ${host}, the header ${BEARER_VARIABLE} fills`,
357
+ );
358
+ }
359
+
360
+ return null;
361
+ }
362
+
363
+ // The settings file's env block reaches the CLI's own process, so a proxy
364
+ // or CA variable there would route the CLI around the broker.
365
+ private findSettingsEnvConflict(): DaemonError | null {
366
+ const settingsEnv = this.gateway.settings?.['env'];
367
+
368
+ const keys = [
369
+ ...Object.keys(this.gateway.env),
370
+ ...(isRecord(settingsEnv) ? Object.keys(settingsEnv) : []),
371
+ ];
372
+
373
+ const variable = keys.find((key) => isBrokerVariable(key));
374
+
375
+ if (variable === undefined) {
376
+ return null;
377
+ }
378
+
379
+ return new DaemonError(
380
+ 'auth_target_unsupported',
381
+ `gateway '${this.id}' sets ${variable} in its settings env, which would route around impd's broker`,
382
+ { agent: this.id, problem: 'guest_env_conflict', variable },
383
+ );
384
+ }
385
+
386
+ private buildPlaceholderRefusal(problem: string): DaemonError {
387
+ return new DaemonError('auth_placeholder_unsupported', `gateway '${this.id}' ${problem}`, {
388
+ agent: this.id,
389
+ });
390
+ }
391
+
182
392
  private writeSettings(): string {
183
393
  this.settingsFile ??= writeHookSettings({
184
394
  id: this.id,
@@ -199,6 +409,43 @@ export class GatewayAdapter implements AgentAdapter {
199
409
  }
200
410
  }
201
411
 
412
+ // The variable the Claude CLI sends as a bearer authorization header, and
413
+ // the value impd's broker replaces with the credential on the host's side.
414
+ const BEARER_VARIABLE = 'ANTHROPIC_AUTH_TOKEN';
415
+ const PLACEHOLDER = 'imp-broker-placeholder';
416
+
417
+ // The Claude CLI's mode that asks a person before each action.
418
+ const MANUAL_PERMISSION_MODE = 'default';
419
+
420
+ // The Claude config folder a brokered session gets inside its guest
421
+ // folder, so no account or setting of the host's image reaches it.
422
+ const CLAUDE_CONFIG_FOLDER = 'claude-config';
423
+
424
+ // The state the Claude CLI reads from its config folder's `.claude.json`
425
+ // to start without its first-run onboarding. It holds no account: the
426
+ // placeholder credential is what keeps the CLI from asking for a login.
427
+ // It holds no folder trust either, so the CLI asks a person to trust the
428
+ // workspace on its first start.
429
+ const ONBOARDED_CONFIG = { hasCompletedOnboarding: true };
430
+
431
+ // Where that state travels, beside the config folder rather than in it.
432
+ const CONFIG_SEED_FILE = 'claude-config-seed.json';
433
+
434
+ // Copies the seed into the config folder only when the folder holds no
435
+ // `.claude.json` yet, then runs the CLI. A resumed session keeps the state
436
+ // the CLI wrote, a folder trust a person accepted included. The arguments
437
+ // are the config folder, the seed, and the CLI's own command line.
438
+ const SEED_CONFIG_SCRIPT =
439
+ 'mkdir -p "$1" && { [ -e "$1/.claude.json" ] || cp "$2" "$1/.claude.json"; } && shift 2 && exec "$@"';
440
+
441
+ // The gateway's settings without a credential helper, which a brokered
442
+ // session never runs.
443
+ function buildSettingsWithoutHelper(
444
+ settings: Readonly<Record<string, unknown>>,
445
+ ): Readonly<Record<string, unknown>> {
446
+ return Object.fromEntries(Object.entries(settings).filter(([key]) => key !== 'apiKeyHelper'));
447
+ }
448
+
202
449
  // The model names a gateway's env sets explicitly, keyed by role:
203
450
  // `ANTHROPIC_MODEL` is `default`, and `ANTHROPIC_DEFAULT_<TIER>_MODEL` is the
204
451
  // tier in lower case. No other env value leaves the adapter.
@@ -9,8 +9,7 @@ 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. A gateway with auth is left out too, since
13
- * every start of one is refused. Resolution follows the rule a spawn
12
+ * menu is a session that can start. Resolution follows the rule a spawn
14
13
  * follows: a bare name comes off PATH, a name carrying a separator is taken
15
14
  * as a path, and either way it has to be executable.
16
15
  */
@@ -23,9 +22,7 @@ export function collectAgentPicks(config: Config): AgentPick[] {
23
22
  { agent: 'claude', label: 'Claude', bin: config.claudeBin },
24
23
  { agent: 'grok', label: 'Grok', bin: config.grokBin },
25
24
  { agent: 'codex', label: 'Codex', bin: config.codexBin },
26
- ...config.gateways
27
- .filter((g) => g.auth === undefined)
28
- .map((g) => ({ agent: g.id, label: g.label, bin: g.bin })),
25
+ ...config.gateways.map((g) => ({ agent: g.id, label: g.label, bin: g.bin })),
29
26
  ];
30
27
 
31
28
  return candidates
@@ -13,6 +13,10 @@ export interface TargetPick {
13
13
  // Whether a session there runs on the daemon's own machine, where a
14
14
  // local directory runs in place.
15
15
  readonly inPlace: boolean;
16
+
17
+ // Whether the target reaches impd's credential broker; false from a
18
+ // daemon that does not say.
19
+ readonly brokerAuth: boolean;
16
20
  }
17
21
 
18
22
  /**
@@ -47,6 +51,7 @@ export function collectTargetPicks(answer: Readonly<Record<string, unknown>>): T
47
51
  takesWorkspace:
48
52
  available && capabilities['transfer'] === true && capabilities['run'] === true,
49
53
  inPlace: entry['provider'] === 'local-pty',
54
+ brokerAuth: entry['brokerAuth'] === true,
50
55
  },
51
56
  ];
52
57
  });
@@ -1616,7 +1616,10 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
1616
1616
  return;
1617
1617
  }
1618
1618
 
1619
- const picks = collectTargetPicks(listed);
1619
+ // An agent that takes its credential from impd's broker runs only on a
1620
+ // target that reaches the broker.
1621
+ const brokered = isBrokerAgent(listed['agents'], this.agent);
1622
+ const picks = collectTargetPicks(listed).filter((t) => !brokered || t.brokerAuth);
1620
1623
 
1621
1624
  // An adopt resumes a session from its history on this host, which a
1622
1625
  // fresh checkout elsewhere does not hold, so it is offered only the
@@ -1870,6 +1873,17 @@ function buildProbedRepo(
1870
1873
  };
1871
1874
  }
1872
1875
 
1876
+ // Whether the daemon lists an agent as taking its credential from impd's
1877
+ // broker; a daemon that does not say lists none.
1878
+ function isBrokerAgent(raw: unknown, agent: AgentID): boolean {
1879
+ return (
1880
+ Array.isArray(raw) &&
1881
+ raw.some(
1882
+ (entry: unknown) => isRecord(entry) && entry['id'] === agent && entry['brokerAuth'] === true,
1883
+ )
1884
+ );
1885
+ }
1886
+
1873
1887
  function parseAnnouncedSources(raw: unknown): AnnouncedSource[] {
1874
1888
  if (!Array.isArray(raw)) {
1875
1889
  return [];
@@ -0,0 +1,25 @@
1
+ import type { ImpPort, ImpView } from './imp-port';
2
+
3
+ /**
4
+ * What a provider whose hosts can reach impd's credential broker offers
5
+ * runtime auth: the literal start of every imp name it builds, the imp a
6
+ * host key runs on, impd's identity, secret and grant calls, and creating
7
+ * and destroying a host's imp the way the provider itself does. A
8
+ * destroy that finds no imp counts as done.
9
+ */
10
+ export interface BrokerAuthHost {
11
+ readonly impPrefix: string;
12
+ readonly port: Pick<
13
+ ImpPort,
14
+ | 'readFeatures'
15
+ | 'readIdentity'
16
+ | 'readSecrets'
17
+ | 'readGrants'
18
+ | 'createGrant'
19
+ | 'removeGrant'
20
+ | 'readImp'
21
+ >;
22
+ readonly getImpName: (hostKey: string) => string;
23
+ readonly createImp: (hostKey: string) => Promise<ImpView>;
24
+ readonly destroyImp: (hostKey: string) => Promise<void>;
25
+ }
@@ -2,7 +2,8 @@
2
2
  * Why impd's credential broker may not be used for a session, or why atc
3
3
  * may not clean up after one:
4
4
  *
5
- * - `auth_impd_too_old`: impd lacks grantable tokens or secret rebinds.
5
+ * - `auth_impd_too_old`: impd lacks grantable tokens, secret rebinds or
6
+ * exec requirements.
6
7
  * - `auth_token_scope`: the token's scope is below `manage`.
7
8
  * - `auth_token_too_broad`: the token can reach imps outside atc's
8
9
  * namespace, through no imp patterns or a pattern whose literal text
@@ -2,7 +2,8 @@ import type { AgentAdapter, SpawnOptionSpec } from '../agents/agent-adapter';
2
2
 
3
3
  interface AgentCapabilities {
4
4
  // Only an installed agent whose starts are not all refused can start a
5
- // session.
5
+ // session, and one that takes its credential from impd's broker only
6
+ // where some target reaches the broker.
6
7
  readonly spawn: boolean;
7
8
  readonly readTranscript: boolean;
8
9
 
@@ -20,6 +21,10 @@ export interface AgentEntry {
20
21
 
21
22
  // Whether the agent's binary resolves, on PATH or at its configured path.
22
23
  readonly installed: boolean;
24
+
25
+ // Whether the agent takes its credential from impd's broker, so it runs
26
+ // only on a target whose entry has `brokerAuth`.
27
+ readonly brokerAuth: boolean;
23
28
  readonly capabilities: AgentCapabilities;
24
29
  readonly models: Readonly<Record<string, string>> | null;
25
30
  readonly spawnOptions: SpawnOptionEntries;
@@ -43,22 +48,30 @@ interface SpawnOptionEntries {
43
48
  * so no environment value, credential, helper command, or base URL reaches
44
49
  * it. Every session runs in a PTY, so each agent can be attached, read as a
45
50
  * screen, and typed into. A stand-in adapter without a profile is listed
46
- * under its id as both label and kind.
51
+ * under its id as both label and kind. hasBrokerTarget holds whether any
52
+ * target reaches impd's credential broker.
47
53
  */
48
54
  export function buildAgentList(
49
55
  adapters: readonly AgentAdapter[],
50
56
  isInstalled: (bin: string) => boolean,
57
+ hasBrokerTarget: boolean,
51
58
  ): AgentEntry[] {
52
59
  return adapters.map((adapter) => {
53
60
  const profile = adapter.profile;
54
61
  const installed = profile === undefined ? false : isInstalled(profile.bin);
55
- const spawnable = installed && (adapter.findSpawnRefusal?.() ?? null) === null;
62
+ const brokerAuth = (adapter.findAuthSelection?.() ?? null) !== null;
63
+
64
+ const spawnable =
65
+ installed &&
66
+ (adapter.findSpawnRefusal?.() ?? null) === null &&
67
+ (!brokerAuth || hasBrokerTarget);
56
68
 
57
69
  return {
58
70
  id: adapter.id,
59
71
  label: profile?.label ?? adapter.id,
60
72
  kind: profile?.kind ?? adapter.id,
61
73
  installed,
74
+ brokerAuth,
62
75
  capabilities: {
63
76
  spawn: spawnable,
64
77
  readTranscript: adapter.parseTranscriptLine !== undefined,
@@ -139,6 +139,8 @@ export function buildScopedContext(
139
139
  // out or taken, so its host is never touched.
140
140
  forgetSession: (id, confirmToken) =>
141
141
  canSee(id) ? ctx.forgetSession(id, confirmToken) : Promise.resolve('missing' as const),
142
+ revokeSessionAuth: (id) => (canSee(id) ? ctx.revokeSessionAuth(id) : Promise.resolve(false)),
143
+ updateSessionAuth: (id) => (canSee(id) ? ctx.updateSessionAuth(id) : Promise.resolve(null)),
142
144
  updateSession: (id, name, pinned) => canSee(id) && ctx.updateSession(id, name, pinned),
143
145
  ackSession: (id) => canSee(id) && ctx.ackSession(id),
144
146
  buildResumeCommand: (id) => (canSee(id) ? ctx.buildResumeCommand(id) : null),
@@ -14,6 +14,10 @@ export interface TargetEntry {
14
14
  readonly available: boolean;
15
15
  readonly default: boolean;
16
16
  readonly capabilities: ExecutionCapabilities;
17
+
18
+ // Whether the target's provider reaches impd's credential broker, so an
19
+ // agent that takes its credential from the broker can run there.
20
+ readonly brokerAuth: boolean;
17
21
  }
18
22
 
19
23
  // What a target without a provider can do: nothing.
@@ -46,5 +50,6 @@ export function buildTargetList(
46
50
  available: target.provider !== null,
47
51
  default: target.id === defaultTarget,
48
52
  capabilities: target.provider?.capabilities ?? NO_CAPABILITIES,
53
+ brokerAuth: target.provider?.brokerAuth !== undefined,
49
54
  }));
50
55
  }