@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,613 @@
1
+ import { spawn, } from "node:child_process";
2
+ import { closeSync, openSync, readSync, writeSync } from "node:fs";
3
+ import { mkdir, rm } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import process from "node:process";
6
+ import { setTimeout as delay } from "node:timers/promises";
7
+ import { defineIdFamily } from "@fungi.computer/cairn";
8
+ import { z } from "zod";
9
+ import { combinedArgvBytes, launchCwdSchema, launchStdinSchema, maximumLaunchTextLength, } from "./private-local-exec-launch.js";
10
+ import { privateLocalExecEnvironment } from "./private-local-exec-environment.js";
11
+ import { capturePrivateLocalConfigRoot } from "./private-local-config-root.js";
12
+ import { Writable } from "node:stream";
13
+ /**
14
+ * Background processes of the local Computer: the one owner of "a command that
15
+ * outlives a single exec call".
16
+ *
17
+ * `exec` kills its whole process group when its call returns, so `nohup … &`
18
+ * cannot survive it. A process started here instead belongs to one scope (a
19
+ * Session, or the invocation when the host has no Sessions) of one host. It
20
+ * runs in its own process group with stdout and stderr kept in one bounded
21
+ * circular log under the project's `.fungi/processes/`, and it ends when it exits, when
22
+ * `kill` is called, when its scope is disposed (Session deleted), or when the
23
+ * host closes. The shell/exec surface owns start, poll, wait, list and kill.
24
+ */
25
+ /** Running processes one scope (Session) may hold at once. */
26
+ const maximumRunningProcessesPerScope = 8;
27
+ /** Running processes one host may hold at once; mirrors the container Computer. */
28
+ const maximumRunningProcesses = 16;
29
+ /** Records, running or exited, one scope keeps; the oldest exited is evicted. */
30
+ const maximumProcessRecordsPerScope = 32;
31
+ /** Bytes of combined stdout and stderr one process log keeps. */
32
+ const maximumLogBytes = 8 * 1_024 * 1_024;
33
+ /** Bytes one read returns at most. */
34
+ const maximumReadBytes = 64 * 1_024;
35
+ const defaultReadBytes = 16 * 1_024;
36
+ /** One wait returns after at most this long; call it again to keep waiting. */
37
+ const maximumWaitMs = 60_000;
38
+ const defaultWaitMs = 10_000;
39
+ /** SIGTERM grace before SIGKILL when a process group is stopped. */
40
+ const terminationGraceMs = 2_000;
41
+ const localProcessIds = defineIdFamily({
42
+ family: "local-computer-process",
43
+ version: 1,
44
+ kinds: { process: { prefix: "local_process" } },
45
+ });
46
+ const processIdSchema = z
47
+ .string()
48
+ .max(128)
49
+ .refine((id) => localProcessIds.process.is(id), "Unknown process id");
50
+ const processStateSchema = z.enum(["running", "exited"]);
51
+ const processSchema = z.strictObject({
52
+ id: processIdSchema,
53
+ command: z.string(),
54
+ cwd: z.string(),
55
+ state: processStateSchema,
56
+ exitCode: z.number().int().nullable(),
57
+ signal: z.string().nullable(),
58
+ startedAt: z.number().int(),
59
+ endedAt: z.number().int().nullable(),
60
+ logPath: z.string(),
61
+ logBytes: z.number().int().nonnegative(),
62
+ logStartOffset: z.number().int().nonnegative(),
63
+ logTruncated: z.boolean(),
64
+ });
65
+ /** The intentional shell form of background `start`. */
66
+ const shellStartInput = z.strictObject({
67
+ operation: z.literal("start"),
68
+ command: z.string().min(1).max(maximumLaunchTextLength),
69
+ stdin: launchStdinSchema.optional(),
70
+ cwd: launchCwdSchema.optional(),
71
+ });
72
+ /** The direct form of background `start`: argv run with no shell interpretation. */
73
+ const directStartInput = z
74
+ .strictObject({
75
+ operation: z.literal("start"),
76
+ executable: z.string().min(1),
77
+ args: z.array(z.string()).default([]),
78
+ stdin: launchStdinSchema.optional(),
79
+ cwd: launchCwdSchema.optional(),
80
+ })
81
+ .refine((value) => combinedArgvBytes(value.executable, value.args) <=
82
+ maximumLaunchTextLength, {
83
+ message: `Computer exec executable and args exceed ${String(maximumLaunchTextLength)} combined UTF-8 bytes`,
84
+ path: ["args"],
85
+ });
86
+ const idInput = z.strictObject({ id: processIdSchema });
87
+ const readInput = z.strictObject({
88
+ id: processIdSchema,
89
+ offset: z.number().int().nonnegative().default(0),
90
+ maxBytes: z
91
+ .number()
92
+ .int()
93
+ .min(1)
94
+ .max(maximumReadBytes)
95
+ .default(defaultReadBytes),
96
+ });
97
+ const waitInput = z.strictObject({
98
+ id: processIdSchema,
99
+ timeoutMs: z.number().int().min(1).max(maximumWaitMs).default(defaultWaitMs),
100
+ });
101
+ const listOutput = z.strictObject({
102
+ processes: z.array(processSchema).max(maximumProcessRecordsPerScope),
103
+ });
104
+ const readOutput = z.strictObject({
105
+ process: processSchema,
106
+ offset: z.number().int().nonnegative(),
107
+ nextOffset: z.number().int().nonnegative(),
108
+ text: z.string(),
109
+ complete: z.boolean(),
110
+ });
111
+ const waitOutput = z.strictObject({
112
+ process: processSchema,
113
+ timedOut: z.boolean(),
114
+ });
115
+ const killOutput = z.strictObject({
116
+ process: processSchema,
117
+ signalled: z.boolean(),
118
+ });
119
+ /** No process with this id was started in this scope. */
120
+ class PrivateLocalProcessNotFoundError extends Error {
121
+ constructor(id) {
122
+ super(`Computer process not found: ${id}`);
123
+ this.name = "PrivateLocalProcessNotFoundError";
124
+ }
125
+ }
126
+ /** Every process slot of this scope or host is occupied. */
127
+ class PrivateLocalProcessLimitError extends Error {
128
+ constructor(limit, owner) {
129
+ super(`This ${owner} can run at most ${String(limit)} background processes at once; wait for or kill one with exec first`);
130
+ this.name = "PrivateLocalProcessLimitError";
131
+ }
132
+ }
133
+ /** Signal a whole process group; false when no member could be signalled. */
134
+ function signalGroup(groupId, signal) {
135
+ try {
136
+ process.kill(-groupId, signal);
137
+ return true;
138
+ }
139
+ catch (error) {
140
+ if (error instanceof Error &&
141
+ "code" in error &&
142
+ (error.code === "ESRCH" || error.code === "EPERM"))
143
+ return false;
144
+ throw error;
145
+ }
146
+ }
147
+ function groupAlive(groupId) {
148
+ try {
149
+ process.kill(-groupId, 0);
150
+ return true;
151
+ }
152
+ catch (cause) {
153
+ if (cause instanceof Error && "code" in cause) {
154
+ if (cause.code === "ESRCH")
155
+ return false;
156
+ if (cause.code === "EPERM")
157
+ return true;
158
+ }
159
+ throw cause;
160
+ }
161
+ }
162
+ function toCommandSpec(request) {
163
+ if ("command" in request) {
164
+ const spec = { kind: "shell", command: request.command };
165
+ return request.stdin === undefined
166
+ ? spec
167
+ : { ...spec, stdin: request.stdin };
168
+ }
169
+ const spec = {
170
+ kind: "direct",
171
+ executable: request.executable,
172
+ args: request.args,
173
+ };
174
+ return request.stdin === undefined ? spec : { ...spec, stdin: request.stdin };
175
+ }
176
+ /**
177
+ * A one-line, human-readable label for a spec; never used for execution.
178
+ * Each direct-form arg is JSON-quoted so `list`/`poll` output does not read
179
+ * like a re-runnable shell line.
180
+ */
181
+ function describeCommandSpec(spec) {
182
+ return spec.kind === "shell"
183
+ ? spec.command
184
+ : [spec.executable, ...spec.args.map((arg) => JSON.stringify(arg))].join(" ");
185
+ }
186
+ function isErrnoException(error) {
187
+ return "code" in error;
188
+ }
189
+ /** Drop a trailing partial UTF-8 sequence so a later read resumes on it. */
190
+ function completeUtf8Length(bytes, length) {
191
+ for (let back = 1; back <= Math.min(4, length); back += 1) {
192
+ const byte = bytes[length - back] ?? 0;
193
+ if ((byte & 0xc0) === 0x80)
194
+ continue;
195
+ if (byte < 0xc2 || byte > 0xf4)
196
+ return length;
197
+ const expected = byte >= 0xf0 ? 4 : byte >= 0xe0 ? 3 : 2;
198
+ return back < expected ? length - back : length;
199
+ }
200
+ return length;
201
+ }
202
+ class ManagedProcess {
203
+ spec;
204
+ cwd;
205
+ id = localProcessIds.process.generate();
206
+ startedAt = Date.now();
207
+ logPath;
208
+ closed;
209
+ #child;
210
+ #groupId;
211
+ #fd;
212
+ #logBytes = 0;
213
+ #logTruncated = false;
214
+ #exitCode = null;
215
+ #signal = null;
216
+ #endedAt = null;
217
+ #leaderExited = false;
218
+ command;
219
+ constructor(spec, cwd, logDirectory, executionPath, home, launch) {
220
+ this.spec = spec;
221
+ this.cwd = cwd;
222
+ this.command = describeCommandSpec(spec);
223
+ this.logPath = join(logDirectory, `${this.id}.log`);
224
+ this.#fd = openSync(this.logPath, "wx+", 0o600);
225
+ const closed = Promise.withResolvers();
226
+ this.closed = closed.promise;
227
+ const env = privateLocalExecEnvironment(executionPath, {
228
+ HOME: home,
229
+ XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR,
230
+ DBUS_SESSION_BUS_ADDRESS: process.env.DBUS_SESSION_BUS_ADDRESS,
231
+ });
232
+ if (launch !== undefined) {
233
+ delete env.XDG_RUNTIME_DIR;
234
+ delete env.DBUS_SESSION_BUS_ADDRESS;
235
+ Object.assign(env, launch.environment);
236
+ }
237
+ try {
238
+ // Its own process group, so neither exec's finalizer nor this host's
239
+ // terminal signals reach it; kill() owns ending the whole group.
240
+ const argv = spec.kind === "shell"
241
+ ? ["sh", "-c", spec.command]
242
+ : [spec.executable, ...spec.args];
243
+ const stdio = [
244
+ spec.stdin === undefined ? "ignore" : "pipe",
245
+ "pipe",
246
+ "pipe",
247
+ ];
248
+ if (launch?.seccompFilter !== undefined)
249
+ stdio.push("pipe");
250
+ this.#child = spawn(launch?.executable ?? argv[0], launch?.args ?? argv.slice(1), {
251
+ cwd,
252
+ env,
253
+ detached: true,
254
+ stdio,
255
+ });
256
+ if (launch?.seccompFilter !== undefined) {
257
+ const filter = this.#child.stdio[3];
258
+ if (!(filter instanceof Writable)) {
259
+ this.#child.kill("SIGKILL");
260
+ throw new Error("Local sandbox filter input fd3 is unavailable");
261
+ }
262
+ // A failed filter delivery cannot become a bare retry or an unhandled
263
+ // stream error. The existing process owner records and reaps failure.
264
+ filter.on("error", (error) => {
265
+ this.#append(Buffer.from(`\n[fungi: sandbox filter failed: ${error.message}]\n`));
266
+ this.killNow();
267
+ });
268
+ filter.end(launch.seccompFilter);
269
+ }
270
+ // Finite stdin, delivered once and closed; never an interactive
271
+ // protocol. `end()` writes the bytes and closes the write side. A
272
+ // child that exits or closes its own stdin before the payload drains
273
+ // (`true`, `head -c1`, an early exit) makes that write EPIPE; without
274
+ // this listener the stream emits 'error' with no handler and crashes
275
+ // the host process. There is no caller to reject here (`start` has
276
+ // already returned a process id), so this only records what happened.
277
+ if (spec.stdin !== undefined) {
278
+ const stdin = spec.stdin;
279
+ this.#child.stdin?.on("error", (error) => {
280
+ const code = isErrnoException(error) ? error.code : undefined;
281
+ this.#append(Buffer.from(code === "EPIPE"
282
+ ? "\n[fungi: stdin not fully consumed]\n"
283
+ : `\n[fungi: stdin failed: ${error.message}]\n`));
284
+ });
285
+ this.#child.stdin?.end(Buffer.from(stdin, "utf8"));
286
+ }
287
+ }
288
+ catch (cause) {
289
+ closeSync(this.#fd);
290
+ throw cause;
291
+ }
292
+ const pid = this.#child.pid;
293
+ this.#groupId =
294
+ pid !== undefined && Number.isSafeInteger(pid) && pid > 1
295
+ ? pid
296
+ : undefined;
297
+ const append = (chunk) => {
298
+ this.#append(chunk);
299
+ };
300
+ this.#child.stdout?.on("data", append);
301
+ this.#child.stderr?.on("data", append);
302
+ this.#child.on("error", (error) => {
303
+ this.#append(Buffer.from(`\n[fungi: process failed: ${error.message}]\n`));
304
+ this.#finish(closed.resolve);
305
+ });
306
+ this.#child.on("exit", (code, signal) => {
307
+ this.#leaderExited = true;
308
+ this.#exitCode = code;
309
+ this.#signal = signal;
310
+ // The command is finished; members it left in its group are not owned
311
+ // by anyone else, so end them and release the log pipes.
312
+ void this.#terminateGroup().then(() => {
313
+ this.#child.stdout?.destroy();
314
+ this.#child.stderr?.destroy();
315
+ });
316
+ });
317
+ this.#child.on("close", () => {
318
+ this.#finish(closed.resolve);
319
+ });
320
+ }
321
+ get running() {
322
+ return this.#endedAt === null;
323
+ }
324
+ #append(chunk) {
325
+ const fd = this.#fd;
326
+ if (fd === undefined)
327
+ return;
328
+ // Circular storage retains the latest bytes without growing the log or
329
+ // copying megabytes on every append. Offsets count all produced bytes.
330
+ const skipped = Math.max(0, chunk.byteLength - maximumLogBytes);
331
+ const kept = chunk.subarray(skipped);
332
+ const position = (this.#logBytes + skipped) % maximumLogBytes;
333
+ const first = Math.min(kept.byteLength, maximumLogBytes - position);
334
+ writeSync(fd, kept, 0, first, position);
335
+ if (first < kept.byteLength)
336
+ writeSync(fd, kept, first, kept.byteLength - first, 0);
337
+ this.#logBytes += chunk.byteLength;
338
+ this.#logTruncated = this.#logBytes > maximumLogBytes;
339
+ }
340
+ /** Read atomically with appends, including a wrap in circular storage. */
341
+ read(offset, maxBytes) {
342
+ const end = this.#logBytes;
343
+ let from = Math.max(Math.max(0, end - maximumLogBytes), Math.min(offset, end));
344
+ const length = Math.min(maxBytes, end - from);
345
+ const bytes = new Uint8Array(length);
346
+ const fd = this.#fd ?? openSync(this.logPath, "r");
347
+ try {
348
+ const position = from % maximumLogBytes;
349
+ const first = Math.min(length, maximumLogBytes - position);
350
+ if (first > 0)
351
+ readSync(fd, bytes, 0, first, position);
352
+ if (first < length)
353
+ readSync(fd, bytes, first, length - first, 0);
354
+ }
355
+ finally {
356
+ if (this.#fd === undefined)
357
+ closeSync(fd);
358
+ }
359
+ // A tail or evicted cursor may start inside a UTF-8 sequence.
360
+ let skipped = 0;
361
+ while (skipped < length && ((bytes[skipped] ?? 0) & 0xc0) === 0x80)
362
+ skipped += 1;
363
+ from += skipped;
364
+ const content = bytes.subarray(skipped);
365
+ const snapshot = this.snapshot();
366
+ // At the final byte of an exited command no continuation can arrive;
367
+ // flush malformed/incomplete terminal bytes through the decoder instead.
368
+ const kept = snapshot.state === "exited" && from + content.length === end
369
+ ? content.length
370
+ : completeUtf8Length(content, content.length);
371
+ return readOutput.parse({
372
+ process: snapshot,
373
+ offset: from,
374
+ nextOffset: from + kept,
375
+ text: new TextDecoder().decode(content.subarray(0, kept)),
376
+ complete: snapshot.state === "exited" && from + kept >= end,
377
+ });
378
+ }
379
+ #finish(resolve) {
380
+ if (this.#endedAt !== null)
381
+ return;
382
+ this.#endedAt = Date.now();
383
+ if (this.#fd !== undefined)
384
+ closeSync(this.#fd);
385
+ this.#fd = undefined;
386
+ resolve(undefined);
387
+ }
388
+ /** TERM the whole group, then KILL whatever ignores it past the grace. */
389
+ async #terminateGroup() {
390
+ const groupId = this.#groupId;
391
+ if (groupId === undefined || !signalGroup(groupId, "SIGTERM"))
392
+ return false;
393
+ const deadline = Date.now() + terminationGraceMs;
394
+ while (groupAlive(groupId) && Date.now() < deadline)
395
+ await delay(20);
396
+ if (groupAlive(groupId))
397
+ signalGroup(groupId, "SIGKILL");
398
+ return true;
399
+ }
400
+ /** Stop this process and every member of its group; settles once it ended. */
401
+ async kill() {
402
+ if (!this.running)
403
+ return false;
404
+ const signalled = await this.#terminateGroup();
405
+ // A group that escaped signalling still releases our pipes and log.
406
+ if (this.#leaderExited || !signalled) {
407
+ this.#child.stdout?.destroy();
408
+ this.#child.stderr?.destroy();
409
+ }
410
+ await this.closed;
411
+ return signalled;
412
+ }
413
+ /** Synchronous last resort while the host process itself exits. */
414
+ killNow() {
415
+ if (this.running && this.#groupId !== undefined)
416
+ signalGroup(this.#groupId, "SIGKILL");
417
+ }
418
+ snapshot() {
419
+ return processSchema.parse({
420
+ id: this.id,
421
+ command: this.command,
422
+ cwd: this.cwd,
423
+ state: this.running ? "running" : "exited",
424
+ exitCode: this.running ? null : this.#exitCode,
425
+ signal: this.running ? null : this.#signal,
426
+ startedAt: this.startedAt,
427
+ endedAt: this.#endedAt,
428
+ logPath: this.logPath,
429
+ logBytes: this.#logBytes,
430
+ logStartOffset: Math.max(0, this.#logBytes - maximumLogBytes),
431
+ logTruncated: this.#logTruncated,
432
+ });
433
+ }
434
+ }
435
+ /** Open the background-process owner for one invocation root. */
436
+ export function createPrivateLocalComputerProcesses(root, home = capturePrivateLocalConfigRoot().home) {
437
+ const logDirectory = join(root, ".fungi", "processes");
438
+ const scopes = new Map();
439
+ let closed = false;
440
+ let exitHook = false;
441
+ const all = () => [...scopes.values()].flatMap((owned) => [...owned.values()]);
442
+ const killAllNow = () => {
443
+ for (const owned of all())
444
+ owned.killNow();
445
+ };
446
+ const dispose = async (owned) => {
447
+ const records = [...owned.values()];
448
+ owned.clear();
449
+ await Promise.all(records.map((record) => record.kill()));
450
+ await Promise.all(records.map((record) => rm(record.logPath, { force: true })));
451
+ };
452
+ const scope = (key) => {
453
+ const owned = () => {
454
+ let selected = scopes.get(key);
455
+ if (selected === undefined) {
456
+ selected = new Map();
457
+ scopes.set(key, selected);
458
+ }
459
+ return selected;
460
+ };
461
+ const find = (id) => {
462
+ const record = scopes.get(key)?.get(id);
463
+ if (record === undefined)
464
+ throw new PrivateLocalProcessNotFoundError(id);
465
+ return record;
466
+ };
467
+ return Object.freeze({
468
+ start: async (spec, cwd, executionPath, sandbox) => {
469
+ if (closed)
470
+ throw new Error("Local Computer processes are closed");
471
+ const records = owned();
472
+ const running = [...records.values()].filter((r) => r.running).length;
473
+ if (running >= maximumRunningProcessesPerScope)
474
+ throw new PrivateLocalProcessLimitError(maximumRunningProcessesPerScope, "Session");
475
+ if (all().filter((r) => r.running).length >= maximumRunningProcesses)
476
+ throw new PrivateLocalProcessLimitError(maximumRunningProcesses, "host");
477
+ if (records.size >= maximumProcessRecordsPerScope) {
478
+ const oldest = [...records.values()].find((r) => !r.running);
479
+ if (oldest !== undefined) {
480
+ records.delete(oldest.id);
481
+ await rm(oldest.logPath, { force: true });
482
+ }
483
+ }
484
+ await mkdir(logDirectory, { recursive: true, mode: 0o700 });
485
+ if (!exitHook) {
486
+ // A host that exits without close still takes its process groups.
487
+ process.on("exit", killAllNow);
488
+ exitHook = true;
489
+ }
490
+ const argv = spec.kind === "shell"
491
+ ? ["sh", "-c", spec.command]
492
+ : [spec.executable, ...spec.args];
493
+ const launch = sandbox?.(cwd, argv);
494
+ const started = new ManagedProcess(spec, cwd, logDirectory, executionPath, home, launch);
495
+ records.set(started.id, started);
496
+ return started.snapshot();
497
+ },
498
+ status: (id) => find(id).snapshot(),
499
+ list: () => [...(scopes.get(key)?.values() ?? [])].map((r) => r.snapshot()),
500
+ read: (id, offset, maxBytes) => Promise.resolve(find(id).read(offset, maxBytes)),
501
+ wait: async (id, timeoutMs, signal) => {
502
+ const record = find(id);
503
+ const controller = new AbortController();
504
+ const abandon = () => {
505
+ controller.abort();
506
+ };
507
+ signal.addEventListener("abort", abandon, { once: true });
508
+ try {
509
+ const timedOut = await Promise.race([
510
+ record.closed.then(() => false),
511
+ delay(timeoutMs, true, { signal: controller.signal }),
512
+ ]);
513
+ return waitOutput.parse({ process: record.snapshot(), timedOut });
514
+ }
515
+ finally {
516
+ signal.removeEventListener("abort", abandon);
517
+ controller.abort();
518
+ }
519
+ },
520
+ kill: async (id) => {
521
+ const record = find(id);
522
+ const signalled = await record.kill();
523
+ return killOutput.parse({ process: record.snapshot(), signalled });
524
+ },
525
+ });
526
+ };
527
+ return Object.freeze({
528
+ scope,
529
+ disposeScope: async (key) => {
530
+ const owned = scopes.get(key);
531
+ scopes.delete(key);
532
+ if (owned !== undefined)
533
+ await dispose(owned);
534
+ },
535
+ running: () => all().some((record) => record.running),
536
+ close: async () => {
537
+ closed = true;
538
+ const owned = [...scopes.values()];
539
+ scopes.clear();
540
+ try {
541
+ await Promise.all(owned.map(dispose));
542
+ }
543
+ finally {
544
+ process.off("exit", killAllNow);
545
+ exitHook = false;
546
+ }
547
+ },
548
+ });
549
+ }
550
+ /**
551
+ * Background operations share the Computer's one shell/exec surface; a
552
+ * plain (not discriminated) union, since `start`'s shell and direct branches
553
+ * share the `operation:"start"` literal that `discriminatedUnion` requires
554
+ * to be unique per branch. Both/neither of `command`/`executable` are still
555
+ * refused, by the strict objects themselves.
556
+ */
557
+ export const backgroundExecInput = z.union([
558
+ shellStartInput,
559
+ directStartInput,
560
+ readInput.omit({ offset: true }).extend({
561
+ operation: z.literal("poll"),
562
+ // Omission reads the tail; a returned nextOffset resumes output explicitly.
563
+ offset: z.number().int().nonnegative().optional(),
564
+ }),
565
+ waitInput.extend({
566
+ operation: z.literal("wait"),
567
+ offset: z.number().int().nonnegative().optional(),
568
+ maxBytes: readInput.shape.maxBytes,
569
+ }),
570
+ idInput.extend({ operation: z.literal("kill") }),
571
+ z.strictObject({ operation: z.literal("list") }),
572
+ ]);
573
+ const backgroundResult = readOutput.extend({
574
+ timedOut: z.boolean(),
575
+ signalled: z.boolean().optional(),
576
+ });
577
+ export const backgroundExecOutput = z.union([backgroundResult, listOutput]);
578
+ /** Reuse Session ownership and logs; a wait deadline never cancels the command. */
579
+ export async function callBackgroundExec(scope, request, resolveCwd, executionPath, signal, sandbox) {
580
+ signal.throwIfAborted();
581
+ if (request.operation === "list")
582
+ return { processes: [...scope.list()] };
583
+ let id;
584
+ let timedOut = false;
585
+ let signalled;
586
+ switch (request.operation) {
587
+ case "start": {
588
+ const cwd = await resolveCwd(request.cwd);
589
+ signal.throwIfAborted();
590
+ id = (await scope.start(toCommandSpec(request), cwd, executionPath, sandbox)).id;
591
+ break;
592
+ }
593
+ case "wait":
594
+ id = request.id;
595
+ timedOut = (await scope.wait(id, request.timeoutMs, signal)).timedOut;
596
+ break;
597
+ case "kill":
598
+ id = request.id;
599
+ signalled = (await scope.kill(id)).signalled;
600
+ break;
601
+ case "poll":
602
+ id = request.id;
603
+ break;
604
+ }
605
+ const maxBytes = "maxBytes" in request ? request.maxBytes : defaultReadBytes;
606
+ const offset = "offset" in request ? request.offset : undefined;
607
+ const from = offset ?? Math.max(0, scope.status(id).logBytes - maxBytes);
608
+ const output = await scope.read(id, from, maxBytes);
609
+ const result = { ...output, timedOut };
610
+ if (signalled !== undefined)
611
+ result.signalled = signalled;
612
+ return backgroundResult.parse(result);
613
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The host's selected user home, not an Agent Home or an invocation Files jail.
3
+ * Pass these values through the existing home/path parameters. Capture once at
4
+ * the outer host/client boundary, before awaiting or constructing lazy owners;
5
+ * later opens, reloads and exec launches must not select ambient HOME again.
6
+ *
7
+ * PATH remains the existing per-client/per-Session choice. Neither this value
8
+ * nor an exec child inherits the rest of the host environment.
9
+ */
10
+ export type PrivateLocalConfigRoot = Readonly<{
11
+ home: string;
12
+ credentialPath: string;
13
+ serviceDirectory: string;
14
+ }>;
15
+ /**
16
+ * Select paths without reading credentials, creating directories or starting a
17
+ * service. Keep this capture independent of service/discovery owners, which
18
+ * consume its paths. Explicit path overrides remain the caller's responsibility;
19
+ * there is no settings registry, fallback chain or alternate storage format.
20
+ */
21
+ export declare function capturePrivateLocalConfigRoot(home?: string): PrivateLocalConfigRoot;
@@ -0,0 +1,19 @@
1
+ import { homedir } from "node:os";
2
+ import { isAbsolute, join, resolve } from "node:path";
3
+ import { resolveDefaultCredentialPath } from "./private-file-credential-store.js";
4
+ /**
5
+ * Select paths without reading credentials, creating directories or starting a
6
+ * service. Keep this capture independent of service/discovery owners, which
7
+ * consume its paths. Explicit path overrides remain the caller's responsibility;
8
+ * there is no settings registry, fallback chain or alternate storage format.
9
+ */
10
+ export function capturePrivateLocalConfigRoot(home = homedir()) {
11
+ if (!isAbsolute(home))
12
+ throw new Error("Local configuration home must be absolute");
13
+ const selected = resolve(home);
14
+ return Object.freeze({
15
+ home: selected,
16
+ credentialPath: resolveDefaultCredentialPath(selected),
17
+ serviceDirectory: join(selected, ".fungi", "service"),
18
+ });
19
+ }