sandboxedjs 0.1.72 → 0.1.73

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 CHANGED
@@ -22,6 +22,27 @@ await box.exec("python3 -c 'print(2**64)'"); // → 18446744073709551616
22
22
  box.dispose();
23
23
  ```
24
24
 
25
+ ## Python packages with compiled extensions
26
+
27
+ `pip install` works for packages with C, Cython, Rust or Meson extensions. A
28
+ wheel built for this runtime is used when one exists; otherwise one is built
29
+ from source, from a recipe generated out of what PyPI and the package already
30
+ state.
31
+
32
+ ```js
33
+ configurePython({ buildFromSource: true }); // build here (Node)
34
+ configurePython({ buildFromSource: "http://localhost:4180/build" }); // ask a builder
35
+ ```
36
+
37
+ Browsers have no compiler, so they ask a machine that does:
38
+
39
+ ```bash
40
+ npx sandboxedjs-build-wheels 4180
41
+ ```
42
+
43
+ See `docs/python/build-on-miss.md`, and `docs/python/compatibility.md` for what
44
+ has been built and tested.
45
+
25
46
  ## Why
26
47
 
27
48
  Sometimes you need to run untrusted or generated code, give an AI agent a shell, build a
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * A wheel index that builds what it does not have.
4
+ *
5
+ * A browser cannot compile a C extension and never will, so "build it from
6
+ * source" there can only mean "ask a machine that can". This is that machine,
7
+ * reduced to the smallest thing that works: it serves the wheel index over
8
+ * HTTP, and it builds a package on request using the same pipeline and the
9
+ * same ABI contract as everything else.
10
+ *
11
+ * sandboxedjs-build-wheels [port=4180]
12
+ *
13
+ * Point a host at it:
14
+ *
15
+ * configurePython({
16
+ * wheelIndex: "http://localhost:4180",
17
+ * buildFromSource: "http://localhost:4180/build",
18
+ * });
19
+ *
20
+ * It binds loopback only. Building runs a package's own build system, which is
21
+ * arbitrary code execution by design, so exposing this to a network would be
22
+ * handing that to whoever can reach it.
23
+ */
24
+
25
+ import { createServer } from "node:http";
26
+ import { existsSync, readFileSync } from "node:fs";
27
+ import { dirname, join, resolve } from "node:path";
28
+ import { fileURLToPath } from "node:url";
29
+ import { spawn } from "node:child_process";
30
+
31
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), "../python-runtime");
32
+ const wheels = join(root, "out/wheels");
33
+
34
+ if (!existsSync(join(root, "scripts/build_extension.py"))) {
35
+ console.error("sandboxedjs-build-wheels: the build pipeline is only present in a checkout");
36
+ process.exit(1);
37
+ }
38
+
39
+ const port = Number(process.argv[2] ?? 4180);
40
+ const building = new Map();
41
+ const finished = new Map();
42
+
43
+ const run = (args) => new Promise((done) => {
44
+ const child = spawn("python3", args, { cwd: root });
45
+ let stdout = "", stderr = "";
46
+ child.stdout.on("data", (c) => { stdout += String(c); });
47
+ child.stderr.on("data", (c) => { stderr += String(c); });
48
+ child.on("error", (e) => done({ code: -1, stdout, stderr: String(e) }));
49
+ child.on("close", (code) => done({ code: code ?? -1, stdout, stderr }));
50
+ });
51
+
52
+ async function build(requirement) {
53
+ const recipe = await run(["scripts/auto_recipe.py", requirement]);
54
+ if (recipe.code !== 0) {
55
+ let reported = {};
56
+ try { reported = JSON.parse(recipe.stdout.trim().split("\n").at(-1) ?? "{}"); } catch {}
57
+ return {
58
+ built: false,
59
+ classification: reported.classification ?? "blocked-toolchain",
60
+ reason: reported.reason ?? recipe.stderr.trim().slice(-400),
61
+ };
62
+ }
63
+ const path = recipe.stdout.trim().split("\n").at(-1);
64
+ const made = await run(["scripts/build_extension.py", path]);
65
+ if (made.code !== 0) {
66
+ return {
67
+ built: false, classification: "blocked-toolchain",
68
+ reason: `${made.stdout}\n${made.stderr}`.trim().split("\n").slice(-12).join("\n"),
69
+ };
70
+ }
71
+ const indexed = await run(["scripts/build_index.py"]);
72
+ if (indexed.code !== 0) {
73
+ return { built: false, classification: "blocked-toolchain", reason: indexed.stderr.slice(-300) };
74
+ }
75
+ return { built: true, classification: "supported-generic" };
76
+ }
77
+
78
+ const server = createServer(async (request, response) => {
79
+ const cors = {
80
+ "access-control-allow-origin": request.headers.origin ?? "*",
81
+ "access-control-allow-headers": "content-type",
82
+ "access-control-allow-methods": "GET,POST,OPTIONS",
83
+ };
84
+ if (request.method === "OPTIONS") { response.writeHead(204, cors).end(); return; }
85
+
86
+ const path = decodeURIComponent(new URL(request.url ?? "/", "http://localhost").pathname);
87
+
88
+ if (request.method === "POST" && path === "/build") {
89
+ const body = await new Promise((done) => {
90
+ let text = ""; request.on("data", (c) => { text += c; }); request.on("end", () => done(text));
91
+ });
92
+ let requirement = "";
93
+ try { requirement = String(JSON.parse(body || "{}").requirement ?? ""); } catch {}
94
+ /* Only a package name and optional pin. Anything else would be handed to a
95
+ * shell-adjacent build as an argument. */
96
+ if (!/^[A-Za-z0-9._-]+(==[A-Za-z0-9._+-]+)?$/.test(requirement)) {
97
+ response.writeHead(400, { ...cors, "content-type": "application/json" })
98
+ .end(JSON.stringify({ built: false, reason: "invalid requirement" }));
99
+ return;
100
+ }
101
+ /* Submit and poll, never one long request. A build takes minutes and an
102
+ * HTTP client -- especially one inside a sandboxed worker -- will drop a
103
+ * connection held open that long, which looks to the caller like the
104
+ * service being unreachable while it is in fact working. This is why the
105
+ * protocol in docs/python/build-on-miss.md has states rather than a single
106
+ * blocking call.
107
+ *
108
+ * One build per package: two identical requests are the same build. */
109
+ if (!building.has(requirement) && !finished.has(requirement)) {
110
+ console.log(`building ${requirement}`);
111
+ building.set(requirement, build(requirement).then((verdict) => {
112
+ console.log(` ${requirement}: ${verdict.built ? "built" : verdict.classification}`);
113
+ finished.set(requirement, verdict);
114
+ building.delete(requirement);
115
+ return verdict;
116
+ }));
117
+ }
118
+ const done = finished.get(requirement);
119
+ const verdict = done
120
+ ? { ...done, state: done.built ? "published" : "failed" }
121
+ : { state: "building-wheel" };
122
+ response.writeHead(200, { ...cors, "content-type": "application/json" })
123
+ .end(JSON.stringify(verdict));
124
+ return;
125
+ }
126
+
127
+ const name = path === "/" ? "/index.json" : path;
128
+ const file = join(wheels, name);
129
+ if (!file.startsWith(wheels) || !existsSync(file)) {
130
+ response.writeHead(404, cors).end("not found");
131
+ return;
132
+ }
133
+ response.writeHead(200, {
134
+ ...cors,
135
+ "content-type": name.endsWith(".json") ? "application/json" : "application/octet-stream",
136
+ }).end(readFileSync(file));
137
+ });
138
+
139
+ server.listen(port, "127.0.0.1", () => {
140
+ console.log(`SandboxedJs wheel builder: http://127.0.0.1:${port}`);
141
+ console.log(` index http://127.0.0.1:${port}/index.json`);
142
+ console.log(` build POST http://127.0.0.1:${port}/build {"requirement":"numpy"}`);
143
+ });
package/dist/agent.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { C as Container } from './container-BwXE2J26.cjs';
1
+ import { C as Container } from './container-BxEoVOmX.cjs';
2
2
  import './contracts-CVgctitO.cjs';
