@fungi.computer/truffle 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/README.md +282 -34
  2. package/dist/auth-cli.d.ts +10 -0
  3. package/dist/auth-cli.js +36 -14
  4. package/dist/fungi-account-command.d.ts +1 -1
  5. package/dist/fungi-account-command.js +6 -3
  6. package/dist/index.d.ts +10 -2
  7. package/dist/index.js +8 -2
  8. package/dist/internal/image-fit-metafile.json +106 -0
  9. package/dist/internal/local-image-fit-worker.mjs +960 -0
  10. package/dist/internal/private-workerd-sandbox-worker.mjs +11 -12
  11. package/dist/internal/sandbox-metafile.json +7 -7
  12. package/dist/local-archive-handoff.d.ts +12 -0
  13. package/dist/local-archive-handoff.js +21 -0
  14. package/dist/local-extensions.d.ts +58 -12
  15. package/dist/local-extensions.js +195 -55
  16. package/dist/local-image-fit.d.ts +30 -0
  17. package/dist/local-image-fit.js +105 -0
  18. package/dist/local-models.d.ts +4 -2
  19. package/dist/local-models.js +11 -6
  20. package/dist/local-session-archives.d.ts +8 -0
  21. package/dist/local-session-archives.js +152 -0
  22. package/dist/local-skill-source.d.ts +18 -3
  23. package/dist/local-skill-source.js +216 -140
  24. package/dist/log-event.d.ts +7 -0
  25. package/dist/log-event.js +8 -4
  26. package/dist/private-bounded-response.d.ts +2 -0
  27. package/dist/private-bounded-response.js +41 -0
  28. package/dist/private-file-credential-store.d.ts +28 -3
  29. package/dist/private-file-credential-store.js +57 -9
  30. package/dist/private-files-patch.d.ts +2 -1
  31. package/dist/private-files-patch.js +21 -3
  32. package/dist/private-fungi-account.d.ts +40 -23
  33. package/dist/private-fungi-account.js +201 -21
  34. package/dist/private-fungi-mcp-connection.d.ts +4 -0
  35. package/dist/private-fungi-mcp-connection.js +25 -6
  36. package/dist/private-fungi-mcp-selection-schema.d.ts +7 -0
  37. package/dist/private-fungi-mcp-selection-schema.js +15 -0
  38. package/dist/private-fungi-mcp-selection.d.ts +6 -6
  39. package/dist/private-fungi-mcp-selection.js +29 -16
  40. package/dist/private-fungi-model-api.js +4 -4
  41. package/dist/private-fungi-model-catalog.d.ts +10 -0
  42. package/dist/private-fungi-model-catalog.js +10 -2
  43. package/dist/private-fungi-model-provider.d.ts +1 -1
  44. package/dist/private-fungi-model-provider.js +6 -1
  45. package/dist/private-fungi-pending-logouts.d.ts +1 -1
  46. package/dist/private-fungi-pending-logouts.js +1 -2
  47. package/dist/private-fungi-service-logout.d.ts +44 -0
  48. package/dist/private-fungi-service-logout.js +59 -0
  49. package/dist/private-invocation-computer.d.ts +24 -3
  50. package/dist/private-invocation-computer.js +101 -62
  51. package/dist/private-invocation-files.d.ts +16 -1
  52. package/dist/private-invocation-files.js +154 -83
  53. package/dist/private-local-acp-stdio.js +3 -0
  54. package/dist/private-local-admission-gate.d.ts +14 -0
  55. package/dist/private-local-admission-gate.js +52 -0
  56. package/dist/private-local-agent-control.d.ts +101 -0
  57. package/dist/private-local-agent-control.js +30 -0
  58. package/dist/private-local-agent-default-model.d.ts +12 -0
  59. package/dist/private-local-agent-default-model.js +31 -0
  60. package/dist/private-local-auth-rpc.d.ts +67 -7
  61. package/dist/private-local-auth-rpc.js +140 -17
  62. package/dist/private-local-bwrap.d.ts +13 -0
  63. package/dist/private-local-bwrap.js +70 -0
  64. package/dist/private-local-client-version.d.ts +5 -0
  65. package/dist/private-local-client-version.js +43 -0
  66. package/dist/private-local-computer-attach.d.ts +3 -3
  67. package/dist/private-local-computer-attach.js +17 -3
  68. package/dist/private-local-computer-processes.d.ts +6 -3
  69. package/dist/private-local-computer-processes.js +55 -31
  70. package/dist/private-local-config-root.d.ts +21 -0
  71. package/dist/private-local-config-root.js +19 -0
  72. package/dist/private-local-containment.d.ts +51 -0
  73. package/dist/private-local-containment.js +161 -0
  74. package/dist/private-local-delegation.d.ts +10 -3
  75. package/dist/private-local-delegation.js +70 -15
  76. package/dist/private-local-effective-model.d.ts +9 -1
  77. package/dist/private-local-effective-model.js +3 -0
  78. package/dist/private-local-exec-environment.d.ts +16 -0
  79. package/dist/private-local-exec-environment.js +11 -0
  80. package/dist/private-local-linux-sandbox.d.ts +8 -0
  81. package/dist/private-local-linux-sandbox.js +67 -0
  82. package/dist/private-local-mail.d.ts +4 -2
  83. package/dist/private-local-mail.js +41 -33
  84. package/dist/private-local-mesh-enrollment.d.ts +34 -0
  85. package/dist/private-local-mesh-enrollment.js +188 -0
  86. package/dist/private-local-mesh-links.d.ts +21 -0
  87. package/dist/private-local-mesh-links.js +229 -0
  88. package/dist/private-local-mesh-record.d.ts +120 -0
  89. package/dist/private-local-mesh-record.js +55 -0
  90. package/dist/private-local-process-identity.d.ts +20 -0
  91. package/dist/private-local-process-identity.js +40 -0
  92. package/dist/private-local-runtime-host.d.ts +64 -13
  93. package/dist/private-local-runtime-host.js +361 -172
  94. package/dist/private-local-sandbox-policy.d.ts +21 -0
  95. package/dist/private-local-sandbox-policy.js +121 -0
  96. package/dist/private-local-sandbox.d.ts +37 -0
  97. package/dist/private-local-sandbox.js +114 -0
  98. package/dist/private-local-seatbelt.d.ts +24 -0
  99. package/dist/private-local-seatbelt.js +68 -0
  100. package/dist/private-local-service-contract.d.ts +91 -11
  101. package/dist/private-local-service-discovery.d.ts +129 -13
  102. package/dist/private-local-service-discovery.js +148 -47
  103. package/dist/private-local-service-lease.d.ts +16 -0
  104. package/dist/private-local-service-lease.js +105 -0
  105. package/dist/private-local-service-listener.d.ts +9 -2
  106. package/dist/private-local-service-listener.js +260 -64
  107. package/dist/private-local-service-log.d.ts +128 -0
  108. package/dist/private-local-service-log.js +321 -0
  109. package/dist/private-local-service-owner.d.ts +87 -1519
  110. package/dist/private-local-service-owner.js +1069 -71
  111. package/dist/private-local-service-process.js +3 -1
  112. package/dist/private-local-service-termination.d.ts +7 -0
  113. package/dist/private-local-service-termination.js +67 -0
  114. package/dist/private-local-service.d.ts +31 -2
  115. package/dist/private-local-service.js +402 -126
  116. package/dist/private-local-session-discovery.d.ts +52 -5
  117. package/dist/private-local-session-discovery.js +132 -11
  118. package/dist/private-local-shiitake-host.js +2 -2
  119. package/dist/private-local-startup-model.d.ts +51 -0
  120. package/dist/private-local-startup-model.js +86 -0
  121. package/dist/private-local-stop-all.d.ts +27 -0
  122. package/dist/private-local-stop-all.js +26 -0
  123. package/dist/private-local-ui-attachment.d.ts +19 -0
  124. package/dist/private-local-ui-attachment.js +71 -0
  125. package/dist/private-local-worker-server.d.ts +1 -0
  126. package/dist/private-local-worker-server.js +13 -5
  127. package/dist/private-mcp-form-contract.d.ts +186 -0
  128. package/dist/private-mcp-form-contract.js +53 -0
  129. package/dist/private-mcp-form-elicitation.d.ts +18 -0
  130. package/dist/private-mcp-form-elicitation.js +97 -0
  131. package/dist/private-mcp-form-session.d.ts +172 -0
  132. package/dist/private-mcp-form-session.js +148 -0
  133. package/dist/private-openai-claims.d.ts +30 -0
  134. package/dist/private-openai-claims.js +69 -0
  135. package/dist/private-oyster-mcp-auth.d.ts +13 -0
  136. package/dist/private-oyster-mcp-auth.js +57 -0
  137. package/dist/private-provider-rejection.d.ts +15 -0
  138. package/dist/private-provider-rejection.js +24 -0
  139. package/dist/private-sqlite-owner.d.ts +34 -2
  140. package/dist/private-sqlite-owner.js +103 -37
  141. package/dist/private-team-computers.d.ts +4 -4
  142. package/dist/private-team-computers.js +11 -2
  143. package/dist/seatbelt/safe-base.sbpl +79 -0
  144. package/dist/subscription-activation.d.ts +9 -0
  145. package/dist/subscription-activation.js +62 -0
  146. package/dist/subscription-identity.d.ts +23 -0
  147. package/dist/subscription-identity.js +124 -0
  148. package/dist/subscription-turn.d.ts +17 -0
  149. package/dist/subscription-turn.js +143 -0
  150. package/dist/team-apps.d.ts +2 -2
  151. package/dist/team-apps.js +7 -2
  152. package/native/bin/LICENSE.bubblewrap +502 -0
  153. package/native/bin/LICENSE.libcap +112 -0
  154. package/native/bin/LICENSE.sandbox-runtime +201 -0
  155. package/native/bin/SOURCE.bubblewrap.tar.xz +0 -0
  156. package/native/bin/bwrap-build-source/Dockerfile +48 -0
  157. package/native/bin/bwrap-build-source/README.md +96 -0
  158. package/native/bin/bwrap-build-source/build.mjs +52 -0
  159. package/native/bin/bwrap-build-source/linux-arm64.ini +14 -0
  160. package/native/bin/bwrap-build-source/pin.mjs +21 -0
  161. package/native/bin/bwrap-build-source/record.mjs +74 -0
  162. package/native/bin/bwrap-build-source/vendor/LICENSE.sandbox-runtime +201 -0
  163. package/native/bin/bwrap-build-source/vendor/README.md +23 -0
  164. package/native/bin/bwrap-build-source/vendor/seccomp-unix-block.c +280 -0
  165. package/native/bin/bwrap-build.json +44 -0
  166. package/native/bin/fungi-bwrap +0 -0
  167. package/native/bin/fungi-bwrap.sha256 +1 -0
  168. package/native/bin/fungi-seccomp.bpf +0 -0
  169. package/native/bin/fungi-seccomp.bpf.sha256 +1 -0
  170. package/package.json +44 -31
  171. package/dist/private-local-model-settings.d.ts +0 -5
  172. package/dist/private-local-model-settings.js +0 -31
  173. package/dist/private-local-subagents.d.ts +0 -27
  174. package/dist/private-local-subagents.js +0 -77
