@fungi.computer/shed 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/backend/runs.ts CHANGED
@@ -1,31 +1,24 @@
1
1
  import * as z from "zod";
2
2
  import { Hex } from "effect/encoding";
3
3
  import type { HostActor } from "@fungi.computer/puffball/app-method-runtime";
4
- import {
5
- workflowGitPassResultSchema,
6
- type WorkflowGitPassResult,
7
- } from "@fungi.computer/agar/computer-contracts";
8
4
  import { resolvedGitAccessSchema } from "@fungi.computer/braid/git-access";
9
5
  import { pending } from "@fungi.computer/agar/backend-definition";
10
6
  import type { WhistleJsonValue } from "@fungi.computer/whistle";
11
7
  import { AppMethodRefusal } from "@fungi.computer/agar/backend";
12
8
  import {
13
9
  computerProcessSchema,
10
+ computerOperationResultSchema,
14
11
  maximumProcessRecords,
15
- writeOutputSchema,
16
- type ComputerFileWriteInput,
17
12
  processStartInputSchema,
18
13
  processListOutputSchema,
19
14
  type ComputerProcess,
20
- } from "@fungi.computer/computer/operations";
15
+ } from "@fungi.computer/computer-contract";
21
16
  import {
22
17
  runCommandInputSchema,
23
18
  runCommandResultSchema,
24
19
  shedComputerRefusalSchema,
25
20
  } from "../src/light-shell/run-command-contract.js";
26
21
 
