@fungi.computer/truffle 0.0.0-stage → 0.1.1

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.
Files changed (116) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +294 -2
  3. package/computerd-loopback.cjs +16 -0
  4. package/dist/auth-cli.d.ts +36 -0
  5. package/dist/auth-cli.js +351 -0
  6. package/dist/fungi-account-command.d.ts +18 -0
  7. package/dist/fungi-account-command.js +92 -0
  8. package/dist/index.d.ts +19 -0
  9. package/dist/index.js +17 -0
  10. package/dist/internal/private-workerd-sandbox-worker.mjs +27488 -0
  11. package/dist/internal/sandbox-metafile.json +6726 -0
  12. package/dist/linear-auth.d.ts +4 -0
  13. package/dist/linear-auth.js +72 -0
  14. package/dist/local-extensions.d.ts +97 -0
  15. package/dist/local-extensions.js +484 -0
  16. package/dist/local-login.d.ts +55 -0
  17. package/dist/local-login.js +167 -0
  18. package/dist/local-models.d.ts +14 -0
  19. package/dist/local-models.js +77 -0
  20. package/dist/local-skill-source.d.ts +6 -0
  21. package/dist/local-skill-source.js +357 -0
  22. package/dist/log-event.d.ts +2 -0
  23. package/dist/log-event.js +9 -0
  24. package/dist/private-bun-shiitake-host.d.ts +11 -0
  25. package/dist/private-bun-shiitake-host.js +66 -0
  26. package/dist/private-file-credential-store.d.ts +33 -0
  27. package/dist/private-file-credential-store.js +283 -0
  28. package/dist/private-files-patch.d.ts +3 -0
  29. package/dist/private-files-patch.js +86 -0
  30. package/dist/private-fungi-account.d.ts +235 -0
  31. package/dist/private-fungi-account.js +1149 -0
  32. package/dist/private-fungi-auth-error.d.ts +5 -0
  33. package/dist/private-fungi-auth-error.js +7 -0
  34. package/dist/private-fungi-mcp-connection.d.ts +22 -0
  35. package/dist/private-fungi-mcp-connection.js +143 -0
  36. package/dist/private-fungi-mcp-endpoint.d.ts +2 -0
  37. package/dist/private-fungi-mcp-endpoint.js +15 -0
  38. package/dist/private-fungi-mcp-selection.d.ts +14 -0
  39. package/dist/private-fungi-mcp-selection.js +36 -0
  40. package/dist/private-fungi-model-api.d.ts +6 -0
  41. package/dist/private-fungi-model-api.js +125 -0
  42. package/dist/private-fungi-model-catalog.d.ts +6 -0
  43. package/dist/private-fungi-model-catalog.js +78 -0
  44. package/dist/private-fungi-model-provider.d.ts +6 -0
  45. package/dist/private-fungi-model-provider.js +46 -0
  46. package/dist/private-fungi-pending-logouts.d.ts +34 -0
  47. package/dist/private-fungi-pending-logouts.js +136 -0
  48. package/dist/private-invocation-computer.d.ts +37 -0
  49. package/dist/private-invocation-computer.js +459 -0
  50. package/dist/private-invocation-files.d.ts +15 -0
  51. package/dist/private-invocation-files.js +449 -0
  52. package/dist/private-local-acp-stdio.d.ts +17 -0
  53. package/dist/private-local-acp-stdio.js +142 -0
  54. package/dist/private-local-auth-rpc.d.ts +69 -0
  55. package/dist/private-local-auth-rpc.js +273 -0
  56. package/dist/private-local-computer-attach.d.ts +49 -0
  57. package/dist/private-local-computer-attach.js +216 -0
  58. package/dist/private-local-computer-backend.d.ts +282 -0
  59. package/dist/private-local-computer-backend.js +402 -0
  60. package/dist/private-local-computer-processes.d.ts +199 -0
  61. package/dist/private-local-computer-processes.js +589 -0
  62. package/dist/private-local-default-model.d.ts +11 -0
  63. package/dist/private-local-default-model.js +45 -0
  64. package/dist/private-local-delegation.d.ts +29 -0
  65. package/dist/private-local-delegation.js +78 -0
  66. package/dist/private-local-effective-model.d.ts +19 -0
  67. package/dist/private-local-effective-model.js +14 -0
  68. package/dist/private-local-exec-launch.d.ts +41 -0
  69. package/dist/private-local-exec-launch.js +51 -0
  70. package/dist/private-local-exec-path.d.ts +6 -0
  71. package/dist/private-local-exec-path.js +40 -0
  72. package/dist/private-local-git-exclude.d.ts +2 -0
  73. package/dist/private-local-git-exclude.js +33 -0
  74. package/dist/private-local-http-dispatch.d.ts +5 -0
  75. package/dist/private-local-http-dispatch.js +14 -0
  76. package/dist/private-local-mail.d.ts +11 -0
  77. package/dist/private-local-mail.js +69 -0
  78. package/dist/private-local-model-settings.d.ts +5 -0
  79. package/dist/private-local-model-settings.js +31 -0
  80. package/dist/private-local-runtime-host.d.ts +141 -0
  81. package/dist/private-local-runtime-host.js +988 -0
  82. package/dist/private-local-service-contract.d.ts +90 -0
  83. package/dist/private-local-service-contract.js +1 -0
  84. package/dist/private-local-service-discovery.d.ts +101 -0
  85. package/dist/private-local-service-discovery.js +221 -0
  86. package/dist/private-local-service-ids.d.ts +3 -0
  87. package/dist/private-local-service-ids.js +8 -0
  88. package/dist/private-local-service-listener.d.ts +15 -0
  89. package/dist/private-local-service-listener.js +211 -0
  90. package/dist/private-local-service-owner.d.ts +1567 -0
  91. package/dist/private-local-service-owner.js +371 -0
  92. package/dist/private-local-service-process.d.ts +1 -0
  93. package/dist/private-local-service-process.js +6 -0
  94. package/dist/private-local-service.d.ts +17 -0
  95. package/dist/private-local-service.js +330 -0
  96. package/dist/private-local-session-discovery.d.ts +19 -0
  97. package/dist/private-local-session-discovery.js +157 -0
  98. package/dist/private-local-shiitake-host.d.ts +44 -0
  99. package/dist/private-local-shiitake-host.js +143 -0
  100. package/dist/private-local-subagents.d.ts +27 -0
  101. package/dist/private-local-subagents.js +77 -0
  102. package/dist/private-local-worker-server.d.ts +15 -0
  103. package/dist/private-local-worker-server.js +136 -0
  104. package/dist/private-sqlite-owner.d.ts +7 -0
  105. package/dist/private-sqlite-owner.js +103 -0
  106. package/dist/private-team-computers.d.ts +38 -0
  107. package/dist/private-team-computers.js +101 -0
  108. package/dist/private-workerd-sandbox.d.ts +3 -0
  109. package/dist/private-workerd-sandbox.js +347 -0
  110. package/dist/team-apps.d.ts +24 -0
  111. package/dist/team-apps.js +123 -0
  112. package/dist/tracker-git-credential.d.ts +15 -0
  113. package/dist/tracker-git-credential.js +87 -0
  114. package/native/bin/build.json +4037 -0
  115. package/native/bin/fungi-apply-patch +0 -0
  116. package/package.json +148 -3
