@questpie/sandbox 3.1.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.
- package/README.md +58 -0
- package/dist/adapter-http-7ypWHBck.d.mts +43 -0
- package/dist/adapter-http-w7SjAfcC.mjs +298 -0
- package/dist/adapter.d.mts +2 -0
- package/dist/adapter.mjs +3 -0
- package/dist/index.d.mts +280 -0
- package/dist/index.mjs +69 -0
- package/package.json +71 -0
- package/src/egress-firewall.ts +330 -0
- package/src/guest-bindings.ts +146 -0
- package/src/guest-entry.ts +446 -0
- package/src/net-validation.ts +359 -0
- package/src/sandbox-server.ts +682 -0
- package/src/server-internals.ts +127 -0
- package/src/types.ts +132 -0
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guest entry — runs INSIDE each freshly-spawned Deno subprocess (one per
|
|
3
|
+
* untrusted request). This file is the in-process half of the hardened sandbox.
|
|
4
|
+
*
|
|
5
|
+
* Runs under Deno ONLY. Excluded from the package's tsc typecheck (it uses Deno
|
|
6
|
+
* globals); shipped as source and executed via `deno run <flags> guest-entry.ts`.
|
|
7
|
+
*
|
|
8
|
+
* Threat-model hardening applied here, BEFORE any guest code runs:
|
|
9
|
+
* - `globalThis.Worker = undefined` → guest cannot spawn nested Workers
|
|
10
|
+
* (the M1 orphan-CPU DoS: terminate() doesn't reap grandchild Workers).
|
|
11
|
+
* Process death reaps everything, but denying Worker also removes the vector.
|
|
12
|
+
* - delete `SharedArrayBuffer` + `Atomics` → removes the timer-amplification
|
|
13
|
+
* gadget needed for Spectre cache side-channels against co-resident tenants.
|
|
14
|
+
* - console.* captured into a logs[] buffer, returned with the result.
|
|
15
|
+
* - guest must `export default` a `function(input)` (Val Town semantics).
|
|
16
|
+
* - everything wrapped in try/catch → a guest throw is a structured error.
|
|
17
|
+
*
|
|
18
|
+
* Process-level hardening (memory cap, fs/env/run/ffi denial, scoped net/import)
|
|
19
|
+
* is applied by the SUPERVISOR via Deno CLI flags — see `sandbox-server.ts`.
|
|
20
|
+
*
|
|
21
|
+
* I/O — TWO modes, selected by the request envelope's `bindings` flag:
|
|
22
|
+
*
|
|
23
|
+
* (A) LEGACY compute-only (no bindings): stdin carries ONE envelope; stdout
|
|
24
|
+
* carries ONE result line (prefixed by RESULT_MARKER). Unchanged from M2.
|
|
25
|
+
*
|
|
26
|
+
* (B) BINDINGS path (`bindings:true` in the envelope): the guest gets a
|
|
27
|
+
* capability-scoped `globalThis.questpie` proxy. stdin/stdout become a
|
|
28
|
+
* newline-delimited FRAMED channel (prefixed by FRAME_MARKER):
|
|
29
|
+
* - the FIRST stdin line is the envelope;
|
|
30
|
+
* - subsequent stdin lines are `{type:"rpc-result", id, ...}` replies;
|
|
31
|
+
* - the guest writes `{type:"rpc", id, method, args}` to stdout for each
|
|
32
|
+
* binding call and the FINAL `{type:"result", ...}` when done.
|
|
33
|
+
* The proxy NEVER sees the per-run token or a broker URL — it only writes
|
|
34
|
+
* method+args frames; the SUPERVISOR holds the token and brokers the call
|
|
35
|
+
* over stdio (NOT the network — the broker is unreachable by guest fetch by
|
|
36
|
+
* design: the egress allowlist rejects loopback/private IPs).
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
// @ts-nocheck — Deno runtime file; not part of the Bun/tsc typecheck graph.
|
|
40
|
+
|
|
41
|
+
import { buildGuestBindings } from "./guest-bindings.ts";
|
|
42
|
+
|
|
43
|
+
/** Marker so the supervisor can pick the result line out of guest stdout noise. */
|
|
44
|
+
const RESULT_MARKER = "__QP_SANDBOX_RESULT__";
|
|
45
|
+
/** Marker prefixing each FRAMED protocol message (bindings path). */
|
|
46
|
+
const FRAME_MARKER = "__QP_SANDBOX_MSG__";
|
|
47
|
+
|
|
48
|
+
// ── 1. HARDEN GLOBALS (must happen before importing/eval'ing guest source) ──
|
|
49
|
+
|
|
50
|
+
// Remove nested-Worker capability (orphan-CPU DoS vector).
|
|
51
|
+
try {
|
|
52
|
+
// deno-lint-ignore no-explicit-any
|
|
53
|
+
(globalThis as any).Worker = undefined;
|
|
54
|
+
} catch {
|
|
55
|
+
/* non-configurable in some builds; the no-Worker subprocess flag still helps */
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Remove Spectre timer-amplification primitives.
|
|
59
|
+
for (const name of ["SharedArrayBuffer", "Atomics"]) {
|
|
60
|
+
try {
|
|
61
|
+
// deno-lint-ignore no-explicit-any
|
|
62
|
+
delete (globalThis as any)[name];
|
|
63
|
+
} catch {
|
|
64
|
+
try {
|
|
65
|
+
// deno-lint-ignore no-explicit-any
|
|
66
|
+
(globalThis as any)[name] = undefined;
|
|
67
|
+
} catch {
|
|
68
|
+
/* best effort */
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── 2. CAPTURE CONSOLE ──
|
|
74
|
+
|
|
75
|
+
const logs: string[] = [];
|
|
76
|
+
const MAX_LOGS = 1000;
|
|
77
|
+
const MAX_LOG_LEN = 4000;
|
|
78
|
+
const orig = {
|
|
79
|
+
log: console.log.bind(console),
|
|
80
|
+
error: console.error.bind(console),
|
|
81
|
+
warn: console.warn.bind(console),
|
|
82
|
+
info: console.info.bind(console),
|
|
83
|
+
};
|
|
84
|
+
for (const level of ["log", "error", "warn", "info"] as const) {
|
|
85
|
+
// deno-lint-ignore no-explicit-any
|
|
86
|
+
(console as any)[level] = (...args: unknown[]) => {
|
|
87
|
+
if (logs.length < MAX_LOGS) {
|
|
88
|
+
const line =
|
|
89
|
+
level +
|
|
90
|
+
": " +
|
|
91
|
+
args
|
|
92
|
+
.map((a) => {
|
|
93
|
+
try {
|
|
94
|
+
return typeof a === "string" ? a : JSON.stringify(a);
|
|
95
|
+
} catch {
|
|
96
|
+
return String(a);
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
.join(" ");
|
|
100
|
+
logs.push(line.length > MAX_LOG_LEN ? line.slice(0, MAX_LOG_LEN) + "…" : line);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── 3. STDIN READERS ──
|
|
106
|
+
|
|
107
|
+
const decoder = new TextDecoder();
|
|
108
|
+
const encoder = new TextEncoder();
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A line-buffered stdin reader. Yields complete
|
|
112
|
+
* `\n`-delimited lines as they arrive, keeping stdin OPEN for back-and-forth.
|
|
113
|
+
*/
|
|
114
|
+
function createLineReader() {
|
|
115
|
+
// deno-lint-ignore no-explicit-any
|
|
116
|
+
const stdin = (Deno as any).stdin;
|
|
117
|
+
const buf = new Uint8Array(65536);
|
|
118
|
+
let pending = "";
|
|
119
|
+
const queue: string[] = [];
|
|
120
|
+
let resolveWaiter: ((line: string | null) => void) | null = null;
|
|
121
|
+
let eof = false;
|
|
122
|
+
|
|
123
|
+
async function pump() {
|
|
124
|
+
while (true) {
|
|
125
|
+
const n = await stdin.read(buf);
|
|
126
|
+
if (n === null) {
|
|
127
|
+
eof = true;
|
|
128
|
+
if (pending.length > 0) {
|
|
129
|
+
queue.push(pending);
|
|
130
|
+
pending = "";
|
|
131
|
+
}
|
|
132
|
+
if (resolveWaiter) {
|
|
133
|
+
const r = resolveWaiter;
|
|
134
|
+
resolveWaiter = null;
|
|
135
|
+
r(queue.shift() ?? null);
|
|
136
|
+
}
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
pending += decoder.decode(buf.subarray(0, n), { stream: true });
|
|
140
|
+
let idx: number;
|
|
141
|
+
while ((idx = pending.indexOf("\n")) !== -1) {
|
|
142
|
+
const line = pending.slice(0, idx);
|
|
143
|
+
pending = pending.slice(idx + 1);
|
|
144
|
+
if (resolveWaiter) {
|
|
145
|
+
const r = resolveWaiter;
|
|
146
|
+
resolveWaiter = null;
|
|
147
|
+
r(line);
|
|
148
|
+
} else {
|
|
149
|
+
queue.push(line);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
void pump();
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
/** Next line, or null at EOF. */
|
|
158
|
+
next(): Promise<string | null> {
|
|
159
|
+
if (queue.length > 0) return Promise.resolve(queue.shift() as string);
|
|
160
|
+
if (eof) return Promise.resolve(null);
|
|
161
|
+
return new Promise((resolve) => {
|
|
162
|
+
resolveWaiter = resolve;
|
|
163
|
+
});
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Write one framed message line to stdout. */
|
|
169
|
+
function writeFrame(msg: unknown) {
|
|
170
|
+
let payload: string;
|
|
171
|
+
try {
|
|
172
|
+
payload = JSON.stringify(msg);
|
|
173
|
+
} catch {
|
|
174
|
+
payload = JSON.stringify({ type: "result", ok: false, error: "frame not serializable", logs });
|
|
175
|
+
}
|
|
176
|
+
// deno-lint-ignore no-explicit-any
|
|
177
|
+
(Deno as any).stdout.writeSync(encoder.encode(FRAME_MARKER + payload + "\n"));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Emit the LEGACY single-line result (no bindings). */
|
|
181
|
+
function emitLegacy(result: { ok: boolean; output?: unknown; logs: string[]; error?: string }) {
|
|
182
|
+
let payload: string;
|
|
183
|
+
try {
|
|
184
|
+
payload = JSON.stringify(result);
|
|
185
|
+
} catch {
|
|
186
|
+
payload = JSON.stringify({
|
|
187
|
+
ok: false,
|
|
188
|
+
error: "result not serializable",
|
|
189
|
+
logs: result.logs ?? [],
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
orig.log(RESULT_MARKER + payload);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── 4. IMPORT + INVOKE THE GUEST ──
|
|
196
|
+
|
|
197
|
+
/** Dynamic-import the guest source as a blob module and return its default fn. */
|
|
198
|
+
async function loadGuestEntry(source: string): Promise<(input: unknown) => unknown> {
|
|
199
|
+
const guestUrl = URL.createObjectURL(
|
|
200
|
+
new Blob([source], { type: "application/typescript" }),
|
|
201
|
+
);
|
|
202
|
+
const mod = await import(guestUrl);
|
|
203
|
+
const entry = mod.default;
|
|
204
|
+
if (typeof entry !== "function") {
|
|
205
|
+
throw new Error("guest must 'export default' a function(input)");
|
|
206
|
+
}
|
|
207
|
+
return entry;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
type Envelope = {
|
|
211
|
+
source: string;
|
|
212
|
+
input: unknown;
|
|
213
|
+
secrets?: Record<string, string>;
|
|
214
|
+
/** When true, run the FRAMED bindings protocol and inject `globalThis.questpie`. */
|
|
215
|
+
bindings?: boolean;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
// ── 4b. BROKERED `fetch` SHIM ──
|
|
219
|
+
//
|
|
220
|
+
// The guest has NO direct network (`--allow-net=[]`), so the native `fetch`
|
|
221
|
+
// can't open a socket. We REPLACE `globalThis.fetch` with a shim that relays an
|
|
222
|
+
// `http.fetch` RPC over the SAME framed stdio channel as the `questpie.*` proxy
|
|
223
|
+
// (one `rpc` frame out, one `rpc-result` frame back — no new frame types). The
|
|
224
|
+
// trusted host broker does the SSRF-safe resolve→validate→pin→redirect fetch and
|
|
225
|
+
// returns a buffered, base64, capped `HttpFetchResponse`; we reconstruct a real
|
|
226
|
+
// `Response` from it. A structured broker error rejects as a `TypeError` (so
|
|
227
|
+
// guest code can `try/catch` a brokered fetch just like a native one).
|
|
228
|
+
|
|
229
|
+
/** Encode raw bytes to base64 (works for arbitrary binary; no Buffer in Deno). */
|
|
230
|
+
function bytesToBase64(bytes: Uint8Array): string {
|
|
231
|
+
let binary = "";
|
|
232
|
+
const chunk = 0x8000; // chunk to avoid arg-count limits on String.fromCharCode
|
|
233
|
+
for (let i = 0; i < bytes.length; i += chunk) {
|
|
234
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
|
235
|
+
}
|
|
236
|
+
return btoa(binary);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Decode base64 to raw bytes (inverse of {@link bytesToBase64}). */
|
|
240
|
+
function base64ToBytes(b64: string): Uint8Array {
|
|
241
|
+
const binary = atob(b64);
|
|
242
|
+
const out = new Uint8Array(binary.length);
|
|
243
|
+
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Install a `globalThis.fetch` that relays through `hostCall("http.fetch", …)`.
|
|
249
|
+
* Accepts the standard `(input, init)` signature; normalizes via the web
|
|
250
|
+
* `Request` constructor so url/method/headers/body are parsed exactly like a real
|
|
251
|
+
* fetch (any body shape → bytes), then frames the {@link HttpFetchRequest} and
|
|
252
|
+
* rebuilds a `Response` from the {@link HttpFetchResponse}.
|
|
253
|
+
*/
|
|
254
|
+
function installFetchShim(hostCall: (method: string, args: unknown) => Promise<unknown>) {
|
|
255
|
+
const brokeredFetch = async (
|
|
256
|
+
input: unknown,
|
|
257
|
+
init?: unknown,
|
|
258
|
+
): Promise<Response> => {
|
|
259
|
+
// Normalize the (input, init) pair into a concrete request via the web
|
|
260
|
+
// Request API (handles string/URL/Request input + every body encoding).
|
|
261
|
+
let req: Request;
|
|
262
|
+
try {
|
|
263
|
+
req = new Request(input as never, init as never);
|
|
264
|
+
} catch (e) {
|
|
265
|
+
throw new TypeError(
|
|
266
|
+
`Failed to construct fetch request: ${e instanceof Error ? e.message : String(e)}`,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const headers: Record<string, string> = {};
|
|
271
|
+
req.headers.forEach((value: string, key: string) => {
|
|
272
|
+
headers[key] = value;
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// Read the (already-buffered) request body as bytes → base64. GET/HEAD have
|
|
276
|
+
// no body; `arrayBuffer()` yields an empty buffer which we omit.
|
|
277
|
+
let bodyBase64: string | undefined;
|
|
278
|
+
const buf = await req.arrayBuffer();
|
|
279
|
+
if (buf.byteLength > 0) bodyBase64 = bytesToBase64(new Uint8Array(buf));
|
|
280
|
+
|
|
281
|
+
const request: HttpFetchRequest = {
|
|
282
|
+
url: req.url,
|
|
283
|
+
method: req.method,
|
|
284
|
+
headers,
|
|
285
|
+
...(bodyBase64 !== undefined ? { bodyBase64 } : {}),
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
// Relay over the existing stdio RPC channel. A structured broker error
|
|
289
|
+
// (network failure / blocked target / oversize) arrives as a rejected
|
|
290
|
+
// hostCall — surface it as a fetch-like TypeError, NOT a fake 5xx Response.
|
|
291
|
+
let value: HttpFetchResponse;
|
|
292
|
+
try {
|
|
293
|
+
value = (await hostCall("http.fetch", request)) as HttpFetchResponse;
|
|
294
|
+
} catch (e) {
|
|
295
|
+
throw new TypeError(e instanceof Error ? e.message : String(e));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const status = typeof value?.status === "number" ? value.status : 0;
|
|
299
|
+
const body = value?.bodyBase64 ? base64ToBytes(value.bodyBase64) : undefined;
|
|
300
|
+
// `Response` forbids a body for 101/204/205/304 and for null-body statuses;
|
|
301
|
+
// guard so reconstruction never throws on those.
|
|
302
|
+
const nullBody = status === 204 || status === 205 || status === 304 || status < 200;
|
|
303
|
+
return new Response(nullBody ? null : (body ?? null), {
|
|
304
|
+
status,
|
|
305
|
+
statusText: typeof value?.statusText === "string" ? value.statusText : "",
|
|
306
|
+
headers: value?.headers ?? {},
|
|
307
|
+
});
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
// deno-lint-ignore no-explicit-any
|
|
311
|
+
(globalThis as any).fetch = brokeredFetch;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Wire types for the brokered `http.fetch` RPC (mirrors host `protocol.ts`). */
|
|
315
|
+
interface HttpFetchRequest {
|
|
316
|
+
url: string;
|
|
317
|
+
method?: string;
|
|
318
|
+
headers?: Record<string, string>;
|
|
319
|
+
bodyBase64?: string;
|
|
320
|
+
}
|
|
321
|
+
interface HttpFetchResponse {
|
|
322
|
+
status: number;
|
|
323
|
+
statusText: string;
|
|
324
|
+
headers: Record<string, string>;
|
|
325
|
+
bodyBase64: string;
|
|
326
|
+
truncated: boolean;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** BINDINGS run — framed channel + `globalThis.questpie` proxy. */
|
|
330
|
+
async function runWithBindings(envelope: Envelope, reader: { next(): Promise<string | null> }) {
|
|
331
|
+
// Pending host calls keyed by correlation id.
|
|
332
|
+
const pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: unknown) => void }>();
|
|
333
|
+
let nextId = 1;
|
|
334
|
+
|
|
335
|
+
// The injected transport: write an `rpc` frame, await its `rpc-result`.
|
|
336
|
+
const hostCall = (method: string, args: unknown): Promise<unknown> => {
|
|
337
|
+
const id = nextId++;
|
|
338
|
+
return new Promise<unknown>((resolve, reject) => {
|
|
339
|
+
pending.set(id, { resolve, reject });
|
|
340
|
+
writeFrame({ type: "rpc", id, method, args });
|
|
341
|
+
});
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
// Background loop: consume `rpc-result` frames and settle pending calls. It
|
|
345
|
+
// runs until stdin EOF (the supervisor closes it after the run ends).
|
|
346
|
+
const drain = (async () => {
|
|
347
|
+
while (true) {
|
|
348
|
+
const line = await reader.next();
|
|
349
|
+
if (line === null) break;
|
|
350
|
+
if (line.length === 0) continue;
|
|
351
|
+
const json = line.startsWith(FRAME_MARKER) ? line.slice(FRAME_MARKER.length) : line;
|
|
352
|
+
let msg: { type?: string; id?: number; ok?: boolean; value?: unknown; error?: { message?: string } };
|
|
353
|
+
try {
|
|
354
|
+
msg = JSON.parse(json);
|
|
355
|
+
} catch {
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
if (msg?.type === "rpc-result" && typeof msg.id === "number") {
|
|
359
|
+
const p = pending.get(msg.id);
|
|
360
|
+
if (!p) continue;
|
|
361
|
+
pending.delete(msg.id);
|
|
362
|
+
if (msg.ok) {
|
|
363
|
+
p.resolve(msg.value);
|
|
364
|
+
} else {
|
|
365
|
+
const m = msg.error?.message ?? "binding call rejected";
|
|
366
|
+
p.reject(new Error(m));
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
// Stdin closed: reject any still-pending calls so the guest can't hang.
|
|
371
|
+
for (const [, p] of pending) p.reject(new Error("host channel closed"));
|
|
372
|
+
pending.clear();
|
|
373
|
+
})();
|
|
374
|
+
|
|
375
|
+
try {
|
|
376
|
+
// deno-lint-ignore no-explicit-any
|
|
377
|
+
(globalThis as any).__secrets = envelope.secrets ?? {};
|
|
378
|
+
// deno-lint-ignore no-explicit-any
|
|
379
|
+
(globalThis as any).questpie = buildGuestBindings(hostCall);
|
|
380
|
+
// Replace native fetch with the brokered shim (guest has no direct net).
|
|
381
|
+
installFetchShim(hostCall);
|
|
382
|
+
} catch {
|
|
383
|
+
/* noop */
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
try {
|
|
387
|
+
const entry = await loadGuestEntry(envelope.source);
|
|
388
|
+
const output = await entry(envelope.input);
|
|
389
|
+
writeFrame({ type: "result", ok: true, output, logs });
|
|
390
|
+
} catch (err) {
|
|
391
|
+
writeFrame({
|
|
392
|
+
type: "result",
|
|
393
|
+
ok: false,
|
|
394
|
+
error: err instanceof Error ? err.message : String(err),
|
|
395
|
+
logs,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
// Surface unexpected drain failures (best effort; the result is already out).
|
|
399
|
+
void drain.catch(() => {});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async function main() {
|
|
403
|
+
// Peek the envelope to choose the protocol. For the bindings path we need a
|
|
404
|
+
// line reader (stdin stays open); for legacy we read all of stdin. We always
|
|
405
|
+
// read the FIRST line via the line reader, then branch.
|
|
406
|
+
const reader = createLineReader();
|
|
407
|
+
const firstLine = await reader.next();
|
|
408
|
+
if (firstLine === null) {
|
|
409
|
+
emitLegacy({ ok: false, error: "empty request envelope", logs });
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
let envelope: Envelope;
|
|
413
|
+
try {
|
|
414
|
+
const json = firstLine.startsWith(FRAME_MARKER)
|
|
415
|
+
? firstLine.slice(FRAME_MARKER.length)
|
|
416
|
+
: firstLine;
|
|
417
|
+
envelope = JSON.parse(json);
|
|
418
|
+
} catch {
|
|
419
|
+
emitLegacy({ ok: false, error: "failed to read/parse request envelope", logs });
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (envelope.bindings) {
|
|
424
|
+
await runWithBindings(envelope, reader);
|
|
425
|
+
} else {
|
|
426
|
+
// Legacy path: the envelope was the whole stdin (single line). Run it.
|
|
427
|
+
try {
|
|
428
|
+
// deno-lint-ignore no-explicit-any
|
|
429
|
+
(globalThis as any).__secrets = envelope.secrets ?? {};
|
|
430
|
+
} catch {
|
|
431
|
+
/* noop */
|
|
432
|
+
}
|
|
433
|
+
try {
|
|
434
|
+
const entry = await loadGuestEntry(envelope.source);
|
|
435
|
+
const output = await entry(envelope.input);
|
|
436
|
+
emitLegacy({ ok: true, output, logs });
|
|
437
|
+
} catch (err) {
|
|
438
|
+
emitLegacy({ ok: false, error: err instanceof Error ? err.message : String(err), logs });
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
await main();
|
|
444
|
+
// Ensure the process exits promptly even if the guest left timers/handles open.
|
|
445
|
+
// deno-lint-ignore no-explicit-any
|
|
446
|
+
(Deno as any).exit(0);
|