@@ -1,5 +1,4 @@
1
1
  import { realpath } from "node:fs/promises";
2
- import { homedir } from "node:os";
3
2
  import { relative, resolve, sep } from "node:path";
4
3
  import process from "node:process";
5
4
  import * as ChildProcess from "effect/process/ChildProcess";
@@ -18,7 +17,12 @@ import { z } from "zod";
18
17
  import { backgroundExecInput, backgroundExecOutput, callBackgroundExec, } from "./private-local-computer-processes.js";
19
18
  import { capturePrivateLocalExecPath, parsePrivateLocalExecPath, } from "./private-local-exec-path.js";
20
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";
21
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;
22
26
  const defaultCommandTimeoutMs = 180_000;
23
27
  /**
24
28
  * Keep in sync with the invocation deadline `private-local-runtime-host.ts`
@@ -78,11 +82,16 @@ const directRunInput = z
78
82
  * branch and the union is refused.
79
83
  */
80
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
+ });
81
90
  const commandOutputFields = {
82
91
  exitCode: z.number().int(),
83
92
  stdout: z.string(),
84
93
  stderr: z.string(),
85
- truncated: z.boolean(),
94
+ truncated: z.union([z.literal(false), z.array(execTruncation).min(1)]),
86
95
  };
87
96
  const execOutput = z.discriminatedUnion("status", [
88
97
  z.strictObject({
@@ -105,14 +114,10 @@ function computerCapabilities(root, processes) {
105
114
  id: "exec",
106
115
  name: "exec",
107
116
  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
- })),
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" })),
116
121
  output: structuredClone(z.toJSONSchema(processes === undefined ? execOutput : shellOutput)),
117
122
  },
118
123
  ]);
