@fungi.computer/truffle 0.0.0-stage → 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.
Files changed (207) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +542 -2
  3. package/computerd-loopback.cjs +16 -0
  4. package/dist/auth-cli.d.ts +46 -0
  5. package/dist/auth-cli.js +373 -0
  6. package/dist/fungi-account-command.d.ts +18 -0
  7. package/dist/fungi-account-command.js +95 -0
  8. package/dist/index.d.ts +27 -0
  9. package/dist/index.js +23 -0
  10. package/dist/internal/image-fit-metafile.json +106 -0
  11. package/dist/internal/local-image-fit-worker.mjs +960 -0
  12. package/dist/internal/private-workerd-sandbox-worker.mjs +27487 -0
  13. package/dist/internal/sandbox-metafile.json +6726 -0
  14. package/dist/linear-auth.d.ts +4 -0
  15. package/dist/linear-auth.js +72 -0
  16. package/dist/local-archive-handoff.d.ts +12 -0
  17. package/dist/local-archive-handoff.js +21 -0
  18. package/dist/local-extensions.d.ts +143 -0
  19. package/dist/local-extensions.js +624 -0
  20. package/dist/local-image-fit.d.ts +30 -0
  21. package/dist/local-image-fit.js +105 -0
  22. package/dist/local-login.d.ts +55 -0
  23. package/dist/local-login.js +167 -0
  24. package/dist/local-models.d.ts +16 -0
  25. package/dist/local-models.js +82 -0
  26. package/dist/local-session-archives.d.ts +8 -0
  27. package/dist/local-session-archives.js +152 -0
  28. package/dist/local-skill-source.d.ts +21 -0
  29. package/dist/local-skill-source.js +433 -0
  30. package/dist/log-event.d.ts +9 -0
  31. package/dist/log-event.js +13 -0
  32. package/dist/private-bounded-response.d.ts +2 -0
  33. package/dist/private-bounded-response.js +41 -0
  34. package/dist/private-bun-shiitake-host.d.ts +11 -0
  35. package/dist/private-bun-shiitake-host.js +66 -0
  36. package/dist/private-file-credential-store.d.ts +58 -0
  37. package/dist/private-file-credential-store.js +331 -0
  38. package/dist/private-files-patch.d.ts +4 -0
  39. package/dist/private-files-patch.js +104 -0
  40. package/dist/private-fungi-account.d.ts +252 -0
  41. package/dist/private-fungi-account.js +1329 -0
  42. package/dist/private-fungi-auth-error.d.ts +5 -0
  43. package/dist/private-fungi-auth-error.js +7 -0
  44. package/dist/private-fungi-mcp-connection.d.ts +26 -0
  45. package/dist/private-fungi-mcp-connection.js +162 -0
  46. package/dist/private-fungi-mcp-endpoint.d.ts +2 -0
  47. package/dist/private-fungi-mcp-endpoint.js +15 -0
  48. package/dist/private-fungi-mcp-selection-schema.d.ts +7 -0
  49. package/dist/private-fungi-mcp-selection-schema.js +15 -0
  50. package/dist/private-fungi-mcp-selection.d.ts +14 -0
  51. package/dist/private-fungi-mcp-selection.js +49 -0
  52. package/dist/private-fungi-model-api.d.ts +6 -0
  53. package/dist/private-fungi-model-api.js +125 -0
  54. package/dist/private-fungi-model-catalog.d.ts +16 -0
  55. package/dist/private-fungi-model-catalog.js +86 -0
  56. package/dist/private-fungi-model-provider.d.ts +6 -0
  57. package/dist/private-fungi-model-provider.js +51 -0
  58. package/dist/private-fungi-pending-logouts.d.ts +34 -0
  59. package/dist/private-fungi-pending-logouts.js +135 -0
  60. package/dist/private-fungi-service-logout.d.ts +44 -0
  61. package/dist/private-fungi-service-logout.js +59 -0
  62. package/dist/private-invocation-computer.d.ts +58 -0
  63. package/dist/private-invocation-computer.js +498 -0
  64. package/dist/private-invocation-files.d.ts +30 -0
  65. package/dist/private-invocation-files.js +520 -0
  66. package/dist/private-local-acp-stdio.d.ts +17 -0
  67. package/dist/private-local-acp-stdio.js +145 -0
  68. package/dist/private-local-admission-gate.d.ts +14 -0
  69. package/dist/private-local-admission-gate.js +52 -0
  70. package/dist/private-local-agent-control.d.ts +101 -0
  71. package/dist/private-local-agent-control.js +30 -0
  72. package/dist/private-local-agent-default-model.d.ts +12 -0
  73. package/dist/private-local-agent-default-model.js +31 -0
  74. package/dist/private-local-auth-rpc.d.ts +129 -0
  75. package/dist/private-local-auth-rpc.js +396 -0
  76. package/dist/private-local-bwrap.d.ts +13 -0
  77. package/dist/private-local-bwrap.js +70 -0
  78. package/dist/private-local-client-version.d.ts +5 -0
  79. package/dist/private-local-client-version.js +43 -0
  80. package/dist/private-local-computer-attach.d.ts +49 -0
  81. package/dist/private-local-computer-attach.js +230 -0
  82. package/dist/private-local-computer-backend.d.ts +282 -0
  83. package/dist/private-local-computer-backend.js +402 -0
  84. package/dist/private-local-computer-processes.d.ts +202 -0
  85. package/dist/private-local-computer-processes.js +613 -0
  86. package/dist/private-local-config-root.d.ts +21 -0
  87. package/dist/private-local-config-root.js +19 -0
  88. package/dist/private-local-containment.d.ts +51 -0
  89. package/dist/private-local-containment.js +161 -0
  90. package/dist/private-local-default-model.d.ts +11 -0
  91. package/dist/private-local-default-model.js +45 -0
  92. package/dist/private-local-delegation.d.ts +36 -0
  93. package/dist/private-local-delegation.js +133 -0
  94. package/dist/private-local-effective-model.d.ts +27 -0
  95. package/dist/private-local-effective-model.js +17 -0
  96. package/dist/private-local-exec-environment.d.ts +16 -0
  97. package/dist/private-local-exec-environment.js +11 -0
  98. package/dist/private-local-exec-launch.d.ts +41 -0
  99. package/dist/private-local-exec-launch.js +51 -0
  100. package/dist/private-local-exec-path.d.ts +6 -0
  101. package/dist/private-local-exec-path.js +40 -0
  102. package/dist/private-local-git-exclude.d.ts +2 -0
  103. package/dist/private-local-git-exclude.js +33 -0
  104. package/dist/private-local-http-dispatch.d.ts +5 -0
  105. package/dist/private-local-http-dispatch.js +14 -0
  106. package/dist/private-local-linux-sandbox.d.ts +8 -0
  107. package/dist/private-local-linux-sandbox.js +67 -0
  108. package/dist/private-local-mail.d.ts +13 -0
  109. package/dist/private-local-mail.js +77 -0
  110. package/dist/private-local-mesh-enrollment.d.ts +34 -0
  111. package/dist/private-local-mesh-enrollment.js +188 -0
  112. package/dist/private-local-mesh-links.d.ts +21 -0
  113. package/dist/private-local-mesh-links.js +229 -0
  114. package/dist/private-local-mesh-record.d.ts +120 -0
  115. package/dist/private-local-mesh-record.js +55 -0
  116. package/dist/private-local-process-identity.d.ts +20 -0
  117. package/dist/private-local-process-identity.js +40 -0
  118. package/dist/private-local-runtime-host.d.ts +192 -0
  119. package/dist/private-local-runtime-host.js +1177 -0
  120. package/dist/private-local-sandbox-policy.d.ts +21 -0
  121. package/dist/private-local-sandbox-policy.js +121 -0
  122. package/dist/private-local-sandbox.d.ts +37 -0
  123. package/dist/private-local-sandbox.js +114 -0
  124. package/dist/private-local-seatbelt.d.ts +24 -0
  125. package/dist/private-local-seatbelt.js +68 -0
  126. package/dist/private-local-service-contract.d.ts +170 -0
  127. package/dist/private-local-service-contract.js +1 -0
  128. package/dist/private-local-service-discovery.d.ts +217 -0
  129. package/dist/private-local-service-discovery.js +322 -0
  130. package/dist/private-local-service-ids.d.ts +3 -0
  131. package/dist/private-local-service-ids.js +8 -0
  132. package/dist/private-local-service-lease.d.ts +16 -0
  133. package/dist/private-local-service-lease.js +105 -0
  134. package/dist/private-local-service-listener.d.ts +22 -0
  135. package/dist/private-local-service-listener.js +407 -0
  136. package/dist/private-local-service-log.d.ts +128 -0
  137. package/dist/private-local-service-log.js +321 -0
  138. package/dist/private-local-service-owner.d.ts +135 -0
  139. package/dist/private-local-service-owner.js +1369 -0
  140. package/dist/private-local-service-process.d.ts +1 -0
  141. package/dist/private-local-service-process.js +8 -0
  142. package/dist/private-local-service-termination.d.ts +7 -0
  143. package/dist/private-local-service-termination.js +67 -0
  144. package/dist/private-local-service.d.ts +46 -0
  145. package/dist/private-local-service.js +606 -0
  146. package/dist/private-local-session-discovery.d.ts +66 -0
  147. package/dist/private-local-session-discovery.js +278 -0
  148. package/dist/private-local-shiitake-host.d.ts +44 -0
  149. package/dist/private-local-shiitake-host.js +143 -0
  150. package/dist/private-local-startup-model.d.ts +51 -0
  151. package/dist/private-local-startup-model.js +86 -0
  152. package/dist/private-local-stop-all.d.ts +27 -0
  153. package/dist/private-local-stop-all.js +26 -0
  154. package/dist/private-local-ui-attachment.d.ts +19 -0
  155. package/dist/private-local-ui-attachment.js +71 -0
  156. package/dist/private-local-worker-server.d.ts +16 -0
  157. package/dist/private-local-worker-server.js +144 -0
  158. package/dist/private-mcp-form-contract.d.ts +186 -0
  159. package/dist/private-mcp-form-contract.js +53 -0
  160. package/dist/private-mcp-form-elicitation.d.ts +18 -0
  161. package/dist/private-mcp-form-elicitation.js +97 -0
  162. package/dist/private-mcp-form-session.d.ts +172 -0
  163. package/dist/private-mcp-form-session.js +148 -0
  164. package/dist/private-openai-claims.d.ts +30 -0
  165. package/dist/private-openai-claims.js +69 -0
  166. package/dist/private-oyster-mcp-auth.d.ts +13 -0
  167. package/dist/private-oyster-mcp-auth.js +57 -0
  168. package/dist/private-provider-rejection.d.ts +15 -0
  169. package/dist/private-provider-rejection.js +24 -0
  170. package/dist/private-sqlite-owner.d.ts +39 -0
  171. package/dist/private-sqlite-owner.js +169 -0
  172. package/dist/private-team-computers.d.ts +38 -0
  173. package/dist/private-team-computers.js +110 -0
  174. package/dist/private-workerd-sandbox.d.ts +3 -0
  175. package/dist/private-workerd-sandbox.js +347 -0
  176. package/dist/seatbelt/safe-base.sbpl +79 -0
  177. package/dist/subscription-activation.d.ts +9 -0
  178. package/dist/subscription-activation.js +62 -0
  179. package/dist/subscription-identity.d.ts +23 -0
  180. package/dist/subscription-identity.js +124 -0
  181. package/dist/subscription-turn.d.ts +17 -0
  182. package/dist/subscription-turn.js +143 -0
  183. package/dist/team-apps.d.ts +24 -0
  184. package/dist/team-apps.js +128 -0
  185. package/dist/tracker-git-credential.d.ts +15 -0
  186. package/dist/tracker-git-credential.js +87 -0
  187. package/native/bin/LICENSE.bubblewrap +502 -0
  188. package/native/bin/LICENSE.libcap +112 -0
  189. package/native/bin/LICENSE.sandbox-runtime +201 -0
  190. package/native/bin/SOURCE.bubblewrap.tar.xz +0 -0
  191. package/native/bin/build.json +4037 -0
  192. package/native/bin/bwrap-build-source/Dockerfile +48 -0
  193. package/native/bin/bwrap-build-source/README.md +96 -0
  194. package/native/bin/bwrap-build-source/build.mjs +52 -0
  195. package/native/bin/bwrap-build-source/linux-arm64.ini +14 -0
  196. package/native/bin/bwrap-build-source/pin.mjs +21 -0
  197. package/native/bin/bwrap-build-source/record.mjs +74 -0
  198. package/native/bin/bwrap-build-source/vendor/LICENSE.sandbox-runtime +201 -0
  199. package/native/bin/bwrap-build-source/vendor/README.md +23 -0
  200. package/native/bin/bwrap-build-source/vendor/seccomp-unix-block.c +280 -0
  201. package/native/bin/bwrap-build.json +44 -0
  202. package/native/bin/fungi-apply-patch +0 -0
  203. package/native/bin/fungi-bwrap +0 -0
  204. package/native/bin/fungi-bwrap.sha256 +1 -0
  205. package/native/bin/fungi-seccomp.bpf +0 -0
  206. package/native/bin/fungi-seccomp.bpf.sha256 +1 -0
  207. package/package.json +161 -3
