Docs

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

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.

  1. 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 child

    The 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 _lastWebView heuristic. The data plane (evaluate, waitFor, screenshot against a resolved target) is direct CDP from the subprocess.

  2. Permission model. Base grants are computed by the app per run:

    FlagValue
    --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/sys

    Extra grants are declared statically in a script header and consented interactively:

    // @permissions net=api.github.com run=git
    // @timeout 15m

    The app parses @permissions, maps keys to flags (net appends to --allow-net, run becomes --allow-run=…, read/write add paths, env adds names), and prompts approve/cancel on first run — or whenever the declaration differs from the approved copy stored in atom.config keyed 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.

  3. 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 none for watcher-style scripts — which is also how persistent paneControls registrations live (the run stays connected; the host retains action stubs with .dup(), the known Cap’n Web lifetime gotcha from ADR-0003).

  4. 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-R with a selection) writes the selection to <scriptDir>/.runs/<runId>.ts, spawns it like any script with TRANQUIL_SCRIPT_DIR set 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.)

  5. The RPC bridge. A per-window ws server listens on 127.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 standard WebSocket client 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-rpc is almost purely additive: a new transport-ws.js beside the existing webview transport (same { encodingLevel: "string", send, receive } contract capnweb expects[^3]), and a new runner-host.js for the server + token table + per-run session lifecycle. trust.js is untouched — runner authentication is a new principal type (possession of a one-time run token), never routed through isTrusted(). 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 by ctx.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.”

  6. The script API. Scripts import a typed module (import-mapped as tranquil/automation) with namespaces tabs, ui, files, clipboard, config, workspace, paneControls, and context.

    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, and tab.url is 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", and tab.waitForNavigation() for click-triggered navigations — the single-tab sequential pattern is a goto loop 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 implement AsyncDisposable, so await 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.waitForEventADR-0023) are importable in local scripts with an ephemeral in-memory journal: same retry/backoff/timeout/TerminalError semantics, 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.*NewTransport
    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 Page methods[^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 declared run= 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
  7. The CDP layer is owned. Grow a small client (working name @tranquil/cdp, ~150 → ~550 lines) from tranquil-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 supportPage.navigate plus 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.

  8. Toolchain. The runtime library ships with the app as TypeScript source (the source is its own types). The app passes --config with the nearest deno.json walking up from the script, falling back to a seeded $ATOM_HOME/automations/deno.json[^4]: an import map with an absolute file: URL to the shipped mod.ts (refreshed on app update), tasks check / fmt / lint / test, and "compilerOptions": { "strict": true }. So run any .ts anywhere keeps working with the tranquil/automation import resolving. @std (JSR) is available to scripts as ordinary imports[^5]. Publishing @tranquil/automation to JSR is the later path.

Options considered

  • Option A: runtime — Deno subprocess (chosen) vs. keep the in-renderer AsyncFunction vs. isolated-vm/vm2 in-renderer vs. Node child_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 are evaluate-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.log output and cannot pass function stubs — paneControls actions 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 fake atom and require inside 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:line stack traces, typed scripts with deno 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:capnweb on 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 @permissions diffing and approval UI must be built carefully (approved copies keyed by script path; any change re-prompts).
  • :9222 stays 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