ADR-0022: Automations run as Deno subprocesses
Status: Active · Date: 2026-08-06 · Implemented: 2026-08-13 (Phase 1)
Phase 1 is implemented and the gate passed — the seeded examples run as sandboxed Deno
subprocesses (including the multi-URL fan-out), cancel/kill works, and the webview RPC surface is
unregressed (guarded by the new rpc-webview-injection smoke suite). The clean break landed: the
in-renderer AsyncFunction runner and puppeteer-core are removed. See the Phase 1 delivery note for the as-built details and follow-ups.
Normal ADR-change discipline applies from here; the design notes track Phases 2–3.
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: The workflow engine is in-app — the sibling decision; workflow runs execute through this ADR’s runner, and its step primitives are the same module local scripts import
- 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
- Automations v2 — Security Review — the full design-time security assessment; this ADR carries only the decision-level deltas. The token bridge here is also the reference implementation of the token/handshake option in RPC Trust Gap
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-core1 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 flags2. 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
layer3 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,localhost:9222--allow-envTRANQUIL_RPC_URL,TRANQUIL_RPC_TOKEN,TRANQUIL_RUN_ID,TRANQUIL_SCRIPT_DIR,TRANQUIL_TRIGGER--allow-readthe entry module + the SDK directory — the two paths Deno needs to load the program, nothing the script reads (see the amendment below) --allow-writenothing; declared paths only --allow-importjsr.io:443— module-graph fetching (npm:/jsr:/https:imports) is not governed by--allow-net2, so leaving this implicit would silently inherit Deno’s wider default allowlist. Verified on Deno 2.8:https:andjsr:fetches fail closed off-allowlist under--no-prompt;npm:registry fetches are exempt from--allow-importentirely — the documented residual channel (the runtime’s owncapnwebarrives vianpm:). Scripts needing other hosts declare them via a consented@permissions import=…grant--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 browser net=api.github.com run=git // @timeout 15mAmended by ADR-0025. The header is now REQUIRED — a script with no
@permissionsline is refused rather than run on a silent baseline, andnoneis the explicit way to say “nothing beyond my own folder”. Two capability keys were added,browserandclipboard, which take no value.The reason: the Deno-flag axis was never the dangerous one. A header-less script could not open a socket, but it could drive an authenticated browser and read the clipboard, because both arrive over loopback and RPC rather than through permission flags. Those are now declared, gated and consented like the rest —
browsergates the CDP port in--allow-netand thetabs/workspacecapabilities;clipboardgates the clipboard capability. An ungranted run does not see the capability at all, rather than one that refuses.Further amended 2026-08-17: the script’s own directory is no longer granted implicitly.
read/writeused to mean “beyond your own folder”, with<scriptDir>added silently to both flags. That madenoneuntrue: a script declaring nothing could still read and rewrite every file beside it, including sibling scripts holding broader approvals — the shared-trust- domain hazard this ADR documented but did not close. Grants are now exactly what the header lists, resolved against the script’s directory when relative (write=output.md,write=results), and--allow-writeis omitted entirely when nothing is declared.A bare
.is rejected at parse time. Deno’s path grants are recursive — a directory grant covers everything beneath it, including folders that do not exist yet — so.would reinstate the same blanket, only written down, and it is the value an author reaches for by reflex.--allow-readkeeps two machinery paths: the entry module and the SDK directory. Both are required and were measured, not assumed — with no read grant,import(entry)fails; with only the entry, resolvingtranquil/automationfails. Because the grant is file- and package-scoped rather than directory-scoped, a script still cannot read a sibling data file:files.read("input.txt")needsread=input.txtlike any other access.The 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.Two honesty requirements on the consent surface (full reasoning in the security review): the prompt renders
run=grants as what they are — “can run commands as you” — because--allow-run=git(or most developer tools) is effectively full user privilege, never a narrow grant; and<scriptDir>was a shared trust domain — the default write grant let a script rewrite sibling scripts that may hold broader approvals, so scripts in one directory shared fate (documented, not partitioned). The 2026-08-17 amendment above closes that: there is no default write grant, so a script reaches a sibling only by naming it in a header the user approved.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 protocol-level with a kill backstop: a cancel message with a 2 s grace window, then hard process kill — on POSIX the mechanism is SIGTERM → SIGKILL, but the signal is the transport, not the definition, because Windows has no graceful signal phase (ADR-0023’s cross-platform notes). 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.)(Implementation finding, 2026-08-12: scripts are not run directly —
deno runexecutes a tiny bootstrap (deno/main.ts) that loads the user script viaTRANQUIL_ENTRYand callsDeno.exit(0)on completion. Without it a script would never end: the runtime holds the RPC and CDP sockets open, which keep Deno’s event loop alive past the script’s top-levelawait, so the run sat “running” until the timeout. The bootstrap statically pre-imports the runtime, so the user script’simport "tranquil/automation"is a cache hit and--allow-readstays scoped to<scriptDir>— the dynamic import needs no read grant for the runtime’s own files. A top-level throw stays uncaught so Deno prints its nativefile:lineerror and exits non-zero; honest stack traces are preserved.)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 expects3), and a newrunner-host.jsfor the server + token table + per-run session lifecycle.runner-host.jsis security-critical code under the same review discipline astrust.js— its invariants: tokens are single-use, never appear in URLs, expire unclaimed after 60 s, the socket closes on any auth failure before a session exists, and no frame reaches the RPC layer pre-auth.trust.jsitself is 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 literally the same module in the same runtime: local scripts import them with an ephemeral in-memory journal (same retry/backoff/timeout/TerminalErrorsemantics, no crash resume), engine runs with the durable one. 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 Pagemethods1tab.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, exceptionDetails-to-Error mapping, and navigation support — (implementation finding, 2026-08-11:Page.captureScreenshotdoes not work against<webview>guests in this Electron — it times out even for visible tabs, an OOPIF-family limit — sotab.screenshot()is host-mediated viawebview.capturePage(), an exception to the direct-CDP data plane) —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.json4: 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 imports5. 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 isolation6; 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 declarative2. - 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 Chromium1. 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 unverified7. 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 layer3 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 ships with the app. (Amended 2026-08-11 — the original decision here was
require-on-PATH with bundling “rejected for now”; overturned when ADR-0023 made Deno carry the entire automations-and-workflows
story.) Packaged builds bundle a pinned Deno binary spawned by absolute path — no PATH
discovery, no install notification, works offline out of the box. Resolution order: config
override → bundled binary (packaged default) → PATH (dev mode only;
yarn startkeeps today’s behavior). Children spawn withDENO_DIR=~/.tranquil/deno-cacheso the bundled runtime’s module cache never collides with a user’s own Deno. Costs and packaging gotchas (~100 MB/arch, macOS JIT entitlements at notarization) live in ADR-0023’s dependencies and cross-platform notes. npm:capnwebon Deno is documented as supported3 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 — and the runner’s CDP grant reaches host windows. The shared CDP endpoint lists host windowpagetargets alongside webview guests, so a script granted--allow-net=…:9222can attach to a host window andRuntime.evaluatewith full renderer privileges — escaping everything the flags deny. Accepted risk, stated plainly: the two-layer claim above holds only up to this endpoint; it is no worse than today (any local process can already attach), and the same-user boundary is explicitly out of scope (tokens in env, loopback sockets — see the security review). Recorded mitigation path: a host-side filtered CDP proxy — the runner bridge brokers target discovery and sockets, exposing onlytype: "webview"targets on the per-run token model — which closes this and the open:9222together. Re-evaluate when scripts users did not author enter the picture.- Windows path/flag differences are untested; macOS is the Phase 1 target. The tri-OS
specifics (protocol-level cancel, per-OS packaging of the bundled Deno, PATHEXT/
.exeresolution forrun=grants) are collected in ADR-0023’s cross-platform notes. - 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 pooledMap5 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.
- Puppeteer
PageAPI — the surface the old runner handed to scripts, and the dependency this ADR removes. https://pptr.dev/api/puppeteer.page↩ - Deno security and permissions — the
--allow-*flag model, per-resource scoping (--allow-net=host:port,--allow-read=path), and--no-promptfail-closed behavior. https://docs.deno.com/runtime/fundamentals/security/↩ - Cap’n Web — the RPC library under
tranquil-rpc; first-class WebSocket transport (newWebSocketRpcSession), custom transports via theRpcTransportinterface, runs on browsers, Node, Deno, Bun, and Workers. https://github.com/cloudflare/capnweb↩ - Deno configuration —
deno.jsonimports (import map), tasks, fmt/lint,compilerOptions, and the--configflag. https://docs.deno.com/runtime/fundamentals/configuration/↩ @std/async— the standard-library concurrency helpers;pooledMapis the bounded-concurrency iterator used by the fan-out example. https://jsr.io/@std/async↩- 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↩
- 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↩