@@ -242,52 +247,70 @@ function nativeGroupFinalizer(process) {
242
247
  })));
243
248
  }
244
249
  function captureBoundedOutput(stream) {
245
- const initial = {
246
- chunks: [],
247
- byteLength: 0,
248
- truncated: false,
249
- };
250
+ const initial = { chunks: [], byteLength: 0, totalBytes: 0 };
250
251
  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
- }
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);
261
257
  return {
262
- chunks: remaining === 0
263
- ? captured.chunks
264
- : [...captured.chunks, chunk.slice(0, remaining)],
265
- byteLength: maximumStreamBytes,
266
- truncated: true,
258
+ chunks: [...captured.chunks, kept],
259
+ byteLength: captured.byteLength + kept.byteLength,
260
+ totalBytes,
267
261
  };
268
262
  });
269
263
  }
270
- function textOutput(captured) {
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) {
271
279
  const bytes = new Uint8Array(captured.byteLength);
272
280
  let offset = 0;
273
281
  for (const chunk of captured.chunks) {
274
282
  bytes.set(chunk, offset);
275
283
  offset += chunk.byteLength;
276
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));
277
290
  return {
278
- text: new TextDecoder().decode(bytes),
279
- truncated: captured.truncated,
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
+ ],
280
299
  };
281
300
  }
