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