@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.
@@ -1,6 +1,7 @@
1
1
  import { writeFileSync } from 'node:fs';
2
2
  import type {
3
3
  AgentAdapter,
4
+ GuestPaths,
4
5
  SpawnOptions,
5
6
  SpawnOverrides,
6
7
  SpawnPlan,
@@ -17,12 +18,16 @@ import type { AgentSessionID } from '../shared/agent-session-id';
17
18
  import type { TargetConfigError } from '../shared/collect-targets';
18
19
  import { socketPath, statusFile } from '../shared/config';
19
20
  import type { DaemonID } from '../shared/daemon-id';
21
+ import { isBrokerVariable } from '../shared/is-broker-variable';
20
22
  import { resolveRepoRoot } from '../shared/resolve-repo-root';
21
23
  import type { SessionID } from '../shared/session-id';
22
24
  import { truncateDetail } from '../shared/truncate-detail';
23
25
  import { truncateToBytes } from '../shared/truncate-to-bytes';
24
26
  import type { FleetEntry, FleetEntryUpdate, FleetStore } from '../store/fleet-entry';
25
27
  import type { SessionWorkspace } from '../store/workspace-materialization';
28
+ import type { BrokerAuthHost } from './broker-auth-host';
29
+ import { buildAuthBinding } from './build-auth-binding';
30
+ import type { AuthBinding } from './build-auth-binding';
26
31
  import type { ExecutionTarget } from './build-execution-targets';
27
32
  import { buildSessionLifecycle } from './build-session-lifecycle';
28
33
  import type { SessionLifecycle } from './build-session-lifecycle';
@@ -33,12 +38,14 @@ import type {
33
38
  ExecutionProvider,
34
39
  HarnessHandle,
35
40
  HarnessRelay,
41
+ HarnessSpec,
36
42
  } from './execution-provider';
37
43
  import { findExecutionRefusal } from './find-execution-refusal';
38
44
  import type { BridgeBinding } from './is-binding-current';
39
45
  import { LocalPTYProvider } from './local-pty-provider';
40
46
  import { mintSessionID } from './mint-session-id';
41
47
  import { pickSessionState } from './pick-session-state';
48
+ import type { RuntimeAuthBinder } from './runtime-auth-binder';
42
49
 
43
50
  export type SessionEventKind = 'added' | 'state' | 'renamed' | 'removed';
44
51
 
@@ -186,6 +193,36 @@ const LOCAL_TARGET_IDENTITY = buildTargetIdentity('local-pty', {});
186
193
  // once after its kill and once more after a forced kill.
187
194
  const FAILED_SPAWN_EXIT_WAIT_MS = 2000;
188
195
 
196
+ // How a harness takes its credential from impd's broker: the broker the
197
+ // provider reaches, and the binding the agent's selection resolves to now.
198
+ interface HarnessAuth {
199
+ readonly host: BrokerAuthHost;
200
+ readonly binding: AuthBinding;
201
+ }
202
+
203
+ // A harness's auth as its setup applies it: a spawn's own new host creates
204
+ // the binding, and every other launch verifies the one its host holds.
205
+ interface HarnessAuthSetup extends HarnessAuth {
206
+ readonly mode: 'create' | 'verify';
207
+ readonly targetIdentity: string;
208
+ }
209
+
210
+ // A harness as its setup plans it: the agent's spawn, and the variables
211
+ // the agent's guest plan starts the harness with, which no variable atc
212
+ // adds overrides.
213
+ interface HarnessPlan extends SpawnPlan {
214
+ readonly env: Readonly<Record<string, string>>;
215
+
216
+ // What a launch behind the broker was planned under, which its host's
217
+ // binding must still admit when each request goes out; null without
218
+ // runtime auth.
219
+ readonly admission: {
220
+ readonly revision: number;
221
+ readonly hash: string;
222
+ readonly attemptID: string | null;
223
+ } | null;
224
+ }
225
+
189
226
  // The target a session runs on and the identity it is bound to there; null
190
227
  // binds a new session to the target as it stands now.
191
228
  interface TargetBinding {
@@ -218,6 +255,10 @@ export class SessionManager {
218
255
  console.error(line);
219
256
  };
220
257
 
258
+ // Binds the runtime auth of each host whose agent takes its credential
259
+ // from impd's broker; with none, every such start is refused.
260
+ authBinder: RuntimeAuthBinder | null = null;
261
+
221
262
  // Whether any registered adapter has a screen detector, decided once at
222
263
  // construction since the registry never changes afterward. Lets a hot path
223
264
  // that only cares about this answer skip walking live sessions to find it.
@@ -237,6 +278,11 @@ export class SessionManager {
237
278
  // of the same session does not start a second harness.
238
279
  private readonly adopting = new Set<SessionID>();
239
280
 
281
+ // The launches readying each host, by host key. A launch counts from its
282
+ // setup until its harness spawns, which follows the setup with no wait,
283
+ // so a host with a launch in flight is never idle.
284
+ private readonly readying = new Map<SessionID, number>();
285
+
240
286
  // Sessions dropped from the list on purpose whose rows the next fleet
241
287
  // write deletes; each stays here until a write carrying it lands.
242
288
  private readonly removedIDs = new Set<SessionID>();
@@ -476,6 +522,25 @@ export class SessionManager {
476
522
  return desired === 'sleep' ? 'asleep' : 'unknown';
477
523
  }
478
524
 
525
+ /**
526
+ * Throws the refusal for an agent that takes its credential from impd's
527
+ * broker on a target whose provider reaches no broker, before a spawn
528
+ * or an adopt does any work.
529
+ */
530
+ requireAgentTarget(agent: AgentID, target: string): void {
531
+ const adapter = this.findAdapter(agent);
532
+
533
+ if (adapter === null || (adapter.findAuthSelection?.() ?? null) === null) {
534
+ return;
535
+ }
536
+
537
+ const provider = this.targets.get(target)?.provider ?? null;
538
+
539
+ if (provider?.brokerAuth === undefined || this.authBinder === null) {
540
+ throw buildBrokerTargetRefusal(agent, target);
541
+ }
542
+ }
543
+
479
544
  // Adopts a headless session back into a terminal: a fresh PTY resumes the
480
545
  // same agent session id. On a remote host the host wakes first, and a
481
546
  // harness still running inside it is attached rather than started again;
@@ -507,18 +572,32 @@ export class SessionManager {
507
572
  }
508
573
 
509
574
  const provider = this.requireExecution(s, 'spawn').provider;
575
+ const auth = this.resolveHarnessAuth(adapter, provider, s.target);
576
+
577
+ const authSetup: HarnessAuthSetup | null =
578
+ auth === null ? null : { ...auth, mode: 'verify', targetIdentity: s.targetIdentity };
510
579
 
511
580
  this.adopting.add(id);
512
581
 
513
- let plan: SpawnPlan;
582
+ let plan: HarnessPlan;
514
583
 
515
584
  try {
516
- plan = await this.setupHarness(adapter, provider, s.id, s.hostKey, s.target, {
517
- prompt: '',
518
- resume: s.agentSessionID,
519
- ...(s.model === undefined ? {} : { model: s.model }),
520
- ...(s.effort === undefined ? {} : { effort: s.effort }),
521
- });
585
+ const setup = await this.setupHarness(
586
+ adapter,
587
+ provider,
588
+ s.id,
589
+ s.hostKey,
590
+ s.target,
591
+ {
592
+ prompt: '',
593
+ resume: s.agentSessionID,
594
+ ...(s.model === undefined ? {} : { model: s.model }),
595
+ ...(s.effort === undefined ? {} : { effort: s.effort }),
596
+ },
597
+ authSetup,
598
+ );
599
+
600
+ plan = setup.plan;
522
601
  } finally {
523
602
  this.adopting.delete(id);
524
603
  }
@@ -544,10 +623,11 @@ export class SessionManager {
544
623
  bin: plan.bin,
545
624
  args: plan.args,
546
625
  cwd: s.cwd,
547
- env: { ATC_SESSION_ID: s.id, ATC_SOCKET: socketPath },
626
+ env: { ...plan.env, ATC_SESSION_ID: s.id, ATC_SOCKET: socketPath },
548
627
  withheldEnv: s.withheldEnv,
549
628
  cols,
550
629
  rows,
630
+ ...this.buildBrokerSpec(s.hostKey, plan.admission),
551
631
  onRelay: (relay) => {
552
632
  this.onRelay(binding, relay);
553
633
  },
@@ -567,6 +647,10 @@ export class SessionManager {
567
647
  this.onEvent('state', s);
568
648
  this.emitChange();
569
649
 
650
+ if (auth !== null) {
651
+ await this.waitForAuthStart(s, provider, pty);
652
+ }
653
+
570
654
  for (const asleep of this.sessions) {
571
655
  if (asleep.hostKey === s.hostKey && asleep.target === s.target && asleep.suspended) {
572
656
  await this.tryAdoptSibling(asleep, s, cols, rows);
@@ -754,16 +838,42 @@ export class SessionManager {
754
838
  // remote directory is not on the daemon's machine, so it is its own root.
755
839
  const repoRoot = provider.remote ? cwd : resolveRepoRoot(cwd);
756
840
  const hostKey = this.pickHostKey(id, parent, target, execution.identity);
841
+ const auth = this.resolveHarnessAuth(adapter, provider, target);
757
842
 
758
- const plan = await this.setupHarness(adapter, provider, id, hostKey, target, {
759
- prompt,
760
- resume,
761
- ...overrides,
762
- });
843
+ if (hostKey !== id) {
844
+ await this.requireSharedBinding(hostKey, auth?.binding ?? null);
845
+ }
846
+
847
+ const authSetup: HarnessAuthSetup | null =
848
+ auth === null
849
+ ? null
850
+ : {
851
+ ...auth,
852
+ mode: hostKey === id ? 'create' : 'verify',
853
+ targetIdentity: execution.identity,
854
+ };
855
+
856
+ const setup = await this.setupHarness(
857
+ adapter,
858
+ provider,
859
+ id,
860
+ hostKey,
861
+ target,
862
+ { prompt, resume, ...overrides },
863
+ authSetup,
864
+ );
865
+
866
+ const plan = setup.plan;
763
867
 
764
868
  // The caller's check runs again once the host is ready, before the
765
869
  // harness starts.
766
- requireInReach();
870
+ try {
871
+ requireInReach();
872
+ } catch (error) {
873
+ await this.tryRemoveAuthAttempt(provider, hostKey, setup.attemptID);
874
+
875
+ throw error;
876
+ }
767
877
 
768
878
  const binding = this.mintBridgeBinding(id, target, execution.identity, hostKey);
769
879
 
@@ -773,10 +883,11 @@ export class SessionManager {
773
883
  bin: plan.bin,
774
884
  args: plan.args,
775
885
  cwd,
776
- env: { ATC_SESSION_ID: id, ATC_SOCKET: socketPath },
886
+ env: { ...plan.env, ATC_SESSION_ID: id, ATC_SOCKET: socketPath },
777
887
  withheldEnv: materialized?.withheldEnv ?? [],
778
888
  cols,
779
889
  rows,
890
+ ...this.buildBrokerSpec(hostKey, plan.admission),
780
891
  onRelay: (relay) => {
781
892
  this.onRelay(binding, relay);
782
893
  },
@@ -827,9 +938,151 @@ export class SessionManager {
827
938
  this.onEvent('added', session);
828
939
  this.onBoot(session, cols, rows);
829
940
 
941
+ // A start impd refuses throws here, after the session listed, so the
942
+ // caller takes the failed spawn back with its binding. A shared host
943
+ // that nothing else runs on goes back to sleep first; a host of its
944
+ // own goes with the take-back.
945
+ if (auth !== null && hostKey === id) {
946
+ await pty.waitForStart?.();
947
+ } else if (auth !== null) {
948
+ await this.waitForAuthStart(session, provider, pty);
949
+ }
950
+
951
+ if (setup.attemptID !== null) {
952
+ await this.requireAuthBinder().updateReady(hostKey, setup.attemptID);
953
+ }
954
+
830
955
  return session;
831
956
  }
832
957
 
958
+ // What a harness of this agent needs to take its credential from impd's
959
+ // broker on a provider, or null when the agent takes none. Throws the
960
+ // refusal for a target whose provider has no broker, so no credential
961
+ // ever falls back to a local start, and for a selection the current auth
962
+ // profiles cannot bind.
963
+ private resolveHarnessAuth(
964
+ adapter: AgentAdapter,
965
+ provider: ExecutionProvider,
966
+ target: string,
967
+ ): HarnessAuth | null {
968
+ const selection = adapter.findAuthSelection?.() ?? null;
969
+
970
+ if (selection === null) {
971
+ return null;
972
+ }
973
+
974
+ const host = provider.brokerAuth;
975
+
976
+ if (host === undefined || this.authBinder === null) {
977
+ throw buildBrokerTargetRefusal(adapter.id, target);
978
+ }
979
+
980
+ const planned = buildAuthBinding(selection.gateway, selection.profiles);
981
+
982
+ if ('problem' in planned) {
983
+ throw new DaemonError('auth_binding_invalid', planned.problem.message, {
984
+ agent: adapter.id,
985
+ problem: planned.problem.code,
986
+ });
987
+ }
988
+
989
+ return { host, binding: planned.binding };
990
+ }
991
+
992
+ // A sub-session joins its parent's host only under the binding that host
993
+ // holds: both without runtime auth, or both bound to the same hash.
994
+ private async requireSharedBinding(
995
+ hostKey: SessionID,
996
+ binding: AuthBinding | null,
997
+ ): Promise<void> {
998
+ const found = await this.authBinder?.findBinding(hostKey);
999
+
1000
+ const held = found ?? null;
1001
+
1002
+ if (held === null && binding === null) {
1003
+ return;
1004
+ }
1005
+
1006
+ if (held !== null && binding !== null && held.bindingHash === binding.hash) {
1007
+ return;
1008
+ }
1009
+
1010
+ throw new DaemonError(
1011
+ 'auth_binding_mismatch',
1012
+ `a sub-session joins the host of session ${hostKey} only under the runtime auth binding that host holds`,
1013
+ { host: hostKey, hostBound: held !== null, bound: binding !== null },
1014
+ );
1015
+ }
1016
+
1017
+ // Takes back what a spawn attempt bound when the spawn fails before its
1018
+ // session lists; a take-back that cannot be confirmed throws.
1019
+ private async tryRemoveAuthAttempt(
1020
+ provider: ExecutionProvider,
1021
+ hostKey: SessionID,
1022
+ attemptID: string | null,
1023
+ ): Promise<void> {
1024
+ const host = provider.brokerAuth;
1025
+
1026
+ if (attemptID === null || host === undefined || this.authBinder === null) {
1027
+ return;
1028
+ }
1029
+
1030
+ await this.authBinder.removeAttempt(host, hostKey, attemptID);
1031
+ }
1032
+
1033
+ // Waits for a revived harness behind the broker to start. A refused start
1034
+ // already ended the harness; the host goes back to sleep when no other
1035
+ // harness runs there, and the refusal is thrown.
1036
+ private async waitForAuthStart(
1037
+ s: Session,
1038
+ provider: ExecutionProvider,
1039
+ pty: HarnessHandle,
1040
+ ): Promise<void> {
1041
+ try {
1042
+ await pty.waitForStart?.();
1043
+ } catch (error) {
1044
+ if (this.isHostIdle(s.hostKey, s.target) && provider.capabilities.suspend) {
1045
+ await this.trySuspendIdleHost(provider, s.hostKey, s.target);
1046
+ }
1047
+
1048
+ throw error;
1049
+ }
1050
+ }
1051
+
1052
+ // Whether no harness runs on a host and no launch is readying it.
1053
+ private isHostIdle(hostKey: SessionID, target: string): boolean {
1054
+ return (
1055
+ !this.readying.has(hostKey) &&
1056
+ !this.sessions.some(
1057
+ (other) => other.hostKey === hostKey && other.target === target && other.pty !== null,
1058
+ )
1059
+ );
1060
+ }
1061
+
1062
+ private async trySuspendIdleHost(
1063
+ provider: ExecutionProvider,
1064
+ hostKey: SessionID,
1065
+ target: string,
1066
+ ): Promise<void> {
1067
+ try {
1068
+ await provider.suspendHost(hostKey, () => this.isHostIdle(hostKey, target));
1069
+ } catch (error) {
1070
+ this.log(
1071
+ `atc could not put the host of session ${hostKey} back to sleep after a refused start (${error instanceof Error ? error.message : String(error)})`,
1072
+ );
1073
+
1074
+ return;
1075
+ }
1076
+
1077
+ for (const onHost of this.sessions) {
1078
+ if (onHost.hostKey === hostKey && this.findProvider(onHost) === provider) {
1079
+ this.updateSuspended(onHost);
1080
+ }
1081
+ }
1082
+
1083
+ this.emitChange();
1084
+ }
1085
+
833
1086
  // The binding a harness start or attach opens its bridge under, at a
834
1087
  // fresh epoch.
835
1088
  private mintBridgeBinding(
@@ -848,7 +1101,10 @@ export class SessionManager {
848
1101
  // Readies the host a harness is about to start on and plans the harness.
849
1102
  // On a remote host the agent plans a guest spawn, whose files unpack into
850
1103
  // the session's own guest folder, and the agent's sign-in check runs
851
- // there first. Every refusal comes before the harness starts.
1104
+ // there first. Every refusal comes before the harness starts. A harness
1105
+ // behind the broker has its binding created or verified before the host
1106
+ // is readied, and a binding this call created is taken back when a later
1107
+ // step fails; the attempt that created it comes back with the plan.
852
1108
  private async setupHarness(
853
1109
  adapter: AgentAdapter,
854
1110
  provider: ExecutionProvider,
@@ -856,35 +1112,194 @@ export class SessionManager {
856
1112
  hostKey: SessionID,
857
1113
  target: string,
858
1114
  options: SpawnOptions,
859
- ): Promise<SpawnPlan> {
1115
+ auth: HarnessAuthSetup | null,
1116
+ ): Promise<{ readonly plan: HarnessPlan; readonly attemptID: string | null }> {
1117
+ this.readying.set(hostKey, (this.readying.get(hostKey) ?? 0) + 1);
1118
+
1119
+ try {
1120
+ return await this.setupHarnessOnHost(adapter, provider, id, hostKey, target, options, auth);
1121
+ } finally {
1122
+ const left = (this.readying.get(hostKey) ?? 1) - 1;
1123
+
1124
+ if (left === 0) {
1125
+ this.readying.delete(hostKey);
1126
+ } else {
1127
+ this.readying.set(hostKey, left);
1128
+ }
1129
+ }
1130
+ }
1131
+
1132
+ private async setupHarnessOnHost(
1133
+ adapter: AgentAdapter,
1134
+ provider: ExecutionProvider,
1135
+ id: SessionID,
1136
+ hostKey: SessionID,
1137
+ target: string,
1138
+ options: SpawnOptions,
1139
+ auth: HarnessAuthSetup | null,
1140
+ ): Promise<{ readonly plan: HarnessPlan; readonly attemptID: string | null }> {
860
1141
  const refusal = adapter.findSpawnRefusal?.() ?? null;
861
1142
 
862
1143
  if (refusal !== null) {
863
1144
  throw refusal;
864
1145
  }
865
1146
 
1147
+ if (auth === null) {
1148
+ await this.requireUnboundHost(adapter.id, hostKey);
1149
+ }
1150
+
866
1151
  if (!provider.remote) {
867
- await provider.prepareHost({ host: hostKey, daemonID: this.store.daemonID });
1152
+ await provider.prepareHost({
1153
+ host: hostKey,
1154
+ daemonID: this.store.daemonID,
1155
+ isIdle: () => this.isHostIdle(hostKey, target),
1156
+ });
868
1157
 
869
- return adapter.planSpawn(options);
1158
+ return {
1159
+ plan: { ...adapter.planSpawn(options), env: {}, admission: null },
1160
+ attemptID: null,
1161
+ };
870
1162
  }
871
1163
 
872
1164
  const guest = provider.guest ?? { dir: '/tmp/atc', atc: null };
873
1165
  const dir = `${guest.dir}/sessions/${id}`;
874
1166
 
1167
+ const paths: GuestPaths =
1168
+ auth === null
1169
+ ? { atc: guest.atc, dir }
1170
+ : {
1171
+ atc: guest.atc,
1172
+ dir,
1173
+ auth: await this.planGuestAuth(hostKey, auth.mode, auth.binding.placeholderEnv),
1174
+ };
1175
+
875
1176
  const plan =
876
1177
  adapter.planGuestSpawn === undefined
877
1178
  ? { ...adapter.planSpawn(options), files: {} }
878
- : adapter.planGuestSpawn(options, { atc: guest.atc, dir });
1179
+ : adapter.planGuestSpawn(options, paths);
879
1180
 
880
1181
  if (plan === null) {
881
- throw buildGuestRefusal(provider.kind, adapter.id, target, guest.atc === null);
1182
+ throw auth === null
1183
+ ? buildGuestRefusal(provider.kind, adapter.id, target, guest.atc === null)
1184
+ : new DaemonError(
1185
+ 'auth_target_unsupported',
1186
+ `agent '${adapter.id}' plans no guest settings to take its credential from impd's broker on target '${target}'`,
1187
+ { agent: adapter.id, target, problem: 'no_guest_plan' },
1188
+ );
882
1189
  }
883
1190
 
1191
+ const env = plan.env ?? {};
1192
+
1193
+ requireGuestEnv(adapter.id, target, env, auth !== null);
1194
+
1195
+ const attemptID = await this.applyHarnessAuth(hostKey, target, auth);
1196
+
1197
+ try {
1198
+ await this.setupGuest(adapter, provider, hostKey, target, dir, plan.files);
1199
+ } catch (error) {
1200
+ await this.tryRemoveAuthAttempt(provider, hostKey, attemptID);
1201
+
1202
+ throw error;
1203
+ }
1204
+
1205
+ const admission =
1206
+ auth === null || paths.auth === undefined
1207
+ ? null
1208
+ : { revision: paths.auth.revision, hash: auth.binding.hash, attemptID };
1209
+
1210
+ return { plan: { bin: plan.bin, args: plan.args, env, admission }, attemptID };
1211
+ }
1212
+
1213
+ // The part of a harness spec that keeps a launch behind the broker: the
1214
+ // requirement impd checks, and the admission each request needs from
1215
+ // the host's binding when it goes out.
1216
+ private buildBrokerSpec(
1217
+ hostKey: SessionID,
1218
+ admission: HarnessPlan['admission'],
1219
+ ): Pick<HarnessSpec, 'requireBroker' | 'admit'> {
1220
+ if (admission === null) {
1221
+ return {};
1222
+ }
1223
+
1224
+ const binder = this.requireAuthBinder();
1225
+
1226
+ return {
1227
+ requireBroker: true,
1228
+ admit: (kind, send) => binder.withLaunchAdmission(hostKey, admission, kind, send),
1229
+ };
1230
+ }
1231
+
1232
+ // A launch without runtime auth never starts on a host that holds a
1233
+ // binding, whatever its state: the imp may hold grants atc could not
1234
+ // confirm gone, and only a rebind or a forget of the host clears it.
1235
+ private async requireUnboundHost(agent: string, hostKey: SessionID): Promise<void> {
1236
+ const held = this.authBinder === null ? null : await this.authBinder.findBinding(hostKey);
1237
+
1238
+ if (held !== null) {
1239
+ throw new DaemonError(
1240
+ 'auth_rebind_required',
1241
+ `agent '${agent}' takes no credential from impd's broker, but its host holds a runtime auth binding; rebind the session or forget it`,
1242
+ { agent, state: held.state },
1243
+ );
1244
+ }
1245
+ }
1246
+
1247
+ // The binding revision and placeholders a guest plan behind the broker
1248
+ // launches under: the next host's first revision, or the revision the
1249
+ // shared host holds.
1250
+ private async planGuestAuth(
1251
+ hostKey: SessionID,
1252
+ mode: HarnessAuthSetup['mode'],
1253
+ placeholderEnv: Readonly<Record<string, string>>,
1254
+ ): Promise<NonNullable<GuestPaths['auth']>> {
1255
+ const held = mode === 'create' ? null : await this.requireAuthBinder().findBinding(hostKey);
1256
+
1257
+ return { revision: held?.revision ?? 1, env: placeholderEnv };
1258
+ }
1259
+
1260
+ // Creates the binding of a host a spawn provisions, or verifies the one
1261
+ // a revive or a sub-session launches under, and returns the attempt
1262
+ // that created it, null for a verify or no auth.
1263
+ private async applyHarnessAuth(
1264
+ hostKey: SessionID,
1265
+ target: string,
1266
+ auth: HarnessAuthSetup | null,
1267
+ ): Promise<string | null> {
1268
+ if (auth === null) {
1269
+ return null;
1270
+ }
1271
+
1272
+ const binder = this.requireAuthBinder();
1273
+
1274
+ if (auth.mode === 'verify') {
1275
+ await binder.verifyBinding(auth.host, hostKey, auth.binding);
1276
+
1277
+ return null;
1278
+ }
1279
+
1280
+ return binder.createBinding(auth.host, {
1281
+ hostKey,
1282
+ target,
1283
+ targetIdentity: auth.targetIdentity,
1284
+ binding: auth.binding,
1285
+ });
1286
+ }
1287
+
1288
+ // Readies a remote host and the files a guest plan reads there, after
1289
+ // the agent's sign-in check.
1290
+ private async setupGuest(
1291
+ adapter: AgentAdapter,
1292
+ provider: ExecutionProvider,
1293
+ hostKey: SessionID,
1294
+ target: string,
1295
+ dir: string,
1296
+ planned: Readonly<Record<string, string>>,
1297
+ ): Promise<void> {
884
1298
  await provider.prepareHost({
885
1299
  host: hostKey,
886
1300
  daemonID: this.store.daemonID,
887
1301
  installATC: adapter.planGuestSpawn !== undefined,
1302
+ isIdle: () => this.isHostIdle(hostKey, target),
888
1303
  });
889
1304
 
890
1305
  const check = adapter.planAuthCheck?.();
@@ -901,13 +1316,11 @@ export class SessionManager {
901
1316
  }
902
1317
  }
903
1318
 
904
- const files = Object.entries(plan.files).map(([path, content]) => ({ path, content }));
1319
+ const files = Object.entries(planned).map(([path, content]) => ({ path, content }));
905
1320
 
906
1321
  if (files.length > 0) {
907
1322
  await provider.transferArchive(buildTarArchive(files), dir, hostKey);
908
1323
  }
909
-
910
- return plan;
911
1324
  }
912
1325
 
913
1326
  // A sub-session runs on its parent's host when its resolved target, name
@@ -970,11 +1383,25 @@ export class SessionManager {
970
1383
  this.emitChange();
971
1384
 
972
1385
  await this.writeFleet();
1386
+ await this.removeSpawnBinding(s);
973
1387
  } finally {
974
1388
  this.rollingBack.delete(id);
975
1389
  }
976
1390
  }
977
1391
 
1392
+ // Takes back the binding a failed spawn provisioned for its own host,
1393
+ // while it is still the spawn's attempt.
1394
+ private async removeSpawnBinding(s: Session): Promise<void> {
1395
+ const provider = this.findProvider(s);
1396
+ const held = s.hostKey === s.id ? await this.authBinder?.findBinding(s.id) : null;
1397
+
1398
+ if (provider === null || held === null || held === undefined || held.state !== 'provisioning') {
1399
+ return;
1400
+ }
1401
+
1402
+ await this.tryRemoveAuthAttempt(provider, s.id, held.attemptID);
1403
+ }
1404
+
978
1405
  // Kills a failed spawn's harness and waits for its exit. A kill that
979
1406
  // throws or an exit it cannot confirm leaves the harness recorded as
980
1407
  // possibly running, which blocks revives of the session.
@@ -1340,7 +1767,14 @@ export class SessionManager {
1340
1767
  }
1341
1768
 
1342
1769
  if (destroys) {
1343
- await provider.destroyHost(s.hostKey);
1770
+ const bound =
1771
+ this.authBinder === null
1772
+ ? false
1773
+ : await this.authBinder.forgetBinding(provider.brokerAuth ?? null, s.hostKey);
1774
+
1775
+ if (!bound) {
1776
+ await provider.destroyHost(s.hostKey);
1777
+ }
1344
1778
 
1345
1779
  for (const onHost of this.sessions) {
1346
1780
  if (onHost.hostKey === s.hostKey && onHost.target === s.target && onHost.id !== s.id) {
@@ -1366,6 +1800,76 @@ export class SessionManager {
1366
1800
  return destroys;
1367
1801
  }
1368
1802
 
1803
+ /**
1804
+ * Withdraws every grant of the runtime auth binding on a session's host.
1805
+ * Launches on the host stay blocked until a rebind, and a harness that
1806
+ * runs keeps running while impd fails its later requests. Returns false
1807
+ * for no such session.
1808
+ */
1809
+ async revokeAuth(id: SessionID): Promise<boolean> {
1810
+ const s = this.sessions.find((x) => x.id === id);
1811
+
1812
+ if (s === undefined) {
1813
+ return false;
1814
+ }
1815
+
1816
+ await this.requireAuthBinder().revokeBinding(
1817
+ this.findProvider(s)?.brokerAuth ?? null,
1818
+ s.hostKey,
1819
+ );
1820
+
1821
+ return true;
1822
+ }
1823
+
1824
+ /**
1825
+ * Binds a session's host to its agent's current auth selection at the
1826
+ * next revision, and returns that revision, or null for no such session.
1827
+ */
1828
+ async updateAuth(id: SessionID): Promise<number | null> {
1829
+ const s = this.sessions.find((x) => x.id === id);
1830
+
1831
+ if (s === undefined) {
1832
+ return null;
1833
+ }
1834
+
1835
+ const adapter = this.findAdapter(s.agent);
1836
+
1837
+ if (adapter === null) {
1838
+ throw new DaemonError('unsupported_operation', `no adapter for agent '${s.agent}'`, {
1839
+ agent: s.agent,
1840
+ });
1841
+ }
1842
+
1843
+ const provider = this.requireExecution(s, 'spawn').provider;
1844
+ const auth = this.resolveHarnessAuth(adapter, provider, s.target);
1845
+
1846
+ if (auth === null) {
1847
+ throw new DaemonError(
1848
+ 'unsupported_operation',
1849
+ `agent '${s.agent}' takes no credential from impd's broker`,
1850
+ { agent: s.agent, problem: 'no_auth_selection' },
1851
+ );
1852
+ }
1853
+
1854
+ const revision = await this.requireAuthBinder().updateBinding(
1855
+ auth.host,
1856
+ s.hostKey,
1857
+ auth.binding,
1858
+ );
1859
+
1860
+ return revision;
1861
+ }
1862
+
1863
+ private requireAuthBinder(): RuntimeAuthBinder {
1864
+ if (this.authBinder === null) {
1865
+ throw new DaemonError('unsupported_operation', 'this daemon binds no runtime auth', {
1866
+ problem: 'no_auth_binder',
1867
+ });
1868
+ }
1869
+
1870
+ return this.authBinder;
1871
+ }
1872
+
1369
1873
  // Removes a forgotten session and its dead sub-sessions, except one whose
1370
1874
  // own target can destroy its host: forgetting that one destroys the host,
1371
1875
  // which takes its own confirmed forget. Only a sub-session among the given
@@ -1641,6 +2145,16 @@ function buildLifecycle(s: Session): SessionLifecycle {
1641
2145
  });
1642
2146
  }
1643
2147
 
2148
+ // An agent that takes its credential from impd's broker on a target that
2149
+ // reaches none.
2150
+ function buildBrokerTargetRefusal(agent: string, target: string): DaemonError {
2151
+ return new DaemonError(
2152
+ 'auth_target_unsupported',
2153
+ `agent '${agent}' takes its credential from impd's broker, which target '${target}' does not reach`,
2154
+ { agent, target },
2155
+ );
2156
+ }
2157
+
1644
2158
  // An agent that plans no remote spawn: for want of an atc inside the host
1645
2159
  // when the host has none, or because the agent never runs remotely.
1646
2160
  function buildGuestRefusal(
@@ -1662,6 +2176,32 @@ function buildGuestRefusal(
1662
2176
  );
1663
2177
  }
1664
2178
 
2179
+ // A guest plan's variables start the harness as they are, so none may be
2180
+ // one atc sets for its own reporting, and behind the broker none may be a
2181
+ // proxy or CA variable that would route around the broker.
2182
+ function requireGuestEnv(
2183
+ agent: string,
2184
+ target: string,
2185
+ env: Readonly<Record<string, string>>,
2186
+ behindBroker: boolean,
2187
+ ): void {
2188
+ const own = Object.keys(env).find((key) => key.startsWith('ATC_'));
2189
+
2190
+ if (own !== undefined) {
2191
+ throw new Error(`agent '${agent}' plans ${own}, which atc sets for the harness itself`);
2192
+ }
2193
+
2194
+ const broker = behindBroker ? Object.keys(env).find((key) => isBrokerVariable(key)) : undefined;
2195
+
2196
+ if (broker !== undefined) {
2197
+ throw new DaemonError(
2198
+ 'auth_target_unsupported',
2199
+ `agent '${agent}' plans ${broker} on target '${target}', which would route around impd's broker`,
2200
+ { agent, target, problem: 'guest_env_conflict', variable: broker },
2201
+ );
2202
+ }
2203
+ }
2204
+
1665
2205
  // Why a harness stopped, as a session's last message.
1666
2206
  function pickExitMessage(exit: Readonly<{ reason?: string; detail?: string }>): string {
1667
2207
  if (exit.reason === 'suspended') {