@fungi.computer/mycelium 0.0.0-stage → 0.0.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 +303 -2
- package/THIRD_PARTY_NOTICES +2 -0
- package/dist/access.d.ts +129 -0
- package/dist/access.js +166 -0
- package/dist/capability-source.d.ts +44 -0
- package/dist/capability-source.js +1 -0
- package/dist/compiler.d.ts +28 -0
- package/dist/compiler.js +221 -0
- package/dist/define.d.ts +8 -0
- package/dist/define.js +7 -0
- package/dist/effect.d.ts +89 -0
- package/dist/effect.js +42 -0
- package/dist/errors.d.ts +48 -0
- package/dist/errors.js +62 -0
- package/dist/execute-tool.d.ts +22 -0
- package/dist/execute-tool.js +163 -0
- package/dist/image-collector.d.ts +47 -0
- package/dist/image-collector.js +39 -0
- package/dist/index.d.ts +149 -0
- package/dist/index.js +22 -0
- package/dist/internal/build-identity.d.ts +1 -0
- package/dist/internal/build-identity.js +15 -0
- package/dist/internal/build-identity.node.d.ts +1 -0
- package/dist/internal/build-identity.node.js +42 -0
- package/dist/internal/execution-lifecycle.d.ts +44 -0
- package/dist/internal/execution-lifecycle.js +328 -0
- package/dist/internal/mcp-principal.d.ts +14 -0
- package/dist/internal/mcp-principal.js +17 -0
- package/dist/mcp-catalog.d.ts +23 -0
- package/dist/mcp-catalog.js +110 -0
- package/dist/mcp.d.ts +92 -0
- package/dist/mcp.js +250 -0
- package/dist/operation-schema.d.ts +47 -0
- package/dist/operation-schema.js +50 -0
- package/dist/promise-facade.d.ts +3 -0
- package/dist/promise-facade.js +82 -0
- package/dist/registration.d.ts +74 -0
- package/dist/registration.js +157 -0
- package/dist/remote.d.ts +33 -0
- package/dist/remote.js +126 -0
- package/dist/runtime.d.ts +51 -0
- package/dist/runtime.js +159 -0
- package/dist/sandbox-boundary.d.ts +44 -0
- package/dist/sandbox-boundary.js +325 -0
- package/dist/sandbox-failure-event.d.ts +15 -0
- package/dist/sandbox-failure-event.js +34 -0
- package/dist/sandbox-failure.d.ts +128 -0
- package/dist/sandbox-failure.js +161 -0
- package/dist/sandbox-guest.d.ts +34 -0
- package/dist/sandbox-guest.js +131 -0
- package/dist/sandbox-wire.d.ts +76 -0
- package/dist/sandbox-wire.js +190 -0
- package/dist/sandbox.d.ts +41 -0
- package/dist/sandbox.js +1 -0
- package/dist/semaphore-transition.d.ts +7 -0
- package/dist/semaphore-transition.js +9 -0
- package/dist/source-runtime.d.ts +28 -0
- package/dist/source-runtime.js +162 -0
- package/dist/testing.d.ts +11 -0
- package/dist/testing.js +19 -0
- package/dist/workers/index.d.ts +9 -0
- package/dist/workers/index.js +90 -0
- package/docs/api/README.md +25 -0
- package/docs/api/define.md +20 -0
- package/docs/api/effect.md +245 -0
- package/docs/api/index.md +1081 -0
- package/docs/api/mcp.md +239 -0
- package/docs/api/remote.md +108 -0
- package/docs/api/sandbox-boundary.md +325 -0
- package/docs/api/sandbox-failure.md +1133 -0
- package/docs/api/sandbox-guest.md +191 -0
- package/docs/api/sandbox-wire.md +117 -0
- package/docs/api/sandbox.md +81 -0
- package/docs/api/semaphore-transition.md +40 -0
- package/docs/api/testing.md +72 -0
- package/docs/api/workers.md +55 -0
- package/package.json +132 -3
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { sameMcpPrincipal, } from "./internal/mcp-principal.js";
|
|
2
|
+
export { sameMcpPrincipal };
|
|
3
|
+
export { createMcpCatalog } from "./mcp-catalog.js";
|
|
4
|
+
import { nanoid } from "@fungi.computer/cairn";
|
|
5
|
+
import { McpServer as ProtocolServer, WebStandardStreamableHTTPServerTransport, specTypeSchemas, } from "@modelcontextprotocol/server";
|
|
6
|
+
import * as Schema from "effect/Schema";
|
|
7
|
+
/** Serve the host's principal-bound Mycelium catalog through one MCP implementation. */
|
|
8
|
+
export function createMcp(options) {
|
|
9
|
+
const live = new Map();
|
|
10
|
+
const opening = new Set();
|
|
11
|
+
let closed = false;
|
|
12
|
+
const isClosed = () => closed;
|
|
13
|
+
async function current(principal, id) {
|
|
14
|
+
const session = await options.catalog.resume(principal, id);
|
|
15
|
+
if (session?.id !== id || !sameMcpPrincipal(session.principal, principal))
|
|
16
|
+
throw new Error("MCP connection is unavailable");
|
|
17
|
+
await session.refresh();
|
|
18
|
+
return session;
|
|
19
|
+
}
|
|
20
|
+
async function connect(session, canElicit) {
|
|
21
|
+
const { instructions } = await session.describe();
|
|
22
|
+
const server = new ProtocolServer({ name: "mycelium", version: "1" }, {
|
|
23
|
+
capabilities: { tools: {} },
|
|
24
|
+
instructions,
|
|
25
|
+
// The durable execution remains suspended during a form elicitation.
|
|
26
|
+
// Do not negotiate a protocol that requires replaying the program.
|
|
27
|
+
supportedProtocolVersions: ["2025-11-25"],
|
|
28
|
+
});
|
|
29
|
+
server.server.setRequestHandler("tools/list", async () => {
|
|
30
|
+
try {
|
|
31
|
+
const active = await current(session.principal, session.id);
|
|
32
|
+
const { tools } = await active.describe();
|
|
33
|
+
return {
|
|
34
|
+
tools: tools.map((tool) => {
|
|
35
|
+
const parsed = specTypeSchemas.Tool["~standard"].validate({
|
|
36
|
+
name: tool.name,
|
|
37
|
+
description: tool.description,
|
|
38
|
+
inputSchema: tool.parameters,
|
|
39
|
+
});
|
|
40
|
+
if (parsed.issues !== undefined)
|
|
41
|
+
throw new Error("Invalid MCP tool descriptor");
|
|
42
|
+
return parsed.value;
|
|
43
|
+
}),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
throw new Error("MCP catalog is unavailable");
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
server.server.setRequestHandler("tools/call", async (request, context) => {
|
|
51
|
+
try {
|
|
52
|
+
const active = await current(session.principal, session.id);
|
|
53
|
+
const confirm = canElicit
|
|
54
|
+
? async (approval, signal) => {
|
|
55
|
+
const answer = await context.mcpReq.send({
|
|
56
|
+
method: "elicitation/create",
|
|
57
|
+
params: {
|
|
58
|
+
mode: "form",
|
|
59
|
+
message: `Allow ${approval.module}.${approval.operation} (${approval.access})?`,
|
|
60
|
+
requestedSchema: {
|
|
61
|
+
type: "object",
|
|
62
|
+
properties: {
|
|
63
|
+
approve: {
|
|
64
|
+
type: "boolean",
|
|
65
|
+
title: "Approve this operation",
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
required: ["approve"],
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
}, { signal });
|
|
72
|
+
return (answer.action === "accept" && answer.content?.approve === true);
|
|
73
|
+
}
|
|
74
|
+
: undefined;
|
|
75
|
+
const access = {
|
|
76
|
+
confirmWrites: true,
|
|
77
|
+
confirm,
|
|
78
|
+
beforeCall: async (signal) => {
|
|
79
|
+
signal.throwIfAborted();
|
|
80
|
+
const request = context.http?.req;
|
|
81
|
+
if (request === undefined)
|
|
82
|
+
throw new Error("MCP request is unavailable");
|
|
83
|
+
const admission = await options.authenticate(request);
|
|
84
|
+
if (admission.kind !== "admitted" ||
|
|
85
|
+
!sameMcpPrincipal(admission.principal, session.principal))
|
|
86
|
+
throw new Error("MCP authority is unavailable");
|
|
87
|
+
await current(session.principal, session.id);
|
|
88
|
+
signal.throwIfAborted();
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
let progress = 0;
|
|
92
|
+
const progressToken = request.params._meta?.progressToken;
|
|
93
|
+
const callOptions = {
|
|
94
|
+
signal: context.mcpReq.signal,
|
|
95
|
+
access,
|
|
96
|
+
onPart: async (part) => {
|
|
97
|
+
if (progressToken === undefined)
|
|
98
|
+
return;
|
|
99
|
+
await context.mcpReq.notify({
|
|
100
|
+
method: "notifications/progress",
|
|
101
|
+
params: {
|
|
102
|
+
progressToken,
|
|
103
|
+
progress: ++progress,
|
|
104
|
+
message: JSON.stringify(part),
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
const result = await active.call({
|
|
110
|
+
id: nanoid(),
|
|
111
|
+
name: request.params.name,
|
|
112
|
+
arguments: Schema.decodeUnknownSync(Schema.Record(Schema.String, Schema.MutableJson))(request.params.arguments ?? {}),
|
|
113
|
+
}, callOptions);
|
|
114
|
+
return { content: result.content, isError: result.isError };
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
throw new Error("MCP tool call failed");
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
const metadata = {
|
|
121
|
+
id: session.id,
|
|
122
|
+
principal: session.principal,
|
|
123
|
+
};
|
|
124
|
+
const transportOptions = {
|
|
125
|
+
sessionIdGenerator: () => nanoid(),
|
|
126
|
+
onsessioninitialized: async (id) => {
|
|
127
|
+
await options.persistence?.initialized(id, metadata);
|
|
128
|
+
live.set(id, Promise.resolve({ server, transport, session }));
|
|
129
|
+
},
|
|
130
|
+
onsessionclosed: async (id) => {
|
|
131
|
+
live.delete(id);
|
|
132
|
+
await session.disconnect();
|
|
133
|
+
await options.persistence?.closed(id, metadata);
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
if (options.persistence !== undefined)
|
|
137
|
+
transportOptions.eventStore = options.persistence.eventStore(session.id);
|
|
138
|
+
const transport = new WebStandardStreamableHTTPServerTransport(transportOptions);
|
|
139
|
+
await server.connect(transport);
|
|
140
|
+
return { server, transport, session };
|
|
141
|
+
}
|
|
142
|
+
async function handleRequest(request) {
|
|
143
|
+
const admission = await options.authenticate(request);
|
|
144
|
+
if (admission.kind === "refused")
|
|
145
|
+
return new Response("Access refused", {
|
|
146
|
+
status: admission.status,
|
|
147
|
+
headers: admission.retryAfter === undefined
|
|
148
|
+
? {}
|
|
149
|
+
: { "Retry-After": String(admission.retryAfter) },
|
|
150
|
+
});
|
|
151
|
+
if (isClosed())
|
|
152
|
+
return new Response("MCP is closed", { status: 503 });
|
|
153
|
+
const principal = admission.principal;
|
|
154
|
+
const id = request.headers.get("Mcp-Session-Id");
|
|
155
|
+
let session;
|
|
156
|
+
const created = id === null;
|
|
157
|
+
let canElicit = false;
|
|
158
|
+
if (created) {
|
|
159
|
+
if (request.method !== "POST")
|
|
160
|
+
return new Response("Not found", { status: 404 });
|
|
161
|
+
let body;
|
|
162
|
+
try {
|
|
163
|
+
body = await request.clone().json();
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return new Response("Invalid initialization", { status: 400 });
|
|
167
|
+
}
|
|
168
|
+
const parsed = specTypeSchemas.InitializeRequest["~standard"].validate(body);
|
|
169
|
+
if (parsed.issues !== undefined)
|
|
170
|
+
return new Response("Invalid initialization", { status: 400 });
|
|
171
|
+
canElicit =
|
|
172
|
+
parsed.value.params.capabilities.elicitation?.form !== undefined;
|
|
173
|
+
session = await options.catalog.open(principal, {
|
|
174
|
+
id: nanoid(),
|
|
175
|
+
title: `${parsed.value.params.clientInfo.name} · MCP`,
|
|
176
|
+
maxOpenSessions: 8,
|
|
177
|
+
});
|
|
178
|
+
if (session === null)
|
|
179
|
+
return new Response("Too many open MCP Sessions", { status: 429 });
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
const active = live.get(id);
|
|
183
|
+
if (active === undefined)
|
|
184
|
+
return new Response("Not found", { status: 404 });
|
|
185
|
+
session = (await active).session;
|
|
186
|
+
}
|
|
187
|
+
if (!sameMcpPrincipal(session.principal, principal))
|
|
188
|
+
return new Response("Not found", { status: 404 });
|
|
189
|
+
if (isClosed()) {
|
|
190
|
+
if (created)
|
|
191
|
+
await session.disconnect();
|
|
192
|
+
return new Response("MCP is closed", { status: 503 });
|
|
193
|
+
}
|
|
194
|
+
let active;
|
|
195
|
+
try {
|
|
196
|
+
active =
|
|
197
|
+
id === null
|
|
198
|
+
? (async () => {
|
|
199
|
+
await session.refresh();
|
|
200
|
+
return connect(session, canElicit);
|
|
201
|
+
})()
|
|
202
|
+
: live.get(id);
|
|
203
|
+
if (active === undefined)
|
|
204
|
+
return new Response("Not found", { status: 404 });
|
|
205
|
+
if (created)
|
|
206
|
+
opening.add(active);
|
|
207
|
+
const connection = await active;
|
|
208
|
+
if (isClosed())
|
|
209
|
+
return new Response("MCP is closed", { status: 503 });
|
|
210
|
+
const response = await connection.transport.handleRequest(request);
|
|
211
|
+
if (created && !response.ok) {
|
|
212
|
+
await session.disconnect();
|
|
213
|
+
await connection.server.close();
|
|
214
|
+
}
|
|
215
|
+
return response;
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
if (id !== null && live.get(id) === active)
|
|
219
|
+
live.delete(id);
|
|
220
|
+
if (active !== undefined) {
|
|
221
|
+
const [result] = await Promise.allSettled([active]);
|
|
222
|
+
if (result.status === "fulfilled")
|
|
223
|
+
await result.value.server.close();
|
|
224
|
+
}
|
|
225
|
+
if (created)
|
|
226
|
+
await session.disconnect();
|
|
227
|
+
return new Response("MCP is unavailable", { status: 503 });
|
|
228
|
+
}
|
|
229
|
+
finally {
|
|
230
|
+
if (active !== undefined)
|
|
231
|
+
opening.delete(active);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
async handleRequest(request) {
|
|
236
|
+
try {
|
|
237
|
+
return await handleRequest(request);
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
return new Response("MCP is unavailable", { status: 503 });
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
async close() {
|
|
244
|
+
closed = true;
|
|
245
|
+
const connections = [...new Set([...live.values(), ...opening])];
|
|
246
|
+
live.clear();
|
|
247
|
+
await Promise.all(connections.map(async (connection) => (await connection).server.close()));
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { JsonSchemaSource } from "@fungi.computer/forage";
|
|
2
|
+
import * as Schema from "effect/Schema";
|
|
3
|
+
import type { SandboxValue } from "./index.js";
|
|
4
|
+
/** One Standard Schema issue. */
|
|
5
|
+
export type OperationSchemaIssue = Readonly<{
|
|
6
|
+
message: string;
|
|
7
|
+
path?: readonly (PropertyKey | Readonly<{
|
|
8
|
+
key: PropertyKey;
|
|
9
|
+
}>)[] | undefined;
|
|
10
|
+
}>;
|
|
11
|
+
/** Standard Schema validation result. */
|
|
12
|
+
export type OperationSchemaResult<T> = Readonly<{
|
|
13
|
+
value: T;
|
|
14
|
+
issues?: undefined;
|
|
15
|
+
}> | Readonly<{
|
|
16
|
+
issues: readonly OperationSchemaIssue[];
|
|
17
|
+
}>;
|
|
18
|
+
/**
|
|
19
|
+
* Any Standard Schema that also implements Standard JSON Schema, such as a
|
|
20
|
+
* Zod 4 schema. Mycelium validates with `validate` and describes the operation
|
|
21
|
+
* to the model with `jsonSchema`.
|
|
22
|
+
*/
|
|
23
|
+
export type StandardOperationSchema<T> = Readonly<{
|
|
24
|
+
"~standard": Readonly<{
|
|
25
|
+
/** Decode or validate one value, synchronously or not. */
|
|
26
|
+
validate: (value: SandboxValue) => OperationSchemaResult<T> | Promise<OperationSchemaResult<T>>;
|
|
27
|
+
/** Describe the accepted and produced JSON to the model. */
|
|
28
|
+
jsonSchema: JsonSchemaSource["~standard"]["jsonSchema"];
|
|
29
|
+
}>;
|
|
30
|
+
}>;
|
|
31
|
+
/**
|
|
32
|
+
* Schema for one operation's input or output: an Effect schema (exchanged as
|
|
33
|
+
* its canonical JSON codec, `Schema.toCodecJson`), or any Standard Schema with
|
|
34
|
+
* Standard JSON Schema such as Zod 4.
|
|
35
|
+
*/
|
|
36
|
+
export type OperationSchema<T> = Schema.Codec<T, unknown> | StandardOperationSchema<T>;
|
|
37
|
+
/** One operation schema reduced to the three things Mycelium needs. */
|
|
38
|
+
export type CompiledOperationSchema<T> = Readonly<{
|
|
39
|
+
/** What Forage renders for the model: always the JSON the sandbox exchanges. */
|
|
40
|
+
description: JsonSchemaSource;
|
|
41
|
+
/** Untrusted sandbox JSON → the value the operation receives. */
|
|
42
|
+
decode: (value: SandboxValue) => Promise<OperationSchemaResult<T>>;
|
|
43
|
+
/** The operation's result → the JSON value the sandbox receives. */
|
|
44
|
+
encode: (value: T) => Promise<OperationSchemaResult<unknown>>;
|
|
45
|
+
}>;
|
|
46
|
+
/** Reduce an Effect or Standard operation schema to Mycelium's internal port. */
|
|
47
|
+
export declare function compileOperationSchema<T>(schema: OperationSchema<T>): CompiledOperationSchema<T>;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import * as Cause from "effect/Cause";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
import * as Exit from "effect/Exit";
|
|
4
|
+
import * as Result from "effect/Result";
|
|
5
|
+
import * as Schema from "effect/Schema";
|
|
6
|
+
import * as SchemaIssue from "effect/SchemaIssue";
|
|
7
|
+
const formatIssues = SchemaIssue.makeFormatterStandardSchemaV1();
|
|
8
|
+
/**
|
|
9
|
+
* Run one Effect parse. A schema failure becomes issues; a defect (a throwing
|
|
10
|
+
* filter or transformation) is a host bug and rethrows as a host error, so its
|
|
11
|
+
* message and stack never reach the model as "invalid input".
|
|
12
|
+
*/
|
|
13
|
+
async function parsed(parse) {
|
|
14
|
+
const exit = await Effect.runPromiseExit(parse);
|
|
15
|
+
if (Exit.isSuccess(exit))
|
|
16
|
+
return { value: exit.value };
|
|
17
|
+
const failure = Cause.findError(exit.cause);
|
|
18
|
+
if (Result.isFailure(failure))
|
|
19
|
+
throw Cause.squash(exit.cause);
|
|
20
|
+
return { issues: formatIssues(failure.success.issue).issues };
|
|
21
|
+
}
|
|
22
|
+
function fromEffect(schema) {
|
|
23
|
+
// One canonical JSON codec owns decode, encode and the description, so the
|
|
24
|
+
// model is told exactly the JSON Mycelium accepts and returns.
|
|
25
|
+
const codec = Schema.toCodecJson(schema);
|
|
26
|
+
const json = Schema.toStandardJSONSchemaV1(codec)["~standard"].jsonSchema;
|
|
27
|
+
const decode = Schema.decodeUnknownEffect(codec);
|
|
28
|
+
const encode = Schema.encodeUnknownEffect(codec);
|
|
29
|
+
return Object.freeze({
|
|
30
|
+
description: {
|
|
31
|
+
"~standard": { jsonSchema: { input: json.input, output: json.input } },
|
|
32
|
+
},
|
|
33
|
+
decode: (value) => parsed(decode(value)),
|
|
34
|
+
encode: (value) => parsed(encode(value)),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
function fromStandard(schema) {
|
|
38
|
+
const validate = async (value) => schema["~standard"].validate(value);
|
|
39
|
+
return Object.freeze({
|
|
40
|
+
description: schema,
|
|
41
|
+
decode: validate,
|
|
42
|
+
// Standard Schema has no encoder: the result must already be JSON and is
|
|
43
|
+
// validated as declared.
|
|
44
|
+
encode: validate,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
/** Reduce an Effect or Standard operation schema to Mycelium's internal port. */
|
|
48
|
+
export function compileOperationSchema(schema) {
|
|
49
|
+
return Schema.isSchema(schema) ? fromEffect(schema) : fromStandard(schema);
|
|
50
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import * as Cause from "effect/Cause";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
import * as Exit from "effect/Exit";
|
|
4
|
+
import * as Option from "effect/Option";
|
|
5
|
+
import * as Scope from "effect/Scope";
|
|
6
|
+
import { fromTypedSandbox } from "./effect.js";
|
|
7
|
+
import { openCoreRuntime } from "./runtime.js";
|
|
8
|
+
async function runPromiseAtBoundary(effect, options) {
|
|
9
|
+
const exit = await Effect.runPromiseExit(effect, options);
|
|
10
|
+
if (Exit.isSuccess(exit))
|
|
11
|
+
return exit.value;
|
|
12
|
+
const failure = Cause.findErrorOption(exit.cause);
|
|
13
|
+
if (Option.isSome(failure))
|
|
14
|
+
throw failure.value;
|
|
15
|
+
if (Cause.hasInterruptsOnly(exit.cause)) {
|
|
16
|
+
throw options?.signal?.reason instanceof Error
|
|
17
|
+
? options.signal.reason
|
|
18
|
+
: new DOMException("The operation was aborted", "AbortError");
|
|
19
|
+
}
|
|
20
|
+
const squashed = Cause.squash(exit.cause);
|
|
21
|
+
if (squashed instanceof Error)
|
|
22
|
+
throw squashed;
|
|
23
|
+
throw new Error("Effect failed without an Error cause");
|
|
24
|
+
}
|
|
25
|
+
/** Adapt the canonical scoped runtime at the named Promise boundary. */
|
|
26
|
+
export async function openPromiseRuntime(options) {
|
|
27
|
+
const runtimeScope = await runPromiseAtBoundary(Scope.make());
|
|
28
|
+
const onSandboxFailure = options.onSandboxFailure;
|
|
29
|
+
try {
|
|
30
|
+
const runtime = await runPromiseAtBoundary(openCoreRuntime({
|
|
31
|
+
modules: options.modules ?? [],
|
|
32
|
+
sources: options.sources ?? [],
|
|
33
|
+
session: options.session,
|
|
34
|
+
execution: options.execution,
|
|
35
|
+
sandbox: fromTypedSandbox(options.sandbox),
|
|
36
|
+
onSandboxFailure: onSandboxFailure === undefined
|
|
37
|
+
? undefined
|
|
38
|
+
: (event) => Effect.promise(() => onSandboxFailure(event)),
|
|
39
|
+
tools: options.tools ?? [],
|
|
40
|
+
access: options.access ?? {},
|
|
41
|
+
}).pipe(Scope.provide(runtimeScope)));
|
|
42
|
+
const acquire = async (acquireOptions) => {
|
|
43
|
+
const leaseScope = await runPromiseAtBoundary(Scope.make());
|
|
44
|
+
try {
|
|
45
|
+
const lease = await runPromiseAtBoundary(runtime.acquire().pipe(Scope.provide(leaseScope)), acquireOptions?.signal === undefined
|
|
46
|
+
? undefined
|
|
47
|
+
: { signal: acquireOptions.signal });
|
|
48
|
+
// One release attempt; a retry observes its outcome, never a fresh success.
|
|
49
|
+
let releasing;
|
|
50
|
+
return Object.freeze({
|
|
51
|
+
revision: lease.revision,
|
|
52
|
+
catalog: lease.catalog,
|
|
53
|
+
executeTool: lease.effectExecuteTool,
|
|
54
|
+
tools: lease.tools,
|
|
55
|
+
release: () => {
|
|
56
|
+
releasing ??= runPromiseAtBoundary(lease
|
|
57
|
+
.release()
|
|
58
|
+
.pipe(Effect.ensuring(Scope.close(leaseScope, Exit.void))));
|
|
59
|
+
return releasing;
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
catch (cause) {
|
|
64
|
+
await runPromiseAtBoundary(Scope.close(leaseScope, Exit.void));
|
|
65
|
+
throw cause;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
// One close attempt; a retry observes its outcome, never a fresh success.
|
|
69
|
+
let closing;
|
|
70
|
+
const close = () => {
|
|
71
|
+
closing ??= runPromiseAtBoundary(runtime
|
|
72
|
+
.close()
|
|
73
|
+
.pipe(Effect.ensuring(Scope.close(runtimeScope, Exit.void))));
|
|
74
|
+
return closing;
|
|
75
|
+
};
|
|
76
|
+
return Object.freeze({ acquire, close });
|
|
77
|
+
}
|
|
78
|
+
catch (cause) {
|
|
79
|
+
await runPromiseAtBoundary(Scope.close(runtimeScope, Exit.void));
|
|
80
|
+
throw cause;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { type Access } from "./access.js";
|
|
2
|
+
import type { InvocationContext, ModuleDefinition, MyceliumJsonValue, OperationDefinition, SandboxValue } from "./index.js";
|
|
3
|
+
import type { JsonSchemaSource } from "@fungi.computer/forage";
|
|
4
|
+
declare const operationBrand: unique symbol;
|
|
5
|
+
declare const operationTypes: unique symbol;
|
|
6
|
+
declare const moduleBrand: unique symbol;
|
|
7
|
+
/** Opaque handle for one schema-described operation. */
|
|
8
|
+
export type OperationRegistration<I = unknown, O = unknown> = Readonly<{
|
|
9
|
+
readonly [operationBrand]: true;
|
|
10
|
+
readonly [operationTypes]?: Readonly<{
|
|
11
|
+
input: I;
|
|
12
|
+
output: O;
|
|
13
|
+
}>;
|
|
14
|
+
}>;
|
|
15
|
+
/** Opaque handle for one namespaced capability module. */
|
|
16
|
+
export type ModuleRegistration = Readonly<{
|
|
17
|
+
readonly [moduleBrand]: true;
|
|
18
|
+
}>;
|
|
19
|
+
/** Captured operation data available only to the package compiler. */
|
|
20
|
+
export type StoredOperation = Readonly<{
|
|
21
|
+
/** Replay classification of this operation, not of the enclosing execute tool. */
|
|
22
|
+
replay: "safe" | "unsafe";
|
|
23
|
+
/** What the operation does, as its author declared it; unlabelled is `write`. */
|
|
24
|
+
access: Access;
|
|
25
|
+
/** The author asks that a person approve each call first. */
|
|
26
|
+
confirm: boolean;
|
|
27
|
+
description?: string;
|
|
28
|
+
/** Model-facing description of the JSON the operation accepts. */
|
|
29
|
+
input: JsonSchemaSource;
|
|
30
|
+
/** Model-facing description of the JSON the operation returns. */
|
|
31
|
+
output?: JsonSchemaSource;
|
|
32
|
+
/** Returns the declared result; the execute door checks it is JSON. */
|
|
33
|
+
invoke: (input: SandboxValue, context: InvocationContext) => Promise<SandboxValue>;
|
|
34
|
+
}>;
|
|
35
|
+
/** Captured module data available only to the package compiler. */
|
|
36
|
+
export type StoredModule = Readonly<{
|
|
37
|
+
id: string;
|
|
38
|
+
/** The key a host's grant names for this module; its id unless reassigned. */
|
|
39
|
+
grantKey: string;
|
|
40
|
+
name: string;
|
|
41
|
+
namespace: readonly string[];
|
|
42
|
+
description?: string;
|
|
43
|
+
operations: Readonly<Record<string, OperationRegistration | ModuleRegistration>>;
|
|
44
|
+
}>;
|
|
45
|
+
/** Construct an opaque operation handle and retain its private definition. */
|
|
46
|
+
export declare function defineOperation<I, O>(definition: OperationDefinition<I, O>): OperationRegistration<I, O>;
|
|
47
|
+
/** Construct an opaque module handle and retain a frozen declaration copy. */
|
|
48
|
+
export declare function defineModule(definition: ModuleDefinition): ModuleRegistration;
|
|
49
|
+
/** Distinguish registered child modules without exposing their private declarations. */
|
|
50
|
+
export declare function isModuleRegistration(registration: OperationRegistration | ModuleRegistration): registration is ModuleRegistration;
|
|
51
|
+
/** Resolve private operation data for a compiler-owned handle. */
|
|
52
|
+
export declare function readOperation(registration: OperationRegistration): StoredOperation | undefined;
|
|
53
|
+
/** Resolve private module data for a compiler-owned handle. */
|
|
54
|
+
export declare function readModule(registration: ModuleRegistration): StoredModule | undefined;
|
|
55
|
+
/** A gate's verdict for one call: run the operation, or return `value` instead. */
|
|
56
|
+
export type GateDecision = Readonly<{
|
|
57
|
+
kind: "allow";
|
|
58
|
+
}> | Readonly<{
|
|
59
|
+
kind: "refuse";
|
|
60
|
+
value: MyceliumJsonValue;
|
|
61
|
+
}>;
|
|
62
|
+
/** Asked before every call of a gated module's operations. */
|
|
63
|
+
export type OperationGate = (call: Readonly<{
|
|
64
|
+
operation: string;
|
|
65
|
+
context: InvocationContext;
|
|
66
|
+
}>) => GateDecision | Promise<GateDecision>;
|
|
67
|
+
/**
|
|
68
|
+
* Look this module's grant up by `grantKey` instead of its id, for a host
|
|
69
|
+
* that grants by a wider owner, such as the extension that built the module.
|
|
70
|
+
*/
|
|
71
|
+
export declare function grantModuleAs(registration: ModuleRegistration, grantKey: string): ModuleRegistration;
|
|
72
|
+
/** Gate an entire module tree while preserving its authority identity. */
|
|
73
|
+
export declare function gateModule(registration: ModuleRegistration, gate: OperationGate): ModuleRegistration;
|
|
74
|
+
export {};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { sandboxNamespaceSegmentSchema } from "./sandbox-guest.js";
|
|
3
|
+
import { parseAccessLabels } from "./access.js";
|
|
4
|
+
import { InvalidOperationInput, InvalidOperationOutput, MyceliumDefinitionError, } from "./errors.js";
|
|
5
|
+
import { compileOperationSchema } from "./operation-schema.js";
|
|
6
|
+
import { BadResponse } from "./sandbox-failure.js";
|
|
7
|
+
const operationBrand = Symbol("mycelium.operation");
|
|
8
|
+
const operationTypes = Symbol("mycelium.operation.types");
|
|
9
|
+
const moduleBrand = Symbol("mycelium.module");
|
|
10
|
+
const operationDefinitions = new WeakMap();
|
|
11
|
+
const moduleDefinitions = new WeakMap();
|
|
12
|
+
/** Construct an opaque operation handle and retain its private definition. */
|
|
13
|
+
export function defineOperation(definition) {
|
|
14
|
+
const registration = Object.freeze({
|
|
15
|
+
[operationBrand]: true,
|
|
16
|
+
});
|
|
17
|
+
const input = compileOperationSchema(definition.input);
|
|
18
|
+
const output = definition.output === undefined
|
|
19
|
+
? undefined
|
|
20
|
+
: compileOperationSchema(definition.output);
|
|
21
|
+
const captured = {
|
|
22
|
+
replay: definition.replay ?? "unsafe",
|
|
23
|
+
...parseAccessLabels(definition),
|
|
24
|
+
input: input.description,
|
|
25
|
+
invoke: async (raw, context) => {
|
|
26
|
+
const decoded = await input.decode(raw);
|
|
27
|
+
if (decoded.issues !== undefined)
|
|
28
|
+
throw new InvalidOperationInput(decoded.issues);
|
|
29
|
+
const result = await definition.execute(decoded.value, context);
|
|
30
|
+
if (output === undefined)
|
|
31
|
+
return result;
|
|
32
|
+
const encoded = await output.encode(result);
|
|
33
|
+
if (encoded.issues !== undefined)
|
|
34
|
+
throw new BadResponse({
|
|
35
|
+
reason: "Capability output failed its declared schema",
|
|
36
|
+
rawCause: new InvalidOperationOutput(encoded.issues),
|
|
37
|
+
});
|
|
38
|
+
return encoded.value;
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
if (definition.description !== undefined)
|
|
42
|
+
captured.description = definition.description;
|
|
43
|
+
if (output !== undefined)
|
|
44
|
+
captured.output = output.description;
|
|
45
|
+
const stored = Object.freeze(captured);
|
|
46
|
+
operationDefinitions.set(registration, stored);
|
|
47
|
+
return registration;
|
|
48
|
+
}
|
|
49
|
+
/** Construct an opaque module handle and retain a frozen declaration copy. */
|
|
50
|
+
export function defineModule(definition) {
|
|
51
|
+
const namespace = z
|
|
52
|
+
.array(sandboxNamespaceSegmentSchema)
|
|
53
|
+
.safeParse(definition.namespace ?? []);
|
|
54
|
+
if (!namespace.success)
|
|
55
|
+
throw new MyceliumDefinitionError("Invalid module namespace");
|
|
56
|
+
const registration = Object.freeze({
|
|
57
|
+
[moduleBrand]: true,
|
|
58
|
+
});
|
|
59
|
+
const captured = {
|
|
60
|
+
id: definition.id,
|
|
61
|
+
grantKey: definition.id,
|
|
62
|
+
name: definition.name ?? definition.id,
|
|
63
|
+
namespace: Object.freeze(namespace.data),
|
|
64
|
+
operations: Object.freeze({ ...definition.operations }),
|
|
65
|
+
};
|
|
66
|
+
if (definition.description !== undefined)
|
|
67
|
+
captured.description = definition.description;
|
|
68
|
+
const stored = Object.freeze(captured);
|
|
69
|
+
moduleDefinitions.set(registration, stored);
|
|
70
|
+
return registration;
|
|
71
|
+
}
|
|
72
|
+
/** Distinguish registered child modules without exposing their private declarations. */
|
|
73
|
+
export function isModuleRegistration(registration) {
|
|
74
|
+
return moduleDefinitions.has(registration);
|
|
75
|
+
}
|
|
76
|
+
/** Resolve private operation data for a compiler-owned handle. */
|
|
77
|
+
export function readOperation(registration) {
|
|
78
|
+
return operationDefinitions.get(registration);
|
|
79
|
+
}
|
|
80
|
+
/** Resolve private module data for a compiler-owned handle. */
|
|
81
|
+
export function readModule(registration) {
|
|
82
|
+
return moduleDefinitions.get(registration);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Wrap every operation of one module behind a host gate, keeping its
|
|
86
|
+
* identity, alias, description and schemas. A refused call never reaches the
|
|
87
|
+
* operation (not even its input decoder) and returns the gate's typed value.
|
|
88
|
+
*/
|
|
89
|
+
function gateModuleAt(registration, gate, prefix) {
|
|
90
|
+
const stored = readModule(registration);
|
|
91
|
+
if (stored === undefined)
|
|
92
|
+
throw new MyceliumDefinitionError("Unknown module registration");
|
|
93
|
+
const operations = {};
|
|
94
|
+
for (const [name, handle] of Object.entries(stored.operations)) {
|
|
95
|
+
const operation = prefix === "" ? name : `${prefix}.${name}`;
|
|
96
|
+
if (isModuleRegistration(handle)) {
|
|
97
|
+
const child = readModule(handle);
|
|
98
|
+
if (child === undefined)
|
|
99
|
+
throw new MyceliumDefinitionError("Unknown child module registration");
|
|
100
|
+
operations[name] = gateModuleAt(handle, gate, [operation, ...child.namespace].join("."));
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const inner = readOperation(handle);
|
|
104
|
+
if (inner === undefined)
|
|
105
|
+
throw new MyceliumDefinitionError(`Unknown operation registration: ${name}`);
|
|
106
|
+
const gated = Object.freeze({
|
|
107
|
+
[operationBrand]: true,
|
|
108
|
+
});
|
|
109
|
+
operationDefinitions.set(gated, Object.freeze({
|
|
110
|
+
...inner,
|
|
111
|
+
invoke: async (input, context) => {
|
|
112
|
+
const decision = await gate({ operation, context });
|
|
113
|
+
return decision.kind === "refuse"
|
|
114
|
+
? decision.value
|
|
115
|
+
: await inner.invoke(input, context);
|
|
116
|
+
},
|
|
117
|
+
}));
|
|
118
|
+
operations[name] = gated;
|
|
119
|
+
}
|
|
120
|
+
const definition = stored.description === undefined
|
|
121
|
+
? {
|
|
122
|
+
id: stored.id,
|
|
123
|
+
name: stored.name,
|
|
124
|
+
namespace: stored.namespace,
|
|
125
|
+
operations,
|
|
126
|
+
}
|
|
127
|
+
: {
|
|
128
|
+
id: stored.id,
|
|
129
|
+
name: stored.name,
|
|
130
|
+
namespace: stored.namespace,
|
|
131
|
+
description: stored.description,
|
|
132
|
+
operations,
|
|
133
|
+
};
|
|
134
|
+
return withGrantKey(defineModule(definition), stored.grantKey);
|
|
135
|
+
}
|
|
136
|
+
/** Copy one module registration under a different grant key. */
|
|
137
|
+
function withGrantKey(registration, grantKey) {
|
|
138
|
+
const stored = readModule(registration);
|
|
139
|
+
if (stored === undefined)
|
|
140
|
+
throw new MyceliumDefinitionError("Unknown module registration");
|
|
141
|
+
const copy = Object.freeze({ [moduleBrand]: true });
|
|
142
|
+
moduleDefinitions.set(copy, Object.freeze({ ...stored, grantKey }));
|
|
143
|
+
return copy;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Look this module's grant up by `grantKey` instead of its id, for a host
|
|
147
|
+
* that grants by a wider owner, such as the extension that built the module.
|
|
148
|
+
*/
|
|
149
|
+
export function grantModuleAs(registration, grantKey) {
|
|
150
|
+
if (grantKey.length === 0 || grantKey.length > 160)
|
|
151
|
+
throw new MyceliumDefinitionError("Grant key must be non-empty and bounded");
|
|
152
|
+
return withGrantKey(registration, grantKey);
|
|
153
|
+
}
|
|
154
|
+
/** Gate an entire module tree while preserving its authority identity. */
|
|
155
|
+
export function gateModule(registration, gate) {
|
|
156
|
+
return gateModuleAt(registration, gate, "");
|
|
157
|
+
}
|