@questpie/sandbox 3.15.2 → 3.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,40 @@
1
+ import "../custom-tools-CgfgyVNv.mjs";
2
+ import * as questpie0 from "questpie";
3
+ import { routeApp } from "questpie";
4
+ import "questpie/executor";
5
+ import { RouteDefinition, RouteParamsFromKey } from "questpie/types";
6
+
7
+ //#region src/server/modules/sandbox/routes/sandbox/rpc.d.ts
8
+
9
+ declare const sandboxRpcRoute: questpie0.RawRouteDefinition<questpie0.JsonRouteParams>;
10
+ //#endregion
11
+ //#region src/server/modules/sandbox/.generated/module.d.ts
12
+ type SandboxCollections = Record<never, never>;
13
+ type SandboxChannels = Record<never, never>;
14
+ type SandboxGlobals = Record<never, never>;
15
+ type SandboxJobs = Record<never, never>;
16
+ type SandboxRoutes = {
17
+ "sandbox/rpc": typeof sandboxRpcRoute extends {
18
+ __brand: "route";
19
+ } ? RouteDefinition<unknown, unknown, RouteParamsFromKey<"sandbox/rpc">> : typeof sandboxRpcRoute;
20
+ };
21
+ type SandboxFieldTypes = Record<never, never>;
22
+ type SandboxModule = {
23
+ name: "questpie-sandbox";
24
+ routes: SandboxRoutes;
25
+ collections: SandboxCollections;
26
+ channels: SandboxChannels;
27
+ globals: SandboxGlobals;
28
+ jobs: SandboxJobs;
29
+ fieldTypes: SandboxFieldTypes;
30
+ messages: Record<never, never>;
31
+ services: Record<never, never>;
32
+ emails: Record<never, never>;
33
+ migrations: readonly unknown[];
34
+ seeds: readonly unknown[];
35
+ };
36
+ //#endregion
37
+ //#region src/server/modules/sandbox/index.d.ts
38
+ declare const sandboxModule: SandboxModule;
39
+ //#endregion
40
+ export { sandboxModule as default, sandboxModule };
@@ -0,0 +1,211 @@
1
+ import { a as BROKER_MAX_REQUEST_WIRE_BYTES, c as brokerResultValueCap, o as brokerRequestWireCap, s as brokerResponseWireCap, t as handleSandboxCustomToolsRpc } from "../custom-tools-DxI7JvUn.mjs";
2
+ import { route, routeApp } from "questpie";
3
+ import { BINDINGS_TOKEN_HEADER, snapshotBoundedBrokerValue } from "questpie/executor";
4
+
5
+ //#region src/server/modules/sandbox/routes/sandbox/rpc.ts
6
+ const MAX_METHOD_BYTES = 256;
7
+ const BINDING_ERROR_CODES = new Set([
8
+ "bad_method",
9
+ "bad_args",
10
+ "unauthorized",
11
+ "forbidden",
12
+ "not_implemented",
13
+ "execution_error"
14
+ ]);
15
+ function json(body, status) {
16
+ return new Response(JSON.stringify(body), {
17
+ status,
18
+ headers: { "content-type": "application/json" }
19
+ });
20
+ }
21
+ function errorResponse(status, code, message) {
22
+ return json({
23
+ ok: false,
24
+ error: {
25
+ code,
26
+ message
27
+ }
28
+ }, status);
29
+ }
30
+ async function readBoundedJson(request) {
31
+ const contentLength = request.headers.get("content-length");
32
+ if (contentLength !== null && (!/^\d+$/.test(contentLength) || Number(contentLength) > BROKER_MAX_REQUEST_WIRE_BYTES)) return {
33
+ ok: false,
34
+ response: errorResponse(413, "bad_args", "sandbox broker request body is too large")
35
+ };
36
+ if (!request.body) return {
37
+ ok: false,
38
+ response: errorResponse(400, "bad_args", "invalid JSON body")
39
+ };
40
+ const reader = request.body.getReader();
41
+ const chunks = [];
42
+ let size = 0;
43
+ try {
44
+ while (true) {
45
+ const part = await reader.read();
46
+ if (part.done) break;
47
+ size += part.value.byteLength;
48
+ if (size > BROKER_MAX_REQUEST_WIRE_BYTES) {
49
+ await reader.cancel();
50
+ return {
51
+ ok: false,
52
+ response: errorResponse(413, "bad_args", "sandbox broker request body is too large")
53
+ };
54
+ }
55
+ chunks.push(part.value);
56
+ }
57
+ } catch {
58
+ return {
59
+ ok: false,
60
+ response: errorResponse(400, "bad_args", "invalid JSON body")
61
+ };
62
+ }
63
+ const bytes = new Uint8Array(size);
64
+ let offset = 0;
65
+ for (const chunk of chunks) {
66
+ bytes.set(chunk, offset);
67
+ offset += chunk.byteLength;
68
+ }
69
+ try {
70
+ return {
71
+ ok: true,
72
+ value: JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)),
73
+ bytes: size
74
+ };
75
+ } catch {
76
+ return {
77
+ ok: false,
78
+ response: errorResponse(400, "bad_args", "invalid JSON body")
79
+ };
80
+ }
81
+ }
82
+ function parseRequestBody(value) {
83
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {
84
+ ok: false,
85
+ response: errorResponse(400, "bad_args", "invalid request body")
86
+ };
87
+ const body = value;
88
+ if (Object.keys(body).some((key) => key !== "method" && key !== "args")) return {
89
+ ok: false,
90
+ response: errorResponse(400, "bad_args", "invalid request body")
91
+ };
92
+ if (typeof body.method !== "string" || body.method.length === 0 || new TextEncoder().encode(body.method).byteLength > MAX_METHOD_BYTES) return {
93
+ ok: false,
94
+ response: errorResponse(400, "bad_method", "invalid method")
95
+ };
96
+ return {
97
+ ok: true,
98
+ method: body.method,
99
+ args: body.args
100
+ };
101
+ }
102
+ function invalidBrokerResult() {
103
+ return {
104
+ ok: false,
105
+ error: {
106
+ code: "execution_error",
107
+ message: "sandbox broker returned an invalid result",
108
+ correlationId: crypto.randomUUID()
109
+ }
110
+ };
111
+ }
112
+ function normalizeBrokerResult(method, value) {
113
+ const bounded = snapshotBoundedBrokerValue(value, brokerResponseWireCap(method));
114
+ if (!bounded.ok || !bounded.value || Array.isArray(bounded.value)) return null;
115
+ const result = bounded.value;
116
+ const resultKeys = Object.keys(result);
117
+ if (result.ok === true) {
118
+ if (resultKeys.length !== 2 || !resultKeys.includes("ok") || !resultKeys.includes("value")) return null;
119
+ const normalizedValue = snapshotBoundedBrokerValue(result.value, brokerResultValueCap(method));
120
+ if (!normalizedValue.ok) return null;
121
+ return {
122
+ ok: true,
123
+ value: normalizedValue.value
124
+ };
125
+ }
126
+ if (result.ok !== false || resultKeys.length !== 2 || !resultKeys.includes("error") || !result.error || typeof result.error !== "object" || Array.isArray(result.error)) return null;
127
+ const error = result.error;
128
+ const errorKeys = Object.keys(error);
129
+ if (!errorKeys.every((key) => [
130
+ "code",
131
+ "message",
132
+ "correlationId"
133
+ ].includes(key)) || !errorKeys.includes("code") || !errorKeys.includes("message") || typeof error.code !== "string" || !BINDING_ERROR_CODES.has(error.code) || typeof error.message !== "string" || error.correlationId !== void 0 && typeof error.correlationId !== "string") return null;
134
+ const correlationId = typeof error.correlationId === "string" && /^[\w-]{1,128}$/.test(error.correlationId) ? error.correlationId : void 0;
135
+ if (error.code === "execution_error") return {
136
+ ok: false,
137
+ error: {
138
+ code: "execution_error",
139
+ message: "sandbox binding operation failed",
140
+ correlationId: correlationId ?? crypto.randomUUID()
141
+ }
142
+ };
143
+ return {
144
+ ok: false,
145
+ error: {
146
+ code: error.code,
147
+ message: error.message,
148
+ ...correlationId ? { correlationId } : {}
149
+ }
150
+ };
151
+ }
152
+ async function handleSandboxBrokerRequest(request, broker, customTools) {
153
+ const decoded = await readBoundedJson(request);
154
+ if (!decoded.ok) return decoded.response;
155
+ const body = parseRequestBody(decoded.value);
156
+ if (!body.ok) return body.response;
157
+ if (decoded.bytes > brokerRequestWireCap(body.method)) return errorResponse(413, "bad_args", "sandbox broker request body is too large");
158
+ let result;
159
+ try {
160
+ result = body.method.startsWith("tools.") ? await handleSandboxCustomToolsRpc({
161
+ app: customTools?.app,
162
+ config: customTools?.config,
163
+ token: request.headers.get(BINDINGS_TOKEN_HEADER),
164
+ requestUrl: request.url,
165
+ method: body.method,
166
+ args: body.args,
167
+ signal: request.signal
168
+ }) : await broker.handleRpc(request.headers.get(BINDINGS_TOKEN_HEADER), body.method, body.args);
169
+ } catch {
170
+ return errorResponse(500, "execution_error", "sandbox broker request failed");
171
+ }
172
+ result = normalizeBrokerResult(body.method, result) ?? invalidBrokerResult();
173
+ const status = result.ok ? 200 : result.error.code === "unauthorized" ? 401 : result.error.code === "forbidden" ? 403 : result.error.code === "not_implemented" ? 501 : result.error.code === "execution_error" ? 500 : 400;
174
+ return json(result, status);
175
+ }
176
+ const sandboxRpcRoute = route().post().raw().access(true).handler((ctx) => {
177
+ const app = routeApp(ctx);
178
+ const broker = app.executor?.broker;
179
+ if (!broker) return errorResponse(503, "execution_error", "sandbox broker unavailable");
180
+ return handleSandboxBrokerRequest(ctx.request, broker, {
181
+ app,
182
+ config: app.state.sandboxCustomTools
183
+ });
184
+ });
185
+ var rpc_default = sandboxRpcRoute;
186
+
187
+ //#endregion
188
+ //#region src/server/modules/sandbox/.generated/module.ts
189
+ const _module = {
190
+ name: "questpie-sandbox",
191
+ routes: { "sandbox/rpc": rpc_default },
192
+ collections: {},
193
+ channels: {},
194
+ globals: {},
195
+ jobs: {},
196
+ messages: {},
197
+ services: {},
198
+ emails: {},
199
+ migrations: [],
200
+ seeds: [],
201
+ fieldTypes: {}
202
+ };
203
+ var module_default = _module;
204
+
205
+ //#endregion
206
+ //#region src/server/modules/sandbox/index.ts
207
+ const sandboxModule = module_default;
208
+ var sandbox_default = sandboxModule;
209
+
210
+ //#endregion
211
+ export { sandbox_default as default, sandboxModule };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@questpie/sandbox",
3
- "version": "3.15.2",
3
+ "version": "3.17.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -10,12 +10,16 @@
10
10
  "files": [
11
11
  "dist",
12
12
  "src/sandbox-server.ts",
13
+ "src/broker-wire.ts",
13
14
  "src/guest-entry.ts",
14
15
  "src/net-validation.ts",
15
16
  "src/server-internals.ts",
16
17
  "src/egress-firewall.ts",
17
18
  "src/types.ts",
18
- "src/guest-bindings.ts"
19
+ "src/guest-bindings.ts",
20
+ "src/guest-runtime-source.ts",
21
+ "src/workload-admission.ts",
22
+ "src/workload.ts"
19
23
  ],
