ADR-0022: Automations run as Deno subprocesses
Status: Proposed · Date: 2026-08-06
Work in progress — this ADR may be modified and iterated on freely until implementation (Phase 1 in the design notes) starts. Status flips to Active when that phase’s gate passes; normal ADR-change discipline applies from then on.
References
- ADR-0003: Cap’n Web RPC — the RPC layer this decision extends with a new transport and a new principal
- ADR-0015: Electron IPC vs Guest↔Host RPC — the boundary rule this ADR amends (a third RPC principal now exists)
- ADR-0023: celld is the workflow-engine substrate — the sibling decision; its step primitives are importable in local scripts
- Inbox draft ADR-0007: Hono + zod-openapi — the earlier Deno-first precedent from the Engine era
- Automations v2 — Design Notes — research, decision log, spike checklist, phased roadmap
Context
The automation runner is ~230 lines of tranquil-automations/lib/tranquil-automations.js. Scripts
are plain .js whose text becomes the body of an AsyncFunction executed in the host renderer:
const fn = new AsyncFunction("tranquil", "require", "atom", "__dirname", "__filename", content);
await fn(tranquil, require, atom, scriptDir, scriptPath || ""); That grants every script full Node (require, fs, child_process via tranquil.exec) and the
full atom API, unsandboxed. A hung script hangs the editor thread. There is no cancel, no run
history, no queue, and stack traces point into an eval frame rather than a file.
Browser control is puppeteer-core[^1] connected to the app’s always-on CDP endpoint
(http://localhost:9222). Two of its seams are already worked around by hand: getActiveTab() resolves tabs by matching a tracked _lastWebView URL against CDP targets (a heuristic that goes
stale), and openBackgroundTab() builds off-screen <webview> elements because Electron’s CDP
lacks Target.createTarget (browser.newPage() throws). Meanwhile any local process can attach to :9222.
The security posture is inverted from the rest of the app: ADR-0003 built an object-capability layer precisely so guests get scoped capabilities — but it serves only webview guests (ADR-0015), while automation scripts get everything.
Deno already has beachheads in the family: tranquil-test-suite is Deno-only and drives this exact
Electron build over CDP with a hand-rolled ~150-line client (smoke/lib/cdp.ts); the website runs
on Deno; the Engine-era API drafts picked Deno
(inbox draft ADR-0007).
Decision
Automation scripts are TypeScript modules executed in a spawned deno run subprocess with
explicitly computed permission flags[^2]. Browser control goes over CDP directly from the
subprocess via an owned minimal client; every app capability — notifications, open-in-editor,
clipboard, config, tab resolution — is served by the host renderer over the existing Cap’n Web RPC
layer[^3] through a new token-authenticated WebSocket transport. The renderer AsyncFunction runner, the injected require/atom globals, tranquil.exec, and the puppeteer-core dependency
are removed, with no compatibility shim.
Process architecture. Everything lives in the window’s renderer — main-process involvement is zero, so multi-window is naturally correct (each window runs its own server on an ephemeral port; a run binds to the window that launched it).
┌──────────────────── Tranquil window (host renderer) ────────────────────┐ │ tranquil-automations tranquil-rpc │ │ ┌─────────────────┐ spawn / SIGTERM ┌──────────────┐ │ │ │ run manager │──────────────────▶│ deno child │ │ │ │ + runs panel │◀──────────────────│ (one per run)│ │ │ └────────┬────────┘ stdout/stderr └──────┬───────┘ │ │ │ workspace model │ ws://127.0.0.1:<port> │ │ ┌────────▼────────┐ ┌──────▼────────────────────┐ │ │ │ browser │ capability calls │ WS server (per window, :0)│ │ │ │ <webview> tabs │◀──────────────────│ one-time token → capnweb │ │ │ └────────┬────────┘ (tabs, ui, …) │ session, audience "runner"│ │ │ │ └───────────────────────────┘ │ └──────────┼──────────────────────────────────────────────────────────────┘ └── CDP ws://127.0.0.1:9222 ◀── tab.evaluate()/screenshot() direct from the deno childThe control plane (which tab is active, open/close tab, notify, open-in-editor) is RPC to the host, answered from the workspace model — which kills the
_lastWebViewheuristic. The data plane (evaluate,waitFor,screenshotagainst a resolved target) is direct CDP from the subprocess.Permission model. Base grants are computed by the app per run:
Flag Value --allow-net127.0.0.1:<rpcPort>,127.0.0.1:9222--allow-envTRANQUIL_RPC_URL,TRANQUIL_RPC_TOKEN,TRANQUIL_RUN_ID,TRANQUIL_SCRIPT_DIR,TRANQUIL_TRIGGER--allow-read/--allow-write<scriptDir>--no-promptalways — fail closed; no TTY prompts in a subprocess never --allow-all,--allow-ffi, unscoped run/sysExtra grants are declared statically in a script header and consented interactively:
// @permissions net=api.github.com run=git // @timeout 15mThe app parses
@permissions, maps keys to flags (netappends to--allow-net,runbecomes--allow-run=…,read/writeadd paths,envadds names), and prompts approve/cancel on first run — or whenever the declaration differs from the approved copy stored inatom.configkeyed by script path. The security model is two-layer and default-deny on both: Deno flags bound direct system access; host capabilities bound app-side effects.Lifecycle. Spawn per run, no warm pool — Deno cold start is tens of milliseconds against script work measured in seconds (revisit if p50 launch exceeds 300 ms). Concurrent runs are isolated processes. Cancel is SIGTERM → 2 s grace → SIGKILL, and process death atomically revokes everything: the RPC session drops with its socket, the CDP sockets die with the process. Default timeout 10 min;
// @timeout nonefor watcher-style scripts — which is also how persistentpaneControlsregistrations live (the run stays connected; the host retains action stubs with.dup(), the known Cap’n Web lifetime gotcha from ADR-0003).Output, history, run-selection. stdout/stderr pipe into a new Automation Runs bottom-dock panel — per-run entries with state, duration, and output, persisted as a ring buffer of the last 100 runs. This closes the no-history gap, and the panel is explicitly designed to later also list engine workflow runs (ADR-0023’s observability lands in the same panel — one Runs surface for both kinds). Notifications fire only on terminal failure (first stack-trace line, click through to the panel). Deno stack traces point at real
file:line— a concrete DX win over eval frames. Run-selection (cmd-shift-Rwith a selection) writes the selection to<scriptDir>/.runs/<runId>.ts, spawns it like any script withTRANQUIL_SCRIPT_DIRset to the source file’s directory, and deletes it after — module resolution and stack traces stay honest. (deno run -over stdin was considered and rejected pending its import-resolution semantics — re-verify at implementation time.)The RPC bridge. A per-window
wsserver listens on127.0.0.1:0. A 32-byte random token is minted per run and delivered via env — never in the URL. Auth is the first text frame (AUTH <token>) within 3 s or the socket closes, because the standardWebSocketclient cannot set headers; the token is single-use and invalidated on auth or run end. Each authenticated socket gets its own capnweb session with an audience-filtered HostApi.The change to
tranquil-rpcis almost purely additive: a newtransport-ws.jsbeside the existing webview transport (same{ encodingLevel: "string", send, receive }contract capnweb expects[^3]), and a newrunner-host.jsfor the server + token table + per-run session lifecycle.trust.jsis untouched — runner authentication is a new principal type (possession of a one-time run token), never routed throughisTrusted(). The one real extension is registry audience tagging:registerCapability(name, factory, { audience })defaulting to["webview"](existing callers unaffected; runner exposure is opt-in),buildHostApi(ctx)filtering byctx.kind("webview"or"runner"), and runner contexts carrying{ runId, scriptPath, scriptDir }.Scope amendment to ADR-0015, stated explicitly: the rule becomes “host renderer ↔ trusted guest → RPC, where a trusted guest is either a
file://webview under a registered root or a local runner process presenting a valid one-time run token; RPC still never reaches main; host ↔ main stays classic IPC.”The script API. Scripts import a typed module (import-mapped as
tranquil/automation) with namespacestabs,ui,files,clipboard,config,workspace,paneControls, andcontext.Multi-URL scripts are the design center, not an edge case. Today’s seeded examples are single-page; real scripts won’t be. Three commitments follow:
- Stable tab identity. A tab handle binds to a stable target/webContents ID resolved once by
the host. URL matching is only an initial-lookup convenience (
tabs.find); handles survive navigation, andtab.urlis a live read, not a snapshot. The old runner’s URL-matched identity breaks on the first navigation — that is a defect this design fixes, not a behavior it preserves. - Navigation is first-class:
tab.goto(url, { waitUntil })with"load"or"domcontentloaded", andtab.waitForNavigation()for click-triggered navigations — the single-tab sequential pattern is agotoloop over URLs. - Fan-out semantics.
tabs.open(url, { background: true })off-screen webviews are capped by the host (default ~4 concurrent, FIFO queue beyond — each is a full renderer process) and must inherit the launching window’s session partition (persist:tb-window-<id>) so authenticated multi-URL flows work — partition inheritance is a verify item. Tab handles implementAsyncDisposable, soawait using tab = await tabs.open(…)auto-closes — no leaked tabs in loops. - Durability forward-compat. Local runs are ephemeral by design — closing the editor kills
the run, and that is a feature. But the SDK’s step primitives (
step.do,step.sleep,step.waitForEvent— ADR-0023) are importable in local scripts with an ephemeral in-memory journal: same retry/backoff/timeout/TerminalErrorsemantics, no crash resume. A local script graduates to a durable workflow by moving code, not rewriting it.
The full mapping from the old
tranquil.*surface:Old tranquil.*New Transport getActiveTab()/getTab(p)/getTabs()tabs.active()/tabs.find(p)/tabs.all()RPC resolve → direct CDP openTab(url)/openBackgroundTab(url)tabs.open(url, { background? })RPC (host creates pane item / off-screen webview) → CDP closeTab(page)tab.close()RPC puppeteer Pagemethods[^1]tab.evaluate(fn, ...args),tab.waitFor(),tab.screenshot(),tab.url,tab.title()direct CDP puppeteer page.goto/waitForNavigationtab.goto(url, { waitUntil? })/tab.waitForNavigation()direct CDP (Page domain) writeFile/readFilefiles.write(name, text)/files.read(name)local Deno fs openFile(p, o)/notify(m, l)ui.open(path, { split })/ui.notify(msg, { level })RPC clipboard.*/getProjectDir()clipboard.read()/clipboard.write()/workspace.projectDir()RPC exec(cmd)new Deno.Command(…)under a declaredrun=grantnative config.get/setsame names, host-prefixed under tranquil-automations.scriptState.*— scripts can no longer write arbitrary app config (deliberate tightening)RPC autoInject.register/unregisterapp-managed URL triggers; a script reads context.trigger/tabs.triggered()n/a require/atom/__dirnamegone — import,import.meta,context.scriptDir— - Stable tab identity. A tab handle binds to a stable target/webContents ID resolved once by
the host. URL matching is only an initial-lookup convenience (
The CDP layer is owned. Grow a small client (working name
@tranquil/cdp, ~150 → ~550 lines) fromtranquil-test-suite/smoke/lib/cdp.ts, which is already proven against this exact Electron build. Additions: attach-by-targetId, function-argument evaluate,Page.captureScreenshot, exceptionDetails-to-Error mapping, and navigation support —Page.navigateplus lifecycle-event waiting (load/domcontentloaded) with evaluate-retry across execution-context recreation. Navigation destroys and recreates the page’s default context; that is exactly the class of bookkeeping puppeteer hid, and without it multi-URL scripts hit “Cannot find context” races. Honest limitation, stated up front: no frames, no real input events, no network interception — any script needing those is the re-evaluation trigger.Toolchain. The runtime library ships with the app as TypeScript source (the source is its own types). The app passes
--configwith the nearestdeno.jsonwalking up from the script, falling back to a seeded$ATOM_HOME/automations/deno.json[^4]: an import map with an absolutefile:URL to the shippedmod.ts(refreshed on app update), taskscheck/fmt/lint/test, and"compilerOptions": { "strict": true }. So run any.tsanywhere keeps working with thetranquil/automationimport resolving.@std(JSR) is available to scripts as ordinary imports[^5]. Publishing@tranquil/automationto JSR is the later path.
Options considered
- Option A: runtime — Deno subprocess (chosen) vs. keep the in-renderer
AsyncFunctionvs.isolated-vm/vm2in-renderer vs. Nodechild_process. The in-renderer runner is the problem statement. vm2 carries a standing sandbox-escape disclaimer — new escapes keep being found and its own README steers integrators toward stronger isolation[^6]; isolated-vm adds a native build and has no I/O permission story. A Node child process isolates crashes but has no per-domain/per-path permission scoping and brings none of the fmt/lint/check/test/TypeScript toolchain. Only the Deno subprocess makes scripts killable, the editor unfreezable, and the permission surface declarative[^2]. - Option B: browser control — owned CDP client (chosen) vs. puppeteer-core on Deno vs. jsr
@astral/astral. Puppeteer is heavy, its Node-compat surface on Deno is a risk, its Electron gaps (Target.createTarget) are exactly what the current code already hacks around, and it is a version treadmill against a pinned Chromium[^1]. Astral is Deno-native and puppeteer-shaped but pre-1.0 and launch-oriented; attaching to an existing Electron endpoint is off its happy path and unverified[^7]. The seeded examples areevaluate-centric, and ~150 proven lines already exist in the test suite — owning the client is the smallest honest surface. - Option C: capability bridge — capnweb over WebSocket (chosen) vs. JSON-RPC over stdio vs. HTTP polling. stdio mixes protocol frames with user
console.logoutput and cannot pass function stubs —paneControlsactions need them. capnweb reuses a shipped, typed, security-reviewed layer[^3] plus two hard-won lifetime/shape gotchas already encoded in ADR-0003. - Option D: migration — clean break (chosen) vs. a
tranquil.*shim vs. dual runners. Only ~5 seeded examples exist, all owned. A shim would have to fakeatomandrequireinside a sandbox that exists to deny them; dual runners double every fix.
Consequences
- Easier: a responsive editor (scripts can’t block the renderer), killable runs, run history,
real
file:linestack traces, typed scripts withdeno check/lint/fmt/test, per-window correctness for free, and a two-layer default-deny security story. Audience tagging strengthens the capability registry for every future consumer, not just the runner. - Deno availability is a new install-time dependency. Decision: require an installed Deno ≥ 2.x found on PATH, with a configurable override and a clear install notification. Bundling the ~100 MB binary is rejected for now; a managed sidecar download is deferred.
npm:capnwebon Deno is documented as supported[^3] but unverified at runtime here; the fallback is vendoring the small runner-side client. Verify in Phase 1.- The consent prompt is a new security surface — the
@permissionsdiffing and approval UI must be built carefully (approved copies keyed by script path; any change re-prompts). :9222stays open to any local process. Out of scope for this ADR; the ephemeral-port, token-authenticated bridge is the template for closing it later.- Windows path/flag differences are untested; macOS is the Phase 1 target.
- Re-evaluate if: capnweb-on-Deno verification fails; requiring Deno proves too much install friction; or scripts outgrow the owned CDP client (frames, input events, network interception).
Appendix: the break, side by side
examples/page-info.js today (8 lines, runs in the renderer with atom and require in scope):
const tab = await tranquil.getActiveTab();
const stats = await tab.evaluate(() => {
const links = document.querySelectorAll('a').length;
const images = document.querySelectorAll('img').length;
const headings = document.querySelectorAll('h1,h2,h3').length;
return `${document.title}\n\nLinks: ${links} | Images: ${images} | Headings: ${headings}`;
});
await tranquil.openFile(tranquil.writeFile('page-info.txt', stats)); Ported (typed, sandboxed, killable — the break costs one import line and renames):
import { files, tabs, ui } from "tranquil/automation";
const tab = await tabs.active();
const stats = await tab.evaluate(() => {
const links = document.querySelectorAll("a").length;
const images = document.querySelectorAll("img").length;
const headings = document.querySelectorAll("h1,h2,h3").length;
return `${document.title}\n\nLinks: ${links} | Images: ${images} | Headings: ${headings}`;
});
await ui.open(files.write("page-info.txt", stats), { split: "down" }); And the pattern the old API never had — multi-URL fan-out, the common case (becomes a new
seeded example; concurrency via @std/async’s pooledMap[^5] rather than an owned pool, await using auto-closes tabs):
import { pooledMap } from "@std/async/pool";
import { files, tabs, ui } from "tranquil/automation";
const urls = ["https://example.com/a", "https://example.com/b", "https://example.com/c"];
const rows = pooledMap(3, urls, async (url) => {
await using tab = await tabs.open(url, { background: true });
await tab.waitFor("h1");
return `${await tab.title()}\t${url}`;
});
await ui.open(files.write("titles.tsv", (await Array.fromAsync(rows)).join("\n"))); The sequential sibling — the same crawl in one visible tab — is for (const url of urls) { await tab.goto(url); … }; both patterns belong in the
writing-automations guide when Phase 1 lands.
[^1]: Puppeteer Page API — the surface the old runner handed to scripts, and the dependency this ADR removes. https://pptr.dev/api/puppeteer.page [^2]: Deno security and permissions — the --allow-* flag model, per-resource scoping (--allow-net=host:port, --allow-read=path), and --no-prompt fail-closed behavior. https://docs.deno.com/runtime/fundamentals/security/ [^3]: Cap’n Web — the RPC library under tranquil-rpc; first-class WebSocket transport (newWebSocketRpcSession), custom transports via the RpcTransport interface, runs on browsers, Node, Deno, Bun, and Workers. https://github.com/cloudflare/capnweb [^4]: Deno configuration — deno.json imports (import map), tasks, fmt/lint, compilerOptions, and the --config flag. https://docs.deno.com/runtime/fundamentals/configuration/ [^5]: @std/async — the standard-library concurrency helpers; pooledMap is the bounded-concurrency iterator used by the fan-out example. https://jsr.io/@std/async [^6]: vm2 — standing security disclaimer: “researchers and security professionals continuously discover new ways to escape the vm2 sandbox”; its README recommends stronger isolation (separate processes, isolated-vm) for untrusted code. https://github.com/patriksimek/vm2 [^7]: Astral — “a high-level puppeteer/playwright-like library for Deno”, CDP-based; evaluated as the browser-control layer and passed over for attach-to-Electron uncertainty. https://jsr.io/@astral/astral