@zgeoff/atc 2.26.1 → 2.26.3

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.26.1",
3
+ "version": "2.26.3",
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,7 @@ type RefusalStep = 'confirm' | 'destination' | 'ref' | 'source';
3
3
 
4
4
  const REFUSAL_STEPS: ReadonlyMap<string, RefusalStep> = new Map<string, RefusalStep>([
5
5
  ['workspace_exists', 'destination'],
6
+ ['workspace_overlap', 'destination'],
6
7
  ['ref_not_found', 'ref'],
7
8
  ['invalid_git_url', 'source'],
8
9
  ['credential_in_url', 'source'],
@@ -976,6 +976,16 @@ export class DaemonConnection {
976
976
  throw new DaemonError('bad_args', 'a spawn with a workspace requires an absolute cwd');
977
977
  }
978
978
 
979
+ // A workspace is created, filled, and removed at the one directory
980
+ // the host resolves cwd to, which a dot segment or a control
981
+ // character would make differ from the path as written.
982
+ if (data.workspace !== undefined && !isPlainWorkspaceDir(data.cwd)) {
983
+ throw new DaemonError(
984
+ 'bad_args',
985
+ 'a spawn with a workspace requires a cwd without . or .. segments or control characters',
986
+ );
987
+ }
988
+
979
989
  let parent: SessionID | null = null;
980
990
 
981
991
  if (data.parent !== undefined) {
@@ -1791,3 +1801,12 @@ function findEventSession(event: EventMsg): SessionID | null {
1791
1801
 
1792
1802
  return null;
1793
1803
  }
1804
+
1805
+ // oxlint-disable-next-line no-control-regex -- control characters are what a workspace cwd is refused for
1806
+ const CONTROL_CHARACTER = /[\u0000-\u001F\u007F]/u;
1807
+
1808
+ function isPlainWorkspaceDir(dir: string): boolean {
1809
+ return (
1810
+ !CONTROL_CHARACTER.test(dir) && !dir.split('/').some((part) => part === '.' || part === '..')
1811
+ );
1812
+ }
@@ -938,9 +938,9 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
938
938
  });
939
939
  };
940
940
 
941
- // A spawn with a workspace source materializes it first, and the session
942
- // registers only once its workspace is ready, so no session ever lists
943
- // over a half-built checkout. A spawn that throws once its process has
941
+ // A spawn with a workspace source materializes it once every refusal has
942
+ // passed and its host is ready, and the session registers only once its
943
+ // workspace is ready, so no session ever lists over a half-built checkout. A spawn that throws once its process has
944
944
  // started takes the session back before it throws, so a failed start
945
945
  // leaves nothing running and a keyed retry spawns once. When taking it
946
946
  // back fails too, the session may still stand, and the throw says so.
@@ -949,17 +949,42 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
949
949
  id: SessionID,
950
950
  requireInReach: () => void = () => {},
