@fungi.computer/mycelium 0.1.0 → 0.2.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/README.md +70 -7
- package/dist/access.d.ts +2 -7
- package/dist/access.js +0 -3
- package/dist/execute-tool.d.ts +3 -2
- package/dist/execute-tool.js +40 -11
- package/dist/image-collector.d.ts +1 -1
- package/dist/index.d.ts +14 -1
- package/dist/index.js +2 -1
- package/dist/internal/build-identity.node.js +1 -1
- package/dist/internal/execution-lifecycle.d.ts +4 -2
- package/dist/internal/execution-lifecycle.js +94 -46
- package/dist/internal/host-cores.d.ts +2 -0
- package/dist/internal/host-cores.js +4 -0
- package/dist/internal/host-cores.node.d.ts +2 -0
- package/dist/internal/host-cores.node.js +5 -0
- package/dist/mcp-catalog.d.ts +0 -6
- package/dist/mcp-catalog.js +0 -1
- package/dist/mcp.d.ts +0 -1
- package/dist/mcp.js +0 -1
- package/dist/output-collector.d.ts +46 -0
- package/dist/output-collector.js +145 -0
- package/dist/runtime.js +1 -1
- package/dist/sandbox-admission.d.ts +19 -0
- package/dist/sandbox-admission.js +21 -0
- package/dist/sandbox-boundary.js +3 -1
- package/dist/sandbox-failure-event.d.ts +1 -1
- package/dist/sandbox-failure-event.js +1 -0
- package/dist/sandbox-failure.d.ts +12 -1
- package/dist/sandbox-failure.js +14 -0
- package/dist/sandbox-wire.d.ts +5 -0
- package/dist/sandbox-wire.js +20 -1
- package/docs/api/index.md +68 -5
- package/docs/api/mcp.md +0 -2
- package/docs/api/sandbox-failure.md +90 -0
- package/docs/api/sandbox-guest.md +36 -30
- package/docs/api/sandbox-wire.md +3 -3
- package/package.json +9 -5
package/README.md
CHANGED
|
@@ -102,6 +102,33 @@ Access checks and authorization gates run before the operation. Product
|
|
|
102
102
|
membership, resource ownership and final external access checks remain host
|
|
103
103
|
responsibilities.
|
|
104
104
|
|
|
105
|
+
Each `execute` call runs in a fresh sandbox, not a REPL: no variable, function
|
|
106
|
+
or global survives between calls. Redefine helpers in every call, or persist
|
|
107
|
+
state to a file and read it back.
|
|
108
|
+
|
|
109
|
+
### Operation identity within execute
|
|
110
|
+
|
|
111
|
+
Every registered-operation call receives a frozen `InvocationContext` with a
|
|
112
|
+
zero-based `operationOrdinal`. One counter spans all registered methods and
|
|
113
|
+
modules in an execute. Allocation happens synchronously when the binding is
|
|
114
|
+
called, before asynchronous admission or operation work, so sequential calls and
|
|
115
|
+
concurrently started calls have distinct positions. Forage's built-in discovery
|
|
116
|
+
helpers do not consume registered-operation positions.
|
|
117
|
+
|
|
118
|
+
The counter resets for each execute attempt. Replaying the same bound-call
|
|
119
|
+
sequence gives the same ordinals even though `executionId` is fresh. A host that
|
|
120
|
+
needs effect deduplication combines its stored Session and command identities
|
|
121
|
+
with `toolCallId` and `operationOrdinal`; an ordinal alone is not a globally
|
|
122
|
+
unique identity. Different call sequences are not promised identical positions,
|
|
123
|
+
and the effect owner must still compare the intended operation and payload on
|
|
124
|
+
replay. This metadata grants neither authority nor permission to replay unsafe
|
|
125
|
+
work.
|
|
126
|
+
|
|
127
|
+
Direct `invokeModuleOperation` callers supply their own context; outside an
|
|
128
|
+
execute there is no enclosing call sequence, so `operationOrdinal` may be
|
|
129
|
+
absent, just as `toolCallId` may be absent. A host requiring execute identity
|
|
130
|
+
must refuse a write with missing identity rather than invent a fallback.
|
|
131
|
+
|
|
105
132
|
## Access labels and grants
|
|
106
133
|
|
|
107
134
|
Each operation's author labels what it does: `access: "read"` observes,
|
|
@@ -193,6 +220,44 @@ bounded `sandboxFailureMessage` projection with a reference. The host receives
|
|
|
193
220
|
operator evidence through `onSandboxFailure` and the existing reporting path.
|
|
194
221
|
Preserve that evidence without sending host diagnostics to the guest.
|
|
195
222
|
|
|
223
|
+
### Sandbox admission
|
|
224
|
+
|
|
225
|
+
The host-only `execution` options accept `sandboxCapacity` (a positive safe
|
|
226
|
+
integer) and `admissionWaitMs` (an integer from 1 to 900,000 milliseconds,
|
|
227
|
+
default 5,000). Guests still submit only `code`. An omitted capacity derives
|
|
228
|
+
from available cores, clamped to 2..32; hosts without a portable core
|
|
229
|
+
observation use 2. Node captures its affinity-aware core count once per runtime
|
|
230
|
+
through `node:os.availableParallelism()`, behind a platform-conditional module
|
|
231
|
+
outside the Workers import graph. Explicit valid capacity overrides bypass
|
|
232
|
+
observation and are not clamped, including values above 32.
|
|
233
|
+
|
|
234
|
+
`normalizeSandboxAdmissionPolicy(options, availableCores?)` validates and
|
|
235
|
+
resolves these host facts. It does not reserve a slot or start a sandbox. Both
|
|
236
|
+
public runtime facades validate and capture the policy when the runtime opens.
|
|
237
|
+
|
|
238
|
+
Admission belongs to the existing runtime-wide backing-work tracker, not each
|
|
239
|
+
lease, group, or revision. A full pool waits until a slot opens or the earlier
|
|
240
|
+
of the admission-wait deadline and the **original invocation deadline**.
|
|
241
|
+
Admission-wait expiry yields `SandboxBusy`, stable code `sandbox-busy`, with
|
|
242
|
+
safe `capacity` and `waitedMs` facts; the submitted program has not run.
|
|
243
|
+
Invocation-deadline expiry remains `DeadlineExceeded`, including when both
|
|
244
|
+
deadlines coincide. Caller cancellation remains `Cancelled`. Queueing does not
|
|
245
|
+
restart the execution timeout. No automatic replay is authorized.
|
|
246
|
+
|
|
247
|
+
The runtime uses one maintained Effect semaphore across source revisions and
|
|
248
|
+
interruptible deadline APIs. Queued work is registered before waiting.
|
|
249
|
+
Cancellation removes queued work; no expired/cancelled waiter may start later. A
|
|
250
|
+
permit remains owned until actual backing settlement, including after an
|
|
251
|
+
unacknowledged abort. Waiting does not hold the lifecycle/revision gate needed
|
|
252
|
+
for release or close.
|
|
253
|
+
|
|
254
|
+
`SandboxBusy` is admitted by the existing owned-error decoder, safe transport,
|
|
255
|
+
host wire and operator-event grammar; it is contention, not an `Unknown` health
|
|
256
|
+
alert. The execute tool retains its safe typed cause and an actionable coded
|
|
257
|
+
message without changing Mule's error contract. Unrelated foreign failures stay
|
|
258
|
+
`Unknown`: do not classify diagnostic prose, unwrap arbitrary cause chains, or
|
|
259
|
+
mislabel an arbitrary adapter `AbortError` as caller-authorized cancellation.
|
|
260
|
+
|
|
196
261
|
An Effect host with a Promise sandbox uses `fromTypedSandbox`, which waits for
|
|
197
262
|
that Promise to settle when interrupted. The concrete sandbox and broker decide
|
|
198
263
|
how backing work is stopped. Hosts supply the Worker Loader binding and
|
|
@@ -287,13 +352,11 @@ grants. The MCP server independently checks the stored principal. The Session's
|
|
|
287
352
|
`call` must carry `McpExecutionContext.access` into its ordinary Mycelium
|
|
288
353
|
execution, including direct tools, without persisting request callbacks.
|
|
289
354
|
|
|
290
|
-
MCP
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
still asks. Form elicitation runs before the operation, and authority is
|
|
296
|
-
refreshed again after acceptance; missing or declined approval refuses
|
|
355
|
+
MCP does not force confirmation for every write. The host's durable approval
|
|
356
|
+
owner decides whether a write applies, waits for approval or is denied; the
|
|
357
|
+
catalog's grants remain authority ceilings, not approval decisions. Explicit
|
|
358
|
+
operation-level confirmation labels still use form elicitation and refresh
|
|
359
|
+
authority after acceptance. Missing or declined explicit confirmation refuses
|
|
297
360
|
execution. The server supports MCP `2025-11-25` Streamable HTTP so an approval
|
|
298
361
|
can suspend the existing execution without replaying already-completed
|
|
299
362
|
operations. Approval requests contain the module, operation and access label,
|
package/dist/access.d.ts
CHANGED
|
@@ -65,7 +65,8 @@ export type AccessRefusal = Readonly<{
|
|
|
65
65
|
* Host-supplied access data and the one way to ask a person. Every field is
|
|
66
66
|
* optional: hosted defaults allow unnamed modules, and no `confirm` means an
|
|
67
67
|
* operation requiring approval is refused, never run unasked. MCP catalogs
|
|
68
|
-
* explicitly default unnamed modules to none
|
|
68
|
+
* explicitly default unnamed modules to none. Native hosts own durable approval
|
|
69
|
+
* policy for admitted writes; explicit author confirmation still uses this port.
|
|
69
70
|
*/
|
|
70
71
|
export type AccessOptions = Readonly<{
|
|
71
72
|
/** Grant for unnamed modules; MCP catalogs use none. Defaults to write for hosted callers. */
|
|
@@ -87,12 +88,6 @@ export type AccessOptions = Readonly<{
|
|
|
87
88
|
beforeCall?: (signal: AbortSignal) => Promise<void>;
|
|
88
89
|
/** Require approval for every write, including operations without an explicit confirm label. */
|
|
89
90
|
confirmWrites?: boolean;
|
|
90
|
-
/**
|
|
91
|
-
* Read, at every call, the module ids whose writes a person already
|
|
92
|
-
* approved for this caller. Such a write needs no per-call `confirm`; an
|
|
93
|
-
* operation its author labelled `confirm` still asks, and grants still apply.
|
|
94
|
-
*/
|
|
95
|
-
approvedWrites?: (context: AccessContext) => readonly string[];
|
|
96
91
|
/** Ask a person to approve one call; resolve true to run it. */
|
|
97
92
|
confirm?: ((request: ConfirmRequest, signal: AbortSignal) => Promise<boolean>) | undefined;
|
|
98
93
|
/** Operator-only observer of every refusal; never changes the decision. */
|
package/dist/access.js
CHANGED
|
@@ -100,9 +100,6 @@ async function decide(options, context, label, input, signal) {
|
|
|
100
100
|
if (!label.confirm &&
|
|
101
101
|
!(options.confirmWrites === true && label.access !== "read"))
|
|
102
102
|
return undefined;
|
|
103
|
-
if (!label.confirm &&
|
|
104
|
-
options.approvedWrites?.(context).includes(label.module) === true)
|
|
105
|
-
return undefined;
|
|
106
103
|
if (options.confirm === undefined)
|
|
107
104
|
return refusal("confirmation-unavailable", label);
|
|
108
105
|
const approved = await options.confirm({
|
package/dist/execute-tool.d.ts
CHANGED
|
@@ -6,13 +6,14 @@ import type { CompiledOperation } from "./compiler.js";
|
|
|
6
6
|
import { type BackingWorkGroup, type SerializedSandboxValue } from "./internal/execution-lifecycle.js";
|
|
7
7
|
import { type MyceliumError } from "./errors.js";
|
|
8
8
|
import type { EffectSandbox } from "./runtime.js";
|
|
9
|
-
|
|
9
|
+
import type { SandboxAdmissionOptions } from "./sandbox-admission.js";
|
|
10
|
+
/** Host execution limits plus the reserved, host-only admission contract. */
|
|
10
11
|
export type ExecutionOptions = Readonly<{
|
|
11
12
|
/** Total execution deadline in milliseconds. */
|
|
12
13
|
timeoutMs?: number;
|
|
13
14
|
/** Maximum acknowledgement wait after cancellation in milliseconds. */
|
|
14
15
|
abortGraceMs?: number;
|
|
15
|
-
}
|
|
16
|
+
}> & SandboxAdmissionOptions;
|
|
16
17
|
/** The Effect-owned execute implementation shared by both public projections. */
|
|
17
18
|
export type ExecuteDoor = Readonly<{
|
|
18
19
|
readonly effectTool: MuleTool;
|
package/dist/execute-tool.js
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
import { isSandboxFailure } from "./sandbox-boundary.js";
|
|
2
|
-
import { ResultTooLarge, sandboxFailureMessage } from "./sandbox-failure.js";
|
|
2
|
+
import { ResultTooLarge, SandboxBusy, sandboxFailureMessage, } from "./sandbox-failure.js";
|
|
3
3
|
import { nanoid } from "@fungi.computer/cairn";
|
|
4
4
|
import { forageDescribeInput, forageSearchInput, forageListInput, } from "@fungi.computer/forage";
|
|
5
5
|
import { Type, } from "@fungi.computer/mule/effect";
|
|
6
6
|
import * as Effect from "effect/Effect";
|
|
7
|
+
import * as Fiber from "effect/Fiber";
|
|
7
8
|
import * as Schema from "effect/Schema";
|
|
8
9
|
import * as Stream from "effect/Stream";
|
|
9
10
|
import { checkAccess } from "./access.js";
|
|
10
11
|
import { normalizeExecutionPolicy, runExecution, } from "./internal/execution-lifecycle.js";
|
|
11
12
|
import { ImageCollector } from "./image-collector.js";
|
|
13
|
+
import { OutputCollector } from "./output-collector.js";
|
|
12
14
|
import { InvalidOperationInput } from "./errors.js";
|
|
13
15
|
/** The one outer Mule input authority for the Mycelium execute door. */
|
|
14
16
|
const ExecuteInputSchema = Schema.Struct({ code: Schema.String });
|
|
15
|
-
const executeDescription =
|
|
17
|
+
const executeDescription = 'Run async JavaScript in the sandbox. Each call is a fresh sandbox, not a REPL: nothing survives between calls, so redefine helpers or persist state to a file. Never shadow injected module names with local bindings. Find operations with await forage.search({ query }) using one short keyword such as read or shell, or page them all with forage.list({limit:50}), optionally by namespace, until nextCursor is null. Inspect input with await forage.describe({ path }) before calling; describe known paths directly. Call an operation as await env.<path>({ ... }) with the exact dotted path, for example await env.computers.local.exec({ command: "pwd" }); a bare computers.local.exec is not defined. Put multiline shell commands in a template literal. Code is JavaScript first, so double each backslash the shell needs (`\\\\(` passes `\\(`; `\\(` becomes `(`). For file edits, prefer Files operations when available. Explicitly return the final value. Before treating a shell or process result as success, check status, exitCode and stderr.';
|
|
16
18
|
/** One bound operation: its labels are checked against the host's grants first. */
|
|
17
19
|
function guardedInvoke(entry, context, access, sessionId) {
|
|
18
20
|
const label = {
|
|
@@ -27,13 +29,19 @@ function guardedInvoke(entry, context, access, sessionId) {
|
|
|
27
29
|
};
|
|
28
30
|
}
|
|
29
31
|
function operationBindings(operations, context, access, sessionId) {
|
|
32
|
+
// One counter per execute, shared by every bound method. Allocate before
|
|
33
|
+
// awaiting access or operation work, not in asynchronous completion order.
|
|
34
|
+
let nextOperationOrdinal = 0;
|
|
30
35
|
const groups = new Map();
|
|
31
36
|
for (const entry of operations) {
|
|
32
37
|
const namespace = entry.namespace;
|
|
33
38
|
const operationName = entry.name;
|
|
34
39
|
const group = groups.get(namespace) ??
|
|
35
40
|
new Map();
|
|
36
|
-
group.set(operationName, guardedInvoke(entry,
|
|
41
|
+
group.set(operationName, (input) => guardedInvoke(entry, Object.freeze({
|
|
42
|
+
...context,
|
|
43
|
+
operationOrdinal: nextOperationOrdinal++,
|
|
44
|
+
}), access, sessionId)(input));
|
|
37
45
|
groups.set(namespace, group);
|
|
38
46
|
}
|
|
39
47
|
return Object.freeze(Object.fromEntries([...groups].map(([namespace, operations]) => [
|
|
@@ -65,9 +73,10 @@ function bindings(operations, catalog, context, access, sessionId) {
|
|
|
65
73
|
export function createExecuteDoor(operations, catalog, sandbox, backingWork, modules, namespacePaths, sessionId, access) {
|
|
66
74
|
const policy = sandbox.execution ?? normalizeExecutionPolicy(undefined);
|
|
67
75
|
const imageCollector = new ImageCollector();
|
|
68
|
-
const run = (executionId, code, signal, toolCallId) => runExecution({
|
|
76
|
+
const run = (executionId, code, signal, toolCallId, output) => runExecution({
|
|
69
77
|
executionId,
|
|
70
78
|
toolCallId,
|
|
79
|
+
output,
|
|
71
80
|
images: Object.freeze({
|
|
72
81
|
push: (image) => imageCollector.push(executionId, image),
|
|
73
82
|
}),
|
|
@@ -92,10 +101,10 @@ export function createExecuteDoor(operations, catalog, sandbox, backingWork, mod
|
|
|
92
101
|
* The Mule projection: after the run, drain the collector once and append
|
|
93
102
|
* its images after the text, on the oversized-result path too.
|
|
94
103
|
*/
|
|
95
|
-
const executeTerminal = (code, toolCallId) => Effect.suspend(() => {
|
|
104
|
+
const executeTerminal = (code, toolCallId, output) => Effect.suspend(() => {
|
|
96
105
|
const executionId = nanoid();
|
|
97
106
|
const drain = () => imageCollector.drain(executionId);
|
|
98
|
-
return run(executionId, code, undefined, toolCallId).pipe(Effect.matchEffect({
|
|
107
|
+
return run(executionId, code, undefined, toolCallId, output).pipe(Effect.matchEffect({
|
|
99
108
|
onSuccess: (value) => Effect.succeed(terminal({ text: value.text, isError: false }, drain())),
|
|
100
109
|
onFailure: (error) => {
|
|
101
110
|
const images = drain();
|
|
@@ -117,7 +126,26 @@ export function createExecuteDoor(operations, catalog, sandbox, backingWork, mod
|
|
|
117
126
|
parameters: Type.Object({ code: Type.String() }, { additionalProperties: false }),
|
|
118
127
|
},
|
|
119
128
|
prepare: (call) => Schema.decodeUnknownEffect(ExecuteInputSchema)(call.arguments).pipe(Effect.map(({ code }) => ({
|
|
120
|
-
execute: () => Stream.
|
|
129
|
+
execute: () => Stream.unwrap(Effect.gen(function* () {
|
|
130
|
+
const collector = yield* OutputCollector.make();
|
|
131
|
+
yield* Effect.addFinalizer(() => Effect.sync(() => {
|
|
132
|
+
collector.dispose();
|
|
133
|
+
}));
|
|
134
|
+
// Start execution in this stream's scope before starting readers:
|
|
135
|
+
// admission/deadlines must not wait for an output subscriber.
|
|
136
|
+
const execution = yield* Effect.forkScoped(executeTerminal(code, call.id, {
|
|
137
|
+
write: (stream, bytes) => {
|
|
138
|
+
collector.write(stream, bytes);
|
|
139
|
+
},
|
|
140
|
+
}), { startImmediately: true });
|
|
141
|
+
return Stream.merge(collector.updates, Stream.fromEffect(Fiber.await(execution)).pipe(Stream.flatMap((exit) => {
|
|
142
|
+
const finalOutput = collector.finish();
|
|
143
|
+
// Observe completion as data so diagnostics precede either
|
|
144
|
+
// outcome. Re-yield the original Exit (including its Cause);
|
|
145
|
+
// Mule alone projects failures into error terminal results.
|
|
146
|
+
return Stream.concat(Stream.fromIterable(finalOutput === undefined ? [] : [finalOutput]), Stream.fromEffect(exit));
|
|
147
|
+
})), { haltStrategy: "right" }).pipe(Stream.takeUntil((part) => part.type === "result"));
|
|
148
|
+
})),
|
|
121
149
|
})), Effect.mapError(() => ({ message: "Invalid execute arguments" }))),
|
|
122
150
|
};
|
|
123
151
|
return Object.freeze({ effectTool, execute });
|
|
@@ -126,7 +154,7 @@ export function createExecuteDoor(operations, catalog, sandbox, backingWork, mod
|
|
|
126
154
|
* Project one finished run into Mule's only terminal part. Each collected
|
|
127
155
|
* image becomes an {@link ImageContent} part after the text, the same
|
|
128
156
|
* `MuleToolResult.content` every other tool result uses; its path, size and
|
|
129
|
-
* type go into `
|
|
157
|
+
* type go into application `data`, separate from the model content.
|
|
130
158
|
*/
|
|
131
159
|
function terminal(head, collected) {
|
|
132
160
|
const images = collected.map((image) => ({
|
|
@@ -140,7 +168,7 @@ function terminal(head, collected) {
|
|
|
140
168
|
];
|
|
141
169
|
if (collected.length === 0)
|
|
142
170
|
return { type: "result", result: { content, isError: head.isError } };
|
|
143
|
-
const
|
|
171
|
+
const data = {
|
|
144
172
|
images: collected.map((image) => ({
|
|
145
173
|
bytes: image.bytes,
|
|
146
174
|
mimeType: image.mimeType,
|
|
@@ -149,15 +177,16 @@ function terminal(head, collected) {
|
|
|
149
177
|
};
|
|
150
178
|
return {
|
|
151
179
|
type: "result",
|
|
152
|
-
result: { content,
|
|
180
|
+
result: { content, data, isError: head.isError },
|
|
153
181
|
};
|
|
154
182
|
}
|
|
155
183
|
function toMuleError(error) {
|
|
156
|
-
|
|
184
|
+
const result = {
|
|
157
185
|
_tag: "MuleError",
|
|
158
186
|
phase: "tool",
|
|
159
187
|
message: isSandboxFailure(error)
|
|
160
188
|
? sandboxFailureMessage(error)
|
|
161
189
|
: error.message,
|
|
162
190
|
};
|
|
191
|
+
return error instanceof SandboxBusy ? { ...result, cause: error } : result;
|
|
163
192
|
}
|
|
@@ -18,7 +18,7 @@ export type CollectedImage = Readonly<{
|
|
|
18
18
|
/** Base64-encoded image bytes. */
|
|
19
19
|
data: string;
|
|
20
20
|
/** MIME type of the collected image. */
|
|
21
|
-
mimeType: "image/jpeg" | "image/png" | "image/webp";
|
|
21
|
+
mimeType: "image/jpeg" | "image/png" | "image/gif" | "image/webp";
|
|
22
22
|
/** Path the image was read from. */
|
|
23
23
|
path: string;
|
|
24
24
|
/** Byte length of the collected image payload. */
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { sandboxFailure, isSandboxFailure, sandboxOperatorValue, } from "./sandbox-boundary.js";
|
|
2
2
|
import type { ForageCatalog } from "@fungi.computer/forage";
|
|
3
|
+
export { normalizeSandboxAdmissionPolicy, type SandboxAdmissionOptions, type SandboxAdmissionPolicy, } from "./sandbox-admission.js";
|
|
3
4
|
import type { MuleTool } from "@fungi.computer/mule/effect";
|
|
4
5
|
import type { Sandbox, MyceliumJsonValue } from "./sandbox.js";
|
|
5
6
|
export type { MyceliumJsonValue, SandboxValue, SandboxBinding, SandboxRequest, Sandbox, } from "./sandbox.js";
|
|
@@ -13,7 +14,7 @@ import type { Access, AccessOptions, LabelledTool } from "./access.js";
|
|
|
13
14
|
import { type ModuleRegistration, type OperationGate, type OperationRegistration } from "./registration.js";
|
|
14
15
|
export type { CapabilitySession, CapabilitySource, CapabilitySourceAcquisition, CapabilitySourceContext, CapabilitySourceSnapshot, } from "./capability-source.js";
|
|
15
16
|
export { InvalidOperationInput, InvalidOperationOutput, MyceliumClosedError, MyceliumDefinitionError, MyceliumSourceError, type MyceliumError, type MyceliumSourcePhase, } from "./errors.js";
|
|
16
|
-
export { SourceInvalid, GuestThrew, DeadlineExceeded, Cancelled, DispatchLost, SandboxCrashed, BadResponse, ResultTooLarge, ResultNotJson, CapabilityFailed, Unknown, sandboxFailureMessage, type SandboxFailure, type CapabilityDiagnostic, type CapabilityFailureFields, } from "./sandbox-failure.js";
|
|
17
|
+
export { SourceInvalid, GuestThrew, DeadlineExceeded, SandboxBusy, Cancelled, DispatchLost, SandboxCrashed, BadResponse, ResultTooLarge, ResultNotJson, CapabilityFailed, Unknown, sandboxFailureMessage, type SandboxFailure, type CapabilityDiagnostic, type CapabilityFailureFields, } from "./sandbox-failure.js";
|
|
17
18
|
export { SandboxFailureEventSchema, type SandboxFailureEvent, } from "./sandbox-failure-event.js";
|
|
18
19
|
export type { ExecutionOptions } from "./execute-tool.js";
|
|
19
20
|
export { GrantSchema, ToolAccessSchema, ToolConfirmSchema, ToolPolicySchema, } from "./access.js";
|
|
@@ -27,6 +28,13 @@ export type InvocationContext = Readonly<{
|
|
|
27
28
|
executionId: string;
|
|
28
29
|
/** Enclosing model tool call; absent for direct operation/execute calls. */
|
|
29
30
|
toolCallId?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Zero-based registered-operation call position within this execute.
|
|
33
|
+
* Assigned before asynchronous admission, across all methods and modules.
|
|
34
|
+
* Replaying the same call sequence produces the same positions. Absent
|
|
35
|
+
* outside execute-bound invocation; not an authorization or replay grant.
|
|
36
|
+
*/
|
|
37
|
+
operationOrdinal?: number;
|
|
30
38
|
/** Enclosing sandbox program supplied by the host; never an operation argument. */
|
|
31
39
|
toolCallCode?: string;
|
|
32
40
|
/** Absolute epoch-millisecond execute deadline; null for direct calls outside execute. */
|
|
@@ -40,6 +48,11 @@ export type InvocationContext = Readonly<{
|
|
|
40
48
|
* where no tool result would carry the image.
|
|
41
49
|
*/
|
|
42
50
|
images?: ExecutionImages;
|
|
51
|
+
/** Process-local stdout/stderr tail sink; absent outside an execute tool run. */
|
|
52
|
+
output?: Readonly<{
|
|
53
|
+
/** Append raw bytes to the execution-owned bounded stdout/stderr tail. */
|
|
54
|
+
write: (stream: "stdout" | "stderr", bytes: Uint8Array) => void;
|
|
55
|
+
}>;
|
|
43
56
|
}>;
|
|
44
57
|
/** Inputs used to construct the Promise-facing runtime. */
|
|
45
58
|
export type MyceliumOptions = Readonly<{
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
export { sandboxFailure, isSandboxFailure, sandboxOperatorValue, } from "./sandbox-boundary.js";
|
|
2
|
+
export { normalizeSandboxAdmissionPolicy, } from "./sandbox-admission.js";
|
|
2
3
|
import { openPromiseRuntime } from "./promise-facade.js";
|
|
3
4
|
import { defineModule, defineOperation, gateModule, grantModuleAs, } from "./registration.js";
|
|
4
5
|
export { InvalidOperationInput, InvalidOperationOutput, MyceliumClosedError, MyceliumDefinitionError, MyceliumSourceError, } from "./errors.js";
|
|
5
|
-
export { SourceInvalid, GuestThrew, DeadlineExceeded, Cancelled, DispatchLost, SandboxCrashed, BadResponse, ResultTooLarge, ResultNotJson, CapabilityFailed, Unknown, sandboxFailureMessage, } from "./sandbox-failure.js";
|
|
6
|
+
export { SourceInvalid, GuestThrew, DeadlineExceeded, SandboxBusy, Cancelled, DispatchLost, SandboxCrashed, BadResponse, ResultTooLarge, ResultNotJson, CapabilityFailed, Unknown, sandboxFailureMessage, } from "./sandbox-failure.js";
|
|
6
7
|
export { SandboxFailureEventSchema, } from "./sandbox-failure-event.js";
|
|
7
8
|
export { GrantSchema, ToolAccessSchema, ToolConfirmSchema, ToolPolicySchema, } from "./access.js";
|
|
8
9
|
export { MAX_EXECUTION_IMAGES, } from "./image-collector.js";
|
|
@@ -2,7 +2,7 @@ import { execFileSync } from "node:child_process";
|
|
|
2
2
|
import { URL } from "node:url";
|
|
3
3
|
// Node-only variant of build-identity.ts, selected only by the package's
|
|
4
4
|
// `#build-identity` internal import (package.json `imports`, `node`
|
|
5
|
-
// condition) -- a literal Node process (apps/exec,
|
|
5
|
+
// condition) -- a literal Node process (apps/exec,
|
|
6
6
|
// runtimes/local's non-bundled dist, this package's own tests) always
|
|
7
7
|
// activates that condition; no bundler build target in this repo does
|
|
8
8
|
// (demo and the Electron probe build with esbuild `platform: "browser"`;
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { type SandboxFailure } from "../sandbox-failure.js";
|
|
2
2
|
import * as Effect from "effect/Effect";
|
|
3
3
|
import * as Fiber from "effect/Fiber";
|
|
4
|
+
import { type SandboxAdmissionPolicy } from "../sandbox-admission.js";
|
|
4
5
|
import type { ExecutionOptions } from "../execute-tool.js";
|
|
5
6
|
import { type MyceliumError } from "../errors.js";
|
|
6
7
|
import type { InvocationContext, SandboxValue } from "../index.js";
|
|
7
8
|
import type { ExecutionImages } from "../image-collector.js";
|
|
8
9
|
import type { EffectSandbox } from "../runtime.js";
|
|
9
|
-
export type ExecutionPolicy = Readonly<{
|
|
10
|
+
export type ExecutionPolicy = SandboxAdmissionPolicy & Readonly<{
|
|
10
11
|
readonly timeoutMs: number;
|
|
11
12
|
readonly abortGraceMs: number;
|
|
12
13
|
}>;
|
|
@@ -18,7 +19,7 @@ export type BackingWorkTracker = Readonly<{
|
|
|
18
19
|
readonly createGroup: () => BackingWorkGroup;
|
|
19
20
|
}>;
|
|
20
21
|
export declare function normalizeExecutionPolicy(value: ExecutionOptions | undefined): ExecutionPolicy;
|
|
21
|
-
export declare function createBackingWorkTracker(): BackingWorkTracker
|
|
22
|
+
export declare function createBackingWorkTracker(capacity: number): Effect.Effect<BackingWorkTracker>;
|
|
22
23
|
export type SerializedSandboxValue = Readonly<{
|
|
23
24
|
readonly text: string;
|
|
24
25
|
}>;
|
|
@@ -29,6 +30,7 @@ export type ExecutionLifecycleRequest = Readonly<{
|
|
|
29
30
|
readonly executionId: string;
|
|
30
31
|
/** This run's slot in the door's collector, exposed through the context. */
|
|
31
32
|
readonly images: ExecutionImages;
|
|
33
|
+
readonly output?: InvocationContext["output"];
|
|
32
34
|
readonly code: string;
|
|
33
35
|
readonly callerSignal: AbortSignal | undefined;
|
|
34
36
|
readonly policy: ExecutionPolicy;
|
|
@@ -1,27 +1,28 @@
|
|
|
1
1
|
import { sandboxFailure, isSandboxFailure, sandboxOperatorValue, sandboxValueKind, } from "../sandbox-boundary.js";
|
|
2
2
|
import { sandboxBuildSha } from "#build-identity";
|
|
3
|
+
import { availableHostCores } from "#host-cores";
|
|
3
4
|
import * as Metric from "effect/Metric";
|
|
4
|
-
import { referenceSandboxFailure, DeadlineExceeded, Cancelled, ResultTooLarge, ResultNotJson, CapabilityFailed, } from "../sandbox-failure.js";
|
|
5
|
+
import { referenceSandboxFailure, DeadlineExceeded, Cancelled, ResultTooLarge, ResultNotJson, CapabilityFailed, SandboxBusy, } from "../sandbox-failure.js";
|
|
5
6
|
import { ForageError } from "@fungi.computer/forage";
|
|
6
7
|
import * as Cause from "effect/Cause";
|
|
7
8
|
import * as Clock from "effect/Clock";
|
|
8
9
|
import * as Effect from "effect/Effect";
|
|
9
10
|
import * as Fiber from "effect/Fiber";
|
|
11
|
+
import * as Semaphore from "effect/Semaphore";
|
|
10
12
|
import { z } from "zod";
|
|
13
|
+
import { normalizeSandboxAdmissionPolicy, sandboxAdmissionOptionsSchema, } from "../sandbox-admission.js";
|
|
11
14
|
import { InvalidOperationInput, MyceliumDefinitionError, } from "../errors.js";
|
|
12
15
|
const jsonValueSchema = z.json();
|
|
13
16
|
const jsonScalarSchema = z.lazy(() => z.union([z.null(), z.string(), z.boolean(), z.number()]));
|
|
14
17
|
const jsonRecordSchema = z.lazy(() => z.record(z.string(), z.unknown()));
|
|
15
|
-
const executionOptionsSchema = z.lazy(() =>
|
|
16
|
-
.
|
|
18
|
+
const executionOptionsSchema = z.lazy(() => sandboxAdmissionOptionsSchema
|
|
19
|
+
.extend({
|
|
17
20
|
timeoutMs: z.number().int().min(1).max(900_000).optional(),
|
|
18
21
|
abortGraceMs: z.number().int().min(1).max(30_000).optional(),
|
|
19
22
|
})
|
|
20
|
-
.strict()
|
|
21
23
|
.optional());
|
|
22
24
|
const maximumJsonDepth = 64;
|
|
23
25
|
const maximumSerializedJsonBytes = 256 * 1024;
|
|
24
|
-
const maximumBackingExecutions = 8;
|
|
25
26
|
const utf8 = new TextEncoder();
|
|
26
27
|
const resumeIdle = (notify) => {
|
|
27
28
|
notify(Effect.succeed(undefined));
|
|
@@ -34,50 +35,44 @@ export function normalizeExecutionPolicy(value) {
|
|
|
34
35
|
return Object.freeze({
|
|
35
36
|
timeoutMs: parsed.data?.timeoutMs ?? 180_000,
|
|
36
37
|
abortGraceMs: parsed.data?.abortGraceMs ?? 5_000,
|
|
38
|
+
...normalizeSandboxAdmissionPolicy({
|
|
39
|
+
sandboxCapacity: parsed.data?.sandboxCapacity,
|
|
40
|
+
admissionWaitMs: parsed.data?.admissionWaitMs,
|
|
41
|
+
}, parsed.data?.sandboxCapacity === undefined
|
|
42
|
+
? availableHostCores()
|
|
43
|
+
: undefined),
|
|
37
44
|
});
|
|
38
45
|
}
|
|
39
|
-
export function createBackingWorkTracker() {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
groupActive += 1;
|
|
59
|
-
return true;
|
|
60
|
-
}).pipe(Effect.flatMap((reserved) => reserved
|
|
61
|
-
? Effect.forkDetach(restore(effect).pipe(Effect.ensuring(Effect.sync(() => {
|
|
62
|
-
active -= 1;
|
|
46
|
+
export function createBackingWorkTracker(capacity) {
|
|
47
|
+
return Effect.gen(function* () {
|
|
48
|
+
const permits = yield* Semaphore.make(capacity);
|
|
49
|
+
return Object.freeze({
|
|
50
|
+
createGroup: () => {
|
|
51
|
+
let groupActive = 0;
|
|
52
|
+
const waiters = new Set();
|
|
53
|
+
const awaitIdle = Effect.callback((resume) => {
|
|
54
|
+
if (groupActive === 0) {
|
|
55
|
+
resume(Effect.succeed(undefined));
|
|
56
|
+
return Effect.sync(() => undefined);
|
|
57
|
+
}
|
|
58
|
+
waiters.add(resume);
|
|
59
|
+
return Effect.sync(() => waiters.delete(resume));
|
|
60
|
+
});
|
|
61
|
+
return Object.freeze({
|
|
62
|
+
start: (effect) => Effect.uninterruptibleMask((restore) => Effect.sync(() => {
|
|
63
|
+
groupActive += 1;
|
|
64
|
+
}).pipe(Effect.andThen(Effect.forkDetach(restore(permits.withPermits(1)(effect)).pipe(Effect.ensuring(Effect.sync(() => {
|
|
63
65
|
groupActive -= 1;
|
|
64
66
|
if (groupActive === 0) {
|
|
65
67
|
const pending = [...waiters];
|
|
66
68
|
waiters.clear();
|
|
67
69
|
pending.forEach(resumeIdle);
|
|
68
70
|
}
|
|
69
|
-
}))))
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
kind: "Unavailable",
|
|
75
|
-
message: "Sandbox execution capacity unavailable",
|
|
76
|
-
},
|
|
77
|
-
}))))),
|
|
78
|
-
awaitIdle,
|
|
79
|
-
});
|
|
80
|
-
},
|
|
71
|
+
}))))))),
|
|
72
|
+
awaitIdle,
|
|
73
|
+
});
|
|
74
|
+
},
|
|
75
|
+
});
|
|
81
76
|
});
|
|
82
77
|
}
|
|
83
78
|
function normalizeJson(value, depth = 0) {
|
|
@@ -178,13 +173,25 @@ export function runExecution(request) {
|
|
|
178
173
|
return Clock.currentTimeMillis.pipe(Effect.flatMap((startedAt) => Effect.suspend(() => {
|
|
179
174
|
const executionId = request.executionId;
|
|
180
175
|
const controller = new AbortController();
|
|
176
|
+
let queuedAt = startedAt;
|
|
177
|
+
let admission = {
|
|
178
|
+
kind: "waiting",
|
|
179
|
+
};
|
|
181
180
|
let cancellation;
|
|
182
181
|
const cancel = (phase) => {
|
|
183
|
-
if (cancellation === undefined) {
|
|
182
|
+
if (cancellation === undefined && admission.kind !== "refused") {
|
|
184
183
|
cancellation = phase;
|
|
185
184
|
controller.abort();
|
|
186
185
|
}
|
|
187
186
|
};
|
|
187
|
+
const refuseAdmission = (now) => {
|
|
188
|
+
const failure = new SandboxBusy({
|
|
189
|
+
capacity: request.policy.capacity,
|
|
190
|
+
waitedMs: Math.max(0, now - queuedAt),
|
|
191
|
+
});
|
|
192
|
+
admission = { kind: "refused", failure };
|
|
193
|
+
return failure;
|
|
194
|
+
};
|
|
188
195
|
const admit = () => {
|
|
189
196
|
if (controller.signal.aborted)
|
|
190
197
|
throw executionFailure(cancellation, request.policy.timeoutMs);
|
|
@@ -192,18 +199,28 @@ export function runExecution(request) {
|
|
|
192
199
|
const detachCaller = forwardAbort(request.callerSignal, () => {
|
|
193
200
|
cancel("cancelled");
|
|
194
201
|
});
|
|
202
|
+
const deadlineAt = startedAt + request.policy.timeoutMs;
|
|
195
203
|
const invocation = {
|
|
196
204
|
executionId,
|
|
197
|
-
deadlineAt
|
|
205
|
+
deadlineAt,
|
|
198
206
|
signal: controller.signal,
|
|
199
207
|
images: request.images,
|
|
200
208
|
};
|
|
201
209
|
if (request.toolCallId !== undefined)
|
|
202
210
|
Object.assign(invocation, { toolCallId: request.toolCallId });
|
|
211
|
+
if (request.output !== undefined)
|
|
212
|
+
Object.assign(invocation, { output: request.output });
|
|
203
213
|
const context = Object.freeze(invocation);
|
|
204
|
-
const sandboxExecution = Effect.suspend(() => {
|
|
214
|
+
const sandboxExecution = Effect.suspend(() => Clock.currentTimeMillis.pipe(Effect.flatMap((now) => {
|
|
215
|
+
if (admission.kind === "refused")
|
|
216
|
+
return Effect.fail(admission.failure);
|
|
217
|
+
if (now >= deadlineAt)
|
|
218
|
+
cancel("deadline_exceeded");
|
|
205
219
|
if (controller.signal.aborted)
|
|
206
220
|
return elapsedSince(startedAt).pipe(Effect.flatMap((elapsedMs) => Effect.fail(executionFailure(cancellation, request.policy.timeoutMs, elapsedMs))));
|
|
221
|
+
if (now >= queuedAt + request.policy.waitMs)
|
|
222
|
+
return Effect.fail(refuseAdmission(now));
|
|
223
|
+
admission = { kind: "admitted" };
|
|
207
224
|
return request.sandbox
|
|
208
225
|
.execute(Object.freeze({
|
|
209
226
|
executionId,
|
|
@@ -221,7 +238,7 @@ export function runExecution(request) {
|
|
|
221
238
|
.pipe(Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause)
|
|
222
239
|
? Effect.failCause(cause)
|
|
223
240
|
: Effect.fail(sandboxFailure(Cause.squash(cause)))));
|
|
224
|
-
});
|
|
241
|
+
})));
|
|
225
242
|
const interruptBacking = (child) => Fiber.interrupt(child).pipe(Effect.timeoutOrElse({
|
|
226
243
|
duration: request.policy.abortGraceMs,
|
|
227
244
|
orElse: () => Effect.fail(cancellation === "deadline_exceeded"
|
|
@@ -280,8 +297,39 @@ export function runExecution(request) {
|
|
|
280
297
|
const elapsedMs = yield* elapsedSince(startedAt);
|
|
281
298
|
return yield* Effect.fail(executionFailure(cancellation, request.policy.timeoutMs, elapsedMs));
|
|
282
299
|
}
|
|
300
|
+
queuedAt = yield* Clock.currentTimeMillis;
|
|
283
301
|
child = yield* request.backingWork.start(sandboxExecution);
|
|
284
|
-
const
|
|
302
|
+
const now = yield* Clock.currentTimeMillis;
|
|
303
|
+
const capacityWait = Effect.sleep(Math.max(0, queuedAt + request.policy.waitMs - now)).pipe(Effect.andThen(Clock.currentTimeMillis), Effect.flatMap((at) => {
|
|
304
|
+
if (admission.kind === "admitted")
|
|
305
|
+
return Effect.never;
|
|
306
|
+
if (admission.kind === "refused")
|
|
307
|
+
return Effect.succeed({
|
|
308
|
+
kind: "busy",
|
|
309
|
+
failure: admission.failure,
|
|
310
|
+
});
|
|
311
|
+
if (controller.signal.aborted)
|
|
312
|
+
return Effect.succeed({ kind: "cancelled" });
|
|
313
|
+
if (at >= deadlineAt) {
|
|
314
|
+
cancel("deadline_exceeded");
|
|
315
|
+
return Effect.succeed({ kind: "deadline" });
|
|
316
|
+
}
|
|
317
|
+
return Effect.succeed({
|
|
318
|
+
kind: "busy",
|
|
319
|
+
failure: refuseAdmission(at),
|
|
320
|
+
});
|
|
321
|
+
}));
|
|
322
|
+
const first = yield* Effect.raceFirst(Fiber.await(child).pipe(Effect.map((exit) => Object.freeze({ kind: "settled", exit }))), Effect.raceFirst(Effect.sleep(Math.max(0, deadlineAt - now)).pipe(Effect.andThen(Effect.sync(() => {
|
|
323
|
+
cancel("deadline_exceeded");
|
|
324
|
+
return Object.freeze({ kind: "deadline" });
|
|
325
|
+
}))), Effect.raceFirst(waitForAbort(controller.signal).pipe(Effect.as(Object.freeze({ kind: "cancelled" }))), capacityWait)));
|
|
326
|
+
const refusal = admission.kind === "refused"
|
|
327
|
+
? admission.failure
|
|
328
|
+
: first.kind === "busy"
|
|
329
|
+
? first.failure
|
|
330
|
+
: undefined;
|
|
331
|
+
if (refusal !== undefined)
|
|
332
|
+
return yield* interruptBacking(child).pipe(Effect.andThen(Effect.fail(refusal)));
|
|
285
333
|
if (first.kind === "settled") {
|
|
286
334
|
return yield* Effect.matchCauseEffectEager(first.exit, {
|
|
287
335
|
onFailure: (cause) =>
|