@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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { readFile,
|
|
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
|
|
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
|
|
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"
|
|
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("
|
|
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
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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
|
|
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
|
-
|
|
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("
|
|
632
|
+
void it("does not request a credential file before a refused Workflow run start", async (t) => {
|
|
665
633
|
const f = workflowRunFixture(t);
|
|
666
|
-
|
|
667
|
-
f.
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
assert.
|
|
673
|
-
|
|
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
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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("
|
|
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
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
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
|
|
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.
|
|
771
|
+
assert.deepEqual(f.forbidden(), []);
|
|
820
772
|
});
|
|
821
773
|
|
|
822
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
822
|
+
assert.deepEqual(f.forbidden(), []);
|
|
876
823
|
});
|
|
877
824
|
|
|
878
|
-
void it("
|
|
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
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
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
|
-
{
|
|
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
|
-
//
|
|
924
|
-
'
|
|
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 =
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
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
|
|
1189
|
+
void it("replays an already started process without any credential call", async (t) => {
|
|
1144
1190
|
const fixture = workflowRunFixture(t);
|
|
1145
|
-
const expected =
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
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.
|
|
1207
|
+
assert.deepEqual(fixture.forbidden(), []);
|
|
1171
1208
|
});
|
|
1172
1209
|
|
|
1173
|
-
void it("starts a separate native process
|
|
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.
|
|
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
|
|
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
|
|
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(
|
|
1547
|
-
assert.equal(
|
|
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("
|
|
1568
|
+
void it("redispatches an uncertain start with the same command and no credential call", async (t) => {
|
|
1551
1569
|
const f = workflowRunFixture(t);
|
|
1552
|
-
|
|
1553
|
-
let
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
return
|
|
1558
|
-
|
|
1559
|
-
|
|
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
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
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 <
|
|
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
|
|
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
|
|
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.
|
|
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
|
+
}
|