# `scribe-runner` CLI reference

Executes Scribe flow jobs for a web API.

`scribe-runner` is one binary with the Scribe client compiled in. It is
started with three things — the web address, the runner's id and an API token
— checks in with the web API, asks it for work, and executes each job in a
workspace it keeps per repository and branch. It needs no `scribe` executable
on the machine and reads no configuration file; every setting is an argument
or an environment variable.

The web registration guide assumes `scribe-runner` is installed and links to no
download. Take the `scribe-runner-<platform>` archive from the manual `build`
workflow, or build it with `scripts/install.sh` (`scripts/install.ps1` on
Windows) beside the other executables. `scribe-runner` has no `--json`;
`--log-format json` formats its logs.

See [flows](/docs/flows.md) for definitions, runs, runner registration and the
runner's behaviour (workspaces, shells, environment, stopping), and the
[web interface guide](/docs/web-interface.md) for the Flows tab.

[scribe](/docs/reference/cli/scribe.md) | [scribe-server](/docs/reference/cli/scribe-server.md) | [scribe-auth](/docs/reference/cli/scribe-auth.md) | [scribe-ctl](/docs/reference/cli/scribe-ctl.md) | [scribe-gateway](/docs/reference/cli/scribe-gateway.md)

## Reading this reference

Command synopses below come from this checkout’s built `--help` output.
Angle brackets denote values, square brackets optional arguments. `[OPTIONS]`
includes the logging options below and the command's own options. Logging
options work before or after a subcommand.

Run `scribe-runner --help` for the command list, `scribe-runner COMMAND --help`
for command help, and `scribe-runner --version` for the build version.

## Commands

```text
Usage: scribe-runner [OPTIONS] <COMMAND>

Commands:
  run      Run in the foreground; Ctrl+C finishes the current job first, a second one aborts it
  service  Install or remove the service that runs this runner with the machine
```

```text
Usage: scribe-runner service [OPTIONS] <COMMAND>

Commands:
  install    Install as a service that starts with the machine (systemd, launchd or the Windows service manager)
  uninstall  Stop and remove the service installed for a runner
```

## Runner options

`run` and `service install` take the same runner options:

