@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
@@ -34,8 +34,14 @@ interface RequestScope {
34
34
  readonly keyNamespace: string;
35
35
  }
36
36
 
37
- // The requests that act on the whole daemon, which only its owner may make.
38
- const OWNER_METHODS: ReadonlySet<string> = new Set(['daemon.quit', 'fleet.restore']);
37
+ // The requests that act on the whole daemon, or on the credentials a
38
+ // session's host may use, which only its owner may make.
39
+ const OWNER_METHODS: ReadonlySet<string> = new Set([
40
+ 'daemon.quit',
41
+ 'fleet.restore',
42
+ 'session.auth.revoke',
43
+ 'session.auth.rebind',
44
+ ]);
39
45
 
40
46
  // What a limited connection is sent for one event, and the sessions that
41
47
  // event moved out of its view.
@@ -68,9 +74,15 @@ interface PeerSocket extends SocketWriter {
68
74
  export interface TCPPeer {
69
75
  readonly verifyHandshake: (presented: string | null) => Promise<string | null>;
70
76
 
71
- // Counts a line other than a handshake, sent before one passed, as a
72
- // failed handshake from the peer's address.
73
- readonly recordFailure: () => void;
77
+ // Counts a line other than a handshake, or one over the size cap, sent
78
+ // before a handshake passed, as a failed handshake from the peer's
79
+ // address.
80
+ readonly recordFailure: (reason: 'unexpected_line' | 'line_too_long') => void;
81
+
82
+ // Records a handshake or a request refused for a principal the config
83
+ // does not list. The principal itself is never logged: an unlisted one
84
+ // is whatever the peer sent, a credential included.
85
+ readonly recordRefusedPrincipal: () => void;
74
86
  }
75
87
 
76
88
  export class DaemonConnection {
@@ -214,7 +226,7 @@ export class DaemonConnection {
214
226
  const oversized = this.lines.pendingLength + chunk.length > MAX_LINE;
215
227
 
216
228
  if (oversized && this.isUnauthenticatedTCP()) {
217
- this.tcp?.recordFailure();
229
+ this.tcp?.recordFailure('line_too_long');
218
230
  this.peer.end();
219
231
 
220
232
  return;
@@ -331,7 +343,7 @@ export class DaemonConnection {
331
343
  const isHello = decoded.kind === 'request' && decoded.msg.m === 'daemon.hello';
332
344
 
333
345
  if (this.tcpHelloSent || !isHello) {
334
- this.tcp?.recordFailure();
346
+ this.tcp?.recordFailure('unexpected_line');
335
347
 
336
348
  return 'close';
337
349
  }
@@ -380,6 +392,7 @@ export class DaemonConnection {
380
392
  }
381
393
 
382
394
  if (!this.ctx.hasListedPrincipal(req.as)) {
395
+ this.tcp?.recordRefusedPrincipal();
383
396
  this.sendErr(req.id, 'unauthorized', `principal '${req.as}' is not listed in principals`);
384
397
 
385
398
  return;
@@ -648,6 +661,48 @@ export class DaemonConnection {
648
661
 
649
662
  return;
650
663
  }
664
+ case 'session.auth.revoke': {
665
+ const parsed = parseRequestParams('session.auth.revoke', req.p);
666
+
667
+ if (!parsed.ok) {
668
+ this.sendErr(req.id, 'bad_args', parsed.message);
669
+
670
+ return;
671
+ }
672
+
673
+ const id = parsed.data.session;
674
+
675
+ const revoked = await ctx.revokeSessionAuth(id);
676
+
677
+ if (revoked) {
678
+ this.sendOk(req.id, { revoked: true });
679
+ } else {
680
+ this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
681
+ }
682
+
683
+ return;
684
+ }
685
+ case 'session.auth.rebind': {
686
+ const parsed = parseRequestParams('session.auth.rebind', req.p);
687
+
688
+ if (!parsed.ok) {
689
+ this.sendErr(req.id, 'bad_args', parsed.message);
690
+
691
+ return;
692
+ }
693
+
694
+ const id = parsed.data.session;
695
+
696
+ const revision = await ctx.updateSessionAuth(id);
697
+
698
+ if (revision === null) {
699
+ this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
700
+ } else {
701
+ this.sendOk(req.id, { revision });
702
+ }
703
+
704
+ return;
705
+ }
651
706
  case 'session.resumeCommand': {
652
707
  const parsed = parseRequestParams('session.resumeCommand', req.p);
653
708
 
@@ -901,6 +956,8 @@ export class DaemonConnection {
901
956
  throw refusal;
902
957
  }
903
958
 
959
+ ctx.requireAgentTarget(agent, target);
960
+
904
961
  const overrides = parseSpawnOverrides(entry, { model: data.model, effort: data.effort });
905
962
 
906
963
  if (!overrides.ok) {
@@ -1619,6 +1676,8 @@ export class DaemonConnection {
1619
1676
  const principal = parsedHello.data.principal ?? null;
1620
1677
 
1621
1678
  if (principal !== null && !this.ctx.hasListedPrincipal(principal)) {
1679
+ tcp.recordRefusedPrincipal();
1680
+
1622
1681
  this.sendErr(
1623
1682
  req.id,
1624
1683
  'unauthorized',
@@ -180,6 +180,10 @@ export interface DaemonContext {
180
180
  // one whose provider cannot both transfer an archive and run a command.
181
181
  readonly requireWorkspaceTarget: (target: string) => void;
182
182
 
183
+ // Throws the refusal for an agent that takes its credential from impd's
184
+ // broker on a target whose provider reaches no broker.
185
+ readonly requireAgentTarget: (agent: AgentID, target: string) => void;
186
+
183
187
  // The source the daemon offers the spawn picker under an id, or null when
184
188
  // it offers none under it.
185
189
  readonly findSource: (id: string) => SourceProvider | null;
@@ -211,6 +215,14 @@ export interface DaemonContext {
211
215
  id: SessionID,
212
216
  confirmToken: string | undefined,
213
217
  ) => Promise<ForgetResult | 'missing'>;
218
+
219
+ // Withdraws the grants of the runtime auth binding on a session's host,
220
+ // and answers false for no such session.
221
+ readonly revokeSessionAuth: (id: SessionID) => Promise<boolean>;
222
+
223
+ // Binds a session's host to its agent's current auth selection, and
224
+ // answers the new revision, or null for no such session.
225
+ readonly updateSessionAuth: (id: SessionID) => Promise<number | null>;
214
226
  readonly updateSession: (id: SessionID, name?: string, pinned?: boolean) => boolean | 'child_pin';
215
227
  readonly quitDaemon: () => void;
216
228
  readonly ackSession: (id: SessionID) => boolean;
@@ -69,6 +69,7 @@ import { loadListenerTokens } from './load-listener-tokens';
69
69
  import { loadTranscriptPage } from './load-transcript-page';
70
70
  import { makeHookRunner } from './make-hook-runner';
71
71
  import type { HookScope } from './make-hook-runner';
72
+ import { makeNonBlockingLog } from './make-non-blocking-log';
72
73
  import { materializeWorkspace } from './materialize-workspace';
73
74
  import { mintMessageID } from './mint-message-id';
74
75
  import { mintSessionID } from './mint-session-id';
@@ -77,6 +78,7 @@ import { PermissionRegistry } from './permission-registry';
77
78
  import { requireGitTransports } from './require-git-transports';
78
79
  import { restoreFleet } from './restore-fleet';
79
80
  import { runEjectHandoff } from './run-eject-handoff';
81
+ import { RuntimeAuthBinder } from './runtime-auth-binder';
80
82
  import { ScreenModel } from './screen-model';
81
83
  import { SessionRuntime } from './session-runtime';
82
84
  import { SessionManager } from './sessions';
@@ -191,6 +193,16 @@ interface ListenOptions {
191
193
  // How many delayed handshakes may wait at once across every address; 64
192
194
  // when unset.
193
195
  readonly maxDelayedHandshakes?: number;
196
+
197
+ // The clock the refusal log reads, Date.now when unset, and how long it
198
+ // folds repeated refusals from one peer into one line; a minute when
199
+ // unset.
200
+ readonly now?: () => number;
201
+ readonly refusalLogIntervalMs?: number;
202
+
203
+ // How many peers and kinds of refusal the refusal log tracks at once;
204
+ // 1024 when unset.
205
+ readonly maxRefusalWindows?: number;
194
206
  }
195
207
 
196
208
  export interface DaemonHandle {
@@ -230,6 +242,17 @@ const HANDSHAKE_FAILURE_DELAY_MS = 10_000;
230
242
  // refused at once.
231
243
  const MAX_DELAYED_HANDSHAKES = 64;
232
244
 
245
+ // How long the TCP listener folds repeated refusals from one peer into one
246
+ // log line.
247
+ const REFUSAL_LOG_INTERVAL_MS = 60_000;
248
+
249
+ // How many peers and kinds of refusal the TCP listener's refusal log tracks
250
+ // at once.
251
+ const MAX_REFUSAL_WINDOWS = 1024;
252
+
253
+ // Where the TCP listener logs when the daemon is given no log.
254
+ const STDERR_FD = 2;
255
+
233
256
  // How long startup waits for a daemon that is shutting down to release the
234
257
  // state lock before refusing to start.
235
258
  const LOCK_WAIT_MS = 2000;
@@ -278,6 +301,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
278
301
  // that stopped mid-effect is settled, so no retry can race either.
279
302
  await store.reconcileMaterializations(Date.now());
280
303
  await store.reconcileIdempotencyKeys(Date.now());
304
+ await store.reconcileAuthBindings(Date.now());
281
305
 
282
306
  await tryRemoveExpiredIdempotencyKeys(store);
283
307
 
@@ -317,6 +341,14 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
317
341
  mgr.log = opts.log;
318
342
  }
319
343
 
344
+ const authBinder = new RuntimeAuthBinder(store);
345
+
346
+ mgr.authBinder = authBinder;
347
+
348
+ // Settled in the background, since it reaches impd: each binding a
349
+ // stopped daemon left mid-change stays blocked until it is settled.
350
+ void tryReconcileAuthBindings(authBinder, store, targetsByID, mgr.log);
351
+
320
352
  const clients = new Set<DaemonConnection>();
321
353
 
322
354
  const runHooks = makeHookRunner(opts.hooks ?? {});
@@ -1272,7 +1304,11 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1272
1304
  arch: process.arch,
1273
1305
  build: opts.build,
1274
1306
  },
1275
- agents: buildAgentList(mgr.collectAdapters(), (bin) => Bun.which(bin) !== null),
1307
+ agents: buildAgentList(
1308
+ mgr.collectAdapters(),
1309
+ (bin) => Bun.which(bin) !== null,
1310
+ targets.some((target) => target.provider?.brokerAuth !== undefined),
1311
+ ),
1276
1312
  targets: buildTargetList(targets, defaultTarget),
1277
1313
  spawnDefaults: { agent: 'claude', target: defaultTarget },
1278
1314
  configRevision,
@@ -1324,6 +1360,9 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1324
1360
  mgr.requireExecution({ target, targetIdentity: null }, 'transfer');
1325
1361
  mgr.requireExecution({ target, targetIdentity: null }, 'run');
1326
1362
  },
1363
+ requireAgentTarget: (agent, target) => {
1364
+ mgr.requireAgentTarget(agent, target);
1365
+ },
1327
1366
  findSource: (id) => sources.find((source) => source.id === id) ?? null,
1328
1367
  collectAlternateGitURLs: (url) => [
1329
1368
  ...new Set(sources.flatMap((source) => source.findAlternateURLs?.(url) ?? [])),
@@ -1446,6 +1485,8 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1446
1485
 
1447
1486
  return { forgotten: true, destroyed };
1448
1487
  },
1488
+ revokeSessionAuth: (id) => mgr.revokeAuth(id),
1489
+ updateSessionAuth: (id) => mgr.updateAuth(id),
1449
1490
  ejectSession: (id, prompt) => {
1450
1491
  const s = mgr.sessions.find((x) => x.id === id);
1451
1492
 
@@ -1494,6 +1535,14 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1494
1535
  return 'no_transcript';
1495
1536
  }
1496
1537
 
1538
+ const listed = mgr.sessions.find((s) => s.id === id);
1539
+
1540
+ // Refused before the headless run stops, so a refusal leaves it as
1541
+ // it was.
1542
+ if (listed !== undefined) {
1543
+ mgr.requireAgentTarget(listed.agent, listed.target);
1544
+ }
1545
+
1497
1546
  const runtime = runtimes.get(id);
1498
1547
 
1499
1548
  runtime?.stopHeadlessRun();
@@ -2010,6 +2059,10 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
2010
2059
  return connection;
2011
2060
  },
2012
2061
  closeConnection: detachConnection,
2062
+ log: opts.log ?? makeNonBlockingLog(STDERR_FD),
2063
+ now: opts.listen.now ?? Date.now,
2064
+ refusalLogIntervalMs: opts.listen.refusalLogIntervalMs ?? REFUSAL_LOG_INTERVAL_MS,
2065
+ maxRefusalWindows: opts.listen.maxRefusalWindows ?? MAX_REFUSAL_WINDOWS,
2013
2066
  });
2014
2067
  } catch (error) {
2015
2068
  await releaseResources();
@@ -2197,6 +2250,36 @@ async function tryRemoveExpiredIdempotencyKeys(store: StateStore): Promise<boole
2197
2250
  return true;
2198
2251
  }
2199
2252
 
2253
+ // Settles the runtime auth bindings a stopped daemon left mid-change, each
2254
+ // through its target's broker; a host with a fleet entry is listed, and a
2255
+ // failure is logged and leaves its binding blocked.
2256
+ async function tryReconcileAuthBindings(
2257
+ binder: RuntimeAuthBinder,
2258
+ store: StateStore,
2259
+ targets: ReadonlyMap<string, ExecutionTarget>,
2260
+ log: (line: string) => void,
2261
+ ): Promise<boolean> {
2262
+ try {
2263
+ const fleet = await store.loadFleet();
2264
+
2265
+ const listed = new Set(fleet.map((entry) => entry.hostKey ?? entry.sessionID));
2266
+
2267
+ await binder.reconcileBindings(
2268
+ (target) => targets.get(target)?.provider?.brokerAuth ?? null,
2269
+ listed,
2270
+ log,
2271
+ );
2272
+ } catch (error) {
2273
+ log(
2274
+ `atc could not settle runtime auth bindings (${error instanceof Error ? error.message : String(error)})`,
2275
+ );
2276
+
2277
+ return false;
2278
+ }
2279
+
2280
+ return true;
2281
+ }
2282
+
2200
2283
  interface ConfirmToken {
2201
2284
  readonly session: SessionID;
2202
2285
  readonly expiresAt: number;
@@ -1,3 +1,6 @@
1
+ import type { DaemonError } from '../protocol/daemon-error';
2
+ import type { BrokerAuthHost } from './broker-auth-host';
3
+
1
4
  /**
2
5
  * The host a session's harness runs on: it starts a process in a
3
6
  * pseudo-terminal and, as its capabilities declare, unpacks files into its
@@ -22,6 +25,11 @@ export interface ExecutionProvider {
22
25
  // host has none. Absent on the daemon's own machine.
23
26
  readonly guest?: GuestLayout;
24
27
 
28
+ // How a harness here can take its credential from impd's broker instead
29
+ // of holding it. Absent on a host with no broker, which never starts a
30
+ // session that needs one.
31
+ readonly brokerAuth?: BrokerAuthHost;
32
+
25
33
  // Readies the host a harness is about to start on: a remote host is
26
34
  // created when missing, woken when asleep, and held awake while its
27
35
  // harnesses run. Rejects with the refusal before any harness starts.
@@ -41,8 +49,12 @@ export interface ExecutionProvider {
41
49
 
42
50
  // Puts a host to sleep with every harness on it kept inside, so a revive
43
51
  // finds each one as it was. Rejects with `host_leased` when another owner
44
- // keeps the host awake, and leaves the host as it was then.
45
- readonly suspendHost: (host: string) => Promise<void>;
52
+ // keeps the host awake, and leaves the host as it was then. A sleep runs
53
+ // after any readying of the host before it, and a readying waits for it.
54
+ // isIdle makes it a sleep of an idle host only: it is checked when the
55
+ // sleep starts and again just before the host sleeps, and a host that a
56
+ // harness or a readying keeps busy stays awake with `host_unavailable`.
57
+ readonly suspendHost: (host: string, isIdle?: () => boolean) => Promise<void>;
46
58
 
47
59
  // Deletes a host and everything on it, harnesses included. Nothing brings
48
60
  // a destroyed host back.
@@ -65,6 +77,10 @@ export interface HostRequest {
65
77
  // Whether the harness about to start needs atc inside the host, which a
66
78
  // provider that ships its own binary installs when missing.
67
79
  readonly installATC?: boolean;
80
+
81
+ // Whether the daemon has nothing running or starting on the host, which
82
+ // the provider checks before it gives the host's lease back on its own.
83
+ readonly isIdle?: () => boolean;
68
84
  }
69
85
 
70
86
  export interface GuestLayout {
@@ -132,6 +148,20 @@ export interface HarnessSpec {
132
148
  readonly cols: number;
133
149
  readonly rows: number;
134
150
 
151
+ // Whether the harness must not start unless the host's credential broker
152
+ // is ready: the host refuses the start and runs nothing otherwise, on
153
+ // every start, a revive's included.
154
+ readonly requireBroker?: boolean;
155
+
156
+ // Admits each start or attach of a harness that requires the broker by
157
+ // calling send, which hands the request to the host, or rejects with the
158
+ // refusal that ends the harness instead, sending nothing. send gets the
159
+ // admission's ticket.
160
+ readonly admit?: (
161
+ kind: 'start' | 'attach',
162
+ send: (ticket: LaunchTicket) => void,
163
+ ) => Promise<void>;
164
+
135
165
  // Takes each connection a process of the harness opens to the daemon. A
136
166
  // remote provider relays them from a socket inside the host that serves
137
167
  // this harness alone, and points the harness's ATC_SOCKET at it.
@@ -143,6 +173,18 @@ export interface HarnessSpec {
143
173
  * lines: each line the process writes arrives whole, and each line the
144
174
  * daemon writes reaches the process in order.
145
175
  */
176
+ /**
177
+ * One admitted request of a harness behind the broker. check runs just
178
+ * before the request goes out and returns the refusal that stops it
179
+ * unsent, or null to let it go; release gives the admission up once its
180
+ * connection ends without the request going out. Each runs at most once
181
+ * to any effect.
182
+ */
183
+ export interface LaunchTicket {
184
+ readonly check: () => DaemonError | null;
185
+ readonly release: () => void;
186
+ }
187
+
146
188
  export interface HarnessRelay {
147
189
  readonly onLine: (listener: (line: string) => void) => void;
148
190
  readonly onClose: (listener: () => void) => void;
@@ -176,6 +218,12 @@ export interface HarnessHandle {
176
218
  // a provider that can send one; absent on a provider that cannot.
177
219
  readonly killForced?: () => void;
178
220
 
221
+ // Resolves once the harness's process has started, or a running one was
222
+ // attached, and rejects with the refusal when the host ends the harness
223
+ // or the daemon lets go of it first. Absent on a provider whose harness
224
+ // starts at once.
225
+ readonly waitForStart?: () => Promise<void>;
226
+
179
227
  // Resolves true once the harness's process has exited, and false when the
180
228
  // wait runs out first or the harness stops being followed without an
181
229
  // exit, such as a host that went to sleep with the process inside.
@@ -0,0 +1,38 @@
1
+ // The most characters of a field a log line keeps; a longer one is cut and
2
+ // ends in `...`.
3
+ const MAX_FIELD_CHARS = 64;
4
+
5
+ /**
6
+ * Formats a value for one `key=value` field of a log line. Every character
7
+ * outside printable ASCII, and a space, `"`, `=`, or `\`, is written as a
8
+ * `\u{hex}` escape, so a value from the network can neither move the
9
+ * terminal, start a new line, nor forge another field. The value is cut to
10
+ * 64 characters before it is escaped.
11
+ */
12
+ export function formatLogField(value: string): string {
13
+ let formatted = '';
14
+ let kept = 0;
15
+
16
+ // Iterating a string steps by code point, so a character outside the
17
+ // basic plane counts and escapes as one.
18
+ for (const char of value) {
19
+ if (kept === MAX_FIELD_CHARS) {
20
+ return `${formatted}...`;
21
+ }
22
+
23
+ formatted += isPlainChar(char) ? char : formatEscape(char);
24
+ kept++;
25
+ }
26
+
27
+ return formatted;
28
+ }
29
+
30
+ function isPlainChar(char: string): boolean {
31
+ const code = char.codePointAt(0) ?? 0;
32
+
33
+ return code > 0x20 && code < 0x7f && char !== '"' && char !== '=' && char !== '\\';
34
+ }
35
+
36
+ function formatEscape(char: string): string {
37
+ return `\\u{${(char.codePointAt(0) ?? 0).toString(16)}}`;
38
+ }
@@ -1,10 +1,11 @@
1
1
  import { createImpClient, openExecSession, openReverseForward } from '@zgeoff/imp-client';
2
- import type { ExecOutcome, ImpClient } from '@zgeoff/imp-client';
2
+ import type { ExecOutcome, ExecSessionOptions, ImpClient } from '@zgeoff/imp-client';
3
3
  import { isRecord } from '../shared/report';
4
4
  import type {
5
5
  ImpCommand,
6
6
  ImpCommandResult,
7
7
  ImpCreateSpec,
8
+ ImpExecRequirement,
8
9
  ImpFeatures,
9
10
  ImpIdentity,
10
11
  ImpLease,
@@ -63,6 +64,7 @@ export class ImpClientPort implements ImpPort {
63
64
  leases: features?.['leases'] === true,
64
65
  grantableTokens: features?.['grantableTokens'] === true,
65
66
  secretRebind: features?.['secretRebind'] === true,
67
+ execRequire: features?.['execRequire'] === true,
66
68
  };
67
69
  };
68
70
 
@@ -199,6 +201,7 @@ export class ImpClientPort implements ImpPort {
199
201
  readonly openSession = (
200
202
  request: ImpSessionRequest,
201
203
  handlers: ImpSessionHandlers,
204
+ gate?: () => boolean,
202
205
  ): ImpSessionConnection => {
203
206
  const token = this.tryReadToken();
204
207
 
@@ -217,34 +220,14 @@ export class ImpClientPort implements ImpPort {
217
220
  const session = openExecSession({
218
221
  baseUrl: this.url,
219
222
  token,
220
- start:
221
- request.kind === 'start'
222
- ? {
223
- name: request.name,
224
- session: request.session,
225
- argv: request.argv,
226
- tty: true,
227
- env: request.env,
228
- cwd: request.cwd,
229
- cols: request.cols,
230
- rows: request.rows,
231
- ...(request.resumeFrom === undefined ? {} : { resumeFrom: request.resumeFrom }),
232
- }
233
- : {
234
- name: request.name,
235
- session: request.session,
236
- cols: request.cols,
237
- rows: request.rows,
238
- wake: request.wake,
239
- ...(request.resumeFrom === undefined ? {} : { resumeFrom: request.resumeFrom }),
240
- },
223
+ start: buildExecStart(request),
241
224
  onStarted: (started) => {
242
225
  handlers.onStarted({ created: started.created, output: started.output });
243
226
  },
244
227
  onOutput: (_channel, data) => {
245
228
  handlers.onOutput(data);
246
229
  },
247
- connect: (url, headers) => new WebSocket(url, { headers }),
230
+ connect: (url, headers) => openGatedSocket(url, headers, gate),
248
231
  });
249
232
 
250
233
  return {
@@ -389,6 +372,41 @@ function toPortError(error: unknown): ImpPortError {
389
372
  return new ImpPortError('UNREACHABLE', message);
390
373
  }
391
374
 
375
+ // A start or an attach as the client takes it, with the requirements impd
376
+ // checks before the command runs or the attach joins it.
377
+ type ExecStartWithRequire = ExecSessionOptions['start'] & {
378
+ readonly require?: readonly ImpExecRequirement[];
379
+ };
380
+
381
+ // The client sends every field of a start or an attach as it is, so a list
382
+ // of requirements reaches impd though the client's types lack it.
383
+ function buildExecStart(request: ImpSessionRequest): ExecStartWithRequire {
384
+ if (request.kind === 'attach') {
385
+ return {
386
+ name: request.name,
387
+ session: request.session,
388
+ cols: request.cols,
389
+ rows: request.rows,
390
+ wake: request.wake,
391
+ ...(request.resumeFrom === undefined ? {} : { resumeFrom: request.resumeFrom }),
392
+ ...(request.require === undefined ? {} : { require: request.require }),
393
+ };
394
+ }
395
+
396
+ return {
397
+ name: request.name,
398
+ session: request.session,
399
+ argv: request.argv,
400
+ tty: true,
401
+ env: request.env,
402
+ cwd: request.cwd,
403
+ cols: request.cols,
404
+ rows: request.rows,
405
+ ...(request.resumeFrom === undefined ? {} : { resumeFrom: request.resumeFrom }),
406
+ ...(request.require === undefined ? {} : { require: request.require }),
407
+ };
408
+ }
409
+
392
410
  // oxlint-disable-next-line prefer-readonly-parameter-types -- a promise is a live handle
393
411
  async function waitForSessionOutcome(outcome: Promise<ExecOutcome>): Promise<ImpSessionOutcome> {
394
412
  const ended = await outcome;
@@ -469,3 +487,33 @@ function openRelay(accept: RelayAccept): ImpRelayConnection {
469
487
  },
470
488
  };
471
489
  }
490
+
491
+ // The client sends its request from the socket's open listener. The gate
492
+ // listens first, so a closed gate, or one that throws, stops that
493
+ // listener and closes the socket before anything goes out.
494
+ function openGatedSocket(
495
+ url: string,
496
+ headers: Readonly<Record<string, string>>,
497
+ gate: (() => boolean) | undefined,
498
+ ): WebSocket {
499
+ const socket = new WebSocket(url, { headers });
500
+
501
+ if (gate !== undefined) {
502
+ socket.addEventListener('open', (event) => {
503
+ if (!tryPassGate(gate)) {
504
+ event.stopImmediatePropagation();
505
+ socket.close(1000, 'closed before sending');
506
+ }
507
+ });
508
+ }
509
+
510
+ return socket;
511
+ }
512
+
513
+ function tryPassGate(gate: () => boolean): boolean {
514
+ try {
515
+ return gate();
516
+ } catch {
517
+ return false;
518
+ }
519
+ }