Docs

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

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.

  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,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: and jsr: fetches fail closed off-allowlist under --no-prompt; npm: registry fetches are exempt from --allow-import entirely — the documented residual channel (the runtime’s own capnweb arrives via npm:). 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/sys

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

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

    Amended by ADR-0025. The header is now REQUIRED — a script with no @permissions line is refused rather than run on a silent baseline, and none is the explicit way to say “nothing beyond my own folder”. Two capability keys were added, browser and clipboard, 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 — browser gates the CDP port in --allow-net and the tabs/workspace capabilities; clipboard gates 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/write used to mean “beyond your own folder”, with <scriptDir> added silently to both flags. That made none untrue: 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-write is 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-read keeps 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, resolving tranquil/automation fails. 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") needs read=input.txt like any other access.

    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.

    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.

  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 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 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.)

    (Implementation finding, 2026-08-12: scripts are not run directly — deno run executes a tiny bootstrap (deno/main.ts) that loads the user script via TRANQUIL_ENTRY and calls Deno.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-level await, so the run sat “running” until the timeout. The bootstrap statically pre-imports the runtime, so the user script’s import "tranquil/automation" is a cache hit and --allow-read stays 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 native file:line error and exits non-zero; honest stack traces are preserved.)

  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 expects3), and a new runner-host.js for the server + token table + per-run session lifecycle. runner-host.js is security-critical code under the same review discipline as trust.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.js itself 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 literally the same module in the same runtime: local scripts import them with an ephemeral in-memory journal (same retry/backoff/timeout/TerminalError semantics, 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.*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 methods1tab.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, exceptionDetails-to-Error mapping, and navigation support(implementation finding, 2026-08-11: Page.captureScreenshot does not work against <webview> guests in this Electron — it times out even for visible tabs, an OOPIF-family limit — so tab.screenshot() is host-mediated via webview.capturePage(), an exception to the direct-CDP data plane)Page.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.json4: 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 imports5. 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 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 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 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 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 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 start keeps today’s behavior). Children spawn with DENO_DIR=~/.tranquil/deno-cache so 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:capnweb on 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 @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 — and the runner’s CDP grant reaches host windows. The shared CDP endpoint lists host window page targets alongside webview guests, so a script granted --allow-net=…:9222 can attach to a host window and Runtime.evaluate with 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 only type: "webview" targets on the per-run token model — which closes this and the open :9222 together. 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/.exe resolution for run= 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.


  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