@fungi.computer/shed 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +122 -2
- package/backend/gills.ts +74 -0
- package/backend/index.ts +15 -0
- package/backend/methods.ts +319 -0
- package/backend/process-page.ts +44 -0
- package/backend/runs.ts +580 -0
- package/backend/test/cloudflare-workers.fixture.ts +1 -0
- package/backend/test/methods.test.ts +662 -0
- package/backend/test/present.test.ts +40 -0
- package/backend/test/runs.node.test.ts +1770 -0
- package/backend/tsconfig.json +10 -0
- package/backend/vitest.config.ts +16 -0
- package/dist/assets/Anton-5.3.0-latin-Byf51wtH.woff2 +0 -0
- package/dist/assets/BricolageGrotesque-VF-latin-Cre6nC2_.woff2 +0 -0
- package/dist/assets/DepartureMono-Regular-2BZob_Zz.woff2 +0 -0
- package/dist/assets/Fraunces-Italic-VF-latin-D2JnYqd-.woff2 +0 -0
- package/dist/assets/Fraunces-VF-latin-CFFu7zhK.woff2 +0 -0
- package/dist/assets/GrenzeGotisch-VF-latin-BhUVw2FV.woff2 +0 -0
- package/dist/assets/InstrumentSans-Italic-VF-latin-BlcgWI5J.woff2 +0 -0
- package/dist/assets/InstrumentSans-VF-latin-BbzFLZTg.woff2 +0 -0
- package/dist/assets/MapleMono-VF-CCDrizxw.woff2 +0 -0
- package/dist/assets/NothingYouCouldDo-Regular-B7VfwEBS.woff2 +0 -0
- package/dist/assets/Nunito-VF-8c6a9bb-cyrillic-BsWUdSU9.woff2 +0 -0
- package/dist/assets/Nunito-VF-8c6a9bb-cyrillic-ext-Cb1j0nGJ.woff2 +0 -0
- package/dist/assets/Nunito-VF-8c6a9bb-latin-BYs3GYNf.woff2 +0 -0
- package/dist/assets/Nunito-VF-8c6a9bb-latin-ext-0RvtKGDy.woff2 +0 -0
- package/dist/assets/Nunito-VF-8c6a9bb-vietnamese-cS2dauUA.woff2 +0 -0
- package/dist/assets/__vite-browser-external-2447137e-BvRk9kiK.js +0 -0
- package/dist/assets/index-2RtcU_CN.css +1 -0
- package/dist/assets/index-bW5A7RuN.js +1769 -0
- package/dist/index.html +15 -0
- package/package.json +159 -3
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import { createShedRuns } from "../runs.js";
|
|
3
|
+
import {
|
|
4
|
+
maximumStreamBytes,
|
|
5
|
+
mintComputerProcessId,
|
|
6
|
+
processListOutputSchema,
|
|
7
|
+
} from "@fungi.computer/computer/operations";
|
|
8
|
+
import { Shed } from "../index.js";
|
|
9
|
+
import * as z from "zod";
|
|
10
|
+
import { expect, it, onTestFinished } from "vitest";
|
|
11
|
+
import { appBackend, type ShedMethodContext } from "../methods.js";
|
|
12
|
+
it("lists ports only through the admitted Computer binding", async () => {
|
|
13
|
+
expect(
|
|
14
|
+
await appBackend.call(
|
|
15
|
+
"listPorts",
|
|
16
|
+
{},
|
|
17
|
+
{
|
|
18
|
+
env: { COMPUTER: { ports: () => Promise.resolve({ ports: [3000] }) } },
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
actor: { kind: "agent", agentId: "agent-a" },
|
|
22
|
+
computerId: "computer-a",
|
|
23
|
+
},
|
|
24
|
+
),
|
|
25
|
+
).toEqual({ ports: [3000] });
|
|
26
|
+
});
|
|
27
|
+
it("forwards inventories without accepting selectors or starting commands", async () => {
|
|
28
|
+
const calls: unknown[] = [];
|
|
29
|
+
const caller: Parameters<typeof appBackend.call>[3] = {
|
|
30
|
+
actor: { kind: "member", memberId: "member-a" },
|
|
31
|
+
computerId: "computer-a",
|
|
32
|
+
};
|
|
33
|
+
const context: ShedMethodContext = {
|
|
34
|
+
env: {
|
|
35
|
+
COMPUTER: {
|
|
36
|
+
processes: (input) => {
|
|
37
|
+
calls.push(input);
|
|
38
|
+
return Promise.resolve({ processes: [] });
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
expect(await appBackend.call("listProcesses", {}, context, caller)).toEqual({
|
|
44
|
+
processes: [],
|
|
45
|
+
});
|
|
46
|
+
for (const input of [
|
|
47
|
+
{ computerId: "other" },
|
|
48
|
+
{ agentId: "other" },
|
|
49
|
+
{ command: "rm -rf /" },
|
|
50
|
+
])
|
|
51
|
+
await expect(
|
|
52
|
+
appBackend.call("listProcesses", z.json().parse(input), context, caller),
|
|
53
|
+
).rejects.toThrow();
|
|
54
|
+
expect(calls).toEqual([{}]);
|
|
55
|
+
});
|
|
56
|
+
it("passes command and cwd unchanged with a host-owned request identity", async () => {
|
|
57
|
+
const calls: unknown[] = [];
|
|
58
|
+
const result = {
|
|
59
|
+
requestId: "0199caff-aaaa-7000-8000-aaaaaaaaaaaa",
|
|
60
|
+
status: "succeeded",
|
|
61
|
+
output: {
|
|
62
|
+
status: "completed",
|
|
63
|
+
exitCode: 0,
|
|
64
|
+
stdout: "hi",
|
|
65
|
+
stderr: "",
|
|
66
|
+
truncated: false,
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
expect(
|
|
70
|
+
await appBackend.call(
|
|
71
|
+
"runCommand",
|
|
72
|
+
{ command: "printf hi", cwd: "/work", timeoutMs: 45000 },
|
|
73
|
+
{
|
|
74
|
+
env: {
|
|
75
|
+
COMPUTER: {
|
|
76
|
+
exec: (input) => {
|
|
77
|
+
calls.push(input);
|
|
78
|
+
return Promise.resolve(result);
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
actor: { kind: "agent", agentId: "agent-a" },
|
|
85
|
+
computerId: "computer-a",
|
|
86
|
+
},
|
|
87
|
+
),
|
|
88
|
+
).toEqual(result);
|
|
89
|
+
expect(calls).toEqual([
|
|
90
|
+
{ command: "printf hi", cwd: "/work", timeoutMs: 45000 },
|
|
91
|
+
]);
|
|
92
|
+
});
|
|
93
|
+
it.each([
|
|
94
|
+
{ command: "" },
|
|
95
|
+
{ command: "a".repeat(16385) },
|
|
96
|
+
{ command: "echo hi", timeoutMs: 45001 },
|
|
97
|
+
{ command: "echo hi", cwd: "/a/../b" },
|
|
98
|
+
{ command: "echo hi", computerId: "other" },
|
|
99
|
+
{ command: "echo hi", memberId: "other" },
|
|
100
|
+
{ command: "echo hi", requestId: "chosen" },
|
|
101
|
+
{ command: "echo hi", backend: "container" },
|
|
102
|
+
])("refuses invalid command input before dispatch: %j", async (input) => {
|
|
103
|
+
let calls = 0;
|
|
104
|
+
await expect(
|
|
105
|
+
appBackend.call(
|
|
106
|
+
"runCommand",
|
|
107
|
+
z.json().parse(input),
|
|
108
|
+
{
|
|
109
|
+
env: {
|
|
110
|
+
COMPUTER: {
|
|
111
|
+
exec: () => {
|
|
112
|
+
calls++;
|
|
113
|
+
return Promise.resolve({});
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
actor: { kind: "agent", agentId: "agent-a" },
|
|
120
|
+
computerId: "computer-a",
|
|
121
|
+
},
|
|
122
|
+
),
|
|
123
|
+
).rejects.toThrow();
|
|
124
|
+
expect(calls).toBe(0);
|
|
125
|
+
});
|
|
126
|
+
it("retains typed execution budget and timeout refusals", async () => {
|
|
127
|
+
const budget = {
|
|
128
|
+
error: "attachment_budget_exceeded",
|
|
129
|
+
kind: "exec",
|
|
130
|
+
retryAfterMs: 1000,
|
|
131
|
+
};
|
|
132
|
+
const timeout = {
|
|
133
|
+
error: "timeout",
|
|
134
|
+
requestId: "0199caff-aaaa-7000-8000-aaaaaaaaaaaa",
|
|
135
|
+
};
|
|
136
|
+
for (const reply of [budget, timeout])
|
|
137
|
+
expect(
|
|
138
|
+
await appBackend.call(
|
|
139
|
+
"runCommand",
|
|
140
|
+
{ command: "echo hi" },
|
|
141
|
+
{
|
|
142
|
+
env: {
|
|
143
|
+
COMPUTER: { exec: () => Promise.resolve(z.json().parse(reply)) },
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
actor: { kind: "agent", agentId: "agent-a" },
|
|
148
|
+
computerId: "computer-a",
|
|
149
|
+
},
|
|
150
|
+
),
|
|
151
|
+
).toEqual(reply);
|
|
152
|
+
});
|
|
153
|
+
it("refuses an absent pin and missing execution binding", async () => {
|
|
154
|
+
let calls = 0;
|
|
155
|
+
expect(
|
|
156
|
+
await appBackend.call(
|
|
157
|
+
"runCommand",
|
|
158
|
+
{ command: "echo hi" },
|
|
159
|
+
{
|
|
160
|
+
env: {
|
|
161
|
+
COMPUTER: {
|
|
162
|
+
exec: () => {
|
|
163
|
+
calls++;
|
|
164
|
+
return Promise.resolve({});
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
{ actor: { kind: "agent", agentId: "agent-a" }, computerId: null },
|
|
170
|
+
),
|
|
171
|
+
).toEqual({ error: "not_granted" });
|
|
172
|
+
expect(
|
|
173
|
+
await appBackend.call(
|
|
174
|
+
"listPorts",
|
|
175
|
+
{},
|
|
176
|
+
{ env: {} },
|
|
177
|
+
{
|
|
178
|
+
actor: { kind: "agent", agentId: "agent-a" },
|
|
179
|
+
computerId: "computer-a",
|
|
180
|
+
},
|
|
181
|
+
),
|
|
182
|
+
).toEqual({ error: "not_granted" });
|
|
183
|
+
expect(calls).toBe(0);
|
|
184
|
+
});
|
|
185
|
+
it("bounds inventory and command replies", async () => {
|
|
186
|
+
const caller: Parameters<typeof appBackend.call>[3] = {
|
|
187
|
+
actor: { kind: "agent", agentId: "agent-a" },
|
|
188
|
+
computerId: "computer-a",
|
|
189
|
+
};
|
|
190
|
+
await expect(
|
|
191
|
+
appBackend.call(
|
|
192
|
+
"listPorts",
|
|
193
|
+
{},
|
|
194
|
+
{
|
|
195
|
+
env: {
|
|
196
|
+
COMPUTER: {
|
|
197
|
+
ports: () =>
|
|
198
|
+
Promise.resolve({
|
|
199
|
+
ports: Array.from({ length: 65 }, () => 3000),
|
|
200
|
+
}),
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
caller,
|
|
205
|
+
),
|
|
206
|
+
).rejects.toThrow();
|
|
207
|
+
await expect(
|
|
208
|
+
appBackend.call(
|
|
209
|
+
"runCommand",
|
|
210
|
+
{ command: "echo hi" },
|
|
211
|
+
{
|
|
212
|
+
env: {
|
|
213
|
+
COMPUTER: {
|
|
214
|
+
exec: () =>
|
|
215
|
+
Promise.resolve({
|
|
216
|
+
requestId: "0199caff-aaaa-7000-8000-aaaaaaaaaaaa",
|
|
217
|
+
status: "succeeded",
|
|
218
|
+
output: {
|
|
219
|
+
status: "completed",
|
|
220
|
+
exitCode: 0,
|
|
221
|
+
stdout: "é".repeat(65537),
|
|
222
|
+
stderr: "",
|
|
223
|
+
truncated: false,
|
|
224
|
+
},
|
|
225
|
+
}),
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
caller,
|
|
230
|
+
),
|
|
231
|
+
).rejects.toThrow();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("the named-method Worker exposes no unauthenticated HTTP execution interface", async () => {
|
|
235
|
+
expect((await Shed.prototype.fetch()).status).toBe(405);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("returns exit status for both streams cut inside UTF-8 by the Computer", async () => {
|
|
239
|
+
// Same byte slice and nonfatal UTF-8 decode as the native Computer producer.
|
|
240
|
+
const stream = new TextDecoder().decode(
|
|
241
|
+
new TextEncoder().encode("€".repeat(22000)).slice(0, maximumStreamBytes),
|
|
242
|
+
);
|
|
243
|
+
expect(new TextEncoder().encode(stream).byteLength).toBe(
|
|
244
|
+
maximumStreamBytes + 2,
|
|
245
|
+
);
|
|
246
|
+
const reply = {
|
|
247
|
+
requestId: "0199caff-aaaa-7000-8000-aaaaaaaaaaaa",
|
|
248
|
+
status: "succeeded",
|
|
249
|
+
output: {
|
|
250
|
+
status: "completed",
|
|
251
|
+
exitCode: 0,
|
|
252
|
+
stdout: stream,
|
|
253
|
+
stderr: stream,
|
|
254
|
+
truncated: true,
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
let calls = 0;
|
|
258
|
+
expect(
|
|
259
|
+
await appBackend.call(
|
|
260
|
+
"runCommand",
|
|
261
|
+
{ command: "emit multibyte streams" },
|
|
262
|
+
{
|
|
263
|
+
env: {
|
|
264
|
+
COMPUTER: {
|
|
265
|
+
exec: () => {
|
|
266
|
+
calls++;
|
|
267
|
+
return Promise.resolve(reply);
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
actor: { kind: "agent", agentId: "agent-a" },
|
|
274
|
+
computerId: "computer-a",
|
|
275
|
+
},
|
|
276
|
+
),
|
|
277
|
+
).toEqual(reply);
|
|
278
|
+
expect(calls).toBe(1);
|
|
279
|
+
});
|
|
280
|
+
it("retains a process whose native cwd contains parent segments", async () => {
|
|
281
|
+
const reply = {
|
|
282
|
+
processes: [
|
|
283
|
+
{
|
|
284
|
+
id: mintComputerProcessId(),
|
|
285
|
+
port: null,
|
|
286
|
+
command: "sleep 1",
|
|
287
|
+
cwd: "/a/../b",
|
|
288
|
+
state: "running",
|
|
289
|
+
exitCode: null,
|
|
290
|
+
sync: "unknown",
|
|
291
|
+
startedAt: 1,
|
|
292
|
+
endedAt: null,
|
|
293
|
+
},
|
|
294
|
+
],
|
|
295
|
+
};
|
|
296
|
+
expect(
|
|
297
|
+
await appBackend.call(
|
|
298
|
+
"listProcesses",
|
|
299
|
+
{},
|
|
300
|
+
{ env: { COMPUTER: { processes: () => Promise.resolve(reply) } } },
|
|
301
|
+
{
|
|
302
|
+
actor: { kind: "agent", agentId: "agent-a" },
|
|
303
|
+
computerId: "computer-a",
|
|
304
|
+
},
|
|
305
|
+
),
|
|
306
|
+
).toEqual(reply);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("pages retained processes on the admitted Computer without starting work and refuses another Computer's cursor", async () => {
|
|
310
|
+
const processes = Array.from({ length: 21 }, (_, index) => ({
|
|
311
|
+
id: mintComputerProcessId(),
|
|
312
|
+
port: null,
|
|
313
|
+
command: `node worker-${String(index)}.mjs`,
|
|
314
|
+
cwd: "/workspace",
|
|
315
|
+
state: "running" as const,
|
|
316
|
+
exitCode: null,
|
|
317
|
+
sync: "unknown",
|
|
318
|
+
startedAt: index + 1,
|
|
319
|
+
endedAt: null,
|
|
320
|
+
})).reverse();
|
|
321
|
+
let reads = 0;
|
|
322
|
+
let starts = 0;
|
|
323
|
+
const notStarted = (): Promise<never> => {
|
|
324
|
+
starts++;
|
|
325
|
+
return Promise.reject(new Error("Reading must not start a command"));
|
|
326
|
+
};
|
|
327
|
+
const context: ShedMethodContext = {
|
|
328
|
+
env: {
|
|
329
|
+
COMPUTER: {
|
|
330
|
+
processes: (input) => {
|
|
331
|
+
expect(input).toEqual({});
|
|
332
|
+
reads++;
|
|
333
|
+
return Promise.resolve({ processes });
|
|
334
|
+
},
|
|
335
|
+
processStart: notStarted,
|
|
336
|
+
exec: notStarted,
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
};
|
|
340
|
+
const caller: Parameters<typeof appBackend.call>[3] = {
|
|
341
|
+
actor: { kind: "member", memberId: "member-a" },
|
|
342
|
+
computerId: "computer-a",
|
|
343
|
+
};
|
|
344
|
+
const page = processListOutputSchema.extend({ next: z.json().optional() });
|
|
345
|
+
const first = page.parse(
|
|
346
|
+
await appBackend.call("listProcesses", { limit: 20 }, context, caller),
|
|
347
|
+
);
|
|
348
|
+
expect(first.processes).toHaveLength(20);
|
|
349
|
+
expect(first.processes[0]?.id).toBe(processes[0]?.id);
|
|
350
|
+
if (first.next === undefined) throw new Error("Expected the source cursor");
|
|
351
|
+
const next = page.parse(
|
|
352
|
+
await appBackend.call(
|
|
353
|
+
"listProcesses",
|
|
354
|
+
{ limit: 20, after: first.next },
|
|
355
|
+
context,
|
|
356
|
+
caller,
|
|
357
|
+
),
|
|
358
|
+
);
|
|
359
|
+
expect(next.processes).toEqual([processes[20]]);
|
|
360
|
+
expect(next.next).toBeUndefined();
|
|
361
|
+
expect(reads).toBe(2);
|
|
362
|
+
expect(
|
|
363
|
+
await appBackend.call(
|
|
364
|
+
"listProcesses",
|
|
365
|
+
{ limit: 20, after: first.next },
|
|
366
|
+
context,
|
|
367
|
+
{ ...caller, computerId: "computer-b" },
|
|
368
|
+
),
|
|
369
|
+
).toEqual({ error: "invalid" });
|
|
370
|
+
expect(reads).toBe(2);
|
|
371
|
+
expect(starts).toBe(0);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("projects a process page through the declared read Gill with App-local navigation", async () => {
|
|
375
|
+
let reads = 0;
|
|
376
|
+
const process = {
|
|
377
|
+
id: mintComputerProcessId(),
|
|
378
|
+
port: null,
|
|
379
|
+
command: "node server.js",
|
|
380
|
+
cwd: "/workspace",
|
|
381
|
+
state: "running",
|
|
382
|
+
exitCode: null,
|
|
383
|
+
sync: "unknown",
|
|
384
|
+
startedAt: Date.now() - 480000,
|
|
385
|
+
endedAt: null,
|
|
386
|
+
};
|
|
387
|
+
const context: ShedMethodContext = {
|
|
388
|
+
env: {
|
|
389
|
+
COMPUTER: {
|
|
390
|
+
processes: () => {
|
|
391
|
+
reads++;
|
|
392
|
+
return Promise.resolve({ processes: [process] });
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
};
|
|
397
|
+
const reply = await appBackend.gill(
|
|
398
|
+
{ id: "processes", params: { computer: "computer-a" } },
|
|
399
|
+
context,
|
|
400
|
+
{
|
|
401
|
+
actor: { kind: "member", memberId: "member-a" },
|
|
402
|
+
computerId: "computer-a",
|
|
403
|
+
},
|
|
404
|
+
);
|
|
405
|
+
if (!("rows" in reply)) throw new Error("Expected process rows");
|
|
406
|
+
expect(reply.rows).toHaveLength(1);
|
|
407
|
+
expect(reply.rows[0]?.open).toEqual({
|
|
408
|
+
params: [{ key: "process", value: process.id }],
|
|
409
|
+
});
|
|
410
|
+
expect(reply.rows[0]?.status).toEqual({ label: "running", tone: "success" });
|
|
411
|
+
expect(reads).toBe(1);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("keeps inventory refusals actionable and separate from empty without guessing container state", async () => {
|
|
415
|
+
const caller: Parameters<typeof appBackend.call>[3] = {
|
|
416
|
+
actor: { kind: "member", memberId: "member-a" },
|
|
417
|
+
computerId: "computer-a",
|
|
418
|
+
};
|
|
419
|
+
const request = { id: "processes", params: { computer: "computer-a" } };
|
|
420
|
+
const missing = await appBackend.gill(request, { env: {} }, caller);
|
|
421
|
+
expect(missing).toMatchObject({
|
|
422
|
+
state: "error",
|
|
423
|
+
rows: [],
|
|
424
|
+
recovery: { kind: "configure", param: "computer" },
|
|
425
|
+
});
|
|
426
|
+
const unavailable = await appBackend.gill(
|
|
427
|
+
request,
|
|
428
|
+
{
|
|
429
|
+
env: {
|
|
430
|
+
COMPUTER: {
|
|
431
|
+
processes: () => Promise.resolve({ error: "unavailable" }),
|
|
432
|
+
},
|
|
433
|
+
},
|
|
434
|
+
},
|
|
435
|
+
caller,
|
|
436
|
+
);
|
|
437
|
+
expect(unavailable).toMatchObject({
|
|
438
|
+
state: "error",
|
|
439
|
+
rows: [],
|
|
440
|
+
recovery: { kind: "open" },
|
|
441
|
+
});
|
|
442
|
+
const empty = await appBackend.gill(
|
|
443
|
+
request,
|
|
444
|
+
{
|
|
445
|
+
env: {
|
|
446
|
+
COMPUTER: { processes: () => Promise.resolve({ processes: [] }) },
|
|
447
|
+
},
|
|
448
|
+
},
|
|
449
|
+
caller,
|
|
450
|
+
);
|
|
451
|
+
expect(empty).toMatchObject({ state: "empty", rows: [] });
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("shortens long display text without losing a retained process or changing its navigation identity", async () => {
|
|
455
|
+
const process = {
|
|
456
|
+
id: mintComputerProcessId(),
|
|
457
|
+
port: null,
|
|
458
|
+
command: "x".repeat(16384),
|
|
459
|
+
cwd: "/" + "x".repeat(4095),
|
|
460
|
+
state: "uncertain",
|
|
461
|
+
exitCode: null,
|
|
462
|
+
sync: "unknown",
|
|
463
|
+
startedAt: 1,
|
|
464
|
+
endedAt: null,
|
|
465
|
+
};
|
|
466
|
+
const reply = await appBackend.gill(
|
|
467
|
+
{ id: "processes", params: { computer: "computer-a" } },
|
|
468
|
+
{
|
|
469
|
+
env: {
|
|
470
|
+
COMPUTER: {
|
|
471
|
+
processes: () => Promise.resolve({ processes: [process] }),
|
|
472
|
+
},
|
|
473
|
+
},
|
|
474
|
+
},
|
|
475
|
+
{
|
|
476
|
+
actor: { kind: "member", memberId: "member-a" },
|
|
477
|
+
computerId: "computer-a",
|
|
478
|
+
},
|
|
479
|
+
);
|
|
480
|
+
if (!("rows" in reply)) throw new Error("Expected retained process rows");
|
|
481
|
+
expect(reply.rows).toHaveLength(1);
|
|
482
|
+
expect(reply.rows[0]?.title).toHaveLength(4096);
|
|
483
|
+
expect(reply.rows[0]?.detail).toHaveLength(1024);
|
|
484
|
+
expect(reply.rows[0]?.status).toEqual({
|
|
485
|
+
label: "uncertain",
|
|
486
|
+
tone: "warning",
|
|
487
|
+
});
|
|
488
|
+
expect(reply.rows[0]?.open).toEqual({
|
|
489
|
+
params: [{ key: "process", value: process.id }],
|
|
490
|
+
});
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it("stops the selected process through the pinned Computer without accepting another selector", async () => {
|
|
494
|
+
const process = {
|
|
495
|
+
id: mintComputerProcessId(),
|
|
496
|
+
port: null,
|
|
497
|
+
command: "sleep 120",
|
|
498
|
+
cwd: null,
|
|
499
|
+
state: "running",
|
|
500
|
+
exitCode: null,
|
|
501
|
+
sync: "unknown",
|
|
502
|
+
startedAt: 1,
|
|
503
|
+
endedAt: null,
|
|
504
|
+
};
|
|
505
|
+
const reply = { process, signalled: true };
|
|
506
|
+
const calls: unknown[] = [];
|
|
507
|
+
const database = new DatabaseSync(":memory:");
|
|
508
|
+
onTestFinished(() => {
|
|
509
|
+
database.close();
|
|
510
|
+
});
|
|
511
|
+
const context = {
|
|
512
|
+
runs: createShedRuns({
|
|
513
|
+
exec(query, ...values) {
|
|
514
|
+
const rows = database.prepare(query).all(...values);
|
|
515
|
+
return { toArray: () => rows };
|
|
516
|
+
},
|
|
517
|
+
}),
|
|
518
|
+
env: {
|
|
519
|
+
COMPUTER: {
|
|
520
|
+
processStart: () => Promise.resolve(process),
|
|
521
|
+
processes: () => Promise.resolve({ processes: [process] }),
|
|
522
|
+
processKill: (input: { id: string }) => {
|
|
523
|
+
calls.push(input);
|
|
524
|
+
return Promise.resolve(reply);
|
|
525
|
+
},
|
|
526
|
+
},
|
|
527
|
+
},
|
|
528
|
+
};
|
|
529
|
+
const caller = {
|
|
530
|
+
actor: { kind: "member", memberId: "member-one" },
|
|
531
|
+
idempotencyKey: "stop-test",
|
|
532
|
+
computerId: "computer-one",
|
|
533
|
+
} satisfies Parameters<typeof appBackend.call>[3];
|
|
534
|
+
await appBackend.call("start", { command: "sleep 120" }, context, caller);
|
|
535
|
+
expect(
|
|
536
|
+
await appBackend.call("stop", { id: process.id }, context, caller),
|
|
537
|
+
).toEqual(reply);
|
|
538
|
+
await expect(
|
|
539
|
+
appBackend.call(
|
|
540
|
+
"stop",
|
|
541
|
+
{ id: process.id, computerId: "other" },
|
|
542
|
+
context,
|
|
543
|
+
caller,
|
|
544
|
+
),
|
|
545
|
+
).rejects.toThrow();
|
|
546
|
+
await expect(
|
|
547
|
+
appBackend.call("stop", { id: "not-a-process-id" }, context, caller),
|
|
548
|
+
).rejects.toThrow();
|
|
549
|
+
await expect(
|
|
550
|
+
appBackend.call("stop", { id: process.id }, context, {
|
|
551
|
+
...caller,
|
|
552
|
+
computerId: null,
|
|
553
|
+
}),
|
|
554
|
+
).rejects.toThrow();
|
|
555
|
+
expect(calls).toEqual([{ id: process.id }]);
|
|
556
|
+
});
|
|
557
|
+
it("presents a Workflow step's read pass to runCommand only through a one-shot credential file", async () => {
|
|
558
|
+
const gitAccess = {
|
|
559
|
+
repository: {
|
|
560
|
+
kind: "installation",
|
|
561
|
+
installationId: "bonsai-a",
|
|
562
|
+
repository: "pond",
|
|
563
|
+
},
|
|
564
|
+
access: "read",
|
|
565
|
+
} 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 }[] = [];
|
|
570
|
+
const result = {
|
|
571
|
+
requestId: "0199caff-aaaa-7000-8000-aaaaaaaaaaaa",
|
|
572
|
+
status: "succeeded",
|
|
573
|
+
output: {
|
|
574
|
+
status: "completed",
|
|
575
|
+
exitCode: 0,
|
|
576
|
+
stdout: "",
|
|
577
|
+
stderr: "",
|
|
578
|
+
truncated: false,
|
|
579
|
+
},
|
|
580
|
+
};
|
|
581
|
+
const context = (pass: unknown): ShedMethodContext => ({
|
|
582
|
+
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
|
+
},
|
|
592
|
+
exec: (input) => {
|
|
593
|
+
execs.push(input);
|
|
594
|
+
return Promise.resolve(result);
|
|
595
|
+
},
|
|
596
|
+
},
|
|
597
|
+
},
|
|
598
|
+
});
|
|
599
|
+
const caller: Parameters<typeof appBackend.call>[3] = {
|
|
600
|
+
actor: { kind: "member", memberId: "member-a" },
|
|
601
|
+
computerId: "computer-a",
|
|
602
|
+
};
|
|
603
|
+
const input = z.json().parse({
|
|
604
|
+
command: 'git clone -- "$FUNGI_GIT_REMOTE" repo',
|
|
605
|
+
timeoutMs: 45000,
|
|
606
|
+
gitAccess,
|
|
607
|
+
});
|
|
608
|
+
expect(
|
|
609
|
+
await appBackend.call(
|
|
610
|
+
"runCommand",
|
|
611
|
+
input,
|
|
612
|
+
context({
|
|
613
|
+
passId: "pass",
|
|
614
|
+
remote,
|
|
615
|
+
bearer,
|
|
616
|
+
expiresAt: Date.now() + 60_000,
|
|
617
|
+
}),
|
|
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;
|
|
636
|
+
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")),
|
|
656
|
+
},
|
|
657
|
+
},
|
|
658
|
+
},
|
|
659
|
+
caller,
|
|
660
|
+
),
|
|
661
|
+
).resolves.toEqual({ error: "not_granted" });
|
|
662
|
+
});
|
|
@@ -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
|
+
});
|