@fungi.computer/shed 0.0.1 → 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.
- package/README.md +17 -5
- package/backend/methods.ts +75 -44
- package/backend/process-page.ts +1 -1
- package/backend/runs.ts +72 -183
- package/backend/test/methods.test.ts +299 -106
- package/backend/test/present.test.ts +40 -0
- package/backend/test/runs.node.test.ts +447 -268
- package/dist/assets/index-BnXCnX-l.css +1 -0
- package/dist/assets/index-CRz0hCtN.js +1769 -0
- package/dist/index.html +2 -2
- package/package.json +37 -20
- package/dist/assets/ghostty-vt-DOMeXDrv.wasm +0 -0
- package/dist/assets/index-C5jExGkL.js +0 -1764
- package/dist/assets/index-Dm2F0ptq.css +0 -1
|
@@ -4,18 +4,47 @@ import {
|
|
|
4
4
|
maximumStreamBytes,
|
|
5
5
|
mintComputerProcessId,
|
|
6
6
|
processListOutputSchema,
|
|
7
|
-
} from "@fungi.computer/computer
|
|
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: {
|
|
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(
|
|
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("
|
|
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: {
|
|
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
|
-
{
|
|
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:
|
|
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(
|
|
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
|
-
{
|
|
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(
|
|
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:
|
|
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("
|
|
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
|
|
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
|
|
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
|
-
"
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
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
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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
|
-
|
|
660
|
-
|
|
661
|
-
|
|
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
|
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { expect, it } from "vitest";
|
|
2
|
+
import { appBackend } from "../methods.js";
|
|
3
|
+
|
|
4
|
+
const present = (command: string, exitCode: number) =>
|
|
5
|
+
appBackend.methods.run?.present?.(
|
|
6
|
+
{ command },
|
|
7
|
+
{ exitCode, logPath: "/logs/1" },
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
it("reads a finished command as a settled row and a failed one as an error row", () => {
|
|
11
|
+
expect(present("git push origin main", 0)).toEqual({
|
|
12
|
+
id: "/logs/1",
|
|
13
|
+
title: "Shed ran git push origin main",
|
|
14
|
+
detail: "Finished",
|
|
15
|
+
tone: "settled",
|
|
16
|
+
});
|
|
17
|
+
expect(present("git push origin main", 2)).toMatchObject({
|
|
18
|
+
detail: "Exited with code 2",
|
|
19
|
+
tone: "error",
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("names a script by its last command, so two scripts with the same setup read apart", () => {
|
|
24
|
+
const clone = present(
|
|
25
|
+
"set -eu\ngit init -q '/work/repo'\n\n# fetch the branch\ngit checkout -q main",
|
|
26
|
+
0,
|
|
27
|
+
);
|
|
28
|
+
const push = present(
|
|
29
|
+
"set -eu\ncd '/work/repo'\ngit add 'board'\ngit push origin 'HEAD:refs/heads/main'",
|
|
30
|
+
0,
|
|
31
|
+
);
|
|
32
|
+
expect(clone).toMatchObject({
|
|
33
|
+
title: "Shed ran git checkout -q main",
|
|
34
|
+
detail: "Finished · 3 commands",
|
|
35
|
+
});
|
|
36
|
+
expect(push).toMatchObject({
|
|
37
|
+
title: "Shed ran git push origin 'HEAD:refs/heads/main'",
|
|
38
|
+
detail: "Finished · 4 commands",
|
|
39
|
+
});
|
|
40
|
+
});
|