301
+ function truncationReport(...parts) {
302
+ const all = parts.flat();
303
+ return all.length === 0 ? false : all;
304
+ }
282
305
  function commandResult(exitCode, stdout, stderr) {
283
- const standardOutput = textOutput(stdout);
284
- const standardError = textOutput(stderr);
306
+ const standardOutput = textOutput(stdout, "stdout");
307
+ const standardError = textOutput(stderr, "stderr");
285
308
  return {
286
309
  status: exitCode === 0 ? "completed" : "failed",
287
310
  exitCode,
288
311
  stdout: standardOutput.text,
289
312
  stderr: standardError.text,
290
- truncated: standardOutput.truncated || standardError.truncated,
313
+ truncated: truncationReport(standardOutput.truncation, standardError.truncation),
291
314
  };
292
315
  }
293
316
  /**
@@ -308,8 +331,12 @@ function describeRunRequest(request) {
308
331
  * passes `executable`/`args` straight to `ChildProcess.make`, never through a
309
332
  * shell; only the intentional shell form interpolates into `sh -c`.
310
333
  */
311
- function buildRunCommand(request, cwd, executionPath) {
312
- const env = { PATH: executionPath, HOME: process.env.HOME ?? homedir() };
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
+ });
313
340
  // Effect's own default stdin is an open "pipe" that nothing closes, so an
314
341
  // executable that reads stdin (`cat`, `python3 -`) would otherwise hang
315
342
  // for the full timeout. "ignore" gives it immediate EOF, matching the
@@ -317,19 +344,27 @@ function buildRunCommand(request, cwd, executionPath) {
317
344
  const stdin = request.stdin === undefined
318
345
  ? "ignore"
319
346
  : 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
- });
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
+ });
333
368
  }
334
369
  /**
335
370
  * Only the direct form names an executable the host itself must locate and
@@ -341,8 +376,8 @@ function isMissingExecutableFailure(cause) {
341
376
  (cause.reason._tag === "NotFound" ||
342
377
  cause.reason._tag === "PermissionDenied"));
343
378
  }
344
- async function runCommandAtInvocationBoundary(request, cwd, executionPath, signal) {
345
- const processCommand = buildRunCommand(request, cwd, executionPath);
379
+ async function runCommandAtInvocationBoundary(request, cwd, executionPath, home, signal, sandbox) {
380
+ const processCommand = buildRunCommand(request, cwd, executionPath, home, sandbox);
346
381
  const timeoutMs = Math.min(request.timeoutMs, maximumCommandTimeoutMs);
347
382
  try {
348
383
  return await runPrivateInvocationProcess(processCommand, signal, timeoutMs, {
@@ -390,8 +425,8 @@ export function runPrivateInvocationProcess(processCommand, signal, timeoutMs, d
390
425
  if (timeoutMs === undefined)
391
426
  throw new Error("Missing command deadline");
392
427
  const elapsedMs = (yield* Clock.currentTimeMillis) - startedAt;
393
- const standardOutput = textOutput(stdout);
394
- const standardError = textOutput(stderr);
428
+ const standardOutput = textOutput(stdout, "stdout");
429
+ const standardError = textOutput(stderr, "stderr");
395
430
  return {
396
431
  status: "deadline_exceeded",
397
432
  timeoutMs,
@@ -406,23 +441,27 @@ export function runPrivateInvocationProcess(processCommand, signal, timeoutMs, d
406
441
  timeoutMs,
407
442
  requestedMs: describe.requestedMs ?? timeoutMs,
408
443
  }),
409
- truncated: standardOutput.truncated || standardError.truncated,
444
+ truncated: truncationReport(standardOutput.truncation, standardError.truncation),
410
445
  };
411
446
  })).pipe(Effect.provide(NodeServices.layer)), { signal });
412
447
  }
413
- async function execute(root, executionPath, request, context) {
448
+ async function execute(root, executionPath, home, request, context, sandbox) {
414
449
  context.signal.throwIfAborted();
415
450
  const cwd = await resolveCwd(root, request.cwd);
416
451
  context.signal.throwIfAborted();
417
- return runCommandAtInvocationBoundary(request, cwd, executionPath, context.signal);
452
+ return runCommandAtInvocationBoundary(request, cwd, executionPath, home, context.signal, sandbox);
418
453
  }
419
454
  /**
420
455
  * Construct one private invocation-root Computer: bounded shell exec, plus the
421
456
  * background-process operations of `processes` when the host supplies a scope.
422
457
  */