| Argument or option | Meaning |
| --- | --- |
| `--server <URL>` | Web address, as in the browser: [https://scribe.example](https://scribe.example) (or SCRIBE_RUNNER_SERVER) |
| `--runner <ID>` | The runner's id from its registration (or SCRIBE_RUNNER_ID) |
| `--token-file <FILE>` | Read the API token (runners:write and flows:read) from a file; without it, SCRIBE_RUNNER_TOKEN |
| `--workdir <DIR>` | Where workspaces, scripts and logs live (default: the user configuration root, runner/<id>; or SCRIBE_RUNNER_WORKDIR) |
| `--ca <FILE>` | CA certificates (PEM) that verify an https:// address instead of the public roots; repeatable |
| `--poll-interval <SECONDS>` | Seconds between asks for work while idle [default: 5] |
| `--keep-workspaces <COUNT>` | Branch workspaces kept per repository; the least recently used beyond this are removed [default: 8] |

The address is the one the browser uses, with any path prefix the deployment
serves the web interface under; the runner appends `/api/v1`. An `https://`
address is verified against the public web roots, or against the `--ca`
files alone when any are given, so a private issuer is one flag (as
`scribe --tls-ca` works); `HTTPS_PROXY` is honoured. The client offers the
hybrid post-quantum key exchange X25519MLKEM768 first and also accepts
classical X25519, which a proxy in front of the web API may offer alone.
`http://` is accepted only for a loopback host (`localhost` or a loopback IP
such as `127.0.0.1` or `[::1]`); any other plaintext address is refused:

```text
scribe-runner: "http://scribe.example" is plaintext on a network address; use https:// (http:// is accepted only for localhost)
```

The token comes from `--token-file`, or from `SCRIBE_RUNNER_TOKEN` when no
file is named; there is no option that takes the token itself, because
process listings show arguments to every account on the machine. It must
carry `runners:write` and `flows:read`, and its owner must be the runner's
owner or a Maintainer of every repository the runner serves. It is never
logged and never passed to a step (see [Trust boundary](/docs/reference/cli/scribe-runner.md#trust-boundary)).
The token is for the web API only; it never reaches a storage server.

The runner also has its own device key, `device-key.pem`, created on first
use in a private file (mode 0600 on Unix): in the directory of the
`--token-file`, or in `runner-credentials/<id>/` under the user configuration
root when the token comes from `SCRIBE_RUNNER_TOKEN`. Only its public key
leaves the machine. At check-in the runner sends that public key, and the web
API has auth derive a credential for it from the token's automation
credential (reader ceiling, same account and expiry, revoked with the token)
and returns the credential certificate with the storage and auth endpoints
and their CAs. The runner fetches its access certificates from auth itself
and keeps every certificate in memory. Those CAs are all it trusts: a CA
handover is followed in memory, and after a job that met any other CA change
the runner checks in again for the CAs the API delivers now (see
[CA rotation and handover](/docs/auth.md#ca-rotation-and-handover)). A web deployment without a TLS auth
service configured answers check-in with `409 runner_auth_unconfigured`, which
the runner logs while it keeps retrying. On Unix a token file or
`device-key.pem` that other accounts can access is refused ("… is accessible
to other accounts (mode 644); run chmod 600 on it"), as is one in a directory
other accounts can write that is not sticky; on Windows one whose access list lets another account
read it, or whose directory lets another account delete entries, is refused the same way. See
[Registration and tokens](/docs/flows.md#registration-and-tokens) and
[identity](/docs/auth.md#identity-device-keys-and-certificates).

The work directory holds `workspaces/` (one directory per repository and
branch, or per `workspaces:` folder a flow declares, and `workspaces.json`
describing them; each is registered on the server with the label
`<runner name>@<host>` and the tags `runner`, `runner-id=<id>`), `jobs/`
(scripts of the job in progress, and the archives
its bundles are packed into before upload), `logs/` for a service
installation, and `scribe-runner.pid` while a runner is up. It never holds
the token or the device key: a service installation keeps both apart, under
`runner-credentials/<id>/` in the user configuration root. It is locked while a runner uses it; a second runner on the
same machine needs its own. Without `--workdir` it is `runner/<id>` under the
user configuration root (`~/.config/scribe`, `~/Library/Application
Support/Scribe`, or `%LOCALAPPDATA%\Scribe`); put a build machine's on the
disk with room for its projects.

### Bundles

A job whose flow declares `bundles:` ends with a `Publish bundles` step. The
runner resolves the rules against the workspace, walking only the directories
the rules can reach, packs archive targets into stored (uncompressed) zips
under `jobs/`, and uploads three files at a time in parts of the size the API
names, resuming after a lost connection from the byte the API has. Each file
is hashed (BLAKE3) as it is read for sending and the digest goes with its last
part, so the web can verify what it stored. The step's log
says what matched, what was packed and how long each upload took; a rule
that matches nothing is noted, not an error. The job's `timeout-minutes`
covers publishing. See [Bundles](/docs/flows.md#bundles) for the rule syntax
and retention.

## Logging options

| Argument or option | Meaning |
| --- | --- |
| `--log-level <LEVEL>` | off\|error\|warn\|info\|debug\|trace (default info) |
| `--log-filter <FILTER>` | Target filter, e.g. 'info,scribe_client=debug' (or SCRIBE_LOG) |
| `--log-format <FORMAT>` | text\|json |
| `--log-dir <DIR>` | Write daily log files here instead of stderr |
| `--log-keep <COUNT>` | Daily log files kept in --log-dir (default 14) |
| `-v, --verbose...` | -v for debug, -vv for trace |
| `-q, --quiet` | Errors only |
| `--timings` | Report nested phase timings to the diagnostic sink |

The runner's own diagnostics (check-in, claims, job outcomes, failures to
reach the API) go to stderr or to daily files. A step's output is not
diagnostics: it goes to the run's log in the web interface, not to the
runner's log, streamed as the step writes it over one request held open per
step (reopened after a silence, a lost connection, a megabyte or five
minutes, resuming where the API confirmed). What a step wrote while the API
was unreachable is kept, up to 8 MiB, and sent when it is back.

## `run`

```text
scribe-runner run --server https://scribe.example --runner <id> --token-file /etc/scribe-runner/token
```

Runs in the foreground until stopped. Ctrl+C or SIGTERM lets the job in
progress finish, then exits; a second one aborts the job, which is reported
failed with "the runner was stopped". A runner that cannot reach the API, is
disabled, or whose token is rejected logs why and keeps trying at a slower
pace; it never exits over a state the API named, so it picks up as soon as the
cause is fixed.

While the runner is up — started this way or by a service — the root of its
work directory holds `scribe-runner.pid` with the process id, one line, and
the file is removed when the runner exits. It answers "is a runner using
this directory, and which process" without a process listing; a file that is
still there while no runner runs was left by a crash, and the next start says
so as it replaces it. The work directory is also locked while a runner uses
it, so a second runner started on the same directory is refused rather than
sharing it.

## `service install`

```text
scribe-runner service install --server https://scribe.example --runner <id> --token-file FILE [--workdir DIR] [--system [--user NAME]]
```

| Option | Meaning |
| --- | --- |
| `--system` | A system-wide service rather than one for the current user (needs administrator rights) |
| `--user <NAME>` | The account a system service runs as (systemd only) |

`SCRIBE_RUNNER_TOKEN` in place of `--token-file` works too. Checks the
registration and token in with the API first, so a typo is a message rather
than a service that restarts forever. Then copies the token to
`runner-credentials/<id>/token` under the installing user's configuration
root (`~/.config/scribe`, `~/Library/Application Support/Scribe`, or
`%LOCALAPPDATA%\Scribe`), in a directory and file readable by that user only
and outside the work directory, and registers a service that runs
`scribe-runner run` with the same options and `--token-file` pointing at it.
The service's device key is `runner-credentials/<id>/device-key.pem`: the
installing check-in creates it there (not beside the `--token-file` given), so
the key the web API had certified is the one the service uses. The command
prints the service name, the runner, the unit file, the stored token and the
work directory.
A `--workdir` that contains that directory, or lies inside it, is refused.

- **Linux**: a systemd unit, `~/.config/systemd/user/scribe-runner-<id>.service`
  or with `--system` `/etc/systemd/system/…`, enabled and started with
  `systemctl [--user] enable --now`. A user service stops with the login
  session unless `loginctl enable-linger` is on; the command says so. The unit
  restarts the runner after a failure, and its stop timeout is an hour so a
  running job can finish.
- **macOS**: a launchd agent, `~/Library/LaunchAgents/com.flyingrat.scribe-runner-<id>.plist`,
  or with `--system` a daemon under `/Library/LaunchDaemons`, bootstrapped at
  once and kept alive; output goes to `<workdir>/logs/launchd.log` and the
  runner's own daily files beside it.
- **Windows**: a service created with `sc.exe`, started automatically at boot,
  logging to `<workdir>/logs`. It runs as LocalSystem; to build under a user
  account, `sc.exe config scribe-runner-<id> obj= .\user password= …`. The
  service manager starts the executable with the hidden `service entry`
  command.

The service name is `scribe-runner-` followed by the first eight characters of
the runner id. Installing again for the same runner replaces the service.

## `service uninstall`

```text
scribe-runner service uninstall --runner <id> [--system]
```

Stops and removes the service installed for the runner (`--system` when it
was installed that way), then deletes the token `service install` stored and
its `runner-credentials/<id>` directory, device key included. The work directory and its
workspaces are left in place; remove them by hand when the machine is done
with them.
Removing the runner's registration in the web interface is separate.

## Environment

| Variable | Meaning |
| --- | --- |
| `SCRIBE_RUNNER_SERVER` | Default for `--server` |
| `SCRIBE_RUNNER_ID` | Default for `--runner` |
| `SCRIBE_RUNNER_TOKEN` | The token, when `--token-file` is not given; the device key is then kept in `runner-credentials/<id>/` |
| `SCRIBE_RUNNER_WORKDIR` | Default for `--workdir` |
| `SCRIBE_LOG` | Diagnostic filter, as for every Scribe executable |
| `SCRIBE_TRANSFER_JOBS` | Parallel object transfers during checkout, 1–8 (default 4) |

## Trust boundary

A runner executes code written by anyone who can write the flows of a
repository it is bound to: every `run:` step is such code, running as the
runner's OS account. Treat binding a runner to a repository as giving that
repository's Writers a shell on the machine, under that account.

- **Steps get no runner credentials.** A step starts from an empty
  environment plus `PATH`, `HOME`, `USER`, `LOGNAME`, `SHELL`, `LANG`,
  `LANGUAGE`, `LC_*`, `TZ`, `TERM`, `TMPDIR`, `TEMP` and `TMP`, and on
  Windows `SystemRoot`, `SystemDrive`, `windir`, `ComSpec`, `PATHEXT`, `OS`,
  `USERNAME`, `USERPROFILE`, `HOMEDRIVE`, `HOMEPATH`, `APPDATA`,
  `LOCALAPPDATA`, `ProgramData`, `ProgramFiles`, `ProgramFiles(x86)`,
  `ProgramW6432`, `CommonProgramFiles`, `CommonProgramFiles(x86)`,
  `CommonProgramW6432`, `NUMBER_OF_PROCESSORS` and `PROCESSOR_ARCHITECTURE`;
  then the run's variables and the flow's and job's `env`. Nothing else the
  runner was started with reaches a step — not `SCRIBE_RUNNER_TOKEN`, not
  another `SCRIBE_*` setting, not a cloud or signing key the operator
  exported. A build that needs a host variable sets it in the flow's `env`.
- **The account is the boundary.** Job steps run as the runner's OS user, so
  a step can still read every file, and inspect every process, that account
  can: the token file, `device-key.pem`, the runner's own environment
  (`/proc/<pid>/environ`, `ps eww`) and memory, the account's SSH keys and
  caches. Keeping the token and the key out of the work directory and out of
  step environments stops them leaking by accident, not a step written to
  find them. Run runners under a dedicated low-privilege OS account that
  holds nothing else of value, and do not reuse a developer's or an
  administrator's account.
- **Choose the token's account for what it exposes.** The runner's token and
  its device key can be taken by such a step. Whoever holds them can read
  what the token's owner can read (the derived credential's ceiling is
  reader), and can act as that owner's runners — claim their jobs, report
  results and upload bundles — for the repositories it reaches. Use the token of an account (a shared CI
  account, for instance) whose read access you are comfortable exposing to
  the Writers of every repository bound to runners it drives, and bind
  runners of one token only to repositories with the same set of trusted
  Writers.

## Exit status

`0` after a clean stop; `1` for an argument, installation or startup failure,
reported on stderr. A job's failure is reported to the web API, not through
the exit status: the runner keeps running.