27
- /** What a Git command needs from a pass: its remote and any header credential. */
28
- type GitCredential = Pick<WorkflowGitPassResult, "remote" | "bearer">;
29
22
  const processInputSchema = processStartInputSchema.omit({
30
23
  requestId: true,
31
24
  requestScope: true,
@@ -49,12 +42,6 @@ export type ShedSql = {
49
42
  ): { toArray(): unknown[] };
50
43
  };
51
44
  export type ShedComputer = Readonly<{
52
- workflowGitPass?:
53
- | ((input: Record<string, never>) => Promise<z.core.util.JSONType>)
54
- | undefined;
55
- write?:
56
- | ((input: ComputerFileWriteInput) => Promise<z.core.util.JSONType>)
57
- | undefined;
58
45
  processStart(input: {
59
46
  command: string;
60
47
  cwd?: string | undefined;
@@ -62,6 +49,23 @@ export type ShedComputer = Readonly<{
62
49
  }): Promise<z.core.util.JSONType>;
63
50
  processes(input: Record<string, never>): Promise<z.core.util.JSONType>;
64
51
  }>;
52
+
53
+ const bindingNotGranted = z.object({
54
+ code: z.literal("binding_not_granted"),
55
+ });
56
+
57
+ /** Only refusal-aware callers translate a host's typed binding denial. */
58
+ export async function computerCall(
59
+ call: () => Promise<WhistleJsonValue>,
60
+ ): Promise<WhistleJsonValue> {
61
+ try {
62
+ return await call();
63
+ } catch (cause) {
64
+ if (!bindingNotGranted.safeParse(cause).success) throw cause;
65
+ return shedComputerRefusalSchema.parse({ error: "not_granted" });
66
+ }
67
+ }
68
+
65
69
  const rowSchema = z.strictObject({
66
70
  requestKey: z.string(),
67
71
  computerId: z.string(),
@@ -73,29 +77,7 @@ const rowSchema = z.strictObject({
73
77
  exitCode: z.number().int().nullable(),
74
78
  logPath: z.string(),
75
79
  gitAccess: z.string().nullable(),
76
- passId: z.string().nullable(),
77
- remote: z.string().nullable(),
78
- expiresAt: z.number().int().nullable(),
79
80
  });
80
- // A header-admitted pass reaches git only as command-local
81
- // http.<remote>.extraHeader config, read from a one-shot file the command
82
- // deletes; neither the command nor any repository config ever holds it.
83
- function gitCredentialFile(pass: GitCredential) {
84
- return pass.bearer === undefined
85
- ? `${pass.remote}\n`
86
- : `${pass.remote}\n${pass.bearer}\n`;
87
- }
88
- function gitCredentialCommand(
89
- remotePath: string,
90
- pass: GitCredential,
91
- command: string,
92
- ) {
93
- const read = `{ IFS= read -r _fungi_git_remote && IFS= read -r _fungi_git_bearer; } < ${quote(remotePath)}; rm -f -- ${quote(remotePath)}; test -n "$_fungi_git_remote"`;
94
- return pass.bearer === undefined
95
- ? `${read} && FUNGI_GIT_REMOTE="$_fungi_git_remote" bash -c ${quote(command)}`
96
- : `${read} && test -n "$_fungi_git_bearer" && FUNGI_GIT_REMOTE="$_fungi_git_remote" GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0="http.$_fungi_git_remote.extraHeader" GIT_CONFIG_VALUE_0="Authorization: Bearer $_fungi_git_bearer" bash -c ${quote(command)}`;
97
- }
98
-
99
81
  export const shedRunCommandInputSchema = runCommandInputSchema.extend({
100
82
  gitAccess: resolvedGitAccessSchema.optional(),
101
83
  });
@@ -103,9 +85,9 @@ export type ShedRunCommandInput = z.output<typeof shedRunCommandInputSchema>;
103
85
  export type ShedRunCommandResult = z.output<typeof runCommandResultSchema>;
104
86
 
105
87
  /**
106
- * One bounded exec that presents the step's Workflow Git pass exactly as
107
- * `run` does: `$FUNGI_GIT_REMOTE` plus command-local header config. Without
108
- * `gitAccess` it is the plain exec.
88
+ * One bounded exec. A Workflow step's admitted `gitAccess` is identity only:
89
+ * the trusted host supplies `$FUNGI_GIT_REMOTE` and its credentials after
90
+ * native admission, so Shed sends and holds none.
109
91
  */
110
92
  export async function runShedCommand(
111
93
  input: ShedRunCommandInput,
@@ -113,39 +95,31 @@ export async function runShedCommand(
113
95
  exec(
114
96
  input: Omit<ShedRunCommandInput, "gitAccess">,
115
97
  ): Promise<WhistleJsonValue>;
116
- workflowGitPass?: ShedComputer["workflowGitPass"];
117
- write?: ShedComputer["write"];
118
98
  }>,
119
- now: () => number = Date.now,
120
99
  ): Promise<ShedRunCommandResult> {
121
- const { gitAccess, ...exec } = input;
122
- if (gitAccess === undefined)
123
- return runCommandResultSchema.parse(await computer.exec(exec));
124
- if (!computer.workflowGitPass || !computer.write)
125
- return shedComputerRefusalSchema.parse({ error: "not_granted" });
126
- const minted = z
127
- .union([workflowGitPassResultSchema, shedComputerRefusalSchema])
128
- .parse(await computer.workflowGitPass({}));
129
- if ("error" in minted) return minted;
130
- if (minted.expiresAt <= now() + 5000)
131
- throw new Error("Workflow Git pass expires before the command starts");
132
- const remotePath = `/workspace/.fungi-git-${Hex.encode(crypto.getRandomValues(new Uint8Array(16)))}.remote`;
133
- const content = gitCredentialFile(minted);
134
- const written = await computer.write({ path: remotePath, content });
135
- const refused = shedComputerRefusalSchema.safeParse(written);
136
- if (refused.success) return refused.data;
137
- const reply = writeOutputSchema.parse(written);
138
- if (
139
- reply.path !== remotePath ||
140
- reply.bytes !== new TextEncoder().encode(content).byteLength
141
- )
142
- throw new Error("Invalid Computer credential file reply");
143
- return runCommandResultSchema.parse(
144
- await computer.exec({
145
- ...exec,
146
- command: gitCredentialCommand(remotePath, minted, exec.command),
147
- }),
148
- );
100
+ const exec: Omit<ShedRunCommandInput, "gitAccess"> = {
101
+ command: input.command,
102
+ timeoutMs: input.timeoutMs,
103
+ };
104
+ if (input.cwd !== undefined) exec.cwd = input.cwd;
105
+ const reply = z
106
+ .union([computerOperationResultSchema, shedComputerRefusalSchema])
107
+ .parse(await computerCall(() => computer.exec(exec)));
108
+ if ("error" in reply)
109
+ return reply.error === "timeout" ? { error: reply.error } : reply;
110
+ if (reply.output === null) return { error: "output_unavailable" };
111
+ const { stdout, stderr, exitCode, truncated } = reply.output;
112
+ return runCommandResultSchema.parse({
113
+ stdout,
114
+ stderr,
115
+ exitCode,
116
+ stdoutTruncated:
117
+ truncated !== false &&
118
+ truncated.some((stream) => stream.stream === "stdout"),
119
+ stderrTruncated:
120
+ truncated !== false &&
121
+ truncated.some((stream) => stream.stream === "stderr"),
122
+ });
149
123
  }
150
124
 
151
125
  function claimKey(actor: HostActor, method: "start" | "run", key: string) {
@@ -166,16 +140,19 @@ function completion(exitCode: number | null, logPath: string) {
166
140
  }
167
141
 
168
142
  /** The Computer owns execution and exit state; Shed only remembers the call-to-process link. */
169
- export function createShedRuns(sql: ShedSql, now: () => number = Date.now) {
143
+ export function createShedRuns(sql: ShedSql) {
170
144
  // Unscoped records are not current claims; do not read or import the old ledger.
145
+ // The superseded claim table held credential-lease columns. Preview data is
146
+ // disposable, so it is dropped, never read, copied or migrated.
147
+ sql.exec("DROP TABLE IF EXISTS shed_process_claims");
171
148
  sql.exec(
172
- "CREATE TABLE IF NOT EXISTS 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)",
149
+ "CREATE TABLE IF NOT EXISTS shed_native_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)",
173
150
  );
174
151
  sql.exec(
175
152
  "CREATE TABLE IF NOT EXISTS shed_retired_claim_keys (requestKey TEXT PRIMARY KEY)",
176
153
  );
177
154
  sql.exec(
178
- "DELETE FROM shed_process_claims WHERE requestKey IN (SELECT requestKey FROM shed_retired_claim_keys)",
155
+ "DELETE FROM shed_native_process_claims WHERE requestKey IN (SELECT requestKey FROM shed_retired_claim_keys)",
179
156
  );
180
157
  const starting = new Map<string, number | null>();
181
158
  function retired(requestKey: string) {
@@ -198,7 +175,7 @@ export function createShedRuns(sql: ShedSql, now: () => number = Date.now) {
198
175
  .parse(
199
176
  sql
200
177
  .exec(
201
- "SELECT * FROM shed_process_claims WHERE computerId=? AND json_extract(requestKey,'$[0]')=? AND json_extract(requestKey,'$[1]')=? ORDER BY startedAt ASC, processId ASC, rowid ASC",
178
+ "SELECT * FROM shed_native_process_claims WHERE computerId=? AND json_extract(requestKey,'$[0]')=? AND json_extract(requestKey,'$[1]')=? ORDER BY startedAt ASC, processId ASC, rowid ASC",
202
179
  computerId,
203
180
  JSON.stringify(actor),
204
181
  mode,
@@ -212,7 +189,10 @@ export function createShedRuns(sql: ShedSql, now: () => number = Date.now) {
212
189
  "INSERT OR IGNORE INTO shed_retired_claim_keys (requestKey) VALUES (?)",
213
190
  requestKey,
214
191
  );
215
- sql.exec("DELETE FROM shed_process_claims WHERE requestKey=?", requestKey);
192
+ sql.exec(
193
+ "DELETE FROM shed_native_process_claims WHERE requestKey=?",
194
+ requestKey,
195
+ );
216
196
  // Replay refusal covers the latest 64 retired calls per member/method.
217
197
  // Older opaque keys are outside the retained replay horizon, like native history.
218
198
  sql.exec(
@@ -250,7 +230,7 @@ export function createShedRuns(sql: ShedSql, now: () => number = Date.now) {
250
230
  ) {
251
231
  const rows = scopeRows(actor, mode, computerId);
252
232
  if (rows.length < maximumProcessRecords) return null;
253
- const reply = await computer.processes({});
233
+ const reply = await computerCall(() => computer.processes({}));
254
234
  const refused = shedComputerRefusalSchema.safeParse(reply);
255
235
  if (refused.success) return refused.data;
256
236
  const inventory = processListOutputSchema.parse(reply);
@@ -266,7 +246,10 @@ export function createShedRuns(sql: ShedSql, now: () => number = Date.now) {
266
246
  function read(requestKey: string) {
267
247
  if (retired(requestKey)) return null;
268
248
  const row = sql
269
- .exec("SELECT * FROM shed_process_claims WHERE requestKey=?", requestKey)
249
+ .exec(
250
+ "SELECT * FROM shed_native_process_claims WHERE requestKey=?",
251
+ requestKey,
252
+ )
270
253
  .toArray()[0];
271
254
  return row === undefined ? null : rowSchema.parse(row);
272
255
  }
@@ -276,7 +259,7 @@ export function createShedRuns(sql: ShedSql, now: () => number = Date.now) {
276
259
  logPath: string,
277
260
  ) {
278
261
  sql.exec(
279
- "UPDATE shed_process_claims SET processId=?,phase=?,exitCode=?,startedAt=? WHERE requestKey=?",
262
+ "UPDATE shed_native_process_claims SET processId=?,phase=?,exitCode=?,startedAt=? WHERE requestKey=?",
280
263
  process.id,
281
264
  process.state === "exited" ? "exited" : "running",
282
265
  process.exitCode,
@@ -326,15 +309,9 @@ export function createShedRuns(sql: ShedSql, now: () => number = Date.now) {
326
309
  new TextEncoder().encode(requestKey),
327
310
  );
328
311
  const directory = `/workspace/.fungi/runs/call-${Hex.encode(new Uint8Array(digest))}`;
329
- const remotePath = `/workspace/.fungi-git-${Hex.encode(new Uint8Array(digest))}.remote`;
330
- const credentialFile = gitCredentialFile;
331
- function prepare(pass: GitCredential | null) {
312
+ function prepare() {
332
313
  const logPath = `${directory}/out.log`;
333
- const command =
334
- pass === null
335
- ? `bash -c ${quote(input.command)}`
336
- : gitCredentialCommand(remotePath, pass, input.command);
337
- const script = `mkdir -p ${quote(directory)} && ${command} 2>&1 | tee ${quote(logPath)}`;
314
+ const script = `mkdir -p ${quote(directory)} && bash -c ${quote(input.command)} 2>&1 | tee ${quote(logPath)}`;
338
315
  const processInput: z.input<typeof processInputSchema> = {
339
316
  command: `bash -o pipefail -c ${quote(script)}`,
340
317
  };
@@ -345,48 +322,14 @@ export function createShedRuns(sql: ShedSql, now: () => number = Date.now) {
345
322
  async function dispatch(
346
323
  start: z.output<typeof processInputSchema>,
347
324
  logPath: string,
348
- pass: GitCredential | null,
349
- expiresAt: number | null,
350
325
  ) {
351
- const remote = pass?.remote ?? null;
352
326
  starting.set(requestKey, input.port ?? null);
353
327
  try {
354
- function checkLease() {
355
- if (remote !== null && (expiresAt === null || expiresAt <= now()))
356
- throw new Error(
357
- `Workflow Git pass expired before process dispatch. Log: ${logPath}`,
358
- );
359
- }
360
- checkLease();
361
- if (pass !== null) {
362
- if (!computer.write)
363
- throw new Error("Computer credential file access is unavailable");
364
- const content = credentialFile(pass);
365
- const reply = await computer.write({
366
- path: remotePath,
367
- content,
368
- });
369
- const refused = shedComputerRefusalSchema.safeParse(reply);
370
- if (refused.success) {
371
- sql.exec(
372
- "DELETE FROM shed_process_claims WHERE requestKey=?",
373
- requestKey,
374
- );
375
- return refused.data;
376
- }
377
- const written = writeOutputSchema.parse(reply);
378
- if (
379
- written.path !== remotePath ||
380
- written.bytes !== new TextEncoder().encode(content).byteLength
381
- )
382
- throw new Error("Invalid Computer credential file reply");
383
- }
384
- checkLease();
385
- const reply = await computer.processStart(start);
328
+ const reply = await computerCall(() => computer.processStart(start));
386
329
  const refused = shedComputerRefusalSchema.safeParse(reply);
387
330
  if (refused.success) {
388
331
  sql.exec(
389
- "DELETE FROM shed_process_claims WHERE requestKey=?",
332
+ "DELETE FROM shed_native_process_claims WHERE requestKey=?",
390
333
  requestKey,
391
334
  );
392
335
  return refused.data;
@@ -402,16 +345,6 @@ export function createShedRuns(sql: ShedSql, now: () => number = Date.now) {
402
345
  }
403
346
  const gitAccess =
404
347
  input.gitAccess === undefined ? null : JSON.stringify(input.gitAccess);
405
- async function mint() {
406
- if (!computer.workflowGitPass)
407
- throw new Error("Workflow Git pass binding is unavailable");
408
- const pass = workflowGitPassResultSchema.parse(
409
- await computer.workflowGitPass({}),
410
- );
411
- if (pass.expiresAt <= now() + 5000)
412
- throw new Error("Workflow Git pass expires before the next poll");
413
- return pass;
414
- }
415
348
  async function resume(prior: z.output<typeof rowSchema>) {
416
349
  verify(prior, input, computerId);
417
350
  if (prior.phase === "starting") {
@@ -426,25 +359,8 @@ export function createShedRuns(sql: ShedSql, now: () => number = Date.now) {
426
359
  }
427
360
  // The host binding retains this call's native idempotency key across retries.
428
361
  if (mode === "start") {
429
- // Renew the same selector rather than storing a header credential.
430
- let pass: GitCredential | null = null;
431
- if (prior.remote !== null) {
432
- if (prior.expiresAt === null || prior.expiresAt <= now())
433
- throw new Error(
434
- `Workflow Git pass expired before process dispatch. Log: ${prior.logPath}`,
435
- );
436
- const renewed = await mint();
437
- if (
438
- renewed.passId !== prior.passId ||
439
- renewed.remote !== prior.remote
440
- )
441
- throw new Error(
442
- `Workflow Git renewal changed its original selector. Log: ${prior.logPath}`,
443
- );
444
- pass = renewed;
445
- }
446
- const prepared = prepare(pass);
447
- return dispatch(prepared.start, prior.logPath, pass, prior.expiresAt);
362
+ const prepared = prepare();
363
+ return dispatch(prepared.start, prior.logPath);
448
364
  }
449
365
  throw new Error(
450
366
  `Process start outcome is unavailable. Log: ${prior.logPath}`,
@@ -463,48 +379,21 @@ export function createShedRuns(sql: ShedSql, now: () => number = Date.now) {
463
379
  "conflict",
464
380
  "Run identity conflicts with its declared port",
465
381
  );
466
- if (mode === "run" && process.state !== "exited" && gitAccess !== null) {
467
- if (prior.expiresAt === null || prior.expiresAt <= now())
468
- throw new Error(`Workflow Git pass expired. Log: ${prior.logPath}`);
469
- const pass = await mint();
470
- if (
471
- prior.expiresAt <= now() ||
472
- pass.passId !== prior.passId ||
473
- pass.remote !== prior.remote ||
474
- pass.expiresAt < prior.expiresAt
475
- )
476
- throw new Error(
477
- `Workflow Git renewal changed or expired its original selector. Log: ${prior.logPath}`,
478
- );
479
- sql.exec(
480
- "UPDATE shed_process_claims SET expiresAt=MAX(expiresAt,?) WHERE requestKey=?",
481
- pass.expiresAt,
482
- requestKey,
483
- );
484
- }
485
382
  return observe(requestKey, process, prior.logPath);
486
383
  }
487
384
  const prior = read(requestKey);
488
385
  if (prior) return resume(prior);
489
- // Mint can yield; recheck the durable claim before starting a process.
490
- const pass: WorkflowGitPassResult | null =
491
- gitAccess === null ? null : await mint();
492
- const claimed = read(requestKey);
493
- if (claimed) return resume(claimed);
494
- const { start, logPath } = prepare(pass);
386
+ const { start, logPath } = prepare();
495
387
  sql.exec(
496
- "INSERT INTO shed_process_claims (requestKey,computerId,command,cwd,phase,logPath,gitAccess,passId,remote,expiresAt) VALUES (?,?,?,?,'starting',?,?,?,?,?)",
388
+ "INSERT INTO shed_native_process_claims (requestKey,computerId,command,cwd,phase,logPath,gitAccess) VALUES (?,?,?,?,'starting',?,?)",
497
389
  requestKey,
498
390
  computerId,
499
391
  input.command,
500
392
  input.cwd ?? null,
501
393
  logPath,
502
394
  gitAccess,
503
- pass?.passId ?? null,
504
- pass?.remote ?? null,
505
- pass?.expiresAt ?? null,
506
395
  );
507
- return dispatch(start, logPath, pass, pass?.expiresAt ?? null);
396
+ return dispatch(start, logPath);
508
397
  }
509
398
  return {
510
399
  owns(processId: string, computerId: string, actor: HostActor) {
@@ -512,7 +401,7 @@ export function createShedRuns(sql: ShedSql, now: () => number = Date.now) {
512
401
  return (
513
402
  sql
514
403
  .exec(
515
- "SELECT requestKey FROM shed_process_claims WHERE processId=? AND computerId=? AND json_extract(requestKey,'$[0]')=?",
404
+ "SELECT requestKey FROM shed_native_process_claims WHERE processId=? AND computerId=? AND json_extract(requestKey,'$[0]')=?",
516
405
  processId,
517
406
  computerId,
518
407
  JSON.stringify(actor),