@@ -0,0 +1,136 @@
1
+ import { mkdir, open } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import { decodeJwt } from "jose";
4
+ import lockfile from "proper-lockfile";
5
+ import writeFileAtomic from "write-file-atomic";
6
+ import { z } from "zod";
7
+ import { logEvent } from "./log-event.js";
8
+ import { resolveDefaultCredentialPath } from "./private-file-credential-store.js";
9
+ const identitySchema = z.strictObject({
10
+ issuer: z.url().max(2048),
11
+ userId: z.string().min(1).max(256),
12
+ loginId: z.string().min(1).max(256),
13
+ });
14
+ const detachSchema = z.strictObject({
15
+ teamId: z.string().min(1).max(256),
16
+ computerId: z.string().min(1).max(256),
17
+ requestId: z.string().min(1).max(256),
18
+ });
19
+ const recordSchema = identitySchema.extend({
20
+ revoke: z.boolean(),
21
+ detaches: z.array(detachSchema).max(16),
22
+ });
23
+ const recordsSchema = z.array(recordSchema).max(128);
24
+ const missingFile = z.object({ code: z.literal("ENOENT") });
25
+ /** Decode only routing metadata. These IDs never authorize a revocation. */
26
+ export function loginIdentity(issuer, access) {
27
+ try {
28
+ const claims = decodeJwt(access);
29
+ return identitySchema.parse({
30
+ issuer,
31
+ userId: claims.sub,
32
+ loginId: claims["https://fungi.computer/oauth/login"],
33
+ });
34
+ }
35
+ catch (cause) {
36
+ logEvent("fungi-account.pending-revoke-identity", cause);
37
+ return undefined;
38
+ }
39
+ }
40
+ function matches(left, right) {
41
+ return (left.issuer === right.issuer &&
42
+ left.userId === right.userId &&
43
+ left.loginId === right.loginId);
44
+ }
45
+ /** Durable logout intent, separate from the credential that logout destroys. */
46
+ export function pendingLogouts(credentialPath = resolveDefaultCredentialPath()) {
47
+ const path = `${credentialPath}.pending-logouts.json`;
48
+ async function update(change) {
49
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
50
+ const release = await lockfile.lock(path, {
51
+ realpath: false,
52
+ stale: 30_000,
53
+ update: 10_000,
54
+ retries: { retries: 20, factor: 1.2, minTimeout: 25, maxTimeout: 100 },
55
+ });
56
+ try {
57
+ let records = [];
58
+ try {
59
+ const file = await open(path, "r");
60
+ try {
61
+ if ((await file.stat()).size > 256 * 1024)
62
+ throw new Error("Pending revocations exceed the storage limit");
63
+ records = recordsSchema.parse(JSON.parse(await file.readFile("utf8")));
64
+ }
65
+ finally {
66
+ await file.close();
67
+ }
68
+ }
69
+ catch (cause) {
70
+ if (!missingFile.safeParse(cause).success)
71
+ throw cause;
72
+ }
73
+ const changed = change(records);
74
+ const serialized = JSON.stringify(recordsSchema.parse(changed.records));
75
+ if (Buffer.byteLength(serialized) > 256 * 1024)
76
+ throw new Error("Pending revocations exceed the storage limit");
77
+ await writeFileAtomic(path, serialized, {
78
+ encoding: "utf8",
79
+ fsync: true,
80
+ mode: 0o600,
81
+ });
82
+ return changed.result;
83
+ }
84
+ finally {
85
+ await release();
86
+ }
87
+ }
88
+ return {
89
+ remember(identity, detaches) {
90
+ return update((records) => {
91
+ const next = [
92
+ ...records.filter((record) => !matches(record, identity)),
93
+ recordSchema.parse({ ...identity, revoke: true, detaches }),
94
+ ];
95
+ let evicted = false;
96
+ while (next.length > 128 ||
97
+ Buffer.byteLength(JSON.stringify(next)) > 256 * 1024) {
98
+ next.shift();
99
+ evicted = true;
100
+ }
101
+ if (evicted)
102
+ logEvent("fungi-account.pending-logout-evicted", new Error("Oldest logout intent evicted at storage limit"));
103
+ return { records: next, result: undefined };
104
+ });
105
+ },
106
+ confirmed(identity, detach) {
107
+ return update((records) => ({
108
+ records: records
109
+ .map((record) => {
110
+ if (!matches(record, identity))
111
+ return record;
112
+ return detach === undefined
113
+ ? { ...record, revoke: false }
114
+ : {
115
+ ...record,
116
+ detaches: record.detaches.filter((item) => item.teamId !== detach.teamId ||
117
+ item.computerId !== detach.computerId ||
118
+ item.requestId !== detach.requestId),
119
+ };
120
+ })
121
+ .filter((record) => record.revoke || record.detaches.length > 0),
122
+ result: undefined,
123
+ }));
124
+ },
125
+ forAccount(identity) {
126
+ return update((records) => {
127
+ const kept = records.filter((record) => record.issuer === identity.issuer &&
128
+ record.userId === identity.userId);
129
+ return {
130
+ records: kept,
131
+ result: kept,
132
+ };
133
+ });
134
+ },
135
+ };
136
+ }
@@ -0,0 +1,37 @@
1
+ import * as ChildProcess from "effect/process/ChildProcess";
2
+ import type { KnapsackComputer } from "@fungi.computer/knapsack";
3
+ import { z } from "zod";
4
+ import { type PrivateLocalProcessScope } from "./private-local-computer-processes.js";
5
+ declare const execOutput: z.ZodDiscriminatedUnion<[z.ZodObject<{
6
+ exitCode: z.ZodNumber;
7
+ stdout: z.ZodString;
8
+ stderr: z.ZodString;
9
+ truncated: z.ZodBoolean;
10
+ status: z.ZodEnum<{
11
+ failed: "failed";
12
+ completed: "completed";
13
+ }>;
14
+ }, z.core.$strict>, z.ZodObject<{
15
+ exitCode: z.ZodNumber;
16
+ stdout: z.ZodString;
17
+ stderr: z.ZodString;
18
+ truncated: z.ZodBoolean;
19
+ status: z.ZodLiteral<"deadline_exceeded">;
20
+ timeoutMs: z.ZodNumber;
21
+ elapsedMs: z.ZodNumber;
22
+ message: z.ZodString;
23
+ }, z.core.$strict>], "status">;
24
+ export type ExecOutput = z.output<typeof execOutput>;
25
+ /** Shared bounded native process owner; callers supply argv/stdin, never shell interpolation. */
26
+ export declare function runPrivateInvocationProcess(processCommand: ChildProcess.Command, signal: AbortSignal, timeoutMs: number | undefined, describe?: Readonly<{
27
+ /** The timeout actually requested, before any clamp; defaults to `timeoutMs`. */
28
+ requestedMs?: number;
29
+ /** The real command text, for the timeout message; omitted when not meaningful (e.g. a native helper). */
30
+ describeCommand?: () => string;
31
+ }>): Promise<ExecOutput>;
32
+ /**
33
+ * Construct one private invocation-root Computer: bounded shell exec, plus the
34
+ * background-process operations of `processes` when the host supplies a scope.
35
+ */
36
+ export declare function createPrivateInvocationComputer(invocationRoot: string, executionPath?: string, processes?: PrivateLocalProcessScope): Promise<KnapsackComputer>;
37
+ export {};
@@ -0,0 +1,459 @@
1
+ import { realpath } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { relative, resolve, sep } from "node:path";
4
+ import process from "node:process";
5
+ import * as ChildProcess from "effect/process/ChildProcess";
6
+ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
7
+ import { isPlatformError } from "effect/PlatformError";
8
+ import * as NodeServices from "@effect/platform-node/NodeServices";
9
+ import { CapabilityFailed, } from "@fungi.computer/mycelium";
10
+ import * as Clock from "effect/Clock";
11
+ import * as Effect from "effect/Effect";
12
+ import * as Fiber from "effect/Fiber";
13
+ import * as Exit from "effect/Exit";
14
+ import * as Option from "effect/Option";
15
+ import * as Scope from "effect/Scope";
16
+ import * as Stream from "effect/Stream";
17
+ import { z } from "zod";
18
+ import { backgroundExecInput, backgroundExecOutput, callBackgroundExec, } from "./private-local-computer-processes.js";
19
+ import { capturePrivateLocalExecPath, parsePrivateLocalExecPath, } from "./private-local-exec-path.js";
20
+ import { combinedArgvBytes, launchCwdSchema, launchStdinSchema, maximumLaunchTextLength, } from "./private-local-exec-launch.js";
21
+ const maximumStreamBytes = 64 * 1_024;
22
+ const defaultCommandTimeoutMs = 180_000;
23
+ /**
24
+ * Keep in sync with the invocation deadline `private-local-runtime-host.ts`
25
+ * sets as `execution.timeoutMs` (~line 705): one Mycelium `execute` call,
26
+ * covering sandbox setup, every command it runs, and teardown.
27
+ */
28
+ const invocationDeadlineMs = 900_000;
29
+ /**
30
+ * Reserved outside any one command for sandbox boot, forage round-trips,
31
+ * JSON marshaling, and process teardown. One command's budget stays below
32
+ * the invocation budget; multiple commands still share that invocation's
33
+ * deadline.
34
+ */
35
+ const invocationSetupTeardownMarginMs = 120_000;
36
+ const maximumCommandTimeoutMs = invocationDeadlineMs - invocationSetupTeardownMarginMs;
37
+ const jsonValueSchema = z.json();
38
+ const capabilityNameSchema = z
39
+ .string()
40
+ .min(1)
41
+ .max(128)
42
+ .regex(/^[A-Za-z][A-Za-z0-9_]*$/);
43
+ const runCommonFields = {
44
+ operation: z.literal("run").default("run"),
45
+ cwd: launchCwdSchema.optional(),
46
+ // Accepted up to the full invocation deadline and silently clamped to
47
+ // maximumCommandTimeoutMs (see runCommandAtInvocationBoundary); rejecting
48
+ // outright here would just trade one confusing failure for another.
49
+ timeoutMs: z
50
+ .number()
51
+ .int()
52
+ .min(1)
53
+ .max(invocationDeadlineMs)
54
+ .default(defaultCommandTimeoutMs),
55
+ };
56
+ /** The intentional shell form: one interpreted command line. */
57
+ const shellRunInput = z.strictObject({
58
+ ...runCommonFields,
59
+ command: z.string().min(1).max(maximumLaunchTextLength),
60
+ stdin: launchStdinSchema.optional(),
61
+ });
62
+ /** The direct form: argv run with no shell interpretation. */
63
+ const directRunInput = z
64
+ .strictObject({
65
+ ...runCommonFields,
66
+ executable: z.string().min(1),
67
+ args: z.array(z.string()).default([]),
68
+ stdin: launchStdinSchema.optional(),
69
+ })
70
+ .refine((value) => combinedArgvBytes(value.executable, value.args) <=
71
+ maximumLaunchTextLength, {
72
+ message: `Computer exec executable and args exceed ${String(maximumLaunchTextLength)} combined UTF-8 bytes`,
73
+ path: ["args"],
74
+ });
75
+ /**
76
+ * `command` (shell) and `executable`/`args` (direct) are mutually exclusive:
77
+ * each branch is a strict object, so supplying both or neither fails every
78
+ * branch and the union is refused.
79
+ */
80
+ const execInput = z.union([shellRunInput, directRunInput]);
81
+ const commandOutputFields = {
82
+ exitCode: z.number().int(),
83
+ stdout: z.string(),
84
+ stderr: z.string(),
85
+ truncated: z.boolean(),
86
+ };
87
+ const execOutput = z.discriminatedUnion("status", [
88
+ z.strictObject({
89
+ status: z.enum(["completed", "failed"]),
90
+ ...commandOutputFields,
91
+ }),
92
+ z.strictObject({
93
+ status: z.literal("deadline_exceeded"),
94
+ timeoutMs: z.number().int().positive(),
95
+ elapsedMs: z.number().nonnegative(),
96
+ message: z.string(),
97
+ ...commandOutputFields,
98
+ }),
99
+ ]);
100
+ const shellInput = z.union([execInput, backgroundExecInput]);
101
+ const shellOutput = z.union([execOutput, backgroundExecOutput]);
102
+ function computerCapabilities(root, processes) {
103
+ return jsonValueSchema.parse([
104
+ {
105
+ id: "exec",
106
+ name: "exec",
107
+ kind: "runtime",
108
+ // Kept terse: a long-rooted project (real test case: a nested nearly
109
+ // 200-byte root) plus this text must stay under Mycelium's 1000-char
110
+ // operation-description bound. Do not restate the full contract here;
111
+ // the JSON Schema parameters carry the exact shape.
112
+ description: `Shell, terminal, git, builds and checks. Computer root: ${root}. cwd must be absolute and remain inside this root; omission starts at the root. ~/$HOME use home. Or {executable,args}; stdin once, closed. exec stops its group, including nohup. timeoutMs default ${String(defaultCommandTimeoutMs)}, max ${String(maximumCommandTimeoutMs)} ms; invocation ${String(invocationDeadlineMs)} ms. Deadline returns partial output.${processes === undefined ? "" : ' Background start takes command or executable+args; {operation:"start",command} returns process.id; poll/wait: state, exitCode, output tail. wait <=60000 ms (default 10000); timedOut means wait expired. Cancelling wait keeps jobs alive. maxBytes <=64 KiB with offset/nextOffset; retain last 8 MiB (logStartOffset). kill stops group; list shows Session jobs. Delete/close stop jobs; restart drops IDs. Use wait, not sleep.'}`,
113
+ parameters: structuredClone(z.toJSONSchema(processes === undefined ? execInput : shellInput, {
114
+ io: "input",
115
+ })),
116
+ output: structuredClone(z.toJSONSchema(processes === undefined ? execOutput : shellOutput)),
117
+ },
118
+ ]);
119
+ }
120
+ /** A one-line, shell-safe label for error messages; never the full command. */
121
+ function shortCommandLabel(command) {
122
+ const oneLine = command.replace(/\s+/gu, " ").trim();
123
+ return oneLine.length > 80 ? `${oneLine.slice(0, 80)}…` : oneLine;
124
+ }
125
+ /** Plain, actionable sentence for a command that hit its (possibly clamped) timeout. */
126
+ function commandTimeoutMessage(input) {
127
+ const clampNote = input.timeoutMs < input.requestedMs
128
+ ? ` (clamped from the requested ${String(input.requestedMs)} ms so the command leaves ${String(invocationSetupTeardownMarginMs)} ms of headroom under the ${String(invocationDeadlineMs)} ms invocation deadline)`
129
+ : "";
130
+ return `Command exceeded timeoutMs=${String(input.timeoutMs)} ms${clampNote}. Command: ${shortCommandLabel(input.command)}. Rerun with a larger timeoutMs, up to ${String(maximumCommandTimeoutMs)} ms; split longer work into separate tool calls.`;
131
+ }
132
+ function isErrnoException(error) {
133
+ return "code" in error;
134
+ }
135
+ function assertContained(root, candidate, requested) {
136
+ const fromRoot = relative(root, candidate);
137
+ if (fromRoot === "" ||
138
+ (!fromRoot.startsWith(`..${sep}`) &&
139
+ fromRoot !== ".." &&
140
+ !fromRoot.startsWith(sep))) {
141
+ return;
142
+ }
143
+ throw new CapabilityFailed({
144
+ namespace: "Computer",
145
+ operation: "exec",
146
+ cause: {
147
+ kind: "InvalidInput",
148
+ message: `Computer cwd resolves outside the invocation root: ${requested}`,
149
+ },
150
+ });
151
+ }
152
+ async function resolveCwd(root, requested) {
153
+ const candidate = resolve(requested ?? root);
154
+ assertContained(root, candidate, requested ?? root);
155
+ const canonical = await realpath(candidate);
156
+ assertContained(root, canonical, requested ?? root);
157
+ return canonical;
158
+ }
159
+ function errnoCode(error) {
160
+ return isErrnoException(error) ? error.code : undefined;
161
+ }
162
+ function groupState(groupId) {
163
+ return Effect.sync(() => {
164
+ try {
165
+ process.kill(-groupId, 0);
166
+ return "running";
167
+ }
168
+ catch (error) {
169
+ if (!(error instanceof Error))
170
+ throw error;
171
+ switch (errnoCode(error)) {
172
+ case "ESRCH":
173
+ return "absent";
174
+ case "EPERM":
175
+ return "unsignalable";
176
+ default:
177
+ throw error;
178
+ }
179
+ }
180
+ });
181
+ }
182
+ /** Signal every member we may signal; false when none could be signalled. */
183
+ function signalGroup(groupId, signal) {
184
+ return Effect.sync(() => {
185
+ try {
186
+ process.kill(-groupId, signal);
187
+ return true;
188
+ }
189
+ catch (error) {
190
+ if (!(error instanceof Error))
191
+ throw error;
192
+ const code = errnoCode(error);
193
+ if (code === "ESRCH" || code === "EPERM")
194
+ return false;
195
+ throw error;
196
+ }
197
+ });
198
+ }
199
+ /** Zombie-only groups are reaped promptly; longer EPERM means a foreign member. */
200
+ const maximumUnsignalablePolls = 200;
201
+ function awaitLeaderExit(process) {
202
+ return Effect.suspend(() => Effect.flatMap(process.isRunning, (running) => running
203
+ ? Effect.sleep("10 millis").pipe(Effect.andThen(awaitLeaderExit(process)))
204
+ : Effect.void));
205
+ }
206
+ function awaitGroupAbsence(groupId, unsignalablePolls = 0) {
207
+ return Effect.suspend(() => Effect.flatMap(groupState(groupId), (state) => {
208
+ if (state === "absent")
209
+ return Effect.void;
210
+ if (state === "unsignalable" &&
211
+ unsignalablePolls >= maximumUnsignalablePolls) {
212
+ return Effect.die(new CapabilityFailed({
213
+ namespace: "Computer",
214
+ operation: "cleanup",
215
+ cause: {
216
+ kind: "OperationFailed",
217
+ message: `Computer process group ${String(groupId)} has members this invocation cannot signal`,
218
+ },
219
+ }));
220
+ }
221
+ return Effect.sleep("10 millis").pipe(Effect.andThen(awaitGroupAbsence(groupId, state === "unsignalable" ? unsignalablePolls + 1 : 0)));
222
+ }));
223
+ }
224
+ function awaitProcessGroupExit(process, groupId) {
225
+ return Effect.all([Effect.exit(awaitLeaderExit(process)), awaitGroupAbsence(groupId)], { concurrency: "unbounded" }).pipe(Effect.flatMap(([leader]) => Exit.isFailure(leader) ? Effect.failCause(leader.cause) : Effect.void), Effect.orDie);
226
+ }
227
+ function nativeGroupFinalizer(process) {
228
+ return Effect.sync(() => {
229
+ const groupId = Number(process.pid);
230
+ if (!Number.isSafeInteger(groupId) || groupId <= 1) {
231
+ throw new Error("ChildProcessSpawner returned an unsafe process group id");
232
+ }
233
+ return groupId;
234
+ }).pipe(Effect.flatMap((groupId) => Effect.flatMap(groupState(groupId), (state) => {
235
+ const completion = awaitProcessGroupExit(process, groupId);
236
+ if (state !== "running")
237
+ return completion;
238
+ return Effect.flatMap(signalGroup(groupId, "SIGTERM"), () => Effect.flatMap(Effect.timeoutOption(completion.pipe(Effect.interruptible), "1 second"), Option.match({
239
+ onNone: () => signalGroup(groupId, "SIGKILL").pipe(Effect.andThen(completion)),
240
+ onSome: () => Effect.void,
241
+ })));
242
+ })));
243
+ }
244
+ function captureBoundedOutput(stream) {
245
+ const initial = {
246
+ chunks: [],
247
+ byteLength: 0,
248
+ truncated: false,
249
+ };
250
+ return Stream.runFold(stream, () => initial, (captured, chunk) => {
251
+ if (captured.truncated)
252
+ return captured;
253
+ const remaining = maximumStreamBytes - captured.byteLength;
254
+ if (chunk.byteLength <= remaining) {
255
+ return {
256
+ chunks: [...captured.chunks, chunk],
257
+ byteLength: captured.byteLength + chunk.byteLength,
258
+ truncated: false,
259
+ };
260
+ }
261
+ return {
262
+ chunks: remaining === 0
263
+ ? captured.chunks
264
+ : [...captured.chunks, chunk.slice(0, remaining)],
265
+ byteLength: maximumStreamBytes,
266
+ truncated: true,
267
+ };
268
+ });
269
+ }
270
+ function textOutput(captured) {
271
+ const bytes = new Uint8Array(captured.byteLength);
272
+ let offset = 0;
273
+ for (const chunk of captured.chunks) {
274
+ bytes.set(chunk, offset);
275
+ offset += chunk.byteLength;
276
+ }
277
+ return {
278
+ text: new TextDecoder().decode(bytes),
279
+ truncated: captured.truncated,
280
+ };
281
+ }
282
+ function commandResult(exitCode, stdout, stderr) {
283
+ const standardOutput = textOutput(stdout);
284
+ const standardError = textOutput(stderr);
285
+ return {
286
+ status: exitCode === 0 ? "completed" : "failed",
287
+ exitCode,
288
+ stdout: standardOutput.text,
289
+ stderr: standardError.text,
290
+ truncated: standardOutput.truncated || standardError.truncated,
291
+ };
292
+ }
293
+ /**
294
+ * Human-readable label for timeout messages; never used for execution. Each
295
+ * direct-form arg is JSON-quoted so the label does not read like a
296
+ * re-runnable shell line.
297
+ */
298
+ function describeRunRequest(request) {
299
+ return "command" in request
300
+ ? request.command
301
+ : [
302
+ request.executable,
303
+ ...request.args.map((arg) => JSON.stringify(arg)),
304
+ ].join(" ");
305
+ }
306
+ /**
307
+ * Build the one `ChildProcess.Command` for a run request. The direct form
308
+ * passes `executable`/`args` straight to `ChildProcess.make`, never through a
309
+ * shell; only the intentional shell form interpolates into `sh -c`.
310
+ */
311
+ function buildRunCommand(request, cwd, executionPath) {
312
+ const env = { PATH: executionPath, HOME: process.env.HOME ?? homedir() };
313
+ // Effect's own default stdin is an open "pipe" that nothing closes, so an
314
+ // executable that reads stdin (`cat`, `python3 -`) would otherwise hang
315
+ // for the full timeout. "ignore" gives it immediate EOF, matching the
316
+ // background process owner's existing behavior for an omitted stdin.
317
+ const stdin = request.stdin === undefined
318
+ ? "ignore"
319
+ : Stream.make(new TextEncoder().encode(request.stdin));
320
+ return "command" in request
321
+ ? ChildProcess.make("sh", ["-c", request.command], {
322
+ cwd,
323
+ env,
324
+ extendEnv: false,
325
+ stdin,
326
+ })
327
+ : ChildProcess.make(request.executable, request.args, {
328
+ cwd,
329
+ env,
330
+ extendEnv: false,
331
+ stdin,
332
+ });
333
+ }
334
+ /**
335
+ * Only the direct form names an executable the host itself must locate and
336
+ * run; the shell form's `sh` is a fixed, always-present interpreter, so a
337
+ * missing-executable mapping only ever applies to `request.executable`.
338
+ */
339
+ function isMissingExecutableFailure(cause) {
340
+ return (isPlatformError(cause) &&
341
+ (cause.reason._tag === "NotFound" ||
342
+ cause.reason._tag === "PermissionDenied"));
343
+ }
344
+ async function runCommandAtInvocationBoundary(request, cwd, executionPath, signal) {
345
+ const processCommand = buildRunCommand(request, cwd, executionPath);
346
+ const timeoutMs = Math.min(request.timeoutMs, maximumCommandTimeoutMs);
347
+ try {
348
+ return await runPrivateInvocationProcess(processCommand, signal, timeoutMs, {
349
+ requestedMs: request.timeoutMs,
350
+ describeCommand: () => describeRunRequest(request),
351
+ });
352
+ }
353
+ catch (cause) {
354
+ // Spawning the direct form's named executable can fail at the OS level
355
+ // before any ExecOutput exists (ENOENT/EACCES), surfacing as an untyped
356
+ // Effect PlatformError that would otherwise reach the model only as a
357
+ // generic sandbox failure. Report it the same honest way as the NUL
358
+ // refusal: a typed CapabilityFailed naming the executable, no process
359
+ // started.
360
+ if ("executable" in request && isMissingExecutableFailure(cause))
361
+ throw new CapabilityFailed({
362
+ namespace: "Computer",
363
+ operation: "exec",
364
+ cause: {
365
+ kind: "InvalidInput",
366
+ message: `Executable ${request.executable} was not found on PATH or could not be run; no process was started.`,
367
+ },
368
+ });
369
+ throw cause;
370
+ }
371
+ }
372
+ /** Shared bounded native process owner; callers supply argv/stdin, never shell interpolation. */
373
+ export function runPrivateInvocationProcess(processCommand, signal, timeoutMs, describe) {
374
+ return Effect.runPromise(Effect.scoped(Effect.gen(function* () {
375
+ const startedAt = yield* Clock.currentTimeMillis;
376
+ const executor = yield* ChildProcessSpawner.ChildProcessSpawner;
377
+ const processScope = yield* Effect.acquireRelease(Scope.make(), (scope) => Scope.close(scope, Exit.void));
378
+ const process = yield* Effect.acquireRelease(executor.spawn(processCommand), nativeGroupFinalizer).pipe(Effect.provideService(Scope.Scope, processScope));
379
+ // Keep both readers alive when the exit wait times out. Killing and
380
+ // reaping the owned group closes the pipes, preserving bounded output.
381
+ const stdoutFiber = yield* Effect.forkChild(captureBoundedOutput(process.stdout));
382
+ const stderrFiber = yield* Effect.forkChild(captureBoundedOutput(process.stderr));
383
+ const exited = yield* timeoutMs === undefined
384
+ ? Effect.map(process.exitCode, Option.some)
385
+ : Effect.timeoutOption(process.exitCode, timeoutMs);
386
+ yield* Scope.close(processScope, Exit.void);
387
+ const [stdout, stderr] = yield* Effect.all([Fiber.join(stdoutFiber), Fiber.join(stderrFiber)], { concurrency: "unbounded" });
388
+ if (Option.isSome(exited))
389
+ return commandResult(Number(exited.value), stdout, stderr);
390
+ if (timeoutMs === undefined)
391
+ throw new Error("Missing command deadline");
392
+ const elapsedMs = (yield* Clock.currentTimeMillis) - startedAt;
393
+ const standardOutput = textOutput(stdout);
394
+ const standardError = textOutput(stderr);
395
+ return {
396
+ status: "deadline_exceeded",
397
+ timeoutMs,
398
+ elapsedMs,
399
+ exitCode: -1,
400
+ stdout: standardOutput.text,
401
+ stderr: standardError.text,
402
+ message: describe?.describeCommand === undefined
403
+ ? `Command exceeded timeoutMs=${String(timeoutMs)} ms. Rerun with a larger timeoutMs, up to ${String(maximumCommandTimeoutMs)} ms; split longer work into separate tool calls.`
404
+ : commandTimeoutMessage({
405
+ command: describe.describeCommand(),
406
+ timeoutMs,
407
+ requestedMs: describe.requestedMs ?? timeoutMs,
408
+ }),
409
+ truncated: standardOutput.truncated || standardError.truncated,
410
+ };
411
+ })).pipe(Effect.provide(NodeServices.layer)), { signal });
412
+ }
413
+ async function execute(root, executionPath, request, context) {
414
+ context.signal.throwIfAborted();
415
+ const cwd = await resolveCwd(root, request.cwd);
416
+ context.signal.throwIfAborted();
417
+ return runCommandAtInvocationBoundary(request, cwd, executionPath, context.signal);
418
+ }
419
+ /**
420
+ * Construct one private invocation-root Computer: bounded shell exec, plus the
421
+ * background-process operations of `processes` when the host supplies a scope.
422
+ */
423
+ export async function createPrivateInvocationComputer(invocationRoot, executionPath = capturePrivateLocalExecPath(process.env.PATH ?? "/usr/bin:/bin", process.cwd()), processes) {
424
+ const root = await realpath(resolve(invocationRoot));
425
+ const path = parsePrivateLocalExecPath(executionPath);
426
+ return Object.freeze({
427
+ getCapabilities: () => Promise.resolve(computerCapabilities(root, processes)),
428
+ call: async (capabilityId, input, context) => {
429
+ const selected = capabilityNameSchema.parse(capabilityId);
430
+ if (selected !== "exec")
431
+ throw new Error(`Computer capability is unavailable: ${selected}`);
432
+ const request = (processes === undefined ? execInput : shellInput).parse(input);
433
+ // execve passes argv as NUL-terminated C strings; a NUL embedded in the
434
+ // shell command, the direct executable, or any direct arg would
435
+ // silently truncate it instead of running the submitted text. stdin is
436
+ // a byte stream, not a C string, so it is exempt. The empty array is
437
+ // every poll/wait/kill/list request: none of those carry argv at all.
438
+ const launchArgv = "command" in request
439
+ ? [request.command]
440
+ : "executable" in request
441
+ ? [request.executable, ...request.args]
442
+ : [];
443
+ if (launchArgv.some((value) => value.includes("\0")))
444
+ throw new CapabilityFailed({
445
+ namespace: "Computer",
446
+ operation: "exec",
447
+ cause: {
448
+ kind: "InvalidInput",
449
+ message: "Computer exec command or argument contains a NUL byte. Remove it or double the backslash in JavaScript escapes (for example, \\\\x00); no process was started.",
450
+ },
451
+ });
452
+ if (request.operation === "run")
453
+ return execute(root, path, request, context);
454
+ if (processes !== undefined)
455
+ return jsonValueSchema.parse(await callBackgroundExec(processes, request, (requested) => resolveCwd(root, requested), path, context.signal));
456
+ throw new Error(`Computer capability is unavailable: ${selected}`);
457
+ },
458
+ });
459
+ }
@@ -0,0 +1,15 @@
1
+ import { type FilesPort } from "@fungi.computer/knapsack";
2
+ /** Safe Files path failures surfaced across the workerd binding boundary. */
3
+ export declare class FilesPathError extends Error {
4
+ readonly reason: "outside-root" | "not-found" | "not-file";
5
+ readonly code: "ENOENT" | undefined;
6
+ constructor(reason: "outside-root" | "not-found" | "not-file", requested: string);
7
+ }
8
+ /**
9
+ * Construct the private local Files port for one captured invocation root.
10
+ *
11
+ * Resolved paths stay under the captured canonical root, including admitted
12
+ * in-root symlinks. This is trusted same-user containment, not protection from
13
+ * a same-user process racing pathname resolution.
14
+ */
15
+ export declare function createPrivateInvocationFilesPort(path: string): Promise<FilesPort>;