20
24
  "type": "module",
21
25
  "sideEffects": false,
@@ -31,6 +35,10 @@
31
35
  "types": "./dist/adapter.d.mts",
32
36
  "default": "./dist/adapter.mjs"
33
37
  },
38
+ "./modules/sandbox": {
39
+ "types": "./dist/modules/sandbox.d.mts",
40
+ "default": "./dist/modules/sandbox.mjs"
41
+ },
34
42
  "./package.json": {
35
43
  "types": "./package.json",
36
44
  "default": "./package.json"
@@ -47,6 +55,10 @@
47
55
  "types": "./dist/adapter.d.mts",
48
56
  "default": "./dist/adapter.mjs"
49
57
  },
58
+ "./modules/sandbox": {
59
+ "types": "./dist/modules/sandbox.d.mts",
60
+ "default": "./dist/modules/sandbox.mjs"
61
+ },
50
62
  "./package.json": {
51
63
  "types": "./package.json",
52
64
  "default": "./package.json"
@@ -56,16 +68,19 @@
56
68
  "scripts": {
57
69
  "build": "tsdown",
58
70
  "check-types": "tsc --noEmit",
71
+ "questpie:generate": "bun questpie generate --verbose",
59
72
  "test": "bun test"
60
73
  },
61
74
  "dependencies": {
62
- "questpie": "^3.15.2"
75
+ "@questpie/mcp": "^3.17.0",
76
+ "questpie": "^3.17.0"
63
77
  },
64
78
  "devDependencies": {
65
79
  "bun-types": "latest",
66
- "tsdown": "^0.18.3"
80
+ "tsdown": "^0.18.3",
81
+ "zod": "^4.2.1"
67
82
  },
68
83
  "peerDependencies": {
69
- "questpie": "^3.0.0"
84
+ "questpie": "^3.17.0"
70
85
  }
71
86
  }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * One place for the sandbox broker's decoded payload and derived wire budgets.
