@zgeoff/atc 2.24.0 → 2.25.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "2.24.0",
3
+ "version": "2.25.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;
@@ -9,6 +9,7 @@ import { toShellArg } from '../shared/to-shell-arg';
9
9
  import type {
10
10
  AgentAdapter,
11
11
  AgentProfile,
12
+ AuthSelection,
12
13
  HeadlessRunner,
13
14
  NameUpdate,
14
15
  ResumeCheck,
@@ -59,6 +60,8 @@ export class GatewayAdapter implements AgentAdapter {
59
60
 
60
61
  private readonly gateway: GatewayConfig;
61
62
 
63
+ private readonly config: Config;
64
+
62
65
  private readonly claude: ClaudeAdapter;
63
66
 
64
67
  // Written on first spawn so constructing the adapter touches no state.
@@ -77,6 +80,7 @@ export class GatewayAdapter implements AgentAdapter {
77
80
  ) {
78
81
  this.bridgeTarget = bridgeTarget;
79
82
  this.gateway = gateway;
83
+ this.config = config;
80
84
  this.id = gateway.id;
81
85
 
82
86
  const models = pickModels(gateway.env);
@@ -102,8 +106,9 @@ export class GatewayAdapter implements AgentAdapter {
102
106
  });
103
107
  }
104
108
 
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
109
+ // A gateway whose credential comes through impd's broker starts only in a
110
+ // guest that reaches the broker, with guest settings the adapter plans
111
+ // for it, and it plans none: started without them, the CLI would send
107
112
  // whatever credential it holds to the gateway's host.
108
113
  findSpawnRefusal(): DaemonError | null {
109
114
  if (this.gateway.auth === undefined) {
@@ -112,11 +117,24 @@ export class GatewayAdapter implements AgentAdapter {
112
117
 
113
118
  return new DaemonError(
114
119
  'auth_target_unsupported',
115
- `gateway '${this.id}' takes its credential from impd's broker, and brokered credentials are not wired yet`,
120
+ `gateway '${this.id}' takes its credential from impd's broker, and atc plans no guest settings for it on any target`,
116
121
  { agent: this.id },
117
122
  );
118
123
  }
119
124
 
125
+ findAuthSelection(): AuthSelection | null {
126
+ const auth = this.gateway.auth;
127
+
128
+ if (auth === undefined) {
129
+ return null;
130
+ }
131
+
132
+ return {
133
+ gateway: { id: this.gateway.id, baseURL: this.gateway.baseURL, auth },
134
+ profiles: this.config.authProfiles,
135
+ };
136
+ }
137
+
120
138
  planSpawn(opts: SpawnOptions): SpawnPlan {
121
139
  return {
122
140
  bin: this.gateway.bin,
@@ -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
  }
@@ -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.
@@ -648,6 +654,48 @@ export class DaemonConnection {
648
654
 
649
655
  return;
650
656
  }
657
+ case 'session.auth.revoke': {
658
+ const parsed = parseRequestParams('session.auth.revoke', req.p);
659
+
660
+ if (!parsed.ok) {
661
+ this.sendErr(req.id, 'bad_args', parsed.message);
662
+
663
+ return;
664
+ }
665
+
666
+ const id = parsed.data.session;
667
+
668
+ const revoked = await ctx.revokeSessionAuth(id);
669
+
670
+ if (revoked) {
671
+ this.sendOk(req.id, { revoked: true });
672
+ } else {
673
+ this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
674
+ }
675
+
676
+ return;
677
+ }
678
+ case 'session.auth.rebind': {
679
+ const parsed = parseRequestParams('session.auth.rebind', req.p);
680
+
681
+ if (!parsed.ok) {
682
+ this.sendErr(req.id, 'bad_args', parsed.message);
683
+
684
+ return;
685
+ }
686
+
687
+ const id = parsed.data.session;
688
+
689
+ const revision = await ctx.updateSessionAuth(id);
690
+
691
+ if (revision === null) {
692
+ this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
693
+ } else {
694
+ this.sendOk(req.id, { revision });
695
+ }
696
+
697
+ return;
698
+ }
651
699
  case 'session.resumeCommand': {
652
700
  const parsed = parseRequestParams('session.resumeCommand', req.p);
653
701
 
@@ -901,6 +949,8 @@ export class DaemonConnection {
901
949
  throw refusal;
902
950
  }
903
951
 
952
+ ctx.requireAgentTarget(agent, target);
953
+
904
954
  const overrides = parseSpawnOverrides(entry, { model: data.model, effort: data.effort });
905
955
 
906
956
  if (!overrides.ok) {
@@ -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;
@@ -77,6 +77,7 @@ import { PermissionRegistry } from './permission-registry';
77
77
  import { requireGitTransports } from './require-git-transports';
78
78
  import { restoreFleet } from './restore-fleet';
79
79
  import { runEjectHandoff } from './run-eject-handoff';
80
+ import { RuntimeAuthBinder } from './runtime-auth-binder';
80
81
  import { ScreenModel } from './screen-model';
81
82
  import { SessionRuntime } from './session-runtime';
82
83
  import { SessionManager } from './sessions';
@@ -278,6 +279,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
278
279
  // that stopped mid-effect is settled, so no retry can race either.
279
280
  await store.reconcileMaterializations(Date.now());
280
281
  await store.reconcileIdempotencyKeys(Date.now());
282
+ await store.reconcileAuthBindings(Date.now());
281
283
 
282
284
  await tryRemoveExpiredIdempotencyKeys(store);
283
285
 
@@ -317,6 +319,14 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
317
319
  mgr.log = opts.log;
318
320
  }
319
321
 
322
+ const authBinder = new RuntimeAuthBinder(store);
323
+
324
+ mgr.authBinder = authBinder;
325
+
326
+ // Settled in the background, since it reaches impd: each binding a
327
+ // stopped daemon left mid-change stays blocked until it is settled.
328
+ void tryReconcileAuthBindings(authBinder, store, targetsByID, mgr.log);
329
+
320
330
  const clients = new Set<DaemonConnection>();
321
331
 
322
332
  const runHooks = makeHookRunner(opts.hooks ?? {});
@@ -1272,7 +1282,11 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1272
1282
  arch: process.arch,
1273
1283
  build: opts.build,
1274
1284
  },
1275
- agents: buildAgentList(mgr.collectAdapters(), (bin) => Bun.which(bin) !== null),
1285
+ agents: buildAgentList(
1286
+ mgr.collectAdapters(),
1287
+ (bin) => Bun.which(bin) !== null,
1288
+ targets.some((target) => target.provider?.brokerAuth !== undefined),
1289
+ ),
1276
1290
  targets: buildTargetList(targets, defaultTarget),
1277
1291
  spawnDefaults: { agent: 'claude', target: defaultTarget },
1278
1292
  configRevision,
@@ -1324,6 +1338,9 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1324
1338
  mgr.requireExecution({ target, targetIdentity: null }, 'transfer');
1325
1339
  mgr.requireExecution({ target, targetIdentity: null }, 'run');
1326
1340
  },
1341
+ requireAgentTarget: (agent, target) => {
1342
+ mgr.requireAgentTarget(agent, target);
1343
+ },
1327
1344
  findSource: (id) => sources.find((source) => source.id === id) ?? null,
1328
1345
  collectAlternateGitURLs: (url) => [
1329
1346
  ...new Set(sources.flatMap((source) => source.findAlternateURLs?.(url) ?? [])),
@@ -1446,6 +1463,8 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1446
1463
 
1447
1464
  return { forgotten: true, destroyed };
1448
1465
  },
1466
+ revokeSessionAuth: (id) => mgr.revokeAuth(id),
1467
+ updateSessionAuth: (id) => mgr.updateAuth(id),
1449
1468
  ejectSession: (id, prompt) => {
1450
1469
  const s = mgr.sessions.find((x) => x.id === id);
1451
1470
 
@@ -1494,6 +1513,14 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1494
1513
  return 'no_transcript';
1495
1514
  }
1496
1515
 
1516
+ const listed = mgr.sessions.find((s) => s.id === id);
1517
+
1518
+ // Refused before the headless run stops, so a refusal leaves it as
1519
+ // it was.
1520
+ if (listed !== undefined) {
1521
+ mgr.requireAgentTarget(listed.agent, listed.target);
1522
+ }
1523
+
1497
1524
  const runtime = runtimes.get(id);
1498
1525
 
1499
1526
  runtime?.stopHeadlessRun();
@@ -2197,6 +2224,36 @@ async function tryRemoveExpiredIdempotencyKeys(store: StateStore): Promise<boole
2197
2224
  return true;
2198
2225
  }
2199
2226
 
2227
+ // Settles the runtime auth bindings a stopped daemon left mid-change, each
2228
+ // through its target's broker; a host with a fleet entry is listed, and a
2229
+ // failure is logged and leaves its binding blocked.
2230
+ async function tryReconcileAuthBindings(
2231
+ binder: RuntimeAuthBinder,
2232
+ store: StateStore,
2233
+ targets: ReadonlyMap<string, ExecutionTarget>,
2234
+ log: (line: string) => void,
2235
+ ): Promise<boolean> {
2236
+ try {
2237
+ const fleet = await store.loadFleet();
2238
+
2239
+ const listed = new Set(fleet.map((entry) => entry.hostKey ?? entry.sessionID));
2240
+
2241
+ await binder.reconcileBindings(
2242
+ (target) => targets.get(target)?.provider?.brokerAuth ?? null,
2243
+ listed,
2244
+ log,
2245
+ );
2246
+ } catch (error) {
2247
+ log(
2248
+ `atc could not settle runtime auth bindings (${error instanceof Error ? error.message : String(error)})`,
2249
+ );
2250
+
2251
+ return false;
2252
+ }
2253
+
2254
+ return true;
2255
+ }
2256
+
2200
2257
  interface ConfirmToken {
2201
2258
  readonly session: SessionID;
2202
2259
  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.