@@ -0,0 +1,498 @@
1
+ import { realpath } from "node:fs/promises";
2
+ import { relative, resolve, sep } from "node:path";
3
+ import process from "node:process";
4
+ import * as ChildProcess from "effect/process/ChildProcess";
5
+ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
6
+ import { isPlatformError } from "effect/PlatformError";
7
+ import * as NodeServices from "@effect/platform-node/NodeServices";
8
+ import { CapabilityFailed, } from "@fungi.computer/mycelium";
9
+ import * as Clock from "effect/Clock";
10
+ import * as Effect from "effect/Effect";
11
+ import * as Fiber from "effect/Fiber";
12
+ import * as Exit from "effect/Exit";
13
+ import * as Option from "effect/Option";
14
+ import * as Scope from "effect/Scope";
15
+ import * as Stream from "effect/Stream";
16
+ import { z } from "zod";
17
+ import { backgroundExecInput, backgroundExecOutput, callBackgroundExec, } from "./private-local-computer-processes.js";
18
+ import { capturePrivateLocalExecPath, parsePrivateLocalExecPath, } from "./private-local-exec-path.js";
19
+ import { combinedArgvBytes, launchCwdSchema, launchStdinSchema, maximumLaunchTextLength, } from "./private-local-exec-launch.js";
20
+ import { privateLocalExecEnvironment } from "./private-local-exec-environment.js";
21
+ import { capturePrivateLocalConfigRoot } from "./private-local-config-root.js";
22
+ const maximumStreamBytes = 64 * 1_024;
23
+ /** Room inside the stream cap for the visible truncation marker. */
24
+ const truncationMarkerReserveBytes = 256;
25
+ const keptStreamBytes = maximumStreamBytes - truncationMarkerReserveBytes;
26
+ const defaultCommandTimeoutMs = 180_000;
27
+ /**
28
+ * Keep in sync with the invocation deadline `private-local-runtime-host.ts`
29
+ * sets as `execution.timeoutMs` (~line 705): one Mycelium `execute` call,
30
+ * covering sandbox setup, every command it runs, and teardown.
31
+ */
32
+ const invocationDeadlineMs = 900_000;
33
+ /**
34
+ * Reserved outside any one command for sandbox boot, forage round-trips,
35
+ * JSON marshaling, and process teardown. One command's budget stays below
36
+ * the invocation budget; multiple commands still share that invocation's
37
+ * deadline.
38
+ */
39
+ const invocationSetupTeardownMarginMs = 120_000;
40
+ const maximumCommandTimeoutMs = invocationDeadlineMs - invocationSetupTeardownMarginMs;
41
+ const jsonValueSchema = z.json();
42
+ const capabilityNameSchema = z
43
+ .string()
44
+ .min(1)
45
+ .max(128)
46
+ .regex(/^[A-Za-z][A-Za-z0-9_]*$/);
47
+ const runCommonFields = {
48
+ operation: z.literal("run").default("run"),
49
+ cwd: launchCwdSchema.optional(),
50
+ // Accepted up to the full invocation deadline and silently clamped to
51
+ // maximumCommandTimeoutMs (see runCommandAtInvocationBoundary); rejecting
52
+ // outright here would just trade one confusing failure for another.
53
+ timeoutMs: z
54
+ .number()
55
+ .int()
56
+ .min(1)
57
+ .max(invocationDeadlineMs)
58
+ .default(defaultCommandTimeoutMs),
59
+ };
60
+ /** The intentional shell form: one interpreted command line. */
61
+ const shellRunInput = z.strictObject({
62
+ ...runCommonFields,
63
+ command: z.string().min(1).max(maximumLaunchTextLength),
64
+ stdin: launchStdinSchema.optional(),
65
+ });
66
+ /** The direct form: argv run with no shell interpretation. */
67
+ const directRunInput = z
68
+ .strictObject({
69
+ ...runCommonFields,
70
+ executable: z.string().min(1),
71
+ args: z.array(z.string()).default([]),
72
+ stdin: launchStdinSchema.optional(),
73
+ })
74
+ .refine((value) => combinedArgvBytes(value.executable, value.args) <=
75
+ maximumLaunchTextLength, {
76
+ message: `Computer exec executable and args exceed ${String(maximumLaunchTextLength)} combined UTF-8 bytes`,
77
+ path: ["args"],
78
+ });
79
+ /**
80
+ * `command` (shell) and `executable`/`args` (direct) are mutually exclusive:
81
+ * each branch is a strict object, so supplying both or neither fails every
82
+ * branch and the union is refused.
83
+ */
84
+ const execInput = z.union([shellRunInput, directRunInput]);
85
+ const execTruncation = z.strictObject({
86
+ stream: z.enum(["stdout", "stderr"]),
87
+ keptBytes: z.number().int().nonnegative(),
88
+ totalBytes: z.number().int().positive(),
89
+ });
90
+ const commandOutputFields = {
91
+ exitCode: z.number().int(),
92
+ stdout: z.string(),
93
+ stderr: z.string(),
94
+ truncated: z.union([z.literal(false), z.array(execTruncation).min(1)]),
95
+ };
96
+ const execOutput = z.discriminatedUnion("status", [
97
+ z.strictObject({
98
+ status: z.enum(["completed", "failed"]),
99
+ ...commandOutputFields,
100
+ }),
101
+ z.strictObject({
102
+ status: z.literal("deadline_exceeded"),
103
+ timeoutMs: z.number().int().positive(),
104
+ elapsedMs: z.number().nonnegative(),
105
+ message: z.string(),
106
+ ...commandOutputFields,
107
+ }),
108
+ ]);
109
+ const shellInput = z.union([execInput, backgroundExecInput]);
110
+ const shellOutput = z.union([execOutput, backgroundExecOutput]);
111
+ function computerCapabilities(root, processes) {
112
+ return jsonValueSchema.parse([
113
+ {
114
+ id: "exec",
115
+ name: "exec",
116
+ kind: "runtime",
117
+ // A filesystem path has no place in the bounded discovery summary.
118
+ // Publish the exact root in the input schema, without truncating it.
119
+ description: `Shell, terminal, git, builds. cwd uses the root in the input schema. ~/$HOME use home. Or {executable,args}; stdin once, closed. exec kills its group at call end; nohup/& die. timeoutMs default ${String(defaultCommandTimeoutMs)}, max ${String(maximumCommandTimeoutMs)} ms; invocation ${String(invocationDeadlineMs)} ms; deadline returns partial output. Streams keep 64 KiB, then a [stdout|stderr truncated] line; redirect to a file or head/tail.${processes === undefined ? "" : ' Jobs: {operation:"start",command} or executable+args returns process.id; poll/wait: state, exitCode, tail. wait <=60000 ms; timedOut: wait expired. Cancelled wait keeps jobs. maxBytes <=64 KiB, offset/nextOffset; keeps last 8 MiB (logStartOffset). kill stops group; list: Session jobs. Delete/close stop jobs. Use wait, not sleep.'}`,
120
+ parameters: structuredClone(z.toJSONSchema((processes === undefined ? execInput : shellInput).describe(`Computer root: ${root}. cwd must be absolute and remain inside this root; omission starts at the root.`), { io: "input" })),
121
+ output: structuredClone(z.toJSONSchema(processes === undefined ? execOutput : shellOutput)),
122
+ },
123
+ ]);
124
+ }
125
+ /** A one-line, shell-safe label for error messages; never the full command. */
126
+ function shortCommandLabel(command) {
127
+ const oneLine = command.replace(/\s+/gu, " ").trim();
128
+ return oneLine.length > 80 ? `${oneLine.slice(0, 80)}…` : oneLine;
129
+ }
130
+ /** Plain, actionable sentence for a command that hit its (possibly clamped) timeout. */
131
+ function commandTimeoutMessage(input) {
132
+ const clampNote = input.timeoutMs < input.requestedMs
133
+ ? ` (clamped from the requested ${String(input.requestedMs)} ms so the command leaves ${String(invocationSetupTeardownMarginMs)} ms of headroom under the ${String(invocationDeadlineMs)} ms invocation deadline)`
134
+ : "";
135
+ 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.`;
136
+ }
137
+ function isErrnoException(error) {
138
+ return "code" in error;
139
+ }
140
+ function assertContained(root, candidate, requested) {
141
+ const fromRoot = relative(root, candidate);
142
+ if (fromRoot === "" ||
143
+ (!fromRoot.startsWith(`..${sep}`) &&
144
+ fromRoot !== ".." &&
145
+ !fromRoot.startsWith(sep))) {
146
+ return;
147
+ }
148
+ throw new CapabilityFailed({
149
+ namespace: "Computer",
150
+ operation: "exec",
151
+ cause: {
152
+ kind: "InvalidInput",
153
+ message: `Computer cwd resolves outside the invocation root: ${requested}`,
154
+ },
155
+ });
156
+ }
157
+ async function resolveCwd(root, requested) {
158
+ const candidate = resolve(requested ?? root);
159
+ assertContained(root, candidate, requested ?? root);
160
+ const canonical = await realpath(candidate);
161
+ assertContained(root, canonical, requested ?? root);
162
+ return canonical;
163
+ }
164
+ function errnoCode(error) {
165
+ return isErrnoException(error) ? error.code : undefined;
166
+ }
167
+ function groupState(groupId) {
168
+ return Effect.sync(() => {
169
+ try {
170
+ process.kill(-groupId, 0);
171
+ return "running";
172
+ }
173
+ catch (error) {
174
+ if (!(error instanceof Error))
175
+ throw error;
176
+ switch (errnoCode(error)) {
177
+ case "ESRCH":
178
+ return "absent";
179
+ case "EPERM":
180
+ return "unsignalable";
181
+ default:
182
+ throw error;
183
+ }
184
+ }
185
+ });
186
+ }
187
+ /** Signal every member we may signal; false when none could be signalled. */
188
+ function signalGroup(groupId, signal) {
189
+ return Effect.sync(() => {
190
+ try {
191
+ process.kill(-groupId, signal);
192
+ return true;
193
+ }
194
+ catch (error) {
195
+ if (!(error instanceof Error))
196
+ throw error;
197
+ const code = errnoCode(error);
198
+ if (code === "ESRCH" || code === "EPERM")
199
+ return false;
200
+ throw error;
201
+ }
202
+ });
203
+ }
204
+ /** Zombie-only groups are reaped promptly; longer EPERM means a foreign member. */
205
+ const maximumUnsignalablePolls = 200;
206
+ function awaitLeaderExit(process) {
207
+ return Effect.suspend(() => Effect.flatMap(process.isRunning, (running) => running
208
+ ? Effect.sleep("10 millis").pipe(Effect.andThen(awaitLeaderExit(process)))
209
+ : Effect.void));
210
+ }
211
+ function awaitGroupAbsence(groupId, unsignalablePolls = 0) {
212
+ return Effect.suspend(() => Effect.flatMap(groupState(groupId), (state) => {
213
+ if (state === "absent")
214
+ return Effect.void;
215
+ if (state === "unsignalable" &&
216
+ unsignalablePolls >= maximumUnsignalablePolls) {
217
+ return Effect.die(new CapabilityFailed({
218
+ namespace: "Computer",
219
+ operation: "cleanup",
220
+ cause: {
221
+ kind: "OperationFailed",
222
+ message: `Computer process group ${String(groupId)} has members this invocation cannot signal`,
223
+ },
224
+ }));
225
+ }
226
+ return Effect.sleep("10 millis").pipe(Effect.andThen(awaitGroupAbsence(groupId, state === "unsignalable" ? unsignalablePolls + 1 : 0)));
227
+ }));
228
+ }
229
+ function awaitProcessGroupExit(process, groupId) {
230
+ 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);
231
+ }
232
+ function nativeGroupFinalizer(process) {
233
+ return Effect.sync(() => {
234
+ const groupId = Number(process.pid);
235
+ if (!Number.isSafeInteger(groupId) || groupId <= 1) {
236
+ throw new Error("ChildProcessSpawner returned an unsafe process group id");
237
+ }
238
+ return groupId;
239
+ }).pipe(Effect.flatMap((groupId) => Effect.flatMap(groupState(groupId), (state) => {
240
+ const completion = awaitProcessGroupExit(process, groupId);
241
+ if (state !== "running")
242
+ return completion;
243
+ return Effect.flatMap(signalGroup(groupId, "SIGTERM"), () => Effect.flatMap(Effect.timeoutOption(completion.pipe(Effect.interruptible), "1 second"), Option.match({
244
+ onNone: () => signalGroup(groupId, "SIGKILL").pipe(Effect.andThen(completion)),
245
+ onSome: () => Effect.void,
246
+ })));
247
+ })));
248
+ }
249
+ function captureBoundedOutput(stream) {
250
+ const initial = { chunks: [], byteLength: 0, totalBytes: 0 };
251
+ return Stream.runFold(stream, () => initial, (captured, chunk) => {
252
+ const remaining = keptStreamBytes - captured.byteLength;
253
+ const totalBytes = captured.totalBytes + chunk.byteLength;
254
+ if (remaining <= 0)
255
+ return { ...captured, totalBytes };
256
+ const kept = chunk.byteLength <= remaining ? chunk : chunk.slice(0, remaining);
257
+ return {
258
+ chunks: [...captured.chunks, kept],
259
+ byteLength: captured.byteLength + kept.byteLength,
260
+ totalBytes,
261
+ };
262
+ });
263
+ }
264
+ /** Length of the longest prefix that does not end inside a UTF-8 character. */
265
+ function completeUtf8Length(bytes) {
266
+ let start = bytes.byteLength;
267
+ while (start > 0 && ((bytes[start - 1] ?? 0) & 0xc0) === 0x80)
268
+ start -= 1;
269
+ if (start === 0)
270
+ return 0;
271
+ const lead = bytes[start - 1] ?? 0;
272
+ const width = lead >= 0xf0 ? 4 : lead >= 0xe0 ? 3 : lead >= 0xc0 ? 2 : 1;
273
+ return start - 1 + width <= bytes.byteLength ? bytes.byteLength : start - 1;
274
+ }
275
+ function kibibytes(bytes) {
276
+ return `${(bytes / 1_024).toFixed(1)} KiB`;
277
+ }
278
+ function textOutput(captured, stream) {
279
+ const bytes = new Uint8Array(captured.byteLength);
280
+ let offset = 0;
281
+ for (const chunk of captured.chunks) {
282
+ bytes.set(chunk, offset);
283
+ offset += chunk.byteLength;
284
+ }
285
+ if (captured.totalBytes <= captured.byteLength) {
286
+ return { text: new TextDecoder().decode(bytes), truncation: [] };
287
+ }
288
+ const keptBytes = completeUtf8Length(bytes);
289
+ const text = new TextDecoder().decode(bytes.subarray(0, keptBytes));
290
+ return {
291
+ text: `${text}\n[fungi: ${stream} truncated: ${kibibytes(keptBytes)} of ${kibibytes(captured.totalBytes)} shown; redirect to a file or use head/tail]`,
292
+ truncation: [
293
+ {
294
+ stream,
295
+ keptBytes,
296
+ totalBytes: captured.totalBytes,
297
+ },
298
+ ],
299
+ };
300
+ }
301
+ function truncationReport(...parts) {
302
+ const all = parts.flat();
303
+ return all.length === 0 ? false : all;
304
+ }
305
+ function commandResult(exitCode, stdout, stderr) {
306
+ const standardOutput = textOutput(stdout, "stdout");
307
+ const standardError = textOutput(stderr, "stderr");
308
+ return {
309
+ status: exitCode === 0 ? "completed" : "failed",
310
+ exitCode,
311
+ stdout: standardOutput.text,
312
+ stderr: standardError.text,
313
+ truncated: truncationReport(standardOutput.truncation, standardError.truncation),
314
+ };
315
+ }
316
+ /**
317
+ * Human-readable label for timeout messages; never used for execution. Each
318
+ * direct-form arg is JSON-quoted so the label does not read like a
319
+ * re-runnable shell line.
320
+ */
321
+ function describeRunRequest(request) {
322
+ return "command" in request
323
+ ? request.command
324
+ : [
325
+ request.executable,
326
+ ...request.args.map((arg) => JSON.stringify(arg)),
327
+ ].join(" ");
328
+ }
329
+ /**
330
+ * Build the one `ChildProcess.Command` for a run request. The direct form
331
+ * passes `executable`/`args` straight to `ChildProcess.make`, never through a
332
+ * shell; only the intentional shell form interpolates into `sh -c`.
333
+ */
334
+ function buildRunCommand(request, cwd, executionPath, home, sandbox) {
335
+ const env = privateLocalExecEnvironment(executionPath, {
336
+ HOME: home,
337
+ XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR,
338
+ DBUS_SESSION_BUS_ADDRESS: process.env.DBUS_SESSION_BUS_ADDRESS,
339
+ });
340
+ // Effect's own default stdin is an open "pipe" that nothing closes, so an
341
+ // executable that reads stdin (`cat`, `python3 -`) would otherwise hang
342
+ // for the full timeout. "ignore" gives it immediate EOF, matching the
343
+ // background process owner's existing behavior for an omitted stdin.
344
+ const stdin = request.stdin === undefined
345
+ ? "ignore"
346
+ : Stream.make(new TextEncoder().encode(request.stdin));
347
+ const argv = "command" in request
348
+ ? ["sh", "-c", request.command]
349
+ : [request.executable, ...request.args];
350
+ // Admission happens before spawn and is never retried after an executable
351
+ // may have run. Folder validity and requireSandbox belong to the host.
352
+ const launch = sandbox?.(cwd, argv);
353
+ if (launch !== undefined) {
354
+ delete env.XDG_RUNTIME_DIR;
355
+ delete env.DBUS_SESSION_BUS_ADDRESS;
356
+ }
357
+ return ChildProcess.make(launch?.executable ?? argv[0], launch?.args ?? argv.slice(1), {
358
+ cwd,
359
+ env: { ...env, ...launch?.environment },
360
+ extendEnv: false,
361
+ stdin,
362
+ additionalFds: launch?.seccompFilter === undefined
363
+ ? undefined
364
+ : {
365
+ fd3: { type: "input", stream: Stream.make(launch.seccompFilter) },
366
+ },
367
+ });
368
+ }
369
+ /**
370
+ * Only the direct form names an executable the host itself must locate and
371
+ * run; the shell form's `sh` is a fixed, always-present interpreter, so a
372
+ * missing-executable mapping only ever applies to `request.executable`.
373
+ */
374
+ function isMissingExecutableFailure(cause) {
375
+ return (isPlatformError(cause) &&
376
+ (cause.reason._tag === "NotFound" ||
377
+ cause.reason._tag === "PermissionDenied"));
378
+ }
379
+ async function runCommandAtInvocationBoundary(request, cwd, executionPath, home, signal, sandbox) {
380
+ const processCommand = buildRunCommand(request, cwd, executionPath, home, sandbox);
381
+ const timeoutMs = Math.min(request.timeoutMs, maximumCommandTimeoutMs);
382
+ try {
383
+ return await runPrivateInvocationProcess(processCommand, signal, timeoutMs, {
384
+ requestedMs: request.timeoutMs,
385
+ describeCommand: () => describeRunRequest(request),
386
+ });
387
+ }
388
+ catch (cause) {
389
+ // Spawning the direct form's named executable can fail at the OS level
390
+ // before any ExecOutput exists (ENOENT/EACCES), surfacing as an untyped
391
+ // Effect PlatformError that would otherwise reach the model only as a
392
+ // generic sandbox failure. Report it the same honest way as the NUL
393
+ // refusal: a typed CapabilityFailed naming the executable, no process
394
+ // started.
395
+ if ("executable" in request && isMissingExecutableFailure(cause))
396
+ throw new CapabilityFailed({
397
+ namespace: "Computer",
398
+ operation: "exec",
399
+ cause: {
400
+ kind: "InvalidInput",
401
+ message: `Executable ${request.executable} was not found on PATH or could not be run; no process was started.`,
402
+ },
403
+ });
404
+ throw cause;
405
+ }
406
+ }
407
+ /** Shared bounded native process owner; callers supply argv/stdin, never shell interpolation. */
408
+ export function runPrivateInvocationProcess(processCommand, signal, timeoutMs, describe) {
409
+ return Effect.runPromise(Effect.scoped(Effect.gen(function* () {
410
+ const startedAt = yield* Clock.currentTimeMillis;
411
+ const executor = yield* ChildProcessSpawner.ChildProcessSpawner;
412
+ const processScope = yield* Effect.acquireRelease(Scope.make(), (scope) => Scope.close(scope, Exit.void));
413
+ const process = yield* Effect.acquireRelease(executor.spawn(processCommand), nativeGroupFinalizer).pipe(Effect.provideService(Scope.Scope, processScope));
414
+ // Keep both readers alive when the exit wait times out. Killing and
415
+ // reaping the owned group closes the pipes, preserving bounded output.
416
+ const stdoutFiber = yield* Effect.forkChild(captureBoundedOutput(process.stdout));
417
+ const stderrFiber = yield* Effect.forkChild(captureBoundedOutput(process.stderr));
418
+ const exited = yield* timeoutMs === undefined
419
+ ? Effect.map(process.exitCode, Option.some)
420
+ : Effect.timeoutOption(process.exitCode, timeoutMs);
421
+ yield* Scope.close(processScope, Exit.void);
422
+ const [stdout, stderr] = yield* Effect.all([Fiber.join(stdoutFiber), Fiber.join(stderrFiber)], { concurrency: "unbounded" });
423
+ if (Option.isSome(exited))
424
+ return commandResult(Number(exited.value), stdout, stderr);
425
+ if (timeoutMs === undefined)
426
+ throw new Error("Missing command deadline");
427
+ const elapsedMs = (yield* Clock.currentTimeMillis) - startedAt;
428
+ const standardOutput = textOutput(stdout, "stdout");
429
+ const standardError = textOutput(stderr, "stderr");
430
+ return {
431
+ status: "deadline_exceeded",
432
+ timeoutMs,
433
+ elapsedMs,
434
+ exitCode: -1,
435
+ stdout: standardOutput.text,
436
+ stderr: standardError.text,
437
+ message: describe?.describeCommand === undefined
438
+ ? `Command exceeded timeoutMs=${String(timeoutMs)} ms. Rerun with a larger timeoutMs, up to ${String(maximumCommandTimeoutMs)} ms; split longer work into separate tool calls.`
439
+ : commandTimeoutMessage({
440
+ command: describe.describeCommand(),
441
+ timeoutMs,
442
+ requestedMs: describe.requestedMs ?? timeoutMs,
443
+ }),
444
+ truncated: truncationReport(standardOutput.truncation, standardError.truncation),
445
+ };
446
+ })).pipe(Effect.provide(NodeServices.layer)), { signal });
447
+ }
448
+ async function execute(root, executionPath, home, request, context, sandbox) {
449
+ context.signal.throwIfAborted();
450
+ const cwd = await resolveCwd(root, request.cwd);
451
+ context.signal.throwIfAborted();
452
+ return runCommandAtInvocationBoundary(request, cwd, executionPath, home, context.signal, sandbox);
453
+ }
454
+ /**
455
+ * Construct one private invocation-root Computer: bounded shell exec, plus the
456
+ * background-process operations of `processes` when the host supplies a scope.
457
+ */
458
+ export async function createPrivateInvocationComputer(invocationRoot, executionPath = capturePrivateLocalExecPath(process.env.PATH ?? "/usr/bin:/bin", process.cwd()), processes,
459
+ /** Selected host home; standalone callers capture it before any await. */
460
+ home = capturePrivateLocalConfigRoot().home,
461
+ /** Native Local always supplies its live per-Agent admission callback. */
462
+ sandbox) {
463
+ const path = parsePrivateLocalExecPath(executionPath);
464
+ const root = await realpath(resolve(invocationRoot));
465
+ return Object.freeze({
466
+ getCapabilities: () => Promise.resolve(computerCapabilities(root, processes)),
467
+ call: async (capabilityId, input, context) => {
468
+ const selected = capabilityNameSchema.parse(capabilityId);
469
+ if (selected !== "exec")
470
+ throw new Error(`Computer capability is unavailable: ${selected}`);
471
+ const request = (processes === undefined ? execInput : shellInput).parse(input);
472
+ // execve passes argv as NUL-terminated C strings; a NUL embedded in the
473
+ // shell command, the direct executable, or any direct arg would
474
+ // silently truncate it instead of running the submitted text. stdin is
475
+ // a byte stream, not a C string, so it is exempt. The empty array is
476
+ // every poll/wait/kill/list request: none of those carry argv at all.
477
+ const launchArgv = "command" in request
478
+ ? [request.command]
479
+ : "executable" in request
480
+ ? [request.executable, ...request.args]
481
+ : [];
482
+ if (launchArgv.some((value) => value.includes("\0")))
483
+ throw new CapabilityFailed({
484
+ namespace: "Computer",
485
+ operation: "exec",
486
+ cause: {
487
+ kind: "InvalidInput",
488
+ 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.",
489
+ },
490
+ });
491
+ if (request.operation === "run")
492
+ return execute(root, path, home, request, context, sandbox);
493
+ if (processes !== undefined)
494
+ return jsonValueSchema.parse(await callBackgroundExec(processes, request, (requested) => resolveCwd(root, requested), path, context.signal, sandbox));
495
+ throw new Error(`Computer capability is unavailable: ${selected}`);
496
+ },
497
+ });
498
+ }
@@ -0,0 +1,30 @@
1
+ import { type FilesPort } from "@fungi.computer/knapsack";
2
+ import type { LocalSandboxLaunch } from "./private-invocation-computer.js";
3
+ /** Same live authority as exec; the policy module owns protected-path checks. */
4
+ export type LocalSandboxWrites = Readonly<{
5
+ assertWrite(path: string): void;
6
+ launch: LocalSandboxLaunch;
7
+ }>;
8
+ /**
9
+ * Hidden scan work for one glob or grep: directory entries admitted to the
10
+ * walk (gitignored and `.git` entries are never admitted) and file bytes read.
11
+ * The result limit counts returned results; these bound the work behind them.
12
+ */
13
+ export type ScanBounds = Readonly<{
14
+ candidates: number;
15
+ bytes: number;
16
+ }>;
17
+ /** Safe Files path failures surfaced across the workerd binding boundary. */
18
+ export declare class FilesPathError extends Error {
19
+ readonly reason: "outside-root" | "not-found" | "not-file";
20
+ readonly code: "ENOENT" | undefined;
21
+ constructor(reason: "outside-root" | "not-found" | "not-file", requested: string);
22
+ }
23
+ /**
24
+ * Construct the private local Files port for one captured invocation root.
25
+ *
26
+ * Resolved paths stay under the captured canonical root, including admitted
27
+ * in-root symlinks. This is trusted same-user containment, not protection from
28
+ * a same-user process racing pathname resolution.
29
+ */
30
+ export declare function createPrivateInvocationFilesPort(path: string, scanBounds?: ScanBounds, sandbox?: LocalSandboxWrites): Promise<FilesPort>;