@fungi.computer/shed 0.1.0 → 0.2.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,5 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
- import { readFile, writeFile, rm, mkdtemp } from "node:fs/promises";
2
+ import { readFile, rm, mkdtemp } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { randomUUID } from "node:crypto";
@@ -9,10 +9,11 @@ import * as z from "zod";
9
9
  import { it, type TestContext } from "node:test";
10
10
  import { DatabaseSync } from "node:sqlite";
11
11
  import {
12
+ maximumProcessRecords,
12
13
  mintComputerProcessId,
13
14
  type ComputerProcess,
14
- } from "@fungi.computer/computer/operations";
15
- import { appBackend } from "../methods.js";
15
+ } from "@fungi.computer/computer-contract";
16
+ import { appBackend, type ShedMethodContext } from "../methods.js";
16
17
  import {
17
18
  createShedRuns,
18
19
  type ShedRunInput,
@@ -26,6 +27,24 @@ import { appBackend as shedBackend } from "../methods.js";
26
27
  import shedMetadata from "../../package.json" with { type: "json" };
27
28
  const appApi = buildAppApi(shedBackend);
28
29
  const appDeclaration = buildAppDeclaration(shedBackend, shedMetadata.fungi.app);
30
+
31
+ function hostComputer(
32
+ overrides: Partial<ShedMethodContext["env"]["COMPUTER"]>,
33
+ ) {
34
+ const unexpected = () =>
35
+ Promise.reject(new Error("Unexpected host binding call"));
36
+ return {
37
+ ports: unexpected,
38
+ processes: unexpected,
39
+ exec: unexpected,
40
+ processStart: unexpected,
41
+ processKill: unexpected,
42
+ read: unexpected,
43
+ preview: unexpected,
44
+ ...overrides,
45
+ };
46
+ }
47
+
29
48
  /** A `run` App method reply that completed with a definite exit code. */
30
49
  const completedRun = z.object({ exitCode: z.number(), logPath: z.string() });
31
50
 
@@ -52,7 +71,7 @@ void it("reuses one process across repeated and restored calls, then returns its
52
71
  startedAt: 1,
53
72
  endedAt: null,
54
73
  };
55
- const computer = {
74
+ const computer = hostComputer({
56
75
  processStart: (input: { command: string; cwd?: string }) => {
57
76
  starts++;
58
77
  assert.match(input.command, /tee/);
@@ -61,7 +80,7 @@ void it("reuses one process across repeated and restored calls, then returns its
61
80
  return Promise.resolve(process);
62
81
  },
63
82
  processes: () => Promise.resolve({ processes: [process] }),
64
- };
83
+ });
65
84
  const caller = {
66
85
  actor: { kind: "member", memberId: "member-one" },
67
86
  computerId: "computer-one",
@@ -118,7 +137,7 @@ void it("tails only the recorded run log on its pinned Computer within the byte
118
137
  const context = {
119
138
  runs,
120
139
  env: {
121
- COMPUTER: {
140
+ COMPUTER: hostComputer({
122
141
  processStart: () => Promise.resolve(process),
123
142
  processes: () => Promise.resolve({ processes: [process] }),
124
143
  read: (input: {
@@ -135,7 +154,7 @@ void it("tails only the recorded run log on its pinned Computer within the byte
135
154
  truncated: true,
136
155
  });
137
156
  },
138
- },
157
+ }),
139
158
  },
140
159
  };
141
160
  const caller = {
@@ -211,7 +230,7 @@ void it("returns only an admitted preview and refuses an unexposed port", async
211
230
  },
212
231
  }),
213
232
  env: {
214
- COMPUTER: {
233
+ COMPUTER: hostComputer({
215
234
  processStart: () =>
216
235
  Promise.reject(new Error("unexpected process start")),
217
236
  processes: () => Promise.reject(new Error("unexpected process read")),
@@ -226,7 +245,7 @@ void it("returns only an admitted preview and refuses an unexposed port", async
226
245
  });
227
246
  return Promise.resolve({ error: "not_granted" });
228
247
  },
229
- },
248
+ }),
230
249
  },
231
250
  };
