@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.
@@ -4,18 +4,47 @@ import {
4
4
  maximumStreamBytes,
5
5
  mintComputerProcessId,
6
6
  processListOutputSchema,
7
- } from "@fungi.computer/computer/operations";
7
+ } from "@fungi.computer/computer-contract";
8
8
  import { Shed } from "../index.js";
9
9
  import * as z from "zod";
10
10
  import { expect, it, onTestFinished } from "vitest";
11
11
  import { appBackend, type ShedMethodContext } from "../methods.js";
12
+
13
+ function hostComputer(
14
+ overrides: Partial<ShedMethodContext["env"]["COMPUTER"]>,
15
+ ) {
16
+ const unexpected = () =>
17
+ Promise.reject(new Error("Unexpected host binding call"));
18
+ return {
19
+ ports: unexpected,
20
+ processes: unexpected,
21
+ exec: unexpected,
22
+ processStart: unexpected,
23
+ processKill: unexpected,
24
+ read: unexpected,
25
+ preview: unexpected,
26
+ ...overrides,
27
+ };
28
+ }
29
+
30
+ function bindingFailure(code = "binding_not_granted") {
31
+ return Object.assign(new Error("App binding refused"), {
32
+ name: "AppInstanceError",
33
+ code,
34
+ });
35
+ }
36
+
12
37
  it("lists ports only through the admitted Computer binding", async () => {
13
38
  expect(
14
39
  await appBackend.call(
15
40
  "listPorts",
16
41
  {},
17
42
  {
18
- env: { COMPUTER: { ports: () => Promise.resolve({ ports: [3000] }) } },
43
+ env: {
44
+ COMPUTER: hostComputer({
45
+ ports: () => Promise.resolve({ ports: [3000] }),
46
+ }),
47
+ },
19
48
  },
20
49
  {
21
50
  actor: { kind: "agent", agentId: "agent-a" },
@@ -32,12 +61,12 @@ it("forwards inventories without accepting selectors or starting commands", asyn
32
61
  };
33
62
  const context: ShedMethodContext = {
34
63
  env: {
35
- COMPUTER: {
64
+ COMPUTER: hostComputer({
36
65
  processes: (input) => {
37
66
  calls.push(input);
38
67
  return Promise.resolve({ processes: [] });
39
68
  },
40
- },
69
+ }),
41
70
  },
42
71
  };
43
72
  expect(await appBackend.call("listProcesses", {}, context, caller)).toEqual({
@@ -72,12 +101,12 @@ it("passes command and cwd unchanged with a host-owned request identity", async
72
101
  { command: "printf hi", cwd: "/work", timeoutMs: 45000 },
73
102
  {
74
103
  env: {
75
- COMPUTER: {
104
+ COMPUTER: hostComputer({
76
105
  exec: (input) => {
77
106
  calls.push(input);
78
107
  return Promise.resolve(result);
79
108
  },
80
- },
109
+ }),
81
110
  },
82
111
  },
83
112
  {
@@ -85,7 +114,13 @@ it("passes command and cwd unchanged with a host-owned request identity", async
85
114
  computerId: "computer-a",
86
115
  },
87
116
  ),
88
- ).toEqual(result);
117
+ ).toEqual({
118
+ stdout: "hi",
119
+ stderr: "",
120
+ exitCode: 0,
121
+ stdoutTruncated: false,
122
+ stderrTruncated: false,
123
+ });
89
124
  expect(calls).toEqual([
90
125
  { command: "printf hi", cwd: "/work", timeoutMs: 45000 },
91
126
  ]);
@@ -107,12 +142,12 @@ it.each([
107
142
  z.json().parse(input),
108
143
  {
109
144
  env: {
110
- COMPUTER: {
145
+ COMPUTER: hostComputer({
111
146
  exec: () => {
112
147
  calls++;
113
148
  return Promise.resolve({});
114
149
  },
115
- },
150
+ }),
116
151
  },
117
152
  },
118
153
  {
@@ -123,7 +158,7 @@ it.each([
123
158
  ).rejects.toThrow();
124
159
  expect(calls).toBe(0);
125
160
  });
126
- it("retains typed execution budget and timeout refusals", async () => {
161
+ it("keeps budget retry guidance and omits timeout host request identity", async () => {
127
162
  const budget = {
128
163
  error: "attachment_budget_exceeded",
129
164
  kind: "exec",
@@ -140,7 +175,9 @@ it("retains typed execution budget and timeout refusals", async () => {
140
175
  { command: "echo hi" },
141
176
  {
142
177
  env: {
143
- COMPUTER: { exec: () => Promise.resolve(z.json().parse(reply)) },
178
+ COMPUTER: hostComputer({
179
+ exec: () => Promise.resolve(z.json().parse(reply)),
180
+ }),
144
181
  },
145
182
  },
146
183
  {
@@ -148,7 +185,7 @@ it("retains typed execution budget and timeout refusals", async () => {
148
185
  computerId: "computer-a",
149
186
  },
150
187
  ),
151
- ).toEqual(reply);
188
+ ).toEqual(reply.error === "timeout" ? { error: reply.error } : reply);
152
189
  });
153
190
  it("refuses an absent pin and missing execution binding", async () => {
154
191
  let calls = 0;
@@ -158,12 +195,12 @@ it("refuses an absent pin and missing execution binding", async () => {
158
195
  { command: "echo hi" },
159
196
  {
160
197
  env: {
161
- COMPUTER: {
198
+ COMPUTER: hostComputer({
162
199
  exec: () => {
163
200
  calls++;
164
201
  return Promise.resolve({});
165
202
  },
166
- },
203
+ }),
167
204
  },
168
205
  },
169
206
  { actor: { kind: "agent", agentId: "agent-a" }, computerId: null },
@@ -173,7 +210,13 @@ it("refuses an absent pin and missing execution binding", async () => {
173
210
  await appBackend.call(
174
211
  "listPorts",
175
212
  {},
176
- { env: {} },
213
+ {
214
+ env: {
215
+ COMPUTER: hostComputer({
216
+ ports: () => Promise.reject(bindingFailure()),
217
+ }),
218
+ },
219
+ },
177
220
  {
178
221
  actor: { kind: "agent", agentId: "agent-a" },
179
222
  computerId: "computer-a",
@@ -193,12 +236,12 @@ it("bounds inventory and command replies", async () => {
193
236
  {},
194
237
  {
195
238
  env: {
196
- COMPUTER: {
239
+ COMPUTER: hostComputer({
197
240
  ports: () =>
198
241
  Promise.resolve({
199
242
  ports: Array.from({ length: 65 }, () => 3000),
200
243
  }),
201
- },
244
+ }),
202
245
  },
203
246
  },
204
247
  caller,
@@ -210,7 +253,7 @@ it("bounds inventory and command replies", async () => {
210
253
  { command: "echo hi" },
211
254
  {
212
255
  env: {
213
- COMPUTER: {
256
+ COMPUTER: hostComputer({
214
257
  exec: () =>
215
258
  Promise.resolve({
216
259
  requestId: "0199caff-aaaa-7000-8000-aaaaaaaaaaaa",
@@ -223,7 +266,7 @@ it("bounds inventory and command replies", async () => {
223
266
  truncated: false,
224
267
  },
225
268
  }),
226
- },
269
+ }),
227
270
  },
228
271
  },
229
272
  caller,
@@ -251,7 +294,10 @@ it("returns exit status for both streams cut inside UTF-8 by the Computer", asyn
251
294
  exitCode: 0,
252
295
  stdout: stream,
253
296
  stderr: stream,
254
- truncated: true,
297
+ truncated: [
298
+ { stream: "stdout", keptBytes: maximumStreamBytes, totalBytes: 66000 },
299
+ { stream: "stderr", keptBytes: maximumStreamBytes, totalBytes: 66000 },
300
+ ],
255
301
  },
256
302
  };
257
303
  let calls = 0;
@@ -261,12 +307,12 @@ it("returns exit status for both streams cut inside UTF-8 by the Computer", asyn
261
307
  { command: "emit multibyte streams" },
262
308
  {
263
309
  env: {
264
- COMPUTER: {
310
+ COMPUTER: hostComputer({
265
311
  exec: () => {
266
312
  calls++;
267
313
  return Promise.resolve(reply);
268
314
  },
269
- },
315
+ }),
270
316
  },
271
317
  },
272
318
  {
@@ -274,9 +320,66 @@ it("returns exit status for both streams cut inside UTF-8 by the Computer", asyn
274
320
  computerId: "computer-a",
275
321
  },
276
322
  ),
277
- ).toEqual(reply);
323
+ ).toEqual({
324
+ stdout: stream,
325
+ stderr: stream,
326
+ exitCode: 0,
327
+ stdoutTruncated: true,
328
+ stderrTruncated: true,
329
+ });
278
330
  expect(calls).toBe(1);
279
331
  });
332
+ it.each([
333
+ {
334
+ output: null,
335
+ expected: { error: "output_unavailable" },
336
+ },
337
+ {
338
+ output: {
339
+ status: "failed",
340
+ exitCode: 7,
341
+ stdout: "partial output",
342
+ stderr: "command failed",
343
+ truncated: [
344
+ { stream: "stderr", keptBytes: maximumStreamBytes, totalBytes: 70000 },
345
+ ],
346
+ },
347
+ expected: {
348
+ stdout: "partial output",
349
+ stderr: "command failed",
350
+ exitCode: 7,
351
+ stdoutTruncated: false,
352
+ stderrTruncated: true,
353
+ },
354
+ },
355
+ ])(
356
+ "projects the host outcome without fabricating success: $expected",
357
+ async ({ output, expected }) => {
358
+ expect(
359
+ await appBackend.call(
360
+ "runCommand",
361
+ { command: "exit 7" },
362
+ {
363
+ env: {
364
+ COMPUTER: hostComputer({
365
+ exec: () =>
366
+ Promise.resolve({
367
+ requestId: "0199caff-aaaa-7000-8000-aaaaaaaaaaaa",
368
+ status: "failed",
369
+ output,
370
+ }),
371
+ }),
372
+ },
373
+ },
374
+ {
375
+ actor: { kind: "agent", agentId: "agent-a" },
376
+ computerId: "computer-a",
377
+ },
378
+ ),
379
+ ).toEqual(expected);
380
+ },
381
+ );
382
+
280
383
  it("retains a process whose native cwd contains parent segments", async () => {
281
384
  const reply = {
282
385
  processes: [
@@ -297,7 +400,11 @@ it("retains a process whose native cwd contains parent segments", async () => {
297
400
  await appBackend.call(
298
401
  "listProcesses",
299
402
  {},
300
- { env: { COMPUTER: { processes: () => Promise.resolve(reply) } } },
403
+ {
404
+ env: {
405
+ COMPUTER: hostComputer({ processes: () => Promise.resolve(reply) }),
406
+ },
407
+ },
301
408
  {
302
409
  actor: { kind: "agent", agentId: "agent-a" },
303
410
  computerId: "computer-a",
@@ -326,7 +433,7 @@ it("pages retained processes on the admitted Computer without starting work and
326
433
  };
327
434
  const context: ShedMethodContext = {
328
435
  env: {
329
- COMPUTER: {
436
+ COMPUTER: hostComputer({
330
437
  processes: (input) => {
331
438
  expect(input).toEqual({});
332
439
  reads++;
@@ -334,7 +441,7 @@ it("pages retained processes on the admitted Computer without starting work and
334
441
  },
335
442
  processStart: notStarted,
336
443
  exec: notStarted,
337
- },
444
+ }),
338
445
  },
339
446
  };
340
447
  const caller: Parameters<typeof appBackend.call>[3] = {
@@ -386,12 +493,12 @@ it("projects a process page through the declared read Gill with App-local naviga
386
493
  };
387
494
  const context: ShedMethodContext = {
388
495
  env: {
389
- COMPUTER: {
496
+ COMPUTER: hostComputer({
390
497
  processes: () => {
391
498
  reads++;
392
499
  return Promise.resolve({ processes: [process] });
393
500
  },
394
- },
501
+ }),
395
502
  },
396
503
  };
397
504
  const reply = await appBackend.gill(
@@ -417,7 +524,17 @@ it("keeps inventory refusals actionable and separate from empty without guessing
417
524
  computerId: "computer-a",
418
525
  };
419
526
  const request = { id: "processes", params: { computer: "computer-a" } };
420
- const missing = await appBackend.gill(request, { env: {} }, caller);
527
+ const missing = await appBackend.gill(
528
+ request,
529
+ {
530
+ env: {
531
+ COMPUTER: hostComputer({
532
+ processes: () => Promise.reject(bindingFailure()),
533
+ }),
534
+ },
535
+ },
536
+ caller,
537
+ );
421
538
  expect(missing).toMatchObject({
422
539
  state: "error",
423
540
  rows: [],
@@ -427,9 +544,9 @@ it("keeps inventory refusals actionable and separate from empty without guessing
427
544
  request,
428
545
  {
429
546
  env: {
430
- COMPUTER: {
547
+ COMPUTER: hostComputer({
431
548
  processes: () => Promise.resolve({ error: "unavailable" }),
432
- },
549
+ }),
433
550
  },
434
551
  },
435
552
  caller,
@@ -443,7 +560,9 @@ it("keeps inventory refusals actionable and separate from empty without guessing
443
560
  request,
444
561
  {
445
562
  env: {
446
- COMPUTER: { processes: () => Promise.resolve({ processes: [] }) },
563
+ COMPUTER: hostComputer({
564
+ processes: () => Promise.resolve({ processes: [] }),
565
+ }),
447
566
  },
448
567
  },
449
568
  caller,
@@ -467,9 +586,9 @@ it("shortens long display text without losing a retained process or changing its
467
586
  { id: "processes", params: { computer: "computer-a" } },
468
587
  {
469
588
  env: {
470
- COMPUTER: {
589
+ COMPUTER: hostComputer({
471
590
  processes: () => Promise.resolve({ processes: [process] }),
472
- },
591
+ }),
473
592
  },
474
593
  },
475
594
  {
@@ -516,14 +635,14 @@ it("stops the selected process through the pinned Computer without accepting ano
516
635
  },
517
636
  }),
518
637
  env: {
519
- COMPUTER: {
638
+ COMPUTER: hostComputer({
520
639
  processStart: () => Promise.resolve(process),
521
640
  processes: () => Promise.resolve({ processes: [process] }),
522
641
  processKill: (input: { id: string }) => {
523
642
  calls.push(input);
524
643
  return Promise.resolve(reply);
525
644
  },
526
- },
645
+ }),
527
646
  },
528
647
  };
529
648
  const caller = {
@@ -554,7 +673,7 @@ it("stops the selected process through the pinned Computer without accepting ano
554
673
  ).rejects.toThrow();
555
674
  expect(calls).toEqual([{ id: process.id }]);
556
675
  });
557
- it("presents a Workflow step's read pass to runCommand only through a one-shot credential file", async () => {
676
+ it("sends a Workflow step's runCommand as ordinary exec input, with no credential transport", async () => {
558
677
  const gitAccess = {
559
678
  repository: {
560
679
  kind: "installation",
@@ -563,10 +682,7 @@ it("presents a Workflow step's read pass to runCommand only through a one-shot c
563
682
  },
564
683
  access: "read",
565
684
  } as const;
566
- const bearer = "wgp.library.secret-pass";
567
- const remote = "https://oyster.test/git/bonsai-a/pond.git";
568
- const writes: { path: string; content: string }[] = [];
569
- const execs: { command: string; timeoutMs?: number }[] = [];
685
+ const execs: unknown[] = [];
570
686
  const result = {
571
687
  requestId: "0199caff-aaaa-7000-8000-aaaaaaaaaaaa",
572
688
  status: "succeeded",
@@ -578,27 +694,15 @@ it("presents a Workflow step's read pass to runCommand only through a one-shot c
578
694
  truncated: false,
579
695
  },
580
696
  };
581
- const context = (pass: unknown): ShedMethodContext => ({
697
+ const context: ShedMethodContext = {
582
698
  env: {
583
- COMPUTER: {
584
- workflowGitPass: () => Promise.resolve(z.json().parse(pass)),
585
- write: (input) => {
586
- writes.push(input);
587
- return Promise.resolve({
588
- path: input.path,
589
- bytes: new TextEncoder().encode(input.content).byteLength,
590
- });
591
- },
699
+ COMPUTER: hostComputer({
592
700
  exec: (input) => {
593
701
  execs.push(input);
594
702
  return Promise.resolve(result);
595
703
  },
596
- },
704
+ }),
597
705
  },
598
- });
599
- const caller: Parameters<typeof appBackend.call>[3] = {
600
- actor: { kind: "member", memberId: "member-a" },
601
- computerId: "computer-a",
602
706
  };
603
707
  const input = z.json().parse({
604
708
  command: 'git clone -- "$FUNGI_GIT_REMOTE" repo',
@@ -606,57 +710,146 @@ it("presents a Workflow step's read pass to runCommand only through a one-shot c
606
710
  gitAccess,
607
711
  });
608
712
  expect(
609
- await appBackend.call(
610
- "runCommand",
611
- input,
612
- context({
613
- passId: "pass",
614
- remote,
615
- bearer,
616
- expiresAt: Date.now() + 60_000,
713
+ await appBackend.call("runCommand", input, context, {
714
+ actor: { kind: "member", memberId: "member-a" },
715
+ computerId: "computer-a",
716
+ }),
717
+ ).toEqual({
718
+ stdout: "",
719
+ stderr: "",
720
+ exitCode: 0,
721
+ stdoutTruncated: false,
722
+ stderrTruncated: false,
723
+ });
724
+ // gitAccess is identity only: the host supplies $FUNGI_GIT_REMOTE and its
725
+ // credentials, so exactly the command and timeout cross the binding.
726
+ expect(execs).toEqual([
727
+ { command: 'git clone -- "$FUNGI_GIT_REMOTE" repo', timeoutMs: 45000 },
728
+ ]);
729
+ });
730
+
731
+ const directBindings = [
732
+ ["listPorts", "ports"],
733
+ ["listProcesses", "processes"],
734
+ ["runCommand", "exec"],
735
+ ] as const;
736
+ const bindingCaller = {
737
+ actor: { kind: "member", memberId: "member-a" },
738
+ computerId: "computer-a",
739
+ } satisfies Parameters<typeof appBackend.call>[3];
740
+
741
+ it.each(directBindings)(
742
+ "maps a typed denied %s invocation",
743
+ async (method, binding) => {
744
+ let calls = 0;
745
+ const context = {
746
+ env: {
747
+ COMPUTER: hostComputer({
748
+ [binding]: () => {
749
+ calls++;
750
+ return Promise.reject(bindingFailure());
751
+ },
752
+ }),
753
+ },
754
+ };
755
+ expect(
756
+ await appBackend.call(
757
+ method,
758
+ method === "runCommand" ? { command: "printf hi" } : {},
759
+ context,
760
+ bindingCaller,
761
+ ),
762
+ ).toEqual({ error: "not_granted" });
763
+ expect(calls).toBe(1);
764
+ expect(
765
+ await appBackend.call(
766
+ method,
767
+ method === "runCommand" ? { command: "printf hi" } : {},
768
+ context,
769
+ { ...bindingCaller, computerId: null },
770
+ ),
771
+ ).toEqual({ error: "not_granted" });
772
+ expect(calls).toBe(1);
773
+ },
774
+ );
775
+
776
+ const workflowInput = {
777
+ command: 'git push "$FUNGI_GIT_REMOTE" HEAD:refs/heads/main',
778
+ gitAccess: {
779
+ repository: {
780
+ kind: "installation",
781
+ installationId: "installation_one",
782
+ repository: "app_source",
783
+ },
784
+ branches: ["refs/heads/main"],
785
+ },
786
+ };
787
+
788
+ function workflowBindingFixture(failure: Error) {
789
+ const calls: string[] = [];
790
+ return {
791
+ calls,
792
+ env: {
793
+ COMPUTER: hostComputer({
794
+ exec: () => {
795
+ calls.push("exec");
796
+ return Promise.reject(failure);
797
+ },
617
798
  }),
618
- caller,
619
- ),
620
- ).toEqual(result);
621
- expect(writes).toHaveLength(1);
622
- const [file] = writes;
623
- expect(file?.content).toBe(`${remote}\n${bearer}\n`);
624
- expect(file?.path).toMatch(/^\/workspace\/\.fungi-git-[0-9a-f]{32}\.remote$/);
625
- const [exec] = execs;
626
- expect(exec?.timeoutMs).toBe(45000);
627
- // The pass travels only as command-local Git config read from the file.
628
- expect(exec?.command).not.toContain(bearer);
629
- expect(exec?.command).toContain(`rm -f -- '${file?.path ?? ""}'`);
630
- expect(exec?.command).toContain(
631
- 'GIT_CONFIG_KEY_0="http.$_fungi_git_remote.extraHeader"',
632
- );
633
- // A refused pass runs nothing and reports the refusal as the step result.
634
- execs.length = 0;
635
- writes.length = 0;
799
+ },
800
+ };
801
+ }
802
+
803
+ it("maps a typed denied Workflow command exec", async () => {
804
+ const context = workflowBindingFixture(bindingFailure());
636
805
  expect(
637
- await appBackend.call(
638
- "runCommand",
639
- input,
640
- context({ error: "revoked" }),
641
- caller,
642
- ),
643
- ).toEqual({ error: "revoked" });
644
- expect(execs).toEqual([]);
645
- expect(writes).toEqual([]);
646
- // Without a write binding there is no credential path, so nothing runs.
647
- await expect(
648
- appBackend.call(
649
- "runCommand",
650
- input,
651
- {
652
- env: {
653
- COMPUTER: {
654
- exec: () => Promise.reject(new Error("must not exec")),
655
- workflowGitPass: () => Promise.reject(new Error("must not mint")),
806
+ await appBackend.call("runCommand", workflowInput, context, bindingCaller),
807
+ ).toEqual({ error: "not_granted" });
808
+ expect(context.calls).toEqual(["exec"]);
809
+ expect(
810
+ await appBackend.call("runCommand", workflowInput, context, {
811
+ ...bindingCaller,
812
+ computerId: null,
813
+ }),
814
+ ).toEqual({ error: "not_granted" });
815
+ expect(context.calls).toEqual(["exec"]);
816
+ });
817
+
818
+ function otherBindingFailures() {
819
+ return [
820
+ bindingFailure("binding_unavailable"),
821
+ bindingFailure("invalid_invocation_facts"),
822
+ new Error("binding_not_granted"),
823
+ ];
824
+ }
825
+
826
+ it.each(directBindings)(
827
+ "rethrows other %s binding failures unchanged",
828
+ async (method, binding) => {
829
+ for (const failure of otherBindingFailures())
830
+ await expect(
831
+ appBackend.call(
832
+ method,
833
+ method === "runCommand" ? { command: "printf hi" } : {},
834
+ {
835
+ env: {
836
+ COMPUTER: hostComputer({
837
+ [binding]: () => Promise.reject(failure),
838
+ }),
839
+ },
656
840
  },
657
- },
658
- },
659
- caller,
660
- ),
661
- ).resolves.toEqual({ error: "not_granted" });
841
+ bindingCaller,
842
+ ),
843
+ ).rejects.toBe(failure);
844
+ },
845
+ );
846
+
847
+ it("rethrows other Workflow command exec failures unchanged", async () => {
848
+ for (const failure of otherBindingFailures()) {
849
+ const context = workflowBindingFixture(failure);
850
+ await expect(
851
+ appBackend.call("runCommand", workflowInput, context, bindingCaller),
852
+ ).rejects.toBe(failure);
853
+ expect(context.calls).toEqual(["exec"]);
854
+ }
662
855
  });