3
3
 
4
4
  /**
package/dist/agent.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { C as Container } from './container-WPf_A76-.js';
1
+ import { C as Container } from './container-B5X6stfx.js';
2
2
  import './contracts-CVgctitO.js';
3
3
 
4
4
  /**
@@ -1238,6 +1238,14 @@ interface PythonOptions {
1238
1238
  * no published wheel targets wasm32-emscripten.
1239
1239
  */
1240
1240
  wheelIndex?: WheelIndex | string | null;
1241
+ /**
1242
+ * Build a missing wheel instead of reporting it.
1243
+ *
1244
+ * `true` builds locally, which needs Node and the build pipeline. A URL asks
1245
+ * a build service -- which is how a browser gets a wheel built, having no
1246
+ * compiler of its own. See docs/python/build-on-miss.md.
1247
+ */
1248
+ buildFromSource?: boolean | string;
1241
1249
  }
1242
1250
  declare function configurePython(options?: PythonOptions): void;
1243
1251
  declare const isPythonAvailable: typeof isCPythonAvailable;
@@ -1238,6 +1238,14 @@ interface PythonOptions {
1238
1238
  * no published wheel targets wasm32-emscripten.
1239
1239
  */
1240
1240
  wheelIndex?: WheelIndex | string | null;
1241
+ /**
1242
+ * Build a missing wheel instead of reporting it.
1243
+ *
1244
+ * `true` builds locally, which needs Node and the build pipeline. A URL asks
1245
+ * a build service -- which is how a browser gets a wheel built, having no
1246
+ * compiler of its own. See docs/python/build-on-miss.md.
1247
+ */
1248
+ buildFromSource?: boolean | string;
1241
1249
  }
1242
1250
  declare function configurePython(options?: PythonOptions): void;
1243
1251
  declare const isPythonAvailable: typeof isCPythonAvailable;