232
251
  const caller = {
@@ -265,7 +284,7 @@ void it("tees both real shell streams, returns a nonzero exit as a normal result
265
284
  const root = await mkdtemp(join(tmpdir(), "shed-shell-"));
266
285
  t.after(() => rm(root, { recursive: true, force: true }));
267
286
  let starts = 0;
268
- const computer = {
287
+ const computer = hostComputer({
269
288
  processStart: (input: { command: string; cwd?: string }) =>
270
289
  new Promise<ComputerProcess>((resolve, reject) => {
271
290
  starts++;
@@ -295,7 +314,7 @@ void it("tees both real shell streams, returns a nonzero exit as a normal result
295
314
  }),
296
315
  processes: () =>
297
316
  Promise.reject(new Error("completed exit must be remembered")),
298
- };
317
+ });
299
318
  const caller = {
300
319
  actor: { kind: "member", memberId: "member-one" },
301
320
  computerId: "computer-one",
@@ -333,7 +352,7 @@ void it("tees both real shell streams, returns a nonzero exit as a normal result
333
352
 
334
353
  void it("rejects a command whose quoted wrapper exceeds the native bound before claiming a run", async (t) => {
335
354
  const { processStartInputSchema } =
336
- await import("@fungi.computer/computer/operations");
355
+ await import("@fungi.computer/computer-contract");
337
356
  const database = new DatabaseSync(":memory:");
338
357
  t.after(() => {
339
358
  database.close();
@@ -347,7 +366,7 @@ void it("rejects a command whose quoted wrapper exceeds the native bound before
347
366
  },
348
367
  }),
349
368
  env: {
350
- COMPUTER: {
369
+ COMPUTER: hostComputer({
351
370
  processStart: (input: { command: string; cwd?: string }) => {
352
371
  starts++;
353
372
  processStartInputSchema
@@ -357,7 +376,7 @@ void it("rejects a command whose quoted wrapper exceeds the native bound before
357
376
  },
358
377
  processes: () => Promise.reject(new Error("unexpected process read")),
359
378
  read: () => Promise.reject(new Error("unexpected log read")),
360
- },
379
+ }),
361
380
  },
362
381
  };
363
382
  const caller = {
@@ -392,14 +411,14 @@ void it("requires the host key and Computer, and refuses to restart after a lost
392
411
  },
393
412
  };
394
413
  let starts = 0;
395
- const computer = {
414
+ const computer = hostComputer({
396
415
  processStart: () => {
397
416
  starts++;
398
417
  return Promise.reject(new Error("Start reply lost"));
399
418
  },
400
419
  processes: () =>
401
420
  Promise.reject(new Error("unidentified process must not be guessed")),
402
- };
421
+ });
403
422
  const caller = {
404
423
  actor: { kind: "member", memberId: "member-one" },
405
424
  computerId: "computer-one",
@@ -450,7 +469,7 @@ void it("publishes run as Pending-capable with its required Computer authority",
450
469
  assert.ok(expose);
451
470
  assert.equal(run.pending, true);
452
471
  assert.equal(run.effect, "write");
453
- assert.deepEqual(run.requires, ["computer.execute", "computer.writeFile"]);
472
+ assert.deepEqual(run.requires, ["computer.execute"]);
454
473
  assert.deepEqual(logs.requires, ["computer.readFile"]);
455
474
  assert.equal(logs.effect, "read");
456
475
  assert.equal(expose.effect, "write");
@@ -465,16 +484,14 @@ void it("publishes run as Pending-capable with its required Computer authority",
465
484
  "exec",
466
485
  "processStart",
467
486
  "processKill",
468
- "write",
469
487
  "read",
470
488
  "preview",
471
- "workflowGitPass",
472
489
  ],
473
490
  },
474
491
  ]);
475
492
  });
476
493
 
477
- void it("renews one Workflow selector while retaining the original process across polls and reopen", async (t) => {
494
+ void it("retains the original process across polls and reopen without any credential traffic", async (t) => {
478
495
  const database = new DatabaseSync(":memory:");
479
496
  t.after(() => {
480
497
  database.close();
@@ -485,9 +502,6 @@ void it("renews one Workflow selector while retaining the original process acros
485
502
  return { toArray: () => rows };
486
503
  },
487
504
  };
488
- let clock = 1000;
489
- const remote =
490
- "https://git.team.internal/workflow/pass_workflow/app_source.git";
491
505
  const gitAccess = {
492
506
  repository: {
493
507
  kind: "installation" as const,
@@ -498,7 +512,6 @@ void it("renews one Workflow selector while retaining the original process acros
498
512
  };
499
513
  const calls: string[] = [];
500
514
  let starts = 0;
501
- let renewals = 0;
502
515
  const process: ComputerProcess = {
503
516
  id: mintComputerProcessId(),
504
517
  port: null,
@@ -510,27 +523,14 @@ void it("renews one Workflow selector while retaining the original process acros
510
523
  startedAt: 1000,
511
524
  endedAt: null,
512
525
  };
513
- const computer = {
514
- write: (input: { path: string; content: string }) =>
515
- Promise.resolve({
516
- path: input.path,
517
- bytes: new TextEncoder().encode(input.content).byteLength,
518
- }),
519
- workflowGitPass: () => {
520
- renewals++;
521
- return Promise.resolve({
522
- passId: "pass_workflow",
523
- remote,
524
- expiresAt: clock + 60000,
525
- });
526
- },
526
+ const computer = hostComputer({
527
527
  processStart: (input: { command: string; cwd?: string }) => {
528
528
  starts++;
529
529
  calls.push(input.command);
530
530
  return Promise.resolve(process);
531
531
  },
532
532
  processes: () => Promise.resolve({ processes: [process] }),
533
- };
533
+ });
534
534
  const caller = {
535
535
  actor: { kind: "member" as const, memberId: "member_one" },
536
536
  computerId: "computer_one",
@@ -541,31 +541,18 @@ void it("renews one Workflow selector while retaining the original process acros
541
541
  cwd: "/workspace",
542
542
  gitAccess,
543
543
  };
544
- await appBackend.call(
545
- "run",
546
- input,
547
- { runs: createShedRuns(sql, () => clock), env: { COMPUTER: computer } },
548
- caller,
549
- );
550
- clock = 6000;
551
- await appBackend.call(
552
- "run",
553
- input,
554
- { runs: createShedRuns(sql, () => clock), env: { COMPUTER: computer } },
555
- caller,
556
- );
544
+ for (let poll = 0; poll < 3; poll++)
545
+ await appBackend.call(
546
+ "run",
547
+ input,
548
+ { runs: createShedRuns(sql), env: { COMPUTER: computer } },
549
+ caller,
550
+ );
551
+ // Replays neither redispatch nor call any credential binding: the Computer
552
+ // would reject an unexpected write or workflowGitPass.
557
553
  assert.equal(starts, 1);
558
- assert.equal(renewals, 2);
559
554
  assert.ok(calls[0]?.includes("FUNGI_GIT_REMOTE"));
560
- clock = 61001;
561
- await appBackend.call(
562
- "run",
563
- input,
564
- { runs: createShedRuns(sql, () => clock), env: { COMPUTER: computer } },
565
- caller,
566
- );
567
- assert.equal(starts, 1);
568
- assert.equal(renewals, 3);
555
+ assert.equal(calls[0]?.includes("wgp."), false);
569
556
  });
570
557
 
571
558
  function workflowRunFixture(t: TestContext) {
@@ -579,16 +566,7 @@ function workflowRunFixture(t: TestContext) {
579
566
  return { toArray: () => rows };
580
567
  },
581
568
  };
582
- let clock = 1000;
583
569
  let starts = 0;
584
- let mints = 0;
585
- const remote =
586
- "https://git.team.internal/workflow/pass_workflow/app_source.git";
587
- let pass: z.core.util.JSONType = {
588
- passId: "pass_workflow",
589
- remote,
590
- expiresAt: 61000,
591
- };
592
570
  const process: ComputerProcess = {
593
571
  id: mintComputerProcessId(),
594
572
  port: null,
@@ -600,29 +578,27 @@ function workflowRunFixture(t: TestContext) {
600
578
  startedAt: 1000,
601
579
  endedAt: null,
602
580
  };
603
- const delivered = new Map<string, string>();
581
+ // Shed has no credential bindings any more. These tripwires would record a
582
+ // regression that mints, writes or renews one.
583
+ const forbidden: string[] = [];
584
+ const tripwire = (operation: string) => () => {
585
+ forbidden.push(operation);
586
+ return Promise.reject(new Error(`Forbidden credential call: ${operation}`));
587
+ };
604
588
  const computer = {
605
- write: (input: { path: string; content: string }) => {
606
- delivered.set(input.path, input.content);
607
- return Promise.resolve({
608
- path: input.path,
609
- bytes: new TextEncoder().encode(input.content).byteLength,
610
- });
611
- },
612
- processStart: (input: { command: string; cwd?: string }) => {
613
- assert.equal(input.command.includes(remote), false);
614
- assert.ok([...delivered.values()].includes(`${remote}\n`));
615
- starts++;
616
- return Promise.resolve(process);
617
- },
618
- processes: () => Promise.resolve({ processes: [process] }),
619
- workflowGitPass: () => {
620
- mints++;
621
- return Promise.resolve(pass);
622
- },
589
+ ...hostComputer({
590
+ processStart: (input: { command: string; cwd?: string }) => {
591
+ assert.equal(input.command.includes("wgp."), false);
592
+ starts++;
593
+ return Promise.resolve(process);
594
+ },
595
+ processes: () => Promise.resolve({ processes: [process] }),
596
+ }),
597
+ write: tripwire("write"),
598
+ workflowGitPass: tripwire("workflowGitPass"),
623
599
  };
624
600
  const context = {
625
- runs: createShedRuns(sql, () => clock),
601
+ runs: createShedRuns(sql),
626
602
  env: { COMPUTER: computer },
627
603
  };
628
604
  const caller = {
@@ -648,42 +624,29 @@ function workflowRunFixture(t: TestContext) {
648
624
  input,
649
625
  computer,
650
626
  process,
651
- remote,
652
627
  starts: () => starts,
653
- written: () => [...delivered.values()],
654
- mints: () => mints,
655
- setClock: (value: number) => {
656
- clock = value;
657
- },
658
- setPass: (value: z.core.util.JSONType) => {
659
- pass = value;
660
- },
628
+ forbidden: () => forbidden,
661
629
  };
662
630
  }
663
631
 
664
- void it("refuses expired or changed selectors without replacing the original process", async (t) => {
632
+ void it("does not request a credential file before a refused Workflow run start", async (t) => {
665
633
  const f = workflowRunFixture(t);
666
- await appBackend.call("run", f.input, f.context, f.caller);
667
- f.setClock(61000);
668
- await assert.rejects(
669
- appBackend.call("run", f.input, f.context, f.caller),
670
- /Workflow Git pass expired/,
671
- );
672
- assert.equal(f.mints(), 1);
673
- assert.equal(f.starts(), 1);
674
- f.setClock(6000);
675
- f.setPass({
676
- passId: "pass_other",
677
- remote: "https://git.team.internal/workflow/pass_other/app_source.git",
678
- expiresAt: 66000,
634
+ let attempts = 0;
635
+ f.computer.processStart = () => {
636
+ attempts++;
637
+ return Promise.resolve({ error: "not_granted" });
638
+ };
639
+
640
+ assert.deepEqual(await appBackend.call("run", f.input, f.context, f.caller), {
641
+ error: "not_granted",
679
642
  });
680
- await assert.rejects(
681
- appBackend.call("run", f.input, f.context, f.caller),
682
- /changed or expired/,
683
- );
684
- f.setPass({ error: "revoked" });
685
- await assert.rejects(appBackend.call("run", f.input, f.context, f.caller));
686
- assert.equal(f.starts(), 1);
643
+ assert.equal(attempts, 1);
644
+ assert.deepEqual(f.forbidden(), []);
645
+ });
646
+
647
+ void it("refuses a replay whose Git scope differs from the original without replacing the process", async (t) => {
648
+ const f = workflowRunFixture(t);
649
+ await appBackend.call("run", f.input, f.context, f.caller);
687
650
  await assert.rejects(
688
651
  appBackend.call(
689
652
  "run",
@@ -703,6 +666,8 @@ void it("refuses expired or changed selectors without replacing the original pro
703
666
  ),
704
667
  /Run identity conflicts/,
705
668
  );
669
+ assert.equal(f.starts(), 1);
670
+ assert.deepEqual(f.forbidden(), []);
706
671
  });
707
672
 
708
673
  // Regression coverage for the Storyboard Save defect: a Workflow step's
@@ -759,33 +724,20 @@ void it("still throws on a real dispatch failure, distinct from a non-zero exit"
759
724
  );
760
725
  });
761
726
 
762
- void it("claims no process when the Workflow binding is missing or refuses", async (t) => {
727
+ void it("dispatches a Workflow run with only the command and calls no credential binding", async (t) => {
763
728
  const f = workflowRunFixture(t);
764
- const { workflowGitPass: omitted, ...withoutPass } = f.computer;
765
- assert.ok(omitted);
766
- await assert.rejects(
767
- appBackend.call(
768
- "run",
769
- f.input,
770
- { ...f.context, env: { COMPUTER: withoutPass } },
771
- f.caller,
772
- ),
773
- /binding is unavailable/,
774
- );
775
- f.setPass({ error: "not_granted" });
776
- await assert.rejects(appBackend.call("run", f.input, f.context, f.caller));
777
- assert.equal(f.starts(), 0);
778
- f.setPass({ passId: "pass_workflow", remote: f.remote, expiresAt: 61000 });
779
- await appBackend.call(
780
- "run",
781
- { ...f.input, command: "printf fixed" },
782
- f.context,
783
- f.caller,
784
- );
785
- assert.equal(f.starts(), 1);
729
+ const inputs: object[] = [];
730
+ f.computer.processStart = (input: { command: string; cwd?: string }) => {
731
+ inputs.push(input);
732
+ return Promise.resolve(f.process);
733
+ };
734
+ await appBackend.call("run", f.input, f.context, f.caller);
735
+ assert.equal(inputs.length, 1);
736
+ assert.deepEqual(Object.keys(inputs[0] ?? {}), ["command"]);
737
+ assert.deepEqual(f.forbidden(), []);
786
738
  });
787
739
 
788
- void it("uses the canonical resolved scope grammar before minting", async (t) => {
740
+ void it("uses the canonical resolved scope grammar before any dispatch", async (t) => {
789
741
  const f = workflowRunFixture(t);
790
742
  for (const gitAccess of [
791
743
  { repository: "$trigger.repository", branches: ["refs/heads/main"] },
@@ -816,30 +768,26 @@ void it("uses the canonical resolved scope grammar before minting", async (t) =>
816
768
  ),
817
769
  );
818
770
  assert.equal(f.starts(), 0);
819
- assert.equal(f.mints(), 0);
771
+ assert.deepEqual(f.forbidden(), []);
820
772
  });
821
773
 
822
- void it("passes the complete Workflow URL only through the command-local shell environment", async (t) => {
774
+ const hostRemote = "https://git.team.internal/app_source.git";
775
+
776
+ void it("lets the host's command-local environment reach the shell with no Shed credential file", async (t) => {
823
777
  const root = await mkdtemp(join(tmpdir(), "shed-workflow-remote-"));
824
778
  t.after(() => rm(root, { recursive: true, force: true }));
825
779
  const f = workflowRunFixture(t);
826
- let credentialPath = "";
827
- f.computer.write = (input: { path: string; content: string }) => {
828
- credentialPath = input.path.replaceAll("/workspace", root);
829
- return writeFile(
830
- input.path.replaceAll("/workspace", root),
831
- input.content,
832
- ).then(() => ({
833
- path: input.path,
834
- bytes: new TextEncoder().encode(input.content).byteLength,
835
- }));
836
- };
837
780
  f.computer.processStart = (input: { command: string; cwd?: string }) =>
838
781
  new Promise((resolve, reject) => {
782
+ // The trusted host injects the environment after native admission.
839
783
  const child = spawn(
840
784
  "bash",
841
785
  ["-c", input.command.replaceAll("/workspace", root)],
842
- { cwd: root, stdio: "ignore" },
786
+ {
787
+ cwd: root,
788
+ stdio: "ignore",
789
+ env: { PATH: process.env.PATH, FUNGI_GIT_REMOTE: hostRemote },
790
+ },
843
791
  );
844
792
  child.once("error", reject);
845
793
  child.once("exit", (code) => {
@@ -853,8 +801,7 @@ void it("passes the complete Workflow URL only through the command-local shell e
853
801
  });
854
802
  const input = {
855
803
  ...f.input,
856
- command:
857
- 'test "$FUNGI_GIT_REMOTE" = "https://git.team.internal/workflow/pass_workflow/app_source.git" && ! test -e .fungi-git-58fc43972eaef9e869f7a384f4da68f65c6b660f84e3d7fa4332f89ac6ceb7e3.remote && printf remote-matched',
804
+ command: `test "$FUNGI_GIT_REMOTE" = "${hostRemote}" && ! ls .fungi-git-* >/dev/null 2>&1 && printf remote-matched`,
858
805
  };
859
806
  const result = await appBackend.call("run", input, f.context, f.caller);
860
807
  assert.deepEqual(result, {
@@ -872,37 +819,37 @@ void it("passes the complete Workflow URL only through the command-local shell e
872
819
  ),
873
820
  "remote-matched",
874
821
  );
875
- await assert.rejects(readFile(credentialPath), { code: "ENOENT" });
822
+ assert.deepEqual(f.forbidden(), []);
876
823
  });
877
824
 
878
- void it("gives a bearer pass to git only as command-local extraHeader config", async (t) => {
825
+ void it("keeps a host-injected bearer out of the dispatched command, log, repository config and claims", async (t) => {
879
826
  const root = await mkdtemp(join(tmpdir(), "shed-workflow-bearer-"));
880
827
  t.after(() => rm(root, { recursive: true, force: true }));
881
828
  const f = workflowRunFixture(t);
882
829
  const remote = "https://oyster.test/git/bonsai/pond.git";
883
- const bearer = `wgp.workflows.${"b".repeat(64)}`;
884
- f.setPass({ passId: "pass_workflow", remote, bearer, expiresAt: 61000 });
885
- await writeFile(
886
- join(root, "expected-header"),
887
- `Authorization: Bearer ${bearer}`,
888
- );
889
- await writeFile(join(root, "expected-secret"), "b".repeat(64));
890
- let credentialPath = "";
830
+ // The presenter's secret lives only in host memory and the child's
831
+ // host-supplied environment: never in a file, argument or output.
832
+ const secret = "b".repeat(64);
833
+ const bearer = `wgp.workflows.${secret}`;
891
834
  let started = "";
892
- f.computer.write = (input: { path: string; content: string }) => {
893
- credentialPath = input.path.replaceAll("/workspace", root);
894
- return writeFile(credentialPath, input.content).then(() => ({
895
- path: input.path,
896
- bytes: new TextEncoder().encode(input.content).byteLength,
897
- }));
898
- };
899
835
  f.computer.processStart = (input: { command: string; cwd?: string }) => {
900
836
  started = input.command;
901
837
  return new Promise((resolve, reject) => {
838
+ // The frozen host-supplied environment form; Shed never builds it.
902
839
  const child = spawn(
903
840
  "bash",
904
841
  ["-c", input.command.replaceAll("/workspace", root)],
905
- { cwd: root, stdio: "ignore", env: { PATH: process.env.PATH } },
842
+ {
843
+ cwd: root,
844
+ stdio: "ignore",
845
+ env: {
846
+ PATH: process.env.PATH,
847
+ FUNGI_GIT_REMOTE: remote,
848
+ GIT_CONFIG_COUNT: "1",
849
+ GIT_CONFIG_KEY_0: `http.${remote}.extraHeader`,
850
+ GIT_CONFIG_VALUE_0: `Authorization: Bearer ${bearer}`,
851
+ },
852
+ },
906
853
  );
907
854
  child.once("error", reject);
908
855
  child.once("exit", (code) => {
@@ -920,17 +867,116 @@ void it("gives a bearer pass to git only as command-local extraHeader config", a
920
867
  command: [
921
868
  "git init -q repo",
922
869
  'test "$FUNGI_GIT_REMOTE" = "https://oyster.test/git/bonsai/pond.git"',
923
- // The expected header comes from a file, so the command never names it.
924
- 'test "$(git -C repo config --get-urlmatch http.extraHeader "$FUNGI_GIT_REMOTE")" = "$(cat expected-header)"',
870
+ // Git sees the host-scoped header; the shell builtin compares it to the
871
+ // child's own environment, so no literal secret is in the command.
872
+ 'test "$(git -C repo config --get-urlmatch http.extraHeader "$FUNGI_GIT_REMOTE")" = "$GIT_CONFIG_VALUE_0"',
925
873
  'test -z "$(git -C repo config --get-urlmatch http.extraHeader https://elsewhere.test/x.git)"',
926
- '! grep -qF "$(cat expected-secret)" repo/.git/config',
927
874
  "printf header-scoped",
928
875
  ].join(" && "),
929
876
  };
930
- const result = await appBackend.call("run", input, f.context, f.caller);
931
- assert.equal(z.object({ exitCode: z.number() }).parse(result).exitCode, 0);
932
- assert.equal(started.includes("b".repeat(64)), false);
933
- await assert.rejects(readFile(credentialPath), { code: "ENOENT" });
877
+ const result = z
878
+ .object({ exitCode: z.number(), logPath: z.string() })
879
+ .parse(await appBackend.call("run", input, f.context, f.caller));
880
+ assert.equal(result.exitCode, 0);
881
+ // Host-side secrecy assertions, after the child exited.
882
+ assert.equal(started.includes(secret), false);
883
+ assert.equal(
884
+ (await readFile(join(root, "repo/.git/config"), "utf8")).includes(secret),
885
+ false,
886
+ );
887
+ const log = await readFile(
888
+ join(root, result.logPath.replace("/workspace/", "")),
889
+ "utf8",
890
+ );
891
+ assert.equal(log, "header-scoped");
892
+ assert.equal(log.includes(secret), false);
893
+ assert.equal(
894
+ JSON.stringify(
895
+ f.sql.exec("SELECT * FROM shed_native_process_claims").toArray(),
896
+ ).includes(secret),
897
+ false,
898
+ );
899
+ assert.deepEqual(f.forbidden(), []);
900
+ });
901
+
902
+ void it("drops the superseded claim table and keeps a current claim across reopening", async (t) => {
903
+ const database = new DatabaseSync(":memory:");
904
+ t.after(() => {
905
+ database.close();
906
+ });
907
+ database.exec(
908
+ "CREATE TABLE shed_process_claims (requestKey TEXT PRIMARY KEY, computerId TEXT NOT NULL, command TEXT NOT NULL, cwd TEXT, processId TEXT, phase TEXT NOT NULL, startedAt INTEGER, exitCode INTEGER, logPath TEXT NOT NULL, gitAccess TEXT, passId TEXT, remote TEXT, expiresAt INTEGER)",
909
+ );
910
+ const sql = {
911
+ exec(query: string, ...values: (string | number | null)[]) {
912
+ const rows = database.prepare(query).all(...values);
913
+ return { toArray: () => rows };
914
+ },
915
+ };
916
+ const names = () =>
917
+ database
918
+ .prepare("SELECT name FROM sqlite_master WHERE type='table'")
919
+ .all()
920
+ .map((row) => String(row.name));
921
+ const columns = () =>
922
+ database
923
+ .prepare("PRAGMA table_info(shed_native_process_claims)")
924
+ .all()
925
+ .map((row) => String(row.name));
926
+ const process: ComputerProcess = {
927
+ id: mintComputerProcessId(),
928
+ port: null,
929
+ command: "sleep 120",
930
+ cwd: null,
931
+ state: "running",
932
+ exitCode: null,
933
+ sync: "synced",
934
+ startedAt: 1,
935
+ endedAt: null,
936
+ };
937
+ let starts = 0;
938
+ const computer = hostComputer({
939
+ processStart: () => {
940
+ starts++;
941
+ return Promise.resolve(process);
942
+ },
943
+ processes: () => Promise.resolve({ processes: [process] }),
944
+ });
945
+ const caller = {
946
+ actor: { kind: "member" as const, memberId: "member_one" },
947
+ computerId: "computer_one",
948
+ idempotencyKey: "reopen_key",
949
+ };
950
+ const open = () => ({
951
+ runs: createShedRuns(sql),
952
+ env: { COMPUTER: computer },
953
+ });
954
+ assert.equal(names().includes("shed_process_claims"), true);
955
+ const first = await appBackend.call(
956
+ "start",
957
+ { command: "sleep 120" },
958
+ open(),
959
+ caller,
960
+ );
961
+ assert.equal(names().includes("shed_process_claims"), false);
962
+ assert.deepEqual(columns(), [
963
+ "requestKey",
964
+ "computerId",
965
+ "command",
966
+ "cwd",
967
+ "processId",
968
+ "phase",
969
+ "startedAt",
970
+ "exitCode",
971
+ "logPath",
972
+ "gitAccess",
973
+ ]);
974
+ // Reopening never drops the current table: the claim replays, no new start.
975
+ assert.deepEqual(
976
+ await appBackend.call("start", { command: "sleep 120" }, open(), caller),
977
+ first,
978
+ );
979
+ assert.equal(starts, 1);
934
980
  });
935
981
 
936
982
  void it("returns a running process at start and reuses its durable claim after reopening", async (t) => {
@@ -956,7 +1002,7 @@ void it("returns a running process at start and reuses its durable claim after r
956
1002
  endedAt: null,
957
1003
  };
958
1004
  let starts = 0;
959
- const computer = {
1005
+ const computer = hostComputer({
960
1006
  processStart: (input: { command: string; cwd?: string }) => {
961
1007
  starts++;
962
1008
  assert.match(input.command, /tee/);
@@ -964,7 +1010,7 @@ void it("returns a running process at start and reuses its durable claim after r
964
1010
  return Promise.resolve(process);
965
1011
  },
966
1012
  processes: () => Promise.resolve({ processes: [process] }),
967
- };
1013
+ });
968
1014
  const caller = {
969
1015
  actor: { kind: "member", memberId: "member-one" },
970
1016
  computerId: "computer-one",
@@ -1017,7 +1063,7 @@ void it("recovers a lost start reply with the same native input instead of start
1017
1063
  endedAt: null,
1018
1064
  };
1019
1065
  const requests: unknown[] = [];
1020
- const computer = {
1066
+ const computer = hostComputer({
1021
1067
  // The native Computer owns replay identity; the binding retains the host call key.
1022
1068
  processStart: (input: { command: string; cwd?: string }) => {
1023
1069
  requests.push(input);
@@ -1026,7 +1072,7 @@ void it("recovers a lost start reply with the same native input instead of start
1026
1072
  return Promise.resolve(process);
1027
1073
  },
1028
1074
  processes: () => Promise.resolve({ processes: [process] }),
1029
- };
1075
+ });
1030
1076
  const caller = {
1031
1077
  actor: { kind: "member", memberId: "member-one" },
1032
1078
  computerId: "computer-one",
@@ -1080,7 +1126,7 @@ void it("starts a declared port and binds retry identity to the native process p
1080
1126
  endedAt: null,
1081
1127
  };
1082
1128
  const requests: unknown[] = [];
1083
- const computer = {
1129
+ const computer = hostComputer({
1084
1130
  processStart: (input: { command: string; cwd?: string; port?: number }) => {
1085
1131
  requests.push(input);
1086
1132
  assert.equal(input.port, 3000);
@@ -1089,7 +1135,7 @@ void it("starts a declared port and binds retry identity to the native process p
1089
1135
  return Promise.resolve(process);
1090
1136
  },
1091
1137
  processes: () => Promise.resolve({ processes: [process] }),
1092
- };
1138
+ });
1093
1139
  const caller = {
1094
1140
  actor: { kind: "member", memberId: "member-one" },
1095
1141
  computerId: "computer-one",
@@ -1140,23 +1186,14 @@ void it("starts a declared port and binds retry identity to the native process p
1140
1186
  assert.equal(requests.length, 1);
1141
1187
  });
1142
1188
 
1143
- void it("replays an already started process without renewing its expired Workflow Git pass", async (t) => {
1189
+ void it("replays an already started process without any credential call", async (t) => {
1144
1190
  const fixture = workflowRunFixture(t);
1145
- const expected = {
1146
- process: fixture.process,
1147
- logPath:
1148
- "/workspace/.fungi/runs/call-9a64fff978ad485e46f9452873bb5507312585c79c4265d164886c2e7cdc347c/out.log",
1149
- };
1150
- assert.deepEqual(
1151
- await appBackend.call(
1152
- "start",
1153
- fixture.input,
1154
- fixture.context,
1155
- fixture.caller,
1156
- ),
1157
- expected,
1191
+ const expected = await appBackend.call(
1192
+ "start",
1193
+ fixture.input,
1194
+ fixture.context,
1195
+ fixture.caller,
1158
1196
  );
1159
- fixture.setClock(61000);
1160
1197
  assert.deepEqual(
1161
1198
  await appBackend.call(
1162
1199
  "start",
@@ -1167,10 +1204,10 @@ void it("replays an already started process without renewing its expired Workflo
1167
1204
  expected,
1168
1205
  );
1169
1206
  assert.equal(fixture.starts(), 1);
1170
- assert.equal(fixture.mints(), 1);
1207
+ assert.deepEqual(fixture.forbidden(), []);
1171
1208
  });
1172
1209
 
1173
- void it("starts a separate native process and Git pass for another member with the same raw key", async (t) => {
1210
+ void it("starts a separate native process for another member with the same raw key", async (t) => {
1174
1211
  const f = workflowRunFixture(t);
1175
1212
  const commands: string[] = [];
1176
1213
  const processes: ComputerProcess[] = [];
@@ -1182,12 +1219,6 @@ void it("starts a separate native process and Git pass for another member with t
1182
1219
  return Promise.resolve(process);
1183
1220
  };
1184
1221
  const first = await appBackend.call("start", f.input, f.context, f.caller);
1185
- const secondRemote = "https://git.fixture/member-b.git";
1186
- f.setPass({
1187
- passId: "pass_member_b",
1188
- remote: secondRemote,
1189
- expiresAt: 60000,
1190
- });
1191
1222
  const secondCaller = {
1192
1223
  ...f.caller,
1193
1224
  actor: { kind: "member", memberId: "member_b" },
@@ -1199,14 +1230,8 @@ void it("starts a separate native process and Git pass for another member with t
1199
1230
  secondCaller,
1200
1231
  );
1201
1232
  assert.equal(commands.length, 2, "member-b must reach its own native start");
1202
- assert.equal(f.mints(), 2);
1203
- assert.equal(f.written().length, 2);
1204
- assert.equal(f.written()[0], `${f.remote}\n`);
1205
- assert.equal(f.written()[1], `${secondRemote}\n`);
1206
- assert.equal(commands[0]?.includes(f.remote), false);
1207
- assert.equal(commands[1]?.includes(secondRemote), false);
1233
+ assert.deepEqual(f.forbidden(), []);
1208
1234
  assert.notEqual(commands[0], commands[1]);
1209
- assert.equal(commands[1].includes(f.remote), false);
1210
1235
  assert.notDeepEqual(second, first);
1211
1236
  });
1212
1237
 
@@ -1465,7 +1490,7 @@ for (const actor of refusedActors)
1465
1490
  );
1466
1491
  assert.equal(starts, 0);
1467
1492
  assert.equal(
1468
- f.sql.exec("SELECT * FROM shed_process_claims").toArray().length,
1493
+ f.sql.exec("SELECT * FROM shed_native_process_claims").toArray().length,
1469
1494
  0,
1470
1495
  );
1471
1496
  });
@@ -1519,22 +1544,14 @@ void it("stops only a process claimed by this actor on this Computer", async (t)
1519
1544
  assert.equal(kills, 1);
1520
1545
  });
1521
1546
 
1522
- void it("keeps the bearer Git remote out of native stored command metadata", async (t) => {
1547
+ void it("keeps credentials out of native stored command metadata", async (t) => {
1523
1548
  const f = workflowRunFixture(t);
1524
1549
  let storedCommand = "";
1525
- let delivered = "";
1526
1550
  const context = {
1527
1551
  ...f.context,
1528
1552
  env: {
1529
1553
  COMPUTER: {
1530
1554
  ...f.computer,
1531
- write: (input: { path: string; content: string }) => {
1532
- delivered = input.content;
1533
- return Promise.resolve({
1534
- path: input.path,
1535
- bytes: new TextEncoder().encode(input.content).byteLength,
1536
- });
1537
- },
1538
1555
  processStart: (input: { command: string; cwd?: string }) => {
1539
1556
  storedCommand = input.command;
1540
1557
  return Promise.resolve({ ...f.process, command: storedCommand });
@@ -1543,42 +1560,37 @@ void it("keeps the bearer Git remote out of native stored command metadata", asy
1543
1560
  },
1544
1561
  };
1545
1562
  await appBackend.call("start", f.input, context, f.caller);
1546
- assert.equal(storedCommand.includes(f.remote), false);
1547
- assert.equal(delivered, `${f.remote}\n`);
1563
+ assert.equal(storedCommand.includes("wgp."), false);
1564
+ assert.equal(storedCommand.includes("https://"), false);
1565
+ assert.deepEqual(f.forbidden(), []);
1548
1566
  });
1549
1567
 
1550
- void it("does not reuse an expired Git remote while redispatching an uncertain start", async (t) => {
1568
+ void it("redispatches an uncertain start with the same command and no credential call", async (t) => {
1551
1569
  const f = workflowRunFixture(t);
1552
- let starts = 0;
1553
- let writes = 0;
1554
- const write = f.computer.write;
1555
- f.computer.write = (input) => {
1556
- writes++;
1557
- return write(input);
1558
- };
1559
- f.computer.processStart = () => {
1560
- starts++;
1561
- return Promise.reject(new Error("Reply lost"));
1570
+ const commands: string[] = [];
1571
+ let attempts = 0;
1572
+ f.computer.processStart = (input: { command: string; cwd?: string }) => {
1573
+ commands.push(input.command);
1574
+ attempts++;
1575
+ return attempts === 1
1576
+ ? Promise.reject(new Error("Reply lost"))
1577
+ : Promise.resolve(f.process);
1562
1578
  };
1563
1579
  await assert.rejects(
1564
1580
  appBackend.call("start", f.input, f.context, f.caller),
1565
1581
  /Reply lost/,
1566
1582
  );
1567
- const writesBeforeExpiry = writes;
1568
- f.setClock(61000);
1569
- await assert.rejects(
1570
- appBackend.call("start", f.input, f.context, f.caller),
1571
- /expired/,
1572
- );
1573
- assert.equal(starts, 1);
1574
- assert.equal(writes, writesBeforeExpiry);
1583
+ await appBackend.call("start", f.input, f.context, f.caller);
1584
+ assert.equal(attempts, 2);
1585
+ assert.equal(commands[0], commands[1]);
1586
+ assert.deepEqual(f.forbidden(), []);
1575
1587
  });
1576
1588
 
1577
1589
  void it("retires full claims with the native window and never starts a second process for a pruned uncertain key", async (t) => {
1578
1590
  const f = workflowRunFixture(t);
1579
1591
  const retained = new Map<string, ComputerProcess>();
1580
1592
  let starts = 0;
1581
- const computer = {
1593
+ const computer = hostComputer({
1582
1594
  processStart(input: { command: string; cwd?: string }) {
1583
1595
  let process = retained.get(input.command);
1584
1596
  if (!process) {
@@ -1603,14 +1615,14 @@ void it("retires full claims with the native window and never starts a second pr
1603
1615
  return Promise.resolve(process);
1604
1616
  },
1605
1617
  processes: () => Promise.resolve({ processes: [...retained.values()] }),
1606
- };
1618
+ });
1607
1619
  const context = { ...f.context, env: { COMPUTER: computer } };
1608
1620
  const input = { command: "printf done" };
1609
1621
  await assert.rejects(
1610
1622
  appBackend.call("start", input, context, f.caller),
1611
1623
  /Reply lost/,
1612
1624
  );
1613
- for (let index = 0; index < 64; index++)
1625
+ for (let index = 0; index < maximumProcessRecords; index++)
1614
1626
  await appBackend.call("start", input, context, {
1615
1627
  ...f.caller,
1616
1628
  idempotencyKey: `fresh-${String(index)}`,
@@ -1620,7 +1632,7 @@ void it("retires full claims with the native window and never starts a second pr
1620
1632
  });
1621
1633
  assert.equal(starts, 65);
1622
1634
  assert.equal(
1623
- f.sql.exec("SELECT * FROM shed_process_claims").toArray().length,
1635
+ f.sql.exec("SELECT * FROM shed_native_process_claims").toArray().length,
1624
1636
  64,
1625
1637
  );
1626
1638
  });
@@ -1638,7 +1650,7 @@ for (const mode of ["start", "run"] as const)
1638
1650
  endedAt: 2000,
1639
1651
  }));
1640
1652
  let truncated = false;
1641
- const computer = {
1653
+ const computer = hostComputer({
1642
1654
  processStart: (input: { command: string }) => {
1643
1655
  const process: ComputerProcess = {
1644
1656
  ...f.process,
@@ -1657,7 +1669,7 @@ for (const mode of ["start", "run"] as const)
1657
1669
  }),
1658
1670
  processKill: () =>
1659
1671
  Promise.resolve({ process: f.process, signalled: true }),
1660
- };
1672
+ });
1661
1673
  const context = { ...f.context, env: { COMPUTER: computer } };
1662
1674
  const input = { command: "sleep 120" };
1663
1675
  const first = await appBackend.call(mode, input, context, f.caller);
@@ -1701,7 +1713,7 @@ for (const mode of ["start", "run"] as const)
1701
1713
  void it("bounds retired markers per member and method to the latest 64 retired calls", async (t) => {
1702
1714
  const f = workflowRunFixture(t);
1703
1715
  let starts = 0;
1704
- const computer = {
1716
+ const computer = hostComputer({
1705
1717
  processStart: (input: { command: string }) => {
1706
1718
  starts++;
1707
1719
  return Promise.resolve({
@@ -1715,7 +1727,7 @@ void it("bounds retired markers per member and method to the latest 64 retired c
1715
1727
  });
1716
1728
  },
1717
1729
  processes: () => Promise.resolve({ processes: [] }),
1718
- };
1730
+ });
1719
1731
  const context = { ...f.context, env: { COMPUTER: computer } };
1720
1732
  for (let index = 0; index < 260; index++) {
1721
1733
  await appBackend.call("start", { command: "true" }, context, {
@@ -1737,7 +1749,7 @@ void it("bounds retired markers per member and method to the latest 64 retired c
1737
1749
  assert.equal(starts, 260);
1738
1750
  });
1739
1751
 
1740
- void it("keeps the native command identity stable across a Git refusal and re-mint", async (t) => {
1752
+ void it("keeps the native command identity stable across a refusal and retry", async (t) => {
1741
1753
  const f = workflowRunFixture(t);
1742
1754
  const accepted = new Map<string, ComputerProcess>();
1743
1755
  let calls = 0;
@@ -1758,13 +1770,180 @@ void it("keeps the native command identity stable across a Git refusal and re-mi
1758
1770
  assert.deepEqual(await appBackend.call("start", f.input, context, f.caller), {
1759
1771
  error: "unavailable",
1760
1772
  });
1761
- f.setPass({
1762
- passId: "pass_reminted",
1763
- remote: "https://git.team.internal/workflow/pass_reminted/app_source.git",
1764
- expiresAt: 61000,
1765
- });
1766
1773
  await appBackend.call("start", f.input, context, f.caller);
1767
- assert.equal(f.mints(), 2);
1774
+ assert.deepEqual(f.forbidden(), []);
1768
1775
  assert.equal(accepted.size, 1);
1769
1776
  assert.equal(calls, 2);
1770
1777
  });
1778
+
1779
+ function bindingFailure(code = "binding_not_granted") {
1780
+ return Object.assign(new Error("App binding refused"), {
1781
+ name: "AppInstanceError",
1782
+ code,
1783
+ });
1784
+ }
1785
+
1786
+ function otherBindingFailures() {
1787
+ return [
1788
+ bindingFailure("binding_unavailable"),
1789
+ bindingFailure("invalid_invocation_facts"),
1790
+ new Error("binding_not_granted"),
1791
+ ];
1792
+ }
1793
+
1794
+ for (const mode of ["start", "run"] as const) {
1795
+ for (const binding of ["processStart"] as const) {
1796
+ void it(`returns a typed denied ${binding} during ${mode}, clears the claim and can retry after grant`, async (t) => {
1797
+ const f = workflowRunFixture(t);
1798
+ const processStart = f.computer.processStart;
1799
+ const denied = () => Promise.reject(bindingFailure());
1800
+ f.computer.processStart = denied;
1801
+ assert.deepEqual(
1802
+ await appBackend.call(mode, f.input, f.context, f.caller),
1803
+ { error: "not_granted" },
1804
+ );
1805
+ assert.equal(f.starts(), 0);
1806
+ assert.deepEqual(f.forbidden(), []);
1807
+ assert.equal(
1808
+ f.sql.exec("SELECT * FROM shed_native_process_claims").toArray().length,
1809
+ 0,
1810
+ );
1811
+ f.computer.processStart = processStart;
1812
+ await appBackend.call(mode, f.input, f.context, f.caller);
1813
+ assert.equal(f.starts(), 1);
1814
+ });
1815
+
1816
+ void it(`rethrows other ${binding} failures during ${mode} without pretending the claim is safe to restart`, async (t) => {
1817
+ for (const failure of otherBindingFailures()) {
1818
+ const f = workflowRunFixture(t);
1819
+ const denied = () => Promise.reject(failure);
1820
+ f.computer.processStart = denied;
1821
+ await assert.rejects(
1822
+ appBackend.call(mode, f.input, f.context, f.caller),
1823
+ (cause: unknown) => cause === failure,
1824
+ );
1825
+ assert.equal(f.starts(), 0);
1826
+ assert.equal(
1827
+ f.sql.exec("SELECT * FROM shed_native_process_claims").toArray()
1828
+ .length,
1829
+ 1,
1830
+ );
1831
+ }
1832
+ });
1833
+ }
1834
+ }
1835
+
1836
+ void it("returns a typed denied inventory refresh without admitting another claim", async (t) => {
1837
+ const f = workflowRunFixture(t);
1838
+ let starts = 0;
1839
+ f.computer.processStart = (input) => {
1840
+ starts++;
1841
+ return Promise.resolve({
1842
+ ...f.process,
1843
+ id: mintComputerProcessId(),
1844
+ command: input.command,
1845
+ cwd: input.cwd ?? null,
1846
+ });
1847
+ };
1848
+ for (let index = 0; index < maximumProcessRecords; index++)
1849
+ await appBackend.call("start", { command: "sleep 120" }, f.context, {
1850
+ ...f.caller,
1851
+ idempotencyKey: `retained-${String(index)}`,
1852
+ });
1853
+ f.computer.processes = () => Promise.reject(bindingFailure());
1854
+ assert.deepEqual(
1855
+ await appBackend.call("start", { command: "sleep 120" }, f.context, {
1856
+ ...f.caller,
1857
+ idempotencyKey: "not-admitted",
1858
+ }),
1859
+ { error: "not_granted" },
1860
+ );
1861
+ assert.equal(starts, maximumProcessRecords);
1862
+ assert.equal(
1863
+ f.sql.exec("SELECT * FROM shed_native_process_claims").toArray().length,
1864
+ maximumProcessRecords,
1865
+ );
1866
+ for (const failure of otherBindingFailures()) {
1867
+ f.computer.processes = () => Promise.reject(failure);
1868
+ await assert.rejects(
1869
+ appBackend.call("start", { command: "sleep 120" }, f.context, {
1870
+ ...f.caller,
1871
+ idempotencyKey: "not-admitted",
1872
+ }),
1873
+ (cause: unknown) => cause === failure,
1874
+ );
1875
+ }
1876
+ assert.equal(starts, maximumProcessRecords);
1877
+ });
1878
+
1879
+ void it("returns a typed denied stop only after the retained process ownership check", async (t) => {
1880
+ const f = workflowRunFixture(t);
1881
+ await appBackend.call("start", f.input, f.context, f.caller);
1882
+ let kills = 0;
1883
+ let failure: Error = bindingFailure();
1884
+ const context = {
1885
+ ...f.context,
1886
+ env: {
1887
+ COMPUTER: {
1888
+ ...f.computer,
1889
+ processKill: () => {
1890
+ kills++;
1891
+ return Promise.reject(failure);
1892
+ },
1893
+ },
1894
+ },
1895
+ };
1896
+ assert.deepEqual(
1897
+ await appBackend.call(
1898
+ "stop",
1899
+ { id: mintComputerProcessId() },
1900
+ context,
1901
+ f.caller,
1902
+ ),
1903
+ { error: "not_found" },
1904
+ );
1905
+ assert.equal(kills, 0);
1906
+ assert.deepEqual(
1907
+ await appBackend.call("stop", { id: f.process.id }, context, f.caller),
1908
+ { error: "not_granted" },
1909
+ );
1910
+ assert.equal(kills, 1);
1911
+ for (const other of otherBindingFailures()) {
1912
+ failure = other;
1913
+ await assert.rejects(
1914
+ appBackend.call("stop", { id: f.process.id }, context, f.caller),
1915
+ (cause: unknown) => cause === other,
1916
+ );
1917
+ }
1918
+ assert.equal(kills, 4);
1919
+ });
1920
+
1921
+ for (const site of ["resume", "expose", "logs"] as const) {
1922
+ void it(`preserves every binding failure identity at strict ${site}`, async (t) => {
1923
+ for (const failure of [bindingFailure(), ...otherBindingFailures()]) {
1924
+ const f = workflowRunFixture(t);
1925
+ if (site === "resume") {
1926
+ await appBackend.call("run", f.input, f.context, f.caller);
1927
+ f.computer.processes = () => Promise.reject(failure);
1928
+ } else if (site === "expose") {
1929
+ f.computer.preview = () => Promise.reject(failure);
1930
+ } else {
1931
+ await appBackend.call("start", f.input, f.context, f.caller);
1932
+ f.computer.read = () => Promise.reject(failure);
1933
+ }
1934
+ const call =
1935
+ site === "expose"
1936
+ ? appBackend.call("expose", { port: 3000 }, f.context, f.caller)
1937
+ : site === "logs"
1938
+ ? appBackend.call(
1939
+ "logs",
1940
+ { run: f.caller.idempotencyKey },
1941
+ f.context,
1942
+ f.caller,
1943
+ )
1944
+ : appBackend.call("run", f.input, f.context, f.caller);
1945
+ await assert.rejects(call, (cause: unknown) => cause === failure);
1946
+ assert.equal(f.starts(), site === "resume" || site === "logs" ? 1 : 0);
1947
+ }
1948
+ });
1949
+ }