3
+ *
4
+ * `http.fetch` bodies are binary-to-base64, then nested in JSON RPC envelopes.
5
+ * The transport limits therefore cannot equal the decoded body limit. Keep all
6
+ * supervisor, route, frame, and custom-tool caps derived here.
7
+ */
8
+
9
+ /** Decoded request/response body limit promised by `http.fetch`. */
10
+ export const BROKER_HTTP_BODY_CAP_BYTES = 5 * 1024 * 1024;
11
+
12
+ /** Exact maximum base64 length for a decoded HTTP body at the public cap. */
13
+ export const BROKER_HTTP_BASE64_CAP_BYTES =
14
+ 4 * Math.ceil(BROKER_HTTP_BODY_CAP_BYTES / 3);
15
+
16
+ /**
17
+ * Room for URL, method, bounded HTTP headers, status metadata, and JSON RPC
18
+ * envelope syntax around a maximum-size base64 body.
19
+ */
20
+ export const BROKER_HTTP_JSON_HEADROOM_BYTES = 128 * 1024;
21
+
22
+ /** Whole JSON request/response budget for the `http.fetch` method. */
23
+ export const BROKER_HTTP_WIRE_CAP_BYTES =
24
+ BROKER_HTTP_BASE64_CAP_BYTES + BROKER_HTTP_JSON_HEADROOM_BYTES;
25
+
26
+ /** Existing small native binding request budget, including its JSON envelope. */
27
+ export const BROKER_NATIVE_REQUEST_WIRE_CAP_BYTES = 64 * 1024;
28
+
29
+ /** Maximum JSON value returned by native bindings before envelope overhead. */
30
+ export const BROKER_NATIVE_RESULT_CAP_BYTES = 768 * 1024;
31
+
32
+ /** Custom-tool logical limits; shared with the host session implementation. */
33
+ export const BROKER_CUSTOM_ARGUMENT_CAP_BYTES = 63 * 1024;
34
+ export const BROKER_CUSTOM_RESULT_CAP_BYTES = 768 * 1024;
35
+ export const BROKER_CUSTOM_LIST_CAP_BYTES = 768 * 1024;
36
+
37
+ const BROKER_JSON_ENVELOPE_HEADROOM_BYTES = 4 * 1024;
38
+
39
+ export const BROKER_NATIVE_RESPONSE_WIRE_CAP_BYTES =
40
+ BROKER_NATIVE_RESULT_CAP_BYTES + BROKER_JSON_ENVELOPE_HEADROOM_BYTES;
41
+ export const BROKER_CUSTOM_REQUEST_WIRE_CAP_BYTES =
42
+ BROKER_CUSTOM_ARGUMENT_CAP_BYTES + BROKER_JSON_ENVELOPE_HEADROOM_BYTES;
43
+ export const BROKER_CUSTOM_RESPONSE_WIRE_CAP_BYTES =
44
+ Math.max(BROKER_CUSTOM_RESULT_CAP_BYTES, BROKER_CUSTOM_LIST_CAP_BYTES) +
45
+ BROKER_JSON_ENVELOPE_HEADROOM_BYTES;
46
+
47
+ /** Maximum body the app broker route will read before method-aware validation. */
48
+ export const BROKER_MAX_REQUEST_WIRE_BYTES = Math.max(
49
+ BROKER_HTTP_WIRE_CAP_BYTES,
50
+ BROKER_NATIVE_REQUEST_WIRE_CAP_BYTES,
51
+ BROKER_CUSTOM_REQUEST_WIRE_CAP_BYTES,
52
+ );
53
+
54
+ /** Maximum response the supervisor will read before method-aware validation. */
55
+ export const BROKER_MAX_RESPONSE_WIRE_BYTES = Math.max(
56
+ BROKER_HTTP_WIRE_CAP_BYTES,
57
+ BROKER_NATIVE_RESPONSE_WIRE_CAP_BYTES,
58
+ BROKER_CUSTOM_RESPONSE_WIRE_CAP_BYTES,
59
+ );
60
+
61
+ /** Prefix/newline room around the largest JSON RPC frame. */
62
+ export const BROKER_FRAME_CAP_BYTES =
63
+ Math.max(BROKER_MAX_REQUEST_WIRE_BYTES, BROKER_MAX_RESPONSE_WIRE_BYTES) +
64
+ 1024;
65
+
66
+ /**
67
+ * A run may emit one maximum RPC request and still return a maximum-sized final
68
+ * frame. Repeated large calls remain bounded by this cumulative budget.
69
+ */
70
+ export const BROKER_TOTAL_OUTPUT_CAP_BYTES = BROKER_FRAME_CAP_BYTES * 2;
71
+
72
+ export function brokerRequestWireCap(method: string): number {
73
+ if (method === "http.fetch") return BROKER_HTTP_WIRE_CAP_BYTES;
74
+ if (method === "tools.call") return BROKER_CUSTOM_REQUEST_WIRE_CAP_BYTES;
75
+ if (method === "tools.list") return BROKER_JSON_ENVELOPE_HEADROOM_BYTES;
76
+ return BROKER_NATIVE_REQUEST_WIRE_CAP_BYTES;
77
+ }
78
+
79
+ export function brokerResponseWireCap(method: string): number {
80
+ if (method === "http.fetch") return BROKER_HTTP_WIRE_CAP_BYTES;
81
+ if (method === "tools.call" || method === "tools.list") {
82
+ return BROKER_CUSTOM_RESPONSE_WIRE_CAP_BYTES;
83
+ }
84
+ return BROKER_NATIVE_RESPONSE_WIRE_CAP_BYTES;
85
+ }
86
+
87
+ export function brokerResultValueCap(method: string): number {
88
+ if (method === "http.fetch") {
89
+ return BROKER_HTTP_WIRE_CAP_BYTES - BROKER_JSON_ENVELOPE_HEADROOM_BYTES;
90
+ }
91
+ if (method === "tools.call") return BROKER_CUSTOM_RESULT_CAP_BYTES;
92
+ if (method === "tools.list") return BROKER_CUSTOM_LIST_CAP_BYTES;
93
+ return BROKER_NATIVE_RESULT_CAP_BYTES;
94
+ }
@@ -161,7 +161,9 @@ function looksIpv4(ip: string): boolean {
161
161
  const parts = ip.split(".");
162
162
  return (
163
163
  parts.length === 4 &&
164
- parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) >= 0 && Number(p) <= 255)
164
+ parts.every(
165
+ (p) => /^\d{1,3}$/.test(p) && Number(p) >= 0 && Number(p) <= 255,
166
+ )
165
167
  );
