Repository navigation
Scripting
Scripting is where you write, install and arm automation: JavaScript run against Joro's automation SDK inside a sandboxed worker process, or a local operating-system command run against captured traffic. It exists for the work that is tedious one tool call at a time — sweeping a range of object IDs, comparing fifty responses, walking a list of endpoints — for piping a request into a tool you already have, and for extending Joro on platforms where Plugins cannot load at all.
There are two ways in. An automation client can submit a program through the script_run capability. You can write, install and arm automations yourself from Settings → Automation → Scripting, where they can run on an event, on a request you pick, or as a lens that renders a tab beside Raw and Render.
An installed automation comes in two kinds, and they share everything around the body — the store, the editor, triggers, lenses and the run log. The script kind's body is JavaScript; the sandbox, the SDK, grants and Visual scripting are about it alone. The command kind's body is a program and a list of arguments, and everything specific to it — how it is enabled, what it may be fed, and what bounds it — is under Local commands.
Each kind has its own launch flag. --automation-scripting is what lets the script kind run, --automation-commands the command kind, and Settings → Automation → Scripting needs at least one of the two. Start Joro with only one and you get the whole editor, list and run log — the Kind dropdown simply refuses the half you did not enable.
--automation-scripting makes the five script_* capabilities grantable. Without it they cannot be listed, granted or invoked, the editor's Sandboxed script kind is disabled and names the flag, and the script routes return a 404 that names it too rather than leaving you to guess which switch you missed.
It is a separate axis from --automation-privileged, which means web shell execution and C2 specifically, and from --automation-commands, which allows the command kind to run. Take any without the others:
./joro --automation-scriptingLike the other automation flags it is set at launch and cannot be changed from the UI, so scripting cannot come on without a restart. See Configuration.
The flag only makes the capabilities grantable. They are still privileged, so no profile includes them, each has to be ticked deliberately, and ticking one opens a second confirmation. Installing and running an automation yourself needs no grant at all — the editor is part of the UI, not the automation surface.
Joro also accepts a hidden --script-worker flag. That is how a sandbox re-execs the binary; it reads a job from stdin and speaks a private protocol on stdout. Do not use it.
The engine is goja, a JavaScript interpreter written in pure Go, which is why scripting works on Windows and on CGO-less builds where Go plugins cannot load.
Every run happens in a separate process. Joro re-execs itself as a worker, writes the job to its stdin, and answers the SDK calls it asks for over a pipe. This is not decoration: goja has no memory ceiling of any kind, and new Array(1e9).fill(0) has to cost something. Joro's capture store is an in-memory ring, so an out-of-memory kill in the proxy would lose the engagement's traffic. It costs the worker instead. The worker links a JavaScript engine and a pipe and holds no Joro state at all — there is no capture store, token file or HTTP client in the process for a script to reach even in principle.
The global object holds ECMAScript built-ins and nothing else. There is no process, require, fetch, setTimeout, filesystem, socket or module loader — so there was no ambient authority to remove, only a bridge to add. joro is that bridge, and it is frozen.
Every run ends with exactly one reason, reported verbatim, alongside a stable outcome code. The reason is prose to read; the code is what to branch on, and it does not change when the wording does.
| Reason | Code | Means |
|---|---|---|
success |
success |
run(ctx) returned. |
script exception |
exception |
An uncaught throw. |
timeout |
timeout |
The wall-clock deadline passed. |
memory limit |
memory_limit |
The heap ceiling was hit and the VM was interrupted. |
sdk budget exceeded |
budget_exceeded |
The run ran out of calls, sends or bridge bytes. |
cancelled |
cancelled |
Joro stopped the run. |
capability denied |
denied |
The run ended on an uncaught denial. The fix is a grant or a scope rule, not a change to the script. |
runtime failure |
runtime_failure |
Joro's fault, not the script's: a compile failure, an unusable entry point, a result that will not serialize. |
worker lost |
worker_lost |
The worker died without reporting, usually the operating system reclaiming it for memory. |
An unrecognized reason reports unknown rather than reading as a success.
Every limit has a default and a maximum a run may ask for. A zero takes the default, and a value over the maximum is clamped down rather than rejected — these arrive from a caller that may be a language model, and the useful answer to timeoutMs: 600000 is a 25-second run, not an argument error that costs a turn.
| Limit | Default | Stock maximum |
|---|---|---|
| Timeout | 25 s | 10 min |
| Memory | 64 MB | 256 MB |
| SDK calls | 100 | 500 |
| Sending calls | 25 | 100 |
| Log output | 256 KB | 1 MB |
| Return value | 128 KB | 1 MB |
| Bridge bytes in | 2 MB | 8 MB |
| Bridge bytes out | 8 MB | 32 MB |
Sending calls are counted in addition to total calls, so an http.resend spends one of each. The two counters exist because the cost of a read is context and the cost of a send is traffic against someone's target.
The stock figures are what applies while you have set nothing. Both columns are editable — see Run budget.
Settings → Automation → Settings holds the Run budget: what a run gets and the most one may ask for. Every field carries Joro's default, and most carry a maximum you can raise or lower; a few have a fixed cap you cannot exceed, and the panel names what each is fixed against.
Per run — the figures an automation or an agent may request, and which your maximum bounds:
| Field | Default | Stock maximum | Fixed cap |
|---|---|---|---|
| SDK calls | 100 | 500 | |
| Sending calls | 25 | 100 | |
| Wall clock | 25 s | 10 min | 10 min |
| Memory | 64 MB | 256 MB | |
| Log output | 256 KB | 1 MB | |
| Result size | 128 KB | 1 MB |
This Joro — set once for this machine, with nothing able to ask for another value:
| Field | Default | Fixed cap |
|---|---|---|
| Concurrent runs | 2 | 3 |
| Storage operations | 1000 | |
| Program size | 256 KB | 1 MB |
| Agent log output | 64 KB | 240 KB |
| Agent result size | 96 KB | 240 KB |
A run beyond the concurrency figure is refused rather than queued, so a caller retries instead of waiting. Program size is checked at install and again at run. The two agent figures bound what script_run hands back to a client, separately from the run's own log budget, which is what you read in the run log; their sum has to fit the 240 KB cap, so raise them together.
Command automations have their own two cards on the same sub-tab — Per command run and This Joro (commands), tabulated under Local commands. They are marked not enabled until Joro is started with --automation-commands.
A program is a classic script that defines an entry point. There are no ES modules.
async function run(ctx) {
const stats = await joro.history.stats({})
console.log(stats)
return { checked: ctx.input?.host ?? 'everything' }
}Whatever run returns is the result, serialized as JSON. An arrow function assigned to run works too — entry resolution runs inside your own scope rather than looking for a global.
ctx carries:
-
ctx.run—{id, startedAt} -
ctx.trigger—{type}, plus the event payload for a triggered run -
ctx.input— whatever JSON was passed in -
ctx.automation—{id, version}, present only for an installed automation
Bundle your dependencies. The only importable module is @joro/sdk, whose import is erased before the program runs; importing anything else is an error that names it rather than a silent rewrite that would report a missing variable when the real problem is a missing bundler. The erasure is bounded to code position, so an export inside a string or a template survives — the source you read back in the run log is the source that ran.
await is cosmetic. Every SDK call is synchronous inside the VM, so an async function run() whose awaits all resolve from SDK calls has already settled by the time the call returns. Promise.all serializes, which is the wanted behaviour anyway. A promise left genuinely pending is reported with an explanation, since the sandbox has no timers or I/O for anything else to resolve it.
joro is a frozen global. Every method takes one argument object, returns whatever the capability returns, and throws on failure with err.code and err.capability set. The arguments are identical to the MCP tool of the same name — see Automation for what each one does, or open the SDK reference panel in the editor, which is generated from the running server.
Most methods return a compact text rendering, not an object. These capabilities were written for a language model reading the result, so history.list hands back a padded table. Expect to work in strings — split lines, match with a regular expression, slice — rather than to walk a structure:
const table = await joro.history.list({ status: '5xx', limit: 50 })
const seqs = table.split('\n')
.map((line) => line.match(/^(\d+)\s/))
.filter(Boolean)
.map((m) => Number(m[1]))A table's first lines are notes (n=12/50 off=0 host=target.com), then a header row, then one row per record, so a leading integer is a reliable way to pick out data rows. Run a method once from the editor and read the output before writing a parser against it.
http.read is the exception, and returns an object. Its result is bytes rather than a table, so there is nothing to parse: text is exactly the window, with no metadata line and no trailing notice, and the rest is fields.
const r = await joro.http.read({ seq, part: 'req', section: 'headers' })
const requestLine = r.text.split('\r\n')[0] // the bytes, and only the bytes
if (r.truncated) { /* continue at r.offset + r.returned */ }
if (r.redacted?.length) { /* these header values were withheld from this token */ }Alongside text it carries seq, part, section, encoding, totalLength, offset, returned, truncated, redacted and decoded. An agent calling the http_read tool gets the same read rendered as text instead: one metadata line, the bytes, then any notes.
| JavaScript | Capability |
|---|---|
joro.instance.get |
instance.get |
joro.history.list / .stats / .highlight
|
history.list, history.stats, history.highlight
|
joro.sitemap.get |
sitemap.get |
joro.scope.get |
scope.get |
joro.http.fingerprint / .read / .search / .diff
|
http.fingerprint, http.read, http.search, http.diff
|
joro.http.resend / .batch
|
http.resend, http.batch
|
joro.websocket.list |
websocket.list |
joro.fuzzer.start / .status / .results / .stop
|
fuzzer.* |
joro.findings.list / .get / .create / .update / .delete
|
findings.* |
joro.notes.list / .hosts / .create / .delete
|
notes.* |
joro.context.get / .clear
|
context.get, context.clear
|
joro.intercept.get / .list
|
config.intercept.get, config.intercept.list
|
joro.detect.rules / .config
|
detect.rules.list, detect.config.get
|
joro.webhook.list / .send
|
webhook.list, webhook.fire
|
That is thirty-four methods, and the table is the whole surface. joro.webhook.send reaches only the endpoints you ticked Let automations fire this on, and never learns where any of them point — see Webhooks#firing-one-from-an-automation. findings.delete and notes.delete are bounded to the run's own entries and to findings you have already dismissed — see Automation#findings-and-notes. What is missing is missing on purpose:
- Match & Replace, Custom Data and noise edits. A script adding a rewrite rule silently changes your own traffic. Engagement setup is a different job with its own token profile.
- Detection writes, including rescan. Same argument — they change what you see rather than what the script learns.
- The scope writes. Scope is the control that bounds a run, so a run cannot edit it.
-
Everything privileged, and
script.*itself: a script that can start scripts launders its own budget.
Widening the SDK and widening what a run is authorized to do are the same edit, so a method that exists is granted and a grant that exists is callable.
A small key/value scratchpad, scoped to the automation's own id. It is the one SDK surface that is not a capability, because there is nothing to authorize — the namespace is bound by Joro rather than passed as an argument, and storage reaches nothing. It is memory, not authority.
await joro.storage.set('seen', [1842, 1843])
const seen = await joro.storage.get('seen') // null if unset
await joro.storage.keys() // sorted
await joro.storage.delete('seen') // true if it existedBounded at 200 keys, 128-character key names, 64 KB per value and 256 KB per automation. Failures throw with err.code — storage_full, storage_too_large, storage_unavailable, invalid_args.
Storage rides in your project file and resets when you switch projects, which is usually what you want: a set of "already tested" IDs from one engagement should not suppress coverage in the next. Only installed automations have a namespace. A one-shot script is told so rather than handed a scratchpad that silently evaporates.
console.log, .info, .warn, .error, .debug and .trace write to the run log, against the run's log budget.
atob(base64) and btoa(binary) are defined in the shim, in JavaScript, because goja ships neither and a lens receives base64. They operate on strings and reach no host function.
A run's grants are the fixed SDK bundle, and they are not intersected with the caller's. This is the one capability whose authority is not its own: a token holding instance_get plus script_run can run code that reads history, resends requests and writes findings, because what you authorized was a standard automation surface rather than a list of checkboxes. Grant script_run on that understanding.
Policy is the other half, and it is inherited rather than invented:
- A run launched by a token gets that token's policy verbatim — its scope requirement, its host whitelist, its credential setting. A token you deliberately issued unrestricted launches unrestricted runs.
- A run nothing launched — a trigger firing, a lens, your own Run button — takes Joro's own scope configuration as its scope requirement.
- Credentials stay masked when no token launched the run. There is no operator setting to inherit, so credential headers are masked in everything the SDK returns.
- A per-automation host whitelist applies only when the caller brought none of its own, since a token's leash is the stricter authority.
Grants say what the code may do; policy says where it may reach.
Note what a cleared scope requirement does not mean. It stops scope being an authorization check on the run; it does not turn scope off. Automation sends go through Joro's own proxy, so the live scope configuration still filters capture and MITM exactly as it does for your browser.
Denial messages name whichever of the two is actually carrying the limit, so a run refused for scope says the run inherited it and cannot clear it itself, and a whitelist denial says "run" when the whitelist is the automation's own rather than a token's.
Each run gets its own synthetic token identity, so it gets its own rate budget, its own concurrency slot and its own cookie jar rather than charging a script's reads against the operator's own minute budget. Every SDK call the run makes appears in Activity as an ordinary invocation, grouped under the run.
An installed automation lives at ~/.joro/automations/<id>/. A script is three files:
| File | Owner | Holds |
|---|---|---|
joro.json |
the author | The manifest: what this is, what it is armed for, what budget it asks for. |
<entrypoint>.js |
the author | The program. A real .js file you can open in your own editor. |
joro.state.json |
you | Enabled, paused, limit overrides, disabled triggers, host whitelist, lens overrides, revision history. |
Custom triggers are not part of a package. They live in ~/.joro/triggers.json and are
referenced by id, so one can be shared by several automations — see
Custom triggers.
Author state and operator state are separate files, so author intent and operator intent cannot overwrite each other. Effective limits are the minimum of the manifest's and yours, then clamped to the maxima — an automation can only ever narrow itself, and updating the source never reverts a tab you retitled.
A command automation drops the middle row: no .js file, because the manifest's command block is the body. Everything built on the source still works — the source hash identifies the exact command, revisions track changes to it, and the run log keeps verbatim what ran.
Automations are machine-global and are deliberately not part of a project file. A project config is published to teammates, and shipping executable code that runs against the full SDK bundle is the same privilege transfer that keeps ~/.joro/automation.json out of one. enabled is machine-global with them. What an automation stores does ride in the project file, as an opaque blob.
Everything is installed disabled. Reviewing something means running it before arming it, so you can always run a disabled automation yourself from the editor — an agent holding script_invoke cannot.
An agent may store at most 32 packages of its own through script_install, counted wherever they sit, so enabling one does not make room for another.
A script:
{
"id": "idor-sweep",
"name": "IDOR sweep",
"version": "1.0.0",
"description": "Resends a captured request across a range of object IDs.",
"kind": "js",
"sdkVersion": "1",
"entrypoint": "index.js",
"triggers": ["manual", "request.selected"],
"limits": { "timeoutMs": 25000, "memoryMb": 64, "maxCalls": 100, "maxSendCalls": 25 },
"minIntervalMs": 1000
}A command, piping the response body through jq as a lens:
{
"id": "jq-pretty",
"name": "jq",
"version": "1.0.0",
"description": "Pretty-prints a JSON response body.",
"kind": "command",
"command": {
"path": "/opt/homebrew/bin/jq",
"args": ["."],
"stdin": "response",
"output": "json"
},
"lens": { "label": "jq", "part": "response" },
"limits": { "timeoutMs": 10000 }
}| Field | Required | Default | Notes |
|---|---|---|---|
id |
yes |
^[a-z0-9][a-z0-9_-]*$, up to 64 characters. Doubles as the directory name, and cannot be changed after install. |
|
name |
no | the id
|
Up to 80 characters. |
version |
no | 0.0.0 |
A free string, up to 32 characters. |
description |
no | Up to 400 characters. | |
kind |
no | js |
js or command. Selects which of the two bodies below applies. |
sdkVersion |
no | 1 |
Script only. Must be 1. Selects the automation-v1 bundle. Empty on a command. |
entrypoint |
no | index.js |
Script only. One bare filename ending .js. No directories. Empty on a command. |
command |
on a command | The command block: what to run, what to feed it, what environment it gets. See Local commands. Absent on a script. | |
triggers |
no | ["manual"] |
Each names an event or a custom trigger. Order is precedence: the dispatcher takes the first with work. |
limits |
no |
timeoutMs, memoryMb, maxCalls, maxSendCalls, maxLogBytes, maxResultBytes. All requests, never guarantees. |
|
lens |
no |
{label, part}. See below. |
|
graph |
no | Script only. The canvas the source was generated from — nodes with their positions, and the wires between them. Written and read by the editor; Joro never executes it. Cleared on a command. See Visual scripting. | |
minIntervalMs |
no | 1000 | Shortest gap between triggered runs. |
The two bodies are mutually exclusive. A script with a command block is refused, as is a command with an entrypoint or an sdkVersion; a manifest that names neither kind nor a command block is a script.
Of the limits fields, a command uses only timeoutMs — the rest describe a JavaScript run. Everything else bounding a command comes from the command budget.
A manifest cannot request a capability. sdkVersion selects a Joro-owned bundle, and that indirection is the point: reviewing a package means reading its code, triggers and limits, not auditing a permission matrix it wrote about itself. A command declares no sdkVersion, because it calls no SDK — what it may do is decided by your enabling it.
entrypoint is one filename and multi-file packages are refused, because there is no module loader — bundling is the author's job.
graph is checked for shape when it is written — ids unique and well-formed, coordinates real, both ends of every wire naming a box that exists — and for nothing else. Whether it is acyclic, whether its ports agree and whether it compiles are the editor's rules, and the editor is where they are enforced. It is bounded at 128 nodes, 256 edges, 16 KiB per box and 512 KiB in total, and those bounds are checked on save rather than on load: a hand-edited over-size graph should report itself when you try to store it, not make the whole package disappear from the list.
From Settings → Automation → Scripting:
-
New ▾ offers
Script,Lens,Local commandandTrigger, and opens the editor on a starter of that shape. Install writes it. -
Import reads a
.jautofile — a gzipped JSON envelope carrying a manifest and a source. It opens the file in the editor rather than installing it, so you read someone else's code before it exists on disk. -
Export downloads the automation as
<id>-<version>.jauto. - Uninstall removes the directory and drops the automation's storage namespace.
An id collision is refused rather than merged. The source is compiled at install time, so a syntax error is reported immediately instead of at first run.
A package is version 2 now that a manifest can name a custom trigger. Naming one by id would leave a package that resolves on your machine and dangles on anyone else's, so an export carries the full definition of every custom trigger the automation references — built-in events are never embedded. Importing installs any this Joro does not already have, naming each, before the editor opens: the automation is about to reference them, and a dangling reference is an automation that silently never fires. The automation itself still only opens in the editor. A package written by a newer Joro is refused outright rather than read for the parts this build understands — silently dropping what it does not recognize is how an automation gets installed without the trigger it was meant to run behind.
Updating an enabled automation requires the hash of the source you are replacing. The editor sends the hash it loaded, so a concurrent edit to something armed and running fails outright rather than silently clobbering. Revisions are recorded as metadata only — {hash, at, bytes}, up to 50 of them. What exactly ran is answered by the run log, which keeps source.
There is no registry, no marketplace and no command-line install path.
Settings → Automation → Scripting is one rail beside one editor. The rail holds four sections — Automations, Lenses, Triggers, and a collapsed Built-in events — and selecting a row opens it in the pane beside it. An automation row carries its name, id, version, source hash, what it is armed for, and how the last run ended; per row you can run it once, enable or disable it, export it, or uninstall it. A small glyph marks one that was built on the canvas. Switching rows with unsaved work asks before discarding it.
Opening an automation gives you the body on the left and one permanent rail on the right: id (permanent once installed), Kind, name, version, description, the lens declaration, minimum interval, limits, and a JSON test input.
The body has two views, Graph and Code, and Graph is where a new script starts. Graph is the canvas described under Visual scripting. Code is the source with syntax highlighting and folding, and below the rail's form it adds the collapsible SDK reference — generated from the running server, so it cannot drift from what the sandbox actually exposes.
Where you declare triggers depends on which view you are in, because there is no sense in offering the same control twice. In Graph view the triggers are boxes on the canvas, added from the palette's + Trigger dropdown; removing a box removes the trigger. In Code view the boxes are gone, so the rail grows a checkbox list instead. Either list offers Joro's events and your own custom triggers together, since from here they are the same thing — something to point this automation at.
A local command has no Graph view at all, and no tab row: there is no JavaScript for a canvas to generate. Its triggers are always the rail's checkbox list.
Kind chooses the body: Sandboxed script or Local command. It is permanent once installed — the server refuses a kind change, because the two are confined by entirely different things and swapping one for the other would keep the id, the triggers and the arming while replacing everything they were granted for. Picking Local command on an instance started without --automation-commands warns you inline rather than refusing: it will install and list, and never run. On an instance started with only --automation-commands, the Sandboxed script option is the one that is disabled, and everything else in the editor works as described.
Run executes the buffer, not what is on disk — an authoring surface that cannot run a draft is not one. The report underneath shows the termination reason, duration, call counts, storage operations, console output and the returned value. It is the same report you get everywhere else a run is started, so a run reads identically wherever it came from.
A command is the exception. Run stays disabled until the package is installed, and then it runs what is stored rather than the form in front of you. There is no draft to execute: a command's program is resolved to an absolute path when you save, so what would run is settled by saving and not by typing.
Save writes the manifest and source together. On a command it writes back what the server stored, which is where you watch the bare program name you typed become the absolute path it resolved to.
The Graph view is a canvas of boxes and wires that compiles to JavaScript. It is an authoring surface, not a second runtime: what it produces is an ordinary async function run(ctx) written to index.js, and that file is what Joro stores, hashes, logs and executes. The graph rides along in the manifest as a drawing of how the file was made.
Two things follow, and they are the whole shape of the feature.
The file is what runs. ~/.joro/automations/<id>/index.js is still a real file you can open in your own editor. If it and the canvas ever disagree, Joro tells you which way and makes you choose — it never quietly reconciles them.
The canvas is not a privilege boundary. Generated JavaScript reaches exactly what hand-written JavaScript reaches. Authority comes from the SDK bundle sdkVersion selects and from your enabling the automation, never from what the graph draws. Reviewing a canvas-built automation is the same job as reviewing any script: read its SDK-call boxes, or read the Code tab, which is always there.
The palette groups eighteen kinds:
| Group | Boxes |
|---|---|
| Source |
Trigger (one per trigger the automation declares), Context (ctx.input, ctx.trigger, ctx.run), Value (a JSON literal) |
| Value |
Read field (a path, optional-chained so a missing parent is empty rather than an error), Text (a template with {{a}} slots), Arithmetic
|
| Logic | Compare, AND, OR, NOT, Choose |
| Action | SDK call, Storage, Log |
| Control | Stop unless, For each, Return |
SDK call is the one worth understanding. Its input ports are generated from the schema the running server serves, the same source the SDK reference is built from — so a method that gains an argument gains a port, and the canvas cannot offer a call the sandbox would refuse. A method that puts bytes on the wire is marked — sends in the dropdown, with a reminder that a lens run has its send capabilities stripped.
Wires carry one of two things: a value, or a true/false. Only Compare, AND, OR and NOT produce the latter, and only the logic boxes, Choose, Stop unless and Return accept it. While you drag, the ports that cannot take the connection fade and the ones that can are highlighted.
Return is a singleton — you cannot add or remove it. On a lens it is drawn as Viewer tab, because what it returns is what the tab shows.
The two views are not two documents.
Graph → Code is live. The Code tab always shows the compiled output, and while a canvas owns the body it is read-only. What Save writes and what Run executes is the compiled source; the text is a window, not a buffer.
Code → Graph does not exist. There is no decompiler. Opening a hand-written script on the Graph tab shows a derived wiring diagram — its triggers, a single Code box, and Return — assembled for the picture and never stored. You can drag its boxes; the positions are forgotten when you leave.
Clicking any palette button on that diagram is a trap door. It does not translate your code. It replaces the derived picture with a fresh seeded graph and drops the new box on it, which means the next Save overwrites index.js with whatever the canvas compiles. A persistent amber banner says exactly that and stays until you save or back out.
Detach is the way out in the other direction: it drops the graph and keeps the generated code as an ordinary hand-owned body. It is one-way — rebuilding a canvas starts from scratch.
Because compiling is deterministic, Joro answers "have these drifted?" by recompiling and comparing rather than by trusting a stored hash. Two states are distinguished, and both say plainly that the file is what runs and that saving will replace it:
-
canvas out of step with the code — a stored graph whose
index.jswas edited outside Joro. - the canvas is taking over — you are building over a body that was hand-written.
An agent cannot submit a graph at all, so script_replace against a canvas-built automation leaves the code and drops the canvas. See Automation.
Some things are refused while you wire: a self-loop, a duplicate wire, a value landing on a true/false port, a second wire into a port that takes one, and anything that would close a cycle. A For each body wired back into its own collector is fine — the loop is split into its two halves before the check.
Others are refused at compile time, and each one disables Save with the reason on the button:
- no Return box, so nothing says where the run ends
- Return reading a value that only exists inside a loop — collect it and return the collection
- a required input with no wire and no literal fallback
- an SDK call with no method, or a method the server does not serve
- a Value box whose text looks like JSON and is not
- more than 128 boxes or 256 wires
That check is deliberately done here rather than on the server. A box reading a variable that is out of scope compiles to a runtime reference error, not a parse error — the server would store it happily, so the canvas is the only place it can be caught before it lands on disk.
A box that nothing reaches from Return is only warned about, not refused: it is the normal state of a graph you are still building. Log, SDK call and Stop unless are never orphans, since their point is what they do rather than what they return. Tidy re-lays a graph that has become a mess.
There are no user-defined functions, no while, no try/catch, no break or continue, no early return other than Stop unless, and no way to build an object or array except as a literal. Nesting goes as far as For each and Choose take you.
This is not a gap to be filled in later so much as the line the feature is drawn on. When you want more than this, Detach and write it — the sandbox, the SDK and the limits are identical either way.
A command automation runs a local operating-system command instead of JavaScript. It exists for the tools you already have — piping a captured request into sqlmap, running jq over a response, handing a URL to a scanner — without leaving Joro or copying bytes by hand.
Everything around the body is shared with scripts: the same store, the same editor, the same triggers, the same lens mechanism, the same run log.
Command automations run only when Joro was started with --automation-commands. It is off by default and set at launch, like the other automation flags.
./joro --automation-commandsWith the flag off, command automations still install, load and list — they simply will not run, and the editor names the flag.
No grant enables this, and no automation token can reach it. There is no capability behind command execution: script_invoke refuses a command automation whoever holds it, and script_install cannot create one — it has no kind argument, and a submitted manifest that names a command block is rejected. Command automations are yours to write and yours to arm.
A command is a program and a list of arguments, never a command line handed to a shell.
The program is resolved to an absolute path when you save, so the binary you reviewed is the binary that runs. Arguments are stored as a list, and the editor's single command box is split into program and arguments as you type, before anything is stored.
Placeholders resolve in arguments only. A {{PLACEHOLDER}} in the program path is refused rather than substituted — the program has to be settled before the run.
There is no shell anywhere in the path, on any platform, including for .bat and .cmd on Windows. The guarantee that follows:
A value taken from captured traffic lands in exactly one argument, whatever it contains. It cannot split one argument into two, add or remove arguments, or change which program runs. A semicolon, a quote, a backtick or a newline in a URL is just a character in a value.
A run is refused rather than started when any of these fails:
- An unresolved placeholder is an error, never passed through as literal text.
- A value may not begin an argument with
-, so a query reading--output=...cannot arrive as an option. The check is on position only:--data={{INPUT}}passes whatever the value starts with, while a bare{{URL}}occupying its own argument is checked. - No NUL bytes, anywhere.
- Outside the request or response body, no newlines, and a 4096-character cap.
Write {{NAME}} in an argument and Joro substitutes it at run time. Values Joro computes itself are used as-is; values taken from the wire are held to a grammar first.
| Placeholder | Resolves to | Source |
|---|---|---|
{{INPUT}} |
The input source's bytes. Piped on standard input when it leads the command line, or substituted into an argument wherever else you write it. | input |
{{SCRATCH}} |
The run's working directory. The command starts there, and anything it leaves behind is collected as an artifact. | joro |
{{HOST}} |
Hostname the request went to. Must look like a hostname or address. | captured |
{{URL}} |
Full URL including path and query. Must be an http or https URL naming a host. |
captured |
{{METHOD}} |
The request's HTTP method. Letters only. | captured |
{{STATUS}} |
The response's status code. Digits only. | captured |
{{SEQ}} |
The request's sequence number in History. Digits only. | captured |
{{FINDING_ID}} |
Identifier of the finding that fired this run. | captured |
{{CAMPAIGN_ID}} |
Identifier of the fuzzing campaign that finished. | captured |
Which ones carry a value depends on the trigger. A finding or a campaign carries no transaction, so neither supplies {{URL}} or {{SEQ}}; a request trigger correspondingly supplies no {{FINDING_ID}}.
| Trigger | Supplies |
|---|---|
manual, request.selected, request.captured, a lens |
{{INPUT}} {{HOST}} {{URL}} {{METHOD}} {{STATUS}} {{SEQ}}
|
detect.finding |
{{INPUT}} {{FINDING_ID}} {{HOST}}
|
fuzzer.complete |
{{INPUT}} {{CAMPAIGN_ID}}
|
automation.completed |
{{INPUT}} |
A custom trigger supplies whatever its event does. automation.completed carries no
transaction and no finding — only what the finished run was and returned, which reaches the
command as the trigger payload.
{{SCRATCH}} and any file placeholder you declare are always available. The editor lists every placeholder with its grammar and warns while you are editing if you use one the automation's triggers cannot supply. A run that reaches a placeholder with no value is refused rather than run without it.
Three ways in, and the choice matters:
| Delivery | Size | Binary-safe | Visible in ps
|
|---|---|---|---|
| Standard input | unbounded | yes | no |
| Input file | artifact budget | yes | no, path only |
Inline {{INPUT}}
|
inline budget | no, text only | yes |
Standard input is the default choice. stdin selects what the program reads: none, request, response, both, or trigger.
Input files materialize a part of the transaction into the run's working directory before the program starts, for a tool whose interface is -r <file> rather than a pipe. Each entry maps a placeholder to the part written there — request, response, both or trigger — up to 8 files. The bytes never touch the argument list.
Inline {{INPUT}} puts the bytes directly in an argument. It needs the inline field naming the source, so an argument cannot name {{INPUT}} unless something says where it comes from. Bounded by the inline budget, refused if it would exceed it, and — because arguments are readable by other processes on the host — the one delivery that is visible in ps.
The command starts from a minimal environment plus whatever env sets. Joro's own environment is not inherited: name what you need in envPass and nothing else is passed through, so credentials exported in the shell that launched Joro do not reach a command by default. Up to 32 variables across both fields.
useProxy sets the conventional proxy and CA-bundle variables, so a command that honours them has its traffic land in History under the same scope, noise and Match & Replace as your browser's.
It is a default rather than a control. A command can unset those variables, and a tool that does its own dialing never consulted them — scope does not bound a subprocess the way it bounds a capability call.
redact masks credential header values in whatever the command reads, by any of the three deliveries.
It is off by default. Turn it on for a command that sends the bytes somewhere Joro does not control. Leave it off to replay a request: a masked Cookie tests the endpoint anonymously, which reports a false negative.
A command is not sandboxed. It is a process running as you, with your filesystem and your network, and nothing here changes that.
What Joro bounds is how long it runs, how much of its output is kept, how much it may leave on disk, and what can appear in its arguments. Both cards live on Settings → Automation → Settings, beneath the script budget.
Per command run:
| Field | Default | Stock maximum | Fixed cap |
|---|---|---|---|
| Wall clock | 60 s | 10 min | 10 min |
| Output kept | 256 KB | 4 MB | |
| Errors kept | 64 KB | 1 MB | |
| Artifacts kept | 16 MB | 256 MB | |
| Inline input | 64 KB | 96 KB Unix · 6 KB Windows |
This Joro (commands):
| Field | Default | Fixed cap |
|---|---|---|
| Concurrent commands | 1 | 4 |
| Scratch kept | 10 runs | 50 runs |
There is no memory figure. A command runs as its own process, so a command that allocates without bound is reclaimed at its own expense and Joro survives it.
The inline cap is fixed by the platform, not by Joro — past it the operating system refuses to start the process at all. Lowering the figure is how you narrow what can appear in ps; piping on standard input avoids it entirely.
Structural limits on the command block: program path 512 characters, 64 arguments, 4096 characters per argument, 8 input files, 32 environment variables, and 4096 characters per environment value.
Each run gets a private working directory, reachable as {{SCRATCH}}. The command starts there, so a tool that writes output beside its input cannot touch your working directory. Anything left behind is collected as an artifact, and directories are kept for the most recent runs only.
| Reason | Code | Means |
|---|---|---|
success |
success |
The command exited zero. |
exit status |
exit_status |
It ran and exited non-zero. |
timeout |
timeout |
The wall clock passed and it was killed. |
cancelled |
cancelled |
Joro stopped it. |
output limit |
output_limit |
It produced more than its output budget and was stopped. |
spawn failed |
spawn_failed |
It never started — a missing or unexecutable binary, or a working directory that could not be made. |
not permitted |
not_permitted |
Refused before spawning: a placeholder that did not validate, or the feature disabled. |
input too large |
input_too_large |
{{INPUT}} would have exceeded the inline budget. Raise it, or pipe on standard input instead, which has no size limit. |
runtime failure |
runtime_failure |
Joro's fault rather than the command's. |
Two of these need care. A non-zero exit is reported, not treated as a failure — plenty of tools exit non-zero to mean "no match" — so the exit code is given and you decide what it meant. And output limit is not a truncated success: output cut mid-stream is not a complete answer.
Choosing the command kind replaces the source pane with a form:
- Command — one box holding the whole command line, split into program and arguments as you type.
-
Input source — what
{{INPUT}}and standard input draw from. - Will run — the resolved preview, showing exactly which argument each placeholder lands in.
- Placeholders — the reference table, with a warning on any the triggers cannot supply.
- Program and Arguments — the stored list, one row per argument, for editing an element directly.
- Input files — placeholder to transaction part.
-
Environment —
enventries and theenvPasswhitelist. - Behaviour — route its traffic through Joro's proxy; mask credentials in what it reads.
-
Output rendering —
textorjson, for how a lens tab renders what the program printed.
A trigger is what makes an automation run. An automation names the ones it wants in
triggers, and each name is either one of Joro's events or a custom trigger you built.
| Event | Started by |
ctx carries |
|---|---|---|
manual |
You, or an agent holding script_invoke. |
Whatever input was passed. |
request.selected |
You, from History's context menu. |
ctx.input is {seq, method, url, host}. |
detect.finding |
Joro, on new findings. |
ctx.trigger.findings[] with id, host, url, name, severity, confidence, category, ruleId. |
fuzzer.complete |
Joro, when a campaign ends. |
ctx.trigger.campaigns[] with status and counts. |
automation.completed |
Joro, when any other automation finishes. |
ctx.trigger.runs[] with automationId, runId, version, outcome, reason, durationMs, call counts, an exitCode on a command, and the run's return value. |
request.captured |
Joro, as traffic is captured. |
ctx.trigger.requests[] with seq, method, host, url, status, contentType. |
Naming an event directly means every one of them. Naming a custom trigger means the ones
that satisfy its conditions. Either way ctx.trigger.type is the event, and
ctx.trigger.triggerId is what actually woke the run — which is how a script watching two
custom triggers on the same event tells them apart.
Give one automation one trigger per event. Duplicates are removed by reference rather
than by event, so naming two custom triggers that watch request.captured saves without
complaint and then quietly runs only the first. If you want either of two conditions to wake
it, that is one trigger with an OR, not two triggers.
A lens may declare only the triggers you start yourself — manual and
request.selected. Anything dispatched is dropped from the manifest when it is saved, since
a lens already runs on every matching request you open.
The ctx column describes a script. A command automation receives the same events, but
the transaction reaches it as placeholder values and its chosen input source rather than as
an object — see Local commands for which trigger supplies what.
callback.interaction is deliberately absent. Every callback listener is built in
Listener-Mode and scripting is proxy-mode only, so it could never fire, and a trigger
that silently never fires is worse than no trigger.
automation.completed is the one name here that is not broadcast anywhere. A per-run event
on Joro's bus would be a firehose an automation client controls, so a finished run reaches
the dispatcher in process and nothing else sees it. An automation never receives its own
completion — the event it would fire on is the one its own run produces, and no interval
could stop that. Two automations can still chain into a cycle, which is what the runaway
breaker below is for.
Every trigger delivers a batch, which is what makes the traffic trigger affordable at
all. One run per interval handles up to 50 events, so there is no process spawn per request.
Up to 200 discrete events buffer between runs; past that the oldest are dropped and the count
is reported to the script as ctx.trigger.dropped rather than merely logged.
A command gets the newest event, not the batch. It has one standard input and one
argument list, so fifty events have nowhere to go — Joro hands it the most recent one and
jumps its cursor to head, skipping whatever arrived in between. The skipped count still
travels in the payload. On request.captured this is the difference that matters: a command
samples traffic at its minimum interval rather than examining all of it, so do not arm
one expecting to see every request. A script is the right body for that.
Arming an automation starts it at the current head of history, so enabling a
request.captured watcher does not replay the engagement.
A traffic-triggered script that sends traffic re-triggers itself, so Joro closes that
loop: a run that made any sends advances its cursor past its own traffic, while a pure
watcher advances only as far as it was actually handed. Longer loops — send, detection scans
it, new finding, trigger — are caught by a runaway breaker: 30 runs in a minute pauses
the automation, raises a notification, and records why. Pausing leaves enabled alone, so a
restart does not re-arm a runaway and re-enabling restores what you meant. A finding an agent
reported through findings_create never fires detect.finding, so an automation client
cannot induce a run.
You can disable individual triggers on an installed automation without editing its manifest, and set a per-automation host whitelist bounding where its triggered runs may send. That whitelist applies only when the run has no whitelist of its own, since a token's own leash is the stricter authority.
The Triggers section of the rail under Settings → Automation → Scripting lists the
ones you have built, with Joro's own events in a Built-in events group below them;
New ▾ → Trigger starts one. A custom trigger is an event plus a graph of conditions
deciding which of those events is worth a run: request.captured where the status is 200
and the response body contains a marker, rather than every captured request.
Without one, an automation armed on request.captured wakes for all of it. That is wasteful
for a script, which can return early — and impossible for a command, which has one
standard input and one argument list and cannot branch at all. Conditions are the only way to
narrow a command.
A trigger is a named object, not a field of an automation. Several automations can name the same one, editing it changes all of them at once, and the list shows which. That is the point of it — a filter you got right belongs in one place — but it means a change has a blast radius, so the editor names what it will reach before you save, and a trigger cannot be deleted while anything still references it. That reach includes webhooks, which watch the same triggers: a filter you narrow to quieten an automation quietens a team channel too. See Webhooks.
A trigger is bounded at 64 boxes, 128 wires and 512 characters per value, and you may keep 500 of them.
Built-in rows are the raw events. They cannot be edited or deleted, and Clone to custom makes an editable copy to start from.
New Trigger opens the graph editor: boxes wired left to right, from the event to the run.
| Box | Does |
|---|---|
| On | The event this watches. One per trigger, fixed once saved. |
| Condition | One test against one field of the event. |
| AND / OR | Passes when every / any input wired into it passes. |
| NOT | Inverts the one input wired into it. |
| Run | The automation runs when what is wired here passes. |
Drag a box to move it — where you leave it is saved with the trigger. Drag from a port to another port to wire them. Select a box to edit it in the rail on the right; hover a connection to remove it.
Ports are typed, and the rules are worth knowing because they are what the canvas will refuse:
- The event feeds conditions, and only conditions. Every condition reads from it, and a condition you add is wired to it for you.
- A condition, and every logic box, produces a true or false, which only logic boxes and Run accept.
- Two conditions do not connect to each other. Combine them through an AND, an OR or a NOT. While you are dragging, boxes that cannot accept the connection fade and the ones that can are highlighted.
- Connections cannot form a loop, and Run takes one input — combine several with an AND or an OR first.
Nothing wired to Run means every event fires it, which is exactly what a built-in is.
A box nothing reaches from Run does nothing. That is allowed, because it is the normal state of a graph you are still building, but it is called out in the editor and in the test result — a condition you wrote and are not getting makes the trigger fire more broadly than the picture suggests.
Tidy lays the graph out again, for one that has become a mess.
Builder is the same trigger as a flat list of conditions combined by one mode, for the common case that does not need a graph. It reads and writes the same document, so switching tabs converts nothing.
It only shows a graph that is genuinely flat — one level of conditions into a single AND or OR. Anything nested, or with a condition feeding two branches, has no honest flat reading, so the builder says so and sends you to the canvas rather than quietly flattening what you built.
A condition is a field, an operator and a value. Which fields exist depends on the event, and Joro serves the list — the editor cannot offer a field or a pairing the server would refuse.
| Event | Fields |
|---|---|
request.captured |
method host url path status contentType protocol responseSize durationMs request.headers request.body request.raw response.headers response.body response.raw
|
detect.finding |
severity confidence category ruleId name host url isNew
|
fuzzer.complete |
campaignId status completed errors
|
automation.completed |
automationId outcome reason trigger calls sendCalls durationMs exitCode value
|
Which operators you get depends on the kind of field, not just on the event. The editor only offers the pairings the server would accept:
| Field kind | Operators |
|---|---|
Text — method host url path severity ruleId automationId … |
is is not contains starts with ends with matches regex matches glob is one of is present
|
Bytes — every .headers, .body and .raw field |
contains starts with ends with matches regex is present
|
Number — responseSize durationMs completed errors calls … |
is is not > < >= <= is one of is present
|
Status — status
|
matches status, plus the numeric set |
Boolean — isNew
|
is is not
|
The bytes row is the one that surprises people: a body or a header block has no is, no
is one of and no matches glob. You are searching a stream, not comparing a value.
matches status takes the same expression History's status filter does:
4xx,403,500-599,none. Every condition can be inverted with not.
String comparisons fold case unless you tick Aa. A field with a closed set of values —
severity, category, content type, method — offers them as a dropdown rather than a text box.
Patterns are RE2: no lookahead, lookbehind or backreferences, and the editor says so while
you type rather than on save.
Two rules decide what a condition does when it cannot answer, and both fail closed:
-
A field the event does not carry satisfies nothing, and
notdoes not rescue it. "The response body does not contain X" is false when the response was binary and there is no body to look in, rather than true because nothing was found. - A body condition never matches a binary or brotli-encoded response. Joro decompresses gzip and deflate; anything else is not text it can search.
Order does not matter to the result, but Joro evaluates cheap tests first, so a status comparison that rejects a request happens before the body regex it saves you.
Test runs the trigger against the most recent captured requests and reports how many of them it matched, with the rows. The pair is the answer: three of the last fifty says something a bare "3" does not, and nothing matching at all usually means a body condition against a response Joro cannot read as text.
Nothing is saved and nothing is run, so a trigger can be tried before it exists. An event with no captured history to replay — a finding, a finished campaign, a finished run — says so rather than reporting zero of zero.
A trigger reference that cannot be resolved never fires, and never means "no filter." Deleting a trigger an automation still names leaves that automation silent, not firing on every raw event, and the same holds for a trigger Joro cannot read. This is why deleting one is refused while it is in use: the failure is safe, but you would rather be told now.
A trigger this build cannot understand — a hand-edited file, or one written by a newer Joro — still appears in the list with the reason shown, so you can see it and fix it, rather than vanishing. The startup log names any automation it silenced.
Custom triggers are stored in ~/.joro/triggers.json, next to your installed automations and
global for the same reason they are: a project config is published to teammates, and an
automation that referenced a per-project trigger would resolve on one engagement and dangle
on the next.
Exporting an automation as a .jauto package carries the definitions of any custom triggers
it names, so the package stays self-contained. Importing one installs any it brings that this
Joro does not already have, and tells you which.
request.selected is started by you and never by the dispatcher. Declaring it puts the automation in History's right-click menu, under Run automation, on both the row and the detail pane. The selected request arrives as ctx.input, and the run report opens over the page.
Sends are not stripped for request.selected. You picked the request and you picked the moment.
A lens is an automation that renders a tab beside Raw and Render in a request or response viewer. Declare one in the manifest:
"lens": { "label": "JWT", "part": "response" }label is up to 24 characters, because it has to fit a tab. part is request, response or both, and defaults to response.
The viewer hands the automation the bytes it is already showing as ctx.input.raw, base64-encoded, capped at 512 KB with ctx.input.truncated set when it was cut. ctx.input.part says which half. Return a string, or {text, language} where language is one of json, html, css, javascript or js to get syntax highlighting; anything else renders as wrapped plain text.
async function run(ctx) {
const bytes = atob(ctx.input.raw)
const tokens = bytes.match(/eyJ[\w-]+\.[\w-]+\.[\w-]+/g) ?? []
if (!tokens.length) return 'No JWTs found.'
const decoded = tokens.map((t) => {
const [header, payload] = t.split('.').slice(0, 2)
.map((seg) => atob(seg.replace(/-/g, '+').replace(/_/g, '/')))
return JSON.stringify({ header: JSON.parse(header), payload: JSON.parse(payload) }, null, 2)
})
return { text: decoded.join('\n\n'), language: 'json' }
}Lenses appear in all six viewers that show captured bytes: History, Detect, Fuzz, Map, Manipulate, and the flagged-request modal.
A lens run has its send capabilities removed. joro.http.resend comes back forbidden rather than merely failing. A lens tab runs on selection, unattended, over bytes you are already looking at — it has no business reaching a target, and that guarantee belongs to the surface rather than to what the client asked for. Its output is rendered and discarded; nothing is stored, and the run is recorded in Activity like any other.
That guarantee is a grant restriction, so it does not carry to a command lens: a command holds no capabilities to remove, and what it reaches is bounded by --automation-commands and by what the command itself does. A command lens renders its standard output according to the command block's output field.
The Lenses panel says as much whenever one is installed, and the reason is worth keeping in front of you: Joro cannot stop a program reaching the network the way it stops a script, and a lens runs on every matching request you open — a spawn per selection, unattended. Keep command lenses to filters that only read what they are given.
A lens declares no triggers, so enabling one arms nothing. Visibility is the automation's own enable switch rather than a second toggle — and since everything installs disabled, a fresh lens does not appear in every viewer unasked.
The Lenses section of the rail under Settings → Automation → Scripting lists them with the tab label, which half they apply to, their order, and whether they are shown. Retitling, repointing or reordering a lens is your override and survives an update to the author's source; clearing a field falls back to the manifest.
Settings → Automation → Activity holds Script runs beneath the invocation log. Each row is one run: when, who started it, how it ended, call counts and duration. Opening one shows the console output, the returned value, and the source verbatim.
A command run adds two things. Its exit code is shown beside the outcome, amber when non-zero, because a non-zero exit is reported rather than treated as a failure and you are the one who decides what it meant. Beneath the output sits Files this command wrote — everything the program left in {{SCRATCH}}, one row each with its size and a download link. A file that pushed the run past its artifact budget is still listed, greyed out and marked dropped, over the artifact budget, so a missing result reads as a budget you can raise rather than a command that did not write anything.
Source rather than a hash is deliberate. For an installed automation a hash answers "did this change?", but a one-shot script submitted over MCP has no artifact to compare against, and a hash would leave you holding a fingerprint of code nobody kept. On a tool that sends traffic to a client's systems, reading exactly what an agent ran is the point.
It is a bounded ring of the 50 most recent runs, in memory, gone when the process exits. Clear empties it.
The run log is readable from the UI only. A script that could read it would be a lateral channel between tokens.
Everything in this section is about the script kind. A command's boundaries are a different list and are stated where the command is: Enabling commands for what turns it on and what can never reach it, and Command limits for what Joro bounds once it can run. The two differ in exactly the way that matters — a script is confined by a sandbox, and a command is confined by your decision to arm it.
There is no network, filesystem, process or module access. joro is the only way out, and it reaches exactly the thirty-four methods above — no configuration writes, no detection writes, no scope writes, nothing privileged, and no way to start another script. The one thing a run can send outside the machine is a notification through a webhook you configured and opened to automation, to a destination it is never told.
A run cannot widen itself. Limits clamp down, the manifest cannot request a capability, and a run's policy is inherited rather than chosen.
A run cannot read the run log, and a one-shot script has no storage namespace.
It is the same one Automation closes on, and it covers both kinds. The sandbox contains the script; the token is a leash on the agent. The worker process is a real boundary against code that allocates without bound or loops forever. It is not a boundary against the operator's own machine — anything running as you can already drive Joro's local API directly. What the script kind buys is a bounded, audited, reviewable way to hand an agent leverage, with the source kept.
A command keeps the second half of that and drops the first. There is no sandbox, and there is no token either — no capability sits behind command execution, so nothing an agent holds reaches one. What stands between a local tool and your machine is that you read it and armed it. What you keep instead is the record: the absolute path the program resolved to when you saved, the exact arguments each placeholder landed in, the exit code, the files the run left behind, and a source hash that says which command this was.
- Automation — capability tokens, the MCP server, grants and Activity
- Automation-Examples — worked examples, including an installed automation
- Settings — the Automation category and its three sub-tabs
- Webhooks — the endpoints these triggers can also fire, and what an automation may send through one
- History — highlights, and the right-click menu that runs an automation on a request
- Plugins — the native extension path, where the platform supports it
- Configuration — the launch flags