423
- export async function createPrivateInvocationComputer(invocationRoot, executionPath = capturePrivateLocalExecPath(process.env.PATH ?? "/usr/bin:/bin", process.cwd()), processes) {
424
- const root = await realpath(resolve(invocationRoot));
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) {
425
463
  const path = parsePrivateLocalExecPath(executionPath);
464
+ const root = await realpath(resolve(invocationRoot));
426
465
  return Object.freeze({
427
466
  getCapabilities: () => Promise.resolve(computerCapabilities(root, processes)),
428
467
  call: async (capabilityId, input, context) => {
@@ -450,9 +489,9 @@ export async function createPrivateInvocationComputer(invocationRoot, executionP
450
489
  },
451
490
  });
452
491
  if (request.operation === "run")
453
- return execute(root, path, request, context);
492
+ return execute(root, path, home, request, context, sandbox);
454
493
  if (processes !== undefined)
455
- return jsonValueSchema.parse(await callBackgroundExec(processes, request, (requested) => resolveCwd(root, requested), path, context.signal));
494
+ return jsonValueSchema.parse(await callBackgroundExec(processes, request, (requested) => resolveCwd(root, requested), path, context.signal, sandbox));
456
495
  throw new Error(`Computer capability is unavailable: ${selected}`);
457
496
  },
458
497
  });
@@ -1,4 +1,19 @@
1
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
+ }>;
2
17
  /** Safe Files path failures surfaced across the workerd binding boundary. */