166
168
  }
167
169
 
@@ -182,7 +184,9 @@ function looksIpv4(ip: string): boolean {
182
184
  export function buildNftRuleset(allow: readonly AllowedEndpoint[]): string {
183
185
  const lines: string[] = [];
184
186
  lines.push(`# QUESTPIE sandbox egress firewall (defense-in-depth, per-run).`);
185
- lines.push(`# Default-deny: only the resolved allowlist + broker may egress.`);
187
+ lines.push(
188
+ `# Default-deny: only the resolved allowlist + broker may egress.`,
189
+ );
186
190
  lines.push(`table inet ${NFT_TABLE} {`);
187
191
  lines.push(`\tchain output {`);
188
192
  lines.push(`\t\ttype filter hook output priority 0; policy drop;`);
@@ -228,7 +232,9 @@ export function buildNftRuleset(allow: readonly AllowedEndpoint[]): string {
228
232
  * default-deny netns, the correct posture for the brokered (`--allow-net=[]`)
229
233
  * path. The guest has nothing to reach over raw sockets; the kernel enforces it.
230
234
  */
231
- export function planEgressFirewall(input: EgressFirewallPlanInput): EgressFirewallPlan {
235
+ export function planEgressFirewall(
236
+ input: EgressFirewallPlanInput,
237
+ ): EgressFirewallPlan {
232
238
  if (!isLinux(input.os)) {
233
239
  return {
234
240
  applied: false,
@@ -54,6 +54,31 @@ export interface GuestStore {
54
54
  delete(args?: { where?: unknown }): Promise<unknown>;
55
55
  }
56
56
 
57
+ /** Schema-preserving custom-tool descriptor returned by discovery. */
58
+ export interface GuestToolDescriptor {
59
+ name: string;
60
+ title?: string;
61
+ description?: string;
62
+ inputSchema: Record<string, unknown>;
63
+ outputSchema?: Record<string, unknown>;
64
+ annotations?: Record<string, unknown>;
65
+ _meta?: Record<string, unknown>;
66
+ }
67
+
68
+ /** Protocol-safe custom-tool result returned to the guest. */
69
+ export interface GuestToolResult {
70
+ content: Array<{ type: string; [key: string]: unknown }>;
71
+ structuredContent?: Record<string, unknown>;
72
+ isError?: boolean;
73
+ _meta?: Record<string, unknown>;
74
+ }
75
+
76
+ /** Explicit custom-tool discovery/call surface. Authorization stays host-side. */
77
+ export interface GuestTools {
78
+ list(): Promise<{ tools: GuestToolDescriptor[] }>;
79
+ call(name: string, args?: Record<string, unknown>): Promise<GuestToolResult>;
80
+ }
81
+
57
82
  /** The `globalThis.questpie` surface handed to untrusted guest code. */
58
83
  export interface GuestBindings {
59
84
  /** Files (file-as-DB), scoped by the run's `capabilities.files`. */
@@ -76,6 +101,11 @@ export interface GuestBindings {
76
101
  * `collections.document_store.<op>` call.
77
102
  */
78
103
  store: Record<string, GuestStore>;
104
+ /**
105
+ * Explicitly released custom MCP tools. Built-in CRUD/file/store operations
106
+ * never flow through this namespace.
107
+ */
108
+ tools: GuestTools;
79
109
  }
80
110
 
81
111
  /**
@@ -142,5 +172,17 @@ export function buildGuestBindings(hostCall: HostCall): GuestBindings {
142
172
  },
143
173
  ) as Record<string, GuestStore>;
144
174
 
145
- return { files, collections, store };
175
+ const tools: GuestTools = {
176
+ list: async () =>
177
+ (await hostCall("tools.list", {})) as {
178
+ tools: GuestToolDescriptor[];
179
+ },
180
+ call: async (name, args) =>
181
+ (await hostCall("tools.call", {
182
+ name,
183
+ arguments: args ?? {},
184
+ })) as GuestToolResult,
185
+ };
186
+
187
+ return { files, collections, store, tools };
146
188
  }