diff --git a/.gitignore b/.gitignore index e6e7e3b..71dcddf 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,4 @@ packages/cli/README.md # filled-in deploy templates: real secrets, never the .example versioned above packages/cli/assets/deploy/*.local.yaml -packages/cli/assets/deploy/brain.env +packages/cli/assets/deploy/runner.env diff --git a/.prettierignore b/.prettierignore index dc2d4b4..526b3ed 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,7 +5,7 @@ coverage bun.lock CHANGELOG.md -# Committed copies of the brain repo's own generated JSON Schemas -# (packages/contract/scripts/sync-brain-schemas.mjs). Left in the brain's own +# Committed copies of the hub repo's own generated JSON Schemas +# (packages/contract/scripts/sync-hub-schemas.mjs). Left in the hub's own # export format so a sync is a plain copy, never a copy plus a reformat. -packages/contract/fixtures/cerveau-schemas +packages/contract/fixtures/hub-schemas diff --git a/CHANGELOG.md b/CHANGELOG.md index 21d51f9..3564229 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,16 @@ All notable changes to `codesema` (the npm package in `packages/cli`) are documented here. Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [SemVer](https://semver.org). +## [0.18.0] - 2026-08-27 + +### Added + +- **A server can now receive its GitHub token and Claude Code credentials through the hub without the hub itself ever being able to read them.** `codesema runner connect --url --token ` mints this machine's own identity and prints a fingerprint for it; the new `codesema runner await-secrets --env-file [--timeout ]` then blocks until a human runs the new `codesema runner autoconfig` from their own workstation and delivers the repository URL and the two runtime secrets over a sealed channel (X25519 key agreement, AES-256-GCM), writing the env file itself (mode `0600`) and printing the repository URL on stdout, empty if the wait times out, so the caller never has to parse the file back out. The hub only ever relays sealed bytes it cannot read. The one manual step this exchange still asks for, comparing the fingerprint `runner connect` printed against the one `runner autoconfig` shows, is the whole defense against a hub that lied about which machine it was handing the secrets to: the same trust-on-first-use doctrine SSH host keys use. `codesema runner list` shows the identities a workspace has minted this way. `packages/cli/assets/deploy/install.sh` needs only `CODESEMA_HUB_TOKEN` to boot now: leaving `REPO_URL`, `GH_TOKEN` or `CLAUDE_CODE_OAUTH_TOKEN` unset switches it from direct mode into this exchange automatically, re-run-safe the same way the rest of the script already is. It also now checks that a detected `docker` is actually usable (`docker info` as the invoking user), not merely installed, and fails with the missing-group fix rather than at the first ticket's container run; rootless podman needs no such check. + +### Changed + +- **The "brain" vocabulary is gone from the CLI: a runner is the local daemon working tickets, a hub is the remote store that owns them (codesema.com or self-hosted).** `codesema brain ` is now `codesema runner ` across the board (`connect`, `status`, `ticket`, `serve`, `stop`, `install-service`, `uninstall-service`), and `codesema workspace --brain` is `codesema workspace --runner`. `install-service` now writes and enables `codesema-runner.service`, generated from the template shipped at `assets/systemd/codesema-runner.service`, and removes a previously installed `codesema-brain.service` unit automatically. The daemon's env file is `runner.env` (`~/.config/codesema/runner.env`), and the two identifiers it reads changed from `CODESEMA_BRAIN_URL`/`CODESEMA_BRAIN_TOKEN` to `CODESEMA_HUB_URL`/`CODESEMA_HUB_TOKEN`. The web settings panel's auto-merge field is `runnerAutoMerge`; the old `brainAutoMerge` key is still read from an existing config file, so nothing already saved breaks. `@codesema/contract` 0.9.0 renames the wire field `brain_ticket` to `hub_ticket`, with the old name still accepted on read. On boot, this repository's outbox and pidfile migrate in place to their new names, `.codesema/hub-outbox.jsonl` and `.codesema/runner.pid`. + ## [0.17.0] - 2026-08-27 ### Added diff --git a/README.md b/README.md index 3701624..fb2dbac 100644 --- a/README.md +++ b/README.md @@ -64,23 +64,23 @@ From the page you can also: **Merging.** The workspace does not merge on its own unless you ask it to: `mergePolicy` defaults to `human`. Task state lives under `.codesema/tasks//` in the repository it belongs to. -## Brain mode +## Runner mode -A brain is a small local service that owns a backlog of tickets for a repository. Pointed at one, the workspace runs a hands-off loop: the brain publishes tickets, the workspace codes them, ships them, reviews them and reports every transition back. +A runner is a background process that connects the workspace to the codesema hub (codesema.com, or your own instance) and works hands-off through its backlog of tickets for a repository: the hub publishes tickets, the runner codes them, ships them, reviews them and reports every transition back. ```bash -codesema brain connect --url http://localhost:3000 --token csk_. -codesema brain status # brain, account, this repo, ready ticket count -codesema brain ticket --issue 42 # draft and publish a ticket from a forge issue -codesema brain ticket --title "…" --prompt "…" # same, from a free-form prompt -codesema workspace --brain # workspace plus the brain daemon, same process -codesema brain serve [--detach] # alias for the line above -codesema brain stop # stops a detached daemon for this repo +codesema runner connect --url http://localhost:3000 --token csk_. +codesema runner status # hub, account, this repo, ready ticket count +codesema runner ticket --issue 42 # draft and publish a ticket from a forge issue +codesema runner ticket --title "…" --prompt "…" # same, from a free-form prompt +codesema workspace --runner # workspace plus the runner daemon, same process +codesema runner serve [--detach] # alias for the line above +codesema runner stop # stops a detached daemon for this repo ``` -A brain and a sync workspace are the same account: `brain connect` stores its token next to the `codesema sync` credentials. `brain ticket` runs the configured agent once, outside the workspace, to write the ticket body in the grammar the brain requires; a body the lint rejects gets one retry with the lint's reasons folded into the prompt. +A runner and a sync workspace are the same account: `runner connect` stores its token next to the `codesema sync` credentials. `runner ticket` runs the configured agent once, outside the workspace, to write the ticket body in the grammar the hub requires; a body the lint rejects gets one retry with the lint's reasons folded into the prompt. -With `--brain`, the workspace polls the brain in the background, drafts the ticket requests waiting on this repository and, when no task is already running here, claims the next published ticket and hands it to the same task manager the UI drives. Reports the brain could not receive are queued and replayed. Auto-merging a brain ticket's task once it ships clean is controlled by `brainAutoMerge` (on by default), independently of `mergePolicy`. +With `--runner`, the workspace polls the hub in the background, drafts the ticket requests waiting on this repository and, when no task is already running here, claims the next published ticket and hands it to the same task manager the UI drives. Reports the hub could not receive are queued and replayed. Auto-merging a hub ticket's task once it ships clean is controlled by `runnerAutoMerge` (on by default), independently of `mergePolicy`. ## Working without a forge @@ -98,7 +98,7 @@ Without an `origin` remote, without `gh`/`glab`, or offline, codesema keeps work | Command | What it does | | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | | `codesema` | Opens the workspace in an interactive terminal; behaves like `review` otherwise | -| `codesema workspace [--brain]` | The workspace, explicitly | +| `codesema workspace [--runner]` | The workspace, explicitly | | `codesema review [--branch] [--target] [--full] [--dual] [--fail-on]` | Reviews a local branch | | `codesema menu` | Terminal menu: workspace, review, dual review, show, cloud (sync and link), config | | `codesema config` | Language, agent, model, effort, auto-sync and the other settings | @@ -107,7 +107,7 @@ Without an `origin` remote, without `gh`/`glab`, or offline, codesema keeps work | `codesema export [--review] [--out]` | Exports the review as Markdown (`--out -` for stdout) | | `codesema sync` / `codesema sync delete` | Pushes the latest review to a codesema.com workspace, or erases everything synced | | `codesema link [code]` | Links this workspace to a codesema.com account | -| `codesema brain ` | See [Brain mode](#brain-mode) | +| `codesema runner ` | See [Runner mode](#runner-mode) | Shared flags: `--agent `, `--port ` (default 4400, 20 ports scanned from there), `--timeout ` (default 900), `--no-open`, `--force` (sync), `-h`, `-v`. `codesema --help` lists them all. @@ -145,7 +145,7 @@ Some keys are global only: they govern the machine (its load, its disk) or give | `mergeStrategy` | unset (`merge`, `squash`, `rebase`) | global only | | `deleteBranchAfterMerge` | `false` | global only | | `allowMergeWithoutChecks` | `false` | global only | -| `brainAutoMerge` | `true` | global only | +| `runnerAutoMerge` | `true` | global only | | `syncUrl`, `syncWorkspaceId`, `syncSecret`, `syncAutoPush` | unset | global only | `maxParallelTasks` is the former name of `maxConcurrentAgents`. It is still honoured, with a warning at startup. @@ -204,7 +204,7 @@ Workspace tasks are the opposite case, since they exist to edit code: they are c | `XDG_CONFIG_HOME` | Base of that default when `CODESEMA_CONFIG_DIR` is unset | | `CODESEMA_NO_UPDATE_CHECK` | Any non-empty value skips the startup npm version check | | `CODESEMA_SYNC_URL` | Points `sync`/`link` at another codesema.com host | -| `CODESEMA_BRAIN_MODE` | Set by `workspace --brain`; starts the brain daemon | +| `CODESEMA_RUNNER_MODE` | Set by `workspace --runner`; starts the runner daemon | | `NO_COLOR`, `TERM=dumb` | Turn coloured terminal output off | | `LC_ALL`, `LC_MESSAGES`, `LANG` | Preselect the wizard's language question | diff --git a/docs/deploy-vm-arm.md b/docs/deploy-vm-arm.md index cea3b0d..91a1692 100644 --- a/docs/deploy-vm-arm.md +++ b/docs/deploy-vm-arm.md @@ -1,17 +1,18 @@ # Deploying an arm on a VM -Arm mode runs `codesema brain serve` unattended on a machine you do not sit -in front of, connected to a brain (today: the codesema.com production -brain), working one repository around the clock. This is the runbook for +Arm mode runs `codesema runner serve` unattended on a machine you do not sit +in front of, connected to the codesema hub (codesema.com, or your own +instance), working one repository around the clock. This is the runbook for standing one up: first as a local rehearsal VM (multipass), later — once the gate below is lifted — on a real server, from the exact same artifact. The provisioning content lives in `packages/cli/assets/deploy/`: -- `cloud-init.yaml.example` — the full cloud-init file for a fresh machine. - Copy it to `cloud-init.local.yaml` (gitignored) and fill in every - `__PLACEHOLDER__`. -- `brain.env.example` — the five values `cloud-init.local.yaml` (and +- `cloud-init.yaml.example`: the full cloud-init file for a fresh machine. + Copy it to `cloud-init.local.yaml` (gitignored); autoconfig mode needs only + `CODESEMA_HUB_TOKEN` filled in, direct mode needs the three commented-out + lines too. +- `runner.env.example`: the five values `cloud-init.local.yaml` (and `install.sh`, below) need, with one line each on where to mint them. - `install.sh` — the same installer `cloud-init.yaml.example` calls under the hood, runnable directly against a server you already have instead of @@ -23,18 +24,19 @@ The provisioning content lives in `packages/cli/assets/deploy/`: checks are safe to run today: the VM is outbound-only on your own machine, so the blast radius of anything going wrong is near zero. -**Step 8 (a real, internet-facing server) is gated.** The order channel a -brain uses to tell an arm to ship or reply to a task has no signature or +**Step 8 (a real, internet-facing server) is gated.** The order channel the +hub uses to tell an arm to ship or reply to a task has no signature or confirmation yet, and there is no kill switch to cut an arm off. Exposing a 24/7 arm on a machine you do not control physical access to, before that -hardening lands, means an attacker who reaches the brain's order channel +hardening lands, means an attacker who reaches the hub's order channel reaches your server. Do not run step 8 until that work has shipped. ## Prerequisites - **On your workstation:** `/dev/kvm` present, and `multipass` (`sudo snap -install multipass`). `codesema` and `claude` installed globally (both are - needed only to mint tokens below, not to run the arm itself). +install multipass`). `codesema` and `claude` installed globally (needed to + mint tokens directly, or to run `codesema runner autoconfig`; never to run + the arm itself). - **A GitHub personal access token** with `repo` scope, for the bench repository the arm will clone and push to. - **codesema.com prod reachable and current.** `curl -s -o /dev/null -w @@ -45,9 +47,51 @@ install multipass`). `codesema` and `claude` installed globally (both are - **The bench repository already registered** in your codesema.com account (connected through the dashboard's GitHub integration). -## 1. Mint a brain token against prod +## Autoconfig (recommended) -`codesema brain connect` takes a token in the form +The fastest path, and the one where your GitHub token and Claude Code +credentials never reach the hub in the clear: the exchange runs over a +sealed channel (X25519 key agreement, AES-256-GCM), so the hub only ever relays +bytes it cannot read. + +1. Mint just the hub token, [as in step 1 below](#1-mint-a-hub-token-against-prod), + then run the install with only that value set: + + ```bash + CODESEMA_HUB_TOKEN=csk_... bash packages/cli/assets/deploy/install.sh + ``` + + For the VM path, fill in only `CODESEMA_HUB_TOKEN` in + `cloud-init.local.yaml`'s `runner.env` section and leave the three + commented-out lines there alone; see [step 3](#3-fill-in-the-templates). + +2. The install registers an identity with the hub, prints a fingerprint for + it, and then blocks, waiting. + +3. From your own workstation, inside the repository this arm will work: + + ```bash + codesema runner autoconfig + ``` + + It asks what it needs and shows its own fingerprint for this exchange. + Compare that fingerprint, by eye, against the one the server printed in + step 2, before confirming anything. This comparison is the only defense + here: encryption proves nobody in the middle can read the secrets in + transit, not that you are encrypting them to the machine you think you + are. A mismatch means stop and investigate, never retry and hope. + +4. Once confirmed, the server receives the repository URL and both runtime + secrets and continues exactly as direct mode does from here: see + [Verify](#6-verify). + +Direct mode, starting at [step 1](#1-mint-a-hub-token-against-prod) below, +remains available: mint and paste in all four values yourself, with no +exchange to compare fingerprints on. + +## 1. Mint a hub token against prod + +`codesema runner connect` takes a token in the form `csk_.` — the same shape `codesema sync`/`codesema link` already use, minted the same way, in a scratch config directory so it never touches your own workspace credentials: @@ -65,11 +109,11 @@ the CLI prints "linked": ```bash WORKSPACE_ID=$(node -p "require('/tmp/codesema-mint/config.json').syncWorkspaceId") SECRET=$(node -p "require('/tmp/codesema-mint/config.json').syncSecret") -echo "csk_${WORKSPACE_ID}.${SECRET}" # → CODESEMA_BRAIN_TOKEN +echo "csk_${WORKSPACE_ID}.${SECRET}" # → CODESEMA_HUB_TOKEN rm -rf /tmp/codesema-mint ``` -Copy the printed value into `brain.env`'s `CODESEMA_BRAIN_TOKEN` (or +Copy the printed value into `runner.env`'s `CODESEMA_HUB_TOKEN` (or straight into `cloud-init.local.yaml`), then clear your terminal scrollback. ## 2. Generate the other two tokens @@ -90,20 +134,20 @@ from your GitHub account settings. cp packages/cli/assets/deploy/cloud-init.yaml.example packages/cli/assets/deploy/cloud-init.local.yaml ``` -Edit `cloud-init.local.yaml` and replace: +Edit `cloud-init.local.yaml`'s `/etc/codesema/runner.env` section: replace +`CODESEMA_HUB_TOKEN`'s placeholder with the token from step 1, then +uncomment and fill in the three direct-mode lines (`CLAUDE_CODE_OAUTH_TOKEN`, +`GH_TOKEN`, `REPO_URL`) with the tokens from step 2 and the bench +repository's HTTPS clone URL. Leave those three commented out instead to use +[autoconfig](#autoconfig-recommended) once the VM is up. -- the three `__PLACEHOLDER__` values in the `/etc/codesema/brain.env` - section, with the tokens from steps 1-2; -- `__CODESEMA_BENCH_REPO_URL__` in `provision.sh`, with the bench - repository's HTTPS clone URL. - -`cloud-init.local.yaml` and any `brain.env` are gitignored — this is the one +`cloud-init.local.yaml` and any `runner.env` are gitignored: this is the one file in this workflow that ever holds real secrets. ## 4. Switch the repository to arm mode In the codesema.com dashboard, open the bench repository's settings and -switch its execution mode from server to arm. This tells the brain to stop +switch its execution mode from server to arm. This tells the hub to stop waiting for its own scheduler on this repository and instead hand tickets to whichever arm connects and claims them. @@ -124,11 +168,11 @@ the initial `node:26` pull). Progress is logged to ```bash multipass shell codesema-arm sudo tail -f /var/log/codesema-provision.log # until it prints "done" -sudo systemctl --user -M codesema@ status codesema-brain.service -sudo journalctl --user -M codesema@ -u codesema-brain.service -f +sudo systemctl --user -M codesema@ status codesema-runner.service +sudo journalctl --user -M codesema@ -u codesema-runner.service -f ``` -From your workstation, `codesema brain status` (run against the same +From your workstation, `codesema runner status` (run against the same account you linked in step 1) should list the bench repository with a recent heartbeat once the arm has claimed a ticket. @@ -150,7 +194,7 @@ recent heartbeat once the arm has claimed a ticket. privileged group" property this runbook otherwise holds to. Only take this path if rootless podman is provably unavailable, and say so in your provisioning notes. -- **`codesema brain serve` exits immediately.** `WorkingDirectory` in the +- **`codesema runner serve` exits immediately.** `WorkingDirectory` in the unit must be a git clone, not an empty directory — check the clone step's log in `/var/log/codesema-provision.log`. @@ -163,40 +207,52 @@ this runbook changes. ## Existing server (BYOC) -Already have a server — a VPS, a machine in your own fleet — instead of +Already have a server, a VPS, a machine in your own fleet, instead of provisioning a fresh one? Skip the VM and cloud-init entirely and run the installer directly on it. Same artifact, same end state: `install.sh` is exactly what `cloud-init.yaml.example`'s own `provision.sh` calls once it has bootstrapped just enough (Node.js, `npm i -g codesema`) to run it, so -there is one place — not two — that knows how to turn a machine into a +there is one place, not two, that knows how to turn a machine into a running arm. +The simplest invocation is [autoconfig mode](#autoconfig-recommended): + +```bash +CODESEMA_HUB_TOKEN=csk_... bash packages/cli/assets/deploy/install.sh +``` + +Direct mode mints and passes all four values upfront instead, with no +fingerprint to compare: + ```bash REPO_URL=https://github.com/org/repo.git \ GH_TOKEN=... \ CLAUDE_CODE_OAUTH_TOKEN=... \ -CODESEMA_BRAIN_TOKEN=csk_... \ +CODESEMA_HUB_TOKEN=csk_... \ bash packages/cli/assets/deploy/install.sh ``` -The same five values `brain.env.example` documents (steps 1-2 above mint +The same five values `runner.env.example` documents (steps 1-2 above mint them the same way), passed as environment variables instead of pasted into -a YAML file — `CODESEMA_BRAIN_URL` defaults to `https://codesema.com` if -left unset. Any of the other four left unset is prompted for interactively -when the script is run from a terminal; a piped or otherwise non-interactive -run fails loudly instead of hanging on a prompt nobody can answer. +a YAML file: `CODESEMA_HUB_URL` defaults to `https://codesema.com` if left +unset. REPO_URL, GH_TOKEN and CLAUDE_CODE_OAUTH_TOKEN are all optional: +leave any of them unset (the default for a non-interactive run, or by +answering blank at its prompt for an interactive one) and the script +switches to autoconfig mode instead of requiring them upfront. `install.sh` is idempotent, the same "check before acting" doctrine throughout this file: Node.js (>= 20, else nodesource), `gh` (else the official apt repo), a container runtime (docker or podman, installing -rootless podman only if neither is present) and `codesema`/`claude-code` are -all checked before anything is installed, the repository is cloned only if -not already there, and the run ends by calling `codesema brain -install-service` itself — it never writes the systemd unit by hand. +rootless podman only if neither is present, and docker specifically +checked with `docker info` for one that is actually usable, not merely +installed) and `codesema`/`claude-code` are all checked before anything is +installed, the repository is cloned only if not already there, and the run +ends by calling `codesema runner install-service` itself: it never writes +the systemd unit by hand. **Same gate as step 7 above (a real server).** This puts a 24/7 arm on a machine whose order channel has no signature or confirmation and no kill -switch yet — the constraint is the brain's order channel, not how the +switch yet: the constraint is the hub's order channel, not how the machine was provisioned, so it applies here exactly as it does to a fresh VPS. Do not point this at a real, internet-facing server until that hardening has shipped. @@ -212,13 +268,13 @@ heartbeat. **Existing server**: ```bash -codesema brain uninstall-service # stops and removes the systemd --user unit -codesema brain disconnect # clears the locally stored brain credentials +codesema runner uninstall-service # stops and removes the systemd --user unit +codesema runner disconnect # clears the locally stored hub credentials npm uninstall -g codesema @anthropic-ai/claude-code ``` -Then, in the dashboard, revoke this arm from the repository's Settings — -`brain disconnect` only clears this machine's own copy of the credentials, -it does not revoke them server-side — and switch the repository's execution -mode back from arm to server if you want the brain's own scheduler to pick +Then, in the dashboard, revoke this arm from the repository's Settings +(`runner disconnect` only clears this machine's own copy of the credentials, +it does not revoke them server-side), and switch the repository's execution +mode back from arm to server if you want the hub's own scheduler to pick this repository back up. diff --git a/package.json b/package.json index f97618f..ec5561c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codesema-tools", - "version": "0.17.0", + "version": "0.18.0", "private": true, "type": "module", "workspaces": [ diff --git a/packages/cli/assets/deploy/cloud-init.yaml.example b/packages/cli/assets/deploy/cloud-init.yaml.example index cc17095..3e6504e 100644 --- a/packages/cli/assets/deploy/cloud-init.yaml.example +++ b/packages/cli/assets/deploy/cloud-init.yaml.example @@ -4,16 +4,21 @@ # described in docs/deploy-vm-arm.md. Same file, same content, either target. # # This is the "fresh machine" path. For an EXISTING server you already -# manage, use assets/deploy/install.sh directly instead — this file's own +# manage, use assets/deploy/install.sh directly instead: this file's own # provision.sh is a thin bootstrap (just enough to get npm running, chicken- # egg) that ends by calling that exact same script, so there is only one # place that knows how to turn a bare machine into a running arm. # -# Usage: cp cloud-init.yaml.example cloud-init.local.yaml, replace every -# __PLACEHOLDER__ below (brain.env.example says where each one comes from), -# then: +# Usage: cp cloud-init.yaml.example cloud-init.local.yaml, fill in +# CODESEMA_HUB_TOKEN below (runner.env.example says where to mint it), then: # multipass launch 24.04 --name codesema-arm --cpus 2 --memory 4G --disk 20G \ # --cloud-init cloud-init.local.yaml +# +# That alone boots into autoconfig mode: install.sh registers this VM with +# the hub and blocks, waiting for a human to run `codesema runner autoconfig` +# from their own workstation. See docs/deploy-vm-arm.md for that half of the +# exchange and for the direct-mode alternative (fill in the three commented +# lines in the write_files section below instead, and skip autoconfig). hostname: codesema-arm @@ -35,35 +40,41 @@ users: # daemon itself never runs as this user. - default # No `sudo`/`groups` key on purpose: this account gets no privileged - # group, since all it ever runs is `codesema brain serve` and the + # group, since all it ever runs is `codesema runner serve` and the # containers that command spawns. - name: codesema - gecos: codesema brain service account + gecos: codesema runner service account system: true homedir: /home/codesema shell: /bin/bash lock_passwd: true write_files: - # defer: true — cloud-init's default module order runs write_files BEFORE + # defer: true: cloud-init's default module order runs write_files BEFORE # users-groups, so an `owner: codesema:...` here would fail with "no such # user" unless the write is deferred past user creation. # - # All five values install.sh needs, in the one file it (and the systemd - # unit it installs, through EnvironmentFile=) reads from — see - # brain.env.example for where each one comes from. CODESEMA_BRAIN_URL - # ships with a real default rather than a placeholder: override it only - # for a self-hosted brain. - - path: /etc/codesema/brain.env + # The file install.sh (and the systemd unit it installs, through + # EnvironmentFile=) reads its configuration from: see runner.env.example + # for where each value comes from. Only CODESEMA_HUB_TOKEN must be filled + # in; CODESEMA_HUB_URL already ships with a real default (override only + # for a self-hosted hub). The three commented-out lines are direct mode + # only: leave them as they are for autoconfig mode, where install.sh gets + # them from the hub instead once a human runs `codesema runner autoconfig` + # and compares its fingerprint against the one `codesema runner connect` + # prints to /var/log/codesema-provision.log. + - path: /etc/codesema/runner.env owner: root:codesema permissions: '0640' defer: true content: | - CLAUDE_CODE_OAUTH_TOKEN=__PLACEHOLDER__ - GH_TOKEN=__PLACEHOLDER__ - CODESEMA_BRAIN_TOKEN=__PLACEHOLDER__ - CODESEMA_BRAIN_URL=https://codesema.com - REPO_URL=__PLACEHOLDER__ + CODESEMA_HUB_TOKEN=__PLACEHOLDER__ + CODESEMA_HUB_URL=https://codesema.com + + # Direct mode only: uncomment and fill in all three to skip autoconfig. + # CLAUDE_CODE_OAUTH_TOKEN=__PLACEHOLDER__ + # GH_TOKEN=__PLACEHOLDER__ + # REPO_URL=__PLACEHOLDER__ # No defer needed: root:root, no dependency on the codesema user existing. - path: /opt/codesema/provision.sh @@ -76,9 +87,11 @@ write_files: # Fail fast and loud on a forgotten placeholder rather than let # install.sh (or the service it enables) crashloop later with no clue - # why. - if grep -q '__PLACEHOLDER__' /etc/codesema/brain.env; then - echo "[codesema-provision] /etc/codesema/brain.env still has __PLACEHOLDER__ values, aborting" >&2 + # why. Anchored to actual assignments, not the whole file: the three + # direct-mode-only lines stay commented out, __PLACEHOLDER__ text and + # all, whenever autoconfig mode is what is wanted. + if grep -qE '^[A-Za-z_]+=__PLACEHOLDER__' /etc/codesema/runner.env; then + echo "[codesema-provision] /etc/codesema/runner.env has an unfilled __PLACEHOLDER__ value, aborting" >&2 exit 1 fi @@ -112,8 +125,8 @@ write_files: loginctl enable-linger codesema # codesema's user manager must actually be up before install.sh's own - # `systemctl --user`/`loginctl` calls (inside `codesema brain - # install-service`) can reach it — enable-linger above only spawns it + # `systemctl --user`/`loginctl` calls (inside `codesema runner + # install-service`) can reach it: enable-linger above only spawns it # asynchronously through logind, hence the bounded retry. `-M # codesema@` reaches the manager through systemd-logind directly # (systemd >= 248, shipped since Ubuntu 20.10), which is more robust @@ -129,12 +142,13 @@ write_files: # reason to know about) need XDG_RUNTIME_DIR themselves. runuser's PAM # session does not reliably set it (a known systemd/util-linux gap: # https://github.com/systemd/systemd/issues/10574), so it is exported - # explicitly instead of assumed. install.sh reads the rest of its - # configuration (REPO_URL, the brain URL/token, the two runtime - # secrets) straight out of brain.env itself. + # explicitly instead of assumed. install.sh reads CODESEMA_HUB_URL and + # CODESEMA_HUB_TOKEN out of runner.env either way; REPO_URL and the two + # runtime secrets come from that same file only in direct mode, else + # install.sh resolves them itself in autoconfig mode. runuser -u codesema -- env XDG_RUNTIME_DIR="/run/user/$(id -u codesema)" bash -c ' set -euo pipefail - set -a; . /etc/codesema/brain.env; set +a + set -a; . /etc/codesema/runner.env; set +a "$(npm root -g)/codesema/assets/deploy/install.sh" ' diff --git a/packages/cli/assets/deploy/install.sh b/packages/cli/assets/deploy/install.sh index 2cbe63a..b865a89 100755 --- a/packages/cli/assets/deploy/install.sh +++ b/packages/cli/assets/deploy/install.sh @@ -1,28 +1,41 @@ #!/usr/bin/env bash # Idempotent codesema arm installer for an EXISTING Ubuntu/Debian server (the # "bring your own compute" path; cloud-init.yaml.example is the equivalent -# for a fresh VM provisioned from scratch — see docs/deploy-vm-arm.md for +# for a fresh VM provisioned from scratch: see docs/deploy-vm-arm.md for # both). Runner-style split, same shape as gitlab-runner's install + # config.sh/svc.sh: this script only gets the OS ready (Node.js, gh, a # container runtime) and codesema/claude-code onto PATH; the systemd --user -# unit itself is written by `codesema brain install-service`, never by this +# unit itself is written by `codesema runner install-service`, never by this # script, so there is exactly one place that knows the unit's shape. # # Safe to re-run: every step checks before it acts. Reads its configuration # from the environment and falls back to an interactive prompt for whichever # required variable is missing and stdin is a terminal: # -# CODESEMA_BRAIN_URL brain to connect to (default: https://codesema.com) -# CODESEMA_BRAIN_TOKEN csk_., from `codesema link` +# CODESEMA_HUB_URL hub to connect to (default: https://codesema.com) +# CODESEMA_HUB_TOKEN csk_., from `codesema link`, +# required in both modes below # REPO_URL HTTPS clone URL of the repo this arm works # GH_TOKEN GitHub token, "repo" scope # CLAUDE_CODE_OAUTH_TOKEN from `claude setup-token` # -# Usage: +# REPO_URL, GH_TOKEN and CLAUDE_CODE_OAUTH_TOKEN are optional. Set all three +# and this runs in direct mode, exactly as below. Leave any of them unset and +# the script switches to autoconfig mode: it registers this machine with the +# hub through `codesema runner connect` (which prints a fingerprint), then +# blocks in `codesema runner await-secrets` until a human runs `codesema +# runner autoconfig` on their own workstation and delivers the three values +# over the sealed-secrets channel. A fresh VM can therefore boot with only +# CODESEMA_HUB_TOKEN set; see docs/deploy-vm-arm.md. +# +# Usage (direct mode): # REPO_URL=https://github.com/org/repo.git GH_TOKEN=... \ -# CLAUDE_CODE_OAUTH_TOKEN=... CODESEMA_BRAIN_TOKEN=csk_... \ +# CLAUDE_CODE_OAUTH_TOKEN=... CODESEMA_HUB_TOKEN=csk_... \ # bash install.sh # +# Usage (autoconfig mode): +# CODESEMA_HUB_TOKEN=csk_... bash install.sh +# # Minting each value: docs/deploy-vm-arm.md. set -euo pipefail @@ -76,18 +89,26 @@ require_var() { || fail "$name is required (set it in the environment, or run this script from an interactive terminal)" } -: "${CODESEMA_BRAIN_URL:=https://codesema.com}" +: "${CODESEMA_HUB_URL:=https://codesema.com}" -prompt_var CODESEMA_BRAIN_TOKEN "Brain token (csk_., from 'codesema link')" secret +prompt_var CODESEMA_HUB_TOKEN "Hub token (csk_., from 'codesema link')" secret prompt_var REPO_URL "Repository to clone (HTTPS URL)" prompt_var GH_TOKEN "GitHub token (repo scope)" secret prompt_var CLAUDE_CODE_OAUTH_TOKEN "Claude Code OAuth token (from 'claude setup-token')" secret -require_var CODESEMA_BRAIN_TOKEN -require_var REPO_URL -require_var GH_TOKEN -require_var CLAUDE_CODE_OAUTH_TOKEN -export GH_TOKEN CLAUDE_CODE_OAUTH_TOKEN +require_var CODESEMA_HUB_TOKEN + +# --- REPO_URL/GH_TOKEN/CLAUDE_CODE_OAUTH_TOKEN all present: direct mode, +# unchanged from here on. Any one missing (the common case on a fresh VM's +# non-interactive boot, where prompt_var above had no terminal to ask on) +# switches to autoconfig mode instead, resolved further down once `runner +# connect` has this machine's identity registered with the hub. +if [ -n "${REPO_URL:-}" ] && [ -n "${GH_TOKEN:-}" ] && [ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]; then + direct_mode=1 + export GH_TOKEN CLAUDE_CODE_OAUTH_TOKEN +else + direct_mode=0 +fi log "starting at $(date -u +%FT%TZ)" @@ -136,6 +157,17 @@ else fi fi +# --- Docker specifically needs a group membership rootless podman does not: +# a freshly added user cannot run it until they are in the docker group and +# have logged back in, and this script has no relogin to wait for, so a +# docker that cannot actually run fails loud here instead of at the first +# ticket's container run. +if command -v docker >/dev/null 2>&1; then + if ! docker info >/dev/null 2>&1; then + fail "docker is installed but not usable by $(id -un): add this user to the docker group and log back in ('sudo usermod -aG docker $(id -un)', then relogin), or install rootless podman instead" + fi +fi + # --- codesema + claude-code on PATH. Presence-gated, not version-gated: a # locked-down service account (no sudo, by design — see # cloud-init.yaml.example's `codesema` user) can reach this script with @@ -160,25 +192,9 @@ else || fail "claude not on PATH after npm install -g — check npm's global bin dir is in PATH" fi -# --- Clone the repository. gh's own credential helper authenticates the -# clone from GH_TOKEN (already exported above), so the token never lands in -# this repo's .git/config — only ~/.gitconfig gets a credential.helper line -# naming gh, which reads GH_TOKEN again at call time. -repo_name="$(basename "$REPO_URL" .git)" -repo_dir="$HOME/$repo_name" - -gh auth setup-git - -if [ -d "$repo_dir/.git" ]; then - log "$repo_dir already cloned, skipping" -else - log "cloning $REPO_URL into $repo_dir" - git clone "$REPO_URL" "$repo_dir" -fi - # --- Base config: written only if absent, so a re-run never clobbers a # configuration already customized by hand (`codesema config`). Written -# FIRST: `codesema brain connect` below loads this same file and merges its +# FIRST: `codesema runner connect` below loads this same file and merges its # own three keys into it, it does not overwrite what is already there. config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/codesema" config_file="$config_dir/config.json" @@ -195,22 +211,74 @@ JSON chmod 0600 "$config_file" fi -codesema brain connect --url "$CODESEMA_BRAIN_URL" --token "$CODESEMA_BRAIN_TOKEN" +codesema runner connect --url "$CODESEMA_HUB_URL" --token "$CODESEMA_HUB_TOKEN" # --- Runtime secrets for the systemd unit: only the two the DAEMON reads on -# every ticket (CLAUDE_CODE_OAUTH_TOKEN, GH_TOKEN). CODESEMA_BRAIN_TOKEN is -# install-time only — `brain connect` above already turned it into -# config.json's stored credentials — so it has no reason to also live here. -env_file="$config_dir/brain.env" -umask 077 -cat > "$env_file" < "$env_file" </dev/null || true)" + fi + fi + require_var REPO_URL + + set -a + . "$env_file" + set +a +fi + +# --- Clone the repository. gh's own credential helper authenticates the +# clone from GH_TOKEN (exported above, one way or another by this point), so +# the token never lands in this repo's .git/config: only ~/.gitconfig gets a +# credential.helper line naming gh, which reads GH_TOKEN again at call time. +repo_name="$(basename "$REPO_URL" .git)" +repo_dir="$HOME/$repo_name" + +gh auth setup-git + +if [ -d "$repo_dir/.git" ]; then + log "$repo_dir already cloned, skipping" +else + log "cloning $REPO_URL into $repo_dir" + git clone "$REPO_URL" "$repo_dir" +fi -( cd "$repo_dir" && codesema brain install-service --env-file "$env_file" ) +( cd "$repo_dir" && codesema runner install-service --env-file "$env_file" ) log "done at $(date -u +%FT%TZ)" -log "check it with: systemctl --user status codesema-brain.service" -log "watch it with: journalctl --user -u codesema-brain.service -f" +log "check it with: systemctl --user status codesema-runner.service" +log "watch it with: journalctl --user -u codesema-runner.service -f" diff --git a/packages/cli/assets/deploy/brain.env.example b/packages/cli/assets/deploy/runner.env.example similarity index 53% rename from packages/cli/assets/deploy/brain.env.example rename to packages/cli/assets/deploy/runner.env.example index b493792..389e887 100644 --- a/packages/cli/assets/deploy/brain.env.example +++ b/packages/cli/assets/deploy/runner.env.example @@ -1,13 +1,19 @@ # One file, everything install.sh and cloud-init.yaml.example need to stand -# up a codesema arm — copy, fill in every __PLACEHOLDER__, then either paste +# up a codesema arm: copy, fill in every __PLACEHOLDER__, then either paste # these five lines into cloud-init.local.yaml's write_files block, or export # them directly before running install.sh by hand on an existing server (or -# scp this file to /etc/codesema/brain.env and `set -a; . brain.env; set +a` +# scp this file to /etc/codesema/runner.env and `set -a; . runner.env; set +a` # first). Full minting steps: docs/deploy-vm-arm.md. # # Only CLAUDE_CODE_OAUTH_TOKEN and GH_TOKEN are read by the running daemon # (systemd's EnvironmentFile=, every ticket); the other three are consumed -# once, at install time, by install.sh and `codesema brain connect`. +# once, at install time, by install.sh and `codesema runner connect`. +# +# Filling this file by hand is direct mode. Autoconfig mode still mints +# CODESEMA_HUB_TOKEN the same way, but CLAUDE_CODE_OAUTH_TOKEN, GH_TOKEN and +# REPO_URL are delivered instead: `codesema runner await-secrets` writes +# this same file once a human runs `codesema runner autoconfig` from their +# own workstation. See docs/deploy-vm-arm.md. # Long-lived token from `claude setup-token`, run on any machine with a browser. CLAUDE_CODE_OAUTH_TOKEN=__PLACEHOLDER__ @@ -16,10 +22,10 @@ CLAUDE_CODE_OAUTH_TOKEN=__PLACEHOLDER__ GH_TOKEN=__PLACEHOLDER__ # csk_., minted with `codesema link` against a scratch CODESEMA_CONFIG_DIR. -CODESEMA_BRAIN_TOKEN=__PLACEHOLDER__ +CODESEMA_HUB_TOKEN=__PLACEHOLDER__ -# The brain to connect to. Real default already filled in: change it only for a self-hosted brain. -CODESEMA_BRAIN_URL=https://codesema.com +# The hub to connect to. Real default already filled in: change it only for a self-hosted hub. +CODESEMA_HUB_URL=https://codesema.com # HTTPS clone URL of the repository this arm works. REPO_URL=__PLACEHOLDER__ diff --git a/packages/cli/assets/systemd/codesema-brain.service b/packages/cli/assets/systemd/codesema-runner.service similarity index 62% rename from packages/cli/assets/systemd/codesema-brain.service rename to packages/cli/assets/systemd/codesema-runner.service index 7e0aa90..787ca28 100644 --- a/packages/cli/assets/systemd/codesema-brain.service +++ b/packages/cli/assets/systemd/codesema-runner.service @@ -1,30 +1,30 @@ -# Example systemd USER unit for the codesema brain daemon (D21). +# Example systemd USER unit for the codesema runner daemon (D21). # -# Runs `codesema brain serve` attached, in the foreground: systemd is already +# Runs `codesema runner serve` attached, in the foreground: systemd is already # the process supervisor here, so this never needs the CLI's own `--detach` # (that flag exists for machines with no service manager, see -# `codesema brain serve --help` / `codesema brain stop`). Restart=on-failure +# `codesema runner serve --help` / `codesema runner stop`). Restart=on-failure # means systemd relaunches the daemon after a crash, but ALSO after any # SIGTERM it did not send itself, so once this unit is running, stop it with # -# systemctl --user stop codesema-brain.service +# systemctl --user stop codesema-runner.service # -# never with a manual `kill` or `codesema brain stop`: either would just be +# never with a manual `kill` or `codesema runner stop`: either would just be # restarted 5 seconds later. # # Install: # mkdir -p ~/.config/systemd/user -# cp codesema-brain.service ~/.config/systemd/user/ +# cp codesema-runner.service ~/.config/systemd/user/ # # then edit WorkingDirectory below to the repo this daemon should serve # systemctl --user daemon-reload -# systemctl --user enable --now codesema-brain.service +# systemctl --user enable --now codesema-runner.service # -# Logs: `journalctl --user -u codesema-brain.service -f` (stdout/stderr go to +# Logs: `journalctl --user -u codesema-runner.service -f` (stdout/stderr go to # the journal by default under systemd, no log file to manage here, unlike -# `--detach`, which has no journal and writes to .codesema/brain-daemon.log). +# `--detach`, which has no journal and writes to .codesema/runner-daemon.log). [Unit] -Description=codesema brain daemon +Description=codesema runner daemon After=network-online.target Wants=network-online.target @@ -37,7 +37,7 @@ WorkingDirectory=/path/to/your/repo # this unit with, which is not always the PATH your interactive shell has. # Run `command -v codesema` in the same shell you'd normally launch it from # and paste the absolute path here if the unit fails to start. -ExecStart=codesema brain serve +ExecStart=codesema runner serve Restart=on-failure RestartSec=5 diff --git a/packages/cli/package.json b/packages/cli/package.json index 47bb34e..e3d47fc 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "codesema", - "version": "0.17.0", + "version": "0.18.0", "description": "Local merge request review, step by step. Your AI agent reviews, codesema displays.", "license": "MIT", "author": "Hasan TASKIN", diff --git a/packages/cli/src/brain-commands.test.ts b/packages/cli/src/brain-commands.test.ts deleted file mode 100644 index 0840d6a..0000000 --- a/packages/cli/src/brain-commands.test.ts +++ /dev/null @@ -1,668 +0,0 @@ -import { - execFileSync, - spawn, - spawnSync, - type ChildProcess, - type SpawnOptions, -} from 'node:child_process' -import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, test } from 'bun:test' -import type { AgentRunOptions } from './agent.js' -import { brainCommand } from './brain-commands.js' -import { readBrainPidfile, writeBrainPidfile } from './brain-pidfile.js' -import { loadGlobalConfig, saveGlobalConfig } from './config.js' -import type { ArmTicket } from './contract.js' -import { t } from './i18n.js' - -process.env.NO_COLOR = '1' - -type Call = { url: string; init: RequestInit } - -function fetchStub(status: number, body: unknown, calls: Call[]): typeof fetch { - return ((url: string | URL | Request, init?: RequestInit) => { - calls.push({ url: String(url), init: init ?? {} }) - return Promise.resolve( - new Response(JSON.stringify(body), { - status, - headers: { 'content-type': 'application/json' }, - }), - ) - }) as typeof fetch -} - -/** Routes a response per `status` query param, so one stub can answer both the ready and in-flight `listTickets` calls `brainStatus` makes. */ -function fetchStubByStatus( - responsesByStatus: Record, - calls: Call[], -): typeof fetch { - return ((url: string | URL | Request, init?: RequestInit) => { - const urlStr = String(url) - calls.push({ url: urlStr, init: init ?? {} }) - const status = new URL(urlStr).searchParams.get('status') ?? '' - const body = responsesByStatus[status] ?? { tickets: [] } - return Promise.resolve( - new Response(JSON.stringify(body), { - status: 200, - headers: { 'content-type': 'application/json' }, - }), - ) - }) as typeof fetch -} - -function fetchOffline(): typeof fetch { - return (() => Promise.reject(new Error('network unreachable'))) as unknown as typeof fetch -} - -/** Same pattern as summary.test.ts's own `captureLog`, made async: `brainCommand` resolves its promise after every `console.log` it makes. */ -async function captureLog(fn: () => Promise): Promise { - const lines: string[] = [] - const original = console.log - console.log = (...args: unknown[]) => { - lines.push(args.join(' ')) - } - try { - await fn() - } finally { - console.log = original - } - return lines -} - -function initRepo(cwd: string, remoteUrl?: string): void { - execFileSync('git', ['init', '-q', '-b', 'main'], { cwd }) - execFileSync( - 'git', - [ - '-c', - 'user.email=t@t', - '-c', - 'user.name=t', - 'commit', - '-q', - '--allow-empty', - '-m', - 'chore: init', - ], - { cwd }, - ) - if (remoteUrl) { - execFileSync('git', ['remote', 'add', 'origin', remoteUrl], { cwd }) - } -} - -const VALID_BODY = `**Context** - -Some context. - -**Goal** - -Some goal. - -**Scope** - -packages/x. - -**Acceptance criteria** - -- WHEN a THE SYSTEM SHALL b [proof:command bun test] -- WHEN c THE SYSTEM SHALL d [proof:diff packages/x/thing.ts] -- WHEN e THE SYSTEM SHALL f [proof:judgment] - -**Out of scope** - -Nothing else.` - -const validTicket: ArmTicket = { - id: 't1', - repo_remote_url: 'https://github.com/o/r.git', - title: 'Add a thing', - body: VALID_BODY, - status: 'published', - depends_on: null, - executed_by: null, - lease_expires_at: null, - issue: null, - branch: null, - mr_iid: null, - mr_url: null, - created_at: '2026-01-01T00:00:00.000Z', - updated_at: '2026-01-01T00:00:00.000Z', -} - -function fakeRunAgent(output: string): (opts: AgentRunOptions) => Promise { - return async () => output -} - -/** A pid that is certainly dead: a child that already ran to completion. */ -function deadPid(): number { - const child = spawnSync('true') - expect(child.pid).toBeGreaterThan(0) - return child.pid -} - -/** A single real process with no custom signal handling: dies on the default SIGTERM. */ -function spawnAlive(): ChildProcess { - return spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)']) -} - -/** - * A single real process that registers a SIGTERM listener before signalling - * "ready" on stdout: adding any listener replaces Node's default (fatal) - * behavior for that signal, so this one survives SIGTERM until SIGKILLed. - * Resolves only once the handler is actually registered, so the stop-timeout - * test below can never race a child that has not installed it yet. - */ -function spawnIgnoringSigterm(): Promise { - return new Promise((resolve) => { - const child = spawn(process.execPath, [ - '-e', - 'process.on("SIGTERM", () => {}); process.stdout.write("ready"); setInterval(() => {}, 1000)', - ]) - child.stdout?.once('data', () => resolve(child)) - }) -} - -describe('brainCommand', () => { - const previousConfigDir = process.env.CODESEMA_CONFIG_DIR - let configDir: string - let cwd: string - - beforeEach(() => { - configDir = mkdtempSync(join(tmpdir(), 'codesema-braincmd-cfg-')) - process.env.CODESEMA_CONFIG_DIR = configDir - cwd = mkdtempSync(join(tmpdir(), 'codesema-braincmd-repo-')) - }) - - afterEach(() => { - rmSync(configDir, { recursive: true, force: true }) - rmSync(cwd, { recursive: true, force: true }) - if (previousConfigDir === undefined) { - delete process.env.CODESEMA_CONFIG_DIR - } else { - process.env.CODESEMA_CONFIG_DIR = previousConfigDir - } - }) - - test('no action prints usage and does not throw', async () => { - await expect(brainCommand({ cwd })).resolves.toBeUndefined() - }) - - test('an unknown action throws', async () => { - await expect(brainCommand({ action: 'nope', cwd })).rejects.toThrow() - }) - - describe('connect', () => { - test('requires both --url and --token', async () => { - await expect(brainCommand({ action: 'connect', cwd, url: 'https://x' })).rejects.toThrow() - await expect(brainCommand({ action: 'connect', cwd, token: 'csk_a.b' })).rejects.toThrow() - }) - - test('rejects a malformed token', async () => { - await expect( - brainCommand({ action: 'connect', cwd, url: 'https://x', token: 'not-a-token' }), - ).rejects.toThrow() - }) - - test('stores the same credentials shape as `codesema sync`', async () => { - await brainCommand({ - action: 'connect', - cwd, - url: 'https://brain.example', - token: 'csk_ws1.sec1', - }) - const config = loadGlobalConfig() - expect(config.syncUrl).toBe('https://brain.example') - expect(config.syncWorkspaceId).toBe('ws1') - expect(config.syncSecret).toBe('sec1') - }) - }) - - describe('disconnect', () => { - test('is a soft no-op when nothing is connected', async () => { - await expect(brainCommand({ action: 'disconnect', cwd })).resolves.toBeUndefined() - expect(loadGlobalConfig().syncUrl).toBeUndefined() - }) - - test('clears syncUrl/syncWorkspaceId/syncSecret, and only those', async () => { - await brainCommand({ - action: 'connect', - cwd, - url: 'https://brain.example', - token: 'csk_ws1.sec1', - }) - saveGlobalConfig({ ...loadGlobalConfig(), agent: 'claude -p' }) - - await expect(brainCommand({ action: 'disconnect', cwd })).resolves.toBeUndefined() - - const config = loadGlobalConfig() - expect(config.syncUrl).toBeUndefined() - expect(config.syncWorkspaceId).toBeUndefined() - expect(config.syncSecret).toBeUndefined() - expect(config.agent).toBe('claude -p') - }) - - test('running it twice is fine (idempotent)', async () => { - await brainCommand({ - action: 'connect', - cwd, - url: 'https://brain.example', - token: 'csk_ws1.sec1', - }) - await brainCommand({ action: 'disconnect', cwd }) - await expect(brainCommand({ action: 'disconnect', cwd })).resolves.toBeUndefined() - }) - }) - - describe('status', () => { - test('throws when not connected', async () => { - await expect(brainCommand({ action: 'status', cwd })).rejects.toThrow() - }) - - test('reports the ready ticket count when connected', async () => { - saveGlobalConfig({ - ...loadGlobalConfig(), - syncUrl: 'https://brain.example', - syncWorkspaceId: 'ws1', - syncSecret: 'sec1', - }) - initRepo(cwd, 'https://github.com/o/r.git') - const calls: Call[] = [] - await expect( - brainCommand({ - action: 'status', - cwd, - fetchImpl: fetchStub(200, { tickets: [validTicket] }, calls), - }), - ).resolves.toBeUndefined() - expect(calls[0]?.url).toContain('/api/cli/tickets?') - expect(calls[0]?.url).toContain('status=published') - }) - - test('does not call the brain when the repo has no origin remote', async () => { - saveGlobalConfig({ - ...loadGlobalConfig(), - syncUrl: 'https://brain.example', - syncWorkspaceId: 'ws1', - syncSecret: 'sec1', - }) - initRepo(cwd) - const calls: Call[] = [] - await brainCommand({ action: 'status', cwd, fetchImpl: fetchStub(200, {}, calls) }) - expect(calls.length).toBe(0) - }) - }) - - describe('status: in flight tickets', () => { - beforeEach(() => { - saveGlobalConfig({ - ...loadGlobalConfig(), - syncUrl: 'https://brain.example', - syncWorkspaceId: 'ws1', - syncSecret: 'sec1', - }) - initRepo(cwd, 'https://github.com/o/r.git') - }) - - test('lists an in-flight ticket with a fresh heartbeat, no stale marker', async () => { - const freshTicket = { - ...validTicket, - id: 't-if-fresh', - title: 'Fix the flaky retry test', - status: 'in_progress', - executed_by: 'cli-arm-01', - updated_at: new Date(Date.now() - 12_000).toISOString(), - lease_expires_at: new Date(Date.now() + 5 * 60_000).toISOString(), - arm_local_status: 'executing', - } - const calls: Call[] = [] - const lines = await captureLog(async () => { - await brainCommand({ - action: 'status', - cwd, - fetchImpl: fetchStubByStatus( - { published: { tickets: [] }, in_flight: { tickets: [freshTicket] } }, - calls, - ), - }) - }) - const inFlightCalls = calls.filter((c) => c.url.includes('status=in_flight')) - expect(inFlightCalls.length).toBe(1) - expect(inFlightCalls[0]?.url).toContain('remote_url=') - const output = lines.join('\n') - expect(output).toContain('Fix the flaky retry test') - expect(output).toContain('cli-arm-01') - expect(output).toContain('executing') - expect(output).not.toContain(t('brain.fieldStale')) - }) - - test('marks a ticket stale once its lease has lapsed', async () => { - const staleTicket = { - ...validTicket, - id: 't-if-stale', - title: 'Add retry logic', - status: 'mr_opened', - executed_by: 'cli-arm-02', - updated_at: new Date(Date.now() - 3 * 60_000).toISOString(), - lease_expires_at: new Date(Date.now() - 60_000).toISOString(), - arm_local_status: 'awaiting_review', - } - const lines = await captureLog(async () => { - await brainCommand({ - action: 'status', - cwd, - fetchImpl: fetchStubByStatus( - { published: { tickets: [] }, in_flight: { tickets: [staleTicket] } }, - [], - ), - }) - }) - const output = lines.join('\n') - expect(output).toContain('Add retry logic') - expect(output).toContain(t('brain.fieldStale')) - }) - - test('degrades gracefully when the brain does not send arm_local_status (older brain)', async () => { - const oldBrainTicket = { - ...validTicket, - id: 't-if-old', - title: 'Legacy ticket from an older brain', - status: 'in_progress', - executed_by: null, - updated_at: new Date(Date.now() - 5_000).toISOString(), - lease_expires_at: new Date(Date.now() + 5 * 60_000).toISOString(), - // No `arm_local_status` key at all: what an older brain, built before - // that field existed, actually sends. - } - const lines = await captureLog(async () => { - await expect( - brainCommand({ - action: 'status', - cwd, - fetchImpl: fetchStubByStatus( - { published: { tickets: [] }, in_flight: { tickets: [oldBrainTicket] } }, - [], - ), - }), - ).resolves.toBeUndefined() - }) - const output = lines.join('\n') - expect(output).toContain('Legacy ticket from an older brain') - expect(output).toContain(t('brain.fieldUnclaimed')) - expect(output).not.toContain('undefined') - expect(output).not.toContain('null') - }) - - test('an unreachable brain degrades the same way the ready count already does', async () => { - await expect( - brainCommand({ action: 'status', cwd, fetchImpl: fetchOffline() }), - ).resolves.toBeUndefined() - }) - }) - - describe('ticket', () => { - test('rejects both --issue and --title/--prompt together', async () => { - await expect( - brainCommand({ action: 'ticket', cwd, issue: '1', title: 'T', prompt: 'p' }), - ).rejects.toThrow() - }) - - test('rejects neither form given', async () => { - await expect(brainCommand({ action: 'ticket', cwd })).rejects.toThrow() - }) - - test('rejects a non-numeric --issue', async () => { - await expect(brainCommand({ action: 'ticket', cwd, issue: 'abc' })).rejects.toThrow() - }) - - test('drafts and publishes from --title/--prompt', async () => { - saveGlobalConfig({ - ...loadGlobalConfig(), - syncUrl: 'https://brain.example', - syncWorkspaceId: 'ws1', - syncSecret: 'sec1', - }) - initRepo(cwd, 'https://github.com/o/r.git') - const calls: Call[] = [] - await expect( - brainCommand({ - action: 'ticket', - cwd, - title: 'Add a thing', - prompt: 'do the thing', - runAgentFn: fakeRunAgent(VALID_BODY), - fetchImpl: fetchStub(201, { ticket: validTicket }, calls), - }), - ).resolves.toBeUndefined() - expect(calls[0]?.url).toBe('https://brain.example/api/cli/tickets') - }) - - test('a drafting failure surfaces as a thrown error', async () => { - saveGlobalConfig({ - ...loadGlobalConfig(), - syncUrl: 'https://brain.example', - syncWorkspaceId: 'ws1', - syncSecret: 'sec1', - }) - initRepo(cwd, 'https://github.com/o/r.git') - await expect( - brainCommand({ - action: 'ticket', - cwd, - title: 'T', - prompt: 'x', - runAgentFn: fakeRunAgent('not a ticket'), - }), - ).rejects.toThrow() - }) - }) - - describe('status: daemon rows (D21)', () => { - beforeEach(() => { - saveGlobalConfig({ - ...loadGlobalConfig(), - syncUrl: 'https://brain.example', - syncWorkspaceId: 'ws1', - syncSecret: 'sec1', - }) - }) - - test('no pidfile: does not throw (reported as not running)', async () => { - initRepo(cwd, 'https://github.com/o/r.git') - await expect( - brainCommand({ action: 'status', cwd, fetchImpl: fetchStub(200, { tickets: [] }, []) }), - ).resolves.toBeUndefined() - }) - - test('a pidfile naming our own (very much alive) pid: does not throw, cleans up nothing', async () => { - initRepo(cwd, 'https://github.com/o/r.git') - writeBrainPidfile(cwd, process.pid, 4400) - await expect( - brainCommand({ action: 'status', cwd, fetchImpl: fetchStub(200, { tickets: [] }, []) }), - ).resolves.toBeUndefined() - expect(readBrainPidfile(cwd)).toMatchObject({ pid: process.pid, port: 4400 }) - }) - - test('a pidfile naming a dead (stolen) pid: does not throw, and the stale file is removed', async () => { - initRepo(cwd, 'https://github.com/o/r.git') - writeBrainPidfile(cwd, deadPid(), 4400) - await expect( - brainCommand({ action: 'status', cwd, fetchImpl: fetchStub(200, { tickets: [] }, []) }), - ).resolves.toBeUndefined() - expect(readBrainPidfile(cwd)).toBeNull() - }) - }) - - describe('serve --detach', () => { - test('spawns a detached re-invocation of `brain serve` (no --detach) and reports pid + log path', async () => { - const calls: { command: string; args: readonly string[]; options: SpawnOptions }[] = [] - const unrefCalls: number[] = [] - const spawnFn = (command: string, args: readonly string[], options: SpawnOptions) => { - calls.push({ command, args, options }) - return { - pid: 4242, - unref: () => { - unrefCalls.push(1) - }, - on: () => {}, - } as unknown as ChildProcess - } - - await expect( - brainCommand({ action: 'serve', cwd, detach: true, spawnFn }), - ).resolves.toBeUndefined() - - const call = calls[0] - if (!call) { - throw new Error('expected spawnFn to have been called') - } - expect(call.command).toBe(process.execPath) - expect(call.args).toEqual([process.argv[1] as string, 'brain', 'serve']) - expect(call.options.cwd).toBe(cwd) - expect(call.options.detached).toBe(true) - expect(unrefCalls.length).toBe(1) - expect(existsSync(join(cwd, '.codesema', 'brain-daemon.log'))).toBe(true) - }) - - test('a spawn that never yields a pid throws (D21 never silently reports success)', async () => { - const spawnFn = () => - ({ pid: undefined, unref: () => {}, on: () => {} }) as unknown as ChildProcess - await expect(brainCommand({ action: 'serve', cwd, detach: true, spawnFn })).rejects.toThrow() - }) - }) - - describe('stop', () => { - test('no pidfile: resolves without throwing (nothing to stop)', async () => { - await expect(brainCommand({ action: 'stop', cwd })).resolves.toBeUndefined() - }) - - test('a pidfile naming a dead pid: resolves without throwing, and the stale file is cleaned up', async () => { - writeBrainPidfile(cwd, deadPid(), 4400) - await expect(brainCommand({ action: 'stop', cwd })).resolves.toBeUndefined() - expect(readBrainPidfile(cwd)).toBeNull() - }) - - test('a live process: SIGTERM kills it, stop waits for it, then cleans up the pidfile', async () => { - const child = spawnAlive() - const pid = child.pid - if (pid === undefined) { - throw new Error('expected a real pid') - } - writeBrainPidfile(cwd, pid, 4400) - try { - await expect( - brainCommand({ action: 'stop', cwd, stopTimeoutMs: 5000, stopPollIntervalMs: 20 }), - ).resolves.toBeUndefined() - expect(readBrainPidfile(cwd)).toBeNull() - } finally { - child.kill('SIGKILL') - } - }) - - test('a live process that ignores SIGTERM: reports the timeout, never hangs, pidfile is left in place', async () => { - const child = await spawnIgnoringSigterm() - const pid = child.pid - if (pid === undefined) { - throw new Error('expected a real pid') - } - writeBrainPidfile(cwd, pid, 4400) - try { - await expect( - brainCommand({ action: 'stop', cwd, stopTimeoutMs: 300, stopPollIntervalMs: 20 }), - ).resolves.toBeUndefined() - expect(readBrainPidfile(cwd)).toMatchObject({ pid }) - } finally { - child.kill('SIGKILL') - } - }) - }) - - describe('install-service / uninstall-service', () => { - const previousXdg = process.env.XDG_CONFIG_HOME - let xdgConfigHome: string - - function noopExecFn(calls: { command: string; args: readonly string[] }[]) { - return (command: string, args: readonly string[]) => { - calls.push({ command, args }) - return '' - } - } - - function unitPath(): string { - return join(xdgConfigHome, 'systemd', 'user', 'codesema-brain.service') - } - - beforeEach(() => { - xdgConfigHome = mkdtempSync(join(tmpdir(), 'codesema-braincmd-xdg-')) - process.env.XDG_CONFIG_HOME = xdgConfigHome - }) - - afterEach(() => { - rmSync(xdgConfigHome, { recursive: true, force: true }) - if (previousXdg === undefined) { - delete process.env.XDG_CONFIG_HOME - } else { - process.env.XDG_CONFIG_HOME = previousXdg - } - }) - - test('install-service refuses to run outside a git repository', async () => { - await expect( - brainCommand({ action: 'install-service', cwd, execFn: noopExecFn([]) }), - ).rejects.toThrow() - expect(existsSync(unitPath())).toBe(false) - }) - - test('install-service writes the unit pinned to the resolved repo root', async () => { - initRepo(cwd) - const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { - cwd, - encoding: 'utf8', - }).trim() - const calls: { command: string; args: readonly string[] }[] = [] - - await expect( - brainCommand({ action: 'install-service', cwd, execFn: noopExecFn(calls) }), - ).resolves.toBeUndefined() - - expect(existsSync(unitPath())).toBe(true) - const unit = readFileSync(unitPath(), 'utf8') - expect(unit).toContain(`WorkingDirectory=${repoRoot}`) - expect(calls.map((c) => c.args.join(' '))).toContain( - '--user enable --now codesema-brain.service', - ) - }) - - test('install-service surfaces a clear error when systemctl is absent, and writes nothing', async () => { - initRepo(cwd) - const execFn = (command: string) => { - if (command === 'systemctl') { - throw Object.assign(new Error('spawn systemctl ENOENT'), { code: 'ENOENT' }) - } - return '' - } - await expect(brainCommand({ action: 'install-service', cwd, execFn })).rejects.toThrow( - t('brain.systemctlNotFound'), - ) - expect(existsSync(unitPath())).toBe(false) - }) - - test('uninstall-service is a soft no-op when nothing is installed', async () => { - await expect( - brainCommand({ action: 'uninstall-service', cwd, execFn: noopExecFn([]) }), - ).resolves.toBeUndefined() - }) - - test('uninstall-service removes a previously installed unit', async () => { - initRepo(cwd) - await brainCommand({ action: 'install-service', cwd, execFn: noopExecFn([]) }) - expect(existsSync(unitPath())).toBe(true) - - await expect( - brainCommand({ action: 'uninstall-service', cwd, execFn: noopExecFn([]) }), - ).resolves.toBeUndefined() - expect(existsSync(unitPath())).toBe(false) - }) - }) -}) diff --git a/packages/cli/src/brain-commands.ts b/packages/cli/src/brain-commands.ts deleted file mode 100644 index 44e5d78..0000000 --- a/packages/cli/src/brain-commands.ts +++ /dev/null @@ -1,476 +0,0 @@ -// `codesema brain …`: connect a workspace to a brain, inspect it, draft and -// publish a ticket by hand, or start/stop the background daemon (D21: `serve -// --detach` backgrounds it, `stop` ends it). Same shape as sync.ts's -// `syncCommand`/`linkCommand`: one action-dispatching entry point. Usage -// errors throw a plain `Error` the CLI's top-level catch prints; `stop` is -// the one action that is a no-op rather than an error when there is nothing -// to do: stopping an already-stopped daemon is success, not misuse. - -import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process' -import { closeSync, mkdirSync, openSync } from 'node:fs' -import { dirname, join } from 'node:path' -import type { runAgent } from './agent.js' -import { - brainErrorMessage, - brainRemoteUrl, - listInFlightTickets, - listTickets, - parseBrainToken, - type InFlightTicket, -} from './brain-client.js' -import { draftAndPublishTicket } from './brain-draft.js' -import { readBrainPidfile, removeBrainPidfile } from './brain-pidfile.js' -import { installBrainService, uninstallBrainService, type ExecCommandFn } from './brain-service.js' -import { loadGlobalConfig, saveGlobalConfig } from './config.js' -import { tryGit } from './git.js' -import { t } from './i18n.js' -import { loadSyncCredentials } from './sync.js' -import { ACCENT, AMBER, dim, GREEN, paint, renderFieldRows, type FieldRow } from './ui.js' -import { isPidAlive } from './workspace-lock.js' -import { workspace } from './workspace.js' - -/** - * The one `spawn` overload `spawnDetachedBrainServe` actually calls, pulled - * out as its own type rather than `typeof spawn`: the real signature is a - * dozen overloads deep, which a test fake has no reason to satisfy. - */ -type SpawnFn = (command: string, args: readonly string[], options: SpawnOptions) => ChildProcess - -/** Same bkctl-style result block as sync.ts's own (private there, so restated here). */ -function printResult(statusMessage: string, rows: FieldRow[]): void { - console.log('') - console.log(` ${paint('✔', GREEN)} ${statusMessage}`) - for (const line of renderFieldRows(rows)) { - console.log(` ${line}`) - } -} - -export type BrainCommandOptions = { - action?: string | undefined - cwd: string - url?: string | undefined - token?: string | undefined - issue?: string | undefined - title?: string | undefined - prompt?: string | undefined - /** `brain serve --detach` only: background the daemon instead of running it here. */ - detach?: boolean | undefined - /** `brain install-service` only: EnvironmentFile= for the generated systemd unit. */ - envFile?: string | undefined - /** Test seam. */ - fetchImpl?: typeof fetch | undefined - /** Test seam. */ - runAgentFn?: typeof runAgent | undefined - /** Test seam for `brain serve --detach`: never forks a real process in tests. */ - spawnFn?: SpawnFn | undefined - /** Test seam for `brain install-service`/`uninstall-service`: never shells out to a real systemctl/loginctl in tests. */ - execFn?: ExecCommandFn | undefined - /** Test seams for `brain stop`'s bounded poll: real 10s/200ms by default. */ - stopTimeoutMs?: number | undefined - stopPollIntervalMs?: number | undefined -} - -async function brainConnect(opts: BrainCommandOptions): Promise { - if (!opts.url || !opts.token) { - throw new Error(t('brain.connectMissingFlags')) - } - const parsed = parseBrainToken(opts.token) - if (!parsed) { - throw new Error(t('brain.badToken')) - } - // Same global credentials sync.ts's createWorkspace/linkWorkspace write: - // `codesema sync`, `codesema link` and the brain daemon share one account. - const path = saveGlobalConfig({ - ...loadGlobalConfig(), - syncUrl: opts.url, - syncWorkspaceId: parsed.workspaceId, - syncSecret: parsed.secret, - }) - printResult(t('brain.connected', { url: opts.url }), [ - { label: t('field.account'), value: parsed.workspaceId }, - ]) - console.log(` ${t('brain.savedTo', { path })}`) - console.log('') -} - -/** - * Local-only: clears the three credentials `brainConnect` wrote, the same - * destructure-and-omit `sync.ts`'s `deleteWorkspaceData` uses to drop - * `syncWorkspaceId`/`syncSecret` (here all three, since disconnecting a brain - * is meant to fully forget it, not just its data). No API call — the brain - * has its own revocation, shipped separately in its dashboard Settings — so - * this only ever touches the local file and reminds the caller to revoke - * there too. - */ -async function brainDisconnect(): Promise { - const config = loadGlobalConfig() - if (!config.syncUrl && !config.syncWorkspaceId && !config.syncSecret) { - printResult(t('brain.alreadyDisconnected'), []) - return - } - const { syncUrl: _url, syncWorkspaceId: _id, syncSecret: _secret, ...rest } = config - saveGlobalConfig(rest) - printResult(t('brain.disconnected'), []) - console.log(` ${paint(t('brain.disconnectRevokeReminder'), AMBER)}`) - console.log('') -} - -/** `{2h14m}` / `{6m03s}` / `{9s}`: coarsest-first, no leading zero on the coarsest unit. */ -function formatUptime(startedAt: string, nowMs: number): string { - const elapsedS = Math.max(0, Math.floor((nowMs - Date.parse(startedAt)) / 1000)) - const h = Math.floor(elapsedS / 3600) - const m = Math.floor((elapsedS % 3600) / 60) - const s = elapsedS % 60 - if (h > 0) { - return `${h}h${String(m).padStart(2, '0')}m` - } - if (m > 0) { - return `${m}m${String(s).padStart(2, '0')}s` - } - return `${s}s` -} - -/** `{12s ago}` / `{3min ago}` / `{2h ago}` / `{5d ago}`: coarsest unit only, i18n'd via `brain.heartbeat*`. */ -function formatHeartbeatAge(updatedAt: string, nowMs: number): string { - const elapsedS = Math.max(0, Math.floor((nowMs - Date.parse(updatedAt)) / 1000)) - if (elapsedS < 60) { - return t('brain.heartbeatSeconds', { n: elapsedS }) - } - const elapsedMin = Math.floor(elapsedS / 60) - if (elapsedMin < 60) { - return t('brain.heartbeatMinutes', { n: elapsedMin }) - } - const elapsedH = Math.floor(elapsedMin / 60) - if (elapsedH < 24) { - return t('brain.heartbeatHours', { n: elapsedH }) - } - return t('brain.heartbeatDays', { n: Math.floor(elapsedH / 24) }) -} - -const IN_FLIGHT_TITLE_MAX = 64 - -function truncateInFlightTitle(title: string): string { - return title.length > IN_FLIGHT_TITLE_MAX ? `${title.slice(0, IN_FLIGHT_TITLE_MAX - 1)}…` : title -} - -/** - * One `brain status` in-flight detail line: brain status, executor, heartbeat - * age, the arm's own local status when the brain reports one (absent on a - * brain build older than that field), and a `stale` tag when the claim's - * lease has already lapsed: a ticket a dead or stuck arm is still shown as - * holding. - */ -function inFlightDetailLine(ticket: InFlightTicket, nowMs: number): string { - const facts = [ - ticket.status, - ticket.executed_by ?? t('brain.fieldUnclaimed'), - formatHeartbeatAge(ticket.updated_at, nowMs), - ...(ticket.arm_local_status ? [ticket.arm_local_status] : []), - ] - const line = dim(facts.join(' · ')) - const isStale = ticket.lease_expires_at !== null && Date.parse(ticket.lease_expires_at) < nowMs - return isStale ? `${line} ${paint(t('brain.fieldStale'), AMBER)}` : line -} - -function printInFlightTickets(tickets: InFlightTicket[]): void { - console.log('') - console.log(` ${paint(t('brain.inFlightHeading'), ACCENT)}`) - const nowMs = Date.now() - for (const ticket of tickets) { - console.log(` ${truncateInFlightTitle(ticket.title)}`) - console.log(` ${inFlightDetailLine(ticket, nowMs)}`) - } -} - -/** - * The daemon rows for `brain status`: pid/port/uptime read off the D21 - * pidfile, or a single "not running" row. A pidfile naming a dead pid is - * cleaned up here too, the same read-time doctrine `brainStop` uses, so - * neither command leaves a stale file for the other to trip over. - */ -function brainDaemonStatusRows(cwd: string): FieldRow[] { - const pidfile = readBrainPidfile(cwd) - if (!pidfile || !isPidAlive(pidfile.pid)) { - if (pidfile) { - removeBrainPidfile(cwd, pidfile.pid) - } - return [{ label: t('brain.fieldDaemon'), value: t('brain.notRunning') }] - } - return [ - { label: t('brain.fieldPid'), value: String(pidfile.pid) }, - { label: t('brain.fieldPort'), value: String(pidfile.port) }, - { label: t('brain.fieldUptime'), value: formatUptime(pidfile.started_at, Date.now()) }, - ] -} - -async function brainStatus(opts: BrainCommandOptions): Promise { - const creds = loadSyncCredentials() - if (!creds) { - throw new Error(t('brain.notConnected')) - } - const remoteUrl = brainRemoteUrl(opts.cwd) - const rows: FieldRow[] = [ - { label: t('brain.fieldUrl'), value: creds.url }, - { label: t('field.account'), value: creds.workspaceId }, - { label: t('brain.fieldRepo'), value: remoteUrl ?? t('brain.noRemote') }, - ...brainDaemonStatusRows(opts.cwd), - ] - if (!remoteUrl) { - printResult(t('brain.statusTitle'), rows) - return - } - const fetchImpl = opts.fetchImpl ?? fetch - const result = await listTickets(creds, remoteUrl, 'published', fetchImpl) - rows.push({ - label: t('brain.fieldReady'), - value: result.ok ? String(result.data.length) : brainErrorMessage(result.error), - }) - const inFlight = await listInFlightTickets(creds, remoteUrl, fetchImpl) - rows.push({ - label: t('brain.fieldInFlight'), - value: inFlight.ok ? String(inFlight.data.length) : brainErrorMessage(inFlight.error), - }) - printResult(t('brain.statusTitle'), rows) - if (inFlight.ok && inFlight.data.length > 0) { - printInFlightTickets(inFlight.data) - } -} - -function parsePositiveInt(raw: string): number | null { - const n = Number(raw) - return Number.isInteger(n) && n > 0 ? n : null -} - -async function brainTicket(opts: BrainCommandOptions): Promise { - const hasIssue = opts.issue !== undefined - const hasPromptForm = opts.title !== undefined && opts.prompt !== undefined - if (hasIssue === hasPromptForm) { - throw new Error(t('brain.ticketUsage')) - } - - const seams = { - ...(opts.runAgentFn ? { runAgentFn: opts.runAgentFn } : {}), - ...(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}), - } - const outcome = hasIssue - ? await (async () => { - const issueNumber = opts.issue ? parsePositiveInt(opts.issue) : null - if (issueNumber === null) { - throw new Error(t('brain.badIssueNumber', { value: opts.issue ?? '' })) - } - return draftAndPublishTicket({ kind: 'issue', cwd: opts.cwd, issueNumber }, seams) - })() - : await draftAndPublishTicket( - { - kind: 'prompt', - cwd: opts.cwd, - title: opts.title as string, - prompt: opts.prompt as string, - }, - seams, - ) - - if (!outcome.ok) { - throw new Error(t('brain.draftFailed', { reason: outcome.reason })) - } - printResult(t('brain.ticketCreated', { title: outcome.ticket.title }), [ - { label: t('brain.fieldId'), value: outcome.ticket.id }, - { label: t('field.status'), value: outcome.ticket.status }, - ]) - console.log('') - console.log(outcome.ticket.body) - console.log('') -} - -function brainDaemonLogPath(cwd: string): string { - return join(cwd, '.codesema', 'brain-daemon.log') -} - -/** - * Re-invokes THIS SAME binary as `codesema brain serve` (no --detach: that - * flag names what the CURRENT process does, not the child, or every child - * would refork itself), detached and unref'd so it outlives us, stdout/stderr - * appended to a repo-local log since a detached process has no terminal to - * write to. `process.argv[1]` is the same self-reference `index.ts`'s - * `isProcessEntrypoint` resolves against: the bin script, whether that is - * the built `dist/index.mjs` or a dev entry point. - */ -function spawnDetachedBrainServe(cwd: string, spawnFn: SpawnFn): ChildProcess { - const entry = process.argv[1] - if (entry === undefined) { - throw new Error(t('brain.detachSpawnFailed')) - } - const logPath = brainDaemonLogPath(cwd) - mkdirSync(dirname(logPath), { recursive: true }) - const logFd = openSync(logPath, 'a') - try { - const child = spawnFn(process.execPath, [entry, 'brain', 'serve'], { - cwd, - detached: true, - stdio: ['ignore', logFd, logFd], - }) - // Without a listener, an async spawn failure (e.g. the exec itself - // failing after the fork) would throw as an uncaught 'error' event, - // long after this command has already printed success and returned. - child.on('error', () => {}) - return child - } finally { - closeSync(logFd) - } -} - -async function brainServe(opts: BrainCommandOptions): Promise { - if (opts.detach) { - const child = spawnDetachedBrainServe(opts.cwd, opts.spawnFn ?? spawn) - child.unref() - if (child.pid === undefined) { - throw new Error(t('brain.detachSpawnFailed')) - } - printResult(t('brain.detached', { pid: child.pid }), [ - { label: t('brain.fieldLog'), value: brainDaemonLogPath(opts.cwd) }, - ]) - return - } - // workspace() (workspace.ts) has a fixed options type this module does not - // own, with no room for a brain flag, so the signal crosses into - // startServer (serve.ts) the same way CODESEMA_SYNC_URL/CODESEMA_DEV_VITE - // already do in this codebase: an env var read at the one place that needs - // it, not threaded through every caller's signature. - process.env.CODESEMA_BRAIN_MODE = '1' - await workspace({ cwd: opts.cwd, open: true, port: undefined }) -} - -const DEFAULT_STOP_TIMEOUT_MS = 10_000 -const DEFAULT_STOP_POLL_INTERVAL_MS = 200 - -/** Polls until `pid` is gone or `timeoutMs` runs out. Never rejects. */ -async function waitForPidDeath( - pid: number, - timeoutMs: number, - pollIntervalMs: number, -): Promise { - const deadline = Date.now() + timeoutMs - while (isPidAlive(pid)) { - if (Date.now() >= deadline) { - return false - } - await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)) - } - return true -} - -/** - * SIGTERM, then a bounded wait (default ~10s) for the pid to actually exit, - * never an infinite hang. An absent pidfile or one naming an already-dead pid - * both mean "nothing to stop", reported the same way `brainStatus` would - * report it, and just as idempotent: calling `stop` twice never throws. - */ -async function brainStop(opts: BrainCommandOptions): Promise { - const pidfile = readBrainPidfile(opts.cwd) - if (!pidfile || !isPidAlive(pidfile.pid)) { - if (pidfile) { - removeBrainPidfile(opts.cwd, pidfile.pid) - } - printResult(t('brain.notRunning'), []) - return - } - try { - process.kill(pidfile.pid, 'SIGTERM') - } catch { - // Died in the gap between the isPidAlive check above and this call. - removeBrainPidfile(opts.cwd, pidfile.pid) - printResult(t('brain.notRunning'), []) - return - } - const died = await waitForPidDeath( - pidfile.pid, - opts.stopTimeoutMs ?? DEFAULT_STOP_TIMEOUT_MS, - opts.stopPollIntervalMs ?? DEFAULT_STOP_POLL_INTERVAL_MS, - ) - if (!died) { - const seconds = Math.round((opts.stopTimeoutMs ?? DEFAULT_STOP_TIMEOUT_MS) / 1000) - console.log('') - console.log(` ${t('brain.stopTimeout', { pid: pidfile.pid, seconds })}`) - console.log('') - return - } - removeBrainPidfile(opts.cwd, pidfile.pid) - printResult(t('brain.stopped', { pid: pidfile.pid }), []) -} - -/** - * Writes and enables the systemd --user unit (D-lifecycle): must run inside - * the repo the daemon should serve, same as `brain serve` itself, since that - * repo's top-level path becomes the unit's WorkingDirectory. - */ -async function brainInstallService(opts: BrainCommandOptions): Promise { - const repoRoot = tryGit(['rev-parse', '--show-toplevel'], opts.cwd) - if (!repoRoot) { - throw new Error(t('brain.serviceNotARepo')) - } - const result = installBrainService({ - workingDirectory: repoRoot, - cwd: opts.cwd, - envFile: opts.envFile, - execFn: opts.execFn, - }) - const rows: FieldRow[] = [ - { label: t('brain.fieldUnit'), value: result.unitPath }, - { label: t('brain.fieldWorkingDirectory'), value: result.workingDirectory }, - { label: t('brain.fieldExecStart'), value: result.execStart }, - ] - if (result.environmentFile) { - rows.push({ label: t('brain.fieldEnvironmentFile'), value: result.environmentFile }) - } - printResult(t('brain.serviceInstalled'), rows) - if (result.lingerError) { - console.log(` ${paint(t('brain.lingerFailed', { reason: result.lingerError }), AMBER)}`) - } - console.log('') -} - -/** Idempotent: no unit file on disk is success, the same "nothing to do" doctrine `brainStop` already has for an absent pidfile. */ -async function brainUninstallService(opts: BrainCommandOptions): Promise { - const result = uninstallBrainService({ execFn: opts.execFn }) - if (!result.removed) { - printResult(t('brain.serviceNotInstalled'), []) - return - } - printResult(t('brain.serviceUninstalled'), [ - { label: t('brain.fieldUnit'), value: result.unitPath }, - ]) -} - -export async function brainCommand(opts: BrainCommandOptions): Promise { - switch (opts.action) { - case 'connect': - await brainConnect(opts) - return - case 'disconnect': - await brainDisconnect() - return - case 'status': - await brainStatus(opts) - return - case 'ticket': - await brainTicket(opts) - return - case 'serve': - await brainServe(opts) - return - case 'stop': - await brainStop(opts) - return - case 'install-service': - await brainInstallService(opts) - return - case 'uninstall-service': - await brainUninstallService(opts) - return - case undefined: - console.log(t('brain.usage')) - return - default: - throw new Error(t('brain.unknownAction', { action: opts.action })) - } -} diff --git a/packages/cli/src/brain-pidfile.test.ts b/packages/cli/src/brain-pidfile.test.ts deleted file mode 100644 index e09522c..0000000 --- a/packages/cli/src/brain-pidfile.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { spawnSync } from 'node:child_process' -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, test } from 'bun:test' -import { - brainPidfilePath, - readBrainPidfile, - removeBrainPidfile, - writeBrainPidfile, -} from './brain-pidfile.js' - -// Repo-local, unlike workspace.lock: no CODESEMA_CONFIG_DIR redirection -// needed, just a throwaway cwd per test. - -let cwd: string - -beforeEach(() => { - cwd = mkdtempSync(join(tmpdir(), 'codesema-brain-pidfile-')) -}) - -afterEach(() => { - rmSync(cwd, { recursive: true, force: true }) -}) - -/** A pid that is certainly dead: a child that already ran to completion. */ -function deadPid(): number { - const child = spawnSync('true') - expect(child.pid).toBeGreaterThan(0) - return child.pid -} - -describe('brainPidfilePath', () => { - test('is repo-local, under .codesema', () => { - expect(brainPidfilePath(cwd)).toBe(join(cwd, '.codesema', 'brain.pid')) - }) -}) - -describe('readBrainPidfile / writeBrainPidfile', () => { - test('absent file reads as null', () => { - expect(readBrainPidfile(cwd)).toBeNull() - }) - - test('round-trips pid, port and an ISO started_at, creating .codesema/', () => { - writeBrainPidfile(cwd, 4242, 4400) - const pidfile = readBrainPidfile(cwd) - if (!pidfile) { - throw new Error('expected a pidfile') - } - expect(pidfile.pid).toBe(4242) - expect(pidfile.port).toBe(4400) - expect(new Date(pidfile.started_at).toISOString()).toBe(pidfile.started_at) - }) - - test('a second write overwrites the first in place', () => { - writeBrainPidfile(cwd, 1, 4400) - writeBrainPidfile(cwd, 2, 4401) - expect(readBrainPidfile(cwd)).toMatchObject({ pid: 2, port: 4401 }) - }) - - test('a corrupt or half-written file reads as null, never throws', () => { - mkdirSync(join(cwd, '.codesema'), { recursive: true }) - writeFileSync(brainPidfilePath(cwd), '{"pid": 12') - expect(readBrainPidfile(cwd)).toBeNull() - }) - - test('non-integer pid/port or a non-string started_at all read as null', () => { - mkdirSync(join(cwd, '.codesema'), { recursive: true }) - writeFileSync( - brainPidfilePath(cwd), - JSON.stringify({ pid: 'x', port: 4400, started_at: '2026-01-01T00:00:00.000Z' }), - ) - expect(readBrainPidfile(cwd)).toBeNull() - writeFileSync( - brainPidfilePath(cwd), - JSON.stringify({ pid: 1, port: 4400.5, started_at: '2026-01-01T00:00:00.000Z' }), - ) - expect(readBrainPidfile(cwd)).toBeNull() - writeFileSync(brainPidfilePath(cwd), JSON.stringify({ pid: 1, port: 4400, started_at: 123 })) - expect(readBrainPidfile(cwd)).toBeNull() - }) -}) - -describe('removeBrainPidfile', () => { - test('removes our own pidfile by default (process.pid)', () => { - writeBrainPidfile(cwd, process.pid, 4400) - removeBrainPidfile(cwd) - expect(existsSync(brainPidfilePath(cwd))).toBe(false) - }) - - test('never removes a pidfile naming a DIFFERENT pid than the default (process.pid)', () => { - writeBrainPidfile(cwd, deadPid(), 4400) - removeBrainPidfile(cwd) - expect(existsSync(brainPidfilePath(cwd))).toBe(true) - }) - - test('removes a foreign pid when it is passed explicitly (brainStop/brainStatus cleanup)', () => { - const pid = deadPid() - writeBrainPidfile(cwd, pid, 4400) - removeBrainPidfile(cwd, pid) - expect(existsSync(brainPidfilePath(cwd))).toBe(false) - }) - - test('does not remove when the explicit pid no longer matches the file (raced by a fresh write)', () => { - const stale = deadPid() - writeBrainPidfile(cwd, stale, 4400) - writeBrainPidfile(cwd, process.pid, 4401) // a new daemon boot took over the file - removeBrainPidfile(cwd, stale) - expect(readBrainPidfile(cwd)).toMatchObject({ pid: process.pid, port: 4401 }) - }) - - test('is a no-op, never throws, when there is nothing to remove', () => { - expect(() => removeBrainPidfile(cwd)).not.toThrow() - expect(existsSync(brainPidfilePath(cwd))).toBe(false) - }) -}) diff --git a/packages/cli/src/brain-pidfile.ts b/packages/cli/src/brain-pidfile.ts deleted file mode 100644 index b7791d8..0000000 --- a/packages/cli/src/brain-pidfile.ts +++ /dev/null @@ -1,73 +0,0 @@ -// Repo-local pidfile for the brain daemon (D21): /.codesema/brain.pid, -// distinct from the machine-wide /workspace.lock -// (workspace-lock.ts). The two answer different questions: the workspace -// lock guarantees ONE workspace process per machine, while this file just -// lets `codesema brain stop`/`brain status`, run later from a different -// process, find the daemon this repo is running, whether it was started -// attached (`codesema brain serve`, a systemd unit) or detached (`--detach`). -// -// Same self-healing doctrine as workspace-lock.ts: a pid nothing is holding -// anymore (a crash, or a SIGKILL with no shutdown handler run) is never a -// permanent blocker. Unlike the workspace lock there is no "acquire" step to -// steal: writing always overwrites, and callers that read a dead pid clean -// up the file themselves (see brain-commands.ts's `brainStop`/`brainStatus`). - -import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' -import { dirname, join } from 'node:path' - -export type BrainPidfile = { pid: number; port: number; started_at: string } - -export function brainPidfilePath(cwd: string): string { - return join(cwd, '.codesema', 'brain.pid') -} - -/** Parsed pidfile, or null when absent/corrupt (both mean: nothing holds it). */ -export function readBrainPidfile(cwd: string): BrainPidfile | null { - let raw: unknown - try { - raw = JSON.parse(readFileSync(brainPidfilePath(cwd), 'utf8')) - } catch { - return null - } - const pidfile = raw as { pid?: unknown; port?: unknown; started_at?: unknown } | null - if ( - !pidfile || - !Number.isInteger(pidfile.pid) || - !Number.isInteger(pidfile.port) || - typeof pidfile.started_at !== 'string' - ) { - return null - } - return { - pid: pidfile.pid as number, - port: pidfile.port as number, - started_at: pidfile.started_at, - } -} - -export function writeBrainPidfile(cwd: string, pid: number, port: number): void { - const path = brainPidfilePath(cwd) - mkdirSync(dirname(path), { recursive: true }) - const content: BrainPidfile = { pid, port, started_at: new Date().toISOString() } - writeFileSync(path, `${JSON.stringify(content)}\n`) -} - -/** - * Removes the pidfile iff it still names `expectedPid`: defaults to our own - * pid, the shutdown-handler case (mirrors `WorkspaceLockHandle.release()`). - * `brainStop`/`brainStatus` pass the pid they just found dead instead: those - * run in a THIRD process, so `process.pid` would never match, and the check - * still guards against unlinking a fresh pidfile a new daemon wrote in the - * gap between that read and this delete. Never throws. - */ -export function removeBrainPidfile(cwd: string, expectedPid: number = process.pid): void { - try { - const path = brainPidfilePath(cwd) - if (readBrainPidfile(cwd)?.pid === expectedPid && existsSync(path)) { - unlinkSync(path) - } - } catch { - // Best-effort: a dead pid in a leftover pidfile is cleaned up next time - // brainStop/brainStatus reads it, or overwritten by the next boot anyway. - } -} diff --git a/packages/cli/src/config.test.ts b/packages/cli/src/config.test.ts index 636f0d0..975a328 100644 --- a/packages/cli/src/config.test.ts +++ b/packages/cli/src/config.test.ts @@ -21,6 +21,7 @@ import { resolveProjectConfig, resolveReviewMode, resolveWatchdogBudgets, + runnerEnvPath, saveGlobalConfig, saveRepoConfig, trustedProjectAgentCommand, @@ -71,6 +72,29 @@ describe('repo agent trust store', () => { }) }) +describe('runner env path', () => { + const previousConfigDir = process.env.CODESEMA_CONFIG_DIR + let configDir: string + + beforeEach(() => { + configDir = mkdtempSync(join(tmpdir(), 'codesema-runner-env-')) + process.env.CODESEMA_CONFIG_DIR = configDir + }) + + afterEach(() => { + if (previousConfigDir === undefined) { + delete process.env.CODESEMA_CONFIG_DIR + } else { + process.env.CODESEMA_CONFIG_DIR = previousConfigDir + } + rmSync(configDir, { recursive: true, force: true }) + }) + + test('lives in the global config dir', () => { + expect(runnerEnvPath()).toBe(join(configDir, 'runner.env')) + }) +}) + describe('sync credentials round-trip', () => { const previousConfigDir = process.env.CODESEMA_CONFIG_DIR let configDir: string diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index e547512..ce02566 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -102,15 +102,15 @@ export type CodesemaConfig = { */ forgeCycleLabels?: boolean | undefined /** - * Arm/brain integration: whether a task created from a brain ticket - * (`TaskRecord.brain_ticket` set) merges automatically once T3.6/D12's four + * Arm/runner integration: whether a task created from a hub ticket + * (`TaskRecord.hub_ticket` set) merges automatically once T3.6/D12's four * conditions hold, REGARDLESS of `mergePolicy`. The general setting keeps * governing every other task (default `'human'`). * - * DEFAULT `true`, unlike `mergePolicy`: the brain integration is itself an - * opt-in the owner made by standing up a local brain and running the arm + * DEFAULT `true`, unlike `mergePolicy`: the runner integration is itself an + * opt-in the owner made by standing up a local hub and running the arm * against it, and the whole point of that loop is to run unattended end to - * end (code, ship, review, merge): a brain-ticket task that still waited + * end (code, ship, review, merge): a hub-ticket task that still waited * on a human to click merge would defeat the reason the integration exists. * * GLOBAL-ONLY, same argument and same doctrine as `mergePolicy` itself: @@ -119,12 +119,12 @@ export type CodesemaConfig = { * that sets it is stripped here and NAMED by `repoGlobalOnlyIgnoredNotices`, * never dropped in silence. */ - brainAutoMerge?: boolean | undefined + runnerAutoMerge?: boolean | undefined /** * Cost guard for one workspace task: how many turns (creation, replies, * automatic fix rounds alike) it may spend before every further reply is * refused and a human decides. GLOBAL-ONLY, same consent argument as - * `brainAutoMerge`: the token budget burned by a looping task belongs to + * `runnerAutoMerge`: the token budget burned by a looping task belongs to * the machine owner, never to a cloned repository. Absent means 30. */ maxTaskTurns?: number | undefined @@ -317,13 +317,13 @@ export function resolveMergeSettings(config: CodesemaConfig): MergeSettings { } /** - * Whether a brain-ticket task auto-merges (see `CodesemaConfig.brainAutoMerge`'s + * Whether a hub-ticket task auto-merges (see `CodesemaConfig.runnerAutoMerge`'s * own doc comment). Absent means `true`, the one setting in this module - * whose unconfigured default is the ENABLED one, since the brain integration + * whose unconfigured default is the ENABLED one, since the runner integration * is itself the opt-in. */ -export function resolveBrainAutoMerge(config: CodesemaConfig): boolean { - return config.brainAutoMerge ?? true +export function resolveRunnerAutoMerge(config: CodesemaConfig): boolean { + return config.runnerAutoMerge ?? true } /** The per-task turn budget (see `CodesemaConfig.maxTaskTurns`). Absent means 30. */ @@ -390,6 +390,9 @@ function parseConfig(path: string, scope: ConfigScope): CodesemaConfig { const secs = (v: unknown) => Number.isInteger(v) && (v as number) >= 1 ? (v as number) : undefined const allowedDomains = sanitizeAllowedDomains(raw.isolationAllowedDomains) + // Compat: `brainAutoMerge` is the pre-rename key, honored only when `runnerAutoMerge` is absent. + const runnerAutoMerge = + typeof raw.runnerAutoMerge === 'boolean' ? raw.runnerAutoMerge : raw.brainAutoMerge return { ...(str(raw.agent) ? { agent: str(raw.agent) } : {}), ...(str(raw.agentId) ? { agentId: str(raw.agentId) } : {}), @@ -492,13 +495,11 @@ function parseConfig(path: string, scope: ConfigScope): CodesemaConfig { ...(typeof raw.forgeCycleLabels === 'boolean' ? { forgeCycleLabels: raw.forgeCycleLabels } : {}), - // Arm/brain integration, GLOBAL-ONLY (see the field's own comment): a + // Arm/runner integration, GLOBAL-ONLY (see the field's own comment): a // consent to merge without asking is the machine owner's to give, not // a cloned repository's. Stripped from a repo file here (and NAMED by // `repoGlobalOnlyIgnoredNotices`), same doctrine as `mergePolicy`. - ...(scope === 'global' && typeof raw.brainAutoMerge === 'boolean' - ? { brainAutoMerge: raw.brainAutoMerge } - : {}), + ...(scope === 'global' && typeof runnerAutoMerge === 'boolean' ? { runnerAutoMerge } : {}), ...(scope === 'global' && typeof raw.maxTaskTurns === 'number' && Number.isInteger(raw.maxTaskTurns) && @@ -695,11 +696,11 @@ const REPO_IGNORED_GLOBAL_ONLY_KEYS = [ 'mergeStrategy', 'deleteBranchAfterMerge', 'allowMergeWithoutChecks', - // Arm/brain integration: the same consent argument as the four above, not - // a machine resource either. A repo cloned from a brain-driven workspace + // Arm/runner integration: the same consent argument as the four above, not + // a machine resource either. A repo cloned from a runner-driven workspace // must not be able to consent to auto-merging its own tickets on the // machine owner's behalf. - 'brainAutoMerge', + 'runnerAutoMerge', // The turn budget is money: same owner, same rule. 'maxTaskTurns', ] as const @@ -850,6 +851,11 @@ export function trustStorePath(): string { return join(globalConfigDir(), 'trusted-agents.json') } +/** Where a runner's sealed secrets are decrypted to before being sourced into its agent process. */ +export function runnerEnvPath(): string { + return join(globalConfigDir(), 'runner.env') +} + function readTrustStore(): Record { const path = trustStorePath() if (!existsSync(path)) { diff --git a/packages/cli/src/brain-client.test.ts b/packages/cli/src/hub-client.test.ts similarity index 57% rename from packages/cli/src/brain-client.test.ts rename to packages/cli/src/hub-client.test.ts index d3fe4b8..4de2f7d 100644 --- a/packages/cli/src/brain-client.test.ts +++ b/packages/cli/src/hub-client.test.ts @@ -1,26 +1,34 @@ -import { describe, expect, test } from 'bun:test' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import type { ArmTicket, ArmTicketRequest, RunnerListEntry } from './contract.js' import { - brainErrorMessage, + claimPendingSecret, claimTicket, claimTicketRequest, createTicket, + depositRunnerSecret, failTicketRequest, getTicket, heartbeat, + hubErrorMessage, listInFlightTickets, + listRunners, listTicketRequests, listTickets, - parseBrainToken, + parseHubToken, pushEvents, + registerRunnerKey, submitTicketRequestTickets, transition, -} from './brain-client.js' -import type { ArmTicket, ArmTicketRequest } from './contract.js' +} from './hub-client.js' +import { loadOrCreateRunnerIdentity } from './runner-identity.js' import type { SyncCredentials } from './sync.js' type Call = { url: string; init: RequestInit } -/** Same stub as sync.test.ts / task-brain.test.ts: records every call, answers one fixed response. */ +/** Same stub as sync.test.ts / task-hub.test.ts: records every call, answers one fixed response. */ function fetchStub(status: number, body: unknown, calls: Call[]): typeof fetch { return ((url: string | URL | Request, init?: RequestInit) => { calls.push({ url: String(url), init: init ?? {} }) @@ -37,7 +45,7 @@ function fetchOffline(): typeof fetch { return (() => Promise.reject(new Error('network unreachable'))) as unknown as typeof fetch } -const creds: SyncCredentials = { url: 'https://brain.example', workspaceId: 'w1', secret: 's1' } +const creds: SyncCredentials = { url: 'https://hub.example', workspaceId: 'w1', secret: 's1' } const validTicket: ArmTicket = { id: 't1', @@ -65,34 +73,43 @@ const validRequest: ArmTicketRequest = { created_at: '2026-01-01T00:00:00.000Z', } -describe('parseBrainToken', () => { +/** A sha256 hex digest (fingerprint) and a base64-encoded 32-byte key (public_key): the exact shapes `sanitizeRunnerListEntry` requires, not placeholders. */ +const validRunnerListEntry: RunnerListEntry = { + name: 'my-laptop', + fingerprint: '597fbc141b11df74a6642a6d8381d1b77ae49de801b177957cfa624fc13db748', + public_key: '6y91HfIciBvPrgZlyIYTjRcUvzSzlEJQroZRfdEPx5M=', + last_seen_at: '2026-01-01T00:00:00.000Z', + has_pending_secret: false, +} + +describe('parseHubToken', () => { test('splits on the first dot', () => { - expect(parseBrainToken('csk_ws1.se.cret')).toEqual({ workspaceId: 'ws1', secret: 'se.cret' }) + expect(parseHubToken('csk_ws1.se.cret')).toEqual({ workspaceId: 'ws1', secret: 'se.cret' }) }) test('rejects a token missing the csk_ prefix', () => { - expect(parseBrainToken('ws1.secret')).toBeNull() + expect(parseHubToken('ws1.secret')).toBeNull() }) test('rejects a token with no dot', () => { - expect(parseBrainToken('csk_ws1secret')).toBeNull() + expect(parseHubToken('csk_ws1secret')).toBeNull() }) test('rejects an empty workspace id or secret', () => { - expect(parseBrainToken('csk_.secret')).toBeNull() - expect(parseBrainToken('csk_ws1.')).toBeNull() + expect(parseHubToken('csk_.secret')).toBeNull() + expect(parseHubToken('csk_ws1.')).toBeNull() }) test('trims surrounding whitespace', () => { - expect(parseBrainToken(' csk_ws1.secret ')).toEqual({ workspaceId: 'ws1', secret: 'secret' }) + expect(parseHubToken(' csk_ws1.secret ')).toEqual({ workspaceId: 'ws1', secret: 'secret' }) }) }) -describe('brainErrorMessage', () => { +describe('hubErrorMessage', () => { test('renders each error kind distinctly', () => { - expect(brainErrorMessage({ kind: 'network' })).toContain('could not reach') - expect(brainErrorMessage({ kind: 'unavailable' })).toContain('does not support') - expect(brainErrorMessage({ kind: 'http', status: 409, error: 'ticket_in_flight' })).toContain( + expect(hubErrorMessage({ kind: 'network' })).toContain('could not reach') + expect(hubErrorMessage({ kind: 'unavailable' })).toContain('does not support') + expect(hubErrorMessage({ kind: 'http', status: 409, error: 'ticket_in_flight' })).toContain( 'ticket_in_flight', ) }) @@ -252,7 +269,7 @@ describe('listInFlightTickets', () => { expect(result).toEqual({ ok: true, data: [{ ...validTicket, arm_local_status: 'executing' }] }) }) - test('degrades arm_local_status to null when the brain does not send it (older brain)', async () => { + test('degrades arm_local_status to null when the hub does not send it (older hub)', async () => { const result = await listInFlightTickets( creds, 'https://github.com/o/r.git', @@ -352,3 +369,175 @@ describe('heartbeat / transition / pushEvents', () => { expect(body.ticket_id).toBe('t1') }) }) + +describe('registerRunnerKey', () => { + test('sends the public key and name, parses the fingerprint back', async () => { + const calls: Call[] = [] + const result = await registerRunnerKey( + creds, + { public_key: 'pk1', name: 'my-laptop' }, + fetchStub(200, { fingerprint: 'fp1' }, calls), + ) + expect(result).toEqual({ ok: true, data: { fingerprint: 'fp1' } }) + const body = JSON.parse(String(calls[0]?.init.body)) as { public_key: string; name: string } + expect(body).toEqual({ public_key: 'pk1', name: 'my-laptop' }) + }) + + test('a malformed response is refused', async () => { + const result = await registerRunnerKey( + creds, + { public_key: 'pk1', name: 'my-laptop' }, + fetchStub(200, {}, []), + ) + expect(result.ok).toBe(false) + }) +}) + +describe('listRunners', () => { + test('parses a valid collection response', async () => { + const calls: Call[] = [] + const result = await listRunners( + creds, + fetchStub(200, { runners: [validRunnerListEntry] }, calls), + ) + expect(result).toEqual({ ok: true, data: [validRunnerListEntry] }) + expect(calls[0]?.url).toBe('https://hub.example/api/cli/runners') + expect(calls[0]?.init.method).toBe('GET') + }) + + /** + * The one place this file's list* behavior intentionally diverges from + * `sanitizeList`'s all-or-nothing doctrine: a malformed row is dropped, + * the rest of the listing still comes back `ok: true`. + */ + test('drops an invalid entry instead of refusing the whole list', async () => { + const result = await listRunners( + creds, + fetchStub(200, { runners: [validRunnerListEntry, { nope: true }] }, []), + ) + expect(result).toEqual({ ok: true, data: [validRunnerListEntry] }) + }) + + test('a 404 on this collection route degrades to unavailable', async () => { + const result = await listRunners(creds, fetchStub(404, { error: 'not found' }, [])) + expect(result).toEqual({ ok: false, error: { kind: 'unavailable' } }) + }) + + test('a network failure is reported as such', async () => { + const result = await listRunners(creds, fetchOffline()) + expect(result).toEqual({ ok: false, error: { kind: 'network' } }) + }) +}) + +describe('depositRunnerSecret', () => { + test('sends the ciphertext to the fingerprint-scoped route', async () => { + const calls: Call[] = [] + const result = await depositRunnerSecret( + creds, + 'fp1', + 'ciphertext-blob', + fetchStub(200, {}, calls), + ) + expect(result).toEqual({ ok: true, data: undefined }) + expect(calls[0]?.url).toBe('https://hub.example/api/cli/runners/fp1/secret') + const body = JSON.parse(String(calls[0]?.init.body)) as { ciphertext: string } + expect(body).toEqual({ ciphertext: 'ciphertext-blob' }) + }) + + test('a by-id 404 is a normal http error, not unavailable', async () => { + const result = await depositRunnerSecret( + creds, + 'unknown-fp', + 'blob', + fetchStub(404, { error: 'unknown runner' }, []), + ) + expect(result).toEqual({ + ok: false, + error: { kind: 'http', status: 404, error: 'unknown runner' }, + }) + }) +}) + +describe('claimPendingSecret', () => { + test('parses the claimed ciphertext', async () => { + const result = await claimPendingSecret( + creds, + 'fp1', + fetchStub( + 200, + { secret: { ciphertext: 'sealed-blob', pushed_at: '2026-01-01T00:00:00.000Z' } }, + [], + ), + ) + expect(result).toEqual({ ok: true, data: { ciphertext: 'sealed-blob' } }) + }) + + test('a 404 (nothing pending) resolves as ok(null), not an error', async () => { + const result = await claimPendingSecret(creds, 'fp1', fetchStub(404, { error: 'none' }, [])) + expect(result).toEqual({ ok: true, data: null }) + }) + + test('a malformed secret body is refused', async () => { + const result = await claimPendingSecret( + creds, + 'fp1', + fetchStub(200, { secret: { nope: true } }, []), + ) + expect(result.ok).toBe(false) + }) + + test('a network failure is reported as such', async () => { + const result = await claimPendingSecret(creds, 'fp1', fetchOffline()) + expect(result).toEqual({ ok: false, error: { kind: 'network' } }) + }) +}) + +/** + * Isolated via `CODESEMA_CONFIG_DIR` (same pattern as runner-daemon.test.ts), + * never via module mocking: a `mock.module` override of `./runner-identity.js` + * is process-wide and, under the project's parallel test runner, was + * observed leaking into runner-identity.test.ts's own suite running + * concurrently in another file. A real, isolated identity file exercises the + * same `request()` code path without that risk. + */ +describe('runner identity header propagation', () => { + const previousConfigDir = process.env.CODESEMA_CONFIG_DIR + let configDir: string + + beforeEach(() => { + configDir = mkdtempSync(join(tmpdir(), 'codesema-hubclient-identity-')) + process.env.CODESEMA_CONFIG_DIR = configDir + }) + + afterEach(() => { + rmSync(configDir, { recursive: true, force: true }) + if (previousConfigDir === undefined) { + delete process.env.CODESEMA_CONFIG_DIR + } else { + process.env.CODESEMA_CONFIG_DIR = previousConfigDir + } + }) + + test('sends no runner header when no identity exists yet', async () => { + const calls: Call[] = [] + await listTicketRequests( + creds, + 'https://github.com/o/r.git', + fetchStub(200, { requests: [] }, calls), + ) + const headers = calls[0]?.init.headers as Record | undefined + expect(headers?.['x-codesema-runner']).toBeUndefined() + }) + + test('adds x-codesema-runner to every request once an identity exists', async () => { + const identity = loadOrCreateRunnerIdentity() + const calls: Call[] = [] + await listTicketRequests( + creds, + 'https://github.com/o/r.git', + fetchStub(200, { requests: [] }, calls), + ) + const headers = calls[0]?.init.headers as Record | undefined + expect(headers?.['x-codesema-runner']).toBe(identity.fingerprint) + }) +}) diff --git a/packages/cli/src/brain-client.ts b/packages/cli/src/hub-client.ts similarity index 61% rename from packages/cli/src/brain-client.ts rename to packages/cli/src/hub-client.ts index 4dcaa52..f1ac39b 100644 --- a/packages/cli/src/brain-client.ts +++ b/packages/cli/src/hub-client.ts @@ -1,8 +1,8 @@ -// Typed HTTP client for the brain (the local SaaS that owns arm tickets). +// Typed HTTP client for the hub (the local SaaS that owns arm tickets). // Same base URL and bearer credentials as codesema.com cloud sync (sync.ts): -// a brain and a sync workspace are the same account, on whichever host -// `codesema brain connect` (or `codesema sync`) last pointed at. Every method -// here returns a BrainResult rather than throwing, so a caller (a command, a +// a hub and a sync workspace are the same account, on whichever host +// `codesema runner connect` (or `codesema sync`) last pointed at. Every method +// here returns a HubResult rather than throwing, so a caller (a command, a // daemon tick) always decides for itself whether an error is worth retrying, // without a try/catch of its own. @@ -11,45 +11,49 @@ import { sanitizeArmClaimResult, sanitizeArmTicket, sanitizeArmTicketRequest, + sanitizeRunnerListEntry, + sanitizeSealedSecretBlob, type ArmClaimResult, type ArmEvent, type ArmIssueRef, type ArmTicket, type ArmTicketRequest, type ArmTransition, + type RunnerListEntry, } from './contract.js' import { tryGit } from './git.js' +import { runnerIdentityHeader } from './runner-identity.js' import { authHeader, type SyncCredentials } from './sync.js' -const BRAIN_REQUEST_TIMEOUT_MS = 10_000 +const HUB_REQUEST_TIMEOUT_MS = 10_000 -export type BrainError = +export type HubError = | { kind: 'http'; status: number; error: string } | { kind: 'network' } /** * A 404 on a route this client treats as always-present on a well-behaved - * brain (a bare collection GET, which answers an empty list rather than - * 404ing on "nothing found"): the brain reached is simply older than this + * hub (a bare collection GET, which answers an empty list rather than + * 404ing on "nothing found"): the hub reached is simply older than this * route. Never produced for a by-id lookup, where a 404 is a normal, * meaningful "not found" and stays a `kind: 'http'` error. */ | { kind: 'unavailable' } -export type BrainResult = { ok: true; data: T } | { ok: false; error: BrainError } +export type HubResult = { ok: true; data: T } | { ok: false; error: HubError } -/** Same read as server-context.ts: raw, unnormalized; the brain normalizes it server-side. */ -export function brainRemoteUrl(cwd: string): string | null { +/** Same read as server-context.ts: raw, unnormalized; the hub normalizes it server-side. */ +export function hubRemoteUrl(cwd: string): string | null { return tryGit(['remote', 'get-url', 'origin'], cwd) } -export function brainErrorMessage(error: BrainError): string { +export function hubErrorMessage(error: HubError): string { if (error.kind === 'network') { - return 'could not reach the brain: check your connection or the brain URL' + return 'could not reach the hub: check your connection or the hub URL' } if (error.kind === 'unavailable') { - return 'this brain build does not support that route yet' + return 'this hub build does not support that route yet' } - return `brain rejected the request (${error.status}): ${error.error}` + return `hub rejected the request (${error.status}): ${error.error}` } /** @@ -57,7 +61,7 @@ export function brainErrorMessage(error: BrainError): string { * builds. Split on the FIRST dot only, so a secret that itself carries a dot * is not truncated. */ -export function parseBrainToken(token: string): { workspaceId: string; secret: string } | null { +export function parseHubToken(token: string): { workspaceId: string; secret: string } | null { const match = /^csk_([^.]+)\.(.+)$/s.exec(token.trim()) if (!match) { return null @@ -99,28 +103,39 @@ function ack(): Record { return {} } -type RequestOptions = { +type RequestOptions = { fetchImpl: typeof fetch - /** See `BrainError`'s `unavailable` doc: only a bare collection GET qualifies. */ + /** See `HubError`'s `unavailable` doc: only a bare collection GET qualifies. */ collectionRoute?: boolean + /** + * A route where a plain 404 means "nothing to report" rather than an + * error (checking whether a claim is pending): resolves as + * `{ ok: true, data: notFoundValue }` instead of a `kind: 'http'` error. + * No route needs both this and `collectionRoute` at once. + */ + notFoundValue?: T } -type RequestSpec = RequestOptions & { +type RequestSpec = RequestOptions & { method: string path: string body?: unknown parse: (body: unknown) => T | null } -async function request(creds: SyncCredentials, spec: RequestSpec): Promise> { +async function request(creds: SyncCredentials, spec: RequestSpec): Promise> { const { method, path, body, parse } = spec let res: Response try { res = await spec.fetchImpl(`${creds.url}${path}`, { method, - headers: { 'content-type': 'application/json', ...authHeader(creds) }, + headers: { + 'content-type': 'application/json', + ...authHeader(creds), + ...runnerIdentityHeader(), + }, ...(method === 'GET' ? {} : { body: JSON.stringify(body ?? {}) }), - signal: AbortSignal.timeout(BRAIN_REQUEST_TIMEOUT_MS), + signal: AbortSignal.timeout(HUB_REQUEST_TIMEOUT_MS), }) } catch { return { ok: false, error: { kind: 'network' } } @@ -130,6 +145,9 @@ async function request(creds: SyncCredentials, spec: RequestSpec): Promise if (res.status === 404 && spec.collectionRoute) { return { ok: false, error: { kind: 'unavailable' } } } + if (res.status === 404 && spec.notFoundValue !== undefined) { + return { ok: true, data: spec.notFoundValue } + } const errorField = field(parsedBody, 'error') const message = typeof errorField === 'string' ? errorField : `HTTP ${res.status}` return { ok: false, error: { kind: 'http', status: res.status, error: message } } @@ -148,7 +166,7 @@ export async function listTicketRequests( creds: SyncCredentials, remoteUrl: string, fetchImpl: typeof fetch = fetch, -): Promise> { +): Promise> { const qs = new URLSearchParams({ remote_url: remoteUrl, status: 'queued' }) return request(creds, { method: 'GET', @@ -163,7 +181,7 @@ export async function claimTicketRequest( creds: SyncCredentials, requestId: string, fetchImpl: typeof fetch = fetch, -): Promise> { +): Promise> { return request(creds, { method: 'POST', path: `/api/cli/ticket-requests/${encodeURIComponent(requestId)}/claim`, @@ -180,7 +198,7 @@ export async function submitTicketRequestTickets( requestId: string, tickets: TicketDraftInput[], fetchImpl: typeof fetch = fetch, -): Promise> { +): Promise> { return request(creds, { method: 'POST', path: `/api/cli/ticket-requests/${encodeURIComponent(requestId)}/tickets`, @@ -201,7 +219,7 @@ export async function failTicketRequest( requestId: string, errorMessage: string, fetchImpl: typeof fetch = fetch, -): Promise>> { +): Promise>> { return request(creds, { method: 'POST', path: `/api/cli/ticket-requests/${encodeURIComponent(requestId)}/fail`, @@ -215,7 +233,7 @@ export async function createTicket( creds: SyncCredentials, input: { remoteUrl: string; title: string; body: string; sourceIssue?: ArmIssueRef }, fetchImpl: typeof fetch = fetch, -): Promise> { +): Promise> { return request(creds, { method: 'POST', path: '/api/cli/tickets', @@ -237,7 +255,7 @@ export async function listTickets( remoteUrl: string, status: string, fetchImpl: typeof fetch = fetch, -): Promise> { +): Promise> { const qs = new URLSearchParams({ remote_url: remoteUrl, status }) return request(creds, { method: 'GET', @@ -250,10 +268,10 @@ export async function listTickets( /** * An `ArmTicket` still in flight (in_progress/mr_opened/ready_to_merge, the - * `status=in_flight` alias the brain resolves server-side), plus the one - * extra field `brain status` needs that `ArmTicket` itself does not carry: + * `status=in_flight` alias the hub resolves server-side), plus the one + * extra field `runner status` needs that `ArmTicket` itself does not carry: * the arm's own last-reported local reconciliation status for this ticket. - * `arm_local_status` is `null` on a brain build that predates that field, + * `arm_local_status` is `null` on a hub build that predates that field, * same degrade-not-break doctrine as every sanitizer in ./contract.js, * applied here instead since the field is not (yet) part of the published * wire contract. @@ -271,12 +289,12 @@ function sanitizeInFlightTicket(raw: unknown): InFlightTicket | null { return { ...ticket, arm_local_status: armLocalStatus || null } } -/** Same collection-route doctrine as `listTickets`; `status=in_flight` is the alias the brain resolves to in_progress/mr_opened/ready_to_merge. */ +/** Same collection-route doctrine as `listTickets`; `status=in_flight` is the alias the hub resolves to in_progress/mr_opened/ready_to_merge. */ export async function listInFlightTickets( creds: SyncCredentials, remoteUrl: string, fetchImpl: typeof fetch = fetch, -): Promise> { +): Promise> { const qs = new URLSearchParams({ remote_url: remoteUrl, status: 'in_flight' }) return request(creds, { method: 'GET', @@ -291,7 +309,7 @@ export async function getTicket( creds: SyncCredentials, ticketId: string, fetchImpl: typeof fetch = fetch, -): Promise> { +): Promise> { return request(creds, { method: 'GET', path: `/api/cli/tickets/${encodeURIComponent(ticketId)}`, @@ -305,7 +323,7 @@ export async function claimTicket( ticketId: string, opts: { leaseSeconds?: number } = {}, fetchImpl: typeof fetch = fetch, -): Promise> { +): Promise> { return request(creds, { method: 'POST', path: `/api/cli/tickets/${encodeURIComponent(ticketId)}/claim`, @@ -319,7 +337,7 @@ export async function heartbeat( creds: SyncCredentials, ticketId: string, fetchImpl: typeof fetch = fetch, -): Promise>> { +): Promise>> { return request(creds, { method: 'POST', path: `/api/cli/tickets/${encodeURIComponent(ticketId)}/heartbeat`, @@ -334,7 +352,7 @@ export async function transition( ticketId: string, input: ArmTransition, fetchImpl: typeof fetch = fetch, -): Promise>> { +): Promise>> { return request(creds, { method: 'POST', path: `/api/cli/tickets/${encodeURIComponent(ticketId)}/transitions`, @@ -348,7 +366,7 @@ export async function pushEvents( creds: SyncCredentials, input: { remoteUrl: string | null; runId: string; ticketId: string; events: ArmEvent[] }, fetchImpl: typeof fetch = fetch, -): Promise>> { +): Promise>> { return request(creds, { method: 'POST', path: '/api/cli/events', @@ -362,3 +380,96 @@ export async function pushEvents( fetchImpl, }) } + +export async function registerRunnerKey( + creds: SyncCredentials, + input: { public_key: string; name: string }, + fetchImpl: typeof fetch = fetch, +): Promise> { + return request(creds, { + method: 'POST', + path: '/api/cli/runners', + body: { public_key: input.public_key, name: input.name }, + parse: (body) => { + const fingerprint = field(body, 'fingerprint') + return typeof fingerprint === 'string' && fingerprint.trim() ? { fingerprint } : null + }, + fetchImpl, + }) +} + +/** + * Unlike every other list* function in this file, an entry this sanitizer + * cannot place DROPS ONLY THAT ENTRY rather than refusing the whole + * collection (contrast `sanitizeList`'s all-or-nothing doctrine, used by + * `listTickets`/`listTicketRequests`/`listInFlightTickets`): this listing is + * read by a human deciding which runner to push a secret to, or by the + * daemon checking pending state, and one malformed row (a hub built for a + * newer runner shape than this client understands) must not hide every + * other legitimately usable runner from view. + */ +export async function listRunners( + creds: SyncCredentials, + fetchImpl: typeof fetch = fetch, +): Promise> { + return request(creds, { + method: 'GET', + path: '/api/cli/runners', + parse: (body) => { + const raw = field(body, 'runners') + if (!Array.isArray(raw)) { + return null + } + const entries: RunnerListEntry[] = [] + for (const item of raw) { + const entry = sanitizeRunnerListEntry(item) + if (entry) { + entries.push(entry) + } + } + return entries + }, + fetchImpl, + collectionRoute: true, + }) +} + +export async function depositRunnerSecret( + creds: SyncCredentials, + fingerprint: string, + ciphertext: string, + fetchImpl: typeof fetch = fetch, +): Promise> { + return request(creds, { + method: 'POST', + path: `/api/cli/runners/${encodeURIComponent(fingerprint)}/secret`, + body: { ciphertext }, + parse: () => undefined, + fetchImpl, + }) +} + +/** + * Claims whatever secret blob the hub is currently holding for this runner, + * for the daemon's own rotation tick to unseal and apply. A 404 (nothing + * pending) is the routine, expected outcome of most ticks and resolves as + * `{ ok: true, data: null }` via `notFoundValue`, never as an error the + * caller has to special-case out of `HubError`. + */ +export async function claimPendingSecret( + creds: SyncCredentials, + fingerprint: string, + fetchImpl: typeof fetch = fetch, +): Promise> { + return request(creds, { + method: 'POST', + path: `/api/cli/runners/${encodeURIComponent(fingerprint)}/secret/claim`, + body: {}, + notFoundValue: null, + parse: (body) => { + const blob = sanitizeSealedSecretBlob(field(body, 'secret')) + return blob ? { ciphertext: blob.ciphertext } : null + }, + fetchImpl, + }) +} diff --git a/packages/cli/src/i18n.test.ts b/packages/cli/src/i18n.test.ts index bccdd2e..26be471 100644 --- a/packages/cli/src/i18n.test.ts +++ b/packages/cli/src/i18n.test.ts @@ -41,7 +41,7 @@ describe('every key is actually translated, not copied from the English', () => * Identical on purpose, and each for a reason that is not "nobody got to * it": a proper noun (`menu.cloud*`), a field name that is the same word in * both languages (`field.mode`, `field.prompt`, `field.web`, - * `field.verdict`, `prep.label.custom`, `brain.fieldId`, `brain.fieldUrl`), + * `field.verdict`, `prep.label.custom`, `runner.fieldId`, `runner.fieldUrl`), * a technical token quoted verbatim (`wizard.stdinStdout`), or a label * whose French spelling IS the English one (`prep.title`, * `prep.label.commits`, `review.commits`, `review.dualLaneA`, @@ -63,12 +63,12 @@ describe('every key is actually translated, not copied from the English', () => 'export.prologue', 'menu.cloud', 'menu.cloudTitle', - 'brain.fieldId', - 'brain.fieldUrl', - 'brain.fieldPid', - 'brain.fieldPort', - 'brain.fieldUptime', - 'brain.fieldLog', + 'runner.fieldId', + 'runner.fieldUrl', + 'runner.fieldPid', + 'runner.fieldPort', + 'runner.fieldUptime', + 'runner.fieldLog', ]) test('no French entry is a copy of its English one, outside the argued list', () => { diff --git a/packages/cli/src/i18n.ts b/packages/cli/src/i18n.ts index 4598159..b7a3941 100644 --- a/packages/cli/src/i18n.ts +++ b/packages/cli/src/i18n.ts @@ -35,24 +35,33 @@ Usage: codesema sync Push the latest review to your codesema.com workspace codesema sync delete Delete all synced data (unlinked workspaces only) codesema link [code] Link this workspace to your codesema.com account (no code: confirm in the browser) - codesema brain connect --url --token - Connect this workspace to a brain (same account as sync/link) - codesema brain status Show the connected brain, this repo, its ready ticket count and + codesema runner connect --url --token + Connect this workspace to a hub (same account as sync/link) + codesema runner status Show the connected hub, this repo, its ready ticket count and in-flight tickets (executor, heartbeat age, stale lease) - codesema brain ticket --issue Draft and publish a ticket from a forge issue - codesema brain ticket --title --prompt

+ codesema runner list List the runners registered on the connected hub (fingerprint, + last heartbeat, pending secret) + codesema runner ticket --issue Draft and publish a ticket from a forge issue + codesema runner ticket --title --prompt

Draft and publish a ticket from a free-form prompt - codesema brain serve [--detach] Alias for \`codesema workspace --brain\`; --detach backgrounds the + codesema runner autoconfig [--fingerprint ] [--gh-token-from-gh] [--claude-token ] [--repo-url ] + Pick a registered runner, collect a GH token and/or a Claude Code + token for it, seal them to that runner's key and deposit them on + the hub for it to pick up + codesema runner await-secrets [--env-file ] [--timeout ] + Run on the runner machine: wait for the secret \`autoconfig\` sealed + for it, decrypt it into --env-file and print the repo URL it carried + codesema runner serve [--detach] Alias for \`codesema workspace --runner\`; --detach backgrounds the daemon (prints its pid and log path) instead of running it in the foreground - codesema brain stop Stop a brain daemon started with --detach (or under systemd) for + codesema runner stop Stop a runner daemon started with --detach (or under systemd) for this repo - codesema brain disconnect Forget the connected brain (clears local credentials only — + codesema runner disconnect Forget the connected hub (clears local credentials only — also revoke this arm in the dashboard's Settings) - codesema brain install-service [--env-file ] - Install a systemd --user unit that runs \`codesema brain serve\` + codesema runner install-service [--env-file ] + Install a systemd --user unit that runs \`codesema runner serve\` for this repo, enabled and started now - codesema brain uninstall-service Stop and remove that systemd --user unit + codesema runner uninstall-service Stop and remove that systemd --user unit Options: --branch Local branch to review (default: interactive picker, else current branch) @@ -62,7 +71,8 @@ Options: --review Agent output to display (default: .codesema/review.json, else last archived review) --out Destination of \`export\` (- for stdout) --port Preferred port for the local server (default: 4400) - --timeout Agent time budget in seconds for \`review\` (default: 900) + --timeout Agent time budget in seconds for \`review\` (default: 900), or the poll budget + for \`runner await-secrets\` (default: 1800) --full Review from scratch instead of updating the previous review --dual Dual review: two independent reviewers run in parallel (same agent, different angles), then a judge model merges their findings @@ -70,12 +80,17 @@ Options: (critical, major, minor, info) or when changes are requested --force \`sync\`: upload even though the diff looks like it carries a secret --no-open Do not open the browser - --brain \`workspace\`: also start the brain daemon (drafts ticket requests, claims + --runner \`workspace\`: also start the runner daemon (drafts ticket requests, claims and runs published tickets) - --url, --token \`brain connect\`: the brain's URL and a csk_. token - --issue \`brain ticket\`: draft from this forge issue number - --title, --prompt \`brain ticket\`: draft from a free-form title and prompt instead of an issue - --env-file \`brain install-service\`: EnvironmentFile= for the generated systemd unit + --url, --token \`runner connect\`: the hub's URL and a csk_. token + --issue \`runner ticket\`: draft from this forge issue number + --title, --prompt \`runner ticket\`: draft from a free-form title and prompt instead of an issue + --fingerprint \`runner autoconfig\`: pick the runner by fingerprint instead of the interactive picker + --gh-token-from-gh \`runner autoconfig\`: capture GH_TOKEN from this machine's \`gh auth token\` without asking + --claude-token \`runner autoconfig\`: Claude Code OAuth token to send instead of prompting for one + --repo-url \`runner autoconfig\`: repo URL to send instead of the detected git remote + --env-file \`runner install-service\`'s EnvironmentFile=, or \`runner await-secrets\`'s + destination env file (default: the runner's own env file) -h, --help Show this help -v, --version Show version @@ -243,12 +258,12 @@ terminal, offers to upgrade when a newer version exists. Set CODESEMA_NO_UPDATE_ 'config.autoSyncQuestion': 'Push every completed review to your codesema.com workspace automatically?', 'config.autoSyncSaved': 'auto-sync {state}: {path}', - 'config.brainAutoMergeEntry': 'Brain ticket auto-merge', - 'config.brainAutoMergeOn': 'on', - 'config.brainAutoMergeOff': 'off', - 'config.brainAutoMergeQuestion': 'Auto-merge a brain-ticket task once it ships clean?', - 'config.brainAutoMergeOnHint': 'the whole point of connecting a brain: no human click to merge', - 'config.brainAutoMergeSaved': 'brain auto-merge {state}: {path}', + 'config.runnerAutoMergeEntry': 'Hub ticket auto-merge', + 'config.runnerAutoMergeOn': 'on', + 'config.runnerAutoMergeOff': 'off', + 'config.runnerAutoMergeQuestion': 'Auto-merge a hub-ticket task once it ships clean?', + 'config.runnerAutoMergeOnHint': 'the whole point of connecting a hub: no human click to merge', + 'config.runnerAutoMergeSaved': 'runner auto-merge {state}: {path}', 'config.maxTurnsEntry': 'Turn budget per task', 'config.maxTurnsQuestion': 'How many turns may one task spend before replies are refused?', 'config.maxTurnsDefaultHint': 'default', @@ -376,66 +391,105 @@ terminal, offers to upgrade when a newer version exists. Set CODESEMA_NO_UPDATE_ 'sync.unreachable': 'could not reach {url}: check your connection or CODESEMA_SYNC_URL', 'sync.badResponse': 'unexpected response from {url}: required fields are missing or invalid', - 'brain.usage': - 'usage: codesema brain ', - 'brain.unknownAction': - 'unknown brain action: {action} (expected connect, disconnect, status, ticket, serve, stop, install-service or uninstall-service)', - 'brain.connectMissingFlags': - '`codesema brain connect` needs both --url and --token ', - 'brain.badToken': 'malformed token: expected csk_.', - 'brain.connected': 'Connected to the brain at {url}.', - 'brain.savedTo': 'saved to: {path}', - 'brain.notConnected': 'not connected to a brain (run `codesema brain connect` first)', - 'brain.fieldUrl': 'url', - 'brain.fieldRepo': 'repo', - 'brain.noRemote': 'no git origin remote', - 'brain.fieldReady': 'ready tickets', - 'brain.statusTitle': 'Brain status', - 'brain.ticketUsage': - 'usage: codesema brain ticket --issue , or --title --prompt <prompt>', - 'brain.badIssueNumber': 'not a valid issue number: {value}', - 'brain.draftFailed': 'could not draft a ticket: {reason}', - 'brain.ticketCreated': 'Ticket created: {title}', - 'brain.fieldId': 'id', - 'brain.fieldDaemon': 'daemon', - 'brain.fieldPid': 'pid', - 'brain.fieldPort': 'port', - 'brain.fieldUptime': 'uptime', - 'brain.fieldLog': 'log', - 'brain.notRunning': 'not running', - 'brain.detached': 'Brain daemon started in the background (pid {pid}).', - 'brain.stopped': 'Brain daemon stopped (pid {pid}).', - 'brain.stopTimeout': + 'runner.usage': + 'usage: codesema runner <connect|disconnect|status|list|ticket|autoconfig|await-secrets|serve|stop|install-service|uninstall-service>', + 'runner.unknownAction': + 'unknown runner action: {action} (expected connect, disconnect, status, list, ticket, autoconfig, await-secrets, serve, stop, install-service or uninstall-service)', + 'runner.connectMissingFlags': + '`codesema runner connect` needs both --url <url> and --token <token>', + 'runner.badToken': 'malformed token: expected csk_<workspaceId>.<secret>', + 'runner.connected': 'Connected to the hub at {url}.', + 'runner.savedTo': 'saved to: {path}', + 'runner.notConnected': 'not connected to a hub (run `codesema runner connect` first)', + 'runner.fieldUrl': 'url', + 'runner.fieldRepo': 'repo', + 'runner.noRemote': 'no git origin remote', + 'runner.fieldReady': 'ready tickets', + 'runner.statusTitle': 'Hub status', + 'runner.ticketUsage': + 'usage: codesema runner ticket --issue <n>, or --title <title> --prompt <prompt>', + 'runner.badIssueNumber': 'not a valid issue number: {value}', + 'runner.draftFailed': 'could not draft a ticket: {reason}', + 'runner.ticketCreated': 'Ticket created: {title}', + 'runner.fieldId': 'id', + 'runner.fieldDaemon': 'daemon', + 'runner.fieldPid': 'pid', + 'runner.fieldPort': 'port', + 'runner.fieldUptime': 'uptime', + 'runner.fieldLog': 'log', + 'runner.notRunning': 'not running', + 'runner.detached': 'Runner daemon started in the background (pid {pid}).', + 'runner.stopped': 'Runner daemon stopped (pid {pid}).', + 'runner.stopTimeout': 'pid {pid} is still running {seconds}s after SIGTERM: send SIGKILL yourself, or `systemctl stop` if this runs as a systemd unit', - 'brain.detachSpawnFailed': 'could not start the detached brain daemon', - 'brain.fieldInFlight': 'in flight tickets', - 'brain.inFlightHeading': 'in flight', - 'brain.fieldUnclaimed': 'unclaimed', - 'brain.fieldStale': 'stale', - 'brain.heartbeatSeconds': '{n}s ago', - 'brain.heartbeatMinutes': '{n}min ago', - 'brain.heartbeatHours': '{n}h ago', - 'brain.heartbeatDays': '{n}d ago', - 'brain.disconnected': 'Disconnected from the brain.', - 'brain.alreadyDisconnected': 'Already disconnected.', - 'brain.disconnectRevokeReminder': + 'runner.detachSpawnFailed': 'could not start the detached runner daemon', + 'runner.fieldInFlight': 'in flight tickets', + 'runner.inFlightHeading': 'in flight', + 'runner.fieldUnclaimed': 'unclaimed', + 'runner.fieldStale': 'stale', + 'runner.heartbeatSeconds': '{n}s ago', + 'runner.heartbeatMinutes': '{n}min ago', + 'runner.heartbeatHours': '{n}h ago', + 'runner.heartbeatDays': '{n}d ago', + 'runner.disconnected': 'Disconnected from the hub.', + 'runner.alreadyDisconnected': 'Already disconnected.', + 'runner.disconnectRevokeReminder': "Also revoke this arm in the dashboard's repo Settings — this only cleared local credentials.", - 'brain.serviceNotARepo': - '`codesema brain install-service` must be run inside the git repository this daemon should serve', - 'brain.systemctlNotFound': - 'systemctl not found: this machine has no user systemd session to install into. Run the daemon in the foreground (`codesema brain serve`) or backgrounded (`codesema brain serve --detach`) instead.', - 'brain.envFileNotFound': 'env file not found: {path}', - 'brain.serviceExecPathUnknown': + 'runner.serviceNotARepo': + '`codesema runner install-service` must be run inside the git repository this daemon should serve', + 'runner.systemctlNotFound': + 'systemctl not found: this machine has no user systemd session to install into. Run the daemon in the foreground (`codesema runner serve`) or backgrounded (`codesema runner serve --detach`) instead.', + 'runner.envFileNotFound': 'env file not found: {path}', + 'runner.serviceExecPathUnknown': 'could not determine the path to the running codesema binary (process.argv[1] is empty)', - 'brain.serviceInstalled': 'Brain service installed and started.', - 'brain.serviceUninstalled': 'Brain service stopped and removed.', - 'brain.serviceNotInstalled': 'No brain service installed (nothing to do).', - 'brain.fieldUnit': 'unit', - 'brain.fieldWorkingDirectory': 'working directory', - 'brain.fieldExecStart': 'exec start', - 'brain.fieldEnvironmentFile': 'environment file', - 'brain.lingerFailed': + 'runner.serviceInstalled': 'Runner service installed and started.', + 'runner.serviceUninstalled': 'Runner service stopped and removed.', + 'runner.serviceNotInstalled': 'No runner service installed (nothing to do).', + 'runner.fieldUnit': 'unit', + 'runner.fieldWorkingDirectory': 'working directory', + 'runner.fieldExecStart': 'exec start', + 'runner.fieldEnvironmentFile': 'environment file', + 'runner.lingerFailed': "could not enable lingering ({reason}): the service will stop when this user's session ends. Common in containers/WSL with no full systemd — run `sudo loginctl enable-linger $(whoami)` yourself if your host supports it.", + 'runner.fieldFingerprint': 'fingerprint', + 'runner.keyRegisterFailed': + 'could not register this runner key with the hub yet ({reason}): the hub may be running an older build', + 'runner.listFailed': 'could not list runners: {reason}', + 'runner.listEmpty': + 'No runners registered yet. Run `codesema runner connect` on the machine that should execute tickets.', + 'runner.listHeading': 'registered runners', + 'runner.fieldPendingSecret': 'secret pending', + 'runner.fieldNeverSeen': 'never seen', + 'runner.autoconfigMissingFlags': 'non-interactive `runner autoconfig` needs: {flags}', + 'runner.autoconfigSelectRunner': 'Which runner is this for?', + 'runner.autoconfigNoRunnerSelected': 'no runner selected', + 'runner.autoconfigFingerprintNotFound': 'no registered runner has fingerprint {fingerprint}', + 'runner.autoconfigFingerprintMismatch': + 'the hub reported a fingerprint for {name} that does not match its own public key: refusing to seal secrets for it', + 'runner.autoconfigConfirmFingerprint': 'Does the runner machine show this same fingerprint?', + 'runner.autoconfigFingerprintNotConfirmed': 'fingerprint not confirmed: aborting', + 'runner.autoconfigUseGhToken': "Use this machine's gh token?", + 'runner.autoconfigPasteGhToken': 'Paste the GH_TOKEN to send', + 'runner.autoconfigGhTokenUnavailable': + '`--gh-token-from-gh` was given but `gh auth token` is not available (is gh installed and logged in?)', + 'runner.autoconfigReuseClaudeToken': "Reuse this machine's Claude Code OAuth token?", + 'runner.autoconfigPasteClaudeToken': 'Paste the Claude Code OAuth token to send', + 'runner.autoconfigUseDetectedRepoUrl': "Use {url} as this runner's repo?", + 'runner.autoconfigRepoUrl': 'Repository URL for this runner', + 'runner.autoconfigNoSecrets': + 'no secret to send: provide at least a GH token or a Claude Code token', + 'runner.autoconfigDepositFailed': 'could not deposit the sealed secret: {reason}', + 'runner.autoconfigDone': 'Secrets sealed and deposited for {name}.', + 'runner.autoconfigReminder': 'The runner will pick this up automatically at its next heartbeat.', + 'runner.awaitSecretsNoIdentity': + 'no runner key on this machine yet (run `codesema runner connect` first)', + 'runner.awaitSecretsWaiting': 'waiting for a sealed secret addressed to {fingerprint}…', + 'runner.awaitSecretsReminder': 'still waiting, fingerprint {fingerprint}', + 'runner.awaitSecretsUndecryptable': + "received a sealed secret that could not be opened with this runner's key, ignoring it", + 'runner.awaitSecretsInvalidPayload': + 'received a secret payload that does not match the expected shape, ignoring it', + 'runner.awaitSecretsTimeout': 'timed out after {seconds}s waiting for a sealed secret', 'menu.title': 'What do you want to do?', 'menu.review': 'Simple review', @@ -594,21 +648,31 @@ Usage : codesema sync Pousse la dernière review vers votre workspace codesema.com codesema sync delete Supprime toutes les données synchronisées (workspaces non rattachés) codesema link [code] Rattache ce workspace à votre compte codesema.com (sans code : confirmation navigateur) - codesema brain connect --url <url> --token <token> - Connecte ce workspace à un cerveau (même compte que sync/link) - codesema brain status Affiche le cerveau connecté, ce dépôt, ses tickets prêts et ses + codesema runner connect --url <url> --token <token> + Connecte ce workspace à un hub (même compte que sync/link) + codesema runner status Affiche le hub connecté, ce dépôt, ses tickets prêts et ses tickets en vol (exécutant, âge du battement, bail expiré) - codesema brain ticket --issue <n> Rédige et publie un ticket depuis une issue du forge - codesema brain ticket --title <t> --prompt <p> + codesema runner list Liste les runners enregistrés sur le hub connecté (empreinte, + dernier battement, secret en attente) + codesema runner ticket --issue <n> Rédige et publie un ticket depuis une issue du forge + codesema runner ticket --title <t> --prompt <p> Rédige et publie un ticket depuis un titre et un prompt libres - codesema brain serve Alias de \`codesema workspace --brain\` - codesema brain disconnect Oublie le cerveau connecté (efface seulement les identifiants + codesema runner autoconfig [--fingerprint <emp>] [--gh-token-from-gh] [--claude-token <jeton>] [--repo-url <url>] + Choisit un runner enregistré, récupère un jeton gh et/ou un jeton + Claude Code pour lui, les scelle avec sa clé et les dépose sur le + hub pour qu'il les récupère + codesema runner await-secrets [--env-file <chemin>] [--timeout <s>] + À lancer sur la machine runner : attend le secret scellé par + \`autoconfig\`, le déchiffre dans --env-file et affiche l'URL du + dépôt reçue + codesema runner serve Alias de \`codesema workspace --runner\` + codesema runner disconnect Oublie le hub connecté (efface seulement les identifiants locaux, pensez aussi à révoquer ce bras dans les Settings du dashboard) - codesema brain install-service [--env-file <chemin>] + codesema runner install-service [--env-file <chemin>] Installe une unité systemd --user qui lance - \`codesema brain serve\` pour ce dépôt, activée et démarrée - codesema brain uninstall-service Arrête et supprime cette unité systemd --user + \`codesema runner serve\` pour ce dépôt, activée et démarrée + codesema runner uninstall-service Arrête et supprime cette unité systemd --user Options : --branch <nom> Branche locale à passer en revue (défaut : sélecteur interactif, sinon branche courante) @@ -618,7 +682,8 @@ Options : --review <fichier> Sortie d'agent à afficher (défaut : .codesema/review.json, sinon dernière revue archivée) --out <fichier> Destination de \`export\` (- pour stdout) --port <n> Port préféré du serveur local (défaut : 4400) - --timeout <s> Budget de temps de l'agent en secondes pour \`review\` (défaut : 900) + --timeout <s> Budget de temps de l'agent en secondes pour \`review\` (défaut : 900), ou + budget d'attente de \`runner await-secrets\` (défaut : 1800) --full Revue complète au lieu de mettre à jour la revue précédente --dual Revue duale : deux reviewers indépendants en parallèle (même agent, angles différents), puis un modèle juge fusionne leurs notes @@ -627,12 +692,17 @@ Options : sont demandés --force \`sync\` : envoie même si le diff semble contenir un secret --no-open Ne pas ouvrir le navigateur - --brain \`workspace\` : démarre aussi le daemon du cerveau (rédige les demandes de + --runner \`workspace\` : démarre aussi le daemon runner (rédige les demandes de tickets, réclame et exécute les tickets publiés) - --url, --token \`brain connect\` : l'URL du cerveau et un jeton csk_<workspaceId>.<secret> - --issue <n> \`brain ticket\` : rédige depuis ce numéro d'issue du forge - --title, --prompt \`brain ticket\` : rédige depuis un titre et un prompt libres plutôt qu'une issue - --env-file <chemin> \`brain install-service\` : EnvironmentFile= de l'unité systemd générée + --url, --token \`runner connect\` : l'URL du hub et un jeton csk_<workspaceId>.<secret> + --issue <n> \`runner ticket\` : rédige depuis ce numéro d'issue du forge + --title, --prompt \`runner ticket\` : rédige depuis un titre et un prompt libres plutôt qu'une issue + --fingerprint <emp> \`runner autoconfig\` : choisit le runner par empreinte plutôt que par sélecteur + --gh-token-from-gh \`runner autoconfig\` : capture GH_TOKEN via \`gh auth token\` sans confirmation + --claude-token <j> \`runner autoconfig\` : jeton OAuth Claude Code à envoyer plutôt que de le demander + --repo-url <url> \`runner autoconfig\` : URL de dépôt à envoyer plutôt que le remote détecté + --env-file <chemin> \`runner install-service\` : EnvironmentFile= de l'unité systemd générée, ou + fichier de destination de \`runner await-secrets\` (défaut : le fichier env du runner) -h, --help Afficher cette aide -v, --version Afficher la version @@ -806,14 +876,14 @@ CODESEMA_NO_UPDATE_CHECK=1 pour désactiver. 'config.autoSyncQuestion': 'Pousser automatiquement chaque review terminée vers votre workspace codesema.com ?', 'config.autoSyncSaved': 'auto-sync {state} : {path}', - 'config.brainAutoMergeEntry': 'Auto-merge des tickets du cerveau', - 'config.brainAutoMergeOn': 'activé', - 'config.brainAutoMergeOff': 'désactivé', - 'config.brainAutoMergeQuestion': - "Merger automatiquement une tâche issue d'un ticket du cerveau une fois livrée sans accroc ?", - 'config.brainAutoMergeOnHint': - 'tout le sens de connecter un cerveau : aucun clic humain pour merger', - 'config.brainAutoMergeSaved': 'auto-merge du cerveau {state} : {path}', + 'config.runnerAutoMergeEntry': 'Auto-merge des tickets du hub', + 'config.runnerAutoMergeOn': 'activé', + 'config.runnerAutoMergeOff': 'désactivé', + 'config.runnerAutoMergeQuestion': + "Merger automatiquement une tâche issue d'un ticket du hub une fois livrée sans accroc ?", + 'config.runnerAutoMergeOnHint': + 'tout le sens de connecter un hub : aucun clic humain pour merger', + 'config.runnerAutoMergeSaved': 'auto-merge du runner {state} : {path}', 'config.maxTurnsEntry': 'Budget de tours par tâche', 'config.maxTurnsQuestion': 'Combien de tours une tâche peut-elle dépenser avant que les relances soient refusées ?', @@ -946,65 +1016,107 @@ CODESEMA_NO_UPDATE_CHECK=1 pour désactiver. 'sync.unreachable': 'impossible de joindre {url} : vérifiez votre connexion ou CODESEMA_SYNC_URL', 'sync.badResponse': 'réponse inattendue de {url} : champs requis manquants ou invalides', - 'brain.usage': - 'usage : codesema brain <connect|disconnect|status|ticket|serve|stop|install-service|uninstall-service>', - 'brain.unknownAction': - 'action brain inconnue : {action} (attendu connect, disconnect, status, ticket, serve, stop, install-service ou uninstall-service)', - 'brain.connectMissingFlags': '`codesema brain connect` nécessite --url <url> et --token <token>', - 'brain.badToken': 'jeton malformé : format attendu csk_<workspaceId>.<secret>', - 'brain.connected': 'Connecté au cerveau à {url}.', - 'brain.savedTo': 'enregistré dans : {path}', - 'brain.notConnected': "non connecté à un cerveau (lancez d'abord `codesema brain connect`)", - 'brain.fieldUrl': 'url', - 'brain.fieldRepo': 'dépôt', - 'brain.noRemote': 'aucun remote git origin', - 'brain.fieldReady': 'tickets prêts', - 'brain.statusTitle': 'Statut du cerveau', - 'brain.ticketUsage': - 'usage : codesema brain ticket --issue <n>, ou --title <titre> --prompt <prompt>', - 'brain.badIssueNumber': "numéro d'issue invalide : {value}", - 'brain.draftFailed': 'impossible de rédiger un ticket : {reason}', - 'brain.ticketCreated': 'Ticket créé : {title}', - 'brain.fieldId': 'id', - 'brain.fieldDaemon': 'démon', - 'brain.fieldPid': 'pid', - 'brain.fieldPort': 'port', - 'brain.fieldUptime': 'uptime', - 'brain.fieldLog': 'log', - 'brain.notRunning': "à l'arrêt", - 'brain.detached': 'Démon brain démarré en arrière-plan (pid {pid}).', - 'brain.stopped': 'Démon brain arrêté (pid {pid}).', - 'brain.stopTimeout': + 'runner.usage': + 'usage : codesema runner <connect|disconnect|status|list|ticket|autoconfig|await-secrets|serve|stop|install-service|uninstall-service>', + 'runner.unknownAction': + 'action runner inconnue : {action} (attendu connect, disconnect, status, list, ticket, autoconfig, await-secrets, serve, stop, install-service ou uninstall-service)', + 'runner.connectMissingFlags': + '`codesema runner connect` nécessite --url <url> et --token <token>', + 'runner.badToken': 'jeton malformé : format attendu csk_<workspaceId>.<secret>', + 'runner.connected': 'Connecté au hub à {url}.', + 'runner.savedTo': 'enregistré dans : {path}', + 'runner.notConnected': "non connecté à un hub (lancez d'abord `codesema runner connect`)", + 'runner.fieldUrl': 'url', + 'runner.fieldRepo': 'dépôt', + 'runner.noRemote': 'aucun remote git origin', + 'runner.fieldReady': 'tickets prêts', + 'runner.statusTitle': 'Statut du hub', + 'runner.ticketUsage': + 'usage : codesema runner ticket --issue <n>, ou --title <titre> --prompt <prompt>', + 'runner.badIssueNumber': "numéro d'issue invalide : {value}", + 'runner.draftFailed': 'impossible de rédiger un ticket : {reason}', + 'runner.ticketCreated': 'Ticket créé : {title}', + 'runner.fieldId': 'id', + 'runner.fieldDaemon': 'démon', + 'runner.fieldPid': 'pid', + 'runner.fieldPort': 'port', + 'runner.fieldUptime': 'uptime', + 'runner.fieldLog': 'log', + 'runner.notRunning': "à l'arrêt", + 'runner.detached': 'Démon runner démarré en arrière-plan (pid {pid}).', + 'runner.stopped': 'Démon runner arrêté (pid {pid}).', + 'runner.stopTimeout': 'le pid {pid} tourne toujours {seconds}s après SIGTERM : envoyez SIGKILL vous-même, ou `systemctl stop` si ça tourne en unité systemd', - 'brain.detachSpawnFailed': 'impossible de démarrer le démon brain détaché', - 'brain.fieldInFlight': 'tickets en vol', - 'brain.inFlightHeading': 'en vol', - 'brain.fieldUnclaimed': 'non attribué', - 'brain.fieldStale': 'expiré', - 'brain.heartbeatSeconds': 'il y a {n}s', - 'brain.heartbeatMinutes': 'il y a {n}min', - 'brain.heartbeatHours': 'il y a {n}h', - 'brain.heartbeatDays': 'il y a {n}j', - 'brain.disconnected': 'Déconnecté du cerveau.', - 'brain.alreadyDisconnected': 'Déjà déconnecté.', - 'brain.disconnectRevokeReminder': + 'runner.detachSpawnFailed': 'impossible de démarrer le démon runner détaché', + 'runner.fieldInFlight': 'tickets en vol', + 'runner.inFlightHeading': 'en vol', + 'runner.fieldUnclaimed': 'non attribué', + 'runner.fieldStale': 'expiré', + 'runner.heartbeatSeconds': 'il y a {n}s', + 'runner.heartbeatMinutes': 'il y a {n}min', + 'runner.heartbeatHours': 'il y a {n}h', + 'runner.heartbeatDays': 'il y a {n}j', + 'runner.disconnected': 'Déconnecté du hub.', + 'runner.alreadyDisconnected': 'Déjà déconnecté.', + 'runner.disconnectRevokeReminder': "Pensez aussi à révoquer ce bras dans les Settings du dépôt sur le dashboard : ceci n'a effacé que les identifiants locaux.", - 'brain.serviceNotARepo': - '`codesema brain install-service` doit être lancé depuis le dépôt git que ce daemon doit servir', - 'brain.systemctlNotFound': - "systemctl introuvable : cette machine n'a pas de session systemd utilisateur pour y installer le service. Lancez plutôt le daemon au premier plan (`codesema brain serve`) ou en arrière-plan (`codesema brain serve --detach`).", - 'brain.envFileNotFound': 'fichier env introuvable : {path}', - 'brain.serviceExecPathUnknown': + 'runner.serviceNotARepo': + '`codesema runner install-service` doit être lancé depuis le dépôt git que ce daemon doit servir', + 'runner.systemctlNotFound': + "systemctl introuvable : cette machine n'a pas de session systemd utilisateur pour y installer le service. Lancez plutôt le daemon au premier plan (`codesema runner serve`) ou en arrière-plan (`codesema runner serve --detach`).", + 'runner.envFileNotFound': 'fichier env introuvable : {path}', + 'runner.serviceExecPathUnknown': "impossible de déterminer le chemin du binaire codesema en cours d'exécution (process.argv[1] est vide)", - 'brain.serviceInstalled': 'Service brain installé et démarré.', - 'brain.serviceUninstalled': 'Service brain arrêté et supprimé.', - 'brain.serviceNotInstalled': 'Aucun service brain installé (rien à faire).', - 'brain.fieldUnit': 'unité', - 'brain.fieldWorkingDirectory': 'répertoire de travail', - 'brain.fieldExecStart': 'commande de démarrage', - 'brain.fieldEnvironmentFile': 'fichier env', - 'brain.lingerFailed': + 'runner.serviceInstalled': 'Service runner installé et démarré.', + 'runner.serviceUninstalled': 'Service runner arrêté et supprimé.', + 'runner.serviceNotInstalled': 'Aucun service runner installé (rien à faire).', + 'runner.fieldUnit': 'unité', + 'runner.fieldWorkingDirectory': 'répertoire de travail', + 'runner.fieldExecStart': 'commande de démarrage', + 'runner.fieldEnvironmentFile': 'fichier env', + 'runner.lingerFailed': "impossible d'activer le lingering ({reason}) : le service s'arrêtera à la fin de la session de cet utilisateur. Fréquent dans les conteneurs/WSL sans systemd complet : lancez vous-même `sudo loginctl enable-linger $(whoami)` si votre hôte le permet.", + 'runner.fieldFingerprint': 'empreinte', + 'runner.keyRegisterFailed': + "impossible d'enregistrer cette clé runner auprès du hub pour le moment ({reason}) : le hub tourne peut-être sur une version plus ancienne", + 'runner.listFailed': 'impossible de lister les runners : {reason}', + 'runner.listEmpty': + 'Aucun runner enregistré pour le moment. Lancez `codesema runner connect` sur la machine qui doit exécuter les tickets.', + 'runner.listHeading': 'runners enregistrés', + 'runner.fieldPendingSecret': 'secret en attente', + 'runner.fieldNeverSeen': 'jamais vu', + 'runner.autoconfigMissingFlags': + 'en mode non interactif, `runner autoconfig` nécessite : {flags}', + 'runner.autoconfigSelectRunner': 'Pour quel runner ?', + 'runner.autoconfigNoRunnerSelected': 'aucun runner sélectionné', + 'runner.autoconfigFingerprintNotFound': + "aucun runner enregistré ne porte l'empreinte {fingerprint}", + 'runner.autoconfigFingerprintMismatch': + 'le hub a renvoyé pour {name} une empreinte qui ne correspond pas à sa propre clé publique : envoi des secrets refusé', + 'runner.autoconfigConfirmFingerprint': 'La machine runner affiche-t-elle la même empreinte ?', + 'runner.autoconfigFingerprintNotConfirmed': 'empreinte non confirmée : abandon', + 'runner.autoconfigUseGhToken': 'Utiliser le jeton gh de cette machine ?', + 'runner.autoconfigPasteGhToken': 'Collez le GH_TOKEN à envoyer', + 'runner.autoconfigGhTokenUnavailable': + '`--gh-token-from-gh` a été fourni mais `gh auth token` est indisponible (gh est-il installé et connecté ?)', + 'runner.autoconfigReuseClaudeToken': 'Réutiliser le jeton OAuth Claude Code de cette machine ?', + 'runner.autoconfigPasteClaudeToken': 'Collez le jeton OAuth Claude Code à envoyer', + 'runner.autoconfigUseDetectedRepoUrl': 'Utiliser {url} comme dépôt de ce runner ?', + 'runner.autoconfigRepoUrl': 'URL du dépôt pour ce runner', + 'runner.autoconfigNoSecrets': + 'aucun secret à envoyer : fournissez au moins un jeton gh ou un jeton Claude Code', + 'runner.autoconfigDepositFailed': 'impossible de déposer le secret scellé : {reason}', + 'runner.autoconfigDone': 'Secrets scellés et déposés pour {name}.', + 'runner.autoconfigReminder': 'Le runner les récupérera automatiquement à son prochain battement.', + 'runner.awaitSecretsNoIdentity': + "pas encore de clé runner sur cette machine (lancez d'abord `codesema runner connect`)", + 'runner.awaitSecretsWaiting': "en attente d'un secret scellé adressé à {fingerprint}…", + 'runner.awaitSecretsReminder': 'toujours en attente, empreinte {fingerprint}', + 'runner.awaitSecretsUndecryptable': + "un secret scellé reçu n'a pas pu être ouvert avec la clé de ce runner, ignoré", + 'runner.awaitSecretsInvalidPayload': + 'un secret reçu ne correspond pas à la forme attendue, ignoré', + 'runner.awaitSecretsTimeout': "délai dépassé après {seconds}s en attente d'un secret scellé", 'menu.title': 'Que voulez-vous faire ?', 'menu.review': 'Revue simple', diff --git a/packages/cli/src/index.test.ts b/packages/cli/src/index.test.ts index 4a07be0..7f0da1d 100644 --- a/packages/cli/src/index.test.ts +++ b/packages/cli/src/index.test.ts @@ -163,7 +163,7 @@ describe('resolveCommand — the ten routed commands', () => { 'export', 'sync', 'link', - 'brain', + 'runner', ]) }) @@ -317,3 +317,40 @@ describe('codesema workspace passes its CLI flags on', () => { ).toBe(true) }) }) + +// The runner autoconfig/await-secrets flags: same source-shape assertion as +// the workspace block above, and for the same reason (runCommand is not +// exported, each branch runs a real subsystem). +describe('codesema runner passes its autoconfig/await-secrets flags on', () => { + test('--fingerprint, --gh-token-from-gh, --claude-token, --repo-url and --timeout reach runnerCommand()', () => { + const source = readFileSync(join(import.meta.dir, 'index.ts'), 'utf8') + .split('\n') + .filter((line) => !/^\s*(\*|\/\/)/.test(line)) + .join('\n') + const marker = "case 'runner':" + const start = source.indexOf(marker) + expect(start).toBeGreaterThanOrEqual(0) + const block = source.slice(start, source.indexOf('break', start)) + expect(block).toContain('await runnerCommand({') + expect(block).toContain('fingerprint: values.fingerprint') + expect(block).toContain("ghTokenFromGh: values['gh-token-from-gh']") + expect(block).toContain("claudeToken: values['claude-token']") + expect(block).toContain("repoUrl: values['repo-url']") + expect( + /timeoutSeconds:\s*parseIntFlag\('timeout',\s*values\.timeout,\s*1,\s*86400\)/.test(block), + ).toBe(true) + }) + + test('the new flags are declared in parseArgs', () => { + const source = readFileSync(join(import.meta.dir, 'index.ts'), 'utf8') + const declarations = [ + "fingerprint: { type: 'string' }", + "'gh-token-from-gh': { type: 'boolean' }", + "'claude-token': { type: 'string' }", + "'repo-url': { type: 'string' }", + ] + for (const flag of declarations) { + expect(source).toContain(flag) + } + }) +}) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index d86dba6..5334907 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -2,7 +2,6 @@ import { realpathSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { parseArgs } from 'node:util' -import { brainCommand } from './brain-commands.js' import { loadConfig } from './config.js' import { exportCommand } from './export.js' import { tryGit } from './git.js' @@ -10,6 +9,7 @@ import { setLanguage, t } from './i18n.js' import { reviewFlagsPassed, runMenu } from './menu.js' import { prep } from './prep.js' import { review, REVIEW_GATE_VALUES, type ReviewGate } from './review.js' +import { runnerCommand } from './runner-commands.js' import { show } from './show.js' import { linkCommand, syncCommand } from './sync.js' import { isInteractive } from './tui.js' @@ -43,7 +43,7 @@ type ParsedValues = { 'no-open'?: boolean | undefined help?: boolean | undefined version?: boolean | undefined - brain?: boolean | undefined + runner?: boolean | undefined url?: string | undefined token?: string | undefined issue?: string | undefined @@ -51,6 +51,10 @@ type ParsedValues = { prompt?: string | undefined detach?: boolean | undefined 'env-file'?: string | undefined + fingerprint?: string | undefined + 'gh-token-from-gh'?: boolean | undefined + 'claude-token'?: string | undefined + 'repo-url'?: string | undefined } export const COMMAND_NAMES = [ @@ -63,7 +67,7 @@ export const COMMAND_NAMES = [ 'export', 'sync', 'link', - 'brain', + 'runner', ] as const export type CommandName = (typeof COMMAND_NAMES)[number] @@ -173,12 +177,12 @@ async function runCommand( }) break case 'workspace': - if (values.brain) { + if (values.runner) { // workspace() (workspace.ts) has a fixed options type with no room - // for a brain flag; the signal crosses into startServer (serve.ts) + // for a runner flag; the signal crosses into startServer (serve.ts) // the same way CODESEMA_SYNC_URL/CODESEMA_DEV_VITE already do in // this codebase, read at the one place that needs it. - process.env.CODESEMA_BRAIN_MODE = '1' + process.env.CODESEMA_RUNNER_MODE = '1' } await workspace({ port: parseIntFlag('port', values.port, 1, 65535), @@ -211,8 +215,8 @@ async function runCommand( case 'link': await linkCommand({ code: arg }) break - case 'brain': - await brainCommand({ + case 'runner': + await runnerCommand({ action: arg, cwd: process.cwd(), url: values.url, @@ -222,6 +226,11 @@ async function runCommand( prompt: values.prompt, detach: values.detach, envFile: values['env-file'], + fingerprint: values.fingerprint, + ghTokenFromGh: values['gh-token-from-gh'], + claudeToken: values['claude-token'], + repoUrl: values['repo-url'], + timeoutSeconds: parseIntFlag('timeout', values.timeout, 1, 86400), }) break } @@ -245,7 +254,7 @@ async function main(): Promise<void> { 'no-open': { type: 'boolean' }, help: { type: 'boolean', short: 'h' }, version: { type: 'boolean', short: 'v' }, - brain: { type: 'boolean' }, + runner: { type: 'boolean' }, url: { type: 'string' }, token: { type: 'string' }, issue: { type: 'string' }, @@ -253,6 +262,10 @@ async function main(): Promise<void> { prompt: { type: 'string' }, detach: { type: 'boolean' }, 'env-file': { type: 'string' }, + fingerprint: { type: 'string' }, + 'gh-token-from-gh': { type: 'boolean' }, + 'claude-token': { type: 'string' }, + 'repo-url': { type: 'string' }, }, }) diff --git a/packages/cli/src/runner-commands.test.ts b/packages/cli/src/runner-commands.test.ts new file mode 100644 index 0000000..2c95c08 --- /dev/null +++ b/packages/cli/src/runner-commands.test.ts @@ -0,0 +1,1188 @@ +import { + execFileSync, + spawn, + spawnSync, + type ChildProcess, + type SpawnOptions, +} from 'node:child_process' +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import type { AgentRunOptions } from './agent.js' +import { loadGlobalConfig, saveGlobalConfig } from './config.js' +import type { ArmTicket, RunnerListEntry } from './contract.js' +import { t } from './i18n.js' +import { runnerCommand } from './runner-commands.js' +import { loadOrCreateRunnerIdentity } from './runner-identity.js' +import { readRunnerPidfile, writeRunnerPidfile } from './runner-pidfile.js' +import { + formatFingerprint, + generateRunnerKeyPair, + runnerKeyFingerprint, + seal, +} from './sealed-box.js' + +process.env.NO_COLOR = '1' + +type Call = { url: string; init: RequestInit } + +function fetchStub(status: number, body: unknown, calls: Call[]): typeof fetch { + return ((url: string | URL | Request, init?: RequestInit) => { + calls.push({ url: String(url), init: init ?? {} }) + return Promise.resolve( + new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }), + ) + }) as typeof fetch +} + +/** Routes a response per `status` query param, so one stub can answer both the ready and in-flight `listTickets` calls `runnerStatus` makes. */ +function fetchStubByStatus( + responsesByStatus: Record<string, { tickets: unknown[] }>, + calls: Call[], +): typeof fetch { + return ((url: string | URL | Request, init?: RequestInit) => { + const urlStr = String(url) + calls.push({ url: urlStr, init: init ?? {} }) + const status = new URL(urlStr).searchParams.get('status') ?? '' + const body = responsesByStatus[status] ?? { tickets: [] } + return Promise.resolve( + new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ) + }) as typeof fetch +} + +function fetchOffline(): typeof fetch { + return (() => Promise.reject(new Error('network unreachable'))) as unknown as typeof fetch +} + +/** One canned response per call, in order; the last entry repeats once exhausted (a poll loop's Nth+ call). */ +function fetchSequence( + responses: { status: number; body: unknown }[], + calls: Call[], +): typeof fetch { + let i = 0 + return ((url: string | URL | Request, init?: RequestInit) => { + calls.push({ url: String(url), init: init ?? {} }) + const resp = responses[Math.min(i, responses.length - 1)] + i++ + return Promise.resolve( + new Response(JSON.stringify(resp?.body ?? {}), { + status: resp?.status ?? 200, + headers: { 'content-type': 'application/json' }, + }), + ) + }) as typeof fetch +} + +/** Same pattern as summary.test.ts's own `captureLog`, made async: `runnerCommand` resolves its promise after every `console.log` it makes. */ +async function captureLog(fn: () => Promise<void>): Promise<string[]> { + const lines: string[] = [] + const original = console.log + console.log = (...args: unknown[]) => { + lines.push(args.join(' ')) + } + try { + await fn() + } finally { + console.log = original + } + return lines +} + +/** Same as `captureLog`, for the STDERR-only progress/warning lines `await-secrets` prints. */ +async function captureErr(fn: () => Promise<void>): Promise<string[]> { + const lines: string[] = [] + const original = console.error + console.error = (...args: unknown[]) => { + lines.push(args.join(' ')) + } + try { + await fn() + } finally { + console.error = original + } + return lines +} + +function initRepo(cwd: string, remoteUrl?: string): void { + execFileSync('git', ['init', '-q', '-b', 'main'], { cwd }) + execFileSync( + 'git', + [ + '-c', + 'user.email=t@t', + '-c', + 'user.name=t', + 'commit', + '-q', + '--allow-empty', + '-m', + 'chore: init', + ], + { cwd }, + ) + if (remoteUrl) { + execFileSync('git', ['remote', 'add', 'origin', remoteUrl], { cwd }) + } +} + +const VALID_BODY = `**Context** + +Some context. + +**Goal** + +Some goal. + +**Scope** + +packages/x. + +**Acceptance criteria** + +- WHEN a THE SYSTEM SHALL b [proof:command bun test] +- WHEN c THE SYSTEM SHALL d [proof:diff packages/x/thing.ts] +- WHEN e THE SYSTEM SHALL f [proof:judgment] + +**Out of scope** + +Nothing else.` + +const validTicket: ArmTicket = { + id: 't1', + repo_remote_url: 'https://github.com/o/r.git', + title: 'Add a thing', + body: VALID_BODY, + status: 'published', + depends_on: null, + executed_by: null, + lease_expires_at: null, + issue: null, + branch: null, + mr_iid: null, + mr_url: null, + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', +} + +function fakeRunAgent(output: string): (opts: AgentRunOptions) => Promise<string> { + return async () => output +} + +/** A real keypair behind every fake runner entry, so the security recompute in `resolveTargetRunner` genuinely matches unless a test deliberately overrides `fingerprint`. */ +function fakeRunnerEntry(overrides: Partial<RunnerListEntry> = {}): RunnerListEntry { + const { publicKey } = generateRunnerKeyPair() + return { + name: 'build-box-1', + fingerprint: runnerKeyFingerprint(publicKey), + public_key: publicKey.toString('base64'), + last_seen_at: new Date().toISOString(), + has_pending_secret: false, + ...overrides, + } +} + +/** A pid that is certainly dead: a child that already ran to completion. */ +function deadPid(): number { + const child = spawnSync('true') + expect(child.pid).toBeGreaterThan(0) + return child.pid +} + +/** A single real process with no custom signal handling: dies on the default SIGTERM. */ +function spawnAlive(): ChildProcess { + return spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)']) +} + +/** + * A single real process that registers a SIGTERM listener before signalling + * "ready" on stdout: adding any listener replaces Node's default (fatal) + * behavior for that signal, so this one survives SIGTERM until SIGKILLed. + * Resolves only once the handler is actually registered, so the stop-timeout + * test below can never race a child that has not installed it yet. + */ +function spawnIgnoringSigterm(): Promise<ChildProcess> { + return new Promise((resolve) => { + const child = spawn(process.execPath, [ + '-e', + 'process.on("SIGTERM", () => {}); process.stdout.write("ready"); setInterval(() => {}, 1000)', + ]) + child.stdout?.once('data', () => resolve(child)) + }) +} + +describe('runnerCommand', () => { + const previousConfigDir = process.env.CODESEMA_CONFIG_DIR + let configDir: string + let cwd: string + + beforeEach(() => { + configDir = mkdtempSync(join(tmpdir(), 'codesema-runnercmd-cfg-')) + process.env.CODESEMA_CONFIG_DIR = configDir + cwd = mkdtempSync(join(tmpdir(), 'codesema-runnercmd-repo-')) + }) + + afterEach(() => { + rmSync(configDir, { recursive: true, force: true }) + rmSync(cwd, { recursive: true, force: true }) + if (previousConfigDir === undefined) { + delete process.env.CODESEMA_CONFIG_DIR + } else { + process.env.CODESEMA_CONFIG_DIR = previousConfigDir + } + }) + + test('no action prints usage and does not throw', async () => { + await expect(runnerCommand({ cwd })).resolves.toBeUndefined() + }) + + test('an unknown action throws', async () => { + await expect(runnerCommand({ action: 'nope', cwd })).rejects.toThrow() + }) + + describe('connect', () => { + test('requires both --url and --token', async () => { + await expect(runnerCommand({ action: 'connect', cwd, url: 'https://x' })).rejects.toThrow() + await expect(runnerCommand({ action: 'connect', cwd, token: 'csk_a.b' })).rejects.toThrow() + }) + + test('rejects a malformed token', async () => { + await expect( + runnerCommand({ action: 'connect', cwd, url: 'https://x', token: 'not-a-token' }), + ).rejects.toThrow() + }) + + test('stores the same credentials shape as `codesema sync`', async () => { + await runnerCommand({ + action: 'connect', + cwd, + url: 'https://hub.example', + token: 'csk_ws1.sec1', + }) + const config = loadGlobalConfig() + expect(config.syncUrl).toBe('https://hub.example') + expect(config.syncWorkspaceId).toBe('ws1') + expect(config.syncSecret).toBe('sec1') + }) + }) + + describe('connect: runner identity', () => { + test('the fingerprint shown is stable across reconnects (keygen happens once)', async () => { + const first = await captureLog(() => + runnerCommand({ + action: 'connect', + cwd, + url: 'https://hub.example', + token: 'csk_ws1.sec1', + fetchImpl: fetchStub(200, {}, []), + }), + ) + const second = await captureLog(() => + runnerCommand({ + action: 'connect', + cwd, + url: 'https://hub.example', + token: 'csk_ws1.sec1', + fetchImpl: fetchStub(200, {}, []), + }), + ) + const fingerprintLine = (lines: string[]) => + lines.find((line) => line.includes(t('runner.fieldFingerprint'))) + expect(fingerprintLine(first)).toBeDefined() + expect(fingerprintLine(first)).toBe(fingerprintLine(second)) + }) + + test('a key-registration failure warns but does not throw, and credentials are still saved', async () => { + const lines = await captureLog(async () => { + await expect( + runnerCommand({ + action: 'connect', + cwd, + url: 'https://hub.example', + token: 'csk_ws1.sec1', + fetchImpl: fetchOffline(), + }), + ).resolves.toBeUndefined() + }) + expect(loadGlobalConfig().syncUrl).toBe('https://hub.example') + expect(lines.some((line) => line.includes(t('runner.fieldFingerprint')))).toBe(true) + }) + }) + + describe('disconnect', () => { + test('is a soft no-op when nothing is connected', async () => { + await expect(runnerCommand({ action: 'disconnect', cwd })).resolves.toBeUndefined() + expect(loadGlobalConfig().syncUrl).toBeUndefined() + }) + + test('clears syncUrl/syncWorkspaceId/syncSecret, and only those', async () => { + await runnerCommand({ + action: 'connect', + cwd, + url: 'https://hub.example', + token: 'csk_ws1.sec1', + }) + saveGlobalConfig({ ...loadGlobalConfig(), agent: 'claude -p' }) + + await expect(runnerCommand({ action: 'disconnect', cwd })).resolves.toBeUndefined() + + const config = loadGlobalConfig() + expect(config.syncUrl).toBeUndefined() + expect(config.syncWorkspaceId).toBeUndefined() + expect(config.syncSecret).toBeUndefined() + expect(config.agent).toBe('claude -p') + }) + + test('running it twice is fine (idempotent)', async () => { + await runnerCommand({ + action: 'connect', + cwd, + url: 'https://hub.example', + token: 'csk_ws1.sec1', + }) + await runnerCommand({ action: 'disconnect', cwd }) + await expect(runnerCommand({ action: 'disconnect', cwd })).resolves.toBeUndefined() + }) + }) + + describe('status', () => { + test('throws when not connected', async () => { + await expect(runnerCommand({ action: 'status', cwd })).rejects.toThrow() + }) + + test('reports the ready ticket count when connected', async () => { + saveGlobalConfig({ + ...loadGlobalConfig(), + syncUrl: 'https://hub.example', + syncWorkspaceId: 'ws1', + syncSecret: 'sec1', + }) + initRepo(cwd, 'https://github.com/o/r.git') + const calls: Call[] = [] + await expect( + runnerCommand({ + action: 'status', + cwd, + fetchImpl: fetchStub(200, { tickets: [validTicket] }, calls), + }), + ).resolves.toBeUndefined() + expect(calls[0]?.url).toContain('/api/cli/tickets?') + expect(calls[0]?.url).toContain('status=published') + }) + + test('does not call the hub when the repo has no origin remote', async () => { + saveGlobalConfig({ + ...loadGlobalConfig(), + syncUrl: 'https://hub.example', + syncWorkspaceId: 'ws1', + syncSecret: 'sec1', + }) + initRepo(cwd) + const calls: Call[] = [] + await runnerCommand({ action: 'status', cwd, fetchImpl: fetchStub(200, {}, calls) }) + expect(calls.length).toBe(0) + }) + }) + + describe('status: in flight tickets', () => { + beforeEach(() => { + saveGlobalConfig({ + ...loadGlobalConfig(), + syncUrl: 'https://hub.example', + syncWorkspaceId: 'ws1', + syncSecret: 'sec1', + }) + initRepo(cwd, 'https://github.com/o/r.git') + }) + + test('lists an in-flight ticket with a fresh heartbeat, no stale marker', async () => { + const freshTicket = { + ...validTicket, + id: 't-if-fresh', + title: 'Fix the flaky retry test', + status: 'in_progress', + executed_by: 'cli-arm-01', + updated_at: new Date(Date.now() - 12_000).toISOString(), + lease_expires_at: new Date(Date.now() + 5 * 60_000).toISOString(), + arm_local_status: 'executing', + } + const calls: Call[] = [] + const lines = await captureLog(async () => { + await runnerCommand({ + action: 'status', + cwd, + fetchImpl: fetchStubByStatus( + { published: { tickets: [] }, in_flight: { tickets: [freshTicket] } }, + calls, + ), + }) + }) + const inFlightCalls = calls.filter((c) => c.url.includes('status=in_flight')) + expect(inFlightCalls.length).toBe(1) + expect(inFlightCalls[0]?.url).toContain('remote_url=') + const output = lines.join('\n') + expect(output).toContain('Fix the flaky retry test') + expect(output).toContain('cli-arm-01') + expect(output).toContain('executing') + expect(output).not.toContain(t('runner.fieldStale')) + }) + + test('marks a ticket stale once its lease has lapsed', async () => { + const staleTicket = { + ...validTicket, + id: 't-if-stale', + title: 'Add retry logic', + status: 'mr_opened', + executed_by: 'cli-arm-02', + updated_at: new Date(Date.now() - 3 * 60_000).toISOString(), + lease_expires_at: new Date(Date.now() - 60_000).toISOString(), + arm_local_status: 'awaiting_review', + } + const lines = await captureLog(async () => { + await runnerCommand({ + action: 'status', + cwd, + fetchImpl: fetchStubByStatus( + { published: { tickets: [] }, in_flight: { tickets: [staleTicket] } }, + [], + ), + }) + }) + const output = lines.join('\n') + expect(output).toContain('Add retry logic') + expect(output).toContain(t('runner.fieldStale')) + }) + + test('degrades gracefully when the hub does not send arm_local_status (older hub)', async () => { + const oldHubTicket = { + ...validTicket, + id: 't-if-old', + title: 'Legacy ticket from an older hub', + status: 'in_progress', + executed_by: null, + updated_at: new Date(Date.now() - 5_000).toISOString(), + lease_expires_at: new Date(Date.now() + 5 * 60_000).toISOString(), + // No `arm_local_status` key at all: what an older hub, built before + // that field existed, actually sends. + } + const lines = await captureLog(async () => { + await expect( + runnerCommand({ + action: 'status', + cwd, + fetchImpl: fetchStubByStatus( + { published: { tickets: [] }, in_flight: { tickets: [oldHubTicket] } }, + [], + ), + }), + ).resolves.toBeUndefined() + }) + const output = lines.join('\n') + expect(output).toContain('Legacy ticket from an older hub') + expect(output).toContain(t('runner.fieldUnclaimed')) + expect(output).not.toContain('undefined') + expect(output).not.toContain('null') + }) + + test('an unreachable hub degrades the same way the ready count already does', async () => { + await expect( + runnerCommand({ action: 'status', cwd, fetchImpl: fetchOffline() }), + ).resolves.toBeUndefined() + }) + }) + + describe('ticket', () => { + test('rejects both --issue and --title/--prompt together', async () => { + await expect( + runnerCommand({ action: 'ticket', cwd, issue: '1', title: 'T', prompt: 'p' }), + ).rejects.toThrow() + }) + + test('rejects neither form given', async () => { + await expect(runnerCommand({ action: 'ticket', cwd })).rejects.toThrow() + }) + + test('rejects a non-numeric --issue', async () => { + await expect(runnerCommand({ action: 'ticket', cwd, issue: 'abc' })).rejects.toThrow() + }) + + test('drafts and publishes from --title/--prompt', async () => { + saveGlobalConfig({ + ...loadGlobalConfig(), + syncUrl: 'https://hub.example', + syncWorkspaceId: 'ws1', + syncSecret: 'sec1', + }) + initRepo(cwd, 'https://github.com/o/r.git') + const calls: Call[] = [] + await expect( + runnerCommand({ + action: 'ticket', + cwd, + title: 'Add a thing', + prompt: 'do the thing', + runAgentFn: fakeRunAgent(VALID_BODY), + fetchImpl: fetchStub(201, { ticket: validTicket }, calls), + }), + ).resolves.toBeUndefined() + expect(calls[0]?.url).toBe('https://hub.example/api/cli/tickets') + }) + + test('a drafting failure surfaces as a thrown error', async () => { + saveGlobalConfig({ + ...loadGlobalConfig(), + syncUrl: 'https://hub.example', + syncWorkspaceId: 'ws1', + syncSecret: 'sec1', + }) + initRepo(cwd, 'https://github.com/o/r.git') + await expect( + runnerCommand({ + action: 'ticket', + cwd, + title: 'T', + prompt: 'x', + runAgentFn: fakeRunAgent('not a ticket'), + }), + ).rejects.toThrow() + }) + }) + + describe('status: daemon rows (D21)', () => { + beforeEach(() => { + saveGlobalConfig({ + ...loadGlobalConfig(), + syncUrl: 'https://hub.example', + syncWorkspaceId: 'ws1', + syncSecret: 'sec1', + }) + }) + + test('no pidfile: does not throw (reported as not running)', async () => { + initRepo(cwd, 'https://github.com/o/r.git') + await expect( + runnerCommand({ action: 'status', cwd, fetchImpl: fetchStub(200, { tickets: [] }, []) }), + ).resolves.toBeUndefined() + }) + + test('a pidfile naming our own (very much alive) pid: does not throw, cleans up nothing', async () => { + initRepo(cwd, 'https://github.com/o/r.git') + writeRunnerPidfile(cwd, process.pid, 4400) + await expect( + runnerCommand({ action: 'status', cwd, fetchImpl: fetchStub(200, { tickets: [] }, []) }), + ).resolves.toBeUndefined() + expect(readRunnerPidfile(cwd)).toMatchObject({ pid: process.pid, port: 4400 }) + }) + + test('a pidfile naming a dead (stolen) pid: does not throw, and the stale file is removed', async () => { + initRepo(cwd, 'https://github.com/o/r.git') + writeRunnerPidfile(cwd, deadPid(), 4400) + await expect( + runnerCommand({ action: 'status', cwd, fetchImpl: fetchStub(200, { tickets: [] }, []) }), + ).resolves.toBeUndefined() + expect(readRunnerPidfile(cwd)).toBeNull() + }) + }) + + describe('serve --detach', () => { + test('spawns a detached re-invocation of `runner serve` (no --detach) and reports pid + log path', async () => { + const calls: { command: string; args: readonly string[]; options: SpawnOptions }[] = [] + const unrefCalls: number[] = [] + const spawnFn = (command: string, args: readonly string[], options: SpawnOptions) => { + calls.push({ command, args, options }) + return { + pid: 4242, + unref: () => { + unrefCalls.push(1) + }, + on: () => {}, + } as unknown as ChildProcess + } + + await expect( + runnerCommand({ action: 'serve', cwd, detach: true, spawnFn }), + ).resolves.toBeUndefined() + + const call = calls[0] + if (!call) { + throw new Error('expected spawnFn to have been called') + } + expect(call.command).toBe(process.execPath) + expect(call.args).toEqual([process.argv[1] as string, 'runner', 'serve']) + expect(call.options.cwd).toBe(cwd) + expect(call.options.detached).toBe(true) + expect(unrefCalls.length).toBe(1) + expect(existsSync(join(cwd, '.codesema', 'runner-daemon.log'))).toBe(true) + }) + + test('a spawn that never yields a pid throws (D21 never silently reports success)', async () => { + const spawnFn = () => + ({ pid: undefined, unref: () => {}, on: () => {} }) as unknown as ChildProcess + await expect(runnerCommand({ action: 'serve', cwd, detach: true, spawnFn })).rejects.toThrow() + }) + }) + + describe('stop', () => { + test('no pidfile: resolves without throwing (nothing to stop)', async () => { + await expect(runnerCommand({ action: 'stop', cwd })).resolves.toBeUndefined() + }) + + test('a pidfile naming a dead pid: resolves without throwing, and the stale file is cleaned up', async () => { + writeRunnerPidfile(cwd, deadPid(), 4400) + await expect(runnerCommand({ action: 'stop', cwd })).resolves.toBeUndefined() + expect(readRunnerPidfile(cwd)).toBeNull() + }) + + test('a live process: SIGTERM kills it, stop waits for it, then cleans up the pidfile', async () => { + const child = spawnAlive() + const pid = child.pid + if (pid === undefined) { + throw new Error('expected a real pid') + } + writeRunnerPidfile(cwd, pid, 4400) + try { + await expect( + runnerCommand({ action: 'stop', cwd, stopTimeoutMs: 5000, stopPollIntervalMs: 20 }), + ).resolves.toBeUndefined() + expect(readRunnerPidfile(cwd)).toBeNull() + } finally { + child.kill('SIGKILL') + } + }) + + test('a live process that ignores SIGTERM: reports the timeout, never hangs, pidfile is left in place', async () => { + const child = await spawnIgnoringSigterm() + const pid = child.pid + if (pid === undefined) { + throw new Error('expected a real pid') + } + writeRunnerPidfile(cwd, pid, 4400) + try { + await expect( + runnerCommand({ action: 'stop', cwd, stopTimeoutMs: 300, stopPollIntervalMs: 20 }), + ).resolves.toBeUndefined() + expect(readRunnerPidfile(cwd)).toMatchObject({ pid }) + } finally { + child.kill('SIGKILL') + } + }) + }) + + describe('list', () => { + test('throws when not connected', async () => { + await expect(runnerCommand({ action: 'list', cwd })).rejects.toThrow() + }) + + describe('connected', () => { + beforeEach(() => { + saveGlobalConfig({ + ...loadGlobalConfig(), + syncUrl: 'https://hub.example', + syncWorkspaceId: 'ws1', + syncSecret: 'sec1', + }) + }) + + test('shows a dedicated empty state', async () => { + const lines = await captureLog(() => + runnerCommand({ action: 'list', cwd, fetchImpl: fetchStub(200, { runners: [] }, []) }), + ) + expect(lines.join('\n')).toContain(t('runner.listEmpty')) + }) + + test('lists the name, the full formatted fingerprint, heartbeat age and a pending-secret marker', async () => { + const entry = fakeRunnerEntry({ + name: 'build-box-1', + last_seen_at: new Date(Date.now() - 5000).toISOString(), + has_pending_secret: true, + }) + const lines = await captureLog(() => + runnerCommand({ + action: 'list', + cwd, + fetchImpl: fetchStub(200, { runners: [entry] }, []), + }), + ) + const output = lines.join('\n') + expect(output).toContain('build-box-1') + expect(output).toContain(formatFingerprint(entry.fingerprint)) + expect(output).toContain(t('runner.fieldPendingSecret')) + }) + + test('a runner with no pending secret does not show the pending-secret marker', async () => { + const entry = fakeRunnerEntry({ has_pending_secret: false }) + const lines = await captureLog(() => + runnerCommand({ + action: 'list', + cwd, + fetchImpl: fetchStub(200, { runners: [entry] }, []), + }), + ) + expect(lines.join('\n')).not.toContain(t('runner.fieldPendingSecret')) + }) + + test('surfaces a clean error on a hub failure instead of throwing raw', async () => { + await expect( + runnerCommand({ action: 'list', cwd, fetchImpl: fetchOffline() }), + ).rejects.toThrow() + }) + }) + }) + + describe('autoconfig', () => { + test('throws when not connected', async () => { + await expect(runnerCommand({ action: 'autoconfig', cwd })).rejects.toThrow() + }) + + describe('connected, non-interactive (no TTY in this test environment)', () => { + beforeEach(() => { + saveGlobalConfig({ + ...loadGlobalConfig(), + syncUrl: 'https://hub.example', + syncWorkspaceId: 'ws1', + syncSecret: 'sec1', + }) + }) + + test('without --fingerprint or a token flag, refuses immediately and lists what is missing', async () => { + await expect(runnerCommand({ action: 'autoconfig', cwd })).rejects.toThrow( + t('runner.autoconfigMissingFlags', { + flags: '--fingerprint <fingerprint>, --gh-token-from-gh and/or --claude-token <token>', + }), + ) + }) + + test('an unknown --fingerprint is refused', async () => { + await expect( + runnerCommand({ + action: 'autoconfig', + cwd, + fingerprint: 'a'.repeat(64), + ghTokenFromGh: true, + execFn: () => 'ghp_x', + fetchImpl: fetchSequence([{ status: 200, body: { runners: [] } }], []), + }), + ).rejects.toThrow() + }) + + test('a runner whose reported fingerprint does not match its own public key is refused (hub incoherent)', async () => { + const entry = fakeRunnerEntry({ fingerprint: 'f'.repeat(64) }) + await expect( + runnerCommand({ + action: 'autoconfig', + cwd, + fingerprint: entry.fingerprint, + ghTokenFromGh: true, + execFn: () => 'ghp_x', + fetchImpl: fetchSequence([{ status: 200, body: { runners: [entry] } }], []), + }), + ).rejects.toThrow() + }) + + test('--gh-token-from-gh throws clearly when gh is not actually available', async () => { + const entry = fakeRunnerEntry() + await expect( + runnerCommand({ + action: 'autoconfig', + cwd, + fingerprint: entry.fingerprint, + ghTokenFromGh: true, + execFn: () => { + throw Object.assign(new Error('spawn gh ENOENT'), { code: 'ENOENT' }) + }, + fetchImpl: fetchSequence([{ status: 200, body: { runners: [entry] } }], []), + }), + ).rejects.toThrow(t('runner.autoconfigGhTokenUnavailable')) + }) + + test('a fully-flagged run (fingerprint + gh-token-from-gh) never prompts and deposits a sealed secret', async () => { + const entry = fakeRunnerEntry() + const calls: Call[] = [] + await expect( + runnerCommand({ + action: 'autoconfig', + cwd, + fingerprint: entry.fingerprint, + ghTokenFromGh: true, + repoUrl: 'https://example.com/o/r.git', + execFn: () => 'ghp_from_gh', + fetchImpl: fetchSequence( + [ + { status: 200, body: { runners: [entry] } }, + { status: 200, body: {} }, + ], + calls, + ), + }), + ).resolves.toBeUndefined() + expect(calls.length).toBe(2) + }) + + test('--claude-token alone is enough: no gh token is required', async () => { + const entry = fakeRunnerEntry() + await expect( + runnerCommand({ + action: 'autoconfig', + cwd, + fingerprint: entry.fingerprint, + claudeToken: 'claude-token-value', + fetchImpl: fetchSequence( + [ + { status: 200, body: { runners: [entry] } }, + { status: 200, body: {} }, + ], + [], + ), + }), + ).resolves.toBeUndefined() + }) + }) + + describe('interactive flow (seamed select/confirm/textInput, no real TTY)', () => { + const previousStdinIsTTY = process.stdin.isTTY + const previousStdoutIsTTY = process.stdout.isTTY + const previousClaudeToken = process.env.CLAUDE_CODE_OAUTH_TOKEN + + beforeEach(() => { + process.stdin.isTTY = true + process.stdout.isTTY = true + delete process.env.CLAUDE_CODE_OAUTH_TOKEN + saveGlobalConfig({ + ...loadGlobalConfig(), + syncUrl: 'https://hub.example', + syncWorkspaceId: 'ws1', + syncSecret: 'sec1', + }) + }) + + afterEach(() => { + process.stdin.isTTY = previousStdinIsTTY + process.stdout.isTTY = previousStdoutIsTTY + if (previousClaudeToken === undefined) { + delete process.env.CLAUDE_CODE_OAUTH_TOKEN + } else { + process.env.CLAUDE_CODE_OAUTH_TOKEN = previousClaudeToken + } + }) + + test('picks the runner via selectFn, confirms the fingerprint, and sends a pasted GH token', async () => { + const entry = fakeRunnerEntry() + const calls: Call[] = [] + await expect( + runnerCommand({ + action: 'autoconfig', + cwd, + fetchImpl: fetchSequence( + [ + { status: 200, body: { runners: [entry] } }, + { status: 200, body: {} }, + ], + calls, + ), + selectFn: async () => entry, + confirmFn: async () => true, + textInputFn: async (o) => + o.title === t('runner.autoconfigPasteGhToken') ? 'ghp_pasted' : null, + execFn: () => { + throw new Error('no gh on this test machine') + }, + runInheritedFn: () => {}, + }), + ).resolves.toBeUndefined() + expect(calls.length).toBe(2) + }) + + test('declining the fingerprint confirmation aborts before anything is deposited', async () => { + const entry = fakeRunnerEntry() + const calls: Call[] = [] + await expect( + runnerCommand({ + action: 'autoconfig', + cwd, + fetchImpl: fetchStub(200, { runners: [entry] }, calls), + selectFn: async () => entry, + confirmFn: async () => false, + }), + ).rejects.toThrow(t('runner.autoconfigFingerprintNotConfirmed')) + expect(calls.length).toBe(1) + }) + + test('declining every reuse offer with nothing pasted leaves no secret to send, and the command refuses', async () => { + const entry = fakeRunnerEntry() + await expect( + runnerCommand({ + action: 'autoconfig', + cwd, + fetchImpl: fetchSequence([{ status: 200, body: { runners: [entry] } }], []), + selectFn: async () => entry, + confirmFn: async (o) => o.title === t('runner.autoconfigConfirmFingerprint'), + textInputFn: async () => null, + execFn: () => '', + runInheritedFn: () => {}, + }), + ).rejects.toThrow(t('runner.autoconfigNoSecrets')) + }) + }) + }) + + describe('await-secrets', () => { + test('throws when not connected', async () => { + await expect(runnerCommand({ action: 'await-secrets', cwd })).rejects.toThrow() + }) + + describe('connected', () => { + beforeEach(() => { + saveGlobalConfig({ + ...loadGlobalConfig(), + syncUrl: 'https://hub.example', + syncWorkspaceId: 'ws1', + syncSecret: 'sec1', + }) + }) + + test('throws when this machine has no runner identity yet', async () => { + await expect( + runnerCommand({ action: 'await-secrets', cwd, timeoutSeconds: 1, pollIntervalMs: 5 }), + ).rejects.toThrow(t('runner.awaitSecretsNoIdentity')) + }) + + describe('with a local runner identity', () => { + let identity: ReturnType<typeof loadOrCreateRunnerIdentity> + let envPath: string + + beforeEach(() => { + identity = loadOrCreateRunnerIdentity() + envPath = join(cwd, 'runner.env') + }) + + function sealedPayload(payload: unknown): string { + return seal(identity.publicKey, Buffer.from(JSON.stringify(payload))) + } + + test('succeeds on the very first poll, writes the env file, and STDOUT carries only the repo_url', async () => { + const ciphertext = sealedPayload({ + v: 1, + secrets: { GH_TOKEN: 'ghp_first_try' }, + repo_url: 'https://example.com/o/r.git', + }) + const lines = await captureLog(async () => { + await expect( + runnerCommand({ + action: 'await-secrets', + cwd, + envFile: envPath, + fetchImpl: fetchSequence([{ status: 200, body: { secret: { ciphertext } } }], []), + }), + ).resolves.toBeUndefined() + }) + expect(lines).toEqual(['https://example.com/o/r.git']) + expect(readFileSync(envPath, 'utf8')).toContain('GH_TOKEN=ghp_first_try') + }) + + test('nothing is printed on STDOUT when no repo_url was sent', async () => { + const ciphertext = sealedPayload({ v: 1, secrets: { GH_TOKEN: 'ghp_no_repo' } }) + const lines = await captureLog(async () => { + await runnerCommand({ + action: 'await-secrets', + cwd, + envFile: envPath, + fetchImpl: fetchSequence([{ status: 200, body: { secret: { ciphertext } } }], []), + }) + }) + expect(lines).toEqual([]) + }) + + test('keeps polling past empty responses and succeeds once a secret appears', async () => { + const ciphertext = sealedPayload({ v: 1, secrets: { GH_TOKEN: 'ghp_after_wait' } }) + const fetchImpl = fetchSequence( + [ + { status: 404, body: {} }, + { status: 404, body: {} }, + { status: 200, body: { secret: { ciphertext } } }, + ], + [], + ) + await expect( + runnerCommand({ + action: 'await-secrets', + cwd, + envFile: envPath, + pollIntervalMs: 5, + fetchImpl, + }), + ).resolves.toBeUndefined() + expect(readFileSync(envPath, 'utf8')).toContain('GH_TOKEN=ghp_after_wait') + }) + + test('a corrupted delivery is logged and skipped, not fatal: a later valid one still lands', async () => { + const ciphertext = sealedPayload({ v: 1, secrets: { GH_TOKEN: 'ghp_after_garbage' } }) + const fetchImpl = fetchSequence( + [ + { status: 200, body: { secret: { ciphertext: 'not-a-real-sealed-blob' } } }, + { status: 200, body: { secret: { ciphertext } } }, + ], + [], + ) + const errLines = await captureErr(async () => { + await expect( + runnerCommand({ + action: 'await-secrets', + cwd, + envFile: envPath, + pollIntervalMs: 5, + fetchImpl, + }), + ).resolves.toBeUndefined() + }) + expect( + errLines.some((line) => line.includes(t('runner.awaitSecretsUndecryptable'))), + ).toBe(true) + expect(readFileSync(envPath, 'utf8')).toContain('GH_TOKEN=ghp_after_garbage') + }) + + test('times out cleanly when nothing ever arrives, without writing the env file', async () => { + await expect( + runnerCommand({ + action: 'await-secrets', + cwd, + envFile: envPath, + timeoutSeconds: 0.15, + pollIntervalMs: 10, + fetchImpl: fetchSequence([{ status: 404, body: {} }], []), + }), + ).rejects.toThrow() + expect(existsSync(envPath)).toBe(false) + }) + + test('reminds on STDERR at the configured interval while waiting', async () => { + // Several empty polls before the secret lands, so the reminder + // interval is guaranteed to elapse at least once: decoupled from + // the timeout path entirely, so this never races a deadline. + const ciphertext = sealedPayload({ v: 1, secrets: { GH_TOKEN: 'ghp_after_reminder' } }) + const fetchImpl = fetchSequence( + [ + { status: 404, body: {} }, + { status: 404, body: {} }, + { status: 404, body: {} }, + { status: 404, body: {} }, + { status: 404, body: {} }, + { status: 200, body: { secret: { ciphertext } } }, + ], + [], + ) + const errLines = await captureErr(async () => { + await expect( + runnerCommand({ + action: 'await-secrets', + cwd, + envFile: envPath, + pollIntervalMs: 5, + reminderIntervalMs: 10, + fetchImpl, + }), + ).resolves.toBeUndefined() + }) + const expectedReminder = t('runner.awaitSecretsReminder', { + fingerprint: formatFingerprint(identity.fingerprint), + }) + expect(errLines.some((line) => line.includes(expectedReminder))).toBe(true) + }) + }) + }) + }) + + describe('install-service / uninstall-service', () => { + const previousXdg = process.env.XDG_CONFIG_HOME + let xdgConfigHome: string + + function noopExecFn(calls: { command: string; args: readonly string[] }[]) { + return (command: string, args: readonly string[]) => { + calls.push({ command, args }) + return '' + } + } + + function unitPath(): string { + return join(xdgConfigHome, 'systemd', 'user', 'codesema-runner.service') + } + + beforeEach(() => { + xdgConfigHome = mkdtempSync(join(tmpdir(), 'codesema-runnercmd-xdg-')) + process.env.XDG_CONFIG_HOME = xdgConfigHome + }) + + afterEach(() => { + rmSync(xdgConfigHome, { recursive: true, force: true }) + if (previousXdg === undefined) { + delete process.env.XDG_CONFIG_HOME + } else { + process.env.XDG_CONFIG_HOME = previousXdg + } + }) + + test('install-service refuses to run outside a git repository', async () => { + await expect( + runnerCommand({ action: 'install-service', cwd, execFn: noopExecFn([]) }), + ).rejects.toThrow() + expect(existsSync(unitPath())).toBe(false) + }) + + test('install-service writes the unit pinned to the resolved repo root', async () => { + initRepo(cwd) + const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { + cwd, + encoding: 'utf8', + }).trim() + const calls: { command: string; args: readonly string[] }[] = [] + + await expect( + runnerCommand({ action: 'install-service', cwd, execFn: noopExecFn(calls) }), + ).resolves.toBeUndefined() + + expect(existsSync(unitPath())).toBe(true) + const unit = readFileSync(unitPath(), 'utf8') + expect(unit).toContain(`WorkingDirectory=${repoRoot}`) + expect(calls.map((c) => c.args.join(' '))).toContain( + '--user enable --now codesema-runner.service', + ) + }) + + test('install-service surfaces a clear error when systemctl is absent, and writes nothing', async () => { + initRepo(cwd) + const execFn = (command: string) => { + if (command === 'systemctl') { + throw Object.assign(new Error('spawn systemctl ENOENT'), { code: 'ENOENT' }) + } + return '' + } + await expect(runnerCommand({ action: 'install-service', cwd, execFn })).rejects.toThrow( + t('runner.systemctlNotFound'), + ) + expect(existsSync(unitPath())).toBe(false) + }) + + test('uninstall-service is a soft no-op when nothing is installed', async () => { + await expect( + runnerCommand({ action: 'uninstall-service', cwd, execFn: noopExecFn([]) }), + ).resolves.toBeUndefined() + }) + + test('uninstall-service removes a previously installed unit', async () => { + initRepo(cwd) + await runnerCommand({ action: 'install-service', cwd, execFn: noopExecFn([]) }) + expect(existsSync(unitPath())).toBe(true) + + await expect( + runnerCommand({ action: 'uninstall-service', cwd, execFn: noopExecFn([]) }), + ).resolves.toBeUndefined() + expect(existsSync(unitPath())).toBe(false) + }) + }) +}) diff --git a/packages/cli/src/runner-commands.ts b/packages/cli/src/runner-commands.ts new file mode 100644 index 0000000..feb5fe6 --- /dev/null +++ b/packages/cli/src/runner-commands.ts @@ -0,0 +1,911 @@ +// `codesema runner …`: connect a workspace to a hub, inspect it, draft and +// publish a ticket by hand, or start/stop the background daemon (D21: `serve +// --detach` backgrounds it, `stop` ends it). Same shape as sync.ts's +// `syncCommand`/`linkCommand`: one action-dispatching entry point. Usage +// errors throw a plain `Error` the CLI's top-level catch prints; `stop` is +// the one action that is a no-op rather than an error when there is nothing +// to do: stopping an already-stopped daemon is success, not misuse. + +import { + execFileSync, + spawn, + spawnSync, + type ChildProcess, + type SpawnOptions, +} from 'node:child_process' +import { closeSync, mkdirSync, openSync } from 'node:fs' +import { hostname } from 'node:os' +import { dirname, join } from 'node:path' +import type { runAgent } from './agent.js' +import { loadGlobalConfig, runnerEnvPath, saveGlobalConfig } from './config.js' +import type { RunnerListEntry } from './contract.js' +import { tryGit } from './git.js' +import { + claimPendingSecret, + depositRunnerSecret, + hubErrorMessage, + hubRemoteUrl, + listInFlightTickets, + listRunners, + listTickets, + parseHubToken, + registerRunnerKey, + type InFlightTicket, +} from './hub-client.js' +import { t } from './i18n.js' +import { loadOrCreateRunnerIdentity, loadRunnerIdentity } from './runner-identity.js' +import { readRunnerPidfile, removeRunnerPidfile } from './runner-pidfile.js' +import { applySecretsToEnvFile, sanitizeRunnerSecretsPayload } from './runner-secrets.js' +import { + installRunnerService, + uninstallRunnerService, + type ExecCommandFn, +} from './runner-service.js' +import { formatFingerprint, runnerKeyFingerprint, seal, unseal } from './sealed-box.js' +import { loadSyncCredentials } from './sync.js' +import { draftAndPublishTicket } from './ticket-draft.js' +import { confirm, isInteractive, select, textInput, type SelectOption } from './tui.js' +import { ACCENT, AMBER, dim, GREEN, paint, renderFieldRows, type FieldRow } from './ui.js' +import { isPidAlive } from './workspace-lock.js' +import { workspace } from './workspace.js' + +/** + * The one `spawn` overload `spawnDetachedRunnerServe` actually calls, pulled + * out as its own type rather than `typeof spawn`: the real signature is a + * dozen overloads deep, which a test fake has no reason to satisfy. + */ +type SpawnFn = (command: string, args: readonly string[], options: SpawnOptions) => ChildProcess + +/** The one shape `runner autoconfig`'s Claude-token step needs from `spawnSync`: run a command attached to the real terminal (an OAuth device flow needs to print a URL and read a code), no output captured. */ +export type RunInheritedFn = (command: string, args: readonly string[]) => void + +function realRunInherited(command: string, args: readonly string[]): void { + spawnSync(command, args, { stdio: 'inherit' }) +} + +/** Same `execFileSync` wrapper runner-service.ts keeps private for its own systemctl calls; restated here for `gh auth token`, the one other place this module shells out to a real binary. */ +function realExecCommand(command: string, args: readonly string[]): string { + return execFileSync(command, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} + +/** Same bkctl-style result block as sync.ts's own (private there, so restated here). */ +function printResult(statusMessage: string, rows: FieldRow[]): void { + console.log('') + console.log(` ${paint('✔', GREEN)} ${statusMessage}`) + for (const line of renderFieldRows(rows)) { + console.log(` ${line}`) + } +} + +export type RunnerCommandOptions = { + action?: string | undefined + cwd: string + url?: string | undefined + token?: string | undefined + issue?: string | undefined + title?: string | undefined + prompt?: string | undefined + /** `runner serve --detach` only: background the daemon instead of running it here. */ + detach?: boolean | undefined + /** `runner install-service`'s EnvironmentFile=, or `runner await-secrets`'s destination env file. */ + envFile?: string | undefined + /** `runner autoconfig` only: fingerprint of the target runner, skips the interactive picker. */ + fingerprint?: string | undefined + /** `runner autoconfig` only: capture GH_TOKEN from `gh auth token` without asking to confirm. */ + ghTokenFromGh?: boolean | undefined + /** `runner autoconfig` only: Claude Code OAuth token to send, skips env reuse/`claude setup-token`/paste. */ + claudeToken?: string | undefined + /** `runner autoconfig` only: repo URL to send, skips the detected-remote confirm/paste. */ + repoUrl?: string | undefined + /** `runner await-secrets` only: seconds to poll before giving up (default 1800). */ + timeoutSeconds?: number | undefined + /** Test seam. */ + fetchImpl?: typeof fetch | undefined + /** Test seam. */ + runAgentFn?: typeof runAgent | undefined + /** Test seam for `runner serve --detach`: never forks a real process in tests. */ + spawnFn?: SpawnFn | undefined + /** Test seam for `runner install-service`/`uninstall-service`/`autoconfig`'s `gh auth token`: never shells out to a real systemctl/loginctl/gh in tests. */ + execFn?: ExecCommandFn | undefined + /** Test seam for `runner autoconfig`'s `claude setup-token`: never spawns a real inherited process in tests. */ + runInheritedFn?: RunInheritedFn | undefined + /** Test seams for `runner autoconfig`'s prompts: never touch a real TTY in tests. */ + selectFn?: RunnerSelectFn | undefined + textInputFn?: typeof textInput | undefined + confirmFn?: typeof confirm | undefined + /** Test seams for `runner stop`'s bounded poll: real 10s/200ms by default. */ + stopTimeoutMs?: number | undefined + stopPollIntervalMs?: number | undefined + /** Test seams for `runner await-secrets`'s poll loop: real 4s/30s by default. */ + pollIntervalMs?: number | undefined + reminderIntervalMs?: number | undefined +} + +async function runnerConnect(opts: RunnerCommandOptions): Promise<void> { + if (!opts.url || !opts.token) { + throw new Error(t('runner.connectMissingFlags')) + } + const parsed = parseHubToken(opts.token) + if (!parsed) { + throw new Error(t('runner.badToken')) + } + // Same global credentials sync.ts's createWorkspace/linkWorkspace write: + // `codesema sync`, `codesema link` and the runner daemon share one account. + const path = saveGlobalConfig({ + ...loadGlobalConfig(), + syncUrl: opts.url, + syncWorkspaceId: parsed.workspaceId, + syncSecret: parsed.secret, + }) + + // Stable across reconnects: only ever generated once per machine, so the + // fingerprint an operator reads here still matches the one `autoconfig` + // recomputes later against whatever the hub reports for this same key. + const identity = loadOrCreateRunnerIdentity() + const creds = { url: opts.url, workspaceId: parsed.workspaceId, secret: parsed.secret } + const registerResult = await registerRunnerKey( + creds, + { public_key: identity.publicKey.toString('base64'), name: hostname() }, + opts.fetchImpl ?? fetch, + ) + + printResult(t('runner.connected', { url: opts.url }), [ + { label: t('field.account'), value: parsed.workspaceId }, + { label: t('runner.fieldFingerprint'), value: formatFingerprint(identity.fingerprint) }, + ]) + console.log(` ${t('runner.savedTo', { path })}`) + if (!registerResult.ok) { + console.log( + ` ${paint(t('runner.keyRegisterFailed', { reason: hubErrorMessage(registerResult.error) }), AMBER)}`, + ) + } + console.log('') +} + +/** + * Local-only: clears the three credentials `runnerConnect` wrote, the same + * destructure-and-omit `sync.ts`'s `deleteWorkspaceData` uses to drop + * `syncWorkspaceId`/`syncSecret` (here all three, since disconnecting a hub + * is meant to fully forget it, not just its data). No API call — the hub + * has its own revocation, shipped separately in its dashboard Settings — so + * this only ever touches the local file and reminds the caller to revoke + * there too. + */ +async function runnerDisconnect(): Promise<void> { + const config = loadGlobalConfig() + if (!config.syncUrl && !config.syncWorkspaceId && !config.syncSecret) { + printResult(t('runner.alreadyDisconnected'), []) + return + } + const { syncUrl: _url, syncWorkspaceId: _id, syncSecret: _secret, ...rest } = config + saveGlobalConfig(rest) + printResult(t('runner.disconnected'), []) + console.log(` ${paint(t('runner.disconnectRevokeReminder'), AMBER)}`) + console.log('') +} + +/** `{2h14m}` / `{6m03s}` / `{9s}`: coarsest-first, no leading zero on the coarsest unit. */ +function formatUptime(startedAt: string, nowMs: number): string { + const elapsedS = Math.max(0, Math.floor((nowMs - Date.parse(startedAt)) / 1000)) + const h = Math.floor(elapsedS / 3600) + const m = Math.floor((elapsedS % 3600) / 60) + const s = elapsedS % 60 + if (h > 0) { + return `${h}h${String(m).padStart(2, '0')}m` + } + if (m > 0) { + return `${m}m${String(s).padStart(2, '0')}s` + } + return `${s}s` +} + +/** `{12s ago}` / `{3min ago}` / `{2h ago}` / `{5d ago}`: coarsest unit only, i18n'd via `runner.heartbeat*`. */ +function formatHeartbeatAge(updatedAt: string, nowMs: number): string { + const elapsedS = Math.max(0, Math.floor((nowMs - Date.parse(updatedAt)) / 1000)) + if (elapsedS < 60) { + return t('runner.heartbeatSeconds', { n: elapsedS }) + } + const elapsedMin = Math.floor(elapsedS / 60) + if (elapsedMin < 60) { + return t('runner.heartbeatMinutes', { n: elapsedMin }) + } + const elapsedH = Math.floor(elapsedMin / 60) + if (elapsedH < 24) { + return t('runner.heartbeatHours', { n: elapsedH }) + } + return t('runner.heartbeatDays', { n: Math.floor(elapsedH / 24) }) +} + +const IN_FLIGHT_TITLE_MAX = 64 + +function truncateInFlightTitle(title: string): string { + return title.length > IN_FLIGHT_TITLE_MAX ? `${title.slice(0, IN_FLIGHT_TITLE_MAX - 1)}…` : title +} + +/** + * One `runner status` in-flight detail line: hub status, executor, heartbeat + * age, the arm's own local status when the hub reports one (absent on a + * hub build older than that field), and a `stale` tag when the claim's + * lease has already lapsed: a ticket a dead or stuck arm is still shown as + * holding. + */ +function inFlightDetailLine(ticket: InFlightTicket, nowMs: number): string { + const facts = [ + ticket.status, + ticket.executed_by ?? t('runner.fieldUnclaimed'), + formatHeartbeatAge(ticket.updated_at, nowMs), + ...(ticket.arm_local_status ? [ticket.arm_local_status] : []), + ] + const line = dim(facts.join(' · ')) + const isStale = ticket.lease_expires_at !== null && Date.parse(ticket.lease_expires_at) < nowMs + return isStale ? `${line} ${paint(t('runner.fieldStale'), AMBER)}` : line +} + +function printInFlightTickets(tickets: InFlightTicket[]): void { + console.log('') + console.log(` ${paint(t('runner.inFlightHeading'), ACCENT)}`) + const nowMs = Date.now() + for (const ticket of tickets) { + console.log(` ${truncateInFlightTitle(ticket.title)}`) + console.log(` ${inFlightDetailLine(ticket, nowMs)}`) + } +} + +/** + * The daemon rows for `runner status`: pid/port/uptime read off the D21 + * pidfile, or a single "not running" row. A pidfile naming a dead pid is + * cleaned up here too, the same read-time doctrine `runnerStop` uses, so + * neither command leaves a stale file for the other to trip over. + */ +function runnerDaemonStatusRows(cwd: string): FieldRow[] { + const pidfile = readRunnerPidfile(cwd) + if (!pidfile || !isPidAlive(pidfile.pid)) { + if (pidfile) { + removeRunnerPidfile(cwd, pidfile.pid) + } + return [{ label: t('runner.fieldDaemon'), value: t('runner.notRunning') }] + } + return [ + { label: t('runner.fieldPid'), value: String(pidfile.pid) }, + { label: t('runner.fieldPort'), value: String(pidfile.port) }, + { label: t('runner.fieldUptime'), value: formatUptime(pidfile.started_at, Date.now()) }, + ] +} + +async function runnerStatus(opts: RunnerCommandOptions): Promise<void> { + const creds = loadSyncCredentials() + if (!creds) { + throw new Error(t('runner.notConnected')) + } + const remoteUrl = hubRemoteUrl(opts.cwd) + const rows: FieldRow[] = [ + { label: t('runner.fieldUrl'), value: creds.url }, + { label: t('field.account'), value: creds.workspaceId }, + { label: t('runner.fieldRepo'), value: remoteUrl ?? t('runner.noRemote') }, + ...runnerDaemonStatusRows(opts.cwd), + ] + if (!remoteUrl) { + printResult(t('runner.statusTitle'), rows) + return + } + const fetchImpl = opts.fetchImpl ?? fetch + const result = await listTickets(creds, remoteUrl, 'published', fetchImpl) + rows.push({ + label: t('runner.fieldReady'), + value: result.ok ? String(result.data.length) : hubErrorMessage(result.error), + }) + const inFlight = await listInFlightTickets(creds, remoteUrl, fetchImpl) + rows.push({ + label: t('runner.fieldInFlight'), + value: inFlight.ok ? String(inFlight.data.length) : hubErrorMessage(inFlight.error), + }) + printResult(t('runner.statusTitle'), rows) + if (inFlight.ok && inFlight.data.length > 0) { + printInFlightTickets(inFlight.data) + } +} + +function parsePositiveInt(raw: string): number | null { + const n = Number(raw) + return Number.isInteger(n) && n > 0 ? n : null +} + +async function runnerTicket(opts: RunnerCommandOptions): Promise<void> { + const hasIssue = opts.issue !== undefined + const hasPromptForm = opts.title !== undefined && opts.prompt !== undefined + if (hasIssue === hasPromptForm) { + throw new Error(t('runner.ticketUsage')) + } + + const seams = { + ...(opts.runAgentFn ? { runAgentFn: opts.runAgentFn } : {}), + ...(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}), + } + const outcome = hasIssue + ? await (async () => { + const issueNumber = opts.issue ? parsePositiveInt(opts.issue) : null + if (issueNumber === null) { + throw new Error(t('runner.badIssueNumber', { value: opts.issue ?? '' })) + } + return draftAndPublishTicket({ kind: 'issue', cwd: opts.cwd, issueNumber }, seams) + })() + : await draftAndPublishTicket( + { + kind: 'prompt', + cwd: opts.cwd, + title: opts.title as string, + prompt: opts.prompt as string, + }, + seams, + ) + + if (!outcome.ok) { + throw new Error(t('runner.draftFailed', { reason: outcome.reason })) + } + printResult(t('runner.ticketCreated', { title: outcome.ticket.title }), [ + { label: t('runner.fieldId'), value: outcome.ticket.id }, + { label: t('field.status'), value: outcome.ticket.status }, + ]) + console.log('') + console.log(outcome.ticket.body) + console.log('') +} + +function runnerDaemonLogPath(cwd: string): string { + return join(cwd, '.codesema', 'runner-daemon.log') +} + +/** + * Re-invokes THIS SAME binary as `codesema runner serve` (no --detach: that + * flag names what the CURRENT process does, not the child, or every child + * would refork itself), detached and unref'd so it outlives us, stdout/stderr + * appended to a repo-local log since a detached process has no terminal to + * write to. `process.argv[1]` is the same self-reference `index.ts`'s + * `isProcessEntrypoint` resolves against: the bin script, whether that is + * the built `dist/index.mjs` or a dev entry point. + */ +function spawnDetachedRunnerServe(cwd: string, spawnFn: SpawnFn): ChildProcess { + const entry = process.argv[1] + if (entry === undefined) { + throw new Error(t('runner.detachSpawnFailed')) + } + const logPath = runnerDaemonLogPath(cwd) + mkdirSync(dirname(logPath), { recursive: true }) + const logFd = openSync(logPath, 'a') + try { + const child = spawnFn(process.execPath, [entry, 'runner', 'serve'], { + cwd, + detached: true, + stdio: ['ignore', logFd, logFd], + }) + // Without a listener, an async spawn failure (e.g. the exec itself + // failing after the fork) would throw as an uncaught 'error' event, + // long after this command has already printed success and returned. + child.on('error', () => {}) + return child + } finally { + closeSync(logFd) + } +} + +async function runnerServe(opts: RunnerCommandOptions): Promise<void> { + if (opts.detach) { + const child = spawnDetachedRunnerServe(opts.cwd, opts.spawnFn ?? spawn) + child.unref() + if (child.pid === undefined) { + throw new Error(t('runner.detachSpawnFailed')) + } + printResult(t('runner.detached', { pid: child.pid }), [ + { label: t('runner.fieldLog'), value: runnerDaemonLogPath(opts.cwd) }, + ]) + return + } + // workspace() (workspace.ts) has a fixed options type this module does not + // own, with no room for a runner flag, so the signal crosses into + // startServer (serve.ts) the same way CODESEMA_SYNC_URL/CODESEMA_DEV_VITE + // already do in this codebase: an env var read at the one place that needs + // it, not threaded through every caller's signature. + process.env.CODESEMA_RUNNER_MODE = '1' + await workspace({ cwd: opts.cwd, open: true, port: undefined }) +} + +const DEFAULT_STOP_TIMEOUT_MS = 10_000 +const DEFAULT_STOP_POLL_INTERVAL_MS = 200 + +/** Polls until `pid` is gone or `timeoutMs` runs out. Never rejects. */ +async function waitForPidDeath( + pid: number, + timeoutMs: number, + pollIntervalMs: number, +): Promise<boolean> { + const deadline = Date.now() + timeoutMs + while (isPidAlive(pid)) { + if (Date.now() >= deadline) { + return false + } + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)) + } + return true +} + +/** + * SIGTERM, then a bounded wait (default ~10s) for the pid to actually exit, + * never an infinite hang. An absent pidfile or one naming an already-dead pid + * both mean "nothing to stop", reported the same way `runnerStatus` would + * report it, and just as idempotent: calling `stop` twice never throws. + */ +async function runnerStop(opts: RunnerCommandOptions): Promise<void> { + const pidfile = readRunnerPidfile(opts.cwd) + if (!pidfile || !isPidAlive(pidfile.pid)) { + if (pidfile) { + removeRunnerPidfile(opts.cwd, pidfile.pid) + } + printResult(t('runner.notRunning'), []) + return + } + try { + process.kill(pidfile.pid, 'SIGTERM') + } catch { + // Died in the gap between the isPidAlive check above and this call. + removeRunnerPidfile(opts.cwd, pidfile.pid) + printResult(t('runner.notRunning'), []) + return + } + const died = await waitForPidDeath( + pidfile.pid, + opts.stopTimeoutMs ?? DEFAULT_STOP_TIMEOUT_MS, + opts.stopPollIntervalMs ?? DEFAULT_STOP_POLL_INTERVAL_MS, + ) + if (!died) { + const seconds = Math.round((opts.stopTimeoutMs ?? DEFAULT_STOP_TIMEOUT_MS) / 1000) + console.log('') + console.log(` ${t('runner.stopTimeout', { pid: pidfile.pid, seconds })}`) + console.log('') + return + } + removeRunnerPidfile(opts.cwd, pidfile.pid) + printResult(t('runner.stopped', { pid: pidfile.pid }), []) +} + +/** + * Writes and enables the systemd --user unit (D-lifecycle): must run inside + * the repo the daemon should serve, same as `runner serve` itself, since that + * repo's top-level path becomes the unit's WorkingDirectory. + */ +async function runnerInstallService(opts: RunnerCommandOptions): Promise<void> { + const repoRoot = tryGit(['rev-parse', '--show-toplevel'], opts.cwd) + if (!repoRoot) { + throw new Error(t('runner.serviceNotARepo')) + } + const result = installRunnerService({ + workingDirectory: repoRoot, + cwd: opts.cwd, + envFile: opts.envFile, + execFn: opts.execFn, + }) + const rows: FieldRow[] = [ + { label: t('runner.fieldUnit'), value: result.unitPath }, + { label: t('runner.fieldWorkingDirectory'), value: result.workingDirectory }, + { label: t('runner.fieldExecStart'), value: result.execStart }, + ] + if (result.environmentFile) { + rows.push({ label: t('runner.fieldEnvironmentFile'), value: result.environmentFile }) + } + printResult(t('runner.serviceInstalled'), rows) + if (result.lingerError) { + console.log(` ${paint(t('runner.lingerFailed', { reason: result.lingerError }), AMBER)}`) + } + console.log('') +} + +/** Idempotent: no unit file on disk is success, the same "nothing to do" doctrine `runnerStop` already has for an absent pidfile. */ +async function runnerUninstallService(opts: RunnerCommandOptions): Promise<void> { + const result = uninstallRunnerService({ execFn: opts.execFn }) + if (!result.removed) { + printResult(t('runner.serviceNotInstalled'), []) + return + } + printResult(t('runner.serviceUninstalled'), [ + { label: t('runner.fieldUnit'), value: result.unitPath }, + ]) +} + +/** A runner that registered but never sent a heartbeat yet reports `last_seen_at: null`. */ +function formatLastSeen(lastSeenAt: string | null, nowMs: number): string { + return lastSeenAt ? formatHeartbeatAge(lastSeenAt, nowMs) : t('runner.fieldNeverSeen') +} + +function printRunnerList(runners: RunnerListEntry[]): void { + console.log('') + console.log(` ${paint(t('runner.listHeading'), ACCENT)}`) + const nowMs = Date.now() + for (const runner of runners) { + console.log(` ${runner.name}`) + const facts = [ + formatFingerprint(runner.fingerprint), + formatLastSeen(runner.last_seen_at, nowMs), + ...(runner.has_pending_secret ? [t('runner.fieldPendingSecret')] : []), + ] + console.log(` ${dim(facts.join(' · '))}`) + } + console.log('') +} + +async function runnerList(opts: RunnerCommandOptions): Promise<void> { + const creds = loadSyncCredentials() + if (!creds) { + throw new Error(t('runner.notConnected')) + } + const result = await listRunners(creds, opts.fetchImpl ?? fetch) + if (!result.ok) { + throw new Error(t('runner.listFailed', { reason: hubErrorMessage(result.error) })) + } + if (result.data.length === 0) { + printResult(t('runner.listEmpty'), []) + return + } + printRunnerList(result.data) +} + +/** + * The flags `runner autoconfig` needs to complete without ever prompting: one + * to pick the runner (`--fingerprint`) and one path to at least one secret + * (`--gh-token-from-gh` or `--claude-token`); everything else (repo URL, + * reusing an already-set `gh`/Claude token) degrades to "not sent" rather + * than blocking, since the final "at least one secret" check is the real + * gate. A bare `CLAUDE_CODE_OAUTH_TOKEN` env var does not count here: reusing + * it still asks for confirmation, which a non-interactive run cannot give. + */ +function missingAutoconfigFlags(opts: RunnerCommandOptions): string[] { + const missing: string[] = [] + if (!opts.fingerprint) { + missing.push('--fingerprint <fingerprint>') + } + if (!opts.ghTokenFromGh && !opts.claudeToken) { + missing.push('--gh-token-from-gh and/or --claude-token <token>') + } + return missing +} + +function findRunnerByFingerprint( + runners: RunnerListEntry[], + fingerprint: string, +): RunnerListEntry | null { + const normalized = fingerprint.trim().toLowerCase() + return runners.find((runner) => runner.fingerprint.toLowerCase() === normalized) ?? null +} + +/** `select` narrowed to the one value type `runner autoconfig` ever picks from: a test fake only has to handle `RunnerListEntry`, not `select`'s full generic signature. */ +type RunnerSelectFn = (opts: { + title: string + options: SelectOption<RunnerListEntry>[] +}) => Promise<RunnerListEntry | null> + +type AutoconfigPromptSeams = { + selectFn: RunnerSelectFn + textInputFn: typeof textInput + confirmFn: typeof confirm +} + +/** + * Picks the target runner, then re-derives its fingerprint from its OWN + * public key rather than trusting `entry.fingerprint` as reported by the hub + * (a hub that got the two out of sync is not safe to seal secrets through). + * Supplying `--fingerprint` stands in for the interactive "does the runner + * machine show the same fingerprint?" confirmation: typing the exact 64-hex + * value on the command line already IS that out-of-band check. + */ +async function resolveTargetRunner( + opts: RunnerCommandOptions, + runners: RunnerListEntry[], + seams: AutoconfigPromptSeams, +): Promise<RunnerListEntry> { + let entry: RunnerListEntry + let alreadyVerifiedByOperator: boolean + if (opts.fingerprint) { + const found = findRunnerByFingerprint(runners, opts.fingerprint) + if (!found) { + throw new Error(t('runner.autoconfigFingerprintNotFound', { fingerprint: opts.fingerprint })) + } + entry = found + alreadyVerifiedByOperator = true + } else { + const nowMs = Date.now() + const picked = await seams.selectFn({ + title: t('runner.autoconfigSelectRunner'), + options: runners.map((runner) => ({ + label: runner.name, + value: runner, + hint: formatLastSeen(runner.last_seen_at, nowMs), + })), + }) + if (!picked) { + throw new Error(t('runner.autoconfigNoRunnerSelected')) + } + entry = picked + alreadyVerifiedByOperator = false + } + + const recomputed = runnerKeyFingerprint(Buffer.from(entry.public_key, 'base64')) + if (recomputed !== entry.fingerprint) { + throw new Error(t('runner.autoconfigFingerprintMismatch', { name: entry.name })) + } + + if (!alreadyVerifiedByOperator) { + console.log(` ${formatFingerprint(recomputed)}`) + const confirmed = await seams.confirmFn({ title: t('runner.autoconfigConfirmFingerprint') }) + if (!confirmed) { + throw new Error(t('runner.autoconfigFingerprintNotConfirmed')) + } + } + + return entry +} + +function tryGhAuthToken(execFn: ExecCommandFn): string | null { + try { + return execFn('gh', ['auth', 'token']).trim() || null + } catch { + return null + } +} + +/** + * Non-interactive without `--gh-token-from-gh` skips even the `gh auth + * token` probe: a fully-flagged run that only wants a Claude token has no + * business shelling out for a GH one nobody asked for, and no one is present + * to answer the confirm/paste fallback anyway. + */ +async function resolveGhToken( + opts: RunnerCommandOptions, + seams: AutoconfigPromptSeams & { execFn: ExecCommandFn }, +): Promise<string | undefined> { + if (opts.ghTokenFromGh) { + const ghToken = tryGhAuthToken(seams.execFn) + if (!ghToken) { + throw new Error(t('runner.autoconfigGhTokenUnavailable')) + } + return ghToken + } + if (!isInteractive()) { + return undefined + } + const ghToken = tryGhAuthToken(seams.execFn) + if (ghToken && (await seams.confirmFn({ title: t('runner.autoconfigUseGhToken') }))) { + return ghToken + } + const pasted = await seams.textInputFn({ title: t('runner.autoconfigPasteGhToken'), mask: true }) + return pasted ?? undefined +} + +/** + * Non-interactive without `--claude-token` returns immediately: no one is + * present to confirm reusing the ambient OAuth token, and `claude + * setup-token` is an interactive device-code flow that a script invoking + * this non-interactively must never be left blocked on. + */ +async function resolveClaudeToken( + opts: RunnerCommandOptions, + seams: AutoconfigPromptSeams & { runInheritedFn: RunInheritedFn }, +): Promise<string | undefined> { + if (opts.claudeToken) { + return opts.claudeToken + } + if (!isInteractive()) { + return undefined + } + const envToken = process.env.CLAUDE_CODE_OAUTH_TOKEN + if (envToken && (await seams.confirmFn({ title: t('runner.autoconfigReuseClaudeToken') }))) { + return envToken + } + seams.runInheritedFn('claude', ['setup-token']) + const pasted = await seams.textInputFn({ + title: t('runner.autoconfigPasteClaudeToken'), + mask: true, + }) + return pasted ?? undefined +} + +async function resolveRepoUrl( + opts: RunnerCommandOptions, + seams: AutoconfigPromptSeams, +): Promise<string | undefined> { + if (opts.repoUrl) { + return opts.repoUrl + } + const detected = hubRemoteUrl(opts.cwd) + if ( + detected && + (await seams.confirmFn({ title: t('runner.autoconfigUseDetectedRepoUrl', { url: detected }) })) + ) { + return detected + } + const pasted = await seams.textInputFn({ title: t('runner.autoconfigRepoUrl') }) + return pasted ?? undefined +} + +/** + * Picks a registered runner, collects whichever secrets the operator has for + * it, seals them against that runner's own public key and deposits the + * result for `runner await-secrets` to pick up. Every prompt has a flag that + * short-circuits it, so a fully-flagged invocation never touches a TTY; a + * non-interactive one missing a required flag fails immediately instead of + * hanging on a prompt that can never be answered. + */ +async function runnerAutoconfig(opts: RunnerCommandOptions): Promise<void> { + const creds = loadSyncCredentials() + if (!creds) { + throw new Error(t('runner.notConnected')) + } + if (!isInteractive()) { + const missing = missingAutoconfigFlags(opts) + if (missing.length > 0) { + throw new Error(t('runner.autoconfigMissingFlags', { flags: missing.join(', ') })) + } + } + + const fetchImpl = opts.fetchImpl ?? fetch + const listResult = await listRunners(creds, fetchImpl) + if (!listResult.ok) { + throw new Error(t('runner.listFailed', { reason: hubErrorMessage(listResult.error) })) + } + if (listResult.data.length === 0) { + throw new Error(t('runner.listEmpty')) + } + + const seams: AutoconfigPromptSeams = { + selectFn: opts.selectFn ?? select, + textInputFn: opts.textInputFn ?? textInput, + confirmFn: opts.confirmFn ?? confirm, + } + const execFn = opts.execFn ?? realExecCommand + const runInheritedFn = opts.runInheritedFn ?? realRunInherited + + const entry = await resolveTargetRunner(opts, listResult.data, seams) + const ghToken = await resolveGhToken(opts, { ...seams, execFn }) + const claudeToken = await resolveClaudeToken(opts, { ...seams, runInheritedFn }) + const repoUrl = await resolveRepoUrl(opts, seams) + + const secrets = { + ...(ghToken ? { GH_TOKEN: ghToken } : {}), + ...(claudeToken ? { CLAUDE_CODE_OAUTH_TOKEN: claudeToken } : {}), + } + if (Object.keys(secrets).length === 0) { + throw new Error(t('runner.autoconfigNoSecrets')) + } + + const payload = { v: 1 as const, secrets, ...(repoUrl ? { repo_url: repoUrl } : {}) } + const ciphertext = seal( + Buffer.from(entry.public_key, 'base64'), + Buffer.from(JSON.stringify(payload)), + ) + const depositResult = await depositRunnerSecret(creds, entry.fingerprint, ciphertext, fetchImpl) + if (!depositResult.ok) { + throw new Error( + t('runner.autoconfigDepositFailed', { reason: hubErrorMessage(depositResult.error) }), + ) + } + + printResult(t('runner.autoconfigDone', { name: entry.name }), []) + console.log(` ${t('runner.autoconfigReminder')}`) + console.log('') +} + +const DEFAULT_AWAIT_TIMEOUT_S = 1800 +const DEFAULT_AWAIT_POLL_INTERVAL_MS = 4000 +const DEFAULT_AWAIT_REMINDER_INTERVAL_MS = 30_000 + +/** Malformed JSON is the same "ignore and keep polling" case as a payload that fails `sanitizeRunnerSecretsPayload`. */ +function tryParseJson(text: string): unknown { + try { + return JSON.parse(text) + } catch { + return null + } +} + +/** + * Runs on the runner machine after `runner connect`: polls the hub for the + * secret `runner autoconfig` sealed for this runner's fingerprint, decrypts + * and validates it, then writes it to the env file the daemon reads. A + * corrupt or malformed delivery is logged and skipped rather than treated as + * fatal, since the hub only ever holds one pending secret per runner and a + * bad one should not need the operator to restart this command by hand. + * STDOUT carries nothing but the repo URL (or nothing, if none was sent) so + * a caller can capture it directly; every other message goes to STDERR. + */ +async function runnerAwaitSecrets(opts: RunnerCommandOptions): Promise<void> { + const creds = loadSyncCredentials() + if (!creds) { + throw new Error(t('runner.notConnected')) + } + const identity = loadRunnerIdentity() + if (!identity) { + throw new Error(t('runner.awaitSecretsNoIdentity')) + } + + const envPath = opts.envFile ?? runnerEnvPath() + const fetchImpl = opts.fetchImpl ?? fetch + const timeoutMs = (opts.timeoutSeconds ?? DEFAULT_AWAIT_TIMEOUT_S) * 1000 + const pollIntervalMs = opts.pollIntervalMs ?? DEFAULT_AWAIT_POLL_INTERVAL_MS + const reminderIntervalMs = opts.reminderIntervalMs ?? DEFAULT_AWAIT_REMINDER_INTERVAL_MS + const formattedFingerprint = formatFingerprint(identity.fingerprint) + + const deadline = Date.now() + timeoutMs + let lastReminder = Date.now() + console.error(` ${t('runner.awaitSecretsWaiting', { fingerprint: formattedFingerprint })}`) + + for (;;) { + const claimed = await claimPendingSecret(creds, identity.fingerprint, fetchImpl) + if (claimed.ok && claimed.data) { + const plaintext = unseal(identity.privateKey, claimed.data.ciphertext) + if (!plaintext) { + console.error(` ${t('runner.awaitSecretsUndecryptable')}`) + } else { + const parsed = tryParseJson(plaintext.toString('utf8')) + const payload = parsed !== null ? sanitizeRunnerSecretsPayload(parsed) : null + if (!payload) { + console.error(` ${t('runner.awaitSecretsInvalidPayload')}`) + } else { + applySecretsToEnvFile(envPath, payload.secrets) + if (payload.repo_url) { + console.log(payload.repo_url) + } + return + } + } + } + + if (Date.now() >= deadline) { + throw new Error(t('runner.awaitSecretsTimeout', { seconds: Math.round(timeoutMs / 1000) })) + } + if (Date.now() - lastReminder >= reminderIntervalMs) { + console.error(` ${t('runner.awaitSecretsReminder', { fingerprint: formattedFingerprint })}`) + lastReminder = Date.now() + } + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)) + } +} + +export async function runnerCommand(opts: RunnerCommandOptions): Promise<void> { + switch (opts.action) { + case 'connect': + await runnerConnect(opts) + return + case 'disconnect': + await runnerDisconnect() + return + case 'status': + await runnerStatus(opts) + return + case 'list': + await runnerList(opts) + return + case 'ticket': + await runnerTicket(opts) + return + case 'autoconfig': + await runnerAutoconfig(opts) + return + case 'await-secrets': + await runnerAwaitSecrets(opts) + return + case 'serve': + await runnerServe(opts) + return + case 'stop': + await runnerStop(opts) + return + case 'install-service': + await runnerInstallService(opts) + return + case 'uninstall-service': + await runnerUninstallService(opts) + return + case undefined: + console.log(t('runner.usage')) + return + default: + throw new Error(t('runner.unknownAction', { action: opts.action })) + } +} diff --git a/packages/cli/src/brain-daemon.test.ts b/packages/cli/src/runner-daemon.test.ts similarity index 68% rename from packages/cli/src/brain-daemon.test.ts rename to packages/cli/src/runner-daemon.test.ts index c32ea8c..0acc337 100644 --- a/packages/cli/src/brain-daemon.test.ts +++ b/packages/cli/src/runner-daemon.test.ts @@ -3,10 +3,12 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, test } from 'bun:test' -import { startBrainDaemon } from './brain-daemon.js' import { loadGlobalConfig, saveGlobalConfig } from './config.js' import type { ArmTicket, ArmTicketRequest, TaskRecord } from './contract.js' +import type { HubResult } from './hub-client.js' import type { Project } from './projects.js' +import { startRunnerDaemon } from './runner-daemon.js' +import type { RunnerSecretsPayload } from './runner-secrets.js' import type { TaskActionResult } from './task-runner.js' import type { TaskCreateResult, TaskManager } from './task-server.js' @@ -141,6 +143,12 @@ const validTicket: ArmTicket = { updated_at: '2026-01-01T00:00:00.000Z', } +const fakeRunnerIdentity = { + publicKey: Buffer.from('pub'), + privateKey: Buffer.from('priv'), + fingerprint: 'fp1', +} + async function settle(ms = 30): Promise<void> { await new Promise((resolve) => setTimeout(resolve, ms)) } @@ -165,7 +173,7 @@ function fastSleep(_ms: number, signal: AbortSignal): Promise<void> { /** * Answers `order` on the FIRST heartbeat only, `null` on every one after - * (the real brain purges an order the moment it hands it back, D19), and an + * (the real hub purges an order the moment it hands it back, D19), and an * otherwise-empty tick everywhere else. `fastSleep` drives several * heartbeat-loop iterations within one `settle()` window, so a stub that * kept re-serving the same order would make a dispatch test see it applied @@ -188,7 +196,7 @@ function fetchHeartbeatOrder(order: unknown, calls: Call[]): typeof fetch { }) as unknown as typeof fetch } -describe('startBrainDaemon', () => { +describe('startRunnerDaemon', () => { const previousConfigDir = process.env.CODESEMA_CONFIG_DIR let configDir: string let cwd: string @@ -213,7 +221,7 @@ describe('startBrainDaemon', () => { const calls: Call[] = [] const lines: string[] = [] const manager = fakeManager({ cwd }) - const handle = startBrainDaemon({ + const handle = startRunnerDaemon({ manager, cwd, fetchImpl: fetchStub(200, {}, calls), @@ -227,7 +235,7 @@ describe('startBrainDaemon', () => { test('no git origin remote: logs once and makes no HTTP call', async () => { saveGlobalConfig({ ...loadGlobalConfig(), - syncUrl: 'https://brain.example', + syncUrl: 'https://hub.example', syncWorkspaceId: 'ws1', syncSecret: 'sec1', }) @@ -235,7 +243,7 @@ describe('startBrainDaemon', () => { const calls: Call[] = [] const lines: string[] = [] const manager = fakeManager({ cwd }) - const handle = startBrainDaemon({ + const handle = startRunnerDaemon({ manager, cwd, fetchImpl: fetchStub(200, {}, calls), @@ -249,14 +257,14 @@ describe('startBrainDaemon', () => { test('flushes the outbox on every tick', async () => { saveGlobalConfig({ ...loadGlobalConfig(), - syncUrl: 'https://brain.example', + syncUrl: 'https://hub.example', syncWorkspaceId: 'ws1', syncSecret: 'sec1', }) initRepo(cwd, 'https://github.com/o/r.git') mkdirSync(join(cwd, '.codesema'), { recursive: true }) writeFileSync( - join(cwd, '.codesema', 'brain-outbox.jsonl'), + join(cwd, '.codesema', 'hub-outbox.jsonl'), `${JSON.stringify({ kind: 'transition', key: 'k1', @@ -266,7 +274,7 @@ describe('startBrainDaemon', () => { ) const calls: Call[] = [] const manager = fakeManager({ cwd }) - const handle = startBrainDaemon({ + const handle = startRunnerDaemon({ manager, cwd, fetchImpl: fetchStub(200, { requests: [], tickets: [] }, calls), @@ -274,14 +282,14 @@ describe('startBrainDaemon', () => { }) await handle.stop() expect( - calls.some((c) => c.url === 'https://brain.example/api/cli/tickets/tkt1/transitions'), + calls.some((c) => c.url === 'https://hub.example/api/cli/tickets/tkt1/transitions'), ).toBe(true) }) test('a non-terminal record blocks claiming, admitted or not', async () => { saveGlobalConfig({ ...loadGlobalConfig(), - syncUrl: 'https://brain.example', + syncUrl: 'https://hub.example', syncWorkspaceId: 'ws1', syncSecret: 'sec1', }) @@ -291,7 +299,7 @@ describe('startBrainDaemon', () => { cwd, records: [{ id: 'existing-task', status: 'waiting_for_you' } as TaskRecord], }) - const handle = startBrainDaemon({ + const handle = startRunnerDaemon({ manager, cwd, fetchImpl: fetchStub(200, { requests: [], tickets: [validTicket] }, calls), @@ -301,10 +309,10 @@ describe('startBrainDaemon', () => { expect(calls.some((c) => c.url.includes('/api/cli/tickets?'))).toBe(false) }) - test('an interrupted brain task is resumed instead of blocking the loop', async () => { + test('an interrupted hub-ticket task is resumed instead of blocking the loop', async () => { saveGlobalConfig({ ...loadGlobalConfig(), - syncUrl: 'https://brain.example', + syncUrl: 'https://hub.example', syncWorkspaceId: 'ws1', syncSecret: 'sec1', }) @@ -318,11 +326,11 @@ describe('startBrainDaemon', () => { { id: 'parked-task', status: 'interrupted', - brain_ticket: { id: validTicket.id, title: validTicket.title }, + hub_ticket: { id: validTicket.id, title: validTicket.title }, } as TaskRecord, ], }) - const handle = startBrainDaemon({ + const handle = startRunnerDaemon({ manager, cwd, fetchImpl: fetchStub(200, { requests: [], tickets: [validTicket] }, calls), @@ -336,7 +344,7 @@ describe('startBrainDaemon', () => { test('a ticket that already has a local task is not claimed again', async () => { saveGlobalConfig({ ...loadGlobalConfig(), - syncUrl: 'https://brain.example', + syncUrl: 'https://hub.example', syncWorkspaceId: 'ws1', syncSecret: 'sec1', }) @@ -348,11 +356,11 @@ describe('startBrainDaemon', () => { { id: 'shipped-task', status: 'shipped', - brain_ticket: { id: validTicket.id, title: validTicket.title }, + hub_ticket: { id: validTicket.id, title: validTicket.title }, } as TaskRecord, ], }) - const handle = startBrainDaemon({ + const handle = startRunnerDaemon({ manager, cwd, fetchImpl: fetchStub(200, { requests: [], tickets: [validTicket] }, calls), @@ -365,7 +373,7 @@ describe('startBrainDaemon', () => { test('no active task and a published ticket: claims it and starts a task on the same manager', async () => { saveGlobalConfig({ ...loadGlobalConfig(), - syncUrl: 'https://brain.example', + syncUrl: 'https://hub.example', syncWorkspaceId: 'ws1', syncSecret: 'sec1', }) @@ -376,7 +384,7 @@ describe('startBrainDaemon', () => { createCalls, createResult: { ok: true, - record: fakeRecord({ id: 'newtask', brain_ticket: { id: 'tkt1', title: 'Add a thing' } }), + record: fakeRecord({ id: 'newtask', hub_ticket: { id: 'tkt1', title: 'Add a thing' } }), }, }) const calls: Call[] = [] @@ -398,16 +406,14 @@ describe('startBrainDaemon', () => { } return new Response(JSON.stringify({}), { status: 200 }) }) as unknown as typeof fetch - const handle = startBrainDaemon({ + const handle = startRunnerDaemon({ manager, cwd, fetchImpl, logFn: (line) => lines.push(line), }) await handle.stop() - expect(calls.some((c) => c.url === 'https://brain.example/api/cli/tickets/tkt1/claim')).toBe( - true, - ) + expect(calls.some((c) => c.url === 'https://hub.example/api/cli/tickets/tkt1/claim')).toBe(true) expect(createCalls.length).toBe(1) expect(lines.some((l) => l.includes('started task newtask'))).toBe(true) }) @@ -415,7 +421,7 @@ describe('startBrainDaemon', () => { test('a queued ticket request is drafted and submitted through draftFn', async () => { saveGlobalConfig({ ...loadGlobalConfig(), - syncUrl: 'https://brain.example', + syncUrl: 'https://hub.example', syncWorkspaceId: 'ws1', syncSecret: 'sec1', }) @@ -446,7 +452,7 @@ describe('startBrainDaemon', () => { const draftCalls: { requestId: string; cwd: string }[] = [] const manager = fakeManager({ cwd }) const lines: string[] = [] - const handle = startBrainDaemon({ + const handle = startRunnerDaemon({ manager, cwd, fetchImpl, @@ -464,7 +470,7 @@ describe('startBrainDaemon', () => { test('backs off after a network failure, not after a 4xx', async () => { saveGlobalConfig({ ...loadGlobalConfig(), - syncUrl: 'https://brain.example', + syncUrl: 'https://hub.example', syncWorkspaceId: 'ws1', syncSecret: 'sec1', }) @@ -472,7 +478,7 @@ describe('startBrainDaemon', () => { const manager = fakeManager({ cwd }) const offlineDurations: number[] = [] - const offlineHandle = startBrainDaemon({ + const offlineHandle = startRunnerDaemon({ manager, cwd, intervalMs: 1000, @@ -487,7 +493,7 @@ describe('startBrainDaemon', () => { expect(offlineDurations.find((d) => d !== 45_000)).toBe(2000) const badRequestDurations: number[] = [] - const badRequestHandle = startBrainDaemon({ + const badRequestHandle = startRunnerDaemon({ manager, cwd, intervalMs: 1000, @@ -502,20 +508,20 @@ describe('startBrainDaemon', () => { expect(badRequestDurations.find((d) => d !== 45_000)).toBe(1000) }) - test('heartbeats the active brain-ticket task on its own schedule', async () => { + test('heartbeats the active hub-ticket task on its own schedule', async () => { saveGlobalConfig({ ...loadGlobalConfig(), - syncUrl: 'https://brain.example', + syncUrl: 'https://hub.example', syncWorkspaceId: 'ws1', syncSecret: 'sec1', }) initRepo(cwd, 'https://github.com/o/r.git') const { claimActive } = await import('./task-queue.js') claimActive(cwd, 'active1') - const record = fakeRecord({ id: 'active1', brain_ticket: { id: 'tkt1', title: 'Add a thing' } }) + const record = fakeRecord({ id: 'active1', hub_ticket: { id: 'tkt1', title: 'Add a thing' } }) const manager = fakeManager({ cwd, records: [record] }) const calls: Call[] = [] - const handle = startBrainDaemon({ + const handle = startRunnerDaemon({ manager, cwd, fetchImpl: fetchStub(200, { requests: [], tickets: [] }, calls), @@ -524,18 +530,18 @@ describe('startBrainDaemon', () => { }) await settle(30) await handle.stop() - expect( - calls.some((c) => c.url === 'https://brain.example/api/cli/tickets/tkt1/heartbeat'), - ).toBe(true) + expect(calls.some((c) => c.url === 'https://hub.example/api/cli/tickets/tkt1/heartbeat')).toBe( + true, + ) }) - test('heartbeats a waiting_for_you brain-ticket task even when the memory slot is free', async () => { + test('heartbeats a waiting_for_you hub-ticket task even when the memory slot is free', async () => { // Regression: the memory slot (claimActive/activeTask) empties the moment // a turn's promise settles, so a task parked on waiting_for_you must be // found from the PERSISTED record, never from that slot. saveGlobalConfig({ ...loadGlobalConfig(), - syncUrl: 'https://brain.example', + syncUrl: 'https://hub.example', syncWorkspaceId: 'ws1', syncSecret: 'sec1', }) @@ -543,11 +549,11 @@ describe('startBrainDaemon', () => { const record = fakeRecord({ id: 'active1', status: 'waiting_for_you', - brain_ticket: { id: 'tkt1', title: 'Add a thing' }, + hub_ticket: { id: 'tkt1', title: 'Add a thing' }, }) const manager = fakeManager({ cwd, records: [record] }) const calls: Call[] = [] - const handle = startBrainDaemon({ + const handle = startRunnerDaemon({ manager, cwd, fetchImpl: fetchStub(200, { requests: [], tickets: [] }, calls), @@ -556,15 +562,15 @@ describe('startBrainDaemon', () => { }) await settle(30) await handle.stop() - expect( - calls.some((c) => c.url === 'https://brain.example/api/cli/tickets/tkt1/heartbeat'), - ).toBe(true) + expect(calls.some((c) => c.url === 'https://hub.example/api/cli/tickets/tkt1/heartbeat')).toBe( + true, + ) }) test('the heartbeat carries the persisted status as local_status', async () => { saveGlobalConfig({ ...loadGlobalConfig(), - syncUrl: 'https://brain.example', + syncUrl: 'https://hub.example', syncWorkspaceId: 'ws1', syncSecret: 'sec1', }) @@ -572,11 +578,11 @@ describe('startBrainDaemon', () => { const record = fakeRecord({ id: 'active1', status: 'waiting_for_you', - brain_ticket: { id: 'tkt1', title: 'Add a thing' }, + hub_ticket: { id: 'tkt1', title: 'Add a thing' }, }) const manager = fakeManager({ cwd, records: [record] }) const calls: Call[] = [] - const handle = startBrainDaemon({ + const handle = startRunnerDaemon({ manager, cwd, fetchImpl: fetchStub(200, { requests: [], tickets: [] }, calls), @@ -594,7 +600,7 @@ describe('startBrainDaemon', () => { test('a ship order from the heartbeat response ships the task', async () => { saveGlobalConfig({ ...loadGlobalConfig(), - syncUrl: 'https://brain.example', + syncUrl: 'https://hub.example', syncWorkspaceId: 'ws1', syncSecret: 'sec1', }) @@ -602,13 +608,13 @@ describe('startBrainDaemon', () => { const record = fakeRecord({ id: 'active1', status: 'waiting_for_you', - brain_ticket: { id: 'tkt1', title: 'Add a thing' }, + hub_ticket: { id: 'tkt1', title: 'Add a thing' }, }) const shipCalls: Array<{ projectId: string; id: string }> = [] const manager = fakeManager({ cwd, records: [record], shipCalls }) const order = { action: 'ship', instruction: null, issued_at: '2026-01-01T00:00:00.000Z' } const calls: Call[] = [] - const handle = startBrainDaemon({ + const handle = startRunnerDaemon({ manager, cwd, fetchImpl: fetchHeartbeatOrder(order, calls), @@ -623,7 +629,7 @@ describe('startBrainDaemon', () => { test('a reply order from the heartbeat response replies with the instruction', async () => { saveGlobalConfig({ ...loadGlobalConfig(), - syncUrl: 'https://brain.example', + syncUrl: 'https://hub.example', syncWorkspaceId: 'ws1', syncSecret: 'sec1', }) @@ -631,7 +637,7 @@ describe('startBrainDaemon', () => { const record = fakeRecord({ id: 'active1', status: 'waiting_for_you', - brain_ticket: { id: 'tkt1', title: 'Add a thing' }, + hub_ticket: { id: 'tkt1', title: 'Add a thing' }, }) const replyCalls: Array<{ projectId: string; id: string; message: string }> = [] const manager = fakeManager({ cwd, records: [record], replyCalls }) @@ -641,7 +647,7 @@ describe('startBrainDaemon', () => { issued_at: '2026-01-01T00:00:00.000Z', } const calls: Call[] = [] - const handle = startBrainDaemon({ + const handle = startRunnerDaemon({ manager, cwd, fetchImpl: fetchHeartbeatOrder(order, calls), @@ -658,7 +664,7 @@ describe('startBrainDaemon', () => { test('an abandon order from the heartbeat response abandons the task', async () => { saveGlobalConfig({ ...loadGlobalConfig(), - syncUrl: 'https://brain.example', + syncUrl: 'https://hub.example', syncWorkspaceId: 'ws1', syncSecret: 'sec1', }) @@ -666,13 +672,13 @@ describe('startBrainDaemon', () => { const record = fakeRecord({ id: 'active1', status: 'waiting_for_you', - brain_ticket: { id: 'tkt1', title: 'Add a thing' }, + hub_ticket: { id: 'tkt1', title: 'Add a thing' }, }) const abandonCalls: Array<{ projectId: string; id: string }> = [] const manager = fakeManager({ cwd, records: [record], abandonCalls }) const order = { action: 'abandon', instruction: null, issued_at: '2026-01-01T00:00:00.000Z' } const calls: Call[] = [] - const handle = startBrainDaemon({ + const handle = startRunnerDaemon({ manager, cwd, fetchImpl: fetchHeartbeatOrder(order, calls), @@ -687,7 +693,7 @@ describe('startBrainDaemon', () => { test('no order in the heartbeat response: nothing is dispatched', async () => { saveGlobalConfig({ ...loadGlobalConfig(), - syncUrl: 'https://brain.example', + syncUrl: 'https://hub.example', syncWorkspaceId: 'ws1', syncSecret: 'sec1', }) @@ -695,14 +701,14 @@ describe('startBrainDaemon', () => { const record = fakeRecord({ id: 'active1', status: 'waiting_for_you', - brain_ticket: { id: 'tkt1', title: 'Add a thing' }, + hub_ticket: { id: 'tkt1', title: 'Add a thing' }, }) const shipCalls: Array<{ projectId: string; id: string }> = [] const replyCalls: Array<{ projectId: string; id: string; message: string }> = [] const abandonCalls: Array<{ projectId: string; id: string }> = [] const manager = fakeManager({ cwd, records: [record], shipCalls, replyCalls, abandonCalls }) const calls: Call[] = [] - const handle = startBrainDaemon({ + const handle = startRunnerDaemon({ manager, cwd, fetchImpl: fetchHeartbeatOrder(null, calls), @@ -719,7 +725,7 @@ describe('startBrainDaemon', () => { test('a manager refusal applying an order is logged, not thrown', async () => { saveGlobalConfig({ ...loadGlobalConfig(), - syncUrl: 'https://brain.example', + syncUrl: 'https://hub.example', syncWorkspaceId: 'ws1', syncSecret: 'sec1', }) @@ -727,7 +733,7 @@ describe('startBrainDaemon', () => { const record = fakeRecord({ id: 'active1', status: 'waiting_for_you', - brain_ticket: { id: 'tkt1', title: 'Add a thing' }, + hub_ticket: { id: 'tkt1', title: 'Add a thing' }, }) const manager = fakeManager({ cwd, @@ -737,7 +743,7 @@ describe('startBrainDaemon', () => { const order = { action: 'ship', instruction: null, issued_at: '2026-01-01T00:00:00.000Z' } const calls: Call[] = [] const lines: string[] = [] - const handle = startBrainDaemon({ + const handle = startRunnerDaemon({ manager, cwd, fetchImpl: fetchHeartbeatOrder(order, calls), @@ -748,4 +754,189 @@ describe('startBrainDaemon', () => { await handle.stop() expect(lines.some((l) => l.includes('refused') && l.includes('ship in progress'))).toBe(true) }) + + describe('secret rotation', () => { + test('no runner identity yet: skips without calling the hub', async () => { + saveGlobalConfig({ + ...loadGlobalConfig(), + syncUrl: 'https://hub.example', + syncWorkspaceId: 'ws1', + syncSecret: 'sec1', + }) + initRepo(cwd, 'https://github.com/o/r.git') + const manager = fakeManager({ cwd }) + const lines: string[] = [] + const claimSecretCalls: unknown[] = [] + const handle = startRunnerDaemon({ + manager, + cwd, + fetchImpl: fetchStub(200, { requests: [], tickets: [] }, []), + logFn: (line) => lines.push(line), + loadIdentityFn: () => null, + claimSecretFn: async (...args) => { + claimSecretCalls.push(args) + return { ok: true, data: null } + }, + }) + await handle.stop() + expect(claimSecretCalls.length).toBe(0) + expect(lines.some((l) => l.includes('no runner identity'))).toBe(true) + }) + + test('no pending secret: a silent no-op', async () => { + saveGlobalConfig({ + ...loadGlobalConfig(), + syncUrl: 'https://hub.example', + syncWorkspaceId: 'ws1', + syncSecret: 'sec1', + }) + initRepo(cwd, 'https://github.com/o/r.git') + const manager = fakeManager({ cwd }) + const lines: string[] = [] + const applyCalls: unknown[] = [] + const handle = startRunnerDaemon({ + manager, + cwd, + fetchImpl: fetchStub(200, { requests: [], tickets: [] }, []), + logFn: (line) => lines.push(line), + loadIdentityFn: () => fakeRunnerIdentity, + claimSecretFn: async () => ({ ok: true, data: null }), + applySecretsFn: (...args) => { + applyCalls.push(args) + }, + }) + await handle.stop() + expect(applyCalls.length).toBe(0) + expect(lines.length).toBe(0) + }) + + test('a valid blob mutates process.env, writes the env file, and logs only the key names', async () => { + saveGlobalConfig({ + ...loadGlobalConfig(), + syncUrl: 'https://hub.example', + syncWorkspaceId: 'ws1', + syncSecret: 'sec1', + }) + initRepo(cwd, 'https://github.com/o/r.git') + const manager = fakeManager({ cwd }) + const lines: string[] = [] + const applyCalls: Array<{ envPath: string; secrets: Record<string, string> }> = [] + const previousToken = process.env.CLAUDE_CODE_OAUTH_TOKEN + const payload: RunnerSecretsPayload = { + v: 1, + secrets: { CLAUDE_CODE_OAUTH_TOKEN: 'secret-token-value' }, + } + const handle = startRunnerDaemon({ + manager, + cwd, + fetchImpl: fetchStub(200, { requests: [], tickets: [] }, []), + logFn: (line) => lines.push(line), + loadIdentityFn: () => fakeRunnerIdentity, + claimSecretFn: async () => ({ ok: true, data: { ciphertext: 'sealed-blob' } }), + unsealFn: () => Buffer.from(JSON.stringify(payload), 'utf8'), + sanitizeSecretsFn: (raw) => raw as RunnerSecretsPayload, + applySecretsFn: (envPath, secrets) => { + applyCalls.push({ envPath, secrets: secrets as Record<string, string> }) + }, + }) + await handle.stop() + try { + expect(applyCalls).toEqual([ + { + envPath: expect.any(String), + secrets: { CLAUDE_CODE_OAUTH_TOKEN: 'secret-token-value' }, + }, + ]) + expect(process.env.CLAUDE_CODE_OAUTH_TOKEN).toBe('secret-token-value') + expect(lines.some((l) => l.includes('CLAUDE_CODE_OAUTH_TOKEN'))).toBe(true) + expect(lines.some((l) => l.includes('secret-token-value'))).toBe(false) + } finally { + if (previousToken === undefined) { + delete process.env.CLAUDE_CODE_OAUTH_TOKEN + } else { + process.env.CLAUDE_CODE_OAUTH_TOKEN = previousToken + } + } + }) + + test('an undecryptable blob logs a warning and mutates nothing', async () => { + saveGlobalConfig({ + ...loadGlobalConfig(), + syncUrl: 'https://hub.example', + syncWorkspaceId: 'ws1', + syncSecret: 'sec1', + }) + initRepo(cwd, 'https://github.com/o/r.git') + const manager = fakeManager({ cwd }) + const lines: string[] = [] + const applyCalls: unknown[] = [] + const handle = startRunnerDaemon({ + manager, + cwd, + fetchImpl: fetchStub(200, { requests: [], tickets: [] }, []), + logFn: (line) => lines.push(line), + loadIdentityFn: () => fakeRunnerIdentity, + claimSecretFn: async () => ({ ok: true, data: { ciphertext: 'sealed-blob' } }), + unsealFn: () => null, + applySecretsFn: (...args) => { + applyCalls.push(args) + }, + }) + await handle.stop() + expect(applyCalls.length).toBe(0) + expect(lines.some((l) => l.includes('could not decrypt'))).toBe(true) + }) + + test('a payload that fails validation logs a warning and mutates nothing', async () => { + saveGlobalConfig({ + ...loadGlobalConfig(), + syncUrl: 'https://hub.example', + syncWorkspaceId: 'ws1', + syncSecret: 'sec1', + }) + initRepo(cwd, 'https://github.com/o/r.git') + const manager = fakeManager({ cwd }) + const lines: string[] = [] + const applyCalls: unknown[] = [] + const handle = startRunnerDaemon({ + manager, + cwd, + fetchImpl: fetchStub(200, { requests: [], tickets: [] }, []), + logFn: (line) => lines.push(line), + loadIdentityFn: () => fakeRunnerIdentity, + claimSecretFn: async () => ({ ok: true, data: { ciphertext: 'sealed-blob' } }), + unsealFn: () => Buffer.from('{"not":"a payload"}', 'utf8'), + sanitizeSecretsFn: () => null, + applySecretsFn: (...args) => { + applyCalls.push(args) + }, + }) + await handle.stop() + expect(applyCalls.length).toBe(0) + expect(lines.some((l) => l.includes('failed validation'))).toBe(true) + }) + + test('a network failure claiming the secret does not crash the tick', async () => { + saveGlobalConfig({ + ...loadGlobalConfig(), + syncUrl: 'https://hub.example', + syncWorkspaceId: 'ws1', + syncSecret: 'sec1', + }) + initRepo(cwd, 'https://github.com/o/r.git') + const manager = fakeManager({ cwd }) + const lines: string[] = [] + const handle = startRunnerDaemon({ + manager, + cwd, + fetchImpl: fetchStub(200, { requests: [], tickets: [] }, []), + logFn: (line) => lines.push(line), + loadIdentityFn: () => fakeRunnerIdentity, + claimSecretFn: (): Promise<HubResult<{ ciphertext: string } | null>> => + Promise.resolve({ ok: false, error: { kind: 'network' } }), + }) + await handle.stop() + expect(lines.some((l) => l.includes('tick failed'))).toBe(false) + }) + }) }) diff --git a/packages/cli/src/brain-daemon.ts b/packages/cli/src/runner-daemon.ts similarity index 67% rename from packages/cli/src/brain-daemon.ts rename to packages/cli/src/runner-daemon.ts index 6fd83b6..313f0a4 100644 --- a/packages/cli/src/brain-daemon.ts +++ b/packages/cli/src/runner-daemon.ts @@ -1,29 +1,34 @@ -// Background loop for `codesema workspace --brain` / `codesema brain serve`: +// Background loop for `codesema workspace --runner` / `codesema runner serve`: // on the SAME TaskManager and process as the local web server, it flushes -// anything task-brain.ts's outbox could not send earlier, drafts and submits -// any ticket request the brain has queued for this repo, and — once the +// anything task-hub.ts's outbox could not send earlier, drafts and submits +// any ticket request the hub has queued for this repo, and — once the // workspace has no task already running here — claims the next published // ticket and hands it to the task manager exactly as if it had been typed by -// hand. A claimed brain-ticket task is kept alive with a heartbeat every 45s, +// hand. A claimed hub-ticket task is kept alive with a heartbeat every 45s, // on its own schedule, independent of the main tick's backoff, and a // decision a human made from the dashboard while that ticket sat waiting // (D19) rides back on that same heartbeat and is applied here: ship, reply, // or abandon. +import { runnerEnvPath } from './config.js' +import { isActiveTaskStatus, type ArmOrder, type ArmTicketRequest } from './contract.js' import { - brainRemoteUrl, + claimPendingSecret, claimTicket, claimTicketRequest, + hubRemoteUrl, listTicketRequests, listTickets, -} from './brain-client.js' -import { draftAndSubmitTicketRequest } from './brain-draft.js' -import { isActiveTaskStatus, type ArmOrder, type ArmTicketRequest } from './contract.js' +} from './hub-client.js' +import { loadRunnerIdentity } from './runner-identity.js' +import { applySecretsToEnvFile, sanitizeRunnerSecretsPayload } from './runner-secrets.js' +import { unseal } from './sealed-box.js' import { loadSyncCredentials, type SyncCredentials } from './sync.js' -import { createBrainTicketTask } from './task-brain-ticket.js' -import { flushBrainOutbox, heartbeatBrainTicket } from './task-brain.js' +import { createHubTicketTask } from './task-hub-ticket.js' +import { flushHubOutbox, heartbeatHubTicket } from './task-hub.js' import type { TaskActionResult } from './task-runner.js' import type { TaskManager } from './task-server.js' +import { draftAndSubmitTicketRequest } from './ticket-draft.js' const DEFAULT_INTERVAL_MS = 25_000 const MAX_BACKOFF_MS = 5 * 60_000 @@ -47,6 +52,11 @@ type DaemonContext = { log: (line: string) => void /** Once per distinct `key` for the whole daemon lifetime, never per tick. */ logOnce: (key: string, line: string) => void + loadIdentityFn: typeof loadRunnerIdentity + claimSecretFn: typeof claimPendingSecret + unsealFn: typeof unseal + sanitizeSecretsFn: typeof sanitizeRunnerSecretsPayload + applySecretsFn: typeof applySecretsToEnvFile } /** Whether this half-tick saw a network failure or a 5xx: the ONLY conditions that back off the next tick. */ @@ -66,7 +76,7 @@ async function draftQueuedRequests( const result = await listTicketRequests(creds, remoteUrl, ctx.fetchImpl) if (!result.ok) { if (result.error.kind === 'unavailable') { - ctx.logOnce('requests-unavailable', 'this brain has no ticket-requests route yet; skipping') + ctx.logOnce('requests-unavailable', 'this hub has no ticket-requests route yet; skipping') return 'ok' } return isRetryable(result.error) ? 'retryable' : 'ok' @@ -107,23 +117,23 @@ async function claimNextTicket( if (!entry) { ctx.logOnce( 'no-project', - 'brain mode is on but this directory is not a registered project; not claiming', + 'runner mode is on but this directory is not a registered project; not claiming', ) return 'ok' } - // A brain task parked on 'interrupted' (daemon killed mid-turn, machine + // A hub-ticket task parked on 'interrupted' (daemon killed mid-turn, machine // rebooted) would otherwise freeze the loop forever: the guard below blocks // new claims while nothing human ever resumes it. 24/7 means the daemon is // that resumer for its own tickets; human-created tasks keep their manual // resume affordance untouched. const interrupted = entry.records.find( - (record) => record.status === 'interrupted' && record.brain_ticket, + (record) => record.status === 'interrupted' && record.hub_ticket, ) if (interrupted) { const outcome = ctx.manager.resume(entry.project.id, interrupted.id) if (outcome.ok) { ctx.log( - `resumed interrupted task ${interrupted.id} for ticket ${interrupted.brain_ticket?.id ?? ''}`, + `resumed interrupted task ${interrupted.id} for ticket ${interrupted.hub_ticket?.id ?? ''}`, ) return 'ok' } @@ -148,7 +158,7 @@ async function claimNextTicket( const result = await listTickets(creds, remoteUrl, 'published', ctx.fetchImpl) if (!result.ok) { if (result.error.kind === 'unavailable') { - ctx.logOnce('tickets-unavailable', 'this brain has no tickets route yet; skipping') + ctx.logOnce('tickets-unavailable', 'this hub has no tickets route yet; skipping') return 'ok' } return isRetryable(result.error) ? 'retryable' : 'ok' @@ -158,9 +168,7 @@ async function claimNextTicket( return 'ok' } if ( - entry.records.some( - (record) => record.brain_ticket?.id === next.id && record.status !== 'failed', - ) + entry.records.some((record) => record.hub_ticket?.id === next.id && record.status !== 'failed') ) { ctx.logOnce( `ticket-${next.id}`, @@ -175,7 +183,7 @@ async function claimNextTicket( } return isRetryable(claim.error) ? 'retryable' : 'ok' } - const created = await createBrainTicketTask(ctx.manager, ctx.cwd, claim.data.ticket) + const created = await createHubTicketTask(ctx.manager, ctx.cwd, claim.data.ticket) ctx.log( created.ok ? `started task ${created.record.id} from ticket ${next.id}` @@ -184,20 +192,77 @@ async function claimNextTicket( return 'ok' } +/** + * Claims and applies whatever secret the hub is holding for this machine's + * runner identity, as steady-state rotation, distinct from the one-time + * provisioning at `codesema runner connect`. Every failure mode short of a + * programming error degrades to a no-op: no local identity yet (the machine + * never registered as a runner), no pending secret (the routine case on + * almost every tick), an unreadable blob (undecryptable, not valid JSON, or + * failing payload validation), or a network failure (the main tick's own + * backoff already covers reachability, so this stays silent rather than + * doubling up on it). Only the unreadable-blob failures are worth a log + * line, since those point at a misconfigured hub or runner rather than + * routine network flakiness. + */ +async function checkPendingSecretRotation( + ctx: DaemonContext, + creds: SyncCredentials, +): Promise<void> { + const identity = ctx.loadIdentityFn() + if (!identity) { + ctx.logOnce( + 'no-runner-identity', + 'runner mode is on but this machine has no runner identity yet', + ) + return + } + const claimed = await ctx.claimSecretFn(creds, identity.fingerprint, ctx.fetchImpl) + if (!claimed.ok || !claimed.data) { + return + } + const plaintext = ctx.unsealFn(identity.privateKey, claimed.data.ciphertext) + if (!plaintext) { + ctx.log('could not decrypt the pending runner secret; skipping this rotation') + return + } + let parsedPlaintext: unknown + try { + parsedPlaintext = JSON.parse(plaintext.toString('utf8')) + } catch { + ctx.log('the decrypted runner secret is not valid JSON; skipping this rotation') + return + } + const payload = ctx.sanitizeSecretsFn(parsedPlaintext) + if (!payload) { + ctx.log('the decrypted runner secret failed validation; skipping this rotation') + return + } + ctx.applySecretsFn(runnerEnvPath(), payload.secrets) + Object.assign(process.env, payload.secrets) + const appliedKeys = Object.keys(payload.secrets) + if (appliedKeys.length > 0) { + ctx.log(`applied rotated runner secret(s): ${appliedKeys.join(', ')}`) + } +} + async function tick(ctx: DaemonContext): Promise<TickOutcome> { - await flushBrainOutbox(ctx.cwd, ctx.fetchImpl) + await flushHubOutbox(ctx.cwd, ctx.fetchImpl) const creds = loadSyncCredentials() if (!creds) { ctx.logOnce( 'not-connected', - 'brain mode is on but not connected (run `codesema brain connect`)', + 'runner mode is on but not connected (run `codesema runner connect`)', ) return 'ok' } - const remoteUrl = brainRemoteUrl(ctx.cwd) + + await checkPendingSecretRotation(ctx, creds) + + const remoteUrl = hubRemoteUrl(ctx.cwd) if (!remoteUrl) { - ctx.logOnce('no-remote', 'brain mode is on but this workspace has no git origin remote') + ctx.logOnce('no-remote', 'runner mode is on but this workspace has no git origin remote') return 'ok' } @@ -226,7 +291,7 @@ function dispatchArmOrder( * waiting (D19): ship, reply with the human's instruction, or abandon. A * refusal from the manager (its own status guards: already shipped, a ship * already in flight, and so on) is JOURNALED here, never retried: the same - * order rides the next heartbeat only if the brain still has it to hand + * order rides the next heartbeat only if the hub still has it to hand * back, and a manager guard is what keeps a duplicate delivery from * double-applying anything. */ @@ -260,12 +325,12 @@ async function heartbeatTick(ctx: DaemonContext): Promise<void> { return } const found = entry.records.find( - (record) => record.brain_ticket && isActiveTaskStatus(record.status), + (record) => record.hub_ticket && isActiveTaskStatus(record.status), ) if (!found) { return } - const order = await heartbeatBrainTicket(ctx.cwd, found, found.status, ctx.fetchImpl) + const order = await heartbeatHubTicket(ctx.cwd, found, found.status, ctx.fetchImpl) if (order) { await applyArmOrder(ctx, entry.project.id, found.id, order) } @@ -289,9 +354,9 @@ function sleep(ms: number, signal: AbortSignal): Promise<void> { }) } -export type BrainDaemonHandle = { stop: () => Promise<void> } +export type RunnerDaemonHandle = { stop: () => Promise<void> } -export type StartBrainDaemonOptions = { +export type StartRunnerDaemonOptions = { manager: TaskManager cwd: string intervalMs?: number @@ -302,14 +367,29 @@ export type StartBrainDaemonOptions = { draftFn?: DraftRequestFn /** Test seam: an injectable, abortable sleep instead of the real timers. */ sleepFn?: (ms: number, signal: AbortSignal) => Promise<void> + /** Test seam. */ + loadIdentityFn?: typeof loadRunnerIdentity + /** Test seam. */ + claimSecretFn?: typeof claimPendingSecret + /** Test seam. */ + unsealFn?: typeof unseal + /** Test seam. */ + sanitizeSecretsFn?: typeof sanitizeRunnerSecretsPayload + /** Test seam. */ + applySecretsFn?: typeof applySecretsToEnvFile } -export function startBrainDaemon(opts: StartBrainDaemonOptions): BrainDaemonHandle { +export function startRunnerDaemon(opts: StartRunnerDaemonOptions): RunnerDaemonHandle { const intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS const fetchImpl = opts.fetchImpl ?? fetch - const log = opts.logFn ?? ((line: string) => console.log(`[brain] ${line}`)) + const log = opts.logFn ?? ((line: string) => console.log(`[runner] ${line}`)) const draftFn = opts.draftFn ?? draftAndSubmitTicketRequest const sleepFn = opts.sleepFn ?? sleep + const loadIdentityFn = opts.loadIdentityFn ?? loadRunnerIdentity + const claimSecretFn = opts.claimSecretFn ?? claimPendingSecret + const unsealFn = opts.unsealFn ?? unseal + const sanitizeSecretsFn = opts.sanitizeSecretsFn ?? sanitizeRunnerSecretsPayload + const applySecretsFn = opts.applySecretsFn ?? applySecretsToEnvFile const controller = new AbortController() const loggedOnce = new Set<string>() @@ -326,6 +406,11 @@ export function startBrainDaemon(opts: StartBrainDaemonOptions): BrainDaemonHand loggedOnce.add(key) log(line) }, + loadIdentityFn, + claimSecretFn, + unsealFn, + sanitizeSecretsFn, + applySecretsFn, } let backoffMs = intervalMs diff --git a/packages/cli/src/runner-identity.test.ts b/packages/cli/src/runner-identity.test.ts new file mode 100644 index 0000000..868071d --- /dev/null +++ b/packages/cli/src/runner-identity.test.ts @@ -0,0 +1,100 @@ +import { chmodSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { + loadOrCreateRunnerIdentity, + loadRunnerIdentity, + runnerIdentityHeader, +} from './runner-identity.js' + +describe('runner identity', () => { + const previousConfigDir = process.env.CODESEMA_CONFIG_DIR + let configDir: string + + beforeEach(() => { + configDir = mkdtempSync(join(tmpdir(), 'codesema-runner-identity-')) + process.env.CODESEMA_CONFIG_DIR = configDir + }) + + afterEach(() => { + if (previousConfigDir === undefined) { + delete process.env.CODESEMA_CONFIG_DIR + } else { + process.env.CODESEMA_CONFIG_DIR = previousConfigDir + } + rmSync(configDir, { recursive: true, force: true }) + }) + + test('loadRunnerIdentity never creates a file', () => { + expect(loadRunnerIdentity()).toBeNull() + expect(() => statSync(join(configDir, 'runner-identity.json'))).toThrow() + }) + + test('runnerIdentityHeader is empty before any identity exists', () => { + expect(runnerIdentityHeader()).toEqual({}) + }) + + test('loadOrCreateRunnerIdentity creates a 32-byte X25519 identity', () => { + const identity = loadOrCreateRunnerIdentity() + expect(identity.publicKey.length).toBe(32) + expect(identity.privateKey.length).toBe(32) + expect(identity.fingerprint).toMatch(/^[0-9a-f]{64}$/) + }) + + test('is idempotent: a second call returns the same identity', () => { + const first = loadOrCreateRunnerIdentity() + const second = loadOrCreateRunnerIdentity() + expect(second.fingerprint).toBe(first.fingerprint) + expect(second.publicKey.equals(first.publicKey)).toBe(true) + expect(second.privateKey.equals(first.privateKey)).toBe(true) + }) + + test('loadRunnerIdentity reads back what loadOrCreateRunnerIdentity wrote', () => { + const created = loadOrCreateRunnerIdentity() + const loaded = loadRunnerIdentity() + expect(loaded?.fingerprint).toBe(created.fingerprint) + }) + + test('the identity file is created with owner-only permissions', () => { + loadOrCreateRunnerIdentity() + const mode = statSync(join(configDir, 'runner-identity.json')).mode & 0o777 + expect(mode).toBe(0o600) + }) + + test('runnerIdentityHeader carries the fingerprint once an identity exists', () => { + const identity = loadOrCreateRunnerIdentity() + expect(runnerIdentityHeader()).toEqual({ 'x-codesema-runner': identity.fingerprint }) + }) + + test('a corrupted identity file makes loadRunnerIdentity return null', () => { + const path = join(configDir, 'runner-identity.json') + writeFileSync(path, 'not valid json{{{') + chmodSync(path, 0o600) + expect(loadRunnerIdentity()).toBeNull() + }) + + test('loadOrCreateRunnerIdentity regenerates over a corrupted file', () => { + const path = join(configDir, 'runner-identity.json') + writeFileSync(path, 'not valid json{{{') + chmodSync(path, 0o600) + const identity = loadOrCreateRunnerIdentity() + expect(identity.publicKey.length).toBe(32) + expect(loadRunnerIdentity()?.fingerprint).toBe(identity.fingerprint) + }) + + test('a well-formed but wrong-length key in the file is treated as corrupted', () => { + const path = join(configDir, 'runner-identity.json') + writeFileSync( + path, + JSON.stringify({ + v: 1, + public_key: Buffer.alloc(10).toString('base64'), + private_key: Buffer.alloc(32).toString('base64'), + created_at: new Date().toISOString(), + }), + ) + chmodSync(path, 0o600) + expect(loadRunnerIdentity()).toBeNull() + }) +}) diff --git a/packages/cli/src/runner-identity.ts b/packages/cli/src/runner-identity.ts new file mode 100644 index 0000000..c1dae5b --- /dev/null +++ b/packages/cli/src/runner-identity.ts @@ -0,0 +1,73 @@ +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { globalConfigDir } from './config.js' +import { generateRunnerKeyPair, runnerKeyFingerprint } from './sealed-box.js' + +export type RunnerIdentity = { + publicKey: Buffer + privateKey: Buffer + fingerprint: string +} + +type StoredRunnerIdentity = { + v: 1 + public_key: string + private_key: string + created_at: string +} + +const RAW_KEY_LENGTH = 32 + +function runnerIdentityPath(): string { + return join(globalConfigDir(), 'runner-identity.json') +} + +export function loadRunnerIdentity(): RunnerIdentity | null { + const path = runnerIdentityPath() + if (!existsSync(path)) { + return null + } + try { + const raw = JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown> + if (raw.v !== 1 || typeof raw.public_key !== 'string' || typeof raw.private_key !== 'string') { + return null + } + const publicKey = Buffer.from(raw.public_key, 'base64') + const privateKey = Buffer.from(raw.private_key, 'base64') + if (publicKey.length !== RAW_KEY_LENGTH || privateKey.length !== RAW_KEY_LENGTH) { + return null + } + return { publicKey, privateKey, fingerprint: runnerKeyFingerprint(publicKey) } + } catch { + return null + } +} + +export function loadOrCreateRunnerIdentity(): RunnerIdentity { + const existing = loadRunnerIdentity() + if (existing) { + return existing + } + + const { publicKey, privateKey } = generateRunnerKeyPair() + const stored: StoredRunnerIdentity = { + v: 1, + public_key: publicKey.toString('base64'), + private_key: privateKey.toString('base64'), + created_at: new Date().toISOString(), + } + + mkdirSync(globalConfigDir(), { recursive: true }) + const path = runnerIdentityPath() + writeFileSync(path, `${JSON.stringify(stored, null, 2)}\n`, { mode: 0o600 }) + // The mode option above only applies when writeFileSync creates the file; + // re-tighten explicitly in case it overwrote a pre-existing, laxer file. + chmodSync(path, 0o600) + + return { publicKey, privateKey, fingerprint: runnerKeyFingerprint(publicKey) } +} + +export function runnerIdentityHeader(): Record<string, string> { + const identity = loadRunnerIdentity() + return identity ? { 'x-codesema-runner': identity.fingerprint } : {} +} diff --git a/packages/cli/src/runner-pidfile.test.ts b/packages/cli/src/runner-pidfile.test.ts new file mode 100644 index 0000000..ffef7eb --- /dev/null +++ b/packages/cli/src/runner-pidfile.test.ts @@ -0,0 +1,164 @@ +import { spawnSync } from 'node:child_process' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { + readRunnerPidfile, + removeRunnerPidfile, + runnerPidfilePath, + writeRunnerPidfile, +} from './runner-pidfile.js' + +// Repo-local, unlike workspace.lock: no CODESEMA_CONFIG_DIR redirection +// needed, just a throwaway cwd per test. + +let cwd: string + +beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), 'codesema-runner-pidfile-')) +}) + +afterEach(() => { + rmSync(cwd, { recursive: true, force: true }) +}) + +/** A pid that is certainly dead: a child that already ran to completion. */ +function deadPid(): number { + const child = spawnSync('true') + expect(child.pid).toBeGreaterThan(0) + return child.pid +} + +function legacyPidfilePath(dir: string): string { + return join(dir, '.codesema', 'brain.pid') +} + +describe('runnerPidfilePath', () => { + test('is repo-local, under .codesema', () => { + expect(runnerPidfilePath(cwd)).toBe(join(cwd, '.codesema', 'runner.pid')) + }) +}) + +describe('readRunnerPidfile / writeRunnerPidfile', () => { + test('absent file reads as null', () => { + expect(readRunnerPidfile(cwd)).toBeNull() + }) + + test('round-trips pid, port and an ISO started_at, creating .codesema/', () => { + writeRunnerPidfile(cwd, 4242, 4400) + const pidfile = readRunnerPidfile(cwd) + if (!pidfile) { + throw new Error('expected a pidfile') + } + expect(pidfile.pid).toBe(4242) + expect(pidfile.port).toBe(4400) + expect(new Date(pidfile.started_at).toISOString()).toBe(pidfile.started_at) + }) + + test('a second write overwrites the first in place', () => { + writeRunnerPidfile(cwd, 1, 4400) + writeRunnerPidfile(cwd, 2, 4401) + expect(readRunnerPidfile(cwd)).toMatchObject({ pid: 2, port: 4401 }) + }) + + test('a corrupt or half-written file reads as null, never throws', () => { + mkdirSync(join(cwd, '.codesema'), { recursive: true }) + writeFileSync(runnerPidfilePath(cwd), '{"pid": 12') + expect(readRunnerPidfile(cwd)).toBeNull() + }) + + test('non-integer pid/port or a non-string started_at all read as null', () => { + mkdirSync(join(cwd, '.codesema'), { recursive: true }) + writeFileSync( + runnerPidfilePath(cwd), + JSON.stringify({ pid: 'x', port: 4400, started_at: '2026-01-01T00:00:00.000Z' }), + ) + expect(readRunnerPidfile(cwd)).toBeNull() + writeFileSync( + runnerPidfilePath(cwd), + JSON.stringify({ pid: 1, port: 4400.5, started_at: '2026-01-01T00:00:00.000Z' }), + ) + expect(readRunnerPidfile(cwd)).toBeNull() + writeFileSync(runnerPidfilePath(cwd), JSON.stringify({ pid: 1, port: 4400, started_at: 123 })) + expect(readRunnerPidfile(cwd)).toBeNull() + }) +}) + +describe('removeRunnerPidfile', () => { + test('removes our own pidfile by default (process.pid)', () => { + writeRunnerPidfile(cwd, process.pid, 4400) + removeRunnerPidfile(cwd) + expect(existsSync(runnerPidfilePath(cwd))).toBe(false) + }) + + test('never removes a pidfile naming a DIFFERENT pid than the default (process.pid)', () => { + writeRunnerPidfile(cwd, deadPid(), 4400) + removeRunnerPidfile(cwd) + expect(existsSync(runnerPidfilePath(cwd))).toBe(true) + }) + + test('removes a foreign pid when it is passed explicitly (runnerStop/runnerStatus cleanup)', () => { + const pid = deadPid() + writeRunnerPidfile(cwd, pid, 4400) + removeRunnerPidfile(cwd, pid) + expect(existsSync(runnerPidfilePath(cwd))).toBe(false) + }) + + test('does not remove when the explicit pid no longer matches the file (raced by a fresh write)', () => { + const stale = deadPid() + writeRunnerPidfile(cwd, stale, 4400) + writeRunnerPidfile(cwd, process.pid, 4401) // a new daemon boot took over the file + removeRunnerPidfile(cwd, stale) + expect(readRunnerPidfile(cwd)).toMatchObject({ pid: process.pid, port: 4401 }) + }) + + test('is a no-op, never throws, when there is nothing to remove', () => { + expect(() => removeRunnerPidfile(cwd)).not.toThrow() + expect(existsSync(runnerPidfilePath(cwd))).toBe(false) + }) +}) + +describe('legacy brain.pid migration', () => { + test('a legacy brain.pid is renamed to runner.pid on first read, and reads back correctly', () => { + mkdirSync(join(cwd, '.codesema'), { recursive: true }) + writeFileSync( + legacyPidfilePath(cwd), + JSON.stringify({ pid: 777, port: 4402, started_at: '2026-01-01T00:00:00.000Z' }), + ) + expect(readRunnerPidfile(cwd)).toMatchObject({ pid: 777, port: 4402 }) + expect(existsSync(legacyPidfilePath(cwd))).toBe(false) + expect(existsSync(runnerPidfilePath(cwd))).toBe(true) + }) + + test('a legacy brain.pid is also migrated on first write, not just on read', () => { + mkdirSync(join(cwd, '.codesema'), { recursive: true }) + writeFileSync( + legacyPidfilePath(cwd), + JSON.stringify({ pid: 777, port: 4402, started_at: '2026-01-01T00:00:00.000Z' }), + ) + writeRunnerPidfile(cwd, 888, 4403) + expect(existsSync(legacyPidfilePath(cwd))).toBe(false) + expect(readRunnerPidfile(cwd)).toMatchObject({ pid: 888, port: 4403 }) + }) + + test('runner.pid wins when both exist, and the legacy file is left untouched', () => { + mkdirSync(join(cwd, '.codesema'), { recursive: true }) + writeFileSync( + legacyPidfilePath(cwd), + JSON.stringify({ pid: 111, port: 4404, started_at: '2026-01-01T00:00:00.000Z' }), + ) + writeFileSync( + runnerPidfilePath(cwd), + JSON.stringify({ pid: 222, port: 4405, started_at: '2026-01-01T00:00:00.000Z' }), + ) + expect(readRunnerPidfile(cwd)).toMatchObject({ pid: 222, port: 4405 }) + expect(existsSync(legacyPidfilePath(cwd))).toBe(true) + }) + + test('with neither file present, reading is still a plain null, no file created', () => { + expect(readRunnerPidfile(cwd)).toBeNull() + expect(existsSync(legacyPidfilePath(cwd))).toBe(false) + expect(existsSync(runnerPidfilePath(cwd))).toBe(false) + }) +}) diff --git a/packages/cli/src/runner-pidfile.ts b/packages/cli/src/runner-pidfile.ts new file mode 100644 index 0000000..81d8beb --- /dev/null +++ b/packages/cli/src/runner-pidfile.ts @@ -0,0 +1,92 @@ +// Repo-local pidfile for the runner daemon (D21): <cwd>/.codesema/runner.pid, +// distinct from the machine-wide <globalConfigDir()>/workspace.lock +// (workspace-lock.ts). The two answer different questions: the workspace +// lock guarantees ONE workspace process per machine, while this file just +// lets `codesema runner stop`/`runner status`, run later from a different +// process, find the daemon this repo is running, whether it was started +// attached (`codesema runner serve`, a systemd unit) or detached (`--detach`). +// +// Same self-healing doctrine as workspace-lock.ts: a pid nothing is holding +// anymore (a crash, or a SIGKILL with no shutdown handler run) is never a +// permanent blocker. Unlike the workspace lock there is no "acquire" step to +// steal: writing always overwrites, and callers that read a dead pid clean +// up the file themselves (see runner-commands.ts's `runnerStop`/`runnerStatus`). + +import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' + +export type RunnerPidfile = { pid: number; port: number; started_at: string } + +export function runnerPidfilePath(cwd: string): string { + return join(cwd, '.codesema', 'runner.pid') +} + +function legacyPidfilePath(cwd: string): string { + return join(cwd, '.codesema', 'brain.pid') +} + +// Pre-rename `brain.pid` is migrated to `runner.pid` on first access; failure leaves the legacy file in place. +function migrateLegacyPidfile(cwd: string): void { + const legacyPath = legacyPidfilePath(cwd) + const path = runnerPidfilePath(cwd) + if (existsSync(legacyPath) && !existsSync(path)) { + try { + renameSync(legacyPath, path) + } catch { + // Best-effort: an unwritable directory just leaves the legacy file in place. + } + } +} + +/** Parsed pidfile, or null when absent/corrupt (both mean: nothing holds it). */ +export function readRunnerPidfile(cwd: string): RunnerPidfile | null { + migrateLegacyPidfile(cwd) + let raw: unknown + try { + raw = JSON.parse(readFileSync(runnerPidfilePath(cwd), 'utf8')) + } catch { + return null + } + const pidfile = raw as { pid?: unknown; port?: unknown; started_at?: unknown } | null + if ( + !pidfile || + !Number.isInteger(pidfile.pid) || + !Number.isInteger(pidfile.port) || + typeof pidfile.started_at !== 'string' + ) { + return null + } + return { + pid: pidfile.pid as number, + port: pidfile.port as number, + started_at: pidfile.started_at, + } +} + +export function writeRunnerPidfile(cwd: string, pid: number, port: number): void { + migrateLegacyPidfile(cwd) + const path = runnerPidfilePath(cwd) + mkdirSync(dirname(path), { recursive: true }) + const content: RunnerPidfile = { pid, port, started_at: new Date().toISOString() } + writeFileSync(path, `${JSON.stringify(content)}\n`) +} + +/** + * Removes the pidfile iff it still names `expectedPid`: defaults to our own + * pid, the shutdown-handler case (mirrors `WorkspaceLockHandle.release()`). + * `runnerStop`/`runnerStatus` pass the pid they just found dead instead: those + * run in a THIRD process, so `process.pid` would never match, and the check + * still guards against unlinking a fresh pidfile a new daemon wrote in the + * gap between that read and this delete. Never throws. + */ +export function removeRunnerPidfile(cwd: string, expectedPid: number = process.pid): void { + try { + const path = runnerPidfilePath(cwd) + if (readRunnerPidfile(cwd)?.pid === expectedPid && existsSync(path)) { + unlinkSync(path) + } + } catch { + // Best-effort: a dead pid in a leftover pidfile is cleaned up next time + // runnerStop/runnerStatus reads it, or overwritten by the next boot anyway. + } +} diff --git a/packages/cli/src/runner-secrets.test.ts b/packages/cli/src/runner-secrets.test.ts new file mode 100644 index 0000000..615afba --- /dev/null +++ b/packages/cli/src/runner-secrets.test.ts @@ -0,0 +1,180 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, test } from 'bun:test' +import { applySecretsToEnvFile, sanitizeRunnerSecretsPayload } from './runner-secrets.js' + +describe('sanitizeRunnerSecretsPayload', () => { + test('accepts a full valid payload and trims values', () => { + const result = sanitizeRunnerSecretsPayload({ + v: 1, + secrets: { CLAUDE_CODE_OAUTH_TOKEN: ' token-a ', GH_TOKEN: 'token-b' }, + repo_url: ' https://example.com/repo.git ', + }) + expect(result).toEqual({ + v: 1, + secrets: { CLAUDE_CODE_OAUTH_TOKEN: 'token-a', GH_TOKEN: 'token-b' }, + repo_url: 'https://example.com/repo.git', + }) + }) + + test('accepts a partial payload with only one secret and no repo_url', () => { + const result = sanitizeRunnerSecretsPayload({ v: 1, secrets: { GH_TOKEN: 'token-b' } }) + expect(result).toEqual({ v: 1, secrets: { GH_TOKEN: 'token-b' } }) + expect(result?.repo_url).toBeUndefined() + }) + + test('drops an unrecognized secret key while keeping known ones', () => { + const result = sanitizeRunnerSecretsPayload({ + v: 1, + secrets: { GH_TOKEN: 'token-b', UNKNOWN_SECRET: 'x' }, + }) + expect(result).toEqual({ v: 1, secrets: { GH_TOKEN: 'token-b' } }) + }) + + test('rejects a missing v', () => { + expect(sanitizeRunnerSecretsPayload({ secrets: { GH_TOKEN: 'x' } })).toBeNull() + }) + + test('rejects a v that is not the number 1', () => { + expect(sanitizeRunnerSecretsPayload({ v: '1', secrets: { GH_TOKEN: 'x' } })).toBeNull() + expect(sanitizeRunnerSecretsPayload({ v: 2, secrets: { GH_TOKEN: 'x' } })).toBeNull() + }) + + test('rejects a payload with no non-empty secret', () => { + expect(sanitizeRunnerSecretsPayload({ v: 1, secrets: {} })).toBeNull() + expect(sanitizeRunnerSecretsPayload({ v: 1, secrets: { GH_TOKEN: ' ' } })).toBeNull() + }) + + test('rejects non-object input', () => { + expect(sanitizeRunnerSecretsPayload(null)).toBeNull() + expect(sanitizeRunnerSecretsPayload('nope')).toBeNull() + expect(sanitizeRunnerSecretsPayload(42)).toBeNull() + expect(sanitizeRunnerSecretsPayload(undefined)).toBeNull() + }) + + test('rejects a secrets value that is not an object', () => { + expect(sanitizeRunnerSecretsPayload({ v: 1, secrets: 'GH_TOKEN=x' })).toBeNull() + expect(sanitizeRunnerSecretsPayload({ v: 1, secrets: null })).toBeNull() + }) + + test('rejects a secret value with the wrong type', () => { + expect(sanitizeRunnerSecretsPayload({ v: 1, secrets: { GH_TOKEN: 12345 } })).toBeNull() + }) + + test('rejects a secret value over the length cap', () => { + expect( + sanitizeRunnerSecretsPayload({ v: 1, secrets: { GH_TOKEN: 'a'.repeat(4097) } }), + ).toBeNull() + expect( + sanitizeRunnerSecretsPayload({ v: 1, secrets: { GH_TOKEN: 'a'.repeat(4096) } }), + ).not.toBeNull() + }) + + test('rejects a repo_url over the length cap', () => { + const longUrl = `https://example.com/${'a'.repeat(2048)}` + expect( + sanitizeRunnerSecretsPayload({ v: 1, secrets: { GH_TOKEN: 'x' }, repo_url: longUrl }), + ).toBeNull() + }) + + test('rejects a repo_url with the wrong type', () => { + expect( + sanitizeRunnerSecretsPayload({ v: 1, secrets: { GH_TOKEN: 'x' }, repo_url: 123 }), + ).toBeNull() + }) + + test('rejects a secret value carrying an embedded newline (env-file injection guard)', () => { + expect( + sanitizeRunnerSecretsPayload({ + v: 1, + secrets: { GH_TOKEN: 'token\nEVIL_KEY=evil' }, + }), + ).toBeNull() + }) + + test('rejects a repo_url carrying an embedded control character', () => { + expect( + sanitizeRunnerSecretsPayload({ + v: 1, + secrets: { GH_TOKEN: 'x' }, + repo_url: 'https://example.com/\trepo.git', + }), + ).toBeNull() + }) +}) + +describe('applySecretsToEnvFile', () => { + const cleanups: string[] = [] + + afterEach(() => { + for (const dir of cleanups.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } + }) + + function makeDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'codesema-runner-secrets-')) + cleanups.push(dir) + return dir + } + + test('creates the file when it does not exist yet', () => { + const dir = makeDir() + const envPath = join(dir, 'runner.env') + applySecretsToEnvFile(envPath, { GH_TOKEN: 'abc' }) + expect(readFileSync(envPath, 'utf8')).toBe('GH_TOKEN=abc\n') + }) + + test('replaces only the passed keys and preserves the others', () => { + const dir = makeDir() + const envPath = join(dir, 'runner.env') + writeFileSync(envPath, 'GH_TOKEN=old\nOTHER_KEY=untouched\n') + applySecretsToEnvFile(envPath, { GH_TOKEN: 'new' }) + const contents = readFileSync(envPath, 'utf8') + expect(contents).toContain('GH_TOKEN=new') + expect(contents).toContain('OTHER_KEY=untouched') + expect(contents).not.toContain('GH_TOKEN=old') + }) + + test('adds a new key alongside existing ones', () => { + const dir = makeDir() + const envPath = join(dir, 'runner.env') + writeFileSync(envPath, 'OTHER_KEY=untouched\n') + applySecretsToEnvFile(envPath, { GH_TOKEN: 'abc' }) + const contents = readFileSync(envPath, 'utf8') + expect(contents).toContain('OTHER_KEY=untouched') + expect(contents).toContain('GH_TOKEN=abc') + }) + + test('a value containing "=" is preserved verbatim (split on the first "=" only)', () => { + const dir = makeDir() + const envPath = join(dir, 'runner.env') + applySecretsToEnvFile(envPath, { GH_TOKEN: 'abc==def' }) + expect(readFileSync(envPath, 'utf8')).toBe('GH_TOKEN=abc==def\n') + }) + + test('writes the file with owner-only permissions', () => { + const dir = makeDir() + const envPath = join(dir, 'runner.env') + applySecretsToEnvFile(envPath, { GH_TOKEN: 'abc' }) + const mode = statSync(envPath).mode & 0o777 + expect(mode).toBe(0o600) + }) + + test('re-tightens permissions when overwriting a pre-existing file', () => { + const dir = makeDir() + const envPath = join(dir, 'runner.env') + writeFileSync(envPath, 'GH_TOKEN=old\n', { mode: 0o644 }) + applySecretsToEnvFile(envPath, { GH_TOKEN: 'new' }) + const mode = statSync(envPath).mode & 0o777 + expect(mode).toBe(0o600) + }) + + test('never leaves the temporary file behind', () => { + const dir = makeDir() + const envPath = join(dir, 'runner.env') + applySecretsToEnvFile(envPath, { GH_TOKEN: 'abc' }) + expect(existsSync(`${envPath}.tmp`)).toBe(false) + }) +}) diff --git a/packages/cli/src/runner-secrets.ts b/packages/cli/src/runner-secrets.ts new file mode 100644 index 0000000..a2e52ec --- /dev/null +++ b/packages/cli/src/runner-secrets.ts @@ -0,0 +1,110 @@ +import { chmodSync, existsSync, readFileSync, writeFileSync } from 'node:fs' +import { nodeAtomicWriteIo, writeFileAtomic, type AtomicWriteIo } from './atomic-write.js' + +export type RunnerSecretsPayload = { + v: 1 + secrets: { + CLAUDE_CODE_OAUTH_TOKEN?: string + GH_TOKEN?: string + } + repo_url?: string +} + +const SECRET_KEYS = ['CLAUDE_CODE_OAUTH_TOKEN', 'GH_TOKEN'] as const +const SECRET_VALUE_MAX = 4096 +const REPO_URL_MAX = 2048 +// \p{Cc} covers every Unicode control character (newline, carriage return, +// tab, ...). A secret or URL carrying one is either corrupted or an attempt +// to inject extra lines into the KEY=value env file applySecretsToEnvFile +// writes to, so it is rejected rather than silently flattened. +const HAS_CONTROL_CHARACTERS = /\p{Cc}/u + +function sanitizeBoundedToken(value: unknown, max: number): string | null { + if (typeof value !== 'string') { + return null + } + const trimmed = value.trim() + if (!trimmed || trimmed.length > max || HAS_CONTROL_CHARACTERS.test(trimmed)) { + return null + } + return trimmed +} + +export function sanitizeRunnerSecretsPayload(raw: unknown): RunnerSecretsPayload | null { + if (!raw || typeof raw !== 'object') { + return null + } + const r = raw as Record<string, unknown> + if (r.v !== 1 || !r.secrets || typeof r.secrets !== 'object') { + return null + } + + const rawSecrets = r.secrets as Record<string, unknown> + const secrets: RunnerSecretsPayload['secrets'] = {} + for (const key of SECRET_KEYS) { + if (rawSecrets[key] === undefined) { + continue + } + const value = sanitizeBoundedToken(rawSecrets[key], SECRET_VALUE_MAX) + if (value === null) { + return null + } + secrets[key] = value + } + if (Object.keys(secrets).length === 0) { + return null + } + + let repoUrl: string | undefined + if (r.repo_url !== undefined) { + const value = sanitizeBoundedToken(r.repo_url, REPO_URL_MAX) + if (value === null) { + return null + } + repoUrl = value + } + + return { v: 1, secrets, ...(repoUrl !== undefined ? { repo_url: repoUrl } : {}) } +} + +function parseEnvFile(contents: string): Map<string, string> { + const entries = new Map<string, string>() + for (const rawLine of contents.split('\n')) { + const line = rawLine.trim() + if (!line || line.startsWith('#')) { + continue + } + const separatorIndex = line.indexOf('=') + if (separatorIndex === -1) { + continue + } + const key = line.slice(0, separatorIndex).trim() + if (!key) { + continue + } + entries.set(key, line.slice(separatorIndex + 1)) + } + return entries +} + +function serializeEnvFile(entries: Map<string, string>): string { + return `${Array.from(entries, ([key, value]) => `${key}=${value}`).join('\n')}\n` +} + +export function applySecretsToEnvFile(envPath: string, secrets: Record<string, string>): void { + const existingContents = existsSync(envPath) ? readFileSync(envPath, 'utf8') : '' + const entries = parseEnvFile(existingContents) + for (const [key, value] of Object.entries(secrets)) { + entries.set(key, value) + } + + const io: AtomicWriteIo = { + mkdir: nodeAtomicWriteIo.mkdir, + writeFile: (path, contents) => writeFileSync(path, contents, { mode: 0o600 }), + rename: nodeAtomicWriteIo.rename, + } + writeFileAtomic(envPath, serializeEnvFile(entries), io) + // Belt-and-suspenders: rename(2) carries the temp file's mode over on POSIX, + // but re-tighten explicitly rather than rely on that on every platform. + chmodSync(envPath, 0o600) +} diff --git a/packages/cli/src/brain-service.test.ts b/packages/cli/src/runner-service.test.ts similarity index 52% rename from packages/cli/src/brain-service.test.ts rename to packages/cli/src/runner-service.test.ts index b11dea1..e47d393 100644 --- a/packages/cli/src/brain-service.test.ts +++ b/packages/cli/src/runner-service.test.ts @@ -10,14 +10,14 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { t } from './i18n.js' import { - installBrainService, - renderBrainServiceUnit, + installRunnerService, + renderRunnerServiceUnit, systemdUnitPath, - uninstallBrainService, + uninstallRunnerService, type ExecCommandFn, -} from './brain-service.js' -import { t } from './i18n.js' +} from './runner-service.js' type Call = { command: string; args: readonly string[] } @@ -38,43 +38,43 @@ function throwingOn(command: string, calls: Call[]): ExecCommandFn { } } -describe('renderBrainServiceUnit', () => { +describe('renderRunnerServiceUnit', () => { test('pins the three per-install directives and keeps the static ones from the shipped template', () => { - const unit = renderBrainServiceUnit({ + const unit = renderRunnerServiceUnit({ workingDirectory: '/home/codesema/bench', - execStart: '/usr/lib/node_modules/codesema/dist/index.mjs brain serve', + execStart: '/usr/lib/node_modules/codesema/dist/index.mjs runner serve', environmentFile: null, }) expect(unit).toContain('[Unit]') expect(unit).toContain('[Service]') expect(unit).toContain('[Install]') expect(unit).toContain('WorkingDirectory=/home/codesema/bench') - expect(unit).toContain('ExecStart=/usr/lib/node_modules/codesema/dist/index.mjs brain serve') + expect(unit).toContain('ExecStart=/usr/lib/node_modules/codesema/dist/index.mjs runner serve') expect(unit).not.toContain('EnvironmentFile=') - expect(unit).toContain('Description=codesema brain daemon') + expect(unit).toContain('Description=codesema runner daemon') expect(unit).toContain('Restart=on-failure') expect(unit).toContain('RestartSec=5') expect(unit).toContain('WantedBy=default.target') }) test('includes EnvironmentFile= only when given', () => { - const unit = renderBrainServiceUnit({ + const unit = renderRunnerServiceUnit({ workingDirectory: '/repo', - execStart: '/bin/codesema brain serve', - environmentFile: '/etc/codesema/brain.env', + execStart: '/bin/codesema runner serve', + environmentFile: '/etc/codesema/runner.env', }) - expect(unit).toContain('EnvironmentFile=/etc/codesema/brain.env') + expect(unit).toContain('EnvironmentFile=/etc/codesema/runner.env') }) }) -describe('installBrainService / uninstallBrainService', () => { +describe('installRunnerService / uninstallRunnerService', () => { const previousXdg = process.env.XDG_CONFIG_HOME let xdgConfigHome: string let cwd: string beforeEach(() => { - xdgConfigHome = mkdtempSync(join(tmpdir(), 'codesema-brainsvc-xdg-')) - cwd = mkdtempSync(join(tmpdir(), 'codesema-brainsvc-cwd-')) + xdgConfigHome = mkdtempSync(join(tmpdir(), 'codesema-runnersvc-xdg-')) + cwd = mkdtempSync(join(tmpdir(), 'codesema-runnersvc-cwd-')) process.env.XDG_CONFIG_HOME = xdgConfigHome }) @@ -89,12 +89,14 @@ describe('installBrainService / uninstallBrainService', () => { }) test('systemdUnitPath honors XDG_CONFIG_HOME', () => { - expect(systemdUnitPath()).toBe(join(xdgConfigHome, 'systemd', 'user', 'codesema-brain.service')) + expect(systemdUnitPath()).toBe( + join(xdgConfigHome, 'systemd', 'user', 'codesema-runner.service'), + ) }) test('writes the unit, reloads, enables --now, then enables lingering, in that order', () => { const calls: Call[] = [] - const result = installBrainService({ + const result = installRunnerService({ workingDirectory: '/repo', cwd, execFn: recordingExecFn(calls), @@ -102,35 +104,35 @@ describe('installBrainService / uninstallBrainService', () => { expect(existsSync(result.unitPath)).toBe(true) expect(result.workingDirectory).toBe('/repo') - expect(result.execStart).toBe(`${realpathSync(process.argv[1] as string)} brain serve`) + expect(result.execStart).toBe(`${realpathSync(process.argv[1] as string)} runner serve`) expect(result.environmentFile).toBeNull() expect(result.lingerError).toBeNull() expect(calls).toEqual([ { command: 'systemctl', args: ['--version'] }, { command: 'systemctl', args: ['--user', 'daemon-reload'] }, - { command: 'systemctl', args: ['--user', 'enable', '--now', 'codesema-brain.service'] }, + { command: 'systemctl', args: ['--user', 'enable', '--now', 'codesema-runner.service'] }, { command: 'loginctl', args: ['enable-linger'] }, ]) }) test('resolves a relative --env-file against the given cwd, not process.cwd()', () => { - writeFileSync(join(cwd, 'brain.env'), 'GH_TOKEN=x\n') - const result = installBrainService({ + writeFileSync(join(cwd, 'runner.env'), 'GH_TOKEN=x\n') + const result = installRunnerService({ workingDirectory: '/repo', cwd, - envFile: 'brain.env', + envFile: 'runner.env', execFn: recordingExecFn([]), }) - expect(result.environmentFile).toBe(join(cwd, 'brain.env')) + expect(result.environmentFile).toBe(join(cwd, 'runner.env')) const written = readFileSync(result.unitPath, 'utf8') - expect(written).toContain(`EnvironmentFile=${join(cwd, 'brain.env')}`) + expect(written).toContain(`EnvironmentFile=${join(cwd, 'runner.env')}`) }) test('an absolute --env-file is used as-is', () => { - const envFile = join(xdgConfigHome, 'brain.env') + const envFile = join(xdgConfigHome, 'runner.env') writeFileSync(envFile, 'GH_TOKEN=x\n') - const result = installBrainService({ + const result = installRunnerService({ workingDirectory: '/repo', cwd, envFile, @@ -141,7 +143,7 @@ describe('installBrainService / uninstallBrainService', () => { test('a missing --env-file throws and writes nothing', () => { expect(() => - installBrainService({ + installRunnerService({ workingDirectory: '/repo', cwd, envFile: 'does-not-exist.env', @@ -154,12 +156,12 @@ describe('installBrainService / uninstallBrainService', () => { test('no systemctl on the machine: throws a clear error and writes nothing', () => { const calls: Call[] = [] expect(() => - installBrainService({ + installRunnerService({ workingDirectory: '/repo', cwd, execFn: throwingOn('systemctl', calls), }), - ).toThrow(t('brain.systemctlNotFound')) + ).toThrow(t('runner.systemctlNotFound')) expect(existsSync(systemdUnitPath())).toBe(false) expect(calls).toEqual([{ command: 'systemctl', args: ['--version'] }]) }) @@ -173,7 +175,7 @@ describe('installBrainService / uninstallBrainService', () => { } return '' } - const result = installBrainService({ workingDirectory: '/repo', cwd, execFn }) + const result = installRunnerService({ workingDirectory: '/repo', cwd, execFn }) expect(result.lingerError).toBe('Failed to connect to bus: No such file or directory') expect(existsSync(result.unitPath)).toBe(true) expect(calls.some((c) => c.command === 'systemctl' && c.args.includes('enable'))).toBe(true) @@ -181,30 +183,30 @@ describe('installBrainService / uninstallBrainService', () => { test('uninstall with no unit installed: idempotent no-op, no exec calls', () => { const calls: Call[] = [] - const result = uninstallBrainService({ execFn: recordingExecFn(calls) }) + const result = uninstallRunnerService({ execFn: recordingExecFn(calls) }) expect(result).toEqual({ removed: false, unitPath: systemdUnitPath() }) expect(calls).toEqual([]) }) test('uninstall removes an installed unit: disable --now, delete the file, then daemon-reload', () => { - installBrainService({ workingDirectory: '/repo', cwd, execFn: recordingExecFn([]) }) + installRunnerService({ workingDirectory: '/repo', cwd, execFn: recordingExecFn([]) }) const calls: Call[] = [] - const result = uninstallBrainService({ execFn: recordingExecFn(calls) }) + const result = uninstallRunnerService({ execFn: recordingExecFn(calls) }) expect(result.removed).toBe(true) expect(existsSync(result.unitPath)).toBe(false) expect(calls).toEqual([ { command: 'systemctl', args: ['--version'] }, - { command: 'systemctl', args: ['--user', 'disable', '--now', 'codesema-brain.service'] }, + { command: 'systemctl', args: ['--user', 'disable', '--now', 'codesema-runner.service'] }, { command: 'systemctl', args: ['--user', 'daemon-reload'] }, ]) }) test('uninstall of an existing unit with no systemctl: throws and leaves the unit file in place', () => { - installBrainService({ workingDirectory: '/repo', cwd, execFn: recordingExecFn([]) }) + installRunnerService({ workingDirectory: '/repo', cwd, execFn: recordingExecFn([]) }) const calls: Call[] = [] - expect(() => uninstallBrainService({ execFn: throwingOn('systemctl', calls) })).toThrow( - t('brain.systemctlNotFound'), + expect(() => uninstallRunnerService({ execFn: throwingOn('systemctl', calls) })).toThrow( + t('runner.systemctlNotFound'), ) expect(existsSync(systemdUnitPath())).toBe(true) }) @@ -216,8 +218,8 @@ describe('directory creation', () => { let cwd: string beforeEach(() => { - xdgConfigHome = mkdtempSync(join(tmpdir(), 'codesema-brainsvc-mkdir-')) - cwd = mkdtempSync(join(tmpdir(), 'codesema-brainsvc-mkdir-cwd-')) + xdgConfigHome = mkdtempSync(join(tmpdir(), 'codesema-runnersvc-mkdir-')) + cwd = mkdtempSync(join(tmpdir(), 'codesema-runnersvc-mkdir-cwd-')) process.env.XDG_CONFIG_HOME = xdgConfigHome }) @@ -233,16 +235,106 @@ describe('directory creation', () => { test('creates ~/.config/systemd/user when it does not exist yet', () => { expect(existsSync(join(xdgConfigHome, 'systemd'))).toBe(false) - installBrainService({ workingDirectory: '/repo', cwd, execFn: recordingExecFn([]) }) + installRunnerService({ workingDirectory: '/repo', cwd, execFn: recordingExecFn([]) }) expect(existsSync(systemdUnitPath())).toBe(true) }) test('overwrites a unit that already exists (re-running install-service after an upgrade)', () => { mkdirSync(join(xdgConfigHome, 'systemd', 'user'), { recursive: true }) writeFileSync(systemdUnitPath(), 'stale content') - installBrainService({ workingDirectory: '/new-repo', cwd, execFn: recordingExecFn([]) }) + installRunnerService({ workingDirectory: '/new-repo', cwd, execFn: recordingExecFn([]) }) const written = readFileSync(systemdUnitPath(), 'utf8') expect(written).toContain('WorkingDirectory=/new-repo') expect(written).not.toContain('stale content') }) }) + +describe('legacy codesema-brain.service purge', () => { + const previousXdg = process.env.XDG_CONFIG_HOME + let xdgConfigHome: string + let cwd: string + + function legacyUnitPath(): string { + return join(xdgConfigHome, 'systemd', 'user', 'codesema-brain.service') + } + + beforeEach(() => { + xdgConfigHome = mkdtempSync(join(tmpdir(), 'codesema-runnersvc-legacy-xdg-')) + cwd = mkdtempSync(join(tmpdir(), 'codesema-runnersvc-legacy-cwd-')) + process.env.XDG_CONFIG_HOME = xdgConfigHome + mkdirSync(join(xdgConfigHome, 'systemd', 'user'), { recursive: true }) + }) + + afterEach(() => { + rmSync(xdgConfigHome, { recursive: true, force: true }) + rmSync(cwd, { recursive: true, force: true }) + if (previousXdg === undefined) { + delete process.env.XDG_CONFIG_HOME + } else { + process.env.XDG_CONFIG_HOME = previousXdg + } + }) + + test('install disables and removes a pre-existing legacy unit before writing the new one', () => { + writeFileSync(legacyUnitPath(), 'stale legacy unit') + const calls: Call[] = [] + const result = installRunnerService({ + workingDirectory: '/repo', + cwd, + execFn: recordingExecFn(calls), + }) + + expect(existsSync(legacyUnitPath())).toBe(false) + expect(existsSync(result.unitPath)).toBe(true) + expect(calls).toEqual([ + { command: 'systemctl', args: ['--version'] }, + { command: 'systemctl', args: ['--user', 'disable', '--now', 'codesema-brain.service'] }, + { command: 'systemctl', args: ['--user', 'daemon-reload'] }, + { command: 'systemctl', args: ['--user', 'enable', '--now', 'codesema-runner.service'] }, + { command: 'loginctl', args: ['enable-linger'] }, + ]) + }) + + test('install with no legacy unit present never mentions codesema-brain.service', () => { + const calls: Call[] = [] + installRunnerService({ workingDirectory: '/repo', cwd, execFn: recordingExecFn(calls) }) + expect(calls.some((c) => c.args.includes('codesema-brain.service'))).toBe(false) + }) + + test('a legacy unit whose own disable fails is still removed, install still succeeds', () => { + writeFileSync(legacyUnitPath(), 'stale legacy unit') + const execFn: ExecCommandFn = (command, args) => { + if (command === 'systemctl' && args.includes('codesema-brain.service')) { + throw new Error('unit not loaded') + } + return '' + } + const result = installRunnerService({ workingDirectory: '/repo', cwd, execFn }) + expect(existsSync(legacyUnitPath())).toBe(false) + expect(existsSync(result.unitPath)).toBe(true) + }) + + test('uninstall purges both the current and the legacy unit in one call', () => { + installRunnerService({ workingDirectory: '/repo', cwd, execFn: recordingExecFn([]) }) + writeFileSync(legacyUnitPath(), 'stale legacy unit') + const calls: Call[] = [] + const result = uninstallRunnerService({ execFn: recordingExecFn(calls) }) + + expect(result.removed).toBe(true) + expect(existsSync(systemdUnitPath())).toBe(false) + expect(existsSync(legacyUnitPath())).toBe(false) + expect(calls).toEqual([ + { command: 'systemctl', args: ['--version'] }, + { command: 'systemctl', args: ['--user', 'disable', '--now', 'codesema-runner.service'] }, + { command: 'systemctl', args: ['--user', 'disable', '--now', 'codesema-brain.service'] }, + { command: 'systemctl', args: ['--user', 'daemon-reload'] }, + ]) + }) + + test('uninstall removes a lone legacy unit even when the current one was never installed', () => { + writeFileSync(legacyUnitPath(), 'stale legacy unit') + const result = uninstallRunnerService({ execFn: recordingExecFn([]) }) + expect(result.removed).toBe(true) + expect(existsSync(legacyUnitPath())).toBe(false) + }) +}) diff --git a/packages/cli/src/brain-service.ts b/packages/cli/src/runner-service.ts similarity index 67% rename from packages/cli/src/brain-service.ts rename to packages/cli/src/runner-service.ts index ebd84fd..efbd032 100644 --- a/packages/cli/src/brain-service.ts +++ b/packages/cli/src/runner-service.ts @@ -1,6 +1,6 @@ -// systemd --user lifecycle for the brain daemon: `codesema brain -// install-service` writes ~/.config/systemd/user/codesema-brain.service from -// the unit shipped at assets/systemd/codesema-brain.service — the asset is +// systemd --user lifecycle for the runner daemon: `codesema runner +// install-service` writes ~/.config/systemd/user/codesema-runner.service from +// the unit shipped at assets/systemd/codesema-runner.service — the asset is // resolved the same way serve.ts resolves its embedded `web-dist` (a URL // relative to this module's own bundled location) — then pins // WorkingDirectory/ExecStart to the repo and binary actually running this @@ -11,8 +11,8 @@ // Restart policy, …) is read back out of the shipped template so the // generated unit never drifts from the one documented in that file. // -// Logic only, no console output: brain-commands.ts renders the result, the -// same split brain-draft.ts's draftAndPublishTicket has with its own caller. +// Logic only, no console output: runner-commands.ts renders the result, the +// same split ticket-draft.ts's draftAndPublishTicket has with its own caller. import { execFileSync } from 'node:child_process' import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' @@ -22,12 +22,14 @@ import { fileURLToPath } from 'node:url' import { t } from './i18n.js' const UNIT_TEMPLATE_PATH = fileURLToPath( - new URL('../assets/systemd/codesema-brain.service', import.meta.url), + new URL('../assets/systemd/codesema-runner.service', import.meta.url), ) -const UNIT_NAME = 'codesema-brain.service' +const UNIT_NAME = 'codesema-runner.service' +/** Pre-rename unit name: purged on install (before the new unit is written) and on uninstall, so the two daemons never run side by side. */ +const LEGACY_UNIT_NAME = 'codesema-brain.service' -/** The one `execFileSync` shape actually needed here, pulled out as its own type for the same reason brain-commands.ts's `SpawnFn` is: a test fake has no reason to satisfy the real dozen-overload signature. */ +/** The one `execFileSync` shape actually needed here, pulled out as its own type for the same reason runner-commands.ts's `SpawnFn` is: a test fake has no reason to satisfy the real dozen-overload signature. */ export type ExecCommandFn = (command: string, args: readonly string[]) => string function realExec(command: string, args: readonly string[]): string { @@ -47,6 +49,23 @@ export function systemdUnitPath(): string { return join(systemdUserDir(), UNIT_NAME) } +function legacyUnitPath(): string { + return join(systemdUserDir(), LEGACY_UNIT_NAME) +} + +function purgeLegacyUnit(execFn: ExecCommandFn): void { + const path = legacyUnitPath() + if (!existsSync(path)) { + return + } + try { + execFn('systemctl', ['--user', 'disable', '--now', LEGACY_UNIT_NAME]) + } catch { + // Best-effort: the legacy unit's own systemctl commands failing must not block the rename. + } + rmSync(path) +} + /** * Directive lines only, keyed by name: comments, section headers and blank * lines are dropped. The three per-install directives are looked up here too @@ -70,7 +89,7 @@ function parseUnitDirectives(templateText: string): Map<string, string> { return directives } -export function renderBrainServiceUnit(input: { +export function renderRunnerServiceUnit(input: { workingDirectory: string execStart: string environmentFile: string | null @@ -78,13 +97,13 @@ export function renderBrainServiceUnit(input: { const directives = parseUnitDirectives(readFileSync(UNIT_TEMPLATE_PATH, 'utf8')) const get = (key: string, fallback: string): string => directives.get(key) ?? fallback const lines = [ - '# Generated by `codesema brain install-service`; re-run that command after', + '# Generated by `codesema runner install-service`; re-run that command after', '# upgrading codesema instead of hand-editing ExecStart below. Stop with', - '# `systemctl --user stop codesema-brain.service`, never a raw `kill` or', - '# `codesema brain stop`: Restart=on-failure relaunches either.', + '# `systemctl --user stop codesema-runner.service`, never a raw `kill` or', + '# `codesema runner stop`: Restart=on-failure relaunches either.', '', '[Unit]', - `Description=${get('Description', 'codesema brain daemon')}`, + `Description=${get('Description', 'codesema runner daemon')}`, `After=${get('After', 'network-online.target')}`, `Wants=${get('Wants', 'network-online.target')}`, '', @@ -108,7 +127,7 @@ function ensureSystemctlAvailable(execFn: ExecCommandFn): void { execFn('systemctl', ['--version']) } catch (err) { if (isNotFound(err)) { - throw new Error(t('brain.systemctlNotFound'), { cause: err }) + throw new Error(t('runner.systemctlNotFound'), { cause: err }) } throw err } @@ -118,12 +137,12 @@ function ensureSystemctlAvailable(execFn: ExecCommandFn): void { function resolveExecStart(): string { const entry = process.argv[1] if (entry === undefined) { - throw new Error(t('brain.serviceExecPathUnknown')) + throw new Error(t('runner.serviceExecPathUnknown')) } return realpathSync(entry) } -export type InstallBrainServiceOptions = { +export type InstallRunnerServiceOptions = { workingDirectory: string /** Resolves a relative `envFile` — the CLI's own invocation directory, threaded through explicitly rather than read from `process.cwd()` here so this stays a function of its inputs. */ cwd: string @@ -131,7 +150,7 @@ export type InstallBrainServiceOptions = { execFn?: ExecCommandFn | undefined } -export type InstallBrainServiceResult = { +export type InstallRunnerServiceResult = { unitPath: string workingDirectory: string execStart: string @@ -140,7 +159,9 @@ export type InstallBrainServiceResult = { lingerError: string | null } -export function installBrainService(opts: InstallBrainServiceOptions): InstallBrainServiceResult { +export function installRunnerService( + opts: InstallRunnerServiceOptions, +): InstallRunnerServiceResult { const execFn = opts.execFn ?? realExec // Probed before anything is written: a missing systemd must leave no // half-installed unit file behind. @@ -148,15 +169,22 @@ export function installBrainService(opts: InstallBrainServiceOptions): InstallBr const environmentFile = opts.envFile ? resolve(opts.cwd, opts.envFile) : null if (environmentFile && !existsSync(environmentFile)) { - throw new Error(t('brain.envFileNotFound', { path: environmentFile })) + throw new Error(t('runner.envFileNotFound', { path: environmentFile })) } - const execStart = `${resolveExecStart()} brain serve` + // Never leave the pre-rename daemon running alongside the renamed one. + purgeLegacyUnit(execFn) + + const execStart = `${resolveExecStart()} runner serve` const unitPath = systemdUnitPath() mkdirSync(dirname(unitPath), { recursive: true }) writeFileSync( unitPath, - renderBrainServiceUnit({ workingDirectory: opts.workingDirectory, execStart, environmentFile }), + renderRunnerServiceUnit({ + workingDirectory: opts.workingDirectory, + execStart, + environmentFile, + }), ) execFn('systemctl', ['--user', 'daemon-reload']) @@ -180,19 +208,25 @@ export function installBrainService(opts: InstallBrainServiceOptions): InstallBr } } -export type UninstallBrainServiceResult = { removed: boolean; unitPath: string } +export type UninstallRunnerServiceResult = { removed: boolean; unitPath: string } -export function uninstallBrainService(opts: { +export function uninstallRunnerService(opts: { execFn?: ExecCommandFn | undefined -}): UninstallBrainServiceResult { +}): UninstallRunnerServiceResult { const execFn = opts.execFn ?? realExec const unitPath = systemdUnitPath() - if (!existsSync(unitPath)) { + const unitExists = existsSync(unitPath) + const legacyExists = existsSync(legacyUnitPath()) + if (!unitExists && !legacyExists) { return { removed: false, unitPath } } ensureSystemctlAvailable(execFn) - execFn('systemctl', ['--user', 'disable', '--now', UNIT_NAME]) - rmSync(unitPath) + if (unitExists) { + execFn('systemctl', ['--user', 'disable', '--now', UNIT_NAME]) + rmSync(unitPath) + } + // Purges both names: an uninstall must never leave the pre-rename unit behind. + purgeLegacyUnit(execFn) execFn('systemctl', ['--user', 'daemon-reload']) return { removed: true, unitPath } } diff --git a/packages/cli/src/sealed-box.test.ts b/packages/cli/src/sealed-box.test.ts new file mode 100644 index 0000000..f0336aa --- /dev/null +++ b/packages/cli/src/sealed-box.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, test } from 'bun:test' +import { + formatFingerprint, + generateRunnerKeyPair, + runnerKeyFingerprint, + seal, + unseal, +} from './sealed-box.js' + +describe('generateRunnerKeyPair', () => { + test('produces 32-byte raw public and private keys', () => { + const pair = generateRunnerKeyPair() + expect(pair.publicKey.length).toBe(32) + expect(pair.privateKey.length).toBe(32) + }) + + test('two calls produce different keys', () => { + const a = generateRunnerKeyPair() + const b = generateRunnerKeyPair() + expect(a.publicKey.equals(b.publicKey)).toBe(false) + expect(a.privateKey.equals(b.privateKey)).toBe(false) + }) +}) + +describe('runnerKeyFingerprint', () => { + test('is a 64-character lowercase hex string', () => { + const { publicKey } = generateRunnerKeyPair() + expect(runnerKeyFingerprint(publicKey)).toMatch(/^[0-9a-f]{64}$/) + }) + + test('is stable for the same key', () => { + const { publicKey } = generateRunnerKeyPair() + expect(runnerKeyFingerprint(publicKey)).toBe(runnerKeyFingerprint(publicKey)) + }) + + test('differs across keys', () => { + const a = generateRunnerKeyPair() + const b = generateRunnerKeyPair() + expect(runnerKeyFingerprint(a.publicKey)).not.toBe(runnerKeyFingerprint(b.publicKey)) + }) +}) + +describe('formatFingerprint', () => { + test('splits the full 64 hex characters into groups of 4 separated by spaces', () => { + const fingerprint = 'a'.repeat(64) + const formatted = formatFingerprint(fingerprint) + expect(formatted).toBe(Array(16).fill('aaaa').join(' ')) + }) + + test('never truncates: every original character survives', () => { + const { publicKey } = generateRunnerKeyPair() + const fingerprint = runnerKeyFingerprint(publicKey) + const formatted = formatFingerprint(fingerprint) + expect(formatted.replace(/ /g, '')).toBe(fingerprint) + }) +}) + +describe('seal / unseal round trip', () => { + test('the recipient recovers the exact plaintext', () => { + const recipient = generateRunnerKeyPair() + const plaintext = Buffer.from('a runner secret token') + const blob = seal(recipient.publicKey, plaintext) + expect(unseal(recipient.privateKey, blob)?.equals(plaintext)).toBe(true) + }) + + test('round trips an empty plaintext', () => { + const recipient = generateRunnerKeyPair() + const blob = seal(recipient.publicKey, Buffer.alloc(0)) + expect(unseal(recipient.privateKey, blob)?.equals(Buffer.alloc(0))).toBe(true) + }) + + test('two seals of the same plaintext produce different blobs (fresh ephemeral key and nonce)', () => { + const recipient = generateRunnerKeyPair() + const plaintext = Buffer.from('same secret') + expect(seal(recipient.publicKey, plaintext)).not.toBe(seal(recipient.publicKey, plaintext)) + }) +}) + +describe('unseal never throws and rejects tampering', () => { + test('a single flipped byte in the ciphertext fails authentication', () => { + const recipient = generateRunnerKeyPair() + const blob = seal(recipient.publicKey, Buffer.from('secret')) + const envelope = JSON.parse(Buffer.from(blob, 'base64').toString('utf8')) as { ct: string } + const ct = Buffer.from(envelope.ct, 'base64') + ct.writeUInt8(ct.readUInt8(0) ^ 0xff, 0) + envelope.ct = ct.toString('base64') + const tampered = Buffer.from(JSON.stringify(envelope)).toString('base64') + expect(unseal(recipient.privateKey, tampered)).toBeNull() + }) + + test('the wrong recipient cannot decrypt', () => { + const recipient = generateRunnerKeyPair() + const attacker = generateRunnerKeyPair() + const blob = seal(recipient.publicKey, Buffer.from('secret')) + expect(unseal(attacker.privateKey, blob)).toBeNull() + }) + + test('a blob that is not valid base64/JSON returns null', () => { + const recipient = generateRunnerKeyPair() + expect(unseal(recipient.privateKey, '!!! not a sealed box !!!')).toBeNull() + }) + + test('an empty string returns null', () => { + const recipient = generateRunnerKeyPair() + expect(unseal(recipient.privateKey, '')).toBeNull() + }) + + test('valid base64 that decodes to non-JSON returns null', () => { + const recipient = generateRunnerKeyPair() + const blob = Buffer.from('this is not json').toString('base64') + expect(unseal(recipient.privateKey, blob)).toBeNull() + }) + + test('a JSON scalar (not an object) returns null', () => { + const recipient = generateRunnerKeyPair() + const blob = Buffer.from(JSON.stringify('just a string')).toString('base64') + expect(unseal(recipient.privateKey, blob)).toBeNull() + }) + + test('an unknown envelope version returns null', () => { + const recipient = generateRunnerKeyPair() + const blob = Buffer.from(JSON.stringify({ v: 2, epk: 'x', nonce: 'y', ct: 'z' })).toString( + 'base64', + ) + expect(unseal(recipient.privateKey, blob)).toBeNull() + }) + + test('missing fields return null', () => { + const recipient = generateRunnerKeyPair() + const blob = Buffer.from(JSON.stringify({ v: 1 })).toString('base64') + expect(unseal(recipient.privateKey, blob)).toBeNull() + }) + + test('a wrong-length ephemeral public key returns null', () => { + const recipient = generateRunnerKeyPair() + const blob = Buffer.from( + JSON.stringify({ + v: 1, + epk: Buffer.alloc(31).toString('base64'), + nonce: Buffer.alloc(12).toString('base64'), + ct: Buffer.alloc(32).toString('base64'), + }), + ).toString('base64') + expect(unseal(recipient.privateKey, blob)).toBeNull() + }) + + test('a wrong-length nonce returns null', () => { + const recipient = generateRunnerKeyPair() + const blob = Buffer.from( + JSON.stringify({ + v: 1, + epk: Buffer.alloc(32).toString('base64'), + nonce: Buffer.alloc(11).toString('base64'), + ct: Buffer.alloc(32).toString('base64'), + }), + ).toString('base64') + expect(unseal(recipient.privateKey, blob)).toBeNull() + }) + + test('a ciphertext shorter than the GCM tag returns null', () => { + const recipient = generateRunnerKeyPair() + const blob = Buffer.from( + JSON.stringify({ + v: 1, + epk: Buffer.alloc(32).toString('base64'), + nonce: Buffer.alloc(12).toString('base64'), + ct: Buffer.alloc(4).toString('base64'), + }), + ).toString('base64') + expect(unseal(recipient.privateKey, blob)).toBeNull() + }) +}) + +describe('frozen construction vector', () => { + // Locks the wire construction down: HKDF salt/info, AAD, envelope field + // names and order, and base64 framing. If this ever changes, EVERY caller + // that persisted or transmitted a blob under the old construction breaks, + // so a change here must be deliberate, not an accidental refactor. + const recipientPublicKey = Buffer.from('XVK0wPwH+z7M0nmqdWSI5Fc2gkws/dFnIFASExxfxHE=', 'base64') + const recipientPrivateKey = Buffer.from('hzGWKy+inBI5YzNkT51oOMZfpwnGAaIRMwUufaWDTJg=', 'base64') + const ephemeralPublicKey = Buffer.from('ItAWoLmSkyrcRLnCXzP0nXO90p1/aMdl0H8Lt3CNHQ4=', 'base64') + const ephemeralPrivateKey = Buffer.from('d16HoXVw8fUm3SxjlzuMHpB/4svzGlCl6iNCaTIExr8=', 'base64') + const nonce = Buffer.alloc(12, 0x05) + const plaintext = Buffer.from('frozen-vector-plaintext') + const expectedBlob = + 'eyJ2IjoxLCJlcGsiOiJJdEFXb0xtU2t5cmNSTG5DWHpQMG5YTzkwcDEvYU1kbDBIOEx0M0NOSFE0PSIsIm5vbmNlIjoiQlFVRkJRVUZCUVVGQlFVRiIsImN0IjoiL0IvNDc4QUhaZGZmV2k5eVg0ZlF1MkZPWUl3bUI3RndyOFVWWC90U1U4VDJ1SHRFYmdzcyJ9' + + test('sealing with fixed seams reproduces the exact recorded blob', () => { + const blob = seal(recipientPublicKey, plaintext, { + ephemeral: { publicKey: ephemeralPublicKey, privateKey: ephemeralPrivateKey }, + nonce, + }) + expect(blob).toBe(expectedBlob) + }) + + test('the recorded blob still unseals to the original plaintext', () => { + expect(unseal(recipientPrivateKey, expectedBlob)?.equals(plaintext)).toBe(true) + }) +}) diff --git a/packages/cli/src/sealed-box.ts b/packages/cli/src/sealed-box.ts new file mode 100644 index 0000000..937b7b8 --- /dev/null +++ b/packages/cli/src/sealed-box.ts @@ -0,0 +1,184 @@ +import { + createCipheriv, + createDecipheriv, + createHash, + createPrivateKey, + createPublicKey, + diffieHellman, + generateKeyPairSync, + hkdfSync, + randomBytes, + type KeyObject, +} from 'node:crypto' + +export type RunnerKeyPair = { + publicKey: Buffer + privateKey: Buffer +} + +export type SealSeams = { + ephemeral?: RunnerKeyPair + nonce?: Buffer +} + +const ENVELOPE_VERSION = 1 +const RAW_KEY_LENGTH = 32 +const NONCE_LENGTH = 12 +const AUTH_TAG_LENGTH = 16 +const HKDF_SALT = Buffer.from('codesema-sealed-box-v1') + +// X25519 has no ASN.1 parameters and a fixed-width OID, so its SPKI/PKCS8 DER +// encodings are a constant-length prefix followed by the raw 32-byte key. +// Bun's node:crypto (checked on 1.3.13) has no 'raw' export/import format for +// OKP keys, so this prefix-and-slice is how raw bytes cross the KeyObject +// boundary in both directions. +const X25519_SPKI_PREFIX = Buffer.from('302a300506032b656e032100', 'hex') +const X25519_PKCS8_PREFIX = Buffer.from('302e020100300506032b656e04220420', 'hex') + +function rawPublicKeyToKeyObject(raw: Buffer): KeyObject { + return createPublicKey({ + key: Buffer.concat([X25519_SPKI_PREFIX, raw]), + format: 'der', + type: 'spki', + }) +} + +function rawPrivateKeyToKeyObject(raw: Buffer): KeyObject { + return createPrivateKey({ + key: Buffer.concat([X25519_PKCS8_PREFIX, raw]), + format: 'der', + type: 'pkcs8', + }) +} + +function keyObjectToRawPublicKey(key: KeyObject): Buffer { + return Buffer.from(key.export({ format: 'der', type: 'spki' })).subarray( + X25519_SPKI_PREFIX.length, + ) +} + +function keyObjectToRawPrivateKey(key: KeyObject): Buffer { + return Buffer.from(key.export({ format: 'der', type: 'pkcs8' })).subarray( + X25519_PKCS8_PREFIX.length, + ) +} + +export function generateRunnerKeyPair(): RunnerKeyPair { + const { publicKey, privateKey } = generateKeyPairSync('x25519') + return { + publicKey: keyObjectToRawPublicKey(publicKey), + privateKey: keyObjectToRawPrivateKey(privateKey), + } +} + +export function runnerKeyFingerprint(publicKey: Buffer): string { + return createHash('sha256').update(publicKey).digest('hex') +} + +export function formatFingerprint(fingerprint: string): string { + const groups: string[] = [] + for (let i = 0; i < fingerprint.length; i += 4) { + groups.push(fingerprint.slice(i, i + 4)) + } + return groups.join(' ') +} + +function deriveSharedKey( + sharedSecret: Buffer, + ephemeralPublicKey: Buffer, + recipientPublicKey: Buffer, +): Buffer { + return Buffer.from( + hkdfSync( + 'sha256', + sharedSecret, + HKDF_SALT, + Buffer.concat([ephemeralPublicKey, recipientPublicKey]), + 32, + ), + ) +} + +export function seal(recipientPublicKey: Buffer, plaintext: Buffer, seams?: SealSeams): string { + const ephemeral = seams?.ephemeral ?? generateRunnerKeyPair() + const nonce = seams?.nonce ?? randomBytes(NONCE_LENGTH) + + const sharedSecret = diffieHellman({ + privateKey: rawPrivateKeyToKeyObject(ephemeral.privateKey), + publicKey: rawPublicKeyToKeyObject(recipientPublicKey), + }) + const key = deriveSharedKey(sharedSecret, ephemeral.publicKey, recipientPublicKey) + + const cipher = createCipheriv('aes-256-gcm', key, nonce, { authTagLength: AUTH_TAG_LENGTH }) + // AAD binds the ciphertext to its addressee: a blob sealed for one runner's + // public key fails authentication if replayed against a different one. + cipher.setAAD(recipientPublicKey) + const sealedCiphertext = Buffer.concat([ + cipher.update(plaintext), + cipher.final(), + cipher.getAuthTag(), + ]) + + const envelope = { + v: ENVELOPE_VERSION, + epk: ephemeral.publicKey.toString('base64'), + nonce: nonce.toString('base64'), + ct: sealedCiphertext.toString('base64'), + } + return Buffer.from(JSON.stringify(envelope)).toString('base64') +} + +export function unseal(recipientPrivateKey: Buffer, blob: string): Buffer | null { + try { + const envelope = JSON.parse(Buffer.from(blob, 'base64').toString('utf8')) as unknown + if (typeof envelope !== 'object' || envelope === null) { + return null + } + const fields = envelope as Record<string, unknown> + if (fields.v !== ENVELOPE_VERSION) { + return null + } + if ( + typeof fields.epk !== 'string' || + typeof fields.nonce !== 'string' || + typeof fields.ct !== 'string' + ) { + return null + } + + const ephemeralPublicKey = Buffer.from(fields.epk, 'base64') + const nonce = Buffer.from(fields.nonce, 'base64') + const sealedCiphertext = Buffer.from(fields.ct, 'base64') + if ( + ephemeralPublicKey.length !== RAW_KEY_LENGTH || + nonce.length !== NONCE_LENGTH || + sealedCiphertext.length < AUTH_TAG_LENGTH + ) { + return null + } + + const privateKeyObject = rawPrivateKeyToKeyObject(recipientPrivateKey) + // createPublicKey's types don't model its documented KeyObject overload + // (deriving the public key from a private one); real at runtime, just + // untyped in this @types/node version. + const derivedPublicKeyObject = createPublicKey( + privateKeyObject as unknown as Parameters<typeof createPublicKey>[0], + ) + const recipientPublicKey = keyObjectToRawPublicKey(derivedPublicKeyObject) + const sharedSecret = diffieHellman({ + privateKey: privateKeyObject, + publicKey: rawPublicKeyToKeyObject(ephemeralPublicKey), + }) + const key = deriveSharedKey(sharedSecret, ephemeralPublicKey, recipientPublicKey) + + const ciphertext = sealedCiphertext.subarray(0, sealedCiphertext.length - AUTH_TAG_LENGTH) + const authTag = sealedCiphertext.subarray(sealedCiphertext.length - AUTH_TAG_LENGTH) + + const decipher = createDecipheriv('aes-256-gcm', key, nonce, { authTagLength: AUTH_TAG_LENGTH }) + decipher.setAAD(recipientPublicKey) + decipher.setAuthTag(authTag) + return Buffer.concat([decipher.update(ciphertext), decipher.final()]) + } catch { + return null + } +} diff --git a/packages/cli/src/serve.test.ts b/packages/cli/src/serve.test.ts index 247bd64..28dd4fc 100644 --- a/packages/cli/src/serve.test.ts +++ b/packages/cli/src/serve.test.ts @@ -550,11 +550,11 @@ describe('startServer', () => { expect(JSON.parse(afterToggle.body)).toMatchObject({ syncAutoPush: true }) }) - test('reports the effective brain settings with their resolved defaults', async () => { + test('reports the effective runner settings with their resolved defaults', async () => { const res = await rawRequest(port, '/api/settings') expect(res.status).toBe(200) expect(JSON.parse(res.body)).toEqual({ - brainAutoMerge: { value: true }, + runnerAutoMerge: { value: true }, mergeStrategy: {}, maxTaskTurns: { value: 30 }, }) @@ -563,13 +563,13 @@ describe('startServer', () => { test('rejects settings mutations without a valid config token', async () => { const noToken = await rawRequest(port, '/api/settings', { method: 'PUT', - body: '{"brainAutoMerge":false}', + body: '{"runnerAutoMerge":false}', }) expect(noToken.status).toBe(403) const badToken = await rawRequest(port, '/api/settings', { method: 'PUT', headers: { 'x-codesema-config-token': 'wrong' }, - body: '{"brainAutoMerge":false}', + body: '{"runnerAutoMerge":false}', }) expect(badToken.status).toBe(403) }) @@ -582,7 +582,7 @@ describe('startServer', () => { const rejections = [ '{"nope":true}', - '{"brainAutoMerge":"yes"}', + '{"runnerAutoMerge":"yes"}', '{"mergeStrategy":"fast-forward"}', '{"maxTaskTurns":0}', '{"maxTaskTurns":501}', @@ -600,7 +600,7 @@ describe('startServer', () => { const stillDefault = await rawRequest(port, '/api/settings') expect(JSON.parse(stillDefault.body)).toEqual({ - brainAutoMerge: { value: true }, + runnerAutoMerge: { value: true }, mergeStrategy: {}, maxTaskTurns: { value: 30 }, }) @@ -608,11 +608,11 @@ describe('startServer', () => { const written = await rawRequest(port, '/api/settings', { method: 'PUT', headers: { 'x-codesema-config-token': token }, - body: JSON.stringify({ brainAutoMerge: false, mergeStrategy: 'squash', maxTaskTurns: 60 }), + body: JSON.stringify({ runnerAutoMerge: false, mergeStrategy: 'squash', maxTaskTurns: 60 }), }) expect(written.status).toBe(200) expect(JSON.parse(written.body)).toEqual({ - brainAutoMerge: { value: false, raw: false }, + runnerAutoMerge: { value: false, raw: false }, mergeStrategy: { value: 'squash', raw: 'squash' }, maxTaskTurns: { value: 60, raw: 60 }, }) @@ -623,11 +623,11 @@ describe('startServer', () => { const partial = await rawRequest(port, '/api/settings', { method: 'PUT', headers: { 'x-codesema-config-token': token }, - body: '{"brainAutoMerge":true}', + body: '{"runnerAutoMerge":true}', }) expect(partial.status).toBe(200) expect(JSON.parse(partial.body)).toEqual({ - brainAutoMerge: { value: true, raw: true }, + runnerAutoMerge: { value: true, raw: true }, mergeStrategy: { value: 'squash', raw: 'squash' }, maxTaskTurns: { value: 60, raw: 60 }, }) diff --git a/packages/cli/src/serve.ts b/packages/cli/src/serve.ts index 8235ed7..2562039 100644 --- a/packages/cli/src/serve.ts +++ b/packages/cli/src/serve.ts @@ -4,15 +4,13 @@ import { readFile } from 'node:fs/promises' import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http' import { extname, join, resolve, sep } from 'node:path' import { fileURLToPath } from 'node:url' -import { brainErrorMessage, brainRemoteUrl, listTickets, type BrainError } from './brain-client.js' -import { startBrainDaemon, type BrainDaemonHandle } from './brain-daemon.js' import { listLocalBranches, listWorktrees } from './branches.js' import { isMergeStrategy, loadGlobalConfig, - resolveBrainAutoMerge, resolveMaxTaskTurns, resolveMergeSettings, + resolveRunnerAutoMerge, saveGlobalConfig, type CodesemaConfig, type MergeStrategy, @@ -32,6 +30,7 @@ import { type ForgeIssueStateFilter, } from './forge-issues.js' import { listOpenMrs, type ForgeMrsResult, type ForgeMrStateFilter } from './forge-mrs.js' +import { hubErrorMessage, hubRemoteUrl, listTickets, type HubError } from './hub-client.js' import { t } from './i18n.js' import type { MrReviewMode, @@ -58,6 +57,7 @@ import { setSyncAutoPush, writeRulesContent, } from './repo-config.js' +import { startRunnerDaemon, type RunnerDaemonHandle } from './runner-daemon.js' import { loadSyncCredentials } from './sync.js' import { applyTaskCriteria } from './task-criteria.js' import type { TaskActionResult } from './task-runner.js' @@ -649,7 +649,7 @@ function nextGlobalConfig(current: CodesemaConfig, picked: AgentSelection): Code } type SettingsSnapshot = { - brainAutoMerge: { value: boolean; raw: boolean | undefined } + runnerAutoMerge: { value: boolean; raw: boolean | undefined } mergeStrategy: { value: MergeStrategy | undefined; raw: MergeStrategy | undefined } maxTaskTurns: { value: number; raw: number | undefined } } @@ -657,7 +657,7 @@ type SettingsSnapshot = { function settingsSnapshot(config: CodesemaConfig): SettingsSnapshot { const merge = resolveMergeSettings(config) return { - brainAutoMerge: { value: resolveBrainAutoMerge(config), raw: config.brainAutoMerge }, + runnerAutoMerge: { value: resolveRunnerAutoMerge(config), raw: config.runnerAutoMerge }, mergeStrategy: { value: merge.strategy, raw: config.mergeStrategy }, maxTaskTurns: { value: resolveMaxTaskTurns(config), raw: config.maxTaskTurns }, } @@ -670,16 +670,16 @@ function handleSettingsGet(res: ServerResponse): void { const MAX_SETTINGS_BODY_BYTES = 1024 const MAX_TASK_TURNS = 500 -type SettingsUpdate = Pick<CodesemaConfig, 'brainAutoMerge' | 'mergeStrategy' | 'maxTaskTurns'> +type SettingsUpdate = Pick<CodesemaConfig, 'runnerAutoMerge' | 'mergeStrategy' | 'maxTaskTurns'> const SETTINGS_KEYS: ReadonlySet<string> = new Set([ - 'brainAutoMerge', + 'runnerAutoMerge', 'mergeStrategy', 'maxTaskTurns', ]) /** - * PUT /api/settings writes the three GLOBAL-ONLY brain-loop settings - * (config.ts: brainAutoMerge, mergeStrategy, maxTaskTurns), so it needs the + * PUT /api/settings writes the three GLOBAL-ONLY runner-loop settings + * (config.ts: runnerAutoMerge, mergeStrategy, maxTaskTurns), so it needs the * same per-server CSRF token as the other /api/config/* mutations. Every * field is validated before ANY write happens, so a bad field never leaves * the other, valid ones written and the invalid one silently skipped. @@ -707,11 +707,11 @@ async function handleSettingsUpdate( return sendJson(res, 400, { error: `unknown setting: ${unknownKey}` }) } const update: SettingsUpdate = {} - if ('brainAutoMerge' in payload) { - if (typeof payload.brainAutoMerge !== 'boolean') { - return sendJson(res, 400, { error: 'brainAutoMerge must be a boolean' }) + if ('runnerAutoMerge' in payload) { + if (typeof payload.runnerAutoMerge !== 'boolean') { + return sendJson(res, 400, { error: 'runnerAutoMerge must be a boolean' }) } - update.brainAutoMerge = payload.brainAutoMerge + update.runnerAutoMerge = payload.runnerAutoMerge } if ('mergeStrategy' in payload) { if (!isMergeStrategy(payload.mergeStrategy)) { @@ -1329,33 +1329,28 @@ async function handleIssuesList( } /** - * The brain's own view of this project's in-flight tickets, for a future + * The hub's own view of this project's in-flight tickets, for a future * dashboard: every status a caller could act on or care about right now. * `done` is left out on purpose — the wire contract has no way to bound it by * date, and an unbounded "every ticket ever finished" is not what "in - * flight" means. 503 whenever the brain integration is not usable for this + * flight" means. 503 whenever the hub integration is not usable for this * project right now (no credentials, or no git origin remote to scope tickets * by) — not 501 (this codebase's convention for "no task manager at all"), * since the feature exists here, it is just not connected. */ -const BRAIN_DASHBOARD_STATUSES = [ - 'published', - 'in_progress', - 'mr_opened', - 'ready_to_merge', -] as const - -async function handleBrainTicketsList(res: ServerResponse, cwd: string): Promise<void> { +const HUB_DASHBOARD_STATUSES = ['published', 'in_progress', 'mr_opened', 'ready_to_merge'] as const + +async function handleHubTicketsList(res: ServerResponse, cwd: string): Promise<void> { const creds = loadSyncCredentials() - const remoteUrl = creds ? brainRemoteUrl(cwd) : null + const remoteUrl = creds ? hubRemoteUrl(cwd) : null if (!creds || !remoteUrl) { return sendJson(res, 503, { available: false }) } const results = await Promise.all( - BRAIN_DASHBOARD_STATUSES.map((status) => listTickets(creds, remoteUrl, status)), + HUB_DASHBOARD_STATUSES.map((status) => listTickets(creds, remoteUrl, status)), ) const tickets: ArmTicket[] = [] - let firstError: BrainError | null = null + let firstError: HubError | null = null for (const result of results) { if (result.ok) { tickets.push(...result.data) @@ -1364,7 +1359,7 @@ async function handleBrainTicketsList(res: ServerResponse, cwd: string): Promise } } if (tickets.length === 0 && firstError) { - return sendJson(res, 503, { available: false, error: brainErrorMessage(firstError) }) + return sendJson(res, 503, { available: false, error: hubErrorMessage(firstError) }) } return sendJson(res, 200, { available: true, tickets }) } @@ -1598,7 +1593,7 @@ function createRequestHandler(handlerOpts: { pathname === '/api/issues' || pathname === '/api/branches' || pathname === '/api/worktrees' || - pathname === '/api/brain/tickets' || + pathname === '/api/hub/tickets' || pathname === '/api/reviews/latest' || pathname === '/api/reviews' || pathname === '/api/reviews/record' || @@ -1629,8 +1624,8 @@ function createRequestHandler(handlerOpts: { if (pathname === '/api/worktrees') { return sendJson(res, 200, listWorktrees(scoped.cwd)) } - if (pathname === '/api/brain/tickets') { - return void handleBrainTicketsList(res, scoped.cwd) + if (pathname === '/api/hub/tickets') { + return void handleHubTicketsList(res, scoped.cwd) } if (pathname === '/api/reviews/latest') { return sendJson(res, 200, { latest: listLatestReviews(scoped.cwd) }) @@ -1953,17 +1948,17 @@ export async function startServer( }), opts.port ?? 4400, ) - // `codesema workspace --brain` / `codesema brain serve` (index.ts, - // brain-commands.ts) set this before calling workspace(), which has no - // room in its own options type for a brain flag: read here, at the one + // `codesema workspace --runner` / `codesema runner serve` (index.ts, + // runner-commands.ts) set this before calling workspace(), which has no + // room in its own options type for a runner flag: read here, at the one // place that actually needs it, the same way CODESEMA_SYNC_URL / // CODESEMA_DEV_VITE already cross an intermediate layer in this codebase. - const brainDaemon: BrainDaemonHandle | null = - opts.taskManager && process.env.CODESEMA_BRAIN_MODE === '1' - ? startBrainDaemon({ manager: opts.taskManager, cwd: opts.cwd }) + const runnerDaemon: RunnerDaemonHandle | null = + opts.taskManager && process.env.CODESEMA_RUNNER_MODE === '1' + ? startRunnerDaemon({ manager: opts.taskManager, cwd: opts.cwd }) : null const stop = async () => { - await brainDaemon?.stop() + await runnerDaemon?.stop() await new Promise<void>((resolveClose) => { server.closeAllConnections() server.close(() => resolveClose()) diff --git a/packages/cli/src/task-brain-ticket.test.ts b/packages/cli/src/task-hub-ticket.test.ts similarity index 80% rename from packages/cli/src/task-brain-ticket.test.ts rename to packages/cli/src/task-hub-ticket.test.ts index ea894d7..e291eac 100644 --- a/packages/cli/src/task-brain-ticket.test.ts +++ b/packages/cli/src/task-hub-ticket.test.ts @@ -5,7 +5,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import type { ArmTicket } from './contract.js' import { addProject, type Project } from './projects.js' -import { createBrainTicketTask, resolveBrainTicketOrigin } from './task-brain-ticket.js' +import { createHubTicketTask, resolveHubTicketOrigin } from './task-hub-ticket.js' import type { TaskActionResult, TaskRunner, TaskRunnerOptions } from './task-runner.js' import { createTaskManager } from './task-server.js' import { listTasks, readTaskEvents } from './tasks-store.js' @@ -17,7 +17,7 @@ const previousConfigDir = process.env.CODESEMA_CONFIG_DIR const cleanups: string[] = [] beforeEach(() => { - configDir = mkdtempSync(join(tmpdir(), 'codesema-brain-ticket-cfg-')) + configDir = mkdtempSync(join(tmpdir(), 'codesema-hub-ticket-cfg-')) cleanups.push(configDir) process.env.CODESEMA_CONFIG_DIR = configDir }) @@ -34,7 +34,7 @@ afterEach(() => { }) function makeDir(): string { - const dir = mkdtempSync(join(tmpdir(), 'codesema-brain-ticket-')) + const dir = mkdtempSync(join(tmpdir(), 'codesema-hub-ticket-')) cleanups.push(dir) return dir } @@ -118,9 +118,9 @@ function fakeTicket(overrides: Partial<ArmTicket> = {}): ArmTicket { } } -describe('resolveBrainTicketOrigin', () => { - test('a valid ticket resolves title, prompt, criteria and brainTicket', () => { - const origin = resolveBrainTicketOrigin('/repo', fakeTicket()) +describe('resolveHubTicketOrigin', () => { + test('a valid ticket resolves title, prompt, criteria and hubTicket', () => { + const origin = resolveHubTicketOrigin('/repo', fakeTicket()) expect(origin.ok).toBe(true) if (!origin.ok) { return @@ -129,14 +129,14 @@ describe('resolveBrainTicketOrigin', () => { expect(origin.prompt).toBe(VALID_BODY) expect(origin.criteria.length).toBe(3) expect(origin.criteria.every((c) => c.id.startsWith('ac-'))).toBe(true) - expect(origin.brainTicket).toEqual({ id: 'tkt-1', title: 'Persist onboarding progress' }) + expect(origin.hubTicket).toEqual({ id: 'tkt-1', title: 'Persist onboarding progress' }) expect(origin.issue).toBeNull() expect(origin.issueSnapshot).toBeNull() expect(origin.coverageGap).toBe(false) }) test('an empty title refuses', () => { - const origin = resolveBrainTicketOrigin('/repo', fakeTicket({ title: ' ' })) + const origin = resolveHubTicketOrigin('/repo', fakeTicket({ title: ' ' })) expect(origin.ok).toBe(false) if (origin.ok) { return @@ -146,7 +146,7 @@ describe('resolveBrainTicketOrigin', () => { }) test('a body that fails T2.3 lint refuses, naming the problem', () => { - const origin = resolveBrainTicketOrigin('/repo', fakeTicket({ body: 'not a ticket at all' })) + const origin = resolveHubTicketOrigin('/repo', fakeTicket({ body: 'not a ticket at all' })) expect(origin.ok).toBe(false) if (origin.ok) { return @@ -160,7 +160,7 @@ describe('resolveBrainTicketOrigin', () => { /\*\*Acceptance criteria\*\*[\s\S]*?\n\n\*\*Out of scope\*\*/, '**Acceptance criteria**\n- WHEN a user closes the tab THE SYSTEM SHALL persist progress\n\n**Out of scope**', ) - const origin = resolveBrainTicketOrigin('/repo', fakeTicket({ body: shortBody })) + const origin = resolveHubTicketOrigin('/repo', fakeTicket({ body: shortBody })) expect(origin.ok).toBe(false) if (origin.ok) { return @@ -168,8 +168,8 @@ describe('resolveBrainTicketOrigin', () => { expect(origin.refusal.error).toContain('at least 3') }) - test('brainTicket.url prefers mr_url over the source issue url', () => { - const origin = resolveBrainTicketOrigin( + test('hubTicket.url prefers mr_url over the source issue url', () => { + const origin = resolveHubTicketOrigin( '/repo', fakeTicket({ mr_url: 'https://forge.example/mr/1', @@ -180,11 +180,11 @@ describe('resolveBrainTicketOrigin', () => { if (!origin.ok) { return } - expect(origin.brainTicket.url).toBe('https://forge.example/mr/1') + expect(origin.hubTicket.url).toBe('https://forge.example/mr/1') }) - test('brainTicket.url falls back to the source issue url when there is no mr_url', () => { - const origin = resolveBrainTicketOrigin( + test('hubTicket.url falls back to the source issue url when there is no mr_url', () => { + const origin = resolveHubTicketOrigin( '/repo', fakeTicket({ mr_url: null, issue: { iid: '42', url: 'https://forge.example/issues/42' } }), ) @@ -192,26 +192,26 @@ describe('resolveBrainTicketOrigin', () => { if (!origin.ok) { return } - expect(origin.brainTicket.url).toBe('https://forge.example/issues/42') + expect(origin.hubTicket.url).toBe('https://forge.example/issues/42') }) - test('no mr_url and no source issue: brainTicket carries no url at all', () => { - const origin = resolveBrainTicketOrigin('/repo', fakeTicket()) + test('no mr_url and no source issue: hubTicket carries no url at all', () => { + const origin = resolveHubTicketOrigin('/repo', fakeTicket()) expect(origin.ok).toBe(true) if (!origin.ok) { return } - expect('url' in origin.brainTicket).toBe(false) + expect('url' in origin.hubTicket).toBe(false) }) }) -describe('createBrainTicketTask', () => { - test('a valid ticket creates a queued task with the right title, criteria and brain_ticket', async () => { +describe('createHubTicketTask', () => { + test('a valid ticket creates a queued task with the right title, criteria and hub_ticket', async () => { const repo = makeRepo() const project = register(repo) const manager = createTaskManager({ ...managerOpts, ...fakeRunner() }) - const created = await createBrainTicketTask(manager, project.path, fakeTicket()) + const created = await createHubTicketTask(manager, project.path, fakeTicket()) expect(created.ok).toBe(true) if (!created.ok) { @@ -221,7 +221,7 @@ describe('createBrainTicketTask', () => { expect(created.record.title).toBe('Persist onboarding progress') expect(created.record.auto_ship).toBe(true) expect(created.record.criteria?.length).toBe(3) - expect(created.record.brain_ticket).toEqual({ + expect(created.record.hub_ticket).toEqual({ id: 'tkt-1', title: 'Persist onboarding progress', }) @@ -229,7 +229,7 @@ describe('createBrainTicketTask', () => { // On disk, not just in the in-memory return value. const onDisk = listTasks(project.path).find((t) => t.id === created.record.id) expect(onDisk?.criteria?.length).toBe(3) - expect(onDisk?.brain_ticket?.id).toBe('tkt-1') + expect(onDisk?.hub_ticket?.id).toBe('tkt-1') // The criteria landed with a journal line, same as a human validation would. const events = readTaskEvents(project.path, created.record.id) @@ -241,7 +241,7 @@ describe('createBrainTicketTask', () => { const project = register(repo) const manager = createTaskManager({ ...managerOpts, ...fakeRunner() }) - const created = await createBrainTicketTask( + const created = await createHubTicketTask( manager, project.path, fakeTicket({ body: 'not a ticket at all' }), @@ -260,7 +260,7 @@ describe('createBrainTicketTask', () => { // Deliberately not registered. const manager = createTaskManager({ ...managerOpts, ...fakeRunner() }) - const created = await createBrainTicketTask(manager, repo, fakeTicket()) + const created = await createHubTicketTask(manager, repo, fakeTicket()) expect(created.ok).toBe(false) if (created.ok) { diff --git a/packages/cli/src/task-brain-ticket.ts b/packages/cli/src/task-hub-ticket.ts similarity index 70% rename from packages/cli/src/task-brain-ticket.ts rename to packages/cli/src/task-hub-ticket.ts index f8a2ec9..67b5eea 100644 --- a/packages/cli/src/task-brain-ticket.ts +++ b/packages/cli/src/task-hub-ticket.ts @@ -1,21 +1,21 @@ -// Turns a brain ticket (a ticket the local brain owns and this arm claimed) +// Turns a hub ticket (a ticket the local hub owns and this arm claimed) // into a queued task: the symmetric twin of task-issue.ts's -// resolveIssueOrigin/admitIssue, but for a ticket the brain already resolved +// resolveIssueOrigin/admitIssue, but for a ticket the hub already resolved // and validated rather than a forge issue read live over the network. // // No forge round trip here: an ArmTicket arrives already sanitized -// (sanitizeArmTicket, contract/brain.ts) by whoever claimed it from the -// brain, so admission is a pure, synchronous lint: lintTicketBody (T2.3), +// (sanitizeArmTicket, contract/arm.ts) by whoever claimed it from the +// hub, so admission is a pure, synchronous lint: lintTicketBody (T2.3), // the SAME gate task-issue.ts's admitIssue runs on a forge issue's body. // // Criteria are frozen on the record AT CREATION, atomically with the title -// and prompt (task-server.ts folds `resolveBrainTicketOrigin`'s `criteria` +// and prompt (task-server.ts folds `resolveHubTicketOrigin`'s `criteria` // straight into `createTask`'s input), never posed afterwards through // applyTaskCriteria's own POST /api/tasks/:id/criteria mechanics. A -// brain-ticket task's very first turn already reads `taskCriteria(record)` +// hub-ticket task's very first turn already reads `taskCriteria(record)` // (task-runner.ts) to build its prompt; criteria landing even one write // later would race that read, and the task would draft-and-wait for a human -// validation nobody is coming to give: the brain validated them already. +// validation nobody is coming to give: the hub validated them already. import { formatTicketProblems, @@ -28,17 +28,17 @@ import { import type { TaskCreateResult, TaskManager } from './task-server.js' /** - * What `resolveBrainTicketOrigin` hands back: the same shape task-server.ts's + * What `resolveHubTicketOrigin` hands back: the same shape task-server.ts's * own (unexported) `TaskOrigin` accepts on its `ok: true` branch: title, - * prompt, no forge issue (a brain ticket is not reconciled against a live - * forge issue the way T2.4's own origin is; `brainTicket.url` is a plain - * pointer, not a reconciliation anchor), and the two brain-only fields - * (`brainTicket`, `criteria`) `task-server.ts`'s `create()` folds onto the + * prompt, no forge issue (a hub ticket is not reconciled against a live + * forge issue the way T2.4's own origin is; `hubTicket.url` is a plain + * pointer, not a reconciliation anchor), and the two hub-only fields + * (`hubTicket`, `criteria`) `task-server.ts`'s `create()` folds onto the * record. The refusal is wrapped in `refusal`, matching * `resolveIssueOrigin`/`resolveTitlePromptOrigin`'s own shape exactly, so * `create()`'s `if (!origin.ok) return origin.refusal` reads it unchanged. */ -export type BrainTicketOrigin = +export type HubTicketOrigin = | { ok: true title: string @@ -46,20 +46,20 @@ export type BrainTicketOrigin = issue: null issueSnapshot: null coverageGap: false - brainTicket: { id: string; title: string; url?: string } + hubTicket: { id: string; title: string; url?: string } criteria: AcceptanceCriterion[] } | { ok: false; refusal: { ok: false; code: 400; error: string } } /** - * Validates a brain ticket and derives the task it would become. `cwd` is + * Validates a hub ticket and derives the task it would become. `cwd` is * taken for symmetry with `resolveIssueOrigin(cwd, ref, execFn)`, whose * caller (`task-server.ts`) reaches this the same way; nothing here touches * disk or the network, so nothing here reads it. * * Refusals, in order: an empty or over-long title (same bound and same * wording as `resolveTitlePromptOrigin`'s own guard), then T2.3's lint on - * the body: a ticket the brain itself would not have been able to publish + * the body: a ticket the hub itself would not have been able to publish * without passing this same gate, but re-checked here rather than trusted, * since a ticket that failed to lint must never become a task with no * criteria to judge it against. @@ -68,7 +68,7 @@ export type BrainTicketOrigin = * call-shape symmetry with `resolveIssueOrigin(cwd, ref, execFn)`: both are * called from the same three-way ternary in `task-server.ts`'s `create()`. */ -export function resolveBrainTicketOrigin(_cwd: string, ticket: ArmTicket): BrainTicketOrigin { +export function resolveHubTicketOrigin(_cwd: string, ticket: ArmTicket): HubTicketOrigin { const title = ticket.title.trim() if (!title) { return { ok: false, refusal: { ok: false, code: 400, error: 'empty title' } } @@ -108,27 +108,27 @@ export function resolveBrainTicketOrigin(_cwd: string, ticket: ArmTicket): Brain issue: null, issueSnapshot: null, coverageGap: false, - brainTicket: { id: ticket.id, title, ...(url ? { url } : {}) }, + hubTicket: { id: ticket.id, title, ...(url ? { url } : {}) }, criteria: lint.body.acceptance_criteria, } } /** - * Creates a task from a brain ticket. Resolves `cwd` to its registered + * Creates a task from a hub ticket. Resolves `cwd` to its registered * project the same way `task-server.ts`'s own `context()` does * (`listAll()`, matched on `project.path`) and calls `manager.create()` with * the ticket as the task's origin: `task-server.ts` resolves it through - * `resolveBrainTicketOrigin` above, so a ticket that fails T2.3's lint never + * `resolveHubTicketOrigin` above, so a ticket that fails T2.3's lint never * reaches `createTask`, and the caller learns why from the very same * `TaskCreateResult` shape any other origin refuses with. * - * `autoShip: true`: a brain-ticket task runs unattended end to end (code, + * `autoShip: true`: a hub-ticket task runs unattended end to end (code, * ship, review, merge), which is exactly what `record.auto_ship` already * gates (`task-server.ts`'s `auto_ship && status === 'review_ok'`); this - * simply opts every brain-ticket task into it, the same way a human ticking + * simply opts every hub-ticket task into it, the same way a human ticking * "auto-ship" in the UI would for a task they created by hand. */ -export async function createBrainTicketTask( +export async function createHubTicketTask( manager: TaskManager, cwd: string, ticket: ArmTicket, @@ -137,5 +137,5 @@ export async function createBrainTicketTask( if (!project) { return { ok: false, code: 404, error: 'unknown project' } } - return manager.create(project.id, { brainTicket: ticket, autoShip: true }) + return manager.create(project.id, { hubTicket: ticket, autoShip: true }) } diff --git a/packages/cli/src/task-brain.test.ts b/packages/cli/src/task-hub.test.ts similarity index 65% rename from packages/cli/src/task-brain.test.ts rename to packages/cli/src/task-hub.test.ts index d438f13..1ab029d 100644 --- a/packages/cli/src/task-brain.test.ts +++ b/packages/cli/src/task-hub.test.ts @@ -1,17 +1,17 @@ import { execFileSync } from 'node:child_process' -import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import { loadGlobalConfig, saveGlobalConfig } from './config.js' import type { ArmOrder, TaskEvent, TaskRecord, TaskTurn } from './contract.js' import { - flushBrainOutbox, - heartbeatBrainTicket, - queueBrainEvent, - reportBrainTransition, - resetPendingBrainEventBatches, -} from './task-brain.js' + flushHubOutbox, + heartbeatHubTicket, + queueHubEvent, + reportHubTransition, + resetPendingHubEventBatches, +} from './task-hub.js' type Call = { url: string; init: RequestInit } @@ -38,6 +38,10 @@ function requestBody(call: Call): Record<string, unknown> { } function outboxPath(cwd: string): string { + return join(cwd, '.codesema', 'hub-outbox.jsonl') +} + +function legacyOutboxPath(cwd: string): string { return join(cwd, '.codesema', 'brain-outbox.jsonl') } @@ -53,7 +57,7 @@ function outboxLines(cwd: string): unknown[] { async function settle(): Promise<void> { // Lets a fire-and-forget effect's own microtask/macrotask chain (fetchStub's - // resolved Response, its own .then chain inside postToBrain) run to + // resolved Response, its own .then chain inside postToHub) run to // completion before an assertion reads its side effect. await new Promise((resolve) => setTimeout(resolve, 20)) } @@ -75,7 +79,7 @@ function fakeRecord(overrides: Partial<TaskRecord> = {}): TaskRecord { auto_ship: true, work_on: false, isolation: 'policy', - brain_ticket: { id: 'tkt-1', title: 't' }, + hub_ticket: { id: 'tkt-1', title: 't' }, created_at: '2026-01-01T00:00:00.000Z', updated_at: '2026-01-01T00:00:00.000Z', ...overrides, @@ -96,31 +100,31 @@ function fakeEvent(seq: number): TaskEvent { return { seq, at: '2026-01-01T00:00:00.000Z', type: 'commit', data: { message: `commit ${seq}` } } } -/** exactOptionalPropertyTypes forbids `{ brain_ticket: undefined }`: the key must be ABSENT, not present-as-undefined. */ -function withoutBrainTicket(record: TaskRecord): TaskRecord { - const { brain_ticket: _dropped, ...rest } = record +/** exactOptionalPropertyTypes forbids `{ hub_ticket: undefined }`: the key must be ABSENT, not present-as-undefined. */ +function withoutHubTicket(record: TaskRecord): TaskRecord { + const { hub_ticket: _dropped, ...rest } = record return rest } -describe('task-brain', () => { +describe('task-hub', () => { const previousConfigDir = process.env.CODESEMA_CONFIG_DIR let configDir: string let cwd: string beforeEach(() => { - configDir = mkdtempSync(join(tmpdir(), 'codesema-brain-cfg-')) + configDir = mkdtempSync(join(tmpdir(), 'codesema-hub-cfg-')) process.env.CODESEMA_CONFIG_DIR = configDir - cwd = mkdtempSync(join(tmpdir(), 'codesema-brain-repo-')) + cwd = mkdtempSync(join(tmpdir(), 'codesema-hub-repo-')) saveGlobalConfig({ ...loadGlobalConfig(), - syncUrl: 'https://brain.example', + syncUrl: 'https://hub.example', syncWorkspaceId: 'ws1', syncSecret: 'sec1', }) }) afterEach(() => { - resetPendingBrainEventBatches() + resetPendingHubEventBatches() rmSync(configDir, { recursive: true, force: true }) rmSync(cwd, { recursive: true, force: true }) if (previousConfigDir === undefined) { @@ -130,18 +134,18 @@ describe('task-brain', () => { } }) - describe('reportBrainTransition', () => { + describe('reportHubTransition', () => { test('a successful report carries the right URL, Bearer header and body', async () => { const calls: Call[] = [] const record = fakeRecord() - await reportBrainTransition( + await reportHubTransition( cwd, record, { type: 'mr_opened', mr_url: 'https://forge.example/mr/1', branch: 'codesema/task-t' }, fetchStub(200, {}, calls), ) expect(calls.length).toBe(1) - expect(calls[0]?.url).toBe('https://brain.example/api/cli/tickets/tkt-1/transitions') + expect(calls[0]?.url).toBe('https://hub.example/api/cli/tickets/tkt-1/transitions') expect(calls[0]?.init.method).toBe('POST') const headers = calls[0]?.init.headers as Record<string, string> expect(headers.authorization).toBe('Bearer csk_ws1.sec1') @@ -157,17 +161,17 @@ describe('task-brain', () => { test('two review_result reports for the same task at different turn counts get distinct idempotency keys', async () => { // A fix-loop round settles a SECOND, genuinely different verdict on the - // same task; the brain must not read it as a retry of the first and + // same task; the hub must not read it as a retry of the first and // drop it as an already-applied duplicate. const calls: Call[] = [] const fetchImpl = fetchStub(200, {}, calls) - await reportBrainTransition( + await reportHubTransition( cwd, fakeRecord({ turns: [] }), { type: 'review_result', verdict: 'request_changes' }, fetchImpl, ) - await reportBrainTransition( + await reportHubTransition( cwd, fakeRecord({ turns: [fakeTurn()] }), { type: 'review_result', verdict: 'approve' }, @@ -177,16 +181,16 @@ describe('task-brain', () => { expect(keys).toEqual(['abc123def456:review_result:0', 'abc123def456:review_result:1']) }) - test('a task with no brain_ticket is a no-op', async () => { + test('a task with no hub_ticket is a no-op', async () => { const calls: Call[] = [] - const record = withoutBrainTicket(fakeRecord()) - await reportBrainTransition(cwd, record, { type: 'mr_opened' }, fetchStub(200, {}, calls)) + const record = withoutHubTicket(fakeRecord()) + await reportHubTransition(cwd, record, { type: 'mr_opened' }, fetchStub(200, {}, calls)) expect(calls.length).toBe(0) }) test('a network failure queues the report in the outbox', async () => { const record = fakeRecord() - await reportBrainTransition(cwd, record, { type: 'merged' }, fetchOffline()) + await reportHubTransition(cwd, record, { type: 'merged' }, fetchOffline()) const lines = outboxLines(cwd) expect(lines.length).toBe(1) const entry = lines[0] as { kind: string; ticket_id: string; transition: { type: string } } @@ -198,7 +202,7 @@ describe('task-brain', () => { test('a 5xx queues the report in the outbox', async () => { const calls: Call[] = [] const record = fakeRecord() - await reportBrainTransition( + await reportHubTransition( cwd, record, { type: 'merged' }, @@ -210,7 +214,7 @@ describe('task-brain', () => { test('a 4xx is logged and abandoned, never queued', async () => { const calls: Call[] = [] const record = fakeRecord() - await reportBrainTransition( + await reportHubTransition( cwd, record, { type: 'failed', error_message: 'boom' }, @@ -221,61 +225,61 @@ describe('task-brain', () => { }) }) - describe('flushBrainOutbox', () => { + describe('flushHubOutbox', () => { test('replays a queued transition and empties the outbox on success', async () => { const record = fakeRecord() - await reportBrainTransition(cwd, record, { type: 'merged' }, fetchOffline()) + await reportHubTransition(cwd, record, { type: 'merged' }, fetchOffline()) expect(outboxLines(cwd).length).toBe(1) const calls: Call[] = [] - await flushBrainOutbox(cwd, fetchStub(200, {}, calls)) + await flushHubOutbox(cwd, fetchStub(200, {}, calls)) expect(calls.length).toBe(1) - expect(calls[0]?.url).toBe('https://brain.example/api/cli/tickets/tkt-1/transitions') + expect(calls[0]?.url).toBe('https://hub.example/api/cli/tickets/tkt-1/transitions') expect(outboxLines(cwd)).toEqual([]) }) test('a 409 on replay drops the entry rather than keeping it queued', async () => { const record = fakeRecord() - await reportBrainTransition(cwd, record, { type: 'merged' }, fetchOffline()) + await reportHubTransition(cwd, record, { type: 'merged' }, fetchOffline()) expect(outboxLines(cwd).length).toBe(1) const calls: Call[] = [] - await flushBrainOutbox(cwd, fetchStub(409, { error: 'already applied' }, calls)) + await flushHubOutbox(cwd, fetchStub(409, { error: 'already applied' }, calls)) expect(calls.length).toBe(1) expect(outboxLines(cwd)).toEqual([]) }) test('still offline: the entry is kept, not lost', async () => { const record = fakeRecord() - await reportBrainTransition(cwd, record, { type: 'merged' }, fetchOffline()) + await reportHubTransition(cwd, record, { type: 'merged' }, fetchOffline()) expect(outboxLines(cwd).length).toBe(1) - await flushBrainOutbox(cwd, fetchOffline('still offline')) + await flushHubOutbox(cwd, fetchOffline('still offline')) expect(outboxLines(cwd).length).toBe(1) }) test('no outbox file: a no-op', async () => { const calls: Call[] = [] - await flushBrainOutbox(cwd, fetchStub(200, {}, calls)) + await flushHubOutbox(cwd, fetchStub(200, {}, calls)) expect(calls.length).toBe(0) }) }) - describe('heartbeatBrainTicket', () => { + describe('heartbeatHubTicket', () => { test('posts to the ticket heartbeat route with the Bearer header', async () => { const calls: Call[] = [] - await heartbeatBrainTicket(cwd, fakeRecord(), undefined, fetchStub(200, {}, calls)) + await heartbeatHubTicket(cwd, fakeRecord(), undefined, fetchStub(200, {}, calls)) expect(calls.length).toBe(1) - expect(calls[0]?.url).toBe('https://brain.example/api/cli/tickets/tkt-1/heartbeat') + expect(calls[0]?.url).toBe('https://hub.example/api/cli/tickets/tkt-1/heartbeat') const headers = calls[0]?.init.headers as Record<string, string> expect(headers.authorization).toBe('Bearer csk_ws1.sec1') }) - test('a task with no brain_ticket is a no-op', async () => { + test('a task with no hub_ticket is a no-op', async () => { const calls: Call[] = [] - await heartbeatBrainTicket( + await heartbeatHubTicket( cwd, - withoutBrainTicket(fakeRecord()), + withoutHubTicket(fakeRecord()), undefined, fetchStub(200, {}, calls), ) @@ -284,24 +288,24 @@ describe('task-brain', () => { test('sends local_status in the body when given', async () => { const calls: Call[] = [] - await heartbeatBrainTicket(cwd, fakeRecord(), 'waiting_for_you', fetchStub(200, {}, calls)) + await heartbeatHubTicket(cwd, fakeRecord(), 'waiting_for_you', fetchStub(200, {}, calls)) expect(requestBody(calls[0] as Call)).toEqual({ local_status: 'waiting_for_you' }) }) test('omits local_status from the body when not given', async () => { const calls: Call[] = [] - await heartbeatBrainTicket(cwd, fakeRecord(), undefined, fetchStub(200, {}, calls)) + await heartbeatHubTicket(cwd, fakeRecord(), undefined, fetchStub(200, {}, calls)) expect(requestBody(calls[0] as Call)).toEqual({}) }) - test('returns the sanitized order the brain hands back', async () => { + test('returns the sanitized order the hub hands back', async () => { const order: ArmOrder = { action: 'ship', instruction: null, issued_at: '2026-01-01T00:00:00.000Z', } const fetchImpl = fetchStub(200, { lease_expires_at: '2026-01-01T00:05:00.000Z', order }, []) - const result = await heartbeatBrainTicket(cwd, fakeRecord(), undefined, fetchImpl) + const result = await heartbeatHubTicket(cwd, fakeRecord(), undefined, fetchImpl) expect(result).toEqual(order) }) @@ -311,40 +315,40 @@ describe('task-brain', () => { { lease_expires_at: '2026-01-01T00:05:00.000Z', order: null }, [], ) - const result = await heartbeatBrainTicket(cwd, fakeRecord(), undefined, fetchImpl) + const result = await heartbeatHubTicket(cwd, fakeRecord(), undefined, fetchImpl) expect(result).toBeNull() }) test('returns null, without throwing, when the success body is empty or not JSON', async () => { const fetchImpl = (() => Promise.resolve(new Response('', { status: 200 }))) as unknown as typeof fetch - const result = await heartbeatBrainTicket(cwd, fakeRecord(), undefined, fetchImpl) + const result = await heartbeatHubTicket(cwd, fakeRecord(), undefined, fetchImpl) expect(result).toBeNull() }) test('returns null, without throwing, on a network failure', async () => { - const result = await heartbeatBrainTicket(cwd, fakeRecord(), undefined, fetchOffline()) + const result = await heartbeatHubTicket(cwd, fakeRecord(), undefined, fetchOffline()) expect(result).toBeNull() }) }) - describe('queueBrainEvent', () => { + describe('queueHubEvent', () => { test('flushes once the batch reaches its cap, as one POST /api/cli/events', async () => { const calls: Call[] = [] const record = fakeRecord() const fetchImpl = fetchStub(200, {}, calls) for (let i = 1; i <= 20; i++) { - queueBrainEvent({ + queueHubEvent({ cwd, taskId: record.id, - ticketId: record.brain_ticket?.id ?? '', + ticketId: record.hub_ticket?.id ?? '', event: fakeEvent(i), fetchImpl, }) } await settle() expect(calls.length).toBe(1) - expect(calls[0]?.url).toBe('https://brain.example/api/cli/events') + expect(calls[0]?.url).toBe('https://hub.example/api/cli/events') const body = requestBody(calls[0] as Call) expect(body.run_id).toBe(record.id) expect(body.ticket_id).toBe('tkt-1') @@ -361,7 +365,7 @@ describe('task-brain', () => { const calls: Call[] = [] const fetchImpl = fetchStub(200, {}, calls) for (let i = 1; i <= 20; i++) { - queueBrainEvent({ + queueHubEvent({ cwd, taskId: 'task-a', ticketId: 'tkt-1', @@ -378,7 +382,7 @@ describe('task-brain', () => { // cached URL rather than a fresh (and now null) read. execFileSync('git', ['remote', 'remove', 'origin'], { cwd, stdio: 'ignore' }) for (let i = 1; i <= 20; i++) { - queueBrainEvent({ + queueHubEvent({ cwd, taskId: 'task-b', ticketId: 'tkt-1', @@ -391,4 +395,65 @@ describe('task-brain', () => { expect(requestBody(calls[1] as Call).remote_url).toBe('git@github.com:o/r.git') }) }) + + describe('legacy brain-outbox.jsonl migration', () => { + function writeLegacyOutbox(entries: unknown[]): void { + mkdirSync(join(cwd, '.codesema'), { recursive: true }) + writeFileSync( + legacyOutboxPath(cwd), + entries.map((entry) => `${JSON.stringify(entry)}\n`).join(''), + ) + } + + test('a legacy outbox is renamed and its entries replayed by flushHubOutbox', async () => { + writeLegacyOutbox([ + { + kind: 'transition', + key: 'legacy-1', + ticket_id: 'tkt-1', + transition: { + type: 'merged', + idempotency_key: 'legacy-1', + at: '2026-01-01T00:00:00.000Z', + }, + }, + ]) + const calls: Call[] = [] + await flushHubOutbox(cwd, fetchStub(200, {}, calls)) + expect(calls.length).toBe(1) + expect(existsSync(legacyOutboxPath(cwd))).toBe(false) + expect(outboxLines(cwd)).toEqual([]) + }) + + test('a legacy outbox is migrated on write too: a new offline report lands beside the old entries', async () => { + writeLegacyOutbox([ + { + kind: 'transition', + key: 'legacy-1', + ticket_id: 'tkt-1', + transition: { + type: 'merged', + idempotency_key: 'legacy-1', + at: '2026-01-01T00:00:00.000Z', + }, + }, + ]) + await reportHubTransition(cwd, fakeRecord(), { type: 'failed' }, fetchOffline()) + expect(existsSync(legacyOutboxPath(cwd))).toBe(false) + expect(outboxLines(cwd).length).toBe(2) + }) + + test('a legacy file reappearing after hub-outbox.jsonl already exists is never touched again', async () => { + await reportHubTransition(cwd, fakeRecord(), { type: 'failed' }, fetchOffline()) + expect(outboxLines(cwd).length).toBe(1) + // hub-outbox.jsonl already exists (even once flushed empty below), so the + // migration guard (`!existsSync(path)`) skips a legacy file from here on. + writeLegacyOutbox([ + { kind: 'transition', key: 'legacy-2', ticket_id: 'tkt-1', transition: {} }, + ]) + await flushHubOutbox(cwd, fetchStub(200, {}, [])) + expect(existsSync(legacyOutboxPath(cwd))).toBe(true) + expect(outboxLines(cwd)).toEqual([]) + }) + }) }) diff --git a/packages/cli/src/task-brain.ts b/packages/cli/src/task-hub.ts similarity index 74% rename from packages/cli/src/task-brain.ts rename to packages/cli/src/task-hub.ts index c4abb67..9147f8e 100644 --- a/packages/cli/src/task-brain.ts +++ b/packages/cli/src/task-hub.ts @@ -1,24 +1,24 @@ -// Fire-and-forget reporting from the arm (this CLI) back to the brain: the +// Fire-and-forget reporting from the arm (this CLI) back to the hub: the // local SaaS that owns a ticket while this workspace executes it. Same // doctrine as task-labels.ts, its closest sibling: never blocks a task // transition on a network round trip, and a failure that could not be // recovered by the outbox is always logged, never swallowed. // -// The brain is reached at the SAME base URL and with the SAME bearer +// The hub is reached at the SAME base URL and with the SAME bearer // credentials as codesema.com cloud sync (sync.ts): `loadSyncCredentials()` // and `authHeader()`. No credentials configured, or a task with no -// `brain_ticket`: every export here degrades to a no-op, never a throw, the +// `hub_ticket`: every export here degrades to a no-op, never a throw, the // same degrade-to-nothing contract as `pushReview`/`autoPushReview`. // -// Outbox (`.codesema/brain-outbox.jsonl`): same append-only recipe as +// Outbox (`.codesema/hub-outbox.jsonl`): same append-only recipe as // tasks-store.ts's events.jsonl, one JSON line per entry. A report that hit // a network failure or a 5xx is appended here and replayed by -// `flushBrainOutbox`; a 4xx (the brain itself rejected the body, a stale +// `flushHubOutbox`; a 4xx (the hub itself rejected the body, a stale // idempotency key included, on a 409) is logged once and dropped, never // retried: resending the exact same rejected body would only repeat the // rejection. -import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { ensureWorkDir } from './config.js' import { @@ -35,9 +35,9 @@ import { import { tryGitAsync } from './git.js' import { authHeader, loadSyncCredentials, type SyncCredentials } from './sync.js' -const BRAIN_REQUEST_TIMEOUT_MS = 10_000 -const BRAIN_EVENT_BATCH_MAX = 20 -const BRAIN_EVENT_BATCH_DELAY_MS = 5_000 +const HUB_REQUEST_TIMEOUT_MS = 10_000 +const HUB_EVENT_BATCH_MAX = 20 +const HUB_EVENT_BATCH_DELAY_MS = 5_000 /** * A separator that cannot appear in a `cwd` (an absolute path) or a 12-hex @@ -50,17 +50,34 @@ const BRAIN_EVENT_BATCH_DELAY_MS = 5_000 */ const KEY_SEP = String.fromCharCode(0) -function brainOutboxPath(cwd: string): string { +function hubOutboxPath(cwd: string): string { + return join(cwd, '.codesema', 'hub-outbox.jsonl') +} + +function legacyOutboxPath(cwd: string): string { return join(cwd, '.codesema', 'brain-outbox.jsonl') } +// Pre-rename `brain-outbox.jsonl` is migrated to `hub-outbox.jsonl` on first access; failure leaves the legacy file in place. +function migrateLegacyOutbox(cwd: string): void { + const legacyPath = legacyOutboxPath(cwd) + const path = hubOutboxPath(cwd) + if (existsSync(legacyPath) && !existsSync(path)) { + try { + renameSync(legacyPath, path) + } catch { + // Best-effort: an unwritable directory just leaves the legacy file in place. + } + } +} + /** * One entry of the outbox. `key` is a local label only (never sent to the - * brain): it names the report in a log line and lets a caller recognise its + * hub): it names the report in a log line and lets a caller recognise its * own write, never a server-side idempotency mechanism. Only * `ArmTransition.idempotency_key`, inside `transition`, is that. */ -type BrainOutboxEntry = +type HubOutboxEntry = | { kind: 'transition'; key: string; ticket_id: string; transition: ArmTransition } | { kind: 'events' @@ -71,20 +88,21 @@ type BrainOutboxEntry = events: ArmEvent[] } -function appendToOutbox(cwd: string, entry: BrainOutboxEntry): void { +function appendToOutbox(cwd: string, entry: HubOutboxEntry): void { ensureWorkDir(cwd) + migrateLegacyOutbox(cwd) try { const line = `${JSON.stringify(entry)}\n` - writeFileSync(brainOutboxPath(cwd), line, { flag: 'a' }) + writeFileSync(hubOutboxPath(cwd), line, { flag: 'a' }) } catch (err) { // The outbox itself could not be written (disk full, permissions): the // report is lost, and that is said rather than silently swallowed. - logBrainFailure(`outbox write (${entry.kind}, ${entry.key})`, errorMessage(err)) + logHubFailure(`outbox write (${entry.kind}, ${entry.key})`, errorMessage(err)) } } -function logBrainFailure(action: string, detail: string): void { - console.warn(`[brain] ${action}: ${detail}`) +function logHubFailure(action: string, detail: string): void { + console.warn(`[hub] ${action}: ${detail}`) } function errorMessage(err: unknown): string { @@ -102,7 +120,7 @@ function errorMessage(err: unknown): string { const originRemoteUrlCache = new Map<string, Promise<string | null>>() /** - * Same read as server-context.ts: raw, unnormalized; the brain normalizes it + * Same read as server-context.ts: raw, unnormalized; the hub normalizes it * server-side. `tryGitAsync`, never the synchronous `tryGit`: this runs on * every event-batch flush, and a synchronous git call would block the WHOLE * process for its duration (git.ts's own doc comment on `tryGitAsync` @@ -118,24 +136,24 @@ function originRemoteUrl(cwd: string): Promise<string | null> { return promise } -type BrainPostOutcome = +type HubPostOutcome = | { kind: 'ok'; body: unknown } | { kind: 'client_error'; status: number; detail: string } | { kind: 'retryable'; detail: string } -async function postToBrain( +async function postToHub( path: string, body: unknown, creds: SyncCredentials, fetchImpl: typeof fetch, -): Promise<BrainPostOutcome> { +): Promise<HubPostOutcome> { let res: Response try { res = await fetchImpl(`${creds.url}${path}`, { method: 'POST', headers: { 'content-type': 'application/json', ...authHeader(creds) }, body: JSON.stringify(body), - signal: AbortSignal.timeout(BRAIN_REQUEST_TIMEOUT_MS), + signal: AbortSignal.timeout(HUB_REQUEST_TIMEOUT_MS), }) } catch (err) { return { kind: 'retryable', detail: errorMessage(err) } @@ -151,7 +169,7 @@ async function postToBrain( } const parsed = (await res.json().catch(() => ({}))) as { error?: unknown } const detail = typeof parsed.error === 'string' ? parsed.error : `HTTP ${res.status}` - // 5xx: the brain itself is unwell, worth a retry once it recovers. Anything + // 5xx: the hub itself is unwell, worth a retry once it recovers. Anything // else in the 4xx family: the request itself was refused (bad body, unknown // ticket, a 409 replay of an idempotency key already applied) and a retry // would only repeat the same refusal. @@ -161,10 +179,10 @@ async function postToBrain( } /** - * Reports one fact about a brain ticket's execution back to the brain: + * Reports one fact about a hub ticket's execution back to the hub: * `mr_opened` on ship, `review_result` on a settled review verdict, `merged` * on a landed merge, `failed` on a failure or an explicit interruption. A - * no-op for a task that carries no `brain_ticket`, and for a machine with no + * no-op for a task that carries no `hub_ticket`, and for a machine with no * sync credentials configured. * * `idempotency_key` and `at` are computed here, never by the caller: the key @@ -173,20 +191,20 @@ async function postToBrain( * outbox replay) land on the SAME fact rather than mint a second one, while * still telling apart two DIFFERENT facts of the same type on the same task * (`review_result` after each of several fix-loop rounds carries a genuinely - * different verdict; a constant key would have the brain read every round + * different verdict; a constant key would have the hub read every round * past the first as a duplicate of the first and drop it). * - * Never throws. Offline, or a 5xx: appended to `.codesema/brain-outbox.jsonl` - * for `flushBrainOutbox` to replay later. A 4xx: logged once and abandoned, + * Never throws. Offline, or a 5xx: appended to `.codesema/hub-outbox.jsonl` + * for `flushHubOutbox` to replay later. A 4xx: logged once and abandoned, * never retried. */ -export async function reportBrainTransition( +export async function reportHubTransition( cwd: string, record: TaskRecord, transition: Omit<ArmTransition, 'idempotency_key' | 'at'>, fetchImpl: typeof fetch = fetch, ): Promise<void> { - const ticketId = record.brain_ticket?.id + const ticketId = record.hub_ticket?.id if (!ticketId) { return } @@ -198,11 +216,11 @@ export async function reportBrainTransition( const label = `transition '${transition.type}' for task ${record.id}` const creds = loadSyncCredentials() if (!creds) { - logBrainFailure(label, 'no sync credentials configured') + logHubFailure(label, 'no sync credentials configured') return } try { - const outcome = await postToBrain( + const outcome = await postToHub( `/api/cli/tickets/${encodeURIComponent(ticketId)}/transitions`, full, creds, @@ -212,13 +230,10 @@ export async function reportBrainTransition( return } if (outcome.kind === 'client_error') { - logBrainFailure( - label, - `rejected by the brain (${outcome.status}): ${outcome.detail}; abandoned`, - ) + logHubFailure(label, `rejected by the hub (${outcome.status}): ${outcome.detail}; abandoned`) return } - logBrainFailure(label, `${outcome.detail}; queued for retry`) + logHubFailure(label, `${outcome.detail}; queued for retry`) appendToOutbox(cwd, { kind: 'transition', key: full.idempotency_key, @@ -226,11 +241,11 @@ export async function reportBrainTransition( transition: full, }) } catch (err) { - // The seam contract says postToBrain never rejects, but a fire-and-forget + // The seam contract says postToHub never rejects, but a fire-and-forget // effect must not depend on that holding forever (same discipline as // task-labels.ts's syncCycleLabel): caught here rather than left to // become an unhandled rejection. - logBrainFailure(label, `${errorMessage(err)}; queued for retry`) + logHubFailure(label, `${errorMessage(err)}; queued for retry`) appendToOutbox(cwd, { kind: 'transition', key: full.idempotency_key, @@ -242,7 +257,7 @@ export async function reportBrainTransition( /** * Reads the `order` field off a heartbeat response body without assuming its - * shape: `body` is `unknown` (postToBrain only ever confirms "this parsed as + * shape: `body` is `unknown` (postToHub only ever confirms "this parsed as * JSON"), so this is the one narrowing step between the wire and * `sanitizeArmOrder`, which validates everything else about it. */ @@ -253,32 +268,32 @@ function orderFieldOf(body: unknown): unknown { } /** - * Sends a heartbeat for a task's brain ticket lease, and returns the order a + * Sends a heartbeat for a task's hub ticket lease, and returns the order a * human decided from the dashboard while this ticket sat waiting (D19): * ship, reply with an instruction, or abandon. `null` on every ordinary tick * nothing is waiting on, and on any failure. * * No outbox: a missed heartbeat is superseded by the next one (the daemon * owns the 45s timer, not this module), and a stale order is superseded the - * same way (the brain purges an order the moment it hands it back, so the + * same way (the hub purges an order the moment it hands it back, so the * next heartbeat only ever carries a fresh one, or none). Retrying either is * never useful. Never throws. * - * `localStatus`, when given, rides along as `local_status` so the brain can + * `localStatus`, when given, rides along as `local_status` so the hub can * show this ticket as waiting (or not) on its own dashboard; omitted, the * body is `{}`, same as before D19. * * `cwd` (unused) is kept for call-shape symmetry with this module's other - * exports (`reportBrainTransition`, `queueBrainEvent`), all of which the + * exports (`reportHubTransition`, `queueHubEvent`), all of which the * daemon calls the same way; a heartbeat needs only the ticket id. */ -export async function heartbeatBrainTicket( +export async function heartbeatHubTicket( _cwd: string, record: TaskRecord, localStatus?: TaskStatus, fetchImpl: typeof fetch = fetch, ): Promise<ArmOrder | null> { - const ticketId = record.brain_ticket?.id + const ticketId = record.hub_ticket?.id if (!ticketId) { return null } @@ -288,19 +303,19 @@ export async function heartbeatBrainTicket( } const label = `heartbeat for task ${record.id}` try { - const outcome = await postToBrain( + const outcome = await postToHub( `/api/cli/tickets/${encodeURIComponent(ticketId)}/heartbeat`, localStatus ? { local_status: localStatus } : {}, creds, fetchImpl, ) if (outcome.kind !== 'ok') { - logBrainFailure(label, outcome.detail) + logHubFailure(label, outcome.detail) return null } return sanitizeArmOrder(orderFieldOf(outcome.body)) } catch (err) { - logBrainFailure(label, errorMessage(err)) + logHubFailure(label, errorMessage(err)) return null } } @@ -317,7 +332,7 @@ type PendingEventBatch = { const pendingEventBatches = new Map<string, PendingEventBatch>() -/** The label a journal line carries to the brain: its own message, its own name, or its bare type. */ +/** The label a journal line carries to the hub: its own message, its own name, or its bare type. */ function armEventLabel(event: TaskEvent): string { const data = event.data as Record<string, unknown> | undefined if (typeof data?.message === 'string' && data.message) { @@ -334,7 +349,7 @@ function armEventFrom(taskId: string, event: TaskEvent): ArmEvent { run_id: taskId, at: event.at, event_type: event.type, - // Bounded HERE, not only by the brain's schema: one oversized label (a + // Bounded HERE, not only by the hub's schema: one oversized label (a // forge CLI dumping its usage text into a message) must degrade to a cut // label, never poison its whole batch with a 422. label: cutCodePoints(armEventLabel(event), ARM_LABEL_MAX) || event.type, @@ -352,7 +367,7 @@ async function flushEventBatch(key: string, fetchImpl: typeof fetch): Promise<vo const label = `${batch.events.length} event(s) for task ${batch.runId}` const creds = loadSyncCredentials() if (!creds) { - logBrainFailure(label, 'no sync credentials configured') + logHubFailure(label, 'no sync credentials configured') return } const remoteUrl = await originRemoteUrl(batch.cwd) @@ -373,35 +388,32 @@ async function flushEventBatch(key: string, fetchImpl: typeof fetch): Promise<vo }) } try { - const outcome = await postToBrain('/api/cli/events', body, creds, fetchImpl) + const outcome = await postToHub('/api/cli/events', body, creds, fetchImpl) if (outcome.kind === 'ok') { return } if (outcome.kind === 'client_error') { - logBrainFailure( - label, - `rejected by the brain (${outcome.status}): ${outcome.detail}; abandoned`, - ) + logHubFailure(label, `rejected by the hub (${outcome.status}): ${outcome.detail}; abandoned`) return } - logBrainFailure(label, `${outcome.detail}; queued for retry`) + logHubFailure(label, `${outcome.detail}; queued for retry`) enqueueForRetry() } catch (err) { - logBrainFailure(label, `${errorMessage(err)}; queued for retry`) + logHubFailure(label, `${errorMessage(err)}; queued for retry`) enqueueForRetry() } } /** - * Queues one task journal line for the brain, batched with its task's other + * Queues one task journal line for the hub, batched with its task's other * pending lines into ONE `POST /api/cli/events`, sent once 20 events have * queued, or 5s after the first one did, whichever comes first. Meant to be - * called only for a task that carries a `brain_ticket` (`tasks-store.ts`'s + * called only for a task that carries a `hub_ticket` (`tasks-store.ts`'s * `appendTaskEvent` is the one caller, gated on that); `ticketId` is taken * from it directly rather than re-derived, so this module never has to load * a task record to do its job. Never throws. */ -export function queueBrainEvent(opts: { +export function queueHubEvent(opts: { cwd: string taskId: string ticketId: string @@ -414,14 +426,14 @@ export function queueBrainEvent(opts: { const existing = pendingEventBatches.get(key) if (existing) { existing.events.push(armEvent) - if (existing.events.length >= BRAIN_EVENT_BATCH_MAX) { + if (existing.events.length >= HUB_EVENT_BATCH_MAX) { void flushEventBatch(key, fetchImpl) } return } const timer = setTimeout(() => { void flushEventBatch(key, fetchImpl) - }, BRAIN_EVENT_BATCH_DELAY_MS) + }, HUB_EVENT_BATCH_DELAY_MS) // A pending batch must never keep the process alive on its own: shutdown // must not wait out a 5s timer nobody else is blocking on. timer.unref?.() @@ -432,7 +444,7 @@ export function queueBrainEvent(opts: { * Test hygiene: drops every pending batch and its timer, and the cached * origin-remote reads alongside it. Never used in production code. */ -export function resetPendingBrainEventBatches(): void { +export function resetPendingHubEventBatches(): void { for (const batch of pendingEventBatches.values()) { clearTimeout(batch.timer) } @@ -442,7 +454,7 @@ export function resetPendingBrainEventBatches(): void { // --- outbox replay ----------------------------------------------------------- -function outboxRequest(entry: BrainOutboxEntry): { path: string; body: unknown } { +function outboxRequest(entry: HubOutboxEntry): { path: string; body: unknown } { if (entry.kind === 'transition') { return { path: `/api/cli/tickets/${encodeURIComponent(entry.ticket_id)}/transitions`, @@ -461,19 +473,17 @@ function outboxRequest(entry: BrainOutboxEntry): { path: string; body: unknown } } /** - * Replays every entry `.codesema/brain-outbox.jsonl` holds, in file order, + * Replays every entry `.codesema/hub-outbox.jsonl` holds, in file order, * and rewrites the file with only what still could not be sent. A line this * process cannot parse (a hand edit, a crash-truncated tail) is dropped * rather than kept forever unreadable, the same tolerance * `tasks-store.ts`'s own journal reader gives a corrupt event line. A 4xx on - * replay (a 409 included: the brain already applied this idempotency key) + * replay (a 409 included: the hub already applied this idempotency key) * drops the entry for good, same rule as a fresh send. Never throws. */ -export async function flushBrainOutbox( - cwd: string, - fetchImpl: typeof fetch = fetch, -): Promise<void> { - const path = brainOutboxPath(cwd) +export async function flushHubOutbox(cwd: string, fetchImpl: typeof fetch = fetch): Promise<void> { + migrateLegacyOutbox(cwd) + const path = hubOutboxPath(cwd) if (!existsSync(path)) { return } @@ -484,14 +494,14 @@ export async function flushBrainOutbox( return } const creds = loadSyncCredentials() - const remaining: BrainOutboxEntry[] = [] + const remaining: HubOutboxEntry[] = [] for (const line of raw.split('\n')) { if (!line.trim()) { continue } - let entry: BrainOutboxEntry + let entry: HubOutboxEntry try { - entry = JSON.parse(line) as BrainOutboxEntry + entry = JSON.parse(line) as HubOutboxEntry } catch { continue } @@ -502,19 +512,19 @@ export async function flushBrainOutbox( const { path: requestPath, body } = outboxRequest(entry) const label = `outbox replay (${entry.kind}, ${entry.key})` try { - const outcome = await postToBrain(requestPath, body, creds, fetchImpl) + const outcome = await postToHub(requestPath, body, creds, fetchImpl) if (outcome.kind === 'retryable') { - logBrainFailure(label, `${outcome.detail}; kept for retry`) + logHubFailure(label, `${outcome.detail}; kept for retry`) remaining.push(entry) } else if (outcome.kind === 'client_error') { - logBrainFailure( + logHubFailure( label, - `rejected by the brain (${outcome.status}): ${outcome.detail}; abandoned`, + `rejected by the hub (${outcome.status}): ${outcome.detail}; abandoned`, ) } // 'ok': dropped in silence, a successful replay is not news. } catch (err) { - logBrainFailure(label, `${errorMessage(err)}; kept for retry`) + logHubFailure(label, `${errorMessage(err)}; kept for retry`) remaining.push(entry) } } diff --git a/packages/cli/src/task-merge.test.ts b/packages/cli/src/task-merge.test.ts index 707a60c..3164d5b 100644 --- a/packages/cli/src/task-merge.test.ts +++ b/packages/cli/src/task-merge.test.ts @@ -696,7 +696,7 @@ describe("the merge gate's git reads are bounded (MAJEUR 2)", () => { `const started = Date.now()`, `const outcome = await mergeTask({`, ` cwd: ${JSON.stringify(cwd)},`, - ` brainAutoMerge: true,`, + ` runnerAutoMerge: true,`, ` task: ${JSON.stringify(greenTask())},`, ` settings: ${JSON.stringify(settings({ policy: 'auto' }))},`, // Injected: this test is about the ONE git read left on this path. @@ -749,7 +749,7 @@ describe('mergeTask under mergePolicy: human (the default)', () => { const repo = makeRepoWithOrigin('git@github.com:o/r.git') const forge = recordingForge() const outcome = await mergeTask({ - brainAutoMerge: true, + runnerAutoMerge: true, cwd: repo, task: greenTask(), settings: settings(), @@ -772,7 +772,7 @@ describe('mergeTask under mergePolicy: human (the default)', () => { const repo = makeRepoWithOrigin('git@github.com:o/r.git') const forge = recordingForge() const outcome = await mergeTask({ - brainAutoMerge: true, + runnerAutoMerge: true, cwd: repo, task: greenTask(), settings: settings(), @@ -792,7 +792,7 @@ describe('mergeTask under mergePolicy: auto', () => { const repo = makeRepoWithOrigin('git@github.com:o/r.git') const forge = recordingForge({ kind: 'ok', stdout: 'Merged pull request #7' }) const outcome = await mergeTask({ - brainAutoMerge: true, + runnerAutoMerge: true, cwd: repo, task: greenTask(), settings: auto(), @@ -810,7 +810,7 @@ describe('mergeTask under mergePolicy: auto', () => { const repo = makeRepoWithOrigin('git@gitlab.com:o/r.git') const forge = recordingForge({ kind: 'ok', stdout: '' }) await mergeTask({ - brainAutoMerge: true, + runnerAutoMerge: true, cwd: repo, task: greenTask(), settings: auto(), @@ -829,7 +829,7 @@ describe('mergeTask under mergePolicy: auto', () => { const repo = makeRepoWithOrigin('git@github.com:o/r.git') const forge = recordingForge() await mergeTask({ - brainAutoMerge: true, + runnerAutoMerge: true, cwd: repo, task: greenTask(), settings: auto(), @@ -844,7 +844,7 @@ describe('mergeTask under mergePolicy: auto', () => { test('an explicit strategy reaches the argv, per CLI', async () => { const gh = recordingForge() await mergeTask({ - brainAutoMerge: true, + runnerAutoMerge: true, cwd: makeRepoWithOrigin('git@github.com:o/r.git'), task: greenTask(), settings: auto({ strategy: 'squash' }), @@ -855,7 +855,7 @@ describe('mergeTask under mergePolicy: auto', () => { const glab = recordingForge() await mergeTask({ - brainAutoMerge: true, + runnerAutoMerge: true, cwd: makeRepoWithOrigin('git@gitlab.com:o/r.git'), task: greenTask(), settings: auto({ strategy: 'rebase' }), @@ -868,7 +868,7 @@ describe('mergeTask under mergePolicy: auto', () => { test("glab has no merge-commit flag: 'merge' sends none rather than inventing one", async () => { const glab = recordingForge() await mergeTask({ - brainAutoMerge: true, + runnerAutoMerge: true, cwd: makeRepoWithOrigin('git@gitlab.com:o/r.git'), task: greenTask(), settings: auto({ strategy: 'merge' }), @@ -887,7 +887,7 @@ describe('mergeTask under mergePolicy: auto', () => { test('the branch is NOT deleted by default, and is on request', async () => { const kept = recordingForge() await mergeTask({ - brainAutoMerge: true, + runnerAutoMerge: true, cwd: makeRepoWithOrigin('git@github.com:o/r.git'), task: greenTask(), settings: auto(), @@ -898,7 +898,7 @@ describe('mergeTask under mergePolicy: auto', () => { const deleted = recordingForge() await mergeTask({ - brainAutoMerge: true, + runnerAutoMerge: true, cwd: makeRepoWithOrigin('git@github.com:o/r.git'), task: greenTask(), settings: auto({ deleteBranch: true }), @@ -917,7 +917,7 @@ describe('mergeTask under mergePolicy: auto', () => { ]) { const forge = recordingForge() const outcome = await mergeTask({ - brainAutoMerge: true, + runnerAutoMerge: true, cwd: makeRepoWithOrigin('git@github.com:o/r.git'), task: greenTask(), settings: auto({ allowMergeWithoutChecks: false }), @@ -936,7 +936,7 @@ describe('mergeTask under mergePolicy: auto', () => { // one that depends on which condition happened to be evaluated last. const forge = recordingForge() const outcome = await mergeTask({ - brainAutoMerge: true, + runnerAutoMerge: true, cwd: makeRepoWithOrigin('git@github.com:o/r.git'), task: greenTask(), settings: auto(), @@ -982,7 +982,7 @@ describe('mergeTask under mergePolicy: auto', () => { test('a task with no criteria emits no merge command either (DP2)', async () => { const forge = recordingForge() const outcome = await mergeTask({ - brainAutoMerge: true, + runnerAutoMerge: true, cwd: makeRepoWithOrigin('git@github.com:o/r.git'), task: makeTask({ review_ref: '/nowhere/review.json' }), settings: auto(), @@ -997,7 +997,7 @@ describe('mergeTask under mergePolicy: auto', () => { test('the consent valve unblocks an unconfigured repo, and the merge happens', async () => { const forge = recordingForge() const outcome = await mergeTask({ - brainAutoMerge: true, + runnerAutoMerge: true, cwd: makeRepoWithOrigin('git@github.com:o/r.git'), task: greenTask(), settings: auto({ allowMergeWithoutChecks: true }), @@ -1013,7 +1013,7 @@ describe('mergeTask under mergePolicy: auto', () => { test('the valve never covers a broken runtime, and no command is emitted', async () => { const forge = recordingForge() const outcome = await mergeTask({ - brainAutoMerge: true, + runnerAutoMerge: true, cwd: makeRepoWithOrigin('git@github.com:o/r.git'), task: greenTask(), settings: auto({ allowMergeWithoutChecks: true }), @@ -1028,21 +1028,21 @@ describe('mergeTask under mergePolicy: auto', () => { }) }) -describe('arm/brain integration: brainAutoMerge overrides mergePolicy for a ticketed task', () => { - // `brainAutoMerge` is GLOBAL-ONLY (config.ts, REPO_IGNORED_GLOBAL_ONLY_KEYS): +describe('arm/runner integration: runnerAutoMerge overrides mergePolicy for a ticketed task', () => { + // `runnerAutoMerge` is GLOBAL-ONLY (config.ts, REPO_IGNORED_GLOBAL_ONLY_KEYS): // `mergeTask` never reads config at all any more, global or repo. The // caller (`runMergeStep`, task-server.ts) resolves the boolean once from - // the global file and hands it in as `opts.brainAutoMerge`, so every test + // the global file and hands it in as `opts.runnerAutoMerge`, so every test // below sets it directly, with no config directory to isolate. const ticketedGreenTask = (over: Partial<TaskRecord> = {}): TaskRecord => - greenTask({ brain_ticket: { id: 'tkt-1', title: 'x' }, ...over }) + greenTask({ hub_ticket: { id: 'tkt-1', title: 'x' }, ...over }) - test('a ticketed task merges under mergePolicy human when brainAutoMerge is true', async () => { + test('a ticketed task merges under mergePolicy human when runnerAutoMerge is true', async () => { const repo = makeRepoWithOrigin('git@github.com:o/r.git') const forge = recordingForge({ kind: 'ok', stdout: '' }) const outcome = await mergeTask({ cwd: repo, - brainAutoMerge: true, + runnerAutoMerge: true, task: ticketedGreenTask(), settings: settings({ policy: 'human' }), inputs: greenInputs(), @@ -1052,12 +1052,12 @@ describe('arm/brain integration: brainAutoMerge overrides mergePolicy for a tick expect(forge.calls.length).toBe(1) }) - test('brainAutoMerge: false holds a ticketed task, like any human-policy task', async () => { + test('runnerAutoMerge: false holds a ticketed task, like any human-policy task', async () => { const repo = makeRepoWithOrigin('git@github.com:o/r.git') const forge = recordingForge() const outcome = await mergeTask({ cwd: repo, - brainAutoMerge: false, + runnerAutoMerge: false, task: ticketedGreenTask(), settings: settings({ policy: 'human' }), inputs: greenInputs(), @@ -1067,16 +1067,16 @@ describe('arm/brain integration: brainAutoMerge overrides mergePolicy for a tick expect(forge.calls).toEqual([]) }) - test('mergeTask never reads config itself: a repo file setting brainAutoMerge has no effect', async () => { + test('mergeTask never reads config itself: a repo file setting runnerAutoMerge has no effect', async () => { const repo = makeRepoWithOrigin('git@github.com:o/r.git') // Global-only per REPO_IGNORED_GLOBAL_ONLY_KEYS: silently stripped from a // repo file already. Written here anyway, on purpose, so this test would // still catch it if `mergeTask` ever read config back on its own. - saveRepoConfig(repo, { brainAutoMerge: true }) + saveRepoConfig(repo, { runnerAutoMerge: true }) const forge = recordingForge() const outcome = await mergeTask({ cwd: repo, - brainAutoMerge: false, + runnerAutoMerge: false, task: ticketedGreenTask(), settings: settings({ policy: 'human' }), inputs: greenInputs(), @@ -1086,12 +1086,12 @@ describe('arm/brain integration: brainAutoMerge overrides mergePolicy for a tick expect(forge.calls).toEqual([]) }) - test('a task with no brain_ticket keeps mergePolicy human untouched even when brainAutoMerge is true', async () => { + test('a task with no hub_ticket keeps mergePolicy human untouched even when runnerAutoMerge is true', async () => { const repo = makeRepoWithOrigin('git@github.com:o/r.git') const forge = recordingForge() const outcome = await mergeTask({ cwd: repo, - brainAutoMerge: true, + runnerAutoMerge: true, task: greenTask(), settings: settings({ policy: 'human' }), inputs: greenInputs(), @@ -1101,12 +1101,12 @@ describe('arm/brain integration: brainAutoMerge overrides mergePolicy for a tick expect(forge.calls).toEqual([]) }) - test('a repo-wide mergePolicy: auto merges regardless of brainAutoMerge', async () => { + test('a repo-wide mergePolicy: auto merges regardless of runnerAutoMerge', async () => { const repo = makeRepoWithOrigin('git@github.com:o/r.git') const forge = recordingForge({ kind: 'ok', stdout: '' }) const outcome = await mergeTask({ cwd: repo, - brainAutoMerge: false, + runnerAutoMerge: false, task: ticketedGreenTask(), settings: settings({ policy: 'auto' }), inputs: greenInputs(), @@ -1120,7 +1120,7 @@ describe('arm/brain integration: brainAutoMerge overrides mergePolicy for a tick const forge = recordingForge() const outcome = await mergeTask({ cwd: repo, - brainAutoMerge: true, + runnerAutoMerge: true, task: ticketedGreenTask(), settings: settings({ policy: 'human' }), inputs: greenInputs({ checks: makeChecks({ status: 'failed' }) }), @@ -1139,26 +1139,26 @@ describe('arm/brain integration: brainAutoMerge overrides mergePolicy for a tick // mergeTask's observable behavior — this is the same truth table, asserted // directly against the exported function. describe('effectiveMergePolicyIsAuto: the exact question mergeTask answers, exported', () => { - test('an explicit auto policy is auto, brain_ticket or not', () => { + test('an explicit auto policy is auto, hub_ticket or not', () => { expect(effectiveMergePolicyIsAuto(greenTask(), settings({ policy: 'auto' }), false)).toBe(true) }) - test('a human policy with no brain_ticket is never auto', () => { + test('a human policy with no hub_ticket is never auto', () => { expect(effectiveMergePolicyIsAuto(greenTask(), settings({ policy: 'human' }), true)).toBe(false) }) - test('a brain ticket with brainAutoMerge overrides a human policy to auto', () => { - const ticketed = greenTask({ brain_ticket: { id: 'tkt-1', title: 'x' } }) + test('a hub ticket with runnerAutoMerge overrides a human policy to auto', () => { + const ticketed = greenTask({ hub_ticket: { id: 'tkt-1', title: 'x' } }) expect(effectiveMergePolicyIsAuto(ticketed, settings({ policy: 'human' }), true)).toBe(true) }) - test('a brain ticket WITHOUT brainAutoMerge does not override a human policy', () => { - const ticketed = greenTask({ brain_ticket: { id: 'tkt-1', title: 'x' } }) + test('a hub ticket WITHOUT runnerAutoMerge does not override a human policy', () => { + const ticketed = greenTask({ hub_ticket: { id: 'tkt-1', title: 'x' } }) expect(effectiveMergePolicyIsAuto(ticketed, settings({ policy: 'human' }), false)).toBe(false) }) - test('a repo-wide auto policy is untouched by brainAutoMerge either way', () => { - const ticketed = greenTask({ brain_ticket: { id: 'tkt-1', title: 'x' } }) + test('a repo-wide auto policy is untouched by runnerAutoMerge either way', () => { + const ticketed = greenTask({ hub_ticket: { id: 'tkt-1', title: 'x' } }) expect(effectiveMergePolicyIsAuto(ticketed, settings({ policy: 'auto' }), false)).toBe(true) }) }) @@ -1182,7 +1182,7 @@ describe('what the merge never does', () => { message: 'Pull request is not mergeable: the merge commit cannot be cleanly created', }) const outcome = await mergeTask({ - brainAutoMerge: true, + runnerAutoMerge: true, cwd: repo, task: greenTask(), settings: settings({ policy: 'auto' }), @@ -1205,7 +1205,7 @@ describe('what the merge never does', () => { const repo = makeRepoWithOrigin('git@example.test:o/r.git') const forge = recordingForge({ kind: 'missing' }) const outcome = await mergeTask({ - brainAutoMerge: true, + runnerAutoMerge: true, cwd: repo, task: greenTask(), settings: settings({ policy: 'auto' }), @@ -1225,7 +1225,7 @@ describe('what the merge never does', () => { message: 'GraphQL: Base branch was modified. Review and try the merge again.', }) const outcome = await mergeTask({ - brainAutoMerge: true, + runnerAutoMerge: true, cwd: repo, task: greenTask(), settings: settings({ policy: 'auto' }), @@ -1265,7 +1265,7 @@ describe('D20 idempotence: a branch the forge already merged is never merged twi } const task = greenTask() const outcome = await mergeTask({ - brainAutoMerge: true, + runnerAutoMerge: true, cwd: repo, task, settings: settings({ policy: 'auto' }), @@ -1301,7 +1301,7 @@ describe('D20 idempotence: a branch the forge already merged is never merged twi message: 'Pull request is not mergeable: the merge commit cannot be cleanly created', }) const outcome = await mergeTask({ - brainAutoMerge: true, + runnerAutoMerge: true, cwd: repo, task: greenTask(), settings: settings({ policy: 'auto' }), @@ -1327,7 +1327,7 @@ describe('D20 idempotence: a branch the forge already merged is never merged twi ) } const outcome = await mergeTask({ - brainAutoMerge: true, + runnerAutoMerge: true, cwd: repo, task: greenTask(), settings: settings({ policy: 'auto' }), @@ -1373,7 +1373,7 @@ describe('the merge really runs a forge CLI when nothing is injected', () => { `const { mergeTask } = await import(${JSON.stringify(modulePath)})`, `const outcome = await mergeTask({`, ` cwd: ${JSON.stringify(repo)},`, - ` brainAutoMerge: true,`, + ` runnerAutoMerge: true,`, ` task: ${JSON.stringify(greenTask())},`, ` settings: ${JSON.stringify(settings({ policy: 'auto' }))},`, ` inputs: ${JSON.stringify(greenInputs())},`, @@ -1437,7 +1437,7 @@ describe('criteriaDraftProposed: the only trace a turn-1 draft ever leaves', () describe('an unusable merge setting is named, never absorbed', () => { test('the degraded keys ride the task journal as their own line', async () => { const outcome = await mergeTask({ - brainAutoMerge: true, + runnerAutoMerge: true, cwd: makeRepoWithOrigin('git@github.com:o/r.git'), task: greenTask(), settings: settings(), diff --git a/packages/cli/src/task-merge.ts b/packages/cli/src/task-merge.ts index e8fbe4e..172e1c7 100644 --- a/packages/cli/src/task-merge.ts +++ b/packages/cli/src/task-merge.ts @@ -42,8 +42,8 @@ import { type TaskRecord, } from './contract.js' import { detectForgeHint, isAncestor, refExists } from './git.js' -import { reportBrainTransition } from './task-brain.js' import { CRITERIA_REASON_IDS_MAX } from './task-criteria-gate.js' +import { reportHubTransition } from './task-hub.js' import { blockingFindingsDetail, checksBlockReady, @@ -694,16 +694,16 @@ export type MergeTaskOptions = { task: TaskRecord settings: MergeSettings /** - * Arm/brain integration: `brainAutoMerge` (config.ts), resolved by the + * Arm/runner integration: `runnerAutoMerge` (config.ts), resolved by the * CALLER from the global config alone and handed in as a plain value. * GLOBAL-ONLY, same doctrine as every field of `settings` above; this * module never reads config itself, so the boundary between "the workspace * resolved a setting" and "a repo could sneak one past this gate" cannot - * blur here. `true` (a brain-ticket task's own consent OVERRIDES + * blur here. `true` (a hub-ticket task's own consent OVERRIDES * `mergePolicy` to `'auto'` for that task only) is the caller's honest * default when nothing configures it either way. */ - brainAutoMerge: boolean + runnerAutoMerge: boolean /** Test seam: the four facts. Omitted, they are collected from disk by `readMergeInputs`. */ inputs?: MergeInputs /** Test seam: the default runs a real gh / glab. */ @@ -742,15 +742,15 @@ const forgeOutcomeMessage = (outcome: Extract<ShipCliOutcome, { kind: 'error' }> /** * Whether the policy this call actually merges under — `opts.settings.policy` - * after the SAME brain override `mergeTask` applies below — is `'auto'`. + * after the SAME runner override `mergeTask` applies below — is `'auto'`. * - * Arm/brain integration: a brain-ticket task's own consent (`brainAutoMerge`, + * Arm/runner integration: a hub-ticket task's own consent (`runnerAutoMerge`, * GLOBAL-ONLY, default true, resolved by the caller and handed in as a plain * value) OVERRIDES `mergePolicy` to `'auto'` for THIS task only: the - * workspace-wide setting, and every task that carries no `brain_ticket`, are + * workspace-wide setting, and every task that carries no `hub_ticket`, are * untouched. Never the other direction: a repo that explicitly wants * `mergePolicy: 'auto'` for every task keeps that regardless of - * `brainAutoMerge`. + * `runnerAutoMerge`. * * Exported (D20) so a caller can ask the SAME question `mergeTask` is about * to answer BEFORE calling it — `task-server.ts`'s `ship()` reads it to @@ -760,9 +760,9 @@ const forgeOutcomeMessage = (outcome: Extract<ShipCliOutcome, { kind: 'error' }> export function effectiveMergePolicyIsAuto( task: TaskRecord, settings: MergeSettings, - brainAutoMerge: boolean, + runnerAutoMerge: boolean, ): boolean { - return settings.policy === 'auto' || Boolean(task.brain_ticket && brainAutoMerge) + return settings.policy === 'auto' || Boolean(task.hub_ticket && runnerAutoMerge) } /** @@ -780,7 +780,7 @@ export async function mergeTask(opts: MergeTaskOptions): Promise<MergeOutcome> { const settings: MergeSettings = effectiveMergePolicyIsAuto( opts.task, opts.settings, - opts.brainAutoMerge, + opts.runnerAutoMerge, ) ? { ...opts.settings, policy: 'auto' } : opts.settings @@ -860,8 +860,8 @@ export async function mergeTask(opts: MergeTaskOptions): Promise<MergeOutcome> { data: { name: 'failed', cli: candidate.cli, message: reason.detail ?? message }, reason_code: 'merge_conflict', }) - if (opts.task.brain_ticket) { - void reportBrainTransition(opts.cwd, opts.task, { + if (opts.task.hub_ticket) { + void reportHubTransition(opts.cwd, opts.task, { type: 'failed', error_message: reason.detail ?? message, }) @@ -885,8 +885,8 @@ export async function mergeTask(opts: MergeTaskOptions): Promise<MergeOutcome> { already_merged: true, }, }) - if (opts.task.brain_ticket) { - void reportBrainTransition(opts.cwd, opts.task, { + if (opts.task.hub_ticket) { + void reportHubTransition(opts.cwd, opts.task, { type: 'merged', branch: opts.task.branch, }) @@ -910,12 +910,12 @@ export async function mergeTask(opts: MergeTaskOptions): Promise<MergeOutcome> { ...(url ? { url } : {}), }, }) - if (opts.task.brain_ticket) { + if (opts.task.hub_ticket) { // `merge_sha` is omitted: neither `gh pr merge` nor `glab mr merge` // hands one back on this path (only the MR/PR url, when the forge - // gives one). The brain reads a `merged` transition with no sha as + // gives one). The hub reads a `merged` transition with no sha as // "landed, sha unknown" rather than a claim about a commit nobody read. - void reportBrainTransition(opts.cwd, opts.task, { type: 'merged', branch: opts.task.branch }) + void reportHubTransition(opts.cwd, opts.task, { type: 'merged', branch: opts.task.branch }) } return { kind: 'merged', cli: candidate.cli, url, readiness, events } } @@ -937,8 +937,8 @@ export async function mergeTask(opts: MergeTaskOptions): Promise<MergeOutcome> { data: { name: 'failed', message: reason.detail ?? 'the merge could not be performed' }, reason_code: 'forge_unreachable', }) - if (opts.task.brain_ticket) { - void reportBrainTransition(opts.cwd, opts.task, { + if (opts.task.hub_ticket) { + void reportHubTransition(opts.cwd, opts.task, { type: 'failed', error_message: reason.detail ?? 'the merge could not be performed', }) diff --git a/packages/cli/src/task-review.test.ts b/packages/cli/src/task-review.test.ts index 08e8d2a..8990a6e 100644 --- a/packages/cli/src/task-review.test.ts +++ b/packages/cli/src/task-review.test.ts @@ -29,13 +29,13 @@ import { actionableFindingIds, applyChecksGate, blockingFindingsDetail, - brainSettleTransition, buildAutoFixTurnPrompt, buildFixTurnPrompt, checksBlockReady, checksFailedDetail, createTaskReviewer, hasBlockingFindings, + hubSettleTransition, readTaskReview, taskReviewVerdict, terminalChecksResult, @@ -2235,14 +2235,14 @@ describe('buildAutoFixTurnPrompt (T3.3)', () => { }) }) -describe('brainSettleTransition', () => { +describe('hubSettleTransition', () => { test('review_ok with no reviewOutcome (the empty-diff short-circuit): an approve, no findings_total', () => { - const transition = brainSettleTransition({ status: 'review_ok' }) + const transition = hubSettleTransition({ status: 'review_ok' }) expect(transition).toEqual({ type: 'review_result', verdict: 'approve' }) }) test('review_ok with a reviewOutcome: an approve, carrying findings_total', () => { - const transition = brainSettleTransition({ + const transition = hubSettleTransition({ status: 'review_ok', reviewOutcome: fakeReview('approve', [{ file: 'a.ts', severity: 'minor', message: 'nit' }]), }) @@ -2250,7 +2250,7 @@ describe('brainSettleTransition', () => { }) test('review_ko with a reviewOutcome (a verdict was produced, possibly overridden): request_changes', () => { - const transition = brainSettleTransition({ + const transition = hubSettleTransition({ status: 'review_ko', reviewOutcome: fakeReview('request_changes', [ { file: 'a.ts', severity: 'major', message: 'bug' }, @@ -2267,13 +2267,13 @@ describe('brainSettleTransition', () => { // No reviewer ever produced a verdict here: reporting review_result would // be indistinguishable from a reviewer that looked at the work and // rejected it. - const transition = brainSettleTransition({ status: 'review_ko' }) + const transition = hubSettleTransition({ status: 'review_ko' }) expect(transition).toEqual({ type: 'failed' }) }) test('review_ko with no reviewOutcome and a reason: failed, carrying the reason as error_message', () => { const reason: TaskReason = { code: 'review_blocked', detail: 'review failed: agent crashed' } - const transition = brainSettleTransition({ status: 'review_ko', reason }) + const transition = hubSettleTransition({ status: 'review_ko', reason }) expect(transition).toEqual({ type: 'failed', error_message: 'review failed: agent crashed', @@ -2282,14 +2282,14 @@ describe('brainSettleTransition', () => { test('a reason with no detail adds no error_message', () => { const reason: TaskReason = { code: 'review_blocked' } - const transition = brainSettleTransition({ status: 'review_ko', reason }) + const transition = hubSettleTransition({ status: 'review_ko', reason }) expect(transition).toEqual({ type: 'failed' }) }) test('costTicks rides along on a review_result, omitted entirely when absent', () => { - const withCost = brainSettleTransition({ status: 'review_ok', costTicks: 42 }) + const withCost = hubSettleTransition({ status: 'review_ok', costTicks: 42 }) expect(withCost).toEqual({ type: 'review_result', verdict: 'approve', cost_ticks: 42 }) - const withoutCost = brainSettleTransition({ status: 'review_ok' }) + const withoutCost = hubSettleTransition({ status: 'review_ok' }) expect('cost_ticks' in withoutCost).toBe(false) }) }) diff --git a/packages/cli/src/task-review.ts b/packages/cli/src/task-review.ts index 642c60b..8e15b4c 100644 --- a/packages/cli/src/task-review.ts +++ b/packages/cli/src/task-review.ts @@ -36,7 +36,6 @@ import { } from './review.js' import { createSession } from './serve.js' import { autoPushReview } from './sync.js' -import { reportBrainTransition } from './task-brain.js' import { buildChecksChapter } from './task-checks.js' import { buildCriteriaChapter, @@ -49,6 +48,7 @@ import { unmetCriteriaFixChapter, type CriteriaOutcome, } from './task-criteria-gate.js' +import { reportHubTransition } from './task-hub.js' import { REVIEW_CUT_DETAIL, taskCriteria, @@ -319,9 +319,9 @@ export function applyChecksGate(record: TaskRecord, checks: TaskChecks | null | } /** - * The arm/brain fact a settled turn reports, decided from what the turn + * The arm/hub fact a settled turn reports, decided from what the turn * actually produced rather than from `status` alone. `status: 'review_ko'` - * covers two different situations, and the brain must not read them as the + * covers two different situations, and the hub must not read them as the * same fact: * * - a `reviewOutcome` is present: a reviewer ran and returned a verdict, @@ -338,7 +338,7 @@ export function applyChecksGate(record: TaskRecord, checks: TaskChecks | null | * Pure and exported so this distinction is tested directly, with no fetch or * outbox to mock. */ -export function brainSettleTransition(opts: { +export function hubSettleTransition(opts: { status: 'review_ok' | 'review_ko' reviewOutcome?: ReviewRecord reason?: TaskReason @@ -380,7 +380,7 @@ const settle = ( io: TaskTurnIo, status: 'review_ok' | 'review_ko', opts: { - /** MAIN repo root: only used for the arm/brain report below, never for I/O on `record` itself. */ + /** MAIN repo root: only used for the arm/hub report below, never for I/O on `record` itself. */ cwd: string /** Why a KO blocks; defaults to a bare `review_blocked`. Ignored on an OK. */ blocked?: TaskReason @@ -395,15 +395,15 @@ const settle = ( delete record.reason } io.persist() - // Arm/brain integration: reported AFTER the persist, never instead of it, + // Arm/hub integration: reported AFTER the persist, never instead of it, // same discipline as every other fire-and-forget effect a settled turn - // triggers (task-labels.ts's cycle label). Never awaited: a brain round + // triggers (task-labels.ts's cycle label). Never awaited: a hub round // trip must not hold up the turn this settle ends. - if (record.brain_ticket) { - void reportBrainTransition( + if (record.hub_ticket) { + void reportHubTransition( opts.cwd, record, - brainSettleTransition({ + hubSettleTransition({ status, ...(opts.reviewOutcome ? { reviewOutcome: opts.reviewOutcome } : {}), ...(record.reason ? { reason: record.reason } : {}), @@ -506,8 +506,8 @@ const settleInterrupted = (record: TaskRecord, io: TaskTurnIo, cwd: string): voi record.status = 'interrupted' record.reason = taskReason('interrupted_by_user', REVIEW_CUT_DETAIL) io.persist() - if (record.brain_ticket) { - void reportBrainTransition(cwd, record, { type: 'failed', error_message: REVIEW_CUT_DETAIL }) + if (record.hub_ticket) { + void reportHubTransition(cwd, record, { type: 'failed', error_message: REVIEW_CUT_DETAIL }) } } diff --git a/packages/cli/src/task-runner.ts b/packages/cli/src/task-runner.ts index fd774ae..ff26e01 100644 --- a/packages/cli/src/task-runner.ts +++ b/packages/cli/src/task-runner.ts @@ -62,12 +62,12 @@ import { } from './load-cap.js' import { projectIdFor } from './projects.js' import type { ChecksConfig } from './repo-config.js' -import { reportBrainTransition } from './task-brain.js' import { bootstrapWorktreeInstall, type BootstrapInstallResult, type BootstrapWorktreeInstallOptions, } from './task-checks.js' +import { reportHubTransition } from './task-hub.js' import { agentHomeVolume, CAGE_FORWARDED_ENV, @@ -1874,8 +1874,8 @@ export function createTaskRunner(opts: TaskRunnerOptions): TaskRunner { // later, can genuinely succeed. Same argument as the abort path above. const retryable = code !== null && !isTerminalReason(code) record.status = retryable ? 'interrupted' : 'failed' - if (!retryable && record.brain_ticket) { - void reportBrainTransition(opts.cwd, record, { type: 'failed', error_message: message }) + if (!retryable && record.hub_ticket) { + void reportHubTransition(opts.cwd, record, { type: 'failed', error_message: message }) } emit(record.id, { // The event names the same outcome as the status. A task parked on @@ -3199,8 +3199,8 @@ export function createTaskRunner(opts: TaskRunnerOptions): TaskRunner { // Everything else abandoned mid-cycle is discarded work: failed. if (current.status !== 'shipped') { current.status = 'failed' - if (current.brain_ticket) { - void reportBrainTransition(opts.cwd, current, { + if (current.hub_ticket) { + void reportHubTransition(opts.cwd, current, { type: 'failed', error_message: 'worktree removed, task abandoned', }) diff --git a/packages/cli/src/task-server.ts b/packages/cli/src/task-server.ts index 394bb59..26a9c89 100644 --- a/packages/cli/src/task-server.ts +++ b/packages/cli/src/task-server.ts @@ -20,11 +20,11 @@ import { import { DEFAULT_MERGE_SETTINGS, loadGlobalConfig, - resolveBrainAutoMerge, resolveMaxAutoFixRounds, resolveProjectAgentCommand, resolveProjectConfig, resolveReviewMode, + resolveRunnerAutoMerge, resolveWatchdogBudgets, type IsolationMode, type MergeSettings, @@ -68,8 +68,6 @@ import { type Project, } from './projects.js' import { readChecksConfig } from './repo-config.js' -import { resolveBrainTicketOrigin } from './task-brain-ticket.js' -import { reportBrainTransition } from './task-brain.js' import { runChecks } from './task-checks.js' import { applyFixLoopDecision, @@ -82,6 +80,8 @@ import { decideFixLoop, type FixLoopDecision, } from './task-fix-loop.js' +import { resolveHubTicketOrigin } from './task-hub-ticket.js' +import { reportHubTransition } from './task-hub.js' import { agentHomeVolume, isolationDefaults, @@ -268,16 +268,16 @@ export type CreateTaskManagerInput = { */ issue?: CreateTaskManagerIssueInput /** - * Arm/brain integration: creates the task FROM this ticket the local - * brain owns, instead of a bare title+prompt or a forge issue. Mutually + * Arm/hub integration: creates the task FROM this ticket the local + * hub owns, instead of a bare title+prompt or a forge issue. Mutually * exclusive in effect with `issue` and with `title`/`prompt`: when given, * they are ignored and this wins, same convention `issue` already has over * `title`/`prompt`. The ticket's own title and (linted) body take their - * place, and the task's record carries `brain_ticket` and its already - * brain-validated `criteria` (see `resolveBrainTicketOrigin`, - * task-brain-ticket.ts). + * place, and the task's record carries `hub_ticket` and its already + * hub-validated `criteria` (see `resolveHubTicketOrigin`, + * task-hub-ticket.ts). */ - brainTicket?: ArmTicket + hubTicket?: ArmTicket /** * Per-task agent CLI (id or full known command). Validated with * `resolveKnownAgentCommand`; unknown/custom is a 400. Absent: the @@ -1020,9 +1020,9 @@ type TaskOrigin = issueSnapshot: TaskIssueSnapshot | null /** T2.4/DP13: true when the issue's raw body carries content the edit-detector cannot see. Always false off the title+prompt path. */ coverageGap: boolean - /** Arm/brain integration: the ticket this task was created from, when it was one. Absent off every other origin. */ - brainTicket?: { id: string; title: string; url?: string } | null - /** The brain's already-validated criteria, frozen onto the record at creation. Absent off every other origin. */ + /** Arm/hub integration: the ticket this task was created from, when it was one. Absent off every other origin. */ + hubTicket?: { id: string; title: string; url?: string } | null + /** The hub's already-validated criteria, frozen onto the record at creation. Absent off every other origin. */ criteria?: AcceptanceCriterion[] | null } | { ok: false; refusal: Extract<TaskCreateResult, { ok: false }> } @@ -1875,14 +1875,14 @@ export function createTaskManager(opts: CreateTaskManagerOptions): TaskManager { record.status = 'shipped' // D20, same write as the status above: advanced to 'merge' when the // chained runMergeStep below will actually attempt one — the SAME - // settings/brainAutoMerge it resolves a few lines later — or cleared - // when it will not (mergePolicy 'human', no brain override), so the + // settings/runnerAutoMerge it resolves a few lines later — or cleared + // when it will not (mergePolicy 'human', no runner override), so the // record never claims to be mid-step when nothing is about to run. if ( effectiveMergePolicyIsAuto( record, opts.mergeSettings ?? DEFAULT_MERGE_SETTINGS, - resolveBrainAutoMerge(loadGlobalConfig()), + resolveRunnerAutoMerge(loadGlobalConfig()), ) ) { record.cycle_step = 'merge' @@ -1899,11 +1899,11 @@ export function createTaskManager(opts: CreateTaskManagerOptions): TaskManager { // `codesema:reviewing` with the `review_ok` it comes from, so the // nominal auto-ship spends nothing at all here. trackCycleLabel(mirrorCycleLabel(projectId, cwd, record)) - // Arm/brain integration: the same "after the persisted transition, + // Arm/hub integration: the same "after the persisted transition, // never instead of it" discipline as the cycle label right above. - // Never awaited: a brain round trip must not hold up the ship's own + // Never awaited: a hub round trip must not hold up the ship's own // answer, exactly like the label. - void reportBrainTransition(cwd, record, { + void reportHubTransition(cwd, record, { type: 'mr_opened', ...(outcome.mrUrl ? { mr_url: outcome.mrUrl } : {}), branch: record.branch, @@ -2121,12 +2121,12 @@ export function createTaskManager(opts: CreateTaskManagerOptions): TaskManager { cwd, task: record, settings, - // Arm/brain integration: `brainAutoMerge` is GLOBAL-ONLY (see its own + // Arm/runner integration: `runnerAutoMerge` is GLOBAL-ONLY (see its own // field comment, config.ts), resolved HERE, once, from the global // file alone, and handed to `mergeTask` as a plain value rather than // read there: a repo file can never contribute to it, and a merge // module that read config itself would blur that boundary. - brainAutoMerge: resolveBrainAutoMerge(loadGlobalConfig()), + runnerAutoMerge: resolveRunnerAutoMerge(loadGlobalConfig()), ...(opts.degradedMergeKeys && opts.degradedMergeKeys.length > 0 ? { degradedKeys: opts.degradedMergeKeys } : {}), @@ -2178,7 +2178,7 @@ export function createTaskManager(opts: CreateTaskManagerOptions): TaskManager { // belongs to is already done, see `schedulePostMergeReplay`'s own // doc. `.catch()` rather than a bare `void`, same discipline as // `trackCycleLabel` above: nothing in this hook's own contract is - // "never rejects" the way `reportBrainTransition`'s is, so an + // "never rejects" the way `reportHubTransition`'s is, so an // unexpected throw is turned into a notice instead of an unhandled // rejection. void schedulePostMergeReplay(ctx, record).catch((err: unknown) => { @@ -3535,9 +3535,9 @@ export function createTaskManager(opts: CreateTaskManagerOptions): TaskManager { // Reads the issue exactly as `create` does — `admitIssue` never writes — // and then throws the snapshot away: D-d, previewing is not launching, so // nothing dates the ticket of a task that does not exist. Same - // brainTicket > issue > title/prompt order `create()` resolves with. - const origin = input.brainTicket - ? resolveBrainTicketOrigin(project.path, input.brainTicket) + // hubTicket > issue > title/prompt order `create()` resolves with. + const origin = input.hubTicket + ? resolveHubTicketOrigin(project.path, input.hubTicket) : input.issue ? await resolveIssueOrigin(project.path, input.issue, opts.issueExecFn) : resolveTitlePromptOrigin(input) @@ -3576,13 +3576,13 @@ export function createTaskManager(opts: CreateTaskManagerOptions): TaskManager { if (!ctx) { return unknownProject } - // `brainTicket` wins over `issue`, which wins over a bare + // `hubTicket` wins over `issue`, which wins over a bare // title+prompt: the same "one origin, and it decides everything - // else" convention `issue` already has. `resolveBrainTicketOrigin` is + // else" convention `issue` already has. `resolveHubTicketOrigin` is // synchronous (no forge round trip: the ticket arrives already - // resolved and validated by the brain), unlike `resolveIssueOrigin`. - const origin = input.brainTicket - ? resolveBrainTicketOrigin(ctx.project.path, input.brainTicket) + // resolved and validated by the hub), unlike `resolveIssueOrigin`. + const origin = input.hubTicket + ? resolveHubTicketOrigin(ctx.project.path, input.hubTicket) : input.issue ? await resolveIssueOrigin(ctx.project.path, input.issue, opts.issueExecFn) : resolveTitlePromptOrigin(input) @@ -3595,7 +3595,7 @@ export function createTaskManager(opts: CreateTaskManagerOptions): TaskManager { issue: issueRef, issueSnapshot, coverageGap, - brainTicket, + hubTicket, criteria, } = origin // Every guard below — base/branch exclusivity and shape, the work-on @@ -3649,12 +3649,12 @@ export function createTaskManager(opts: CreateTaskManagerOptions): TaskManager { // resolveIssueOrigin/resolveTitlePromptOrigin) — one guard, not two, // so a future drift here cannot silently split the pair. ...(issueRef && issueSnapshot ? { issue: issueRef, issueSnapshot } : {}), - // Arm/brain integration: both land in the SAME write as everything + // Arm/hub integration: both land in the SAME write as everything // else above. Criteria in particular must never trail the record by // a second write: the task's very first turn already reads // `taskCriteria(record)` to build its prompt, and criteria arriving // even one write later would race that read. - ...(brainTicket ? { brainTicket } : {}), + ...(hubTicket ? { hubTicket } : {}), ...(criteria && criteria.length > 0 ? { criteria } : {}), }) // The WHY is journaled on the task itself: an 'auto' workspace that fell @@ -3699,11 +3699,11 @@ export function createTaskManager(opts: CreateTaskManagerOptions): TaskManager { }) } } - // Arm/brain integration: the same 'criteria'/'validated' line + // Arm/hub integration: the same 'criteria'/'validated' line // POST /api/tasks/:id/criteria journals for a human-validated list - // (task-criteria.ts); the brain played that role instead, so the + // (task-criteria.ts); the hub played that role instead, so the // record's journal says so the same way. - if (brainTicket && criteria && criteria.length > 0) { + if (hubTicket && criteria && criteria.length > 0) { const criteriaEvent = appendTaskEvent(ctx.project.path, record.id, { type: 'criteria', data: { @@ -3746,9 +3746,9 @@ export function createTaskManager(opts: CreateTaskManagerOptions): TaskManager { task_id: failure.id, event: { name: 'task_event', data: event }, }) - // Never awaited: the caller must not wait on a brain round trip for a + // Never awaited: the caller must not wait on a hub round trip for a // task that just failed to even start. - void reportBrainTransition(ctx.project.path, failure, { + void reportHubTransition(ctx.project.path, failure, { type: 'failed', error_message: started.error, }) diff --git a/packages/cli/src/tasks-store.test.ts b/packages/cli/src/tasks-store.test.ts index ec52c72..8fc2d91 100644 --- a/packages/cli/src/tasks-store.test.ts +++ b/packages/cli/src/tasks-store.test.ts @@ -27,12 +27,12 @@ import { listTasks, loadTask, onStoreUnreadable, - peekBrainTicketIdCache, + peekHubTicketIdCache, readTaskChecks, readTaskEvents, readTaskJournal, removeTaskDir, - resetBrainTicketIdCache, + resetHubTicketIdCache, resetJournalCursors, resetStoreReports, saveTask, @@ -64,7 +64,7 @@ afterEach(() => { setJournalReader(null) resetJournalCursors() resetStoreReports() - resetBrainTicketIdCache() + resetHubTicketIdCache() rmSync(cwd, { recursive: true, force: true }) }) @@ -913,30 +913,30 @@ describe('removeTaskDir', () => { expect(removeTaskDir(cwd, '')).toBe(false) }) - // Arm/brain integration: `brainTicketIdCache` is WRITE-ONCE for a LIVE + // Arm/hub integration: `hubTicketIdCache` is WRITE-ONCE for a LIVE // task, but this is the one place a task's own id stops meaning that task // at all: an id left in the cache after removal would answer a later, // unrelated task carrying the same 12-hex id with a ticket from a task // that no longer exists. - test('evicts the removed task from brainTicketIdCache', () => { - const task = createTask(cwd, { ...input, brainTicket: { id: 'tkt-1', title: 't' } }) - expect(peekBrainTicketIdCache(cwd, task.id)).toBe('tkt-1') + test('evicts the removed task from hubTicketIdCache', () => { + const task = createTask(cwd, { ...input, hubTicket: { id: 'tkt-1', title: 't' } }) + expect(peekHubTicketIdCache(cwd, task.id)).toBe('tkt-1') expect(removeTaskDir(cwd, task.id)).toBe(true) - expect(peekBrainTicketIdCache(cwd, task.id)).toBeUndefined() + expect(peekHubTicketIdCache(cwd, task.id)).toBeUndefined() }) test.skipIf(RUNNING_AS_ROOT)( 'a removal that could not complete leaves the cache entry untouched', () => { - const task = createTask(cwd, { ...input, brainTicket: { id: 'tkt-1', title: 't' } }) + const task = createTask(cwd, { ...input, hubTicket: { id: 'tkt-1', title: 't' } }) // Same recipe as the other permission-failure tests in this file: no // write access on the PARENT directory makes rmSync fail without root. chmodSync(tasksDir(cwd), 0o000) try { expect(removeTaskDir(cwd, task.id)).toBe(false) - expect(peekBrainTicketIdCache(cwd, task.id)).toBe('tkt-1') + expect(peekHubTicketIdCache(cwd, task.id)).toBe('tkt-1') } finally { chmodSync(tasksDir(cwd), 0o700) } diff --git a/packages/cli/src/tasks-store.ts b/packages/cli/src/tasks-store.ts index 8502f0d..c271664 100644 --- a/packages/cli/src/tasks-store.ts +++ b/packages/cli/src/tasks-store.ts @@ -34,26 +34,26 @@ import { type TaskReason, type TaskRecord, } from './contract.js' -import { queueBrainEvent } from './task-brain.js' +import { queueHubEvent } from './task-hub.js' export function tasksDir(cwd: string): string { return join(cwd, '.codesema', 'tasks') } /** - * Per-process cache of `record.brain_ticket?.id`, keyed by `${cwd}\0${id}`. - * `brain_ticket` is WRITE-ONCE (see `CreateTaskInput.brainTicket`'s own doc + * Per-process cache of `record.hub_ticket?.id`, keyed by `${cwd}\0${id}`. + * `hub_ticket` is WRITE-ONCE (see `CreateTaskInput.hubTicket`'s own doc * comment), so a cache entry never goes stale for the lifetime of its task. * `appendTaskEvent` is on the hot path of a chatty turn (tens of thousands * of `tool_use`/`tool_result` lines), and reloading task.json on every - * single append just to answer "does this task have a brain_ticket" would + * single append just to answer "does this task have a hub_ticket" would * cost exactly what the journal cursor cache below exists to avoid. - * `createTask` warms it directly for every task (brain-ticket or not, so + * `createTask` warms it directly for every task (hub-ticket or not, so * `null` is cached rather than leaving a gap); a task written by an earlier * process gets one lazy `loadTask` the first time one of its events is * appended in THIS process. */ -const brainTicketIdCache = new Map<string, string | null>() +const hubTicketIdCache = new Map<string, string | null>() /** * A separator that cannot appear in a `cwd` (an absolute path) or a 12-hex @@ -66,13 +66,13 @@ const brainTicketIdCache = new Map<string, string | null>() */ const KEY_SEP = String.fromCharCode(0) -function brainTicketCacheKey(cwd: string, id: string): string { +function hubTicketCacheKey(cwd: string, id: string): string { return `${cwd}${KEY_SEP}${id}` } /** Test hygiene: drops the cache, i.e. simulates a fresh process. */ -export function resetBrainTicketIdCache(): void { - brainTicketIdCache.clear() +export function resetHubTicketIdCache(): void { + hubTicketIdCache.clear() } /** @@ -80,8 +80,8 @@ export function resetBrainTicketIdCache(): void { * undefined/null distinction `appendTaskEvent` reads it with: `undefined` * (never touched) versus `null` (touched, cached as "no ticket"). */ -export function peekBrainTicketIdCache(cwd: string, id: string): string | null | undefined { - return brainTicketIdCache.get(brainTicketCacheKey(cwd, id)) +export function peekHubTicketIdCache(cwd: string, id: string): string | null | undefined { + return hubTicketIdCache.get(hubTicketCacheKey(cwd, id)) } export function taskDir(cwd: string, id: string): string { @@ -111,12 +111,12 @@ export function removeTaskDir(cwd: string, id: string): boolean { } try { rmSync(taskDir(cwd, id), { recursive: true, force: true }) - // Arm/brain integration: the ONE eviction `brainTicketIdCache` needs. + // Arm/hub integration: the ONE eviction `hubTicketIdCache` needs. // WRITE-ONCE means a live task's entry never goes stale, but a removed // task's directory is gone for good (this function's own doc comment): // an entry for it staying in the cache forever would be the one leak in // an otherwise process-lifetime-bounded cache. - brainTicketIdCache.delete(brainTicketCacheKey(cwd, id)) + hubTicketIdCache.delete(hubTicketCacheKey(cwd, id)) return true } catch { return false @@ -148,13 +148,13 @@ export type CreateTaskInput = { issue?: TaskIssueRef issueSnapshot?: TaskIssueSnapshot /** - * Arm/brain integration: the brain ticket this task was created from, when + * Arm/hub integration: the hub ticket this task was created from, when * it was one. WRITE-ONCE, same discipline as `issue`: fixed here, at * creation, never re-decided by a later turn. */ - brainTicket?: { id: string; title: string; url?: string } + hubTicket?: { id: string; title: string; url?: string } /** - * The brain's already-validated acceptance criteria, frozen onto the + * The hub's already-validated acceptance criteria, frozen onto the * record in this SAME write. Never posed as a second write through * `applyTaskCriteria` (task-criteria.ts): the task's very first turn reads * `taskCriteria(record)` (task-runner.ts) to build its prompt, and @@ -205,13 +205,13 @@ export function createTask(cwd: string, input: CreateTaskInput): TaskRecord { ...(input.issue && input.issueSnapshot ? { issue: input.issue, issue_snapshot: input.issueSnapshot } : {}), - ...(input.brainTicket ? { brain_ticket: input.brainTicket } : {}), + ...(input.hubTicket ? { hub_ticket: input.hubTicket } : {}), ...(input.criteria && input.criteria.length > 0 ? { criteria: input.criteria } : {}), created_at: now, updated_at: now, } saveTask(cwd, record) - brainTicketIdCache.set(brainTicketCacheKey(cwd, id), input.brainTicket?.id ?? null) + hubTicketIdCache.set(hubTicketCacheKey(cwd, id), input.hubTicket?.id ?? null) return record } @@ -621,19 +621,19 @@ export function appendTaskEvent(cwd: string, id: string, input: AppendTaskEventI size: cursor.size + Buffer.byteLength(line, 'utf8'), needsNewline: false, }) - // Arm/brain integration: fire-and-forget, cache-gated (see - // `brainTicketIdCache`'s own doc comment) so a chatty turn's tens of + // Arm/hub integration: fire-and-forget, cache-gated (see + // `hubTicketIdCache`'s own doc comment) so a chatty turn's tens of // thousands of tool_use/tool_result lines never cost an extra task.json // read each: only the FIRST event of a task this process has not yet // touched pays for one. - const cacheKey = brainTicketCacheKey(cwd, id) - let ticketId = brainTicketIdCache.get(cacheKey) + const cacheKey = hubTicketCacheKey(cwd, id) + let ticketId = hubTicketIdCache.get(cacheKey) if (ticketId === undefined) { - ticketId = loadTask(cwd, id)?.brain_ticket?.id ?? null - brainTicketIdCache.set(cacheKey, ticketId) + ticketId = loadTask(cwd, id)?.hub_ticket?.id ?? null + hubTicketIdCache.set(cacheKey, ticketId) } if (ticketId) { - queueBrainEvent({ cwd, taskId: id, ticketId, event }) + queueHubEvent({ cwd, taskId: id, ticketId, event }) } return event } diff --git a/packages/cli/src/brain-draft.test.ts b/packages/cli/src/ticket-draft.test.ts similarity index 94% rename from packages/cli/src/brain-draft.test.ts rename to packages/cli/src/ticket-draft.test.ts index c48c1a1..4a2c673 100644 --- a/packages/cli/src/brain-draft.test.ts +++ b/packages/cli/src/ticket-draft.test.ts @@ -4,13 +4,13 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import type { AgentRunOptions } from './agent.js' +import { loadGlobalConfig, saveGlobalConfig } from './config.js' +import type { ArmTicket, ArmTicketRequest } from './contract.js' import { draftAndPublishTicket, draftAndSubmitTicketRequest, draftTicketBody, -} from './brain-draft.js' -import { loadGlobalConfig, saveGlobalConfig } from './config.js' -import type { ArmTicket, ArmTicketRequest } from './contract.js' +} from './ticket-draft.js' type Call = { url: string; init: RequestInit } @@ -106,7 +106,7 @@ function runAgentSequence(outputs: string[]): { } } -describe('brain-draft', () => { +describe('ticket-draft', () => { const previousConfigDir = process.env.CODESEMA_CONFIG_DIR let configDir: string let cwd: string @@ -214,18 +214,18 @@ describe('brain-draft', () => { }) describe('draftAndPublishTicket', () => { - test('fails fast when no brain is connected', async () => { + test('fails fast when no hub is connected', async () => { const result = await draftAndPublishTicket({ kind: 'prompt', cwd, title: 'T', prompt: 'x' }) expect(result).toEqual({ ok: false, - reason: 'not connected to a brain: run `codesema brain connect` first', + reason: 'not connected to a hub: run `codesema runner connect` first', }) }) test('fails fast when the repo has no origin remote', async () => { saveGlobalConfig({ ...loadGlobalConfig(), - syncUrl: 'https://brain.example', + syncUrl: 'https://hub.example', syncWorkspaceId: 'ws1', syncSecret: 'sec1', }) @@ -237,7 +237,7 @@ describe('brain-draft', () => { test('drafts then publishes with POST /tickets, from a free-form prompt', async () => { saveGlobalConfig({ ...loadGlobalConfig(), - syncUrl: 'https://brain.example', + syncUrl: 'https://hub.example', syncWorkspaceId: 'ws1', syncSecret: 'sec1', }) @@ -249,7 +249,7 @@ describe('brain-draft', () => { { runAgentFn: agent.fn, fetchImpl: fetchStub(201, { ticket: validTicket }, calls) }, ) expect(result).toEqual({ ok: true, ticket: validTicket }) - expect(calls[0]?.url).toBe('https://brain.example/api/cli/tickets') + expect(calls[0]?.url).toBe('https://hub.example/api/cli/tickets') const body = JSON.parse(String(calls[0]?.init.body)) as { title: string; remote_url: string } expect(body.title).toBe('Add a thing') expect(body.remote_url).toBe('https://github.com/o/r.git') @@ -258,7 +258,7 @@ describe('brain-draft', () => { test('surfaces a publish rejection (e.g. lint refused server-side) as a reason', async () => { saveGlobalConfig({ ...loadGlobalConfig(), - syncUrl: 'https://brain.example', + syncUrl: 'https://hub.example', syncWorkspaceId: 'ws1', syncSecret: 'sec1', }) @@ -288,7 +288,7 @@ describe('brain-draft', () => { test('drafts a title and body from the bare prompt, then submits one ticket', async () => { saveGlobalConfig({ ...loadGlobalConfig(), - syncUrl: 'https://brain.example', + syncUrl: 'https://hub.example', syncWorkspaceId: 'ws1', syncSecret: 'sec1', }) @@ -299,7 +299,7 @@ describe('brain-draft', () => { fetchImpl: fetchStub(200, { tickets: [validTicket] }, calls), }) expect(result).toEqual({ ok: true, tickets: [validTicket] }) - expect(calls[0]?.url).toBe('https://brain.example/api/cli/ticket-requests/req1/tickets') + expect(calls[0]?.url).toBe('https://hub.example/api/cli/ticket-requests/req1/tickets') const body = JSON.parse(String(calls[0]?.init.body)) as { tickets: { title: string; body: string }[] } @@ -309,7 +309,7 @@ describe('brain-draft', () => { test('a drafting failure calls failTicketRequest with the reason and reports it', async () => { saveGlobalConfig({ ...loadGlobalConfig(), - syncUrl: 'https://brain.example', + syncUrl: 'https://hub.example', syncWorkspaceId: 'ws1', syncSecret: 'sec1', }) @@ -320,7 +320,7 @@ describe('brain-draft', () => { fetchImpl: fetchStub(200, {}, calls), }) expect(result.ok).toBe(false) - expect(calls[0]?.url).toBe('https://brain.example/api/cli/ticket-requests/req1/fail') + expect(calls[0]?.url).toBe('https://hub.example/api/cli/ticket-requests/req1/fail') const body = JSON.parse(String(calls[0]?.init.body)) as { error_message: string } expect(body.error_message).toBeTruthy() }) @@ -328,7 +328,7 @@ describe('brain-draft', () => { test('not connected fails without ever calling the agent', async () => { const agent = runAgentSequence([VALID_BODY]) const result = await draftAndSubmitTicketRequest(request, cwd, { runAgentFn: agent.fn }) - expect(result).toEqual({ ok: false, reason: 'not connected to a brain' }) + expect(result).toEqual({ ok: false, reason: 'not connected to a hub' }) expect(agent.calls.length).toBe(0) }) }) diff --git a/packages/cli/src/brain-draft.ts b/packages/cli/src/ticket-draft.ts similarity index 94% rename from packages/cli/src/brain-draft.ts rename to packages/cli/src/ticket-draft.ts index 52b86cc..293f436 100644 --- a/packages/cli/src/brain-draft.ts +++ b/packages/cli/src/ticket-draft.ts @@ -2,18 +2,11 @@ // body conforming to the contract's grammar (ticket.ts), using the // configured agent as a one-shot writer rather than an interactive session. // Two publish paths share the same drafting core: `draftAndPublishTicket` -// (one ticket, `POST /tickets`, driven by `codesema brain ticket`) and +// (one ticket, `POST /tickets`, driven by `codesema runner ticket`) and // `draftAndSubmitTicketRequest` (one ticket, `POST /ticket-requests/:id/tickets`, -// driven by the daemon against a brain-issued `ArmTicketRequest`). +// driven by the daemon against a hub-issued `ArmTicketRequest`). import { runAgent } from './agent.js' -import { - brainErrorMessage, - brainRemoteUrl, - createTicket, - failTicketRequest, - submitTicketRequestTickets, -} from './brain-client.js' import { loadConfig } from './config.js' import { ACCEPTANCE_CRITERIA_HEADING, @@ -31,6 +24,13 @@ import { } from './contract.js' import { getIssue } from './forge-issues.js' import { tryGit } from './git.js' +import { + createTicket, + failTicketRequest, + hubErrorMessage, + hubRemoteUrl, + submitTicketRequestTickets, +} from './hub-client.js' import { loadSyncCredentials } from './sync.js' /** No provider-specific flag needed beyond this: every AGENT_DEFS base command (wizard.ts) already reads a prompt on stdin and writes plain text to stdout. */ @@ -215,7 +215,7 @@ export type DraftAndPublishInput = export type PublishResult = { ok: true; ticket: ArmTicket } | { ok: false; reason: string } -/** `codesema brain ticket`: draft one ticket and publish it with `POST /tickets`. */ +/** `codesema runner ticket`: draft one ticket and publish it with `POST /tickets`. */ export async function draftAndPublishTicket( input: DraftAndPublishInput, seams: DraftSeams & { fetchImpl?: typeof fetch } = {}, @@ -223,9 +223,9 @@ export async function draftAndPublishTicket( const fetchImpl = seams.fetchImpl ?? fetch const creds = loadSyncCredentials() if (!creds) { - return { ok: false, reason: 'not connected to a brain: run `codesema brain connect` first' } + return { ok: false, reason: 'not connected to a hub: run `codesema runner connect` first' } } - const remoteUrl = brainRemoteUrl(input.cwd) + const remoteUrl = hubRemoteUrl(input.cwd) if (!remoteUrl) { return { ok: false, reason: 'this workspace has no git origin remote' } } @@ -267,7 +267,7 @@ export async function draftAndPublishTicket( fetchImpl, ) if (!created.ok) { - return { ok: false, reason: brainErrorMessage(created.error) } + return { ok: false, reason: hubErrorMessage(created.error) } } return { ok: true, ticket: created.data } } @@ -280,7 +280,7 @@ export type TicketRequestDraftResult = * ticket from `request.prompt` and submit it with * `POST /ticket-requests/:id/tickets`. A drafting or submission failure calls * `failTicketRequest` (best-effort: its own failure does not change the - * outcome reported here) so the brain does not keep the request stuck + * outcome reported here) so the hub does not keep the request stuck * claimed by a run that gave up on it. */ export async function draftAndSubmitTicketRequest( @@ -291,7 +291,7 @@ export async function draftAndSubmitTicketRequest( const fetchImpl = seams.fetchImpl ?? fetch const creds = loadSyncCredentials() if (!creds) { - return { ok: false, reason: 'not connected to a brain' } + return { ok: false, reason: 'not connected to a hub' } } const drafted = await draftTicketBody({ cwd, promptContext: request.prompt }, seams) @@ -307,7 +307,7 @@ export async function draftAndSubmitTicketRequest( fetchImpl, ) if (!submitted.ok) { - const reason = brainErrorMessage(submitted.error) + const reason = hubErrorMessage(submitted.error) await failTicketRequest(creds, request.id, reason, fetchImpl) return { ok: false, reason } } diff --git a/packages/cli/src/tui.test.ts b/packages/cli/src/tui.test.ts index d060c0b..626a98a 100644 --- a/packages/cli/src/tui.test.ts +++ b/packages/cli/src/tui.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { parseYesNo } from './tui.js' +import { maskCharacters, parseYesNo, textInput } from './tui.js' describe('parseYesNo', () => { test('accepts english yes/no in any case, trimmed', () => { @@ -25,3 +25,25 @@ describe('parseYesNo', () => { expect(parseYesNo('0')).toBe(null) }) }) + +describe('maskCharacters', () => { + test('renders one * per character, never the value itself', () => { + expect(maskCharacters('')).toBe('') + expect(maskCharacters('a')).toBe('*') + expect(maskCharacters('ghp_super_secret_token')).toBe( + '*'.repeat('ghp_super_secret_token'.length), + ) + }) + + test('counts by character length, unrelated to the actual bytes typed', () => { + expect(maskCharacters('12345')).toBe('*****') + expect(maskCharacters(' ')).toBe('*****') + }) +}) + +describe('textInput mask option', () => { + test('outside a TTY, resolves to null the same way whether masked or not', async () => { + expect(await textInput({ title: 'token' })).toBeNull() + expect(await textInput({ title: 'token', mask: true })).toBeNull() + }) +}) diff --git a/packages/cli/src/tui.ts b/packages/cli/src/tui.ts index 4e199ac..63e44f1 100644 --- a/packages/cli/src/tui.ts +++ b/packages/cli/src/tui.ts @@ -215,13 +215,80 @@ export async function select<T>(opts: { }) } +/** One `*` per character typed: what a masked `textInput` shows on screen instead of the value itself. */ +export function maskCharacters(value: string): string { + return '*'.repeat(value.length) +} + +/** + * Same raw-mode/keypress mechanics as `select`, but echoing `maskCharacters` + * instead of the real value: used for secrets (tokens) a masked `textInput` + * must never print in the clear, even transiently while typing. + */ +async function maskedTextInput(opts: { + title: string + placeholder?: string +}): Promise<string | null> { + const { stdin, stdout } = process + emitKeypressEvents(stdin) + const wasRaw = stdin.isRaw + stdin.setRawMode(true) + stdin.resume() + + let value = '' + const suffix = opts.placeholder ? ` ${faint(`(${opts.placeholder})`)}` : '' + const render = () => { + stdout.write(`\r\x1b[2K ${color('?', ACCENT)} ${opts.title}${suffix} ${maskCharacters(value)}`) + } + + return new Promise<string | null>((resolve) => { + const finish = (result: string | null) => { + stdin.removeListener('keypress', onKeypress) + stdin.setRawMode(Boolean(wasRaw)) + stdin.pause() + stdout.write('\n') + resolve(result) + } + + const onKeypress = (char: string | undefined, key: KeypressEvent) => { + if (key.ctrl && key.name === 'c') { + stdin.setRawMode(Boolean(wasRaw)) + stdout.write('\n') + process.exit(130) + } + if (key.name === 'return' || key.name === 'enter') { + return finish(value.trim() || null) + } + if (key.name === 'escape') { + return finish(null) + } + if (key.name === 'backspace') { + value = value.slice(0, -1) + return render() + } + if (char && !key.ctrl && char >= ' ' && char !== '\x7f') { + value += char + return render() + } + } + + stdin.on('keypress', onKeypress) + render() + }) +} + export async function textInput(opts: { title: string placeholder?: string + /** Echoes `*` per keystroke instead of the real value: use for tokens/secrets. */ + mask?: boolean }): Promise<string | null> { if (!isInteractive()) { return null } + if (opts.mask) { + return maskedTextInput(opts) + } const rl = createInterface({ input: process.stdin, output: process.stdout }) try { const suffix = opts.placeholder ? ` ${faint(`(${opts.placeholder})`)}` : '' diff --git a/packages/cli/src/wizard.test.ts b/packages/cli/src/wizard.test.ts index b50a499..dac3664 100644 --- a/packages/cli/src/wizard.test.ts +++ b/packages/cli/src/wizard.test.ts @@ -19,12 +19,12 @@ import { afterEach(() => setLanguage(null)) describe('describeConfigEntries', () => { - test('lists agent, language, auto-sync, brain auto-merge, merge strategy, turn budget then back, with current values as hints', () => { + test('lists agent, language, auto-sync, runner auto-merge, merge strategy, turn budget then back, with current values as hints', () => { const entries = describeConfigEntries({ agent: 'claude -p --model opus', language: 'fr', syncAutoPush: true, - brainAutoMerge: false, + runnerAutoMerge: false, mergeStrategy: 'squash', maxTaskTurns: 60, }) @@ -32,7 +32,7 @@ describe('describeConfigEntries', () => { 'agent', 'language', 'autoSync', - 'brainAutoMerge', + 'runnerAutoMerge', 'mergeStrategy', 'maxTaskTurns', 'back', @@ -40,7 +40,7 @@ describe('describeConfigEntries', () => { expect(entries[0]?.hint).toBe('claude -p --model opus') expect(entries[1]?.hint).toBe('Français') expect(entries[2]?.hint).toBe(t('config.autoSyncOn')) - expect(entries[3]?.hint).toBe(t('config.brainAutoMergeOff')) + expect(entries[3]?.hint).toBe(t('config.runnerAutoMergeOff')) expect(entries[4]?.hint).toBe('squash') expect(entries[5]?.hint).toBe('60') }) @@ -50,8 +50,8 @@ describe('describeConfigEntries', () => { expect(entries[0]?.hint).toBe(t('config.agentEntryUnset')) expect(entries[1]?.hint).toBe(t('config.languageAuto')) expect(entries[2]?.hint).toBe(t('config.autoSyncUnset')) - // Unlike the other three, absent resolves to ON (resolveBrainAutoMerge's own doctrine), never an "unset" placeholder. - expect(entries[3]?.hint).toBe(t('config.brainAutoMergeOn')) + // Unlike the other three, absent resolves to ON (resolveRunnerAutoMerge's own doctrine), never an "unset" placeholder. + expect(entries[3]?.hint).toBe(t('config.runnerAutoMergeOn')) // Like autoSync, absence IS its own state here (resolveMergeSettings, D13): no strategy is picked on the project's behalf. expect(entries[4]?.hint).toBe(t('config.mergeStrategyUnset')) }) diff --git a/packages/cli/src/wizard.ts b/packages/cli/src/wizard.ts index 147c1ac..dc7e874 100644 --- a/packages/cli/src/wizard.ts +++ b/packages/cli/src/wizard.ts @@ -496,7 +496,7 @@ export async function runOnboarding(cwd: string): Promise<string | null> { } export type ConfigEntryId = - 'agent' | 'language' | 'autoSync' | 'brainAutoMerge' | 'mergeStrategy' | 'maxTaskTurns' | 'back' + 'agent' | 'language' | 'autoSync' | 'runnerAutoMerge' | 'mergeStrategy' | 'maxTaskTurns' | 'back' export type ConfigEntry = { id: ConfigEntryId @@ -521,9 +521,9 @@ function autoSyncLabel(syncAutoPush: boolean | undefined): string { return syncAutoPush ? t('config.autoSyncOn') : t('config.autoSyncOff') } -/** Unlike autoSyncLabel, no "unset" state: resolveBrainAutoMerge (config.ts) treats absent as on. */ -function brainAutoMergeLabel(brainAutoMerge: boolean | undefined): string { - return (brainAutoMerge ?? true) ? t('config.brainAutoMergeOn') : t('config.brainAutoMergeOff') +/** Unlike autoSyncLabel, no "unset" state: resolveRunnerAutoMerge (config.ts) treats absent as on. */ +function runnerAutoMergeLabel(runnerAutoMerge: boolean | undefined): string { + return (runnerAutoMerge ?? true) ? t('config.runnerAutoMergeOn') : t('config.runnerAutoMergeOff') } /** Like autoSyncLabel, absence IS its own displayed state: resolveMergeSettings @@ -544,9 +544,9 @@ export function describeConfigEntries(current: CodesemaConfig): ConfigEntry[] { { id: 'language', label: t('config.languageEntry'), hint: languageLabel(current.language) }, { id: 'autoSync', label: t('config.autoSyncEntry'), hint: autoSyncLabel(current.syncAutoPush) }, { - id: 'brainAutoMerge', - label: t('config.brainAutoMergeEntry'), - hint: brainAutoMergeLabel(current.brainAutoMerge), + id: 'runnerAutoMerge', + label: t('config.runnerAutoMergeEntry'), + hint: runnerAutoMergeLabel(current.runnerAutoMerge), }, { id: 'mergeStrategy', @@ -648,30 +648,30 @@ export async function configCommand(repoRoot: string | null): Promise<void> { continue } - if (picked === 'brainAutoMerge') { + if (picked === 'runnerAutoMerge') { const choice = await select<'on' | 'off'>({ - title: t('config.brainAutoMergeQuestion'), + title: t('config.runnerAutoMergeQuestion'), options: [ - { label: t('config.brainAutoMergeOff'), hint: '', value: 'off' }, + { label: t('config.runnerAutoMergeOff'), hint: '', value: 'off' }, { - label: t('config.brainAutoMergeOn'), - hint: t('config.brainAutoMergeOnHint'), + label: t('config.runnerAutoMergeOn'), + hint: t('config.runnerAutoMergeOnHint'), value: 'on', }, ], - initialIndex: (current.brainAutoMerge ?? true) ? 1 : 0, + initialIndex: (current.runnerAutoMerge ?? true) ? 1 : 0, summary: false, }) if (choice === null) { continue } // GLOBAL-ONLY, same doctrine as autoSync/mergePolicy (config.ts's own - // doc on brainAutoMerge): a consent to merge without asking is the + // doc on runnerAutoMerge): a consent to merge without asking is the // machine owner's to give, not a cloned repository's. - const path = saveGlobalConfig({ ...loadGlobalConfig(), brainAutoMerge: choice === 'on' }) + const path = saveGlobalConfig({ ...loadGlobalConfig(), runnerAutoMerge: choice === 'on' }) console.log('') console.log( - ` ${t('config.brainAutoMergeSaved', { state: brainAutoMergeLabel(choice === 'on'), path })}`, + ` ${t('config.runnerAutoMergeSaved', { state: runnerAutoMergeLabel(choice === 'on'), path })}`, ) console.log('') continue @@ -691,7 +691,7 @@ export async function configCommand(repoRoot: string | null): Promise<void> { if (choice === null) { continue } - // GLOBAL-ONLY, same doctrine as brainAutoMerge/maxTaskTurns (config.ts's + // GLOBAL-ONLY, same doctrine as runnerAutoMerge/maxTaskTurns (config.ts's // own doc on mergeStrategy): how a repository merges is the machine // owner's call, not a cloned repository's. 'unset' clears the key // rather than storing a sentinel, so the forge keeps applying its own diff --git a/packages/cli/src/workspace-lock.test.ts b/packages/cli/src/workspace-lock.test.ts index a47e207..448bca7 100644 --- a/packages/cli/src/workspace-lock.test.ts +++ b/packages/cli/src/workspace-lock.test.ts @@ -104,7 +104,7 @@ describe('acquireWorkspaceLock', () => { }) }) -// Exported for brain-pidfile.ts (D21): brain.pid follows the same "a dead +// Exported for runner-pidfile.ts (D21): runner.pid follows the same "a dead // pid is never a permanent blocker" doctrine as this lock, and reuses this // exact check rather than a second copy of it. describe('isPidAlive', () => { diff --git a/packages/cli/src/workspace-lock.ts b/packages/cli/src/workspace-lock.ts index a6f2747..a61632f 100644 --- a/packages/cli/src/workspace-lock.ts +++ b/packages/cli/src/workspace-lock.ts @@ -36,7 +36,7 @@ export function readWorkspaceLock(): WorkspaceLock | null { /** * Signal 0 probes existence without sending anything. EPERM means the pid is * alive but owned by someone else — still alive, so still a real holder. - * Exported for brain-pidfile.ts's readers (D21): the repo-local brain.pid + * Exported for runner-pidfile.ts's readers (D21): the repo-local runner.pid * follows the same "a dead pid blocks nothing" doctrine as this lock. */ export function isPidAlive(pid: number): boolean { diff --git a/packages/cli/src/workspace.ts b/packages/cli/src/workspace.ts index d5c1b90..3ec33b3 100644 --- a/packages/cli/src/workspace.ts +++ b/packages/cli/src/workspace.ts @@ -6,12 +6,12 @@ // repo, the workspace opens on the existing registry (possibly empty — add // projects from the UI). The process stays in the foreground: tasks live as // long as it runs. D21 introduces one targeted exception to that: -// `codesema brain serve --detach` (brain-commands.ts) backgrounds the brain +// `codesema runner serve --detach` (runner-commands.ts) backgrounds the runner // daemon behind a detached child process; every other entry point (bare -// `codesema workspace`, `codesema review`, `codesema brain serve` without the -// flag) stays foreground-only. Whenever CODESEMA_BRAIN_MODE is set, a -// repo-local `<cwd>/.codesema/brain.pid` (brain-pidfile.ts) records -// {pid, port, started_at} once the port is known, so `brain stop`/`brain +// `codesema workspace`, `codesema review`, `codesema runner serve` without the +// flag) stays foreground-only. Whenever CODESEMA_RUNNER_MODE is set, a +// repo-local `<cwd>/.codesema/runner.pid` (runner-pidfile.ts) records +// {pid, port, started_at} once the port is known, so `runner stop`/`runner // status`, run later from a different process, can find this daemon; it // is erased on shutdown, right beside the lock below. The first // Ctrl-C shuts down gracefully (agents SIGTERMed, the turns IN FLIGHT @@ -26,7 +26,6 @@ // racing this one's registry and task stores. import { knownAgent, type WatchdogBudgets } from './agent.js' -import { removeBrainPidfile, writeBrainPidfile } from './brain-pidfile.js' import { globalConfigPath, hasInvalidPositiveIntKey, @@ -49,6 +48,7 @@ import { t, uiLocale } from './i18n.js' import { createMrReviewRunner } from './mr-review-runner.js' import { openBrowser } from './open.js' import { addProject, listProjects, type Project } from './projects.js' +import { removeRunnerPidfile, writeRunnerPidfile } from './runner-pidfile.js' import { createSession, startServer } from './serve.js' import { DEFAULT_ISOLATION_ALLOWED_DOMAINS, @@ -384,8 +384,8 @@ function installShutdownHandlers(deps: { lock.release() // Mirrors the write at lock.setPort() below: only ever written and // removed together, gated on the same env var. - if (process.env.CODESEMA_BRAIN_MODE === '1') { - removeBrainPidfile(cwd) + if (process.env.CODESEMA_RUNNER_MODE === '1') { + removeRunnerPidfile(cwd) } process.exit(0) } @@ -606,8 +606,8 @@ export async function workspace( throw err } lock.setPort(started.port) - if (process.env.CODESEMA_BRAIN_MODE === '1') { - writeBrainPidfile(repoRoot ?? opts.cwd, process.pid, started.port) + if (process.env.CODESEMA_RUNNER_MODE === '1') { + writeRunnerPidfile(repoRoot ?? opts.cwd, process.pid, started.port) } console.log('') diff --git a/packages/contract/README.md b/packages/contract/README.md index 7c6fe2c..ac59535 100644 --- a/packages/contract/README.md +++ b/packages/contract/README.md @@ -11,7 +11,7 @@ This package is intentionally tiny and dependency-free. It contains no I/O, no n - **Grounding**: `groundReview` checks a sanitized review against the diff it claims to describe — findings on files absent from the diff are dropped, line anchors outside every hunk are removed, duplicates (same file, line and kind) merge keeping the highest severity, and an `approve` verdict left with a critical finding is escalated to `request_changes`. It returns the corrected review plus a `GroundingReport` of what was changed. - **Secret scanner**: `detectDiffSecrets` returns the `SecretMatch`es in a diff (dotenv files, private keys, and AWS/GitHub/Slack/Google/Stripe/OpenAI/Anthropic credentials), so a diff carrying a committed secret is never uploaded. - **Ticket contract**: `TicketBody` (five sections with verbatim English headings) and `AcceptanceCriterion` (`{ id, text }`, the `id` derived from the text so reordering the list renames nothing), with the deterministic lint that gates a ticket about to be launched — `lintTicketBody`, `lintCriteria` — and the tolerant read-back side `sanitizeTicketBody`, `readAcceptanceCriteria`, `extractAcceptanceCriteria`. -- **Brain wire types**: the types and sanitizers for the tickets, transitions and events exchanged between the brain (the local SaaS that owns a repository's tickets) and the arm (this CLI, claiming and executing them): `ArmTicketRequest` and `ArmTicket` (a ticket at proposal time and once published, `ArmTicket.status` a closed lifecycle enum), `ArmTransition` (one fact the arm reports back, e.g. `mr_opened`, `merged`, gated on a mandatory `idempotency_key`), `ArmEvent` (one line of the arm's execution journal) and `ArmClaimResult` (the brain's claim/lease response), with their sanitizers `sanitizeArmTicketRequest`, `sanitizeArmTicket`, `sanitizeArmTransition`, `sanitizeArmEvent`, `sanitizeArmClaimResult`. `TaskRecord.brain_ticket` (tasks.ts) carries the write-once pointer back from a task to the brain ticket it was claimed from. +- **Hub wire types**: the types and sanitizers for the tickets, transitions and events exchanged between the hub (the local SaaS that owns a repository's tickets) and the arm (this CLI, claiming and executing them): `ArmTicketRequest` and `ArmTicket` (a ticket at proposal time and once published, `ArmTicket.status` a closed lifecycle enum), `ArmTransition` (one fact the arm reports back, e.g. `mr_opened`, `merged`, gated on a mandatory `idempotency_key`), `ArmEvent` (one line of the arm's execution journal) and `ArmClaimResult` (the hub's claim/lease response), with their sanitizers `sanitizeArmTicketRequest`, `sanitizeArmTicket`, `sanitizeArmTransition`, `sanitizeArmEvent`, `sanitizeArmClaimResult`. `TaskRecord.hub_ticket` (tasks.ts) carries the write-once pointer back from a task to the hub ticket it was claimed from. - **JSON Schemas**: `reviewRecordSchema`, `ticketBodySchema`, `recapRecordSchema`, `armTicketSchema` and `armTransitionSchema`, the record, ticket-body, recap, arm-ticket and arm-transition shapes as draft 2020-12 schemas, for validation outside TypeScript. ## Usage @@ -25,22 +25,22 @@ if (!record) throw new Error('unusable review record') The codesema CLI uses these functions to validate agent output before archiving a review; codesema.com uses the very same functions to validate reviews synced from the CLI. One source of truth on both sides of the wire. -## Cross-repo conformance with the brain +## Cross-repo conformance with the hub -The brain (a separate repo: the local SaaS whose `/api/cli` routes the `Arm*` sanitizers above exist to talk to) publishes its own TypeBox body schemas for those routes. `fixtures/cerveau-schemas/*.schema.json` is a committed, hand-synced copy of them, and `brain.test.ts`'s "cross-repo" tests validate this package's sanitizer output against those copies with [ajv](https://ajv.js.org) (a devDependency, test-only: the published package stays runtime dependency-free), on top of the tests that validate output against this package's own published schemas. +The hub (a separate repo: the local SaaS whose `/api/cli` routes the `Arm*` sanitizers above exist to talk to) publishes its own TypeBox body schemas for those routes. `fixtures/hub-schemas/*.schema.json` is a committed, hand-synced copy of them, and `arm.test.ts`'s "cross-repo" tests validate this package's sanitizer output against those copies with [ajv](https://ajv.js.org) (a devDependency, test-only: the published package stays runtime dependency-free), on top of the tests that validate output against this package's own published schemas. -This exists because of a real incident: a 422 on `run_id` crossed both repos' test suites unnoticed, because the brain required a uuid shape while the arm sends a 12-hex task id, and each repo only ever checked its own copy of the shape. +This exists because of a real incident: a 422 on `run_id` crossed both repos' test suites unnoticed, because the hub required a uuid shape while the arm sends a 12-hex task id, and each repo only ever checked its own copy of the shape. **Syncing the fixtures.** Run from a machine with both repos checked out as local siblings: ``` -bun run --cwd packages/contract sync-brain-schemas -- --check # report drift, exit 1 if stale, writes nothing -bun run --cwd packages/contract sync-brain-schemas # copy the brain's current schemas over the fixtures +bun run --cwd packages/contract sync-hub-schemas -- --check # report drift, exit 1 if stale, writes nothing +bun run --cwd packages/contract sync-hub-schemas # copy the hub's current schemas over the fixtures ``` -The brain repo path defaults to this repo's sibling directory named `codesema`; override it with a positional argument or the `CODESEMA_BRAIN_REPO` env var. The brain must have already run its own export (`bun backend/scripts/export-cli-schemas.ts` from the brain repo) so its `backend/contracts/cli/*.schema.json` files exist. +The hub repo path defaults to this repo's sibling directory named `codesema`; override it with a positional argument or the `CODESEMA_HUB_REPO` env var. The hub must have already run its own export (`bun backend/scripts/export-cli-schemas.ts` from the hub repo) so its `backend/contracts/cli/*.schema.json` files exist. -The sync is manual and deliberately NOT wired into CI: the fixtures are allowed to lag behind the brain's actual schemas between syncs, on purpose, so this package's own test suite never depends on the brain repo being present or reachable. Run it after a change to the brain's `/api/cli` body schemas, or whenever the cross-repo tests in `brain.test.ts` look suspicious. +The sync is manual and deliberately NOT wired into CI: the fixtures are allowed to lag behind the hub's actual schemas between syncs, on purpose, so this package's own test suite never depends on the hub repo being present or reachable. Run it after a change to the hub's `/api/cli` body schemas, or whenever the cross-repo tests in `arm.test.ts` look suspicious. ## Versioning diff --git a/packages/contract/fixtures/cerveau-schemas/claim.schema.json b/packages/contract/fixtures/hub-schemas/claim.schema.json similarity index 100% rename from packages/contract/fixtures/cerveau-schemas/claim.schema.json rename to packages/contract/fixtures/hub-schemas/claim.schema.json diff --git a/packages/contract/fixtures/cerveau-schemas/events.schema.json b/packages/contract/fixtures/hub-schemas/events.schema.json similarity index 100% rename from packages/contract/fixtures/cerveau-schemas/events.schema.json rename to packages/contract/fixtures/hub-schemas/events.schema.json diff --git a/packages/contract/fixtures/cerveau-schemas/heartbeat.schema.json b/packages/contract/fixtures/hub-schemas/heartbeat.schema.json similarity index 100% rename from packages/contract/fixtures/cerveau-schemas/heartbeat.schema.json rename to packages/contract/fixtures/hub-schemas/heartbeat.schema.json diff --git a/packages/contract/fixtures/hub-schemas/runner-register.schema.json b/packages/contract/fixtures/hub-schemas/runner-register.schema.json new file mode 100644 index 0000000..5f99d88 --- /dev/null +++ b/packages/contract/fixtures/hub-schemas/runner-register.schema.json @@ -0,0 +1,19 @@ +{ + "type": "object", + "required": [ + "public_key", + "name" + ], + "properties": { + "public_key": { + "minLength": 44, + "maxLength": 44, + "type": "string" + }, + "name": { + "minLength": 1, + "maxLength": 200, + "type": "string" + } + } +} diff --git a/packages/contract/fixtures/hub-schemas/runner-secret-claim.schema.json b/packages/contract/fixtures/hub-schemas/runner-secret-claim.schema.json new file mode 100644 index 0000000..e1a8346 --- /dev/null +++ b/packages/contract/fixtures/hub-schemas/runner-secret-claim.schema.json @@ -0,0 +1,4 @@ +{ + "type": "object", + "properties": {} +} diff --git a/packages/contract/fixtures/hub-schemas/runner-secret.schema.json b/packages/contract/fixtures/hub-schemas/runner-secret.schema.json new file mode 100644 index 0000000..a6b4432 --- /dev/null +++ b/packages/contract/fixtures/hub-schemas/runner-secret.schema.json @@ -0,0 +1,13 @@ +{ + "type": "object", + "required": [ + "ciphertext" + ], + "properties": { + "ciphertext": { + "minLength": 1, + "maxLength": 8192, + "type": "string" + } + } +} diff --git a/packages/contract/fixtures/cerveau-schemas/transitions.schema.json b/packages/contract/fixtures/hub-schemas/transitions.schema.json similarity index 100% rename from packages/contract/fixtures/cerveau-schemas/transitions.schema.json rename to packages/contract/fixtures/hub-schemas/transitions.schema.json diff --git a/packages/contract/package.json b/packages/contract/package.json index 2114088..936eceb 100644 --- a/packages/contract/package.json +++ b/packages/contract/package.json @@ -1,6 +1,6 @@ { "name": "@codesema/contract", - "version": "0.8.0", + "version": "0.9.0", "description": "Shared review contract (types + sanitizers) between the codesema CLI and codesema.com.", "license": "MIT", "author": "Hasan TASKIN", @@ -30,7 +30,7 @@ "build": "tsdown", "typecheck": "tsc --noEmit", "prepublishOnly": "npm run build", - "sync-brain-schemas": "node scripts/sync-brain-schemas.mjs" + "sync-hub-schemas": "node scripts/sync-hub-schemas.mjs" }, "engines": { "node": ">=20" diff --git a/packages/contract/scripts/sync-brain-schemas.mjs b/packages/contract/scripts/sync-hub-schemas.mjs similarity index 56% rename from packages/contract/scripts/sync-brain-schemas.mjs rename to packages/contract/scripts/sync-hub-schemas.mjs index b769a4e..65558d2 100644 --- a/packages/contract/scripts/sync-brain-schemas.mjs +++ b/packages/contract/scripts/sync-hub-schemas.mjs @@ -1,31 +1,31 @@ /** - * Manual sync of the brain's exported `/api/cli` JSON Schemas into this + * Manual sync of the hub's exported `/api/cli` JSON Schemas into this * package's committed fixtures (D-contrat, asymmetric arbitration). * - * The brain (backend/scripts/export-cli-schemas.ts, a SEPARATE repo) emits + * The hub (backend/scripts/export-cli-schemas.ts, a SEPARATE repo) emits * its TypeBox body schemas as JSON Schema files. This script copies those - * files into fixtures/cerveau-schemas/ so brain.test.ts can validate this - * package's sanitizer output against the brain's ACTUAL wire contract, not a + * files into fixtures/hub-schemas/ so hub.test.ts can validate this + * package's sanitizer output against the hub's ACTUAL wire contract, not a * hand-copied guess of it. That guess is exactly the class of bug that - * motivated this: a 422 on `run_id` (uuid on the brain's side, a 12-hex + * motivated this: a 422 on `run_id` (uuid on the hub's side, a 12-hex * arm-generated id on this side) that neither repo's own tests could see, * because each repo only checked its own copy of the shape. * * Deliberately NOT wired into CI and NOT a network fetch: both repos are * assumed to sit as local sibling checkouts on the machine running this - * script, and the sync is a manual step run after the brain regenerates its - * schemas. The fixtures are therefore allowed to lag behind the brain by + * script, and the sync is a manual step run after the hub regenerates its + * schemas. The fixtures are therefore allowed to lag behind the hub by * design: that lag is the cost of keeping this repo's tests independent of - * the brain repo's availability, not an oversight. + * the hub repo's availability, not an oversight. * * Usage (from packages/contract/): - * node scripts/sync-brain-schemas.mjs # copy, report drift - * node scripts/sync-brain-schemas.mjs --check # report only, exit 1 on drift - * node scripts/sync-brain-schemas.mjs /path/to/codesema # explicit brain repo path - * CODESEMA_BRAIN_REPO=/path/to/codesema node scripts/sync-brain-schemas.mjs + * node scripts/sync-hub-schemas.mjs # copy, report drift + * node scripts/sync-hub-schemas.mjs --check # report only, exit 1 on drift + * node scripts/sync-hub-schemas.mjs /path/to/codesema # explicit hub repo path + * CODESEMA_HUB_REPO=/path/to/codesema node scripts/sync-hub-schemas.mjs * - * Resolution order for the brain repo path: CLI argument, then - * CODESEMA_BRAIN_REPO env var, then a default resolved relative to THIS + * Resolution order for the hub repo path: CLI argument, then + * CODESEMA_HUB_REPO env var, then a default resolved relative to THIS * FILE (not the invocation cwd, which would make the default fragile * depending on where the script is run from): ../../../../codesema, i.e. * codesema-tools's own sibling directory named `codesema`. @@ -36,25 +36,33 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' -const SCHEMA_NAMES = ['claim', 'heartbeat', 'transitions', 'events'] +const SCHEMA_NAMES = [ + 'claim', + 'heartbeat', + 'transitions', + 'events', + 'runner-register', + 'runner-secret', + 'runner-secret-claim', +] const here = path.dirname(fileURLToPath(import.meta.url)) -const FIXTURES_DIR = path.join(here, '..', 'fixtures', 'cerveau-schemas') -const DEFAULT_BRAIN_REPO = path.join(here, '..', '..', '..', '..', 'codesema') +const FIXTURES_DIR = path.join(here, '..', 'fixtures', 'hub-schemas') +const DEFAULT_HUB_REPO = path.join(here, '..', '..', '..', '..', 'codesema') function parseArgs(argv) { const check = argv.includes('--check') const positional = argv.find((arg) => arg !== '--check') - const brainRepo = positional ?? process.env.CODESEMA_BRAIN_REPO ?? DEFAULT_BRAIN_REPO - return { check, brainRepo: path.resolve(brainRepo) } + const hubRepo = positional ?? process.env.CODESEMA_HUB_REPO ?? DEFAULT_HUB_REPO + return { check, hubRepo: path.resolve(hubRepo) } } -function readBrainSchema(brainRepo, name) { - const filePath = path.join(brainRepo, 'backend', 'contracts', 'cli', `${name}.schema.json`) +function readHubSchema(hubRepo, name) { + const filePath = path.join(hubRepo, 'backend', 'contracts', 'cli', `${name}.schema.json`) if (!existsSync(filePath)) { throw new Error( - `brain schema not found: ${filePath}\n` + - `run its export script first: (cd ${brainRepo} && bun backend/scripts/export-cli-schemas.ts)`, + `hub schema not found: ${filePath}\n` + + `run its export script first: (cd ${hubRepo} && bun backend/scripts/export-cli-schemas.ts)`, ) } return JSON.parse(readFileSync(filePath, 'utf8')) @@ -74,8 +82,8 @@ function reportDrift(name, current, incoming) { } } -function syncOne(name, brainRepo, check) { - const incoming = readBrainSchema(brainRepo, name) +function syncOne(name, hubRepo, check) { + const incoming = readHubSchema(hubRepo, name) const current = readFixture(name) try { deepStrictEqual(current, incoming) @@ -93,11 +101,11 @@ function syncOne(name, brainRepo, check) { } function main() { - const { check, brainRepo } = parseArgs(process.argv.slice(2)) - console.log(`brain repo: ${brainRepo}${check ? ' (--check: report only)' : ''}`) + const { check, hubRepo } = parseArgs(process.argv.slice(2)) + console.log(`hub repo: ${hubRepo}${check ? ' (--check: report only)' : ''}`) mkdirSync(FIXTURES_DIR, { recursive: true }) - const drifted = SCHEMA_NAMES.map((name) => syncOne(name, brainRepo, check)).some(Boolean) + const drifted = SCHEMA_NAMES.map((name) => syncOne(name, hubRepo, check)).some(Boolean) if (check && drifted) { console.error('\nfixtures are stale: run without --check to update them') diff --git a/packages/contract/src/brain.test.ts b/packages/contract/src/arm.test.ts similarity index 93% rename from packages/contract/src/brain.test.ts rename to packages/contract/src/arm.test.ts index 9f8ad2b..a7de151 100644 --- a/packages/contract/src/brain.test.ts +++ b/packages/contract/src/arm.test.ts @@ -1,10 +1,10 @@ import { randomBytes, randomUUID } from 'node:crypto' import Ajv from 'ajv' import { describe, expect, test } from 'bun:test' -import claimBodySchema from '../fixtures/cerveau-schemas/claim.schema.json' -import eventsBodySchema from '../fixtures/cerveau-schemas/events.schema.json' -import heartbeatBodySchema from '../fixtures/cerveau-schemas/heartbeat.schema.json' -import transitionsBodySchema from '../fixtures/cerveau-schemas/transitions.schema.json' +import claimBodySchema from '../fixtures/hub-schemas/claim.schema.json' +import eventsBodySchema from '../fixtures/hub-schemas/events.schema.json' +import heartbeatBodySchema from '../fixtures/hub-schemas/heartbeat.schema.json' +import transitionsBodySchema from '../fixtures/hub-schemas/transitions.schema.json' import { ARM_BODY_MAX, ARM_BRANCH_MAX, @@ -41,7 +41,7 @@ import { type ArmTicket, type ArmTicketRequest, type ArmTransition, -} from './brain.js' +} from './arm.js' import { TASK_STATUS_VALUES } from './tasks.js' // --- Fixtures ---------------------------------------------------------------- @@ -1100,24 +1100,24 @@ describe('reverse cross test: armOrderSchema is not looser than sanitizeArmOrder }) }) -// --- Cross-repo: brain schemas (D-contrat, asymmetric arbitration) -------- +// --- Cross-repo: hub schemas (D-contrat, asymmetric arbitration) -------- // -// The brain (a separate repo) exports its own TypeBox body schemas for the +// The hub (a separate repo) exports its own TypeBox body schemas for the // four `/api/cli` routes this package's sanitizers exist to talk to, as // plain JSON Schema files synced here BY HAND (never over the network, never -// wired into CI: scripts/sync-brain-schemas.mjs, see this package's README) -// into fixtures/cerveau-schemas/. Everything above this point proves a +// wired into CI: scripts/sync-hub-schemas.mjs, see this package's README) +// into fixtures/hub-schemas/. Everything above this point proves a // sanitizer's output against THIS package's own published schema; the tests -// below prove the same output against the BRAIN's independently-maintained +// below prove the same output against the HUB's independently-maintained // schema, the only thing that can catch the two repos' copies of a shape // drifting apart. // // Concrete motivation: a 422 that crossed both repos' own test suites, -// because the brain once required `run_id` to look like a uuid while the arm +// because the hub once required `run_id` to look like a uuid while the arm // generates one as a 12-hex string (`randomBytes(6).toString('hex')`, // packages/cli/src/tasks-store.ts, reused below as `armRunId`). Each repo's // tests only ever checked its own copy of the shape, so neither caught the -// mismatch. The tests below require the brain's copied schema to accept +// mismatch. The tests below require the hub's copied schema to accept // exactly that shape, so a regression on either side fails here instead of // on a production heartbeat. @@ -1143,11 +1143,11 @@ function sanitizedValidEvent(overrides: Partial<ArmEvent> = {}): ArmEvent { /** * The envelope POSTed to `/api/cli/tickets/:id/events`: `remote_url`/`run_id`/ * `ticket_id` alongside the batch, on top of each item's own `run_id` - * (cli-tickets.ts's `cliEventsBodySchema`, brain repo). Not a type this + * (cli-tickets.ts's `cliEventsBodySchema`, hub repo). Not a type this * package publishes (only the per-item `ArmEvent` is), so built here * directly. Override fields are typed `unknown`, not `ArmEvent`-shaped: * several tests below deliberately pass a shape sanitizeArmEvent would never - * produce, to prove the BRAIN schema also refuses it. + * produce, to prove the HUB schema also refuses it. */ function eventEnvelope( overrides: { @@ -1166,25 +1166,25 @@ function eventEnvelope( } describe('cross-repo: claim and heartbeat request bodies (no dedicated sanitizer in this package)', () => { - // Mirrors the brain's own MAX_LEASE_SECONDS and 1-second floor + // Mirrors the hub's own MAX_LEASE_SECONDS and 1-second floor // (backend/src/modules/tickets/adapters/ticket-claim.ts), duplicated here // rather than imported: same asymmetric arbitration as the rest of this - // block, this package has no dependency on the brain repo. - const BRAIN_LEASE_SECONDS_MIN = 1 - const BRAIN_LEASE_SECONDS_MAX = 900 + // block, this package has no dependency on the hub repo. + const HUB_LEASE_SECONDS_MIN = 1 + const HUB_LEASE_SECONDS_MAX = 900 test('an empty claim body (lease_seconds omitted) validates', () => { expect(validateClaimBody({})).toBe(true) }) - test('a lease_seconds within the brain-documented bail range validates', () => { - for (const lease_seconds of [BRAIN_LEASE_SECONDS_MIN, 180, BRAIN_LEASE_SECONDS_MAX]) { + test('a lease_seconds within the hub-documented bail range validates', () => { + for (const lease_seconds of [HUB_LEASE_SECONDS_MIN, 180, HUB_LEASE_SECONDS_MAX]) { expect(validateClaimBody({ lease_seconds })).toBe(true) } }) - test('a lease_seconds outside the brain-documented bail range is refused', () => { - for (const lease_seconds of [BRAIN_LEASE_SECONDS_MIN - 1, BRAIN_LEASE_SECONDS_MAX + 1, 0, -1]) { + test('a lease_seconds outside the hub-documented bail range is refused', () => { + for (const lease_seconds of [HUB_LEASE_SECONDS_MIN - 1, HUB_LEASE_SECONDS_MAX + 1, 0, -1]) { expect(validateClaimBody({ lease_seconds })).toBe(false) } }) @@ -1199,13 +1199,13 @@ describe('cross-repo: claim and heartbeat request bodies (no dedicated sanitizer expect(validateHeartbeatBody({ lease_seconds: 180 })).toBe(true) }) - test('a local_status at the brain bound (40) validates, one over it is refused', () => { + test('a local_status at the hub bound (40) validates, one over it is refused', () => { expect(validateHeartbeatBody({ local_status: 'x'.repeat(40) })).toBe(true) expect(validateHeartbeatBody({ local_status: 'x'.repeat(41) })).toBe(false) }) }) -describe('cross-repo: sanitizeArmTransition output validates against the brain schema', () => { +describe('cross-repo: sanitizeArmTransition output validates against the hub schema', () => { test('the minimal transition validates', () => { expect(validateTransitionBody(sanitizeArmTransition(structuredClone(minimalTransition)))).toBe( true, @@ -1218,7 +1218,7 @@ describe('cross-repo: sanitizeArmTransition output validates against the brain s ) }) - test('every valid transition type produces a brain-schema-valid transition', () => { + test('every valid transition type produces a hub-schema-valid transition', () => { const types = ['mr_opened', 'review_result', 'merged', 'failed'] as const for (const type of types) { expect(validateTransitionBody(sanitizeArmTransition({ ...minimalTransition, type }))).toBe( @@ -1228,25 +1228,25 @@ describe('cross-repo: sanitizeArmTransition output validates against the brain s }) }) -describe('reverse cross-repo: the brain schema is not looser than sanitizeArmTransition on the fields it constrains', () => { - test('a blank idempotency_key: refused by sanitizeArmTransition (null) and by the brain schema', () => { +describe('reverse cross-repo: the hub schema is not looser than sanitizeArmTransition on the fields it constrains', () => { + test('a blank idempotency_key: refused by sanitizeArmTransition (null) and by the hub schema', () => { expect(sanitizeArmTransition({ ...minimalTransition, idempotency_key: '' })).toBeNull() expect(validateTransitionBody({ ...minimalTransition, idempotency_key: '' })).toBe(false) }) - test('an unrecognized type: refused by sanitizeArmTransition (null) and by the brain schema', () => { + test('an unrecognized type: refused by sanitizeArmTransition (null) and by the hub schema', () => { expect(sanitizeArmTransition({ ...minimalTransition, type: 'not-a-type' })).toBeNull() expect(validateTransitionBody({ ...minimalTransition, type: 'not-a-type' })).toBe(false) }) - test('a missing idempotency_key: refused by sanitizeArmTransition (null) and by the brain schema', () => { + test('a missing idempotency_key: refused by sanitizeArmTransition (null) and by the hub schema', () => { const { idempotency_key: _drop, ...withoutKey } = minimalTransition expect(sanitizeArmTransition(withoutKey)).toBeNull() expect(validateTransitionBody(withoutKey)).toBe(false) }) }) -describe('cross-repo: closes the run_id class (12-hex arm task id vs the brain schema)', () => { +describe('cross-repo: closes the run_id class (12-hex arm task id vs the hub schema)', () => { test('a 12-hex run_id, the shape the arm actually generates, validates at the envelope level', () => { expect(validateEventsBody(eventEnvelope())).toBe(true) }) @@ -1258,59 +1258,59 @@ describe('cross-repo: closes the run_id class (12-hex arm task id vs the brain s // Mirrors sanitizeArmEvent's own "a missing or blank run_id: no usable // identity, null" table (above), at the ENVELOPE level, where the original - // incident actually lived: an empty run_id has length 0, so the brain's + // incident actually lived: an empty run_id has length 0, so the hub's // own `minLength: 1` catches it exactly like sanitizeArmEvent does. - test('an empty run_id: refused by sanitizeArmEvent (null) and by the brain schema (minLength 1)', () => { + test('an empty run_id: refused by sanitizeArmEvent (null) and by the hub schema (minLength 1)', () => { expect(sanitizeArmEvent({ ...validEvent, run_id: '' })).toBeNull() expect(validateEventsBody(eventEnvelope({ run_id: '' }))).toBe(false) }) // A DIFFERENT case from the empty string above, and deliberately NOT // asserted as refused: `minLength` counts raw characters, it does not trim - // first, so a whitespace-only run_id (length 3) satisfies the brain's - // `minLength: 1` even though sanitizeArmEvent refuses it as blank. Brain + // first, so a whitespace-only run_id (length 3) satisfies the hub's + // `minLength: 1` even though sanitizeArmEvent refuses it as blank. Hub // schema looser than this package's sanitizer is fine per the D-contrat - // arbitration (only the reverse, brain stricter than what the arm actually + // arbitration (only the reverse, hub stricter than what the arm actually // produces, is the bug class this suite exists to catch), and // sanitizeArmEvent never lets a whitespace-only run_id reach the wire in // the first place, so this asymmetry has no real payload to bite on. - test('a whitespace-only run_id: refused by sanitizeArmEvent (null), but the brain schema does not trim, so it accepts the raw shape', () => { + test('a whitespace-only run_id: refused by sanitizeArmEvent (null), but the hub schema does not trim, so it accepts the raw shape', () => { expect(sanitizeArmEvent({ ...validEvent, run_id: ' ' })).toBeNull() expect(validateEventsBody(eventEnvelope({ run_id: ' ' }))).toBe(true) }) - test('a run_id over the brain envelope bound (64) is refused', () => { + test('a run_id over the hub envelope bound (64) is refused', () => { expect(validateEventsBody(eventEnvelope({ run_id: 'a'.repeat(65) }))).toBe(false) }) // Documents a real asymmetry rather than asserting a failure for it: the - // brain's ITEM-level run_id has no maxLength, unlike its own envelope-level + // hub's ITEM-level run_id has no maxLength, unlike its own envelope-level // run_id (64) or this package's own ARM_RUN_ID_MAX (64) truncation. A - // brain schema looser than this package's sanitizer is fine per the - // D-contrat arbitration; only the reverse (brain stricter than what the arm + // hub schema looser than this package's sanitizer is fine per the + // D-contrat arbitration; only the reverse (hub stricter than what the arm // actually produces) is the bug class this suite exists to catch. - test('the brain schema is looser than this package at the item level: an over-length item run_id still validates there', () => { + test('the hub schema is looser than this package at the item level: an over-length item run_id still validates there', () => { const item = sanitizedValidEvent({ run_id: armRunId }) const overLength = { ...item, run_id: 'x'.repeat(200) } expect(validateEventsBody(eventEnvelope({ events: [overLength] }))).toBe(true) }) }) -describe('cross-repo: ticket_id, when present, must be a real uuid (brain-side format check)', () => { +describe('cross-repo: ticket_id, when present, must be a real uuid (hub-side format check)', () => { test('a real uuid ticket_id validates', () => { expect(validateEventsBody(eventEnvelope({ ticket_id: randomUUID() }))).toBe(true) }) // ArmTicket.id (sanitizeArmTicket, above) only requires a non-blank string // up to ARM_ID_MAX: it does NOT enforce a uuid shape. `ticket_id` here is - // exactly that id, echoed back by packages/cli's task-brain.ts - // (`ticketId = record.brain_ticket?.id`) when it reports events for a + // exactly that id, echoed back by packages/cli's task-hub.ts + // (`ticketId = record.hub_ticket?.id`) when it reports events for a // claimed ticket. Verified structurally today, since every ticket id the - // brain currently hands out IS a uuid, but nothing in this package's own + // hub currently hands out IS a uuid, but nothing in this package's own // sanitizer enforces that, so this is the same class of risk as the // run_id incident, one hop over: noted here rather than silently assumed // away. - test('a non-uuid ticket_id is refused by the brain schema', () => { + test('a non-uuid ticket_id is refused by the hub schema', () => { expect(validateEventsBody(eventEnvelope({ ticket_id: 'not-a-uuid' }))).toBe(false) }) }) diff --git a/packages/contract/src/brain.ts b/packages/contract/src/arm.ts similarity index 91% rename from packages/contract/src/brain.ts rename to packages/contract/src/arm.ts index 774240d..55b5d91 100644 --- a/packages/contract/src/brain.ts +++ b/packages/contract/src/arm.ts @@ -1,12 +1,12 @@ -// Brain wire contract: types and sanitizers for the tickets, transitions and -// events exchanged between the brain (the local SaaS that owns tickets) and +// Hub wire contract: types and sanitizers for the tickets, transitions and +// events exchanged between the hub (the local SaaS that owns tickets) and // the arm (this CLI, claiming and executing them). Same doctrine as the rest // of the contract: whitelist and truncate, never throw. // -// These shapes mirror the BRAIN's own wire format, not this package's usual +// These shapes mirror the HUB's own wire format, not this package's usual // style: several fields below are REQUIRED keys carrying an explicit `null` // rather than an optional key that is simply omitted (TaskRecord's own -// convention, tasks.ts). That mirrors nullable columns in the brain's own +// convention, tasks.ts). That mirrors nullable columns in the hub's own // store, which always sends the key. The sanitizers below preserve that // shape rather than converting it to "absent means unknown". @@ -19,10 +19,10 @@ import { import { NON_BLANK } from './ticket.js' /** - * A forge issue the brain resolved a ticket from, or attached to one. + * A forge issue the hub resolved a ticket from, or attached to one. * * `iid` is a STRING here, unlike `TaskIssueRef.iid` (tasks.ts, a decimal - * integer): the brain names issues however its own forge client returns + * integer): the hub names issues however its own forge client returns * them, and this contract must not assume every source it may grow to * support hands back a number. Gated as a pair, same doctrine as tasks.ts's * `sanitizeIssueRef`: a reference missing either half cannot be resolved by @@ -34,10 +34,10 @@ export type ArmIssueRef = { } /** - * A ticket proposal as the brain first raises it, before it is published. + * A ticket proposal as the hub first raises it, before it is published. * * `status` is a plain STRING, not `ArmTicketStatus`: the proposal lifecycle - * is the brain's own vocabulary, and this contract has no business rejecting + * is the hub's own vocabulary, and this contract has no business rejecting * a value it does not yet recognize there. Only `ArmTicket.status`, the * lifecycle the arm actually acts on, is a closed enum below. */ @@ -69,7 +69,7 @@ export type ArmTicketStatus = | 'already_implemented' /** - * A ticket the brain owns and the arm may claim and execute. + * A ticket the hub owns and the arm may claim and execute. * * `depends_on`, `executed_by`, `lease_expires_at`, `issue`, `branch`, * `mr_iid` and `mr_url` are all REQUIRED keys of type `string | null` (see @@ -93,14 +93,14 @@ export type ArmTicket = { updated_at: string } -/** What kind of fact an `ArmTransition` reports back to the brain about one ticket. */ +/** What kind of fact an `ArmTransition` reports back to the hub about one ticket. */ export type ArmTransitionType = 'mr_opened' | 'review_result' | 'merged' | 'failed' /** - * One fact the arm reports back to the brain about a ticket it executed. + * One fact the arm reports back to the hub about a ticket it executed. * * `idempotency_key` is MANDATORY, unlike every other field below: the - * brain's report endpoint uses it to tell a retried report from a second, + * hub's report endpoint uses it to tell a retried report from a second, * real transition apart. A transition this sanitizer cannot name one for is * not a degraded transition, it is unsafe to apply, so `sanitizeArmTransition` * refuses the whole record rather than keeping the rest of it. @@ -115,7 +115,7 @@ export type ArmTransition = { /** * Same literal union as `Verdict` (index.ts), restated rather than * imported: index.ts itself re-exports this module (`export * from - * './brain.js'`), so importing `Verdict` from index.ts here would cycle + * './arm.js'`), so importing `Verdict` from index.ts here would cycle * straight back through it. TypeScript compares union types structurally, * so this stays interchangeable with `Verdict` for every caller. */ @@ -126,7 +126,7 @@ export type ArmTransition = { cost_ticks?: number } -/** One line of the arm's own execution journal for a ticket run, reported to the brain. */ +/** One line of the arm's own execution journal for a ticket run, reported to the hub. */ export type ArmEvent = { run_id: string at: string @@ -135,7 +135,7 @@ export type ArmEvent = { payload?: TaskEventData } -/** What claiming a ticket (the brain's lease endpoint) hands back to the arm. */ +/** What claiming a ticket (the hub's lease endpoint) hands back to the arm. */ export type ArmClaimResult = { ticket: ArmTicket lease_expires_at: string @@ -145,7 +145,7 @@ export type ArmClaimResult = { export type ArmOrderAction = 'ship' | 'reply' | 'abandon' /** - * The decision itself, as the brain's heartbeat response hands it back to the + * The decision itself, as the hub's heartbeat response hands it back to the * arm: what to do, and the instruction to carry out when that is `'reply'`. * `instruction` and `issued_at` are REQUIRED keys, same convention as * `ArmTicket` above (this module's own doc comment) rather than tasks.ts's @@ -160,7 +160,7 @@ export type ArmOrder = { } /** - * What the brain's heartbeat route hands back to the arm (D19): the lease + * What the hub's heartbeat route hands back to the arm (D19): the lease * extension every heartbeat already grants, plus the order a human decided * from the dashboard while this ticket was waiting on one. `null` on every * ordinary tick nothing is waiting on. @@ -239,7 +239,7 @@ const nullableStr = (v: unknown, max: number): string | null => { * back to `fallback` when unusable. Bounded, unlike tasks.ts's own * `isoOrNow` (which never truncates): this module publishes JSON Schemas for * some of the shapes that use it, and the forward cross test in - * brain.test.ts requires every string this sanitizer can produce to already + * arm.test.ts requires every string this sanitizer can produce to already * satisfy the bound the matching schema declares. Built on `str`, not a * separate trim+slice, so it inherits the same trim-cut-trim guarantee: a * value that degrades to whitespace-only after truncation falls back to @@ -301,7 +301,7 @@ function sanitizeArmIssueRef(raw: unknown): ArmIssueRef | null { } /** - * Revalidates a ticket proposal read off the brain's wire. `id` is the one + * Revalidates a ticket proposal read off the hub's wire. `id` is the one * identity-bearing field, same role `id` plays for `sanitizeTaskRecord` * (tasks.ts): without it the object cannot be told apart from any other, so * the whole proposal is unusable. Every other field degrades independently. @@ -326,7 +326,7 @@ export function sanitizeArmTicketRequest(raw: unknown): ArmTicketRequest | null } /** - * Revalidates an `ArmTicket` read off the brain's wire. Two fields gate the + * Revalidates an `ArmTicket` read off the hub's wire. Two fields gate the * whole record: `id` (identity) and `status`. An unrecognized status is * never fabricated into a plausible one (contrast `TaskStatus`'s own * `'failed'` fallback, tasks.ts): a ticket this build cannot place in its @@ -369,7 +369,7 @@ export function sanitizeArmTicket(raw: unknown): ArmTicket | null { /** * Revalidates an `ArmTransition` before it is sent to, or read back from, - * the brain's report endpoint. Two fields gate the whole record: `type` + * the hub's report endpoint. Two fields gate the whole record: `type` * (same never-fabricate rule as `ArmTicket.status`) and `idempotency_key`, * mandatory per this type's own doc comment. Every other field is optional * and degrades to absence, never to an invented placeholder. @@ -444,7 +444,7 @@ function sanitizeArmEventPayload(raw: unknown): TaskEventData { } /** - * Revalidates an `ArmEvent` before it is reported to the brain. Gated on + * Revalidates an `ArmEvent` before it is reported to the hub. Gated on * `run_id`: an event this reader cannot place under a run is unusable, same * role `TaskEvent.seq` plays in `sanitizeTaskEvent` (tasks.ts). */ @@ -468,7 +468,7 @@ export function sanitizeArmEvent(raw: unknown): ArmEvent | null { } /** - * Revalidates the brain's claim/lease response. Gated on `ticket`: a claim + * Revalidates the hub's claim/lease response. Gated on `ticket`: a claim * whose ticket cannot itself be trusted (see `sanitizeArmTicket`) grants * nothing usable, so the whole result is refused rather than handing back a * lease over an unreadable ticket. The lease falls back to the ticket's own @@ -495,7 +495,7 @@ export function sanitizeArmClaimResult(raw: unknown): ArmClaimResult | null { } /** - * Revalidates an `ArmOrder` read off the brain's heartbeat response. Gated on + * Revalidates an `ArmOrder` read off the hub's heartbeat response. Gated on * `action`: same never-fabricate rule as `ArmTicket.status` and * `ArmTransition.type`, an order outside this closed set is not safe to * dispatch, so the whole order is refused rather than guessed at. @@ -522,12 +522,12 @@ export function sanitizeArmOrder(raw: unknown): ArmOrder | null { } /** - * Revalidates the brain's heartbeat response. Gated on `lease_expires_at`, + * Revalidates the hub's heartbeat response. Gated on `lease_expires_at`, * same reasoning as `sanitizeArmClaimResult`: a heartbeat that cannot say * when the lease it just renewed expires is not a usable response, and unlike * `created_at`/`updated_at` elsewhere in this module, a lease deadline must * never fall back to "now": that would either claim an already-expired lease - * or fabricate an extension the brain never granted. A malformed `order` + * or fabricate an extension the hub never granted. A malformed `order` * degrades to `null` rather than sinking the whole response, the same * never-fabricate rule `sanitizeArmOrder` itself applies. */ @@ -551,7 +551,7 @@ export function sanitizeArmHeartbeatResponse(raw: unknown): ArmHeartbeatResponse * `reviewRecordSchema` (index.ts), `ticketBodySchema` and `recapRecordSchema` * (this package): every `sanitizeArmTicket` output validates here (forward), * and the schema refuses every shape the sanitizer refuses (backward, tested - * in brain.test.ts) so the two cannot silently drift apart. + * in arm.test.ts) so the two cannot silently drift apart. */ export const armTicketSchema = { $schema: 'https://json-schema.org/draft/2020-12/schema', diff --git a/packages/contract/src/index.ts b/packages/contract/src/index.ts index d927ffa..11a3f62 100644 --- a/packages/contract/src/index.ts +++ b/packages/contract/src/index.ts @@ -14,9 +14,10 @@ import { // All agent input passes through here: whitelist and truncate, never throw. -export * from './brain.js' +export * from './arm.js' export * from './reasons.js' export * from './recap.js' +export * from './runner.js' export * from './tasks.js' export * from './ticket.js' diff --git a/packages/contract/src/runner.test.ts b/packages/contract/src/runner.test.ts new file mode 100644 index 0000000..695aab3 --- /dev/null +++ b/packages/contract/src/runner.test.ts @@ -0,0 +1,494 @@ +import Ajv from 'ajv' +import { describe, expect, test } from 'bun:test' +import runnerRegisterBodySchema from '../fixtures/hub-schemas/runner-register.schema.json' +import runnerSecretBodySchema from '../fixtures/hub-schemas/runner-secret.schema.json' +import { + RUNNER_FINGERPRINT_LEN, + RUNNER_NAME_MAX, + RUNNER_PUBLIC_KEY_B64_LEN, + RUNNER_TIMESTAMP_MAX, + runnerListEntrySchema, + sanitizeRunnerListEntry, + sanitizeSealedSecretBlob, + SEALED_BLOB_MAX_B64, + sealedSecretBlobSchema, + type RunnerListEntry, + type SealedSecretBlob, +} from './runner.js' + +// --- Fixtures ---------------------------------------------------------------- +// Both fingerprints and public keys below are real sha256/base64 output +// (verified against the exact patterns runner.ts compiles), not hand-typed +// hex or base64, so a miscounted literal cannot slip a fixture past its own +// gate. + +const FINGERPRINT_A = '4573d6f8f348168f1dd347accfd0d0268cffa812a83362f06e687c07513c708a'.slice( + 0, + RUNNER_FINGERPRINT_LEN, +) +const FINGERPRINT_B = '28198f53ee68fc724b51fea98097a78112a0873d7bc91c4cffe23659d5f8a09d'.slice( + 0, + RUNNER_FINGERPRINT_LEN, +) +const PUBLIC_KEY_A = 'lrO9snlYjhUxg3Ca0xs18uU8mu8Qp6/WLJKdB2JyNEc=' +const PUBLIC_KEY_B = '/5OVO7JjZ8JYA8W8FsbyuvU0mqP+JrPnEZm7aADMQfY=' + +const validEntry: RunnerListEntry = { + name: 'laptop-runner', + fingerprint: FINGERPRINT_A, + public_key: PUBLIC_KEY_A, + last_seen_at: '2026-08-14T10:00:00.000Z', + has_pending_secret: false, +} + +const validBlob: SealedSecretBlob = { + ciphertext: 'c2VhbGVkLXNlY3JldC1wYXlsb2Fk', + pushed_at: '2026-08-14T10:00:00.000Z', +} + +test('fixtures are exactly the length runner.ts requires', () => { + expect(FINGERPRINT_A).toHaveLength(RUNNER_FINGERPRINT_LEN) + expect(FINGERPRINT_B).toHaveLength(RUNNER_FINGERPRINT_LEN) + expect(PUBLIC_KEY_A).toHaveLength(RUNNER_PUBLIC_KEY_B64_LEN) + expect(PUBLIC_KEY_B).toHaveLength(RUNNER_PUBLIC_KEY_B64_LEN) +}) + +test('published bounds are locked to their literal values', () => { + expect(RUNNER_NAME_MAX).toBe(200) + expect(RUNNER_FINGERPRINT_LEN).toBe(64) + expect(RUNNER_PUBLIC_KEY_B64_LEN).toBe(44) + expect(SEALED_BLOB_MAX_B64).toBe(8192) + expect(RUNNER_TIMESTAMP_MAX).toBe(40) +}) + +// --- sanitizeRunnerListEntry --------------------------------------------------- + +describe('sanitizeRunnerListEntry', () => { + test('a valid entry round-trips unchanged', () => { + expect(sanitizeRunnerListEntry(structuredClone(validEntry))).toEqual(validEntry) + }) + + test('non-object input: null', () => { + expect(sanitizeRunnerListEntry(null)).toBeNull() + expect(sanitizeRunnerListEntry(undefined)).toBeNull() + expect(sanitizeRunnerListEntry('junk')).toBeNull() + expect(sanitizeRunnerListEntry(42)).toBeNull() + expect(sanitizeRunnerListEntry([])).toBeNull() + }) + + describe('fingerprint gates the whole record', () => { + test('a non-string fingerprint drops the WHOLE entry', () => { + for (const fingerprint of [undefined, null, 42, {}, []]) { + expect(sanitizeRunnerListEntry({ ...validEntry, fingerprint })).toBeNull() + } + }) + + test('63 or 65 hex characters is refused, never truncated or padded', () => { + expect(sanitizeRunnerListEntry({ ...validEntry, fingerprint: 'a'.repeat(63) })).toBeNull() + expect(sanitizeRunnerListEntry({ ...validEntry, fingerprint: 'a'.repeat(65) })).toBeNull() + }) + + test('uppercase hex is refused, never case-folded', () => { + expect( + sanitizeRunnerListEntry({ ...validEntry, fingerprint: FINGERPRINT_A.toUpperCase() }), + ).toBeNull() + }) + + test('non-hex characters at the exact length are refused', () => { + const nonHex = `g${FINGERPRINT_A.slice(1)}` + expect(sanitizeRunnerListEntry({ ...validEntry, fingerprint: nonHex })).toBeNull() + }) + + test('a valid fingerprint is kept byte for byte', () => { + expect( + sanitizeRunnerListEntry({ ...validEntry, fingerprint: FINGERPRINT_B })?.fingerprint, + ).toBe(FINGERPRINT_B) + }) + }) + + describe('public_key gates the whole record', () => { + test('a non-string public_key drops the WHOLE entry', () => { + for (const public_key of [undefined, null, 42, {}, []]) { + expect(sanitizeRunnerListEntry({ ...validEntry, public_key })).toBeNull() + } + }) + + test('43 or 45 characters is refused, never truncated or padded', () => { + expect( + sanitizeRunnerListEntry({ ...validEntry, public_key: PUBLIC_KEY_A.slice(0, 43) }), + ).toBeNull() + expect(sanitizeRunnerListEntry({ ...validEntry, public_key: `${PUBLIC_KEY_A}A` })).toBeNull() + }) + + test('the URL-safe base64 alphabet (- or _) is refused: standard base64 only', () => { + const urlSafe = `${PUBLIC_KEY_A.slice(0, 10)}-${PUBLIC_KEY_A.slice(11)}` + expect(sanitizeRunnerListEntry({ ...validEntry, public_key: urlSafe })).toBeNull() + }) + + test('a missing padding character is refused, never re-padded', () => { + const unpadded = PUBLIC_KEY_A.slice(0, 43) + expect(sanitizeRunnerListEntry({ ...validEntry, public_key: unpadded })).toBeNull() + }) + + test('a valid public_key is kept byte for byte', () => { + expect(sanitizeRunnerListEntry({ ...validEntry, public_key: PUBLIC_KEY_B })?.public_key).toBe( + PUBLIC_KEY_B, + ) + }) + }) + + test('name is truncated, never rejected for length', () => { + const r = sanitizeRunnerListEntry({ ...validEntry, name: 'n'.repeat(RUNNER_NAME_MAX + 50) }) + expect(r?.name.length).toBe(RUNNER_NAME_MAX) + }) + + test('a blank or non-string name falls back to a fixed placeholder, never the empty string', () => { + for (const name of [undefined, null, '', ' ', 42, {}, []]) { + const r = sanitizeRunnerListEntry({ ...validEntry, name }) + expect(r?.name).toBe('unnamed runner') + } + }) + + test('truncation never leaves a trailing space on name, even when the cut lands on an internal run of whitespace', () => { + const nameWithInternalSpace = `${'n'.repeat(RUNNER_NAME_MAX - 1)} ${'x'.repeat(50)}` + const r = sanitizeRunnerListEntry({ ...validEntry, name: nameWithInternalSpace }) + expect(r?.name).toBe(r?.name.trim()) + }) + + test('last_seen_at: absent, blank or non-string becomes null', () => { + for (const last_seen_at of [undefined, null, '', ' ', 42, {}, []]) { + expect(sanitizeRunnerListEntry({ ...validEntry, last_seen_at })?.last_seen_at).toBeNull() + } + }) + + test('last_seen_at is truncated, never rejected for length', () => { + const long = '2'.repeat(RUNNER_TIMESTAMP_MAX + 50) + const r = sanitizeRunnerListEntry({ ...validEntry, last_seen_at: long }) + expect(r?.last_seen_at?.length).toBe(RUNNER_TIMESTAMP_MAX) + }) + + test('has_pending_secret is read strictly: only the literal boolean true counts', () => { + expect( + sanitizeRunnerListEntry({ ...validEntry, has_pending_secret: true })?.has_pending_secret, + ).toBe(true) + for (const has_pending_secret of [false, undefined, null, 1, 'true', {}, []]) { + expect( + sanitizeRunnerListEntry({ ...validEntry, has_pending_secret })?.has_pending_secret, + ).toBe(false) + } + }) + + test('hostile input, once sanitized, still validates against the published schema', () => { + const hostile = sanitizeRunnerListEntry({ + fingerprint: FINGERPRINT_A, + public_key: PUBLIC_KEY_A, + name: 42, + last_seen_at: [], + has_pending_secret: 'yes', + }) + expect(hostile).not.toBeNull() + expect(listEntrySchemaErrors(hostile)).toEqual([]) + }) +}) + +// --- sanitizeSealedSecretBlob --------------------------------------------------- + +describe('sanitizeSealedSecretBlob', () => { + test('a valid blob round-trips unchanged', () => { + expect(sanitizeSealedSecretBlob(structuredClone(validBlob))).toEqual(validBlob) + }) + + test('non-object input: null', () => { + expect(sanitizeSealedSecretBlob(null)).toBeNull() + expect(sanitizeSealedSecretBlob(undefined)).toBeNull() + expect(sanitizeSealedSecretBlob('junk')).toBeNull() + expect(sanitizeSealedSecretBlob(42)).toBeNull() + expect(sanitizeSealedSecretBlob([])).toBeNull() + }) + + describe('ciphertext gates the whole record', () => { + test('a non-string ciphertext drops the WHOLE blob', () => { + for (const ciphertext of [undefined, null, 42, {}, []]) { + expect(sanitizeSealedSecretBlob({ ...validBlob, ciphertext })).toBeNull() + } + }) + + test('an empty ciphertext drops the WHOLE blob', () => { + expect(sanitizeSealedSecretBlob({ ...validBlob, ciphertext: '' })).toBeNull() + }) + + test('a ciphertext exactly at SEALED_BLOB_MAX_B64 is kept unchanged', () => { + const atMax = 'a'.repeat(SEALED_BLOB_MAX_B64) + expect(sanitizeSealedSecretBlob({ ...validBlob, ciphertext: atMax })?.ciphertext).toBe(atMax) + }) + + test('a ciphertext one character over SEALED_BLOB_MAX_B64 drops the WHOLE blob, never truncated', () => { + const overMax = 'a'.repeat(SEALED_BLOB_MAX_B64 + 1) + expect(sanitizeSealedSecretBlob({ ...validBlob, ciphertext: overMax })).toBeNull() + }) + }) + + test('missing pushed_at falls back to a generated stamp', () => { + const r = sanitizeSealedSecretBlob({ ...validBlob, pushed_at: undefined }) + expect(typeof r?.pushed_at).toBe('string') + expect(r?.pushed_at.length).toBeGreaterThan(0) + }) + + test('a blank pushed_at falls back to a generated stamp', () => { + const r = sanitizeSealedSecretBlob({ ...validBlob, pushed_at: ' ' }) + expect(typeof r?.pushed_at).toBe('string') + expect(r?.pushed_at.length).toBeGreaterThan(0) + }) + + test('pushed_at is truncated, never rejected for length', () => { + const long = '2'.repeat(RUNNER_TIMESTAMP_MAX + 50) + const r = sanitizeSealedSecretBlob({ ...validBlob, pushed_at: long }) + expect(r?.pushed_at.length).toBe(RUNNER_TIMESTAMP_MAX) + }) +}) + +// --- The published schemas ---------------------------------------------------- + +describe('runnerListEntrySchema / sealedSecretBlobSchema', () => { + test('both declare a draft 2020-12 schema with their own id', () => { + expect(runnerListEntrySchema.$schema).toBe('https://json-schema.org/draft/2020-12/schema') + expect(runnerListEntrySchema.$id).toBe('https://codesema.com/schemas/runner-list-entry.json') + expect(sealedSecretBlobSchema.$schema).toBe('https://json-schema.org/draft/2020-12/schema') + expect(sealedSecretBlobSchema.$id).toBe('https://codesema.com/schemas/sealed-secret-blob.json') + }) + + test('every required key exists in properties, on both schemas', () => { + for (const schema of [runnerListEntrySchema, sealedSecretBlobSchema]) { + const props = new Set(Object.keys(schema.properties)) + for (const key of schema.required) { + expect(props.has(key)).toBe(true) + } + } + }) +}) + +// --- Cross tests: sanitizer output validates against the published schema, and +// the schema is not looser than what the sanitizer actually accepts. Deliberately +// local and tiny, like arm.test.ts's own validator: this proves the SCHEMA +// against the SANITIZER, not a library's leniency. Neither published schema here +// uses $ref/$defs or arrays, so unlike arm.test.ts's own copy of this validator, +// there is no deref step and no array/number branch to carry. + +type Schema = Record<string, unknown> + +function typeMatches(node: unknown, type: string): boolean { + switch (type) { + case 'null': + return node === null + case 'string': + return typeof node === 'string' + case 'boolean': + return typeof node === 'boolean' + case 'object': + return !!node && typeof node === 'object' && !Array.isArray(node) + default: + return false + } +} + +function validateString(node: string, s: Schema, path: string): string[] { + const errors: string[] = [] + const length = [...node].length + if (typeof s.maxLength === 'number' && length > s.maxLength) { + errors.push(`${path}: maxLength`) + } + if (typeof s.minLength === 'number' && length < s.minLength) { + errors.push(`${path}: minLength`) + } + if (typeof s.pattern === 'string' && !new RegExp(s.pattern, 'u').test(node)) { + errors.push(`${path}: pattern`) + } + return errors +} + +function validateObject(node: object, s: Schema, path: string): string[] { + const errors: string[] = [] + const record = node as Record<string, unknown> + const properties = (s.properties ?? {}) as Record<string, Schema> + for (const key of (s.required ?? []) as string[]) { + if (!Object.hasOwn(record, key)) { + errors.push(`${path}.${key}: required`) + } + } + for (const [key, value] of Object.entries(record)) { + const child = Object.hasOwn(properties, key) ? properties[key] : undefined + if (!child) { + if (s.additionalProperties === false) { + errors.push(`${path}.${key}: additionalProperties`) + } + continue + } + errors.push(...validate(value, child, `${path}.${key}`)) + } + return errors +} + +function validate(node: unknown, schema: Schema, path = '$'): string[] { + const types = + typeof schema.type === 'string' + ? [schema.type] + : Array.isArray(schema.type) + ? (schema.type as string[]) + : [] + const hasAssertion = + 'const' in schema || 'enum' in schema || types.length > 0 || Array.isArray(schema.anyOf) + if (!hasAssertion) { + // A schema node that asserts NOTHING accepts every value that reaches it. + // Fail loudly here instead of quietly proving nothing. + throw new Error(`runner schema validator: '${path}' asserts nothing`) + } + const errors: string[] = [] + if ('const' in schema && node !== schema.const) { + errors.push(`${path}: const`) + } + if (Array.isArray(schema.enum) && !schema.enum.includes(node)) { + errors.push(`${path}: enum`) + } + if (Array.isArray(schema.anyOf)) { + const branches = schema.anyOf as Schema[] + if (!branches.some((branch) => validate(node, branch, path).length === 0)) { + errors.push(`${path}: anyOf`) + } + } + if (types.length === 0) { + return errors + } + if (!types.some((type) => typeMatches(node, type))) { + errors.push(`${path}: type`) + return errors + } + if (typeof node === 'string') { + errors.push(...validateString(node, schema, path)) + } else if (node && typeof node === 'object') { + errors.push(...validateObject(node, schema, path)) + } + return errors +} + +const listEntrySchemaErrors = (value: unknown): string[] => + validate(value, runnerListEntrySchema as unknown as Schema) + +const blobSchemaErrors = (value: unknown): string[] => + validate(value, sealedSecretBlobSchema as unknown as Schema) + +describe('cross test: sanitizeRunnerListEntry output validates against runnerListEntrySchema', () => { + test('the full nominal entry validates', () => { + expect(listEntrySchemaErrors(sanitizeRunnerListEntry(structuredClone(validEntry)))).toEqual([]) + }) + + test('an entry with last_seen_at null validates', () => { + const withNull = { ...validEntry, last_seen_at: null } + expect(listEntrySchemaErrors(sanitizeRunnerListEntry(withNull))).toEqual([]) + }) + + test('an entry with a fallback name validates', () => { + expect(listEntrySchemaErrors(sanitizeRunnerListEntry({ ...validEntry, name: '' }))).toEqual([]) + }) +}) + +describe('reverse cross test: runnerListEntrySchema is not looser than sanitizeRunnerListEntry accepts', () => { + const BASE = { + name: 'runner', + fingerprint: FINGERPRINT_A, + public_key: PUBLIC_KEY_A, + last_seen_at: null, + has_pending_secret: false, + } + + test('an empty fingerprint is schema-invalid: sanitizeRunnerListEntry refuses the WHOLE record for it', () => { + expect(listEntrySchemaErrors({ ...BASE, fingerprint: '' })).not.toEqual([]) + }) + + test('an uppercase fingerprint is schema-invalid: sanitizeRunnerListEntry never case-folds one', () => { + expect( + listEntrySchemaErrors({ ...BASE, fingerprint: FINGERPRINT_A.toUpperCase() }), + ).not.toEqual([]) + }) + + test('an empty name is schema-invalid: sanitizeRunnerListEntry only ever emits a non-blank name', () => { + expect(listEntrySchemaErrors({ ...BASE, name: '' })).not.toEqual([]) + }) + + test('a missing key is schema-invalid: every key of RunnerListEntry is always present', () => { + const { has_pending_secret: _drop, ...missing } = BASE + expect(listEntrySchemaErrors(missing)).not.toEqual([]) + }) + + test('an extra unknown key is schema-invalid: additionalProperties is false', () => { + expect(listEntrySchemaErrors({ ...BASE, extra: 'nope' })).not.toEqual([]) + }) +}) + +describe('cross test: sanitizeSealedSecretBlob output validates against sealedSecretBlobSchema', () => { + test('the nominal blob validates', () => { + expect(blobSchemaErrors(sanitizeSealedSecretBlob(structuredClone(validBlob)))).toEqual([]) + }) + + test('a blob with ciphertext at the exact bound validates', () => { + const atMax = sanitizeSealedSecretBlob({ + ...validBlob, + ciphertext: 'a'.repeat(SEALED_BLOB_MAX_B64), + }) + expect(blobSchemaErrors(atMax)).toEqual([]) + }) +}) + +describe('reverse cross test: sealedSecretBlobSchema is not looser than sanitizeSealedSecretBlob accepts', () => { + const BASE = { + ciphertext: 'c2VhbGVk', + pushed_at: '2026-08-14T10:00:00.000Z', + } + + test('an empty ciphertext is schema-invalid: sanitizeSealedSecretBlob refuses the WHOLE record for it', () => { + expect(blobSchemaErrors({ ...BASE, ciphertext: '' })).not.toEqual([]) + }) + + test('an oversized ciphertext is schema-invalid: sanitizeSealedSecretBlob never truncates it', () => { + expect( + blobSchemaErrors({ ...BASE, ciphertext: 'a'.repeat(SEALED_BLOB_MAX_B64 + 1) }), + ).not.toEqual([]) + }) + + test('a missing key is schema-invalid: every key of SealedSecretBlob is always present', () => { + const { pushed_at: _drop, ...missing } = BASE + expect(blobSchemaErrors(missing)).not.toEqual([]) + }) + + test('an extra unknown key is schema-invalid: additionalProperties is false', () => { + expect(blobSchemaErrors({ ...BASE, extra: 'nope' })).not.toEqual([]) + }) +}) + +describe('cross-repo: runner request bodies match the hub schemas byte for byte on their bounds', () => { + const ajv = new Ajv({ allErrors: true }) + const validRegister = ajv.compile(runnerRegisterBodySchema) + const validSecret = ajv.compile(runnerSecretBodySchema) + + test("a register body at this package's exact bounds passes the hub schema", () => { + const body = { + public_key: 'A'.repeat(RUNNER_PUBLIC_KEY_B64_LEN - 1) + '=', + name: 'n'.repeat(RUNNER_NAME_MAX), + } + expect(validRegister(body)).toBe(true) + }) + + test('the hub schema pins public_key to the same exact length as this package', () => { + expect( + validRegister({ public_key: 'A'.repeat(RUNNER_PUBLIC_KEY_B64_LEN + 1), name: 'x' }), + ).toBe(false) + expect( + validRegister({ public_key: 'A'.repeat(RUNNER_PUBLIC_KEY_B64_LEN - 1), name: 'x' }), + ).toBe(false) + }) + + test("a sealed blob at this package's ceiling passes, one byte over fails", () => { + expect(validSecret({ ciphertext: 'c'.repeat(SEALED_BLOB_MAX_B64) })).toBe(true) + expect(validSecret({ ciphertext: 'c'.repeat(SEALED_BLOB_MAX_B64 + 1) })).toBe(false) + expect(validSecret({ ciphertext: '' })).toBe(false) + }) +}) diff --git a/packages/contract/src/runner.ts b/packages/contract/src/runner.ts new file mode 100644 index 0000000..0fadaad --- /dev/null +++ b/packages/contract/src/runner.ts @@ -0,0 +1,208 @@ +// Hub wire contract: types and sanitizers for the runners a codesema arm +// registers with the hub (an X25519 keypair identity, fingerprinted as a +// sha256 hex digest) and for the mailbox of secrets the hub seals to a +// runner's public key. Same doctrine as arm.ts: whitelist and truncate free +// text, never throw. `fingerprint` and `public_key` are cryptographic +// identity, not free text, so unlike every truncated field elsewhere in +// this package they are refused whole rather than cut or case-folded when +// they do not match their expected shape exactly. + +import { NON_BLANK } from './ticket.js' + +/** + * A runner the hub knows about, as returned by its runner-list endpoint. + * `fingerprint` (the sha256 hex digest of `public_key`) is this type's + * identity: unlike `name`, it is never truncated or fabricated, only + * matched exactly or refused (see `sanitizeRunnerListEntry`). + */ +export type RunnerListEntry = { + name: string + fingerprint: string + public_key: string + last_seen_at: string | null + has_pending_secret: boolean +} + +/** + * A secret blob the hub has sealed (encrypted) to one runner's public key, + * waiting to be picked up. `ciphertext` is opaque base64 to this package: it + * is validated for shape (non-empty, bounded) but never decoded or read. + */ +export type SealedSecretBlob = { + ciphertext: string + pushed_at: string +} + +export const RUNNER_NAME_MAX = 200 +/** A sha256 hex digest of a runner's public key: exactly this many lowercase hex characters. */ +export const RUNNER_FINGERPRINT_LEN = 64 +/** Standard base64 of exactly 32 raw bytes (an X25519 public key): always this many characters. */ +export const RUNNER_PUBLIC_KEY_B64_LEN = 44 +export const SEALED_BLOB_MAX_B64 = 8192 +/** Bound for an ISO-8601 instant read back from the wire: same figure as arm.ts's own ARM_TIMESTAMP_MAX. */ +export const RUNNER_TIMESTAMP_MAX = 40 + +/** + * `name`'s fallback when the hub sends a blank one: a fixed placeholder, + * never a value borrowed from elsewhere on the same record. Reusing + * `fingerprint` as a display name here would still be correct today, but it + * would tie a field that degrades independently to one that gates the whole + * record, the two are kept unrelated on purpose. + */ +const RUNNER_NAME_FALLBACK = 'unnamed runner' + +/** Whitelisted, not merely bounded: hex lowercase, exactly RUNNER_FINGERPRINT_LEN characters. */ +const RUNNER_FINGERPRINT_PATTERN = `^[0-9a-f]{${RUNNER_FINGERPRINT_LEN}}$` +const RUNNER_FINGERPRINT_RE = new RegExp(RUNNER_FINGERPRINT_PATTERN) + +/** + * Standard base64 of exactly 32 raw bytes: 32 does not fall on a 3-byte + * boundary, so the final 4-character group carries one literal `=` pad, + * leaving RUNNER_PUBLIC_KEY_B64_LEN - 1 real alphabet characters ahead of it. + */ +const RUNNER_PUBLIC_KEY_PATTERN = `^[A-Za-z0-9+/]{${RUNNER_PUBLIC_KEY_B64_LEN - 1}}=$` +const RUNNER_PUBLIC_KEY_RE = new RegExp(RUNNER_PUBLIC_KEY_PATTERN) + +/** + * Trim, cut, trim again: same recipe as arm.ts's own `str`, duplicated + * rather than imported (that helper is private to arm.ts). Load-bearing for + * the same reason there: a value sliced at `max` can still gain a trailing + * space from an internal run of whitespace landing right at the cut. + */ +const str = (v: unknown, max: number): string => + typeof v === 'string' ? v.trim().slice(0, max).trim() : '' + +const nullableStr = (v: unknown, max: number): string | null => { + const s = str(v, max) + return s ? s : null +} + +/** `pushed_at`'s doctrine: an ISO instant, bounded, falling back to now when unusable, same idiom as arm.ts's own `isoOrNow`. */ +const isoOrNow = (v: unknown, max: number = RUNNER_TIMESTAMP_MAX): string => { + const s = str(v, max) + return s ? s : new Date().toISOString() +} + +/** + * A runner's fingerprint is cryptographic identity, not free text: refused + * whole on any mismatch (wrong length, wrong case, wrong alphabet) rather + * than truncated or normalized, same never-fabricate rule `sanitizeArmTicket` + * applies to `status` (arm.ts). Half a fingerprint, or one folded to + * lowercase behind the caller's back, is not safe to treat as an identity. + */ +function sanitizeRunnerFingerprint(raw: unknown): string | null { + return typeof raw === 'string' && RUNNER_FINGERPRINT_RE.test(raw) ? raw : null +} + +/** Same never-fabricate rule as `sanitizeRunnerFingerprint`, applied to the public key half of the pair. */ +function sanitizeRunnerPublicKey(raw: unknown): string | null { + return typeof raw === 'string' && RUNNER_PUBLIC_KEY_RE.test(raw) ? raw : null +} + +/** + * Ciphertext is opaque bytes, not free text: an oversized or empty blob is + * refused whole rather than truncated. Truncating ciphertext does not + * produce a smaller valid secret, it produces garbage that merely looks + * intact, same reasoning as `sanitizeArmSha` (arm.ts) refusing a partial + * hash rather than keeping it. + */ +function sanitizeCiphertext(raw: unknown): string | null { + return typeof raw === 'string' && raw.length > 0 && raw.length <= SEALED_BLOB_MAX_B64 ? raw : null +} + +/** + * Revalidates a runner entry read off the hub's list. Two fields gate the + * whole record, `fingerprint` and `public_key`: together they are this + * type's identity, and neither is safe to half-trust (see + * `sanitizeRunnerFingerprint` and `sanitizeRunnerPublicKey`). `name` + * degrades independently: a blank one falls back to a fixed placeholder + * rather than the empty string, same str-then-fallback idiom as arm.ts's + * own `isoOr`. `has_pending_secret` is read strictly: only the literal + * boolean `true` counts, any other value (including a truthy non-boolean) + * degrades to `false`. + */ +export function sanitizeRunnerListEntry(raw: unknown): RunnerListEntry | null { + if (!raw || typeof raw !== 'object') { + return null + } + const r = raw as Record<string, unknown> + const fingerprint = sanitizeRunnerFingerprint(r.fingerprint) + const public_key = sanitizeRunnerPublicKey(r.public_key) + if (!fingerprint || !public_key) { + return null + } + return { + name: str(r.name, RUNNER_NAME_MAX) || RUNNER_NAME_FALLBACK, + fingerprint, + public_key, + last_seen_at: nullableStr(r.last_seen_at, RUNNER_TIMESTAMP_MAX), + has_pending_secret: r.has_pending_secret === true, + } +} + +/** + * Revalidates a sealed secret blob read off the hub's mailbox. Gated on + * `ciphertext` (see `sanitizeCiphertext`): a blob whose payload is unusable + * is not a degraded blob, it is not a blob. `pushed_at` follows arm.ts's + * `isoOrNow` doctrine instead, an unusable stamp falls back to now rather + * than sinking the whole record, the same treatment `ArmTransition.at` gets. + */ +export function sanitizeSealedSecretBlob(raw: unknown): SealedSecretBlob | null { + if (!raw || typeof raw !== 'object') { + return null + } + const r = raw as Record<string, unknown> + const ciphertext = sanitizeCiphertext(r.ciphertext) + if (!ciphertext) { + return null + } + return { + ciphertext, + pushed_at: isoOrNow(r.pushed_at), + } +} + +/** + * JSON Schema (draft 2020-12) for a `RunnerListEntry`, same pattern as + * `armTicketSchema` (arm.ts): every `sanitizeRunnerListEntry` output + * validates here (forward), and the schema refuses every shape the + * sanitizer refuses (backward, tested in runner.test.ts), so the two cannot + * silently drift apart. + */ +export const runnerListEntrySchema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + $id: 'https://codesema.com/schemas/runner-list-entry.json', + title: 'Codesema runner list entry', + type: 'object', + additionalProperties: false, + required: ['name', 'fingerprint', 'public_key', 'last_seen_at', 'has_pending_secret'], + properties: { + name: { type: 'string', maxLength: RUNNER_NAME_MAX, pattern: NON_BLANK }, + fingerprint: { type: 'string', pattern: RUNNER_FINGERPRINT_PATTERN }, + public_key: { type: 'string', pattern: RUNNER_PUBLIC_KEY_PATTERN }, + last_seen_at: { + anyOf: [ + { type: 'null' }, + { type: 'string', maxLength: RUNNER_TIMESTAMP_MAX, pattern: NON_BLANK }, + ], + }, + has_pending_secret: { type: 'boolean' }, + }, +} as const + +/** + * JSON Schema (draft 2020-12) for a `SealedSecretBlob`, same pattern and + * same forward/backward guarantee as `runnerListEntrySchema` above. + */ +export const sealedSecretBlobSchema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + $id: 'https://codesema.com/schemas/sealed-secret-blob.json', + title: 'Codesema sealed secret blob', + type: 'object', + additionalProperties: false, + required: ['ciphertext', 'pushed_at'], + properties: { + ciphertext: { type: 'string', minLength: 1, maxLength: SEALED_BLOB_MAX_B64 }, + pushed_at: { type: 'string', maxLength: RUNNER_TIMESTAMP_MAX, pattern: NON_BLANK }, + }, +} as const diff --git a/packages/contract/src/tasks.test.ts b/packages/contract/src/tasks.test.ts index afa7600..3cf4f15 100644 --- a/packages/contract/src/tasks.test.ts +++ b/packages/contract/src/tasks.test.ts @@ -8,13 +8,13 @@ import { sanitizeTaskEvent, sanitizeTaskRecord, TASK_AGENT_MAX, - TASK_BRAIN_TICKET_ID_MAX, TASK_CHECK_COMMAND_MAX, TASK_CHECK_TAIL_MAX, TASK_CHECKS_ERROR_MAX, TASK_CHECKS_LIST_MAX, TASK_EVENT_DATA_KEYS_MAX, TASK_EVENT_DATA_STRING_MAX, + TASK_HUB_TICKET_ID_MAX, TASK_ISSUE_PROJECT_MAX, TASK_ISSUE_URL_MAX, TASK_STATUS_VALUES, @@ -45,10 +45,10 @@ const validIssue: TaskIssueRef = { url: 'https://github.com/getCodesema/codesema-cli/issues/42', } -const validBrainTicket = { +const validHubTicket = { id: 'tick-1', title: 'Add rate limiting', - url: 'https://brain.local/tickets/tick-1', + url: 'https://hub.local/tickets/tick-1', } const CRITERION_TEXT = 'WHEN x THE SYSTEM SHALL y' @@ -921,73 +921,97 @@ describe('sanitizeTaskRecord — issue binding (T2.4)', () => { }) }) -describe('sanitizeTaskRecord — brain ticket binding', () => { - test('a record with brain_ticket round-trips unchanged', () => { - const withTicket = { ...validRecord, brain_ticket: validBrainTicket } +describe('sanitizeTaskRecord — hub ticket binding', () => { + test('a record with hub_ticket round-trips unchanged', () => { + const withTicket = { ...validRecord, hub_ticket: validHubTicket } expect(sanitizeTaskRecord(structuredClone(withTicket))).toEqual(withTicket) }) - test('brain_ticket without a url round-trips unchanged (url is optional)', () => { - const { url: _drop, ...withoutUrl } = validBrainTicket - const withTicket = { ...validRecord, brain_ticket: withoutUrl } + test('hub_ticket without a url round-trips unchanged (url is optional)', () => { + const { url: _drop, ...withoutUrl } = validHubTicket + const withTicket = { ...validRecord, hub_ticket: withoutUrl } expect(sanitizeTaskRecord(structuredClone(withTicket))).toEqual(withTicket) }) - test('a record without brain_ticket carries no key, same as any record predating this field', () => { + test('a record without hub_ticket carries no key, same as any record predating this field', () => { const r = sanitizeTaskRecord(structuredClone(validRecord)) expect(r).toEqual(validRecord) - expect(r && 'brain_ticket' in r).toBe(false) + expect(r && 'hub_ticket' in r).toBe(false) }) - test('brain_ticket: a non-object drops the whole field rather than inventing one', () => { + test('hub_ticket: a non-object drops the whole field rather than inventing one', () => { for (const junk of [null, 'tick-1', 42, [], true]) { - const r = sanitizeTaskRecord({ ...validRecord, brain_ticket: junk }) - expect(r && 'brain_ticket' in r).toBe(false) + const r = sanitizeTaskRecord({ ...validRecord, hub_ticket: junk }) + expect(r && 'hub_ticket' in r).toBe(false) } }) - test('brain_ticket: a missing or blank id drops the whole field: no usable identity', () => { + test('hub_ticket: a missing or blank id drops the whole field: no usable identity', () => { for (const id of [undefined, '', ' ', 42, null]) { - const r = sanitizeTaskRecord({ ...validRecord, brain_ticket: { ...validBrainTicket, id } }) - expect(r && 'brain_ticket' in r).toBe(false) + const r = sanitizeTaskRecord({ ...validRecord, hub_ticket: { ...validHubTicket, id } }) + expect(r && 'hub_ticket' in r).toBe(false) } }) - test('brain_ticket: id and title are truncated to their bounds, never rejected for length', () => { + test('hub_ticket: id and title are truncated to their bounds, never rejected for length', () => { const r = sanitizeTaskRecord({ ...validRecord, - brain_ticket: { - ...validBrainTicket, - id: 'i'.repeat(TASK_BRAIN_TICKET_ID_MAX + 50), + hub_ticket: { + ...validHubTicket, + id: 'i'.repeat(TASK_HUB_TICKET_ID_MAX + 50), title: 't'.repeat(TASK_TITLE_MAX + 50), }, }) - expect(r?.brain_ticket?.id.length).toBe(TASK_BRAIN_TICKET_ID_MAX) - expect(r?.brain_ticket?.title.length).toBe(TASK_TITLE_MAX) + expect(r?.hub_ticket?.id.length).toBe(TASK_HUB_TICKET_ID_MAX) + expect(r?.hub_ticket?.title.length).toBe(TASK_TITLE_MAX) }) - test('brain_ticket: title degrades to an empty string rather than dropping the field', () => { + test('hub_ticket: title degrades to an empty string rather than dropping the field', () => { const r = sanitizeTaskRecord({ ...validRecord, - brain_ticket: { id: validBrainTicket.id, title: 42 }, + hub_ticket: { id: validHubTicket.id, title: 42 }, }) - expect(r?.brain_ticket).toEqual({ id: validBrainTicket.id, title: '' }) + expect(r?.hub_ticket).toEqual({ id: validHubTicket.id, title: '' }) }) - test('brain_ticket: url must be an http(s) URL, or the key is simply omitted', () => { + test('hub_ticket: url must be an http(s) URL, or the key is simply omitted', () => { for (const url of ['not a url', 'ftp://example.com/1', 'javascript:alert(1)']) { - const r = sanitizeTaskRecord({ ...validRecord, brain_ticket: { ...validBrainTicket, url } }) - expect(r && r.brain_ticket && 'url' in r.brain_ticket).toBe(false) + const r = sanitizeTaskRecord({ ...validRecord, hub_ticket: { ...validHubTicket, url } }) + expect(r && r.hub_ticket && 'url' in r.hub_ticket).toBe(false) } }) - test('brain_ticket: url is truncated to its bound, never rejected for length', () => { - const longUrl = `https://brain.local/${'x'.repeat(TASK_ISSUE_URL_MAX)}` + test('hub_ticket: url is truncated to its bound, never rejected for length', () => { + const longUrl = `https://hub.local/${'x'.repeat(TASK_ISSUE_URL_MAX)}` + const r = sanitizeTaskRecord({ + ...validRecord, + hub_ticket: { ...validHubTicket, url: longUrl }, + }) + expect(r?.hub_ticket?.url?.length).toBe(TASK_ISSUE_URL_MAX) + }) +}) + +describe('sanitizeTaskRecord — legacy brain_ticket compat', () => { + test('a record with only legacy brain_ticket normalizes to hub_ticket in the output', () => { + const legacy = { ...validRecord, brain_ticket: validHubTicket } + const r = sanitizeTaskRecord(structuredClone(legacy)) + expect(r?.hub_ticket).toEqual(validHubTicket) + expect(r && 'brain_ticket' in r).toBe(false) + }) + + test('legacy brain_ticket goes through the same validation as hub_ticket: ids are truncated, not rejected', () => { const r = sanitizeTaskRecord({ ...validRecord, - brain_ticket: { ...validBrainTicket, url: longUrl }, + brain_ticket: { ...validHubTicket, id: 'i'.repeat(TASK_HUB_TICKET_ID_MAX + 50) }, }) - expect(r?.brain_ticket?.url?.length).toBe(TASK_ISSUE_URL_MAX) + expect(r?.hub_ticket?.id.length).toBe(TASK_HUB_TICKET_ID_MAX) + }) + + test('when both brain_ticket and hub_ticket are present, hub_ticket wins', () => { + const legacyTicket = { id: 'legacy-1', title: 'Legacy title' } + const both = { ...validRecord, brain_ticket: legacyTicket, hub_ticket: validHubTicket } + const r = sanitizeTaskRecord(structuredClone(both)) + expect(r?.hub_ticket).toEqual(validHubTicket) }) }) diff --git a/packages/contract/src/tasks.ts b/packages/contract/src/tasks.ts index ed04692..f50fde3 100644 --- a/packages/contract/src/tasks.ts +++ b/packages/contract/src/tasks.ts @@ -591,18 +591,18 @@ export type TaskRecord = { */ criteria?: AcceptanceCriterion[] /** - * The brain ticket this task was created from, when it was (arm/brain + * The hub ticket this task was created from, when it was (arm/hub * integration): a stable pointer back to the ticket that owns this task, so - * a reader can open it without knowing the brain's own routing. WRITE-ONCE, + * a reader can open it without knowing the hub's own routing. WRITE-ONCE, * same discipline as `issue`: fixed at creation, never re-decided by a * later turn. * * OPTIONAL, and absence is the honest default: a record predating this - * field, and a task never claimed from a brain ticket (title+prompt, or a + * field, and a task never claimed from a hub ticket (title+prompt, or a * forge issue per T2.4/T2.5), name no ticket, exactly what "no - * brain_ticket" always meant before this field existed. + * hub_ticket" always meant before this field existed. */ - brain_ticket?: { + hub_ticket?: { id: string title: string url?: string @@ -649,8 +649,8 @@ export const TASK_EVENT_DATA_STRING_MAX = 2_000 export const TASK_ISSUE_PROJECT_MAX = 200 /** Bound of `TaskIssueRef.url`: a forge issue URL, never long in practice. */ export const TASK_ISSUE_URL_MAX = 500 -/** Bound of `TaskRecord.brain_ticket.id`: an id from an external system, not this store's own 12-hex TASK_ID_RE. */ -export const TASK_BRAIN_TICKET_ID_MAX = 64 +/** Bound of `TaskRecord.hub_ticket.id`: an id from an external system, not this store's own 12-hex TASK_ID_RE. */ +export const TASK_HUB_TICKET_ID_MAX = 64 const TASK_STATUSES: ReadonlySet<TaskStatus> = new Set([ 'queued', @@ -1015,17 +1015,17 @@ function sanitizeTaskTurn(raw: unknown): TaskTurn | null { /** * Whitelist and truncate, never throw: a non-object, or one whose `id` is * missing or blank, drops the WHOLE field, same doctrine as `sanitizeIssueRef` - * above, since a brain ticket pointer nobody can identify is worse than none. + * above, since a hub ticket pointer nobody can identify is worse than none. * `title` degrades to an empty string rather than nulling the field, and * `url` is kept only when it is an http(s) URL, same rule `isHttpUrl` applies * everywhere else in this module. */ -function sanitizeBrainTicket(raw: unknown): { id: string; title: string; url?: string } | null { +function sanitizeHubTicket(raw: unknown): { id: string; title: string; url?: string } | null { if (!raw || typeof raw !== 'object') { return null } const r = raw as Record<string, unknown> - const id = str(r.id, TASK_BRAIN_TICKET_ID_MAX) + const id = str(r.id, TASK_HUB_TICKET_ID_MAX) if (!id) { return null } @@ -1080,7 +1080,8 @@ export function sanitizeTaskRecord(raw: unknown): TaskRecord | null { const issue = sanitizeIssueRef(r.issue) const issueSnapshot = sanitizeIssueSnapshot(r.issue_snapshot) const criteria = sanitizeAcceptanceCriteria(r.criteria) - const brainTicket = sanitizeBrainTicket(r.brain_ticket) + // Legacy on-disk tasks.json may still carry `brain_ticket`; `hub_ticket` wins when both are present. + const hubTicket = sanitizeHubTicket(r.hub_ticket !== undefined ? r.hub_ticket : r.brain_ticket) return { version: 1, id, @@ -1155,7 +1156,7 @@ export function sanitizeTaskRecord(raw: unknown): TaskRecord | null { // than trusted. ...(issue ? { issue } : {}), ...(issueSnapshot ? { issue_snapshot: issueSnapshot } : {}), - ...(brainTicket ? { brain_ticket: brainTicket } : {}), + ...(hubTicket ? { hub_ticket: hubTicket } : {}), // Optional and whitelisted, same doctrine as `checks_status`: absence is // "not currently shipping or merging", which is also what an unknown or // stale token degrades to rather than being trusted as a step in progress. diff --git a/packages/contract/src/ticket.ts b/packages/contract/src/ticket.ts index 6e82dde..1ea250f 100644 --- a/packages/contract/src/ticket.ts +++ b/packages/contract/src/ticket.ts @@ -765,7 +765,7 @@ export type TicketCriteriaLintResult = * * `requireProofMethod` defaults to `false`, and that default is the whole * lint's behavior before D17 existed, byte-for-byte: nothing changes for a - * caller that does not pass this. Only `brain-draft.ts` passes `true`, to gate + * caller that does not pass this. Only `ticket-draft.ts` passes `true`, to gate * drafts on carrying a `[proof:...]` tag per criterion; the ADMISSION lint * (task-from-issue) and boot-time reconciliation are deliberately left at the * default, so an existing ticket written before D17 keeps linting exactly as @@ -1322,7 +1322,7 @@ function problemsForCriterion( ) } // Off by default (byte-identical to the lint's pre-D17 behavior): only - // `brain-draft.ts` opts in today. When it does, a criterion with no valid + // `ticket-draft.ts` opts in today. When it does, a criterion with no valid // `[proof:<method> <argument>]` tag is refused by name, same as a criterion // that fails EARS. if (opts.requireProofMethod && !parseCriterionProof(text)) { diff --git a/packages/web/src/components/RepoSettings.vue b/packages/web/src/components/RepoSettings.vue index 499dc0f..5bc8917 100644 --- a/packages/web/src/components/RepoSettings.vue +++ b/packages/web/src/components/RepoSettings.vue @@ -4,8 +4,8 @@ import { firstTokenBin, parseModelFlag } from '../composables/agentCommand' import { isMergeStrategyOption, parseSettingsSnapshot, - type BrainSettings, type MergeStrategy, + type RunnerSettings, } from '../composables/useSettings' import type { AgentOption } from '../types' @@ -43,18 +43,18 @@ const agentError = ref<string | null>(null) const model = ref('') const effort = ref('') -const brainAutoMerge = ref(true) +const runnerAutoMerge = ref(true) const mergeStrategy = ref<MergeStrategy | undefined>(undefined) const maxTaskTurns = ref(30) -const savingBrainAutoMerge = ref(false) -const brainAutoMergeError = ref<string | null>(null) +const savingRunnerAutoMerge = ref(false) +const runnerAutoMergeError = ref<string | null>(null) const savingMergeStrategy = ref(false) const mergeStrategyError = ref<string | null>(null) const savingMaxTaskTurns = ref(false) const maxTaskTurnsError = ref<string | null>(null) -function applySettings(settings: BrainSettings): void { - brainAutoMerge.value = settings.brainAutoMerge +function applySettings(settings: RunnerSettings): void { + runnerAutoMerge.value = settings.runnerAutoMerge mergeStrategy.value = settings.mergeStrategy maxTaskTurns.value = settings.maxTaskTurns } @@ -91,8 +91,12 @@ async function load() { } async function putSettings( - partial: Partial<{ brainAutoMerge: boolean; mergeStrategy: MergeStrategy; maxTaskTurns: number }>, -): Promise<BrainSettings> { + partial: Partial<{ + runnerAutoMerge: boolean + mergeStrategy: MergeStrategy + maxTaskTurns: number + }>, +): Promise<RunnerSettings> { if (!configToken) { throw new Error('missing config token') } @@ -108,21 +112,21 @@ async function putSettings( return parseSettingsSnapshot(await res.json()) } -async function saveBrainAutoMerge(next: boolean) { - if (!configToken || savingBrainAutoMerge.value) { +async function saveRunnerAutoMerge(next: boolean) { + if (!configToken || savingRunnerAutoMerge.value) { return } - const previous = brainAutoMerge.value - brainAutoMerge.value = next - savingBrainAutoMerge.value = true - brainAutoMergeError.value = null + const previous = runnerAutoMerge.value + runnerAutoMerge.value = next + savingRunnerAutoMerge.value = true + runnerAutoMergeError.value = null try { - applySettings(await putSettings({ brainAutoMerge: next })) + applySettings(await putSettings({ runnerAutoMerge: next })) } catch (e) { - brainAutoMerge.value = previous - brainAutoMergeError.value = e instanceof Error ? e.message : String(e) + runnerAutoMerge.value = previous + runnerAutoMergeError.value = e instanceof Error ? e.message : String(e) } finally { - savingBrainAutoMerge.value = false + savingRunnerAutoMerge.value = false } } @@ -431,22 +435,22 @@ onMounted(load) </section> <section class="cfg-section"> - <h2 class="cfg-section-title">{{ $t('settings.brainTitle') }}</h2> + <h2 class="cfg-section-title">{{ $t('settings.runnerTitle') }}</h2> - <p class="cfg-hint codesema-muted">{{ $t('settings.brainAutoMergeHint') }}</p> + <p class="cfg-hint codesema-muted">{{ $t('settings.runnerAutoMergeHint') }}</p> <div class="cfg-section-actions"> <button class="cfg-toggle-btn" - :class="{ 'cfg-toggle-btn--on': brainAutoMerge }" - :disabled="!configToken || savingBrainAutoMerge" - @click="saveBrainAutoMerge(!brainAutoMerge)" + :class="{ 'cfg-toggle-btn--on': runnerAutoMerge }" + :disabled="!configToken || savingRunnerAutoMerge" + @click="saveRunnerAutoMerge(!runnerAutoMerge)" > {{ - brainAutoMerge ? $t('settings.brainAutoMergeOn') : $t('settings.brainAutoMergeOff') + runnerAutoMerge ? $t('settings.runnerAutoMergeOn') : $t('settings.runnerAutoMergeOff') }} </button> - <p v-if="brainAutoMergeError" class="cfg-error"> - {{ $t('settings.brainAutoMergeError') }} ({{ brainAutoMergeError }}) + <p v-if="runnerAutoMergeError" class="cfg-error"> + {{ $t('settings.runnerAutoMergeError') }} ({{ runnerAutoMergeError }}) </p> </div> diff --git a/packages/web/src/composables/useSettings.test.ts b/packages/web/src/composables/useSettings.test.ts index 38047b8..d063eae 100644 --- a/packages/web/src/composables/useSettings.test.ts +++ b/packages/web/src/composables/useSettings.test.ts @@ -19,45 +19,45 @@ describe('parseSettingsSnapshot', () => { test('reads a well-formed GET/PUT response', () => { expect( parseSettingsSnapshot({ - brainAutoMerge: { value: false, raw: false }, + runnerAutoMerge: { value: false, raw: false }, mergeStrategy: { value: 'squash', raw: 'squash' }, maxTaskTurns: { value: 60, raw: 60 }, }), - ).toEqual({ brainAutoMerge: false, mergeStrategy: 'squash', maxTaskTurns: 60 }) + ).toEqual({ runnerAutoMerge: false, mergeStrategy: 'squash', maxTaskTurns: 60 }) }) test('an absent mergeStrategy field (the forge-default state) parses to undefined', () => { expect( parseSettingsSnapshot({ - brainAutoMerge: { value: true }, + runnerAutoMerge: { value: true }, mergeStrategy: {}, maxTaskTurns: { value: 30 }, }), - ).toEqual({ brainAutoMerge: true, mergeStrategy: undefined, maxTaskTurns: 30 }) + ).toEqual({ runnerAutoMerge: true, mergeStrategy: undefined, maxTaskTurns: 30 }) }) test('degrades to the server defaults on garbage, never throws', () => { expect(parseSettingsSnapshot(null)).toEqual({ - brainAutoMerge: true, + runnerAutoMerge: true, mergeStrategy: undefined, maxTaskTurns: 30, }) expect(parseSettingsSnapshot('nope')).toEqual({ - brainAutoMerge: true, + runnerAutoMerge: true, mergeStrategy: undefined, maxTaskTurns: 30, }) expect( parseSettingsSnapshot({ - brainAutoMerge: { value: 'yes' }, + runnerAutoMerge: { value: 'yes' }, mergeStrategy: { value: 'fast-forward' }, maxTaskTurns: { value: -1 }, }), - ).toEqual({ brainAutoMerge: true, mergeStrategy: undefined, maxTaskTurns: 30 }) + ).toEqual({ runnerAutoMerge: true, mergeStrategy: undefined, maxTaskTurns: 30 }) expect( parseSettingsSnapshot({ maxTaskTurns: { value: 12.5 }, }), - ).toEqual({ brainAutoMerge: true, mergeStrategy: undefined, maxTaskTurns: 30 }) + ).toEqual({ runnerAutoMerge: true, mergeStrategy: undefined, maxTaskTurns: 30 }) }) }) diff --git a/packages/web/src/composables/useSettings.ts b/packages/web/src/composables/useSettings.ts index 10db1b6..68c79b7 100644 --- a/packages/web/src/composables/useSettings.ts +++ b/packages/web/src/composables/useSettings.ts @@ -1,13 +1,13 @@ -// The three global brain-loop settings (packages/cli/src/config.ts: -// brainAutoMerge, mergeStrategy, maxTaskTurns), read and written through +// The three global runner-loop settings (packages/cli/src/config.ts: +// runnerAutoMerge, mergeStrategy, maxTaskTurns), read and written through // GET/PUT /api/settings. Pure parsing and validation live here so they stay // testable without mounting RepoSettings.vue; the fetches themselves stay in // the component, same split as useChecks.ts. export type MergeStrategy = 'merge' | 'squash' | 'rebase' -export type BrainSettings = { - brainAutoMerge: boolean +export type RunnerSettings = { + runnerAutoMerge: boolean /** Undefined is a real, honest state here: the forge applies its own * default merge strategy when none was ever configured (config.ts D13). */ mergeStrategy: MergeStrategy | undefined @@ -27,11 +27,11 @@ export function isMergeStrategyOption(value: string): value is MergeStrategy { * default the server's own resolveXxx would apply, never a crash: the same * whitelist-and-fallback doctrine `config.ts` documents for itself. */ -export function parseSettingsSnapshot(raw: unknown): BrainSettings { +export function parseSettingsSnapshot(raw: unknown): RunnerSettings { const body = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {} - const brainAutoMergeValue = (body.brainAutoMerge as { value?: unknown } | undefined)?.value - const brainAutoMerge = typeof brainAutoMergeValue === 'boolean' ? brainAutoMergeValue : true + const runnerAutoMergeValue = (body.runnerAutoMerge as { value?: unknown } | undefined)?.value + const runnerAutoMerge = typeof runnerAutoMergeValue === 'boolean' ? runnerAutoMergeValue : true const mergeStrategyValue = (body.mergeStrategy as { value?: unknown } | undefined)?.value const mergeStrategy = @@ -47,5 +47,5 @@ export function parseSettingsSnapshot(raw: unknown): BrainSettings { ? maxTaskTurnsValue : 30 - return { brainAutoMerge, mergeStrategy, maxTaskTurns } + return { runnerAutoMerge, mergeStrategy, maxTaskTurns } } diff --git a/packages/web/src/i18n.ts b/packages/web/src/i18n.ts index b350af0..df42a1e 100644 --- a/packages/web/src/i18n.ts +++ b/packages/web/src/i18n.ts @@ -161,12 +161,12 @@ const en = { 'settings.modelPlaceholderOpencode': 'openrouter/provider/model', 'settings.effortLabel': 'Reasoning effort', 'settings.effortDefault': 'CLI default', - 'settings.brainTitle': 'Brain integration', - 'settings.brainAutoMergeHint': - 'Automatically merge a task created from a brain ticket once it ships, passes review and checks, instead of waiting for a human to approve the merge. Global setting: applies to every repository.', - 'settings.brainAutoMergeOn': 'Auto-merge: on', - 'settings.brainAutoMergeOff': 'Auto-merge: off', - 'settings.brainAutoMergeError': 'Could not update auto-merge.', + 'settings.runnerTitle': 'Runner', + 'settings.runnerAutoMergeHint': + 'Automatically merge a task created from a hub ticket once it ships, passes review and checks, instead of waiting for a human to approve the merge. Global setting: applies to every repository.', + 'settings.runnerAutoMergeOn': 'Auto-merge: on', + 'settings.runnerAutoMergeOff': 'Auto-merge: off', + 'settings.runnerAutoMergeError': 'Could not update auto-merge.', 'settings.mergeStrategyLabel': 'Merge strategy', 'settings.mergeStrategyHint': 'Strategy passed to the forge CLI when a task branch merges. Left unset, the forge applies its own default.', @@ -1050,12 +1050,12 @@ const fr: Record<MessageKey, string> = { 'settings.modelPlaceholderOpencode': 'openrouter/provider/model', 'settings.effortLabel': 'Effort de raisonnement', 'settings.effortDefault': 'défaut du CLI', - 'settings.brainTitle': 'Intégration cerveau', - 'settings.brainAutoMergeHint': - "Fusionner automatiquement une tâche créée depuis un ticket du cerveau une fois livrée, revue et vérifiée, sans attendre l'approbation d'un humain. Réglage global : s'applique à tous les dépôts.", - 'settings.brainAutoMergeOn': 'Fusion auto : activée', - 'settings.brainAutoMergeOff': 'Fusion auto : désactivée', - 'settings.brainAutoMergeError': 'Impossible de mettre à jour la fusion automatique.', + 'settings.runnerTitle': 'Runner', + 'settings.runnerAutoMergeHint': + "Fusionner automatiquement une tâche créée depuis un ticket du hub une fois livrée, revue et vérifiée, sans attendre l'approbation d'un humain. Réglage global : s'applique à tous les dépôts.", + 'settings.runnerAutoMergeOn': 'Fusion auto : activée', + 'settings.runnerAutoMergeOff': 'Fusion auto : désactivée', + 'settings.runnerAutoMergeError': 'Impossible de mettre à jour la fusion automatique.', 'settings.mergeStrategyLabel': 'Stratégie de fusion', 'settings.mergeStrategyHint': "Stratégie transmise à l'outil de forge lors de la fusion d'une branche de tâche. Non définie, la forge applique son propre défaut.",