@pooriaarab/foxden 0.0.0-stage → 0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pooria Arab
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,296 @@
1
- # Temporary Holding Version
1
+ # foxden
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A sandbox in a browser tab that runs Python and shell with no network.
4
+
5
+ foxden gives each "den" its own folder of files and its own Python. The Python
6
+ is [Pyodide](https://pyodide.org) (CPython compiled to WebAssembly). It runs in
7
+ a worker inside a sandboxed page. That page has an opaque origin and the CSP
8
+ `connect-src 'none'`, so code in it cannot reach any server. You put files in
9
+ `/drop`, run code, and take the results from `/out`.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npm i @pooriaarab/foxden
15
+ ```
16
+
17
+ The npm package is `@pooriaarab/foxden`: npm refuses the plain name `foxden` as too similar to an existing package (`boxen`).
18
+
19
+ foxden installs `pyodide` 314.0.7 next to it. Your page must serve two
20
+ folders: the sandbox page from foxden, and three files from Pyodide. Copy them
21
+ when you build:
22
+
23
+ ```bash
24
+ mkdir -p public/foxden/pyodide
25
+ cp -r node_modules/@pooriaarab/foxden/dist/den public/foxden/
26
+ cp node_modules/pyodide/pyodide.asm.wasm node_modules/pyodide/python_stdlib.zip node_modules/pyodide/pyodide-lock.json public/foxden/pyodide/
27
+ ```
28
+
29
+ Use the Pyodide files of version 314.0.7. The loader inside `den.js` is that
30
+ version, and files from another version do not work with it. With pnpm,
31
+ `node_modules/pyodide` exists only when you also run `pnpm add pyodide@314.0.7`.
32
+
33
+ ## Example
34
+
35
+ ```js
36
+ import { idbStore, iframeRuntime, openDen } from "@pooriaarab/foxden";
37
+
38
+ const den = await openDen({
39
+ name: "notes",
40
+ store: idbStore(),
41
+ runtime: iframeRuntime({ denUrl: "/foxden/den/den.html", pyodideUrl: "/foxden/pyodide/" }),
42
+ });
43
+
44
+ await den.writeFile("/drop/sales.csv", "region,amount\nnorth,10\nsouth,20\n");
45
+ const run = await den.run(
46
+ `
47
+ import csv
48
+ rows = list(csv.DictReader(open("/drop/sales.csv")))
49
+ sum(float(row["amount"]) for row in rows)
50
+ `,
51
+ { timeoutMs: 5000 },
52
+ );
53
+ console.log(run.result); // 30.0
54
+
55
+ const sh = await den.sh("grep north /drop/sales.csv");
56
+ console.log(sh.stdout); // north,10
57
+ ```
58
+
59
+ This runs in a browser page (through a bundler) and in a Firefox extension
60
+ page. It does not run in Node, because it needs an iframe.
61
+
62
+ ## Use cases
63
+
64
+ | Who | What they build | How foxden helps |
65
+ |---|---|---|
66
+ | A person with a private spreadsheet | A tab that sums, filters, or cleans a CSV | The file stays in the browser. Code cannot send it anywhere, because the sandbox has no network. |
67
+ | A developer of a local AI agent | A "code interpreter" tool for the agent | The agent writes Python. foxden runs it with a time limit and returns stdout, stderr, the result, and the changed files. Model-written code never runs in the extension itself. |
68
+ | A teacher | A Python lesson page that needs no install and no server | Each student gets a den in their own tab. A loop that never ends stops at the time limit. |
69
+ | A documentation team | "Try it" boxes on a docs site | The reader's device runs the examples. The site needs only static files, no code-execution server. |
70
+ | A person who must convert a file | A page that turns JSON into CSV, or splits a large text file | The conversion runs locally. There is no need to upload the file to an unknown converter site. |
71
+ | A security analyst | A place to open a suspicious text, CSV, or log file offline | Code that parses the file has no network and no extension API. A snapshot keeps the file set for later. |
72
+ | A website owner | Heavy work on the visitor's device ("bring your own compute") | The site serves the sandbox page and Pyodide. The visitor's CPU does the work, and the data stays with the visitor. |
73
+
74
+ ## How it works
75
+
76
+ The page that opens a den (the host) owns the files. For each run, it sends
77
+ the code and a copy of the files to the sandbox page. The sandbox page sends
78
+ back the output and the files that changed. The host checks every path and
79
+ saves the files in its store.
80
+
81
+ ```mermaid
82
+ flowchart LR
83
+ subgraph host["Host page: extension page or website (has network)"]
84
+ api["openDen()<br/>den.run / den.sh / writeFile"]
85
+ store[("idbStore<br/>IndexedDB")]
86
+ files["Pyodide files<br/>wasm + stdlib zip"]
87
+ end
88
+ subgraph sandbox["den.html: opaque origin, CSP connect-src 'none'"]
89
+ page["den.js<br/>timeout watchdog"]
90
+ worker["module worker<br/>Pyodide (one thread)"]
91
+ end
92
+ api <-- "postMessage over a MessageChannel<br/>typed messages, each one checked" --> page
93
+ files -- "sent once, as bytes" --> page
94
+ page -- "start, run, terminate" --> worker
95
+ api <--> store
96
+ ```
97
+
98
+ A run moves files from `/drop` to `/out`:
99
+
100
+ ```mermaid
101
+ sequenceDiagram
102
+ participant U as User
103
+ participant H as Host (foxden)
104
+ participant S as Sandbox page
105
+ participant W as Pyodide worker
106
+ U->>H: drop sales.csv
107
+ H->>H: writeFile("/drop/sales.csv"), save to IndexedDB
108
+ U->>H: den.run(code, { timeoutMs })
109
+ H->>S: run: code + every den file
110
+ S->>W: write files to /drop, /out, /work
111
+ W->>W: run Python (no network)
112
+ W-->>S: stdout, stderr, repr of result, changed files
113
+ S-->>H: reply (or "timeout" after it kills the worker)
114
+ H->>H: check paths, apply changes to /out, save
115
+ H-->>U: result, and /out/sums.csv to download
116
+ ```
117
+
118
+ The sandbox page is isolated in one of two ways:
119
+
120
+ - **`manifest-sandbox`** (Firefox 154 and later, in an extension). The
121
+ extension lists `den.html` in the manifest
122
+ [`sandbox`](https://developer.mozilla.org/en-US/docs/Mozilla/Firefox/Releases/154)
123
+ key, with its own CSP. The host loads it in a plain iframe. The E2E test
124
+ checks this mode on Firefox 157.
125
+ - **`iframe-sandbox`** (websites, and Firefox 153). The host loads `den.html`
126
+ in an iframe with `sandbox="allow-scripts"`. The `<meta>` CSP in `den.html`
127
+ blocks the network.
128
+
129
+ `iframeRuntime` picks the mode. Before it loads Pyodide, `den.html` checks that
130
+ its origin is opaque and that it has no extension API. If the check fails, it
131
+ refuses to start, and `iframeRuntime` tries `iframe-sandbox` instead.
132
+
133
+ What we found in Firefox 157 while we built this: the manifest sandbox CSP
134
+ applies only when the iframe has no `sandbox` attribute. With the attribute,
135
+ Firefox did not apply the manifest CSP, and `fetch`, `XMLHttpRequest` and
136
+ `WebSocket` calls from the page reached a local probe server. foxden
137
+ therefore keeps the `<meta>` CSP in `den.html` in every mode.
138
+
139
+ When a run passes `timeoutMs`, `den.html` terminates the worker and starts a
140
+ new one. Files survive, because the host owns them. Python variables do not.
141
+ If the sandbox page itself stops answering, the host removes the iframe and
142
+ starts a new runtime.
143
+
144
+ ## What "shell" means here
145
+
146
+ `den.sh()` is not bash, and it does not run in the sandbox. It is a small
147
+ command language that reads the den files in the host page. It has `ls`,
148
+ `cat`, `head [-n N]`, `wc [-l|-w|-c]`, `grep [-i|-v|-c|-n]` and `echo`, joined
149
+ by `|`, with single and double quotes. `grep` matches plain text, not regular
150
+ expressions. Any other syntax (`;`, `&&`, `>`, `$`, `*`) gives exit code 2
151
+ and runs nothing. To write files, use Python.
152
+
153
+ ## API
154
+
155
+ ```ts
156
+ import { openDen, iframeRuntime, idbStore, memoryStore, runShell, normalizePath,
157
+ encodeSnapshot, decodeSnapshot } from "@pooriaarab/foxden";
158
+ ```
159
+
160
+ | Name | What it does |
161
+ |---|---|
162
+ | `openDen({ name, runtime, store?, snapshot?, maxDenBytes?, killGraceMs? })` | Opens a den and starts its runtime. `name` is 1-64 ASCII letters, digits, `-` or `_`. Rejects with `DenLoadError` when the runtime does not start. One name can be open once per page. |
163
+ | `den.run(code, { timeoutMs?, maxOutputBytes? })` | Runs Python. Returns `{ stdout, stderr, result, error, truncated, files, durationMs }`. `result` is the `repr()` of the last expression, or `null`. `error.kind` is `python`, `timeout`, `crashed`, or `storage`. Defaults: 30 s, 1 MiB. |
164
+ | `den.sh(command)` | Runs the shell above. Returns `{ stdout, stderr, code, truncated }`. |
165
+ | `den.writeFile(path, data)` / `den.readFile(path)` / `den.deleteFile(path)` / `den.list()` | Files under `/drop`, `/out`, and `/work`. A path with `..` throws `PathError`. |
166
+ | `den.snapshot()` / `den.restore(bytes)` | Packs all files into bytes with a SHA-256 for each file, and reads them back. Corrupt bytes throw `SnapshotError` and change nothing. Python variables are not in a snapshot. |
167
+ | `den.fork({ name })` | Opens a new den with a copy of the files. |
168
+ | `den.close()` | Removes the iframe and frees the name. |
169
+ | `den.info` | `{ kind, isolation, loadMs }` of the current runtime. |
170
+ | `iframeRuntime({ denUrl, pyodideUrl, isolation?, loadTimeoutMs?, container? })` | The default runtime. `isolation` is `"auto"`, `"manifest-sandbox"`, or `"iframe-sandbox"`. |
171
+ | `idbStore(dbName?)` / `memoryStore()` | Where the files live between page loads. The default is `memoryStore()`. |
172
+
173
+ `runtime` is an adapter: any object with `start()`, `run(request)`, and
174
+ `close()` can replace `iframeRuntime`. We checked
175
+ [BrowserPod](https://browserpod.io) as a second adapter. Its npm package is
176
+ proprietary ("All Rights Reserved"), so foxden does not include it.
177
+
178
+ Errors have a stable `name`: `PathError`, `SnapshotError`, `DenError`,
179
+ `DenLoadError`. foxden has no CLI and no MCP server.
180
+
181
+ ### Use it in an extension
182
+
183
+ Copy `den/` and the Pyodide files into your extension, then add this to
184
+ `manifest.json`:
185
+
186
+ ```json
187
+ {
188
+ "sandbox": { "pages": ["den/den.html"] },
189
+ "content_security_policy": {
190
+ "extension_pages": "script-src 'self'; object-src 'none'",
191
+ "sandbox": "sandbox allow-scripts; default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; worker-src blob:; connect-src 'none'"
192
+ }
193
+ }
194
+ ```
195
+
196
+ `worker-src blob:` is needed because a page with an opaque origin cannot
197
+ start a worker from a URL. `den.js` starts it from a string that is part of
198
+ the file. `web-ext lint` warns about this CSP, and about the `sandbox` key
199
+ when `strict_min_version` is below 154. See `scripts/lint-ext.mjs`.
200
+
201
+ ### Use it on a website
202
+
203
+ Serve `den/` and the Pyodide files from your site, and call `iframeRuntime`
204
+ with their URLs. foxden uses `iframe-sandbox` there. Serve
205
+ `pyodide.asm.wasm` with any content type: the host reads it as bytes.
206
+
207
+ ## Demo extension
208
+
209
+ `extension/` holds the demo. Its toolbar button opens the Space tab. Drop a
210
+ CSV, press Run, and the sample code counts the rows, sums each number column,
211
+ and writes `/out/sums.csv` for download. The badge says "Network: off".
212
+
213
+ ```bash
214
+ pnpm install
215
+ pnpm build && pnpm build:ext # writes dist-ext/ (about 15 MB)
216
+ pnpm e2e # the extension in real Firefox
217
+ pnpm e2e:web # the website case in real Firefox
218
+ ```
219
+
220
+ Both E2E tests send all remote traffic to a dead proxy, so Pyodide must load
221
+ from local files. They try `fetch`, `XMLHttpRequest`, `WebSocket`,
222
+ `EventSource`, `import()`, and a nested worker against a local probe server,
223
+ and expect zero requests. They write `artifacts/e2e-<date>.json` and
224
+ `artifacts/e2e-web-<date>.json`. On an Apple silicon Mac with Firefox 157,
225
+ Pyodide loads in about 1.5 to 2.5 seconds.
226
+
227
+ ## Firefox APIs used
228
+
229
+ | API | Why |
230
+ |---|---|
231
+ | [`sandbox` manifest key](https://developer.mozilla.org/en-US/docs/Mozilla/Firefox/Releases/154) (Firefox 154) | An extension page with an opaque origin and no extension API. |
232
+ | [`content_security_policy`](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/content_security_policy) (`sandbox`) | `connect-src 'none'` and `worker-src blob:` for the sandbox page. |
233
+ | [`<iframe sandbox>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/iframe#sandbox) | The isolation on websites and on Firefox 153. |
234
+ | [CSP `connect-src`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/connect-src) in a `<meta>` tag | Blocks the network in every mode. A worker made from a blob URL gets the same CSP. |
235
+ | [`Worker`](https://developer.mozilla.org/en-US/docs/Web/API/Worker/Worker) (`type: "module"`) and [`terminate()`](https://developer.mozilla.org/en-US/docs/Web/API/Worker/terminate) | Runs Pyodide off the page thread, and kills code that runs too long. |
236
+ | [`URL.createObjectURL`](https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL_static) | The worker script, and the download links for `/out` files. |
237
+ | [`MessageChannel`](https://developer.mozilla.org/en-US/docs/Web/API/MessageChannel) and [`postMessage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage) | The only link between the host and the sandbox page. |
238
+ | [WebAssembly](https://developer.mozilla.org/en-US/docs/WebAssembly) and `'wasm-unsafe-eval'` | Runs Pyodide. One thread only. |
239
+ | [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) | `idbStore` keeps den files across reloads. |
240
+ | [`SubtleCrypto.digest`](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest) | The SHA-256 of each file in a snapshot. |
241
+ | [`runtime.getManifest`](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/runtime/getManifest) | `iframeRuntime` checks if `den.html` is in the `sandbox` key. |
242
+ | [`action.onClicked`](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/action/onClicked) and [`tabs.create`](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/create) | The demo opens the Space tab. |
243
+ | [Drag and drop](https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API) and `<input type="file">` | The demo fills `/drop`. |
244
+ | [`unlimitedStorage`](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/permissions) | Firefox does not evict the demo's files. |
245
+
246
+ foxden does not use `SharedArrayBuffer` or WebAssembly threads. Firefox does
247
+ not give them to extension pages. It does not use OPFS or JSPI yet.
248
+
249
+ ## Limits
250
+
251
+ - Python runs on one thread, at WebAssembly speed. Heavy number work is
252
+ slower than native Python.
253
+ - The download is large: about 12 MB of Pyodide files, plus 1.6 MB for
254
+ `den.js`. Each den loads its own copy of Pyodide, in 1.5 to 2.5 seconds.
255
+ - Only the Python standard library is there. pandas and NumPy are not
256
+ bundled, and the sandbox cannot download packages.
257
+ - A timeout kills the worker, so Python variables are lost. Files stay.
258
+ - When a run ends, foxden cancels the asyncio tasks that the code started.
259
+ If a task or a JavaScript timer is still alive 200 ms later, the worker
260
+ restarts, and Python variables are lost. Files stay.
261
+ - Snapshots hold files only, not Python variables.
262
+ - Each run copies every den file to the sandbox and back. Very large dens
263
+ are slow. The default cap is 256 MiB per den.
264
+ - Two pages that open the same den name at once both write to IndexedDB. The
265
+ last save wins.
266
+ - The AMO policy says add-ons must not "load remote code for execution". It
267
+ does not say if Python that a user or a model writes, run in a bundled
268
+ interpreter, counts. This question is open. foxden bundles Pyodide and
269
+ runs all such code only in the sandbox page.
270
+ - The `iframe-sandbox` fallback for Firefox 153 is tested on Firefox 157 with
271
+ the attribute (the website E2E test), not on a real Firefox 153.
272
+ - The memory limit is the WebAssembly limit (4 GB). Code that asks for more
273
+ gets a `MemoryError`, but it can use that much RAM before it does.
274
+ - Only Firefox runs the E2E tests. The package is not on npm yet.
275
+
276
+ ## Part of the fox primitives
277
+
278
+ ```mermaid
279
+ graph LR
280
+ foxden --> foxmate
281
+ foxden -. "planned: a tool" .-> foxloop
282
+ foxden -. "planned: over MCP" .-> foxbridge
283
+ click foxden "https://github.com/pooriaarab/foxden"
284
+ click foxmate "https://github.com/pooriaarab/foxmate"
285
+ click foxloop "https://github.com/pooriaarab/foxloop"
286
+ click foxbridge "https://github.com/pooriaarab/foxbridge"
287
+ ```
288
+
289
+ foxden depends on no other fox repo.
290
+ [foxmate](https://github.com/pooriaarab/foxmate) will use it as its code
291
+ sandbox. [foxloop](https://github.com/pooriaarab/foxloop) and
292
+ [foxbridge](https://github.com/pooriaarab/foxbridge) can call it as a tool.
293
+
294
+ ## License
295
+
296
+ MIT. Pyodide is MPL-2.0. The demo extension ships it unmodified.
@@ -0,0 +1,11 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <!-- No network for this page or its worker, on every host. -->
6
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; worker-src blob:; connect-src 'none'" />
7
+ <title>foxden sandbox</title>
8
+ <script src="den.js"></script>
9
+ </head>
10
+ <body></body>
11
+ </html>