3
18
  export declare class FilesPathError extends Error {
4
19
  readonly reason: "outside-root" | "not-found" | "not-file";
@@ -12,4 +27,4 @@ export declare class FilesPathError extends Error {
12
27
  * in-root symlinks. This is trusted same-user containment, not protection from
13
28
  * a same-user process racing pathname resolution.
14
29
  */
15
- export declare function createPrivateInvocationFilesPort(path: string): Promise<FilesPort>;
30
+ export declare function createPrivateInvocationFilesPort(path: string, scanBounds?: ScanBounds, sandbox?: LocalSandboxWrites): Promise<FilesPort>;
@@ -1,12 +1,17 @@
1
1
  import { lstat, open, opendir, realpath, stat } from "node:fs/promises";
2
2
  import { isAbsolute, join, matchesGlob, normalize, relative, resolve, sep, } from "node:path";
3
3
  import { FilesEditError } from "@fungi.computer/knapsack";
4
+ import { SearchVisibility } from "@fungi.computer/knapsack/files-search";
5
+ import { imageReadResult, imageSniffBytes, imageTooLargeMessage, sniffImageMimeType, } from "@fungi.computer/knapsack/files-read";
4
6
  import { RE2JS } from "re2js";
5
7
  import { createPrivateFilesPatch } from "./private-files-patch.js";
8
+ import { fitLocalImage, maximumLocalImageInputBytes, } from "./local-image-fit.js";
6
9
  import writeFileAtomic from "write-file-atomic";
7
10
  const maximumFileBytes = 128 * 1_024;
8
- const maximumScanBytes = maximumFileBytes;
9
- const maximumScanCandidates = 256;
11
+ const defaultScanBounds = {
12
+ candidates: 50_000,
13
+ bytes: 64 * 1_024 * 1_024,
14
+ };
10
15
  const utf8 = new TextEncoder();
11
16
  /** Safe Files path failures surfaced across the workerd binding boundary. */
12
17
  export class FilesPathError extends Error {
@@ -38,13 +43,17 @@ async function existingRealpath(candidate, requested) {
38
43
  throw cause;
39
44
  }
40
45
  }
41
- /** One maximum result page and one maximum read page bound hidden scan work. */
46
+ /** One explicit bound on walked entries and one on bytes read. */
42
47
  class ScanBudget {
48
+ bounds;
43
49
  bytes = 0;
44
50
  candidates = 0;
45
51
  exhausted = false;
52
+ constructor(bounds) {
53
+ this.bounds = bounds;
54
+ }
46
55
  admitCandidate() {
47
- if (this.candidates === maximumScanCandidates) {
56
+ if (this.candidates === this.bounds.candidates) {
48
57
  this.exhausted = true;
49
58
  return false;
50
59
  }
@@ -52,7 +61,7 @@ class ScanBudget {
52
61
  return true;
53
62
  }
54
63
  admitBytes(bytes) {
55
- if (bytes > maximumScanBytes - this.bytes) {
64
+ if (bytes > this.bounds.bytes - this.bytes) {
56
65
  this.exhausted = true;
57
66
  return false;
58
67
  }
@@ -191,6 +200,18 @@ class InvocationRoot {
191
200
  throw new FilesPathError("outside-root", requested);
192
201
  }
193
202
  }
203
+ async function readBytesAt(file, path, position, length, context) {
204
+ const buffer = Buffer.alloc(Math.min(length, file.size - position));
205
+ let bytesRead = 0;
206
+ while (bytesRead < buffer.byteLength) {
207
+ checkCancellation(context);
208
+ const next = await file.handle.read(buffer, bytesRead, buffer.byteLength - bytesRead, position + bytesRead);
209
+ if (next.bytesRead === 0)
210
+ throw new Error(`Files path changed during read: ${path.requested}`);
211
+ bytesRead += next.bytesRead;
212
+ }
213
+ return buffer;
214
+ }
194
215
  async function readPage(path, offset, limit, context) {
195
216
  const file = await openRegularFile(path, context);
196
217
  try {
@@ -205,16 +226,19 @@ async function readPage(path, offset, limit, context) {
205
226
  truncated: false,
206
227
  };
207
228
  }
208
- const requestedBytes = Math.min(limit, file.size - offset);
209
- const buffer = Buffer.alloc(requestedBytes);
210
- let bytesRead = 0;
211
- while (bytesRead < requestedBytes) {
212
- checkCancellation(context);
213
- const next = await file.handle.read(buffer, bytesRead, requestedBytes - bytesRead, offset + bytesRead);
214
- if (next.bytesRead === 0)
215
- throw new Error(`Files path changed during read: ${path.requested}`);
216
- bytesRead += next.bytesRead;
229
+ if (offset === 0) {
230
+ const head = await readBytesAt(file, path, 0, imageSniffBytes, context);
231
+ const mimeType = sniffImageMimeType(head);
232
+ if (mimeType !== undefined) {
233
+ if (file.size > maximumLocalImageInputBytes)
234
+ throw new Error(imageTooLargeMessage(path.requested, file.size, maximumLocalImageInputBytes));
235
+ // Every image is shrunk here, before it becomes an image part.
236
+ return imageReadResult(path.requested, "image/jpeg", await fitLocalImage(path.requested, await readBytesAt(file, path, 0, file.size, context), context.signal));
237
+ }
217
238
  }
239
+ const requestedBytes = Math.min(limit, file.size - offset);
240
+ const buffer = await readBytesAt(file, path, offset, requestedBytes, context);
241
+ const bytesRead = buffer.byteLength;
218
242
  const page = decodeUtf8(buffer, path.requested, offset + bytesRead < file.size);
219
243
  const nextOffset = offset + page.bytes;
220
244
  return {
@@ -265,7 +289,34 @@ async function readGrepCandidate(path, context) {
265
289
  return undefined;
266
290
  }
267
291
  }
268
- async function* walkFiles(root, base, budget, context) {
292
+ function slashed(path) {
293
+ return path.split(sep).join("/");
294
+ }
295
+ async function readGitignore(root, directory, context) {
296
+ const candidate = join(directory, ".gitignore");
297
+ try {
298
+ await lstat(candidate);
299
+ }
300
+ catch (cause) {
301
+ if (missingPath(cause))
302
+ return undefined;
303
+ throw cause;
304
+ }
305
+ let resolved;
306
+ try {
307
+ resolved = await root.walked(candidate, candidate, context);
308
+ }
309
+ catch (cause) {
310
+ if (cause instanceof FilesPathError)
311
+ return undefined;
312
+ throw cause;
313
+ }
314
+ return await readGrepCandidate({ requested: candidate, resolved }, context);
315
+ }
316
+ function visiblePath(root, visibility, path, isDirectory) {
317
+ return visibility.visible(slashed(relative(root.canonicalPath, path)), isDirectory);
318
+ }
319
+ async function* walkFiles(root, base, budget, visibility, context) {
269
320
  async function* visit(directory, relativeParent, ancestors) {
270
321
  if (ancestors.has(directory))
271
322
  return;
@@ -274,10 +325,12 @@ async function* walkFiles(root, base, budget, context) {
274
325
  const handle = await opendir(directory);
275
326
  for await (const entry of handle) {
276
327
  checkCancellation(context);
328
+ const candidate = join(directory, entry.name);
329
+ if (!(await visiblePath(root, visibility, candidate, entry.isDirectory())))
330
+ continue;
277
331
  if (!budget.admitCandidate())
278
332
  return;
279
333
  const relativePath = relativeParent === "" ? entry.name : join(relativeParent, entry.name);
280
- const candidate = join(directory, entry.name);
281
334
  const resolvedPath = await root.walked(candidate, outputPath(base.requested, relativePath), context);
282
335
  checkCancellation(context);
283
336
  const metadata = await stat(resolvedPath);
@@ -298,58 +351,104 @@ async function* walkFiles(root, base, budget, context) {
298
351
  }
299
352
  yield* visit(base.resolved, "", new Set());
300
353
  }
301
- async function grepFile(target, input, matchesLine, context) {
354
+ async function grepFile(target, input, context) {
302
355
  const matches = [];
303
356
  const content = await readBoundedText(target, context);
357
+ const limited = appendMatchingLines(matches, input.path, content, lineMatcher(input.pattern, input.regex, input.ignoreCase), input.limit, context);
304
358
  return {
305
359
  path: input.path,
306
- matches,
307
- truncated: appendMatchingLines(matches, input.path, content, matchesLine, input.limit, context),
360
+ results: matches,
361
+ truncated: limited ? "limit" : null,
308
362
  };
309
363
  }
310
- async function grepDirectory(root, target, input, matchesLine, context) {
311
- const matches = [];
312
- const budget = new ScanBudget();
313
- let incomplete = false;
314
- for await (const entry of walkFiles(root, target, budget, context)) {
315
- if (entry.isDirectory)
316
- continue;
317
- if (!entry.isFile) {
318
- incomplete = true;
364
+ /** One directory level; the entries past the limit are only witnessed, not returned. */
365
+ async function listLevel(root, directory, input, visibility, context) {
366
+ const handle = await opendir(directory.resolved);
367
+ const results = [];
368
+ for await (const entry of handle) {
369
+ checkCancellation(context);
370
+ if (!(await visiblePath(root, visibility, join(directory.resolved, entry.name), entry.isDirectory())))
319
371
  continue;
372
+ if (results.length === input.limit) {
373
+ return { path: input.path, results, truncated: "limit" };
320
374
  }
321
- const logicalPath = outputPath(input.path, entry.relativePath);
322
- if (entry.size > maximumFileBytes) {
323
- incomplete = true;
375
+ const metadata = await lstat(join(directory.resolved, entry.name));
376
+ const path = outputPath(input.path, entry.name);
377
+ results.push({ path, type: entryType(metadata, path) });
378
+ }
379
+ return { path: input.path, results, truncated: null };
380
+ }
381
+ /** One bounded walk for a glob or content search with an optional include glob. */
382
+ async function searchTree(root, directory, search, bounds, visibility, context) {
383
+ const { input } = search;
384
+ const pathPattern = search.kind === "glob" ? search.input.pattern : search.input.include;
385
+ const matchesLine = search.kind === "grep"
386
+ ? lineMatcher(search.input.pattern, search.input.regex, search.input.ignoreCase)
387
+ : undefined;
388
+ const results = [];
389
+ const budget = new ScanBudget(bounds);
390
+ for await (const entry of walkFiles(root, directory, budget, visibility, context)) {
391
+ checkCancellation(context);
392
+ if (pathPattern !== undefined &&
393
+ !matchesGlob(entry.relativePath, pathPattern))
394
+ continue;
395
+ const path = outputPath(input.path, entry.relativePath);
396
+ if (matchesLine === undefined) {
397
+ if (results.length === input.limit) {
398
+ return { path: input.path, results, truncated: "limit" };
399
+ }
400
+ results.push({
401
+ path,
402
+ type: entry.isDirectory ? "directory" : "file",
403
+ });
324
404
  continue;
325
405
  }
406
+ // Directories, special files, oversized files and non-text files hold no
407
+ // searchable lines; skipping them is not a cut of the scan.
408
+ if (!entry.isFile || entry.size > maximumFileBytes)
409
+ continue;
326
410
  if (!budget.admitBytes(entry.size))
327
411
  break;
328
- const content = await readGrepCandidate({ requested: logicalPath, resolved: entry.resolvedPath }, context);
329
- if (content === undefined) {
330
- incomplete = true;
412
+ const content = await readGrepCandidate({ requested: path, resolved: entry.resolvedPath }, context);
413
+ if (content === undefined)
331
414
  continue;
332
- }
333
- if (appendMatchingLines(matches, logicalPath, content, matchesLine, input.limit, context)) {
334
- return { path: input.path, matches, truncated: true };
415
+ if (appendMatchingLines(results, path, content, matchesLine, input.limit, context)) {
416
+ return { path: input.path, results, truncated: "limit" };
335
417
  }
336
418
  }
337
419
  return {
338
420
  path: input.path,
339
- matches,
340
- truncated: incomplete || budget.exhausted,
421
+ results,
422
+ truncated: budget.exhausted ? "scan" : null,
341
423
  };
342
424
  }
343
- async function grepPath(root, input, context) {
425
+ async function searchPath(root, search, bounds, context) {
426
+ const { input } = search;
344
427
  const target = await root.existing(input.path, context);
345
428
  checkCancellation(context);
346
- const targetMetadata = await stat(target.resolved);
347
- const matchesLine = lineMatcher(input.pattern, input.regex, input.ignoreCase);
348
- if (targetMetadata.isFile())
349
- return await grepFile(target, input, matchesLine, context);
350
- if (targetMetadata.isDirectory())
351
- return await grepDirectory(root, target, input, matchesLine, context);
352
- throw new Error(`Files path is not a file or directory: ${input.path}`);
429
+ const metadata = await stat(target.resolved);
430
+ const visibility = new SearchVisibility((directory) => readGitignore(root, join(root.canonicalPath, directory), context));
431
+ if (!(await visiblePath(root, visibility, target.resolved, metadata.isDirectory())))
432
+ return { path: input.path, results: [], truncated: null };
433
+ if (metadata.isFile()) {
434
+ if (search.kind === "glob")
435
+ throw new Error(`Files glob needs a directory path, not a file: ${input.path}`);
436
+ if (search.kind === "grep") {
437
+ if (search.input.include !== undefined)
438
+ throw new Error(`Files grep include needs a directory path, not a file: ${input.path}`);
439
+ return await grepFile(target, search.input, context);
440
+ }
441
+ return {
442
+ path: input.path,
443
+ results: [{ path: input.path, type: "file" }],
444
+ truncated: null,
445
+ };
446
+ }
447
+ if (!metadata.isDirectory())
448
+ throw new Error(`Files path is not a file or directory: ${input.path}`);
449
+ if (search.kind === "list")
450
+ return await listLevel(root, target, input, visibility, context);
451
+ return await searchTree(root, target, search, bounds, visibility, context);
353
452
  }
354
453
  function createWriteQueue() {
355
454
  let tail = Promise.resolve();
@@ -373,7 +472,7 @@ function createWriteQueue() {
373
472
  * in-root symlinks. This is trusted same-user containment, not protection from
374
473
  * a same-user process racing pathname resolution.
375
474
  */
376
- export async function createPrivateInvocationFilesPort(path) {
475
+ export async function createPrivateInvocationFilesPort(path, scanBounds = defaultScanBounds, sandbox) {
377
476
  const root = await InvocationRoot.capture(path);
378
477
  const write = createWriteQueue();
379
478
  const port = {
@@ -381,44 +480,13 @@ export async function createPrivateInvocationFilesPort(path) {
381
480
  return await readPage(await root.existing(input.path, context), input.offset, input.limit, context);
382
481
  },
383
482
  async list(input, context) {
384
- const directory = await root.existing(input.path, context);
385
- checkCancellation(context);
386
- const handle = await opendir(directory.resolved);
387
- const entries = [];
388
- for await (const entry of handle) {
389
- checkCancellation(context);
390
- if (entries.length === input.limit) {
391
- return { path: input.path, entries, truncated: true };
392
- }
393
- const metadata = await lstat(join(directory.resolved, entry.name));
394
- const path = outputPath(input.path, entry.name);
395
- entries.push({
396
- path,
397
- name: entry.name,
398
- type: entryType(metadata, path),
399
- size: metadata.size,
400
- modifiedAt: metadata.mtimeMs,
401
- });
402
- }
403
- return { path: input.path, entries, truncated: false };
483
+ return await searchPath(root, { kind: "list", input }, scanBounds, context);
404
484
  },
405
485
  async glob(input, context) {
406
- const directory = await root.existing(input.path, context);
407
- const matches = [];
408
- const budget = new ScanBudget();
409
- for await (const entry of walkFiles(root, directory, budget, context)) {
410
- checkCancellation(context);
411
- if (!matchesGlob(entry.relativePath, input.pattern))
412
- continue;
413
- if (matches.length === input.limit) {
414
- return { path: input.path, matches, truncated: true };
415
- }
416
- matches.push(outputPath(input.path, entry.relativePath));
417
- }
418
- return { path: input.path, matches, truncated: budget.exhausted };
486
+ return await searchPath(root, { kind: "glob", input }, scanBounds, context);
419
487
  },
420
488
  async grep(input, context) {
421
- return await grepPath(root, input, context);
489
+ return await searchPath(root, { kind: "grep", input }, scanBounds, context);
422
490
  },
423
491
  async replace(input, context) {
424
492
  return await write(async () => {
@@ -432,6 +500,7 @@ export async function createPrivateInvocationFilesPort(path) {
432
500
  throw new FilesEditError("file-too-large", input.path, `Result exceeds ${String(maximumFileBytes)} bytes; use files.patch for a small change`);
433
501
  }
434
502
  checkCancellation(context);
503
+ sandbox?.assertWrite(path.resolved);
435
504
  await writeFileAtomic(path.resolved, input.content, {
436
505
  encoding: "utf8",
437
506
  });
@@ -439,7 +508,9 @@ export async function createPrivateInvocationFilesPort(path) {
439
508
  });
440
509
  },
441
510
  };
442
- const patch = await createPrivateFilesPatch(root.canonicalPath, port.read);
511
+ // The native parser/patch engine still owns patch grammar. Its adapter must
512
+ // accept the launch callback too; never parse patch headers here.
513
+ const patch = await createPrivateFilesPatch(root.canonicalPath, port.read, sandbox?.launch);
443
514
  if (patch === undefined)
444
515
  return Object.freeze(port);
445
516
  return Object.freeze({