951
951
  ): Promise<Readonly<Record<string, unknown>>> => {
952
- const prepared =
953
- p.workspace === null ? null : await materializeSpawnWorkspace(p, id, p.workspace);
952
+ const source = p.workspace;
953
+ let warnings: readonly string[] = [];
954
+
955
+ const materialize =
956
+ source === null
957
+ ? null
958
+ : async (
959
+ host: Readonly<{
960
+ readyHost: () => Promise<{ readonly host: SessionID; readonly dir: string }>;
961
+ removeClaim: (dir: string) => Promise<boolean>;
962
+ }>,
963
+ targetIdentity: string,
964
+ ) => {
965
+ const prepared = await materializeSpawnWorkspace(p, id, source, host, targetIdentity);
966
+
967
+ if (prepared.kind !== 'ready') {
968
+ return null;
969
+ }
970
+
971
+ warnings = prepared.warnings;
954
972
 
955
- const materialized = prepared?.kind === 'ready' ? prepared : null;
956
- const warnings = materialized === null ? [] : materialized.warnings;
973
+ return prepared;
974
+ };
957
975
 
958
976
  try {
959
- const session = await startSpawnedSession(p, id, materialized, requireInReach);
977
+ const session = await startSpawnedSession(p, id, materialize, requireInReach);
960
978
 
961
979
  return warnings.length === 0 ? { session } : { session, warnings };
962
980
  } catch (error) {
981
+ // A failed spawn gives back the workspace directory it reserved,
982
+ // unless what it did may still stand: its key then stays held as
983
+ // outcome_unknown, and so does its directory.
984
+ if (!(error instanceof EffectRemainsError)) {
985
+ mgr.releaseWorkspace(id);
986
+ }
987
+
963
988
  try {
964
989
  await mgr.removeFailedSpawn(id);
965
990
  } catch (cleanupError) {
@@ -975,16 +1000,21 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
975
1000
  }
976
1001
  };
977
1002
 
978
- // Materializes a spawn's workspace on its target. The target's identity
979
- // binds when the materialization starts, and every provider call after
980
- // passes the execution gate against that binding.
1003
+ // Materializes a spawn's workspace on the host its spawn readies once the
1004
+ // source resolves, under the target identity the spawn bound, and every
1005
+ // provider call passes the execution gate against that binding.
981
1006
  const materializeSpawnWorkspace = (
982
1007
  p: SpawnParams,
983
1008
  id: SessionID,
984
1009
  source: SpawnWorkspaceSource,
1010
+ host: Readonly<{
1011
+ readyHost: () => Promise<{ readonly host: SessionID; readonly dir: string }>;
1012
+ removeClaim: (dir: string) => Promise<boolean>;
1013
+ }>,
1014
+ targetIdentity: string,
985
1015
  ) => {
986
- const bound = mgr.requireExecution({ target: p.target, targetIdentity: null }, 'run');
987
- const binding = { target: p.target, targetIdentity: bound.identity };
1016
+ const binding = { target: p.target, targetIdentity };
1017
+ const bound = mgr.requireExecution(binding, 'run');
988
1018
 
989
1019
  return materializeWorkspace(
990
1020
  {
@@ -1000,6 +1030,8 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1000
1030
  log: (line) => {
1001
1031
  mgr.log(line);
1002
1032
  },
1033
+ readyHost: host.readyHost,
1034
+ removeClaim: host.removeClaim,
1003
1035
  stagingRoot: tmpdir(),
1004
1036
  gitTransports,
1005
1037
  },
@@ -1009,10 +1041,18 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1009
1041
  const startSpawnedSession = async (
1010
1042
  p: SpawnParams,
1011
1043
  id: SessionID,
1012
- materialized: Readonly<{
1013
- workspace: SessionWorkspace;
1014
- withheldEnv: readonly string[];
1015
- }> | null,
1044
+ materialize:
1045
+ | ((
1046
+ host: Readonly<{
1047
+ readyHost: () => Promise<{ readonly host: SessionID; readonly dir: string }>;
1048
+ removeClaim: (dir: string) => Promise<boolean>;
1049
+ }>,
1050
+ targetIdentity: string,
1051
+ ) => Promise<Readonly<{
1052
+ workspace: SessionWorkspace;
1053
+ withheldEnv: readonly string[];
1054
+ }> | null>)
1055
+ | null,
1016
1056
  requireInReach: () => void,
1017
1057
  ): Promise<SessionDescriptor> => {
1018
1058
  const s = await mgr.spawn(
@@ -1028,7 +1068,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1028
1068
  p.overrides,
1029
1069
  id,
1030
1070
  p.target,
1031
- materialized,
1071
+ materialize,
1032
1072
  requireInReach,
1033
1073
  );
1034
1074
 
@@ -248,16 +248,8 @@ export class ImpClientPort implements ImpPort {
248
248
  };
249
249
 
250
250
  // oxlint-disable-next-line prefer-readonly-parameter-types -- the command's input bytes have no readonly form
251
- readonly runCommand = async (name: string, command: ImpCommand): Promise<ImpCommandResult> => {
252
- const result = await this.tryCall((client) =>
253
- client.run(name, command.argv, {
254
- ...(command.cwd === undefined ? {} : { cwd: command.cwd }),
255
- ...(command.stdin === undefined ? {} : { stdin: command.stdin }),
256
- }),
257
- );
258
-
259
- return { code: result.code, stdout: result.stdout, stderr: result.stderr };
260
- };
251
+ readonly runCommand = (name: string, command: ImpCommand): Promise<ImpCommandResult> =>
252
+ this.tryCall((client) => runCommandInChunks(client, name, command));
261
253
 
262
254
  readonly openReverseForward = (
263
255
  name: string,
@@ -425,6 +417,51 @@ async function waitForSessionOutcome(outcome: Promise<ExecOutcome>): Promise<Imp
425
417
  return ended;
426
418
  }
427
419
 
420
+ // impd takes no WebSocket message over its size limit and closes the exec
421
+ // when one arrives, so a command's input goes over in pieces well below it.
422
+ const STDIN_CHUNK_BYTES = 1024 * 1024;
423
+
424
+ // Runs a command with its input sent a piece at a time. A command that ends
425
+ // before it takes all its input answers with its own exit, or with the
426
+ // failure that ended it, never with the refused write.
427
+ async function runCommandInChunks(
428
+ client: ImpClient,
429
+ name: string,
430
+
431
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- the command's input bytes have no readonly form
432
+ command: ImpCommand,
433
+ ): Promise<ImpCommandResult> {
434
+ const options = command.cwd === undefined ? {} : { cwd: command.cwd };
435
+
436
+ const handle = await client.openExec(name, command.argv, options);
437
+
438
+ const output = Promise.all([
439
+ new Response(handle.stdout).bytes(),
440
+ new Response(handle.stderr).bytes(),
441
+ ]);
442
+
443
+ try {
444
+ const stdin = command.stdin ?? new Uint8Array(0);
445
+
446
+ for (let at = 0; at < stdin.byteLength; at += STDIN_CHUNK_BYTES) {
447
+ await handle.write(stdin.subarray(at, at + STDIN_CHUNK_BYTES));
448
+ }
449
+
450
+ await handle.closeStdin();
451
+ } catch {
452
+ // A write fails once the exec has ended, and the exit holds why: the
453
+ // command's own exit, or the failure that ended it, which rethrows
454
+ // here. Closing first ends an exec the failed write left running.
455
+ handle.close();
456
+
457
+ await handle.exit;
458
+ }
459
+
460
+ const [exit, [stdout, stderr]] = await Promise.all([handle.exit, output]);
461
+
462
+ return { code: exit.code, stdout, stderr };
463
+ }
464
+
428
465
  // oxlint-disable-next-line prefer-readonly-parameter-types -- a promise is a live handle
429
466
  async function waitForListening(listening: Promise<unknown>): Promise<void> {
430
467
  await listening;
@@ -4,6 +4,7 @@ import { isCompiledBinary } from '../shared/is-compiled-binary';
4
4
  import type { BrokerAuthHost } from './broker-auth-host';
5
5
  import { buildImpName } from './build-imp-name';
6
6
  import { buildTarArchive } from './build-tar-archive';
7
+ import { EffectRemainsError } from './effect-remains-error';
7
8
  import type {
8
9
  CommandResult,
9
10
  CommandSpec,
@@ -219,9 +220,18 @@ export class ImpProvider implements ExecutionProvider {
219
220
 
220
221
  await this.setupGuest(name, request.installATC === true);
221
222
  } catch (error) {
222
- await this.tryUndoPrepare(name, label, created, leased);
223
+ const undone = await this.tryUndoPrepare(name, label, created, leased);
223
224
 
224
- throw toHostRefusal(error, name);
225
+ const refusal = toHostRefusal(error, name);
226
+
227
+ if (!undone) {
228
+ throw new EffectRemainsError(
229
+ `imp ${name} failed to ready and destroying the imp it created failed too`,
230
+ { cause: refusal },
231
+ );
232
+ }
233
+
234
+ throw refusal;
225
235
  }
226
236
 
227
237
  const host = this.hosts.get(request.host) ?? {
@@ -405,20 +415,29 @@ export class ImpProvider implements ExecutionProvider {
405
415
  // Takes back what a failed prepare left: an imp it created is destroyed,
406
416
  // which ends the lease on it too, since no session was ever listed on it;
407
417
  // on an imp that existed before, only the lease this prepare took is
408
- // given back, and the imp itself stays.
418
+ // given back, and the imp itself stays. Resolves to false when an imp it
419
+ // created may still stand; a lease left behind runs out on its own.
409
420
  private async tryUndoPrepare(
410
421
  name: string,
411
422
  label: string,
412
423
  created: boolean,
413
424
  leased: boolean,
414
- ): Promise<void> {
415
- try {
416
- if (created) {
425
+ ): Promise<boolean> {
426
+ if (created) {
427
+ try {
417
428
  await this.port.destroyImp(name);
418
- } else if (leased) {
419
- await this.port.releaseLease(name, label);
429
+
430
+ return true;
431
+ } catch (error) {
432
+ return error instanceof ImpPortError && error.code === 'NOT_FOUND';
420
433
  }
421
- } catch {}
434
+ }
435
+
436
+ if (leased) {
437
+ await this.port.releaseLease(name, label).catch(() => false);
438
+ }
439
+
440
+ return true;
422
441
  }
423
442
 
424
443
  // Readies the folder the harnesses' report sockets live in, and copies
@@ -17,6 +17,7 @@ import { resolveGitURL } from '../workspace/resolve-git-url';
17
17
  import { resolvePathSource } from '../workspace/resolve-path-source';
18
18
  import { runGit } from '../workspace/run-git';
19
19
  import { sanitizeWorkspaceClone } from '../workspace/sanitize-workspace-clone';
20
+ import { EffectRemainsError } from './effect-remains-error';
20
21
  import type { ExecutionProvider } from './execution-provider';
21
22
  import { requireGitTransports } from './require-git-transports';
22
23
 
@@ -33,6 +34,12 @@ interface MaterializeRequest {
33
34
  readonly inPlace: boolean;
34
35
  }
35
36
 
37
+ // The host a workspace lands on, and the directory there it is built in.
38
+ interface Landing {
39
+ readonly host: string;
40
+ readonly dir: string;
41
+ }
42
+
36
43
  interface MaterializeDeps {
37
44
  // The target's provider for one operation, after the execution gate
38
45
  // passes it; throws the gate's refusal otherwise.
@@ -40,6 +47,15 @@ interface MaterializeDeps {
40
47
  readonly store: Pick<StateStore, 'createMaterialization' | 'updateMaterialization'>;
41
48
  readonly log: (line: string) => void;
42
49
 
50
+ // Readies the host on the target the workspace lands on and resolves to
51
+ // it, once the source has resolved.
52
+ readonly readyHost: () => Promise<Landing>;
53
+
54
+ // Removes the directory this call claimed after a failure, and resolves
55
+ // to whether it did; one it leaves holds what another session needs, or
56
+ // no longer resolves to itself.
57
+ readonly removeClaim: (dir: string) => Promise<boolean>;
58
+
43
59
  // The directory on the daemon's host that holds each clone's staging
44
60
  // directory while the workspace is built.
45
61
  readonly stagingRoot: string;
@@ -63,6 +79,10 @@ interface ReadyWorkspace {
63
79
  interface MaterializationProgress {
64
80
  phase: MaterializationPhase;
65
81
 
82
+ // The host the workspace lands on and its directory there, once the
83
+ // host is ready.
84
+ landing: Landing | null;
85
+
66
86
  // Whether this call created the target directory, so a failure removes
67
87
  // only a directory it made.
68
88
  claimed: boolean;
@@ -113,7 +133,7 @@ export async function materializeWorkspace(
113
133
 
114
134
  const secret = findCredentialSecret(source);
115
135
  const withheldEnv = buildWithheldEnv(source);
116
- const progress: MaterializationProgress = { phase: 'resolving', claimed: false };
136
+ const progress: MaterializationProgress = { phase: 'resolving', landing: null, claimed: false };
117
137
 
118
138
  const updateProgress = (update: Readonly<Partial<MaterializationProgress>>) => {
119
139
  Object.assign(progress, update);
@@ -145,14 +165,28 @@ export async function materializeWorkspace(
145
165
  } catch (error) {
146
166
  const refusal = toScrubbedRefusal(error, progress.phase, secret);
147
167
 
148
- await tryRemoveClaimedDir(request, deps, progress, secret);
149
- await tryUpdateFailed(request, deps, refusal.code);
168
+ // A failure that may have left an effect standing, such as a host the
169
+ // spawn could not take back, reaches the caller as it is, so a keyed
170
+ // spawn keeps its key as outcome_unknown.
171
+ const remains = error instanceof EffectRemainsError;
172
+ const code = remains ? 'outcome_unknown' : refusal.code;
173
+
174
+ const left = await tryRemoveClaimedDir(request, deps, progress, secret);
175
+
176
+ await tryUpdateFailed(request, deps, code);
150
177
 
151
178
  deps.log(
152
- `atc: workspace for session ${request.sessionID} failed while ${progress.phase}: ${refusal.code}: ${refusal.message}`,
179
+ `atc: workspace for session ${request.sessionID} failed while ${progress.phase}: ${code}: ${refusal.message}`,
153
180
  );
154
181
 
155
- throw refusal;
182
+ if (remains) {
183
+ throw error;
184
+ }
185
+
186
+ // A directory the failure left is reported, so the caller can remove it.
187
+ throw left === null
188
+ ? refusal
189
+ : new DaemonError(refusal.code, refusal.message, { ...refusal.data, leftDir: left });
156
190
  }
157
191
  }
158
192
 
@@ -220,7 +254,11 @@ async function runMaterialization(
220
254
  const repoURL = secret === null ? pinned.repoURL : toRedacted(pinned.repoURL, secret);
221
255
  const ref = secret === null || pinned.ref === null ? pinned.ref : toRedacted(pinned.ref, secret);
222
256
 
223
- await claimTargetDir(request, deps, updateProgress);
257
+ const landing = await deps.readyHost();
258
+
259
+ updateProgress({ landing });
260
+
261
+ await claimTargetDir(request, deps, landing, updateProgress);
224
262
  await recordPhase(request, deps, updateProgress, 'cloning', { repoURL, ref });
225
263
 
226
264
  const clone = await createCleanClone(pinned, join(staging, 'clone'), transports);
@@ -231,13 +269,15 @@ async function runMaterialization(
231
269
  await recordPhase(request, deps, updateProgress, 'transferring', { sha: clone.sha });
232
270
 
233
271
  try {
234
- await deps.requireProvider('transfer').transferArchive(clone.archive, request.dir);
272
+ await deps
273
+ .requireProvider('transfer')
274
+ .transferArchive(clone.archive, landing.dir, landing.host);
235
275
  } catch (error) {
236
276
  throw toDaemonError(error, 'transfer_failed', 'transferring');
237
277
  }
238
278
 
239
279
  await recordPhase(request, deps, updateProgress, 'verifying', {});
240
- await verifyTargetHead(request, deps, clone.sha);
280
+ await verifyTargetHead(request, deps, landing, clone.sha);
241
281
 
242
282
  const materializedAt = Date.now();
243
283
 
@@ -306,6 +346,16 @@ async function resolveSource(
306
346
  };
307
347
  }
308
348
 
349
+ // A credential the clone would refuse is refused here, before any host
350
+ // is readied for it.
351
+ if (source.credentialRef !== undefined && findCredentialSecret(source) === null) {
352
+ throw new DaemonError(
353
+ 'credential_missing',
354
+ 'the credential environment variable is unset or empty',
355
+ { phase: 'resolving' },
356
+ );
357
+ }
358
+
309
359
  // The clone fetches the URL it records, so the spawn API's `owner/repo`
310
360
  // shorthand reaches the repository it expands to.
311
361
  const resolved = await resolveGitURL(expandGitShorthand(source.url), staging, transports);
@@ -359,11 +409,14 @@ async function requireNoURLCredentials(url: string, cwd: string): Promise<void>
359
409
  async function claimTargetDir(
360
410
  request: MaterializeRequest,
361
411
  deps: MaterializeDeps,
412
+ landing: Landing,
362
413
  updateProgress: ProgressTracker,
363
414
  ): Promise<void> {
364
- const parent = await deps
365
- .requireProvider('run')
366
- .runCommand({ argv: ['mkdir', '-p', '--', dirname(request.dir)], cwd: '/' });
415
+ const parent = await deps.requireProvider('run').runCommand({
416
+ argv: ['mkdir', '-p', '--', dirname(landing.dir)],
417
+ cwd: '/',
418
+ host: landing.host,
419
+ });
367
420
 
368
421
  if (parent.exitCode !== 0) {
369
422
  throw new DaemonError(
@@ -375,7 +428,7 @@ async function claimTargetDir(
375
428
 
376
429
  const claim = await deps
377
430
  .requireProvider('run')
378
- .runCommand({ argv: ['mkdir', '--', request.dir], cwd: '/' });
431
+ .runCommand({ argv: ['mkdir', '--', landing.dir], cwd: '/', host: landing.host });
379
432
 
380
433
  if (claim.exitCode !== 0) {
381
434
  throw new DaemonError(
@@ -487,11 +540,12 @@ const STATUS_ARGV = [
487
540
  async function verifyTargetHead(
488
541
  request: MaterializeRequest,
489
542
  deps: MaterializeDeps,
543
+ landing: Landing,
490
544
  sha: string,
491
545
  ): Promise<void> {
492
546
  const head = await deps
493
547
  .requireProvider('run')
494
- .runCommand({ argv: [...VERIFY_ENV, ...VERIFY_ARGV], cwd: request.dir });
548
+ .runCommand({ argv: [...VERIFY_ENV, ...VERIFY_ARGV], cwd: landing.dir, host: landing.host });
495
549
 
496
550
  const actual = head.exitCode === 0 ? head.stdout.trim() : null;
497
551
 
@@ -505,7 +559,7 @@ async function verifyTargetHead(
505
559
 
506
560
  const status = await deps
507
561
  .requireProvider('run')
508
- .runCommand({ argv: [...VERIFY_ENV, ...STATUS_ARGV], cwd: request.dir });
562
+ .runCommand({ argv: [...VERIFY_ENV, ...STATUS_ARGV], cwd: landing.dir, host: landing.host });
509
563
 
510
564
  const changed = status.stdout.split('\n').filter((line) => line !== '');
511
565
 
@@ -550,35 +604,43 @@ function toRedacted(text: string, secret: string): string {
550
604
 
551
605
  /**
552
606
  * Removes the target directory after a failure, when this call created it,
553
- * so a failed materialization leaves no partial checkout behind. A removal
554
- * that fails is logged; the directory then stays and blocks the next
555
- * materialization into it with `workspace_exists`.
607
+ * so a failed materialization leaves no partial checkout behind. Resolves
608
+ * to the directory when it stays, logged with the reason: one another
609
+ * session's directory lies inside, one that no longer resolves to itself,
610
+ * or one whose removal failed. It then blocks the next materialization
611
+ * into it with `workspace_exists` until an operator removes it.
556
612
  */
557
613
  async function tryRemoveClaimedDir(
558
614
  request: MaterializeRequest,
559
615
  deps: MaterializeDeps,
560
616
  progress: Readonly<MaterializationProgress>,
561
617
  secret: string | null,
562
- ): Promise<void> {
563
- if (!progress.claimed) {
564
- return;
618
+ ): Promise<string | null> {
619
+ if (!progress.claimed || progress.landing === null) {
620
+ return null;
565
621
  }
566
622
 
623
+ const dir = progress.landing.dir;
624
+
567
625
  try {
568
- const removed = await deps
569
- .requireProvider('run')
570
- .runCommand({ argv: ['rm', '-rf', '--', request.dir], cwd: '/' });
626
+ const removed = await deps.removeClaim(dir);
571
627
 
572
- if (removed.exitCode !== 0) {
573
- throw new Error(removed.stderr.trim());
628
+ if (removed) {
629
+ return null;
574
630
  }
631
+
632
+ deps.log(
633
+ `atc: left ${dir} on target '${request.target}' after its workspace for session ${request.sessionID} failed: another session's directory lies inside it, or it no longer resolves to itself; remove it by hand`,
634
+ );
575
635
  } catch (error) {
576
636
  const reason = toScrubbedRefusal(error, progress.phase, secret).message;
577
637
 
578
638
  deps.log(
579
- `atc: cannot remove ${request.dir} after its workspace for session ${request.sessionID} failed: ${reason}`,
639
+ `atc: left ${dir} on target '${request.target}' after its workspace for session ${request.sessionID} failed, since removing it failed: ${reason}; remove it by hand`,
580
640
  );
581
641
  }
642
+
643
+ return dir;
582
644
  }
583
645
 
584
646
  async function tryUpdateFailed(
@@ -1,4 +1,5 @@
1
1
  import { writeFileSync } from 'node:fs';
2
+ import { posix } from 'node:path';
2
3
  import type {
3
4
  AgentAdapter,
4
5
  GuestPaths,
@@ -33,6 +34,7 @@ import { buildSessionLifecycle } from './build-session-lifecycle';
33
34
  import type { SessionLifecycle } from './build-session-lifecycle';
34
35
  import { buildTarArchive } from './build-tar-archive';
35
36
  import { buildTargetIdentity } from './build-target-identity';
37
+ import { EffectRemainsError } from './effect-remains-error';
36
38
  import type {
37
39
  ExecutionCapability,
38
40
  ExecutionProvider,
@@ -185,6 +187,54 @@ interface MaterializedSpawn {
185
187
  readonly withheldEnv: readonly string[];
186
188
  }
187
189
 
190
+ // Builds a spawn's workspace on a target bound to an identity, calling
191
+ // readyHost for the host it lands on once its source resolves and asking
192
+ // canRemoveClaim before a failure removes the directory it claimed, or
193
+ // resolves to null for a directory that runs as it stands.
194
+ type SpawnMaterializer = (
195
+ host: SpawnHostAccess,
196
+ targetIdentity: string,
197
+ ) => Promise<MaterializedSpawn | null>;
198
+
199
+ interface SpawnHostAccess {
200
+ readonly readyHost: () => Promise<{ readonly host: SessionID; readonly dir: string }>;
201
+ readonly removeClaim: (dir: string) => Promise<boolean>;
202
+ }
203
+
204
+ // A workspace directory a spawn on a shared host holds, as the spawn gave
205
+ // it and as the host resolves it once checked there.
206
+ interface WorkspaceReservation {
207
+ readonly hostKey: SessionID;
208
+ readonly target: string;
209
+ readonly dir: string;
210
+ resolved: string | null;
211
+
212
+ // A workspace spawn builds its directory and may remove it on a failure;
213
+ // a plain spawn only starts in its directory, which another plain spawn
214
+ // may share.
215
+ readonly kind: 'workspace' | 'plain';
216
+ }
217
+
218
+ // Prints a directory with every symlink in it resolved: its nearest
219
+ // existing directory as the host resolves it and a newline, which keeps a
220
+ // name that ends in newlines whole, then a NUL and the rest of the path as
221
+ // given.
222
+ const RESOLVE_DIR_SCRIPT = `p=$1; s=; while [ ! -d "$p" ]; do s=/\${p##*/}$s; p=\${p%/*}; [ -n "$p" ] || p=/; done; cd -P -- "$p" && pwd -P && printf '\\0%s' "$s"`;
223
+
224
+ // Removes a directory only while it still resolves to itself: it enters
225
+ // the directory, compares where it landed with the path it was given, and
226
+ // removes the contents from inside it, so no symlink changed on the way is
227
+ // followed, then the directory itself, which is empty by then.
228
+ const REMOVE_DIR_SCRIPT =
229
+ 'cd -P -- "$1" || exit 3; [ "$(pwd -P; printf x)" = "$2" ] || exit 4; find . -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + || exit 5; cd / && rmdir -- "$1"';
230
+
231
+ // A readied host's harness plan, and the auth attempt that provisioned the
232
+ // host, if one did.
233
+ interface HarnessSetup {
234
+ readonly plan: HarnessPlan;
235
+ readonly attemptID: string | null;
236
+ }
237
+
188
238
  // The identity of the implicit `local` target, which a fleet row without a
189
239
  // stored identity ran on.
190
240
  const LOCAL_TARGET_IDENTITY = buildTargetIdentity('local-pty', {});
@@ -283,6 +333,14 @@ export class SessionManager {
283
333
  // so a host with a launch in flight is never idle.
284
334
  private readonly readying = new Map<SessionID, number>();
285
335
 
336
+ // The workspace directory each spawn on a shared host holds from its
337
+ // overlap check until its session lists or its spawn fails, by spawn id.
338
+ private readonly reservations = new Map<SessionID, WorkspaceReservation>();
339
+
340
+ // The shared hosts with a workspace rollback removing a directory now,
341
+ // each with how many removals run there.
342
+ private readonly removals = new Map<string, number>();
343
+
286
344
  // Sessions dropped from the list on purpose whose rows the next fleet
287
345
  // write deletes; each stays here until a write carrying it lands.
288
346
  private readonly removedIDs = new Set<SessionID>();
@@ -805,9 +863,11 @@ export class SessionManager {
805
863
  // the new process runs with, and the session keeps them for every revive.
806
864
  // id is minted here unless the caller minted it ahead of the spawn. target
807
865
  // is the execution target the harness runs on; one this daemon cannot use
808
- // refuses the spawn before anything starts. materialized holds what cwd
809
- // was materialized from, when it was, and the variables the session's
810
- // harnesses go without.
866
+ // refuses the spawn before anything starts. materialize builds cwd on the
867
+ // host once every refusal has passed and the host is ready, and returns
868
+ // what cwd was materialized from, when it was, and the variables the
869
+ // session's harnesses go without; a failure there takes back the host the
870
+ // spawn readied, unless it is a parent's.
811
871
  async spawn(
812
872
  cwd: string,
813
873
  name: string,
@@ -821,7 +881,7 @@ export class SessionManager {
821
881
  overrides: SpawnOverrides = {},
822
882
  id: SessionID = mintSessionID(),
823
883
  target = 'local',
824
- materialized: MaterializedSpawn | null = null,
884
+ materialize: SpawnMaterializer | null = null,
825
885
  requireInReach: () => void = () => {},
826
886
  ): Promise<Session> {
827
887
  const adapter = this.findAdapter(agent);
@@ -853,16 +913,52 @@ export class SessionManager {
853
913
  targetIdentity: execution.identity,
854
914
  };
855
915
 
856
- const setup = await this.setupHarness(
857
- adapter,
858
- provider,
859
- id,
860
- hostKey,
861
- target,
862
- { prompt, resume, ...overrides },
863
- authSetup,
864
- );
916
+ const refusal = adapter.findSpawnRefusal?.() ?? null;
917
+
918
+ if (refusal !== null) {
919
+ throw refusal;
920
+ }
921
+
922
+ if (hostKey !== id) {
923
+ if (materialize === null) {
924
+ this.claimPlainDir(id, hostKey, target, cwd);
925
+ } else {
926
+ this.claimWorkspace(id, hostKey, target, cwd);
927
+ }
928
+ }
929
+
930
+ const setupHost = () =>
931
+ this.setupHarnessOnHost(
932
+ adapter,
933
+ provider,
934
+ id,
935
+ hostKey,
936
+ target,
937
+ { prompt, resume, ...overrides },
938
+ authSetup,
939
+ );
865
940
 
941
+ // The host stays readying until its workspace is in place, so nothing
942
+ // gives its lease back or puts it to sleep in between.
943
+ const prepared = await this.withHostReadying(hostKey, async () => {
944
+ if (materialize === null) {
945
+ return { setup: await setupHost(), materialized: null };
946
+ }
947
+
948
+ return this.materializeOnSpawnHost(
949
+ provider,
950
+ id,
951
+ hostKey,
952
+ target,
953
+ cwd,
954
+ execution.identity,
955
+ materialize,
956
+ setupHost,
957
+ );
958
+ });
959
+
960
+ const setup = prepared.setup;
961
+ const materialized = prepared.materialized;
866
962
  const plan = setup.plan;
867
963
 
868
964
  // The caller's check runs again once the host is ready, before the
@@ -933,6 +1029,7 @@ export class SessionManager {
933
1029
 
934
1030
  this.attachHarness(session, pty, this.hasHostLifecycle(target));
935
1031
  this.sessions.push(session);
1032
+ this.releaseWorkspace(id);
936
1033
  void this.tryWriteFleet(session.id);
937
1034
  this.writeStatus();
938
1035
  this.onEvent('added', session);
@@ -990,7 +1087,8 @@ export class SessionManager {
990
1087
  }
991
1088
 
992
1089
  // 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.
1090
+ // holds: both without runtime auth, or both bound to the same hash, and
1091
+ // then only while that binding is ready.
994
1092
  private async requireSharedBinding(
995
1093
  hostKey: SessionID,
996
1094
  binding: AuthBinding | null,
@@ -1004,6 +1102,14 @@ export class SessionManager {
1004
1102
  }
1005
1103
 
1006
1104
  if (held !== null && binding !== null && held.bindingHash === binding.hash) {
1105
+ if (held.state !== 'ready') {
1106
+ throw new DaemonError(
1107
+ 'auth_blocked',
1108
+ `the runtime auth of host ${hostKey} is ${held.state}; rebind it to launch again`,
1109
+ { host: hostKey, state: held.state },
1110
+ );
1111
+ }
1112
+
1007
1113
  return;
1008
1114
  }
1009
1115
 
@@ -1014,6 +1120,375 @@ export class SessionManager {
1014
1120
  );
1015
1121
  }
1016
1122
 
1123
+ /**
1124
+ * Gives back the workspace directory a spawn reserved on a shared host.
1125
+ * The spawn's session listing gives it back, and so does a failed spawn,
1126
+ * except one whose effects may still stand, whose directory stays
1127
+ * reserved so no other workspace lands in or around what it left.
1128
+ */
1129
+ releaseWorkspace(id: SessionID): void {
1130
+ this.reservations.delete(id);
1131
+ }
1132
+
1133
+ // Claims a workspace's directory on a shared host, refusing one inside
1134
+ // or around the directory of a session listed there or of another spawn's
1135
+ // reservation. The check and the reservation run in one turn, so of two
1136
+ // concurrent spawns at most one passes.
1137
+ private claimWorkspace(id: SessionID, hostKey: SessionID, target: string, dir: string): void {
1138
+ const listed = this.sessions
1139
+ .filter((s) => s.hostKey === hostKey && s.target === target && posix.isAbsolute(s.cwd))
1140
+ .map((s) => [s.id, s.cwd] as const);
1141
+
1142
+ this.requireSeparateWorkspace(id, hostKey, target, dir, [dir], listed);
1143
+ this.reservations.set(id, { hostKey, target, dir, resolved: null, kind: 'workspace' });
1144
+ }
1145
+
1146
+ // Holds a plain spawn's directory on a shared host until the session
1147
+ // lists, refusing one inside or around a directory a workspace spawn is
1148
+ // still building there: that spawn's rollback could remove it. While a
1149
+ // rollback removes a directory on the host, every plain spawn there is
1150
+ // refused, since its directory may reach the one going through a
1151
+ // symlink no check has resolved yet. Plain spawns share directories
1152
+ // freely, and a rollback resolves each held plain directory on the host
1153
+ // and keeps a directory that holds one.
1154
+ private claimPlainDir(id: SessionID, hostKey: SessionID, target: string, dir: string): void {
1155
+ if ((this.removals.get(buildHostSlot(hostKey, target)) ?? 0) > 0) {
1156
+ throw new DaemonError(
1157
+ 'workspace_overlap',
1158
+ `a failed workspace spawn is removing its directory on the host of session ${hostKey}; spawn again once it is done`,
1159
+ { phase: 'resolving', dir, session: hostKey },
1160
+ );
1161
+ }
1162
+
1163
+ for (const [other, r] of this.reservations) {
1164
+ if (!posix.isAbsolute(dir)) {
1165
+ break;
1166
+ }
1167
+
1168
+ if (
1169
+ other !== id &&
1170
+ r.kind === 'workspace' &&
1171
+ r.hostKey === hostKey &&
1172
+ r.target === target &&
1173
+ [r.dir, r.resolved ?? r.dir].some((held) => isPathOverlapping(dir, held))
1174
+ ) {
1175
+ throw new DaemonError(
1176
+ 'workspace_overlap',
1177
+ `${dir} overlaps ${r.dir}, where session ${other} is still building its workspace on the same host`,
1178
+ { phase: 'resolving', dir, session: other },
1179
+ );
1180
+ }
1181
+ }
1182
+
1183
+ this.reservations.set(id, { hostKey, target, dir, resolved: null, kind: 'plain' });
1184
+ }
1185
+
1186
+ // The directory a spawn's workspace lands in, as the readied host
1187
+ // resolves it: every later step creates, fills, and removes this
1188
+ // physical path, never the requested one. On a shared host the spawn's
1189
+ // claim is checked again against every directory as the host resolves
1190
+ // it, and records the physical path, in the turn after the last wait,
1191
+ // with the sessions listed then.
1192
+ private async claimHostDir(
1193
+ provider: ExecutionProvider,
1194
+ id: SessionID,
1195
+ hostKey: SessionID,
1196
+ target: string,
1197
+ dir: string,
1198
+ ): Promise<string> {
1199
+ const before = this.collectHostSessionIDs(hostKey, target);
1200
+
1201
+ const physical = await this.resolveHostDir(provider, hostKey, dir);
1202
+ const listed = await this.resolveListedDirs(provider, hostKey, target);
1203
+
1204
+ if (this.collectHostSessionIDs(hostKey, target) !== before) {
1205
+ return this.claimHostDir(provider, id, hostKey, target, dir);
1206
+ }
1207
+
1208
+ const reservation = this.reservations.get(id);
1209
+
1210
+ if (reservation !== undefined) {
1211
+ this.requireSeparateWorkspace(id, hostKey, target, dir, [dir, physical], listed.dirs);
1212
+
1213
+ reservation.resolved = physical;
1214
+ }
1215
+
1216
+ return physical;
1217
+ }
1218
+
1219
+ // Removes the physical directory a failed spawn claimed, and resolves to
1220
+ // whether it did. It removes nothing while the directory, as the host
1221
+ // resolves it in the turn after the last wait, holds the directory of a
1222
+ // listed session or of another spawn's claim, or once the directory no
1223
+ // longer resolves to itself on the host; the directory then stays for an
1224
+ // operator to remove.
1225
+ private async removeClaimedDir(
1226
+ provider: ExecutionProvider,
1227
+ id: SessionID,
1228
+ hostKey: SessionID,
1229
+ target: string,
1230
+ dir: string,
1231
+ ): Promise<boolean> {
1232
+ if (!this.hasHostLifecycle(target)) {
1233
+ const removed = await provider.runCommand({
1234
+ argv: ['sh', '-c', REMOVE_DIR_SCRIPT, 'sh', dir, `${dir}\nx`],
1235
+ cwd: '/',
1236
+ });
1237
+
1238
+ return removed.exitCode === 0;
1239
+ }
1240
+
1241
+ const before = this.collectHostSessionIDs(hostKey, target);
1242
+
1243
+ const listed = await this.resolveListedDirs(provider, hostKey, target);
1244
+
1245
+ if (this.collectHostSessionIDs(hostKey, target) !== before) {
1246
+ return this.removeClaimedDir(provider, id, hostKey, target, dir);
1247
+ }
1248
+
1249
+ // A directory the host could not resolve may lie inside through a
1250
+ // symlink, so the rollback keeps its own.
1251
+ if (listed.unresolved) {
1252
+ return false;
1253
+ }
1254
+
1255
+ const holds = this.collectOtherWorkspaceDirs(id, hostKey, target, listed.dirs).some(
1256
+ ([, other]) => isPathWithin(other, dir),
1257
+ );
1258
+
1259
+ if (holds) {
1260
+ return false;
1261
+ }
1262
+
1263
+ const slot = buildHostSlot(hostKey, target);
1264
+
1265
+ this.removals.set(slot, (this.removals.get(slot) ?? 0) + 1);
1266
+
1267
+ try {
1268
+ const removed = await provider.runCommand({
1269
+ argv: ['sh', '-c', REMOVE_DIR_SCRIPT, 'sh', dir, `${dir}\nx`],
1270
+ cwd: '/',
1271
+ host: hostKey,
1272
+ });
1273
+
1274
+ return removed.exitCode === 0;
1275
+ } finally {
1276
+ const left = (this.removals.get(slot) ?? 1) - 1;
1277
+
1278
+ if (left === 0) {
1279
+ this.removals.delete(slot);
1280
+ } else {
1281
+ this.removals.set(slot, left);
1282
+ }
1283
+ }
1284
+ }
1285
+
1286
+ // The ids of the sessions listed on a host and of the plain spawns still
1287
+ // starting there, as one string a later read compares against.
1288
+ private collectHostSessionIDs(hostKey: SessionID, target: string): string {
1289
+ return [
1290
+ ...this.sessions.filter((s) => s.hostKey === hostKey && s.target === target).map((s) => s.id),
1291
+ ...this.collectPlainDirs(hostKey, target).map(([id]) => id),
1292
+ ].join(' ');
1293
+ }
1294
+
1295
+ // The directories plain spawns still starting on a host hold, as given.
1296
+ private collectPlainDirs(hostKey: SessionID, target: string): (readonly [SessionID, string])[] {
1297
+ return [...this.reservations]
1298
+ .filter(([, r]) => r.kind === 'plain' && r.hostKey === hostKey && r.target === target)
1299
+ .map(([id, r]) => [id, r.dir] as const);
1300
+ }
1301
+
1302
+ private requireSeparateWorkspace(
1303
+ id: SessionID,
1304
+ hostKey: SessionID,
1305
+ target: string,
1306
+ dir: string,
1307
+ forms: readonly string[],
1308
+ listed: readonly (readonly [SessionID, string])[],
1309
+ ): void {
1310
+ const other = this.collectOtherWorkspaceDirs(id, hostKey, target, listed).find(([, path]) =>
1311
+ forms.some((form) => isPathOverlapping(form, path)),
1312
+ );
1313
+
1314
+ if (other !== undefined) {
1315
+ throw new DaemonError(
1316
+ 'workspace_overlap',
1317
+ `${dir} overlaps ${other[1]}, the directory of session ${other[0]} on the same host; a workspace there lands beside it`,
1318
+ { phase: 'resolving', dir, session: other[0] },
1319
+ );
1320
+ }
1321
+ }
1322
+
1323
+ // The directories other sessions on a host hold: the listed ones given,
1324
+ // and every other spawn's reservation in each form it has.
1325
+ private collectOtherWorkspaceDirs(
1326
+ id: SessionID,
1327
+ hostKey: SessionID,
1328
+ target: string,
1329
+ listed: readonly (readonly [SessionID, string])[],
1330
+ ): (readonly [SessionID, string])[] {
1331
+ const dirs = [...listed];
1332
+
1333
+ for (const [other, r] of this.reservations) {
1334
+ if (other !== id && r.hostKey === hostKey && r.target === target) {
1335
+ // A relative plain directory counts only once the host resolves
1336
+ // it, since it resolves against the session's home there.
1337
+ dirs.push(
1338
+ ...[r.dir, r.resolved ?? r.dir]
1339
+ .filter((held) => posix.isAbsolute(held))
1340
+ .map((held) => [other, held] as const),
1341
+ );
1342
+ }
1343
+ }
1344
+
1345
+ return dirs;
1346
+ }
1347
+
1348
+ // The directory of every session listed on a host, and of every plain
1349
+ // spawn still starting there, as the host resolves it, and its own
1350
+ // absolute form, and whether any of them failed to resolve, which a
1351
+ // rollback treats as a directory that may lie inside its own.
1352
+ private async resolveListedDirs(
1353
+ provider: ExecutionProvider,
1354
+ hostKey: SessionID,
1355
+ target: string,
1356
+ ): Promise<{ readonly dirs: (readonly [SessionID, string])[]; readonly unresolved: boolean }> {
1357
+ const onHost = [
1358
+ ...this.sessions.filter((s) => s.hostKey === hostKey && s.target === target),
1359
+ ...this.collectPlainDirs(hostKey, target).map(([id, cwd]) => ({ id, cwd })),
1360
+ ];
1361
+
1362
+ const resolved = await Promise.all(
1363
+ onHost.map(async (s) => {
1364
+ const dir = await this.resolveHostDir(provider, hostKey, s.cwd).catch(() => null);
1365
+
1366
+ const dirs: (readonly [SessionID, string])[] = [];
1367
+
1368
+ if (posix.isAbsolute(s.cwd)) {
1369
+ dirs.push([s.id, s.cwd]);
1370
+ }
1371
+
1372
+ if (dir !== null) {
1373
+ dirs.push([s.id, dir]);
1374
+ }
1375
+
1376
+ return { dirs, unresolved: dir === null };
1377
+ }),
1378
+ );
1379
+
1380
+ return {
1381
+ dirs: resolved.flatMap((r) => r.dirs),
1382
+ unresolved: resolved.some((r) => r.unresolved),
1383
+ };
1384
+ }
1385
+
1386
+ // A directory on a host with every symlink in it resolved: an absolute
1387
+ // one through its nearest existing directory, and a relative one as a
1388
+ // harness started in it sees it, since impd resolves both alike. A null
1389
+ // host resolves it on the daemon's own machine.
1390
+ private async resolveHostDir(
1391
+ provider: ExecutionProvider,
1392
+ hostKey: SessionID | null,
1393
+ dir: string,
1394
+ ): Promise<string> {
1395
+ const absolute = posix.isAbsolute(dir);
1396
+
1397
+ const result = await provider.runCommand({
1398
+ argv: ['sh', '-c', RESOLVE_DIR_SCRIPT, 'sh', absolute ? posix.normalize(dir) : '.'],
1399
+ cwd: absolute ? '/' : dir,
1400
+ ...(hostKey === null ? {} : { host: hostKey }),
1401
+ });
1402
+
1403
+ const split = result.stdout.lastIndexOf('\0');
1404
+
1405
+ if (result.exitCode !== 0 || split < 1 || result.stdout[split - 1] !== '\n') {
1406
+ throw new DaemonError(
1407
+ 'host_unavailable',
1408
+ `${hostKey === null ? 'the daemon host' : `the host of session ${hostKey}`} cannot resolve ${dir}: ${result.stderr.trim()}`,
1409
+ { phase: 'resolving', dir },
1410
+ );
1411
+ }
1412
+
1413
+ const existing = result.stdout.slice(0, split - 1);
1414
+ const rest = result.stdout.slice(split + 1);
1415
+
1416
+ return existing === '/' ? rest || '/' : `${existing}${rest}`;
1417
+ }
1418
+
1419
+ // Materializes a spawn's workspace, readying its host once the source
1420
+ // resolves, or after the workspace for a directory that runs as it
1421
+ // stands. A failure once the host is ready takes it back.
1422
+ private async materializeOnSpawnHost(
1423
+ provider: ExecutionProvider,
1424
+ id: SessionID,
1425
+ hostKey: SessionID,
1426
+ target: string,
1427
+ dir: string,
1428
+ targetIdentity: string,
1429
+ materialize: SpawnMaterializer,
1430
+ setupHost: () => Promise<HarnessSetup>,
1431
+ ): Promise<{ readonly setup: HarnessSetup; readonly materialized: MaterializedSpawn | null }> {
1432
+ const readied: { setup: HarnessSetup | null } = { setup: null };
1433
+
1434
+ try {
1435
+ const materialized = await materialize(
1436
+ {
1437
+ readyHost: async () => {
1438
+ readied.setup = await setupHost();
1439
+
1440
+ const landing = this.hasHostLifecycle(target)
1441
+ ? await this.claimHostDir(provider, id, hostKey, target, dir)
1442
+ : await this.resolveHostDir(provider, null, dir);
1443
+
1444
+ return { host: hostKey, dir: landing };
1445
+ },
1446
+ removeClaim: (landing) => this.removeClaimedDir(provider, id, hostKey, target, landing),
1447
+ },
1448
+ targetIdentity,
1449
+ );
1450
+
1451
+ readied.setup ??= await setupHost();
1452
+
1453
+ return { setup: readied.setup, materialized };
1454
+ } catch (error) {
1455
+ if (readied.setup !== null) {
1456
+ await this.destroyFailedSpawnHost(provider, id, hostKey, readied.setup.attemptID);
1457
+ }
1458
+
1459
+ throw error;
1460
+ }
1461
+ }
1462
+
1463
+ // Takes back the host a spawn readied when the spawn fails before its
1464
+ // session lists: an attempt that provisioned the host takes back its imp
1465
+ // and binding, and a host of the spawn's own without one is destroyed. A
1466
+ // parent's host stays as it is. A take-back that fails throws, and a
1467
+ // destroy that fails throws that the host may still stand.
1468
+ private async destroyFailedSpawnHost(
1469
+ provider: ExecutionProvider,
1470
+ id: SessionID,
1471
+ hostKey: SessionID,
1472
+ attemptID: string | null,
1473
+ ): Promise<void> {
1474
+ if (attemptID !== null) {
1475
+ await this.tryRemoveAuthAttempt(provider, hostKey, attemptID);
1476
+
1477
+ return;
1478
+ }
1479
+
1480
+ if (hostKey === id && provider.capabilities.destroy) {
1481
+ try {
1482
+ await provider.destroyHost(hostKey);
1483
+ } catch (error) {
1484
+ throw new EffectRemainsError(
1485
+ `spawn of session ${id} failed and destroying its host failed too`,
1486
+ { cause: error },
1487
+ );
1488
+ }
1489
+ }
1490
+ }
1491
+
1017
1492
  // Takes back what a spawn attempt bound when the spawn fails before its
1018
1493
  // session lists; a take-back that cannot be confirmed throws.
1019
1494
  private async tryRemoveAuthAttempt(
@@ -1105,7 +1580,7 @@ export class SessionManager {
1105
1580
  // behind the broker has its binding created or verified before the host
1106
1581
  // is readied, and a binding this call created is taken back when a later
1107
1582
  // step fails; the attempt that created it comes back with the plan.
1108
- private async setupHarness(
1583
+ private setupHarness(
1109
1584
  adapter: AgentAdapter,
1110
1585
  provider: ExecutionProvider,
1111
1586
  id: SessionID,
@@ -1114,10 +1589,17 @@ export class SessionManager {
1114
1589
  options: SpawnOptions,
1115
1590
  auth: HarnessAuthSetup | null,
1116
1591
  ): Promise<{ readonly plan: HarnessPlan; readonly attemptID: string | null }> {
1592
+ return this.withHostReadying(hostKey, () =>
1593
+ this.setupHarnessOnHost(adapter, provider, id, hostKey, target, options, auth),
1594
+ );
1595
+ }
1596
+
1597
+ // Counts a host as readying while run runs, so it is not idle then.
1598
+ private async withHostReadying<T>(hostKey: SessionID, run: () => Promise<T>): Promise<T> {
1117
1599
  this.readying.set(hostKey, (this.readying.get(hostKey) ?? 0) + 1);
1118
1600
 
1119
1601
  try {
1120
- return await this.setupHarnessOnHost(adapter, provider, id, hostKey, target, options, auth);
1602
+ return await run();
1121
1603
  } finally {
1122
1604
  const left = (this.readying.get(hostKey) ?? 1) - 1;
1123
1605
 
@@ -2235,3 +2717,20 @@ function formatTargetRefusal(code: ErrorCode, target: string): string {
2235
2717
 
2236
2718
  return `target '${target}' cannot start a terminal`;
2237
2719
  }
2720
+
2721
+ // Whether either of two directories on a host holds the other, or they are
2722
+ // the same.
2723
+ // The key of a shared host on one target, for state kept per host.
2724
+ function buildHostSlot(hostKey: SessionID, target: string): string {
2725
+ return `${target}\0${hostKey}`;
2726
+ }
2727
+
2728
+ function isPathOverlapping(a: string, b: string): boolean {
2729
+ return isPathWithin(a, b) || isPathWithin(b, a);
2730
+ }
2731
+
2732
+ function isPathWithin(child: string, parent: string): boolean {
2733
+ const relative = posix.relative(parent, child);
2734
+
2735
+ return relative !== '..' && !relative.startsWith('../') && !posix.isAbsolute(relative);
2736
+ }
@@ -43,6 +43,7 @@ const ERROR_CODES = [
43
43
  'sanitize_failed',
44
44
  'tar_failed',
45
45
  'workspace_exists',
46
+ 'workspace_overlap',
46
47
  'transfer_failed',
47
48
  'workspace_mismatch',
48
49
  'github_unavailable',