@skill-router/runtime
v0.1.18
Published
SkillRouter runtime — command registry, local daemon (127.0.0.1:19825) and browser bridge for sr-* CLI adapters. Fork of the published @jackwener/opencli 1.8.6 npm artifact (Apache-2.0, see NOTICE); wire-compatible with additive SkillRouter handoff and co
Downloads
91
Readme
@skill-router/runtime (sr-runtime)
SkillRouter's local runtime: command registry, executor, plugin system, local daemon (127.0.0.1:19825) and browser bridge. Forked from the published @jackwener/opencli 1.8.6 npm artifact with selected 1.8.7/main framework changes ported (Apache-2.0, see NOTICE). It ships the bare runtime only, without the upstream site adapters; SkillRouter adapters are installed separately as plugins. Basic commands stay wire-compatible with the upstream protocol.
It is normally installed and upgraded together with @skill-router/cli by sr update; no separate steps are needed.
sr-runtime plugin install file:///abs/path/sr-arxiv # install an adapter
sr-runtime sr-arxiv paper 1706.03762 # run an adapter command directly
sr-runtime plugin migrate # adopt existing plugins from ~/.opencli/plugins
sr-runtime plugin verify [name] # check installed plugins against their content hashes
sr-runtime plugin lint /abs/path/sr-x # static pre-release checks for an adapter
sr-runtime sitedata sr-x --show # show the source and version of a site's calibration data
sr-runtime sitedata sr-x --refresh # refresh a site's calibration data now
sr-runtime verify sr-x # run the whole-site fixtures (write commands stop at prepare)
sr-runtime doctor # check daemon and Bridge connectivityplugin verify exits non-zero for tampered, missing and unverified plugins.
Plugin startup indexes
Official bundle builds generate sr-startup.json from the final staged buyer tree. This is an
explicit author contract: top-level code only registers deterministic commands and same-site auth
helpers; metadata must not depend on environment variables or user caches. It must not depend on other
plugins, register conditional/global lifecycle hooks, or perform
work that must happen before another command runs. Set "startup": "eager" in the plugin manifest
for packages that need ordinary startup. The build refuses an invalid index instead of shipping one.
The generator uses an isolated cache directory and executes trusted author code; it is never run automatically during plugin installation.
For a reviewed local plugin: node runtime/scripts/prepare-startup.mjs /path/to/plugin.
Startup fingerprints are limited to 256 directory entries and 2 MiB of package content (excluding
generated index/lock and dependency directories); larger packages use eager startup to bound hashing
overhead. Official authors mark those packages startup: eager. Security seals keep their full scope.
Valid indexed packages register command metadata without importing their JavaScript. The selected package loads once, before argument validation, hooks and execution policy, and its live metadata must match. Imports serialize; alias winners retain discovery order. Indexes generated by a different runtime version, and missing, stale or unsupported indexes use ordinary eager discovery. Before an unindexed plugin loads, preceding indexed commands are materialized and the rest stay eager, including packages with indexes. User CLI directories or existing global hooks also keep plugins eager. Packages sharing a site keep their full eager registration history. This preserves asynchronously registered legacy hooks; mixed installations may therefore retain the full eager startup cost.
The source fingerprint detects staleness and is not a trust root. It excludes its own index and the
installer-generated package-lock.json; command metadata must not depend on that lock file. Strict integrity still seals all
plugin packages before discovery imports, including the startup index itself. A package edited after
discovery, an import failure, unexpected registration, or live metadata mismatch fails the command.
Regenerate the index after editing a package; build it after filtering files for delivery. Old runtimes
ignore the extra JSON file and continue eager loading. Updating only the runtime does not add indexes
to existing installed plugins; updated plugin bundles are needed as well.
browser <session> open and analyze now use one DOM-stability wait during navigation: 500 ms of quiet,
with a combined maximum of 3 seconds. The same browser response records the final redirected URL. analyze keeps its separate XHR observation step; back and
explicit --state keep their own settling. DOM quiet is a heuristic, not proof that delayed application
data has loaded. Transport failures and closed tabs during settling are reported as errors; the old
second wait silently swallowed them. Navigation interruptions retain the existing retry behavior.
The 3-second ceiling applies to a DOM observation, not transport latency or a retried observation. Use browser <session> wait selector ".ready" when a task needs a specific ready element.
Site calibration data (sitedata)
Every plugin ships a sitedata.json that is data only: URLs, selectors, labels, mappings, wait limits,
fixtures and pace. After a command finishes, the runtime spawns at most one detached background process every
15 seconds to check for updates by ETag; the command itself never waits on the network. Selection order: a
fresh cache under 24 hours old → a valid cache under 30 days old → the copy bundled with the plugin; a change
in the bundled version discards the old cache. Remote documents must pass schema, regular-expression safety
and plugin domain allowlist validation; a failure never replaces the last-known-good copy, and a document
older than the bundled version is never accepted. SR_NO_SITEDATA_REFRESH=1 disables the background refresh;
SR_SITEDATA_REFRESH_INTERVAL_MS adjusts the check interval (5 seconds to 24 hours).
Sites that declare pace keep persistent accounting in ~/.skillrouter/state/pace/<site>.json; when that
state cannot be written, the command is refused rather than released. verify checks row counts, columns and
pattern/notEmpty for read commands, and for write commands strips the submit arguments and checks only
prepare_assert. plugin lint checks command/fixture alignment, literal sleeps, disabled automation surfaces
and out-of-domain URLs.
When the same site serves different layouts, commands.<cmd>.strategies in sitedata.json carries extra
selector sets (same keys as the command's own selectors). A read command tries the sticky winner, then its own
selectors, then the active strategies — at most three — and only a layout-shaped failure (selector miss, empty
result, timeout, command error) switches to the next one; a write command never switches. The winner for this
machine lives in ~/.skillrouter/state/strategies/<site>.json; SR_STRATEGY=<id> pins one set for a run
(s0 is the command's own), and verify reports the strategy it landed on.
Browser sessions
Browser commands reuse a persistent site session by default; precedence is --site-session >
SR_SITE_SESSION > the command's declaration > the default. A read command first makes one non-waiting local
lease attempt: when idle it reuses site:<site>, when busy it switches to an isolated tab and prints
[sr] site:<site> busy → isolated tab (SR_QUIET=1 silences the notice). A read that loses its lease
mid-command discards its result and reruns once in full on an isolated tab; if that fails too, it exits 65
with the original error. A write command returns SESSION_BUSY (exit 75) immediately when the session is busy
and is never rerun automatically. After a process crash the lease is reclaimed by TTL within about 45 seconds.
Read commands must therefore be idempotent.
After a persistent command succeeds, the runtime sends control-release: the final page and the site-to-tab
mapping are kept, but debugger control is detached at once and the extension icon goes from LIVE to IDLE.
Only an explicit tabs close really closes the tab.
The browser subcommand picks its window default by host: a person in an interactive terminal gets the
foreground; when driven by an agent, MCP or a pipe it runs in the background and never steals focus.
--window, --show and login-wall takeover are unaffected; pick and blocking record are always foreground.
Failure handoff and login walls
When an adapter command fails hard, the runtime writes a handoff bundle to
~/.skillrouter/handoff/<timestamp>-p<PID>-<UUID>.json and refreshes latest.json (mode 0600, kept seven
days, never uploaded). When takeover is safe the bundle carries the adapter lease, the page target, a
screenshot and candidate elements; inspect the scene with
sr-runtime browser <session> --surface adapter state --tab <target>. Before taking over a write command,
confirm whether the first attempt partially succeeded; never blindly replay it. Login and CAPTCHA go to a
human; rate limits and risk-control decisions are never taken over.
On a login wall the runtime brings the tab to the foreground, shows a card asking a human to log in, and
reruns the command once after the login lands. SR_REQUEST_HELP=off disables this.
sr report-fix --pick lets you pick the repair target on the live page at the breakpoint and uploads the
chosen selector and its structural fingerprint to the SkillRouter repair service. Apart from the optional run
signal below, it is the only command in this runtime that sends data out on its own.
Before browser <session> network --detail <key> writes to its cache or output, sensitive headers such as
authorization, cookie, token, password, secret and session id, and credential-shaped values, are replaced with
<redacted>; URL query parameters are redacted the same way.
Optional anonymous run signal
Off by default. After sr signal on, the runtime asynchronously sends a fixed five-field record
{site, cmd, sitedata_version, ok, code} when a command completes; it carries no arguments, URLs, page
content, output or credentials, and a failed send never affects the command. SR_SIGNAL=1|0 switches it
explicitly. Details: https://skillrouter.org/us/privacy .
Local runs journal
Every real command run appends one line to ~/.skillrouter/journal/runs.jsonl (directory 0700, file 0600,
one JSON object per line, ≤ 4096 bytes, rotated to runs.1.jsonl at 8 MiB). The line holds only
ts, v: 1, site, cmd, access, browser, outcome, code, exit, ms, rows, shape, rule, strategy, pid — never
arguments, output rows, URLs, page text or credentials — so "did it run, was the shape right" is answered
without asking the agent. outcome is ok/empty/shape_mismatch when the sitedata fixture checked the
shape, and auth/arg/env/timeout/selector_miss/crash on failure. SR_JOURNAL=off disables it;
verify subprocesses never write, and a journal failure can never change a command's result or exit code.
Action overlay, recording and exploration audit
SR_SHOW_ACTIONS=1 or --show displays a synthetic cursor, target highlight and action labels (decoration
only, not audit evidence).
sr-runtime browser work pick --prompt "click the publish button" # returns ref + selector (300 s, Esc cancels)
sr-runtime browser work record --purpose "demo a search" # writes trace.json + states/ after Finish
sr-runtime browser work record --values # record typed values (never passwords, OTP, card numbers, SSN)
sr-runtime browser work explore start # local black box: command names and redacted arguments only
sr-runtime browser work explore stop
sr-runtime browser audit <record-dir> --json # verdict: read_pure / write_actions / violationsaudit exits 1 on a violations verdict. network, analyze and wait xhr read the network capture buffer
and cannot leave complete network evidence, so an explore session that uses them is always judged violations.
Recordings are written with mode 0600 to ~/.skillrouter/records/<timestamp>-<host>/, kept seven days,
capped at 500 steps or 5 MiB. URLs keep origin, path and parameter names; sensitive parameter values are
redacted. trace.json is untrusted data supplied by the web page; states/*.txt are full-page text snapshots
that may contain personal information from the page and should not be uploaded. Google/Microsoft sign-in,
password managers, Alipay/Taobao login, PayPal, Stripe and any host containing bank refuse recording by
default; SR_RECORD_BLOCK_HOST=host,*.suffix extends the blocklist, and SR_RECORD_ALLOW_HOST=a,b is an
explicit risk acceptance that takes precedence over it. The bank substring rule has a clear ceiling: bank
sites whose domain lacks the word and punycode domains not on the list are not recognised automatically.
Recordings and exploration files stay on this machine, are never uploaded and must not be placed in the
four-field feedback body; they leave the machine only when the owner explicitly submits a candidate later.
Fewer round trips
state --interactive keeps only interactive elements and the ancestors that hold them. state --diff prints,
after the first diff snapshot of a mode on a document, only the rows that appeared, changed or were renumbered; its
footer counts the rest (diff: new 3 | unchanged 220 | removed 1, where a removed row disappeared, changed, was
renumbered, or had its ancestor text change). A document is the browser document: same-page anchors, in-app route changes and re-opening the URL you
are already on keep it and its baselines; a real navigation starts a new one.
The first diff snapshot of a mode on a document is the full baseline (diff: baseline); full and interactive
diffs keep separate baselines, and plain state, record, verify or adapter snapshots never disturb them.
--state takes an interactive diff snapshot, so it claims the interactive baseline for the following
state --interactive --diff calls.
Markdown tables, iframes and svg blocks take part in the diff as whole units; the hidden_interactive hint is
repeated every time, compounds only for refs printed in that diff. [N] numbering is that of a full snapshot taken
at the same moment, and a row whose [N] shifted is reprinted with its new number: read the reprinted rows before
reusing a [N] from an earlier snapshot, and when a diff reports new or removed rows but prints no addressable row
(identical-looking rows share one hash), take a plain state first. state --settle 1000 first waits up to one second for the DOM to stop
changing.
open, click, type, fill, select, keys and scroll accept --state: after the action the runtime waits
for the DOM to settle (up to 1 s) and prints the interactive diff state in the same call, so one process replaces
the action-then-state pair. A row's diff hash covers what the row prints: its [N], tag, the listed attributes,
scroll counts, the text the row prints and the first 200 characters of its subtree text; only the row's layout shape
(inline text versus a text line under it) is not hashed. A text field prints its live value only when its markup has
no non-empty value attribute, so fill … --state shows the filled field there and keeps printing the markup value
on pre-filled fields; the fill receipt carries the value either way. State that is not printed — unlisted aria
attributes, text past the printed prefix — is not part of the diff, and a plain state does not print it either;
read it from the command's receipt, with browser extract for long text, or with browser eval for a single
attribute. The baseline is kept on the page
itself, so a page can tamper with it: --diff is always opt-in, a mode's first diff snapshot on a document that
does not print diff: baseline means the page pre-seeded it, and a plain state is the deterministic view.
sr-runtime browser work state --interactive --diff
sr-runtime browser work click 12 --state
sr-runtime browser work state --settle 1000Domain allowlist and plugin integrity
Every adapter command runs inside a domain allowlist: the plugin manifest domains ∪ the sitedata.json
domains. Navigations and requests the runtime itself initiates outside the allowlist are refused (exit 68,
handoff takeover_policy.mode = stop); navigations the page performs on its own are only recorded as landing
URLs. A plugin that declares no domains gets a single warning; SR_STRICT_DOMAINS=1 turns that into a refusal
and installs a page-side fetch/XHR observer (count only, never blocking). about:, data:, blob: and
chrome-extension: pass; javascript: and chrome: do not. A manifest whose domains contain illegal
entries (a scheme, path or port, loopback or private addresses, a public-suffix wildcard) is refused by
plugin install/update; already-installed plugins are enforced on their valid subset. Matching is exact: a
full hostname matches only that host, and *.example.com matches subdomains only.
plugin install/update records a content hash of the plugin directory in ~/.sr/plugins.lock.json
(excluding node_modules, .git and *.log); plugin verify compares against it, the executor warns once
per process about a mismatching plugin, and under SR_STRICT_INTEGRITY=1 only verified plugins run. A
local-path install is a symlink hashed through its real directory; after editing its code, run
plugin update <name> to re-record it. Threat model: the domain guard catches hijacked pages, poisoned
sitedata and honest adapter mistakes; adapter code runs inside the runtime process, so this is not a sandbox
against a malicious plugin author, which is what plugin lint and the integrity hash are for. The lock file
lives in the user's home directory and only detects one-sided tampering.
Directories and environment variables
The runtime's own state lives under ~/.sr/ (plugins, plugins.lock.json, monorepos, the update-check cache);
logins and configuration shared with upstream opencli stay under ~/.opencli/, so both can coexist.
SR_DAEMON_TOKEN optionally enables a daemon handshake token (off by default).
Local workspaces
On macOS/Linux, sr-runtime workspace --help exposes local settings, task progress,
files and encrypted credentials under ~/.skillrouter/. These commands bypass
plugin discovery and startup hooks. They neither execute website actions nor upload
local data. Domain, category and account are always explicit; task and run identify
one batch. Read the returned revision before each update; conflicts require a fresh
read and a decision, never an automatic retry of a website action.
sr-runtime workspace settings get --domain example.com --category invoices --account work
printf '%s' '{"parameters":{"month":"2026-09"}}' | sr-runtime workspace init --domain example.com --category invoices --account work --task monthly --run 2026-09 --revision 0 --stdin
sr-runtime workspace resume --domain example.com --category invoices --account work --task monthly --run 2026-09Use item to record pending → running → succeeded with business evidence. Use
attach --id invoice-1 --file /explicit/path/invoice.pdf --role output --revision N
with the same run scope to copy a result, or --role input --reference to reference
an input. finish --revision N --stdin takes {"evidence":"scope checked ..."};
all items must be succeeded/skipped and all registered files must verify. show
reports historical status; resume checks current file integrity and never replays
submitted, running or unknown actions.
credential put accepts {username,password} or {token} through stdin only;
credential inspect returns metadata. Supply --domain, --account, --id,
--origin, --kind, --revision and --stdin when saving. Do not put secrets in
shell literals/history. Authorized local adapters can use readCredential from
@skill-router/runtime/workspace with the exact origin; never log its return value.
The vault uses AES-256-GCM and a private local key, optionally SR_WORKSPACE_KEY
(64 hex characters). Back up the complete vault including key-id and its original
key. Switching keys is rejected; rotation and Windows ACL support are not implemented.
The vault does not protect against a process running as the same OS user.
Documents are limited to 1 MiB / 1024 immutable revisions, runs to 1000 items and 1000 artifacts, individual files to 512 MiB (JSON: 1 MiB). Keep secrets out of settings, notes and artifacts; field-name checks are not automatic secret detection.
Automatic login from the vault
When a browser command hits a login wall, the runtime may fill in and submit the login form once from the local vault before handing the tab over to a person. The guarantee it makes is deliberately narrow and is stated here in full.
Threat model
In scope — these are the shapes the network guard exists to stop:
- the page HTML points the submit somewhere else:
action,formaction,<base href>, a hidden button, an implicit Enter submit, aform=control outside its form, ormethod=getputting the value in a query string; - page script (careless or injected) sends the submitted content to another site with
XHR,
fetchorsendBeacon— a cross-site staticGET(script, image, font, stylesheet, media, manifest, text-track) is still allowed by design, so same-site hostile JavaScript can abuse that channel; see Out of scope below; - the page navigates to another site after the submit and asks for the credential again.
Out of scope — not promised, and a person should not assume otherwise:
- hostile JavaScript on the same site as the form. It can read the password box directly and never needs a submit path. No in-page check can stop that, which is why the guard moved to the network layer and this is written down instead of patched;
- a phishing domain the user themselves saved the credential for: the credential's registered domain is the trust anchor;
- WebSocket frames (the CDP
Fetchdomain does not intercept them); - forms inside a cross-site iframe (the probe only ever runs in the top-level document and never types into an iframe);
- another process running as the same OS user;
- the direct-CDP browser path (
CDPPage, electron /--cdp): it has no guard at all, so automatic login there always ends asguard_unavailable.
Opt-in per credential
Automatic login is off for every credential unless it was saved with --auto-login:
printf '%s' '{"username":"…","password":"…"}' | sr-runtime workspace credential put \
--domain example.com --account work --id wp --origin https://example.com \
--kind password --revision 0 --auto-login --stdinA credential without the flag (including every credential saved before the flag existed)
is reported as auto_login_off without being decrypted, and the hint says to re-save it
with --auto-login. Turning it off again is another put without the flag — a new
revision. The flag lives beside the encrypted envelope, deliberately outside its
authenticated data: it is a convenience switch, not a security boundary (anyone who can
read the key can read the record either way).
Network guard
While the credential is being filled in and submitted, the runtime opens a tab-level
request guard in the SkillRouter Bridge extension (CDP Fetch domain interception
performed inside the extension). From start until stop:
- requests to the credential's registered domain (and its declared domains, plus the wildcard of the credential's domain) are allowed;
- cross-site static requests are allowed:
GET/HEADof script, stylesheet, image, font, media, manifest and text-track resources; - everything else cross-site is refused with
BlockedByClient: XHR,fetch,sendBeacon(Ping),EventSource, the WebSocket handshake, every document navigation (top-level and iframe), and any non-GET/HEADrequest.
The guard needs SkillRouter Bridge 0.1.8 or newer; an older extension answers the
vault-guard action with "Unknown action" and the attempt ends as guard_unavailable
before anything is decrypted — fail closed, no typing, no pause. The policy lives in the
extension, so page HTML and page script cannot reach or change it, and Fetch.* is not
reachable through the generic cdp command channel. Only the guard's own tab is
affected, and it closes on stop, on CDP detach, or when its TTL (page timeout + 45s,
capped at 3 minutes) expires.
A successful-looking login is only reported as logged_in after the guard has been
stopped and reports that it stayed active for the whole window with nothing blocked. If
the page looks logged in but the guard blocked a request, or was lost (DevTools attached,
extension reloaded, guard expired), the attempt is rejected with the usual 10-minute
pause: the extension's verdict is the one that counts, because a blocked request means the
credential may have been on its way somewhere it does not belong.
Prerequisites
All of these must also hold:
- the site is not
access: red(those never touch a credential); - the live document is on a host the command is allowed to touch — the origin is read
from the page itself (
location.href), never from the navigation that was requested, so a redirect to another host, scheme or port is refused instead of trusted; - a
kind=passwordcredential is stored whose origin is exactly that live origin — scheme, host and port all included (https://example.comnever matcheshttps://example.com:8443orhttp://example.com); - each sensitive keystroke carries the origin it was authorized for: the check happens after the field is focused and inside the same evaluate that prepares native input, so a page that moves during the click or between checks is never typed into, and the value is never submitted to a document that is not the one it belongs to;
- exactly one credential matches, or
SR_VAULT_ACCOUNT(and optionallySR_VAULT_ID) selects one (a named account that is not stored is reported as no credential rather than silently swapped for another); - the same domain has not failed a login within the last 10 minutes, and no other attempt for it is in flight (one attempt per domain at a time);
- the page has a visible password box and a username box, with no one-time-code, CAPTCHA or other challenge control.
On success the command is re-run once on the same tab; if the wall is still there
afterwards, the domain is paused for 10 minutes like any other rejection. A write command
whose arguments include a true send / confirm / commit / submit / execute /
publish / apply / pay is never replayed by itself: it fails with AUTH_RESTORED
(exit code 75, agent handoff, verify_before_retry) so the user can confirm nothing was
submitted and run it again. Any other outcome leaves behaviour exactly as before — the
same AUTH_REQUIRED error and handoff classification — plus a one-line hint on how to
save a credential. A write command may also be logged in from the vault at its pre-run
login check, before anything has executed.
The pause is unconditional once the credential has been sent. Whatever the page does
after the submit — stays on the login page, times out, keeps the password box, or lands on
another origin — the domain is paused for 10 minutes. The same holds when the document
changes origin after at least one character has been typed (during typing or just before
the submit): the attempt ends as rejected, the domain is paused, and the hint says the
value may have reached another site and the credential should be reviewed. That is
deliberate: a conditional pause would let every following command submit the password
again while the hint claimed it was paused.
The decrypted value lives only inside the login attempt. It reaches the page exclusively
through the browser's native text input (insert-text / CDP), never through a page
script: typeText(..., { sensitive: true }) refuses the DOM-setter fallback and the
attempt stops with no_form instead of shipping the value. Any attempt that typed
something and did not end in logged_in blanks both fields again before returning — but
only on the document it filled: after a cross-origin navigation the old document is gone
and this runtime runs no script at all in the new one. The value never enters the
trace/observation stream, a log line, an error message, the returned object or the CLI
output.
Two things this cannot promise, and which a person should know:
- a site that re-fills the username itself after a failed login (WordPress does) puts it back on its own; clearing only removes what this runtime typed;
- under
SR_STRICT_ACCESS=1no automatic login happens at all. The guard blocks synthetic input forreadcommands and vault typing is synthetic input too, soreadcommands are skipped with a hint that says exactly that. There is no exemption for vault typing:SR_STRICT_ACCESS=1means "read commands get zero synthetic input", and this code does not relax that.
Nothing here has been validated against a real site yet — the spec's live acceptance run is still outstanding.
Audit log
Every attempt appends one line to ~/.skillrouter/state/vault-login/vault-login.log
(mode 0600; rotated to vault-login.log.1, overwriting the previous .1, once the
file passes 1 MiB):
{"ts":"…","domain":"…","origin":"https://…","account":"…","id":"…","revision":1,"outcome":"rejected","attempted":true,"guard":{"started":true,"active":true,"blocked":["127.0.0.1"],"detached":false,"expired":false}}guard is null when no guard was opened (an auto_login_off, guard_unavailable or
pre-guard outcome). The line never contains the username, the password, a URL path, a
query string, a request body or a hint; origin is a bare origin and blocked is a list
of hosts. Writing is best-effort — a read-only home changes no outcome.
# Ask the runtime to try the vault on the tab you are already looking at
sr-runtime browser work vault-loginThe command prints the outcome as JSON (logged_in, no_credential, ambiguous,
origin_mismatch, no_form, cooldown, disabled, rejected, guard_denied,
auto_login_off, guard_unavailable) and exits 0 only for logged_in. Environment
switches: SR_VAULT_LOGIN=0 turns automatic login off entirely;
SR_VAULT_ACCOUNT / SR_VAULT_ID choose between several saved credentials for the same
origin.
