6.0 KiB
RunUserScript
Executes a user-authored script from the caller's user folder.
Two kinds of scripts are supported:
| kind | directory | runtime | signature | use case |
|---|---|---|---|---|
'script' (default) |
scripts/ |
plain Node.js — no Chromium | main({ params }) |
Data processing, API calls, computation, file conversion |
'browser-macro' |
browser-macros/ |
Playwright — Chromium | main({ context, params }) |
Web automation with a live browser session |
Input
{
name: string, // filename — '.js' is appended if absent
params?: Record<string, unknown>, // runtime values matching the script's param spec
kind?: 'script' | 'browser-macro' // default: 'script'
}
Param validation
Params are validated against the params: block in the script's YAML frontmatter:
- Extra params not listed in the spec → error containing "param"
- Wrong type for a declared param → error containing "param"
- Missing required param (no default) → error containing "param"
- Params with defaults are filled in automatically when not supplied
On any param error the tool returns isError: true immediately — no subprocess is spawned.
Session integration (browser-macro only)
If a browser-macro script's frontmatter declares session_profile_id: <N>, the tool:
- Loads the profile from the DB (owner-gated — must belong to
ctx.userId). - Decrypts the user's envelope-encrypted DEK using the master key.
- Decrypts the AES-GCM storageState blob using the DEK.
- Passes the decrypted Playwright
storageStateobject to the child process.
If any step fails the tool returns isError: true with a descriptive message.
For kind: 'script' (plain runtime), session_profile_id in the frontmatter is ignored — no session is loaded.
Self-healing recorder (browser-macro only)
When a browser-macro fails at runtime, the tool automatically enables the BrowseWeb recorder for the current task (if not already enabled). On task completion, recording-flush stages a candidate patch as browser-macros/{name}.next.js for diff review.
Plain scripts (kind: 'script') do not auto-enable the recorder.
Output format
On success:
<result stringified>
[script logs]
<console.log lines from the child process>
The result is JSON-stringified if it is an object or array; String(result) otherwise. The [script logs] section is only appended when the script produced logs.
On failure (plain):
RunUserScript "{name}" failed: <error message>
On failure (browser-macro):
RunUserScript "{name}" failed: <error message>
The recorder is now enabled for this task; subsequent BrowseWeb actions will be captured.
On task complete, a candidate patch will be saved as browser-macros/{name}.next.js for review.
Error cases
| Situation | isError |
message contains |
|---|---|---|
| No authenticated user | true | "authenticated" |
| Script file not found | true | "not found" |
| Frontmatter parse error | true | "frontmatter" |
| Param type / missing error | true | "param" |
| Session profile not found / not owned | true | "not found or does not belong" |
| Profile not active | true | "not active" |
| DEK / blob decryption failure | true | "decrypt" |
| Script timeout (60 s) | true | "timeout" |
| Script exits non-zero | true | "exited code" |
| Plain script denied child_process (e.g. spawning python) | true | "exited code" + "use the Bash tool" |
Notes
- The tool is a META_TOOL — it is available in every movement without listing it in
allowed_tools. - Use
kind: 'browser-macro'for any script that needs a browser (context). - Use
ListUserAssetsfirst to discover available scripts and their param specs. - On browser-macro failure, use
BrowseWebas a manual fallback.
Running Python (don't — use Bash)
RunUserScript runs Node only. There is no Python interpreter path. A
common footgun is to write a Node script that does
child_process.spawn('python3', ...) and run it here — that cannot work:
plain scripts run under Node's --permission model, which denies
child_process entirely (you get ERR_ACCESS_DENIED). Even if it were
allowed, the child's env is scrubbed, so it would not see the orchestrator's
provisioned Python environment.
To run Python, use the Bash tool instead: python3 your_script.py. The
Bash sandbox has the pip packages pre-baked (pypdf, pdfplumber, python-docx,
python-pptx, openpyxl, pandas, numpy, …). That is the supported, working path.
Security and trust model
RunUserScript is disabled by default. To enable it, add to config.yaml:
tools:
user_scripts_enabled: true
Only enable for trusted users. User scripts run in a restricted child process:
- Env is scrubbed — only
PATH,HOME,TMPDIR/TMP,LANG,NODE_ENV, andPLAYWRIGHT_BROWSERS_PATHare forwarded. API keys, database passwords, and other secrets in the orchestrator's environment are not visible to the script. - CWD is set to the system tmpdir, not the orchestrator workspace.
- Stdout is capped at 1 MB and stderr at 200 KB; exceeding either limit kills the child.
- On timeout, the entire process group (including Playwright's Chromium for browser-macros) is killed.
The two runtimes have different capability levels:
- Plain scripts (
kind: 'script') run under Node's Permissions Model (--permission):--allow-fs-readis limited to the child-runner dir and tmpdir,--allow-fs-writeto tmpdir, andchild_process, worker threads, and native addons are denied. A plain script that tries to spawn a subprocess (e.g. python) fails withERR_ACCESS_DENIED. See "Running Python" above. - Browser-macros (
kind: 'browser-macro') cannot use--permission— Chromium launch, native bindings, and outbound HTTPS all need unrestrictedchild_process/addons/network. They run with full Node.js capability (env-scrubbed only) and rely on container-level isolation. Treat them as trusted code.