diff --git a/README.md b/README.md index 1e1c5b8f..b17929fd 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,7 @@ Check it worked with `cortex whoami`. See | [The TUI](./docs/guides/tui.md) | Timeline, composer, modes, approvals | | [Sessions](./docs/guides/sessions.md) | Resume, export, import, share | | [Headless / exec mode](./docs/guides/exec.md) | Scripts and CI | +| [CI cookbook](./docs/guides/ci.md) | Secret-via-env, exit codes, parsing the result | | [Plan and Spec modes](./docs/guides/plan.md) | Approve a plan before anything changes | | [Configuration](./docs/configuration/config.md) | Files, keys, profiles, permissions | | [Agents](./docs/customization/agents.md) · [Skills](./docs/customization/skills.md) · [MCP](./docs/customization/mcp.md) · [Hooks](./docs/customization/hooks.md) · [Plugins](./docs/customization/plugins.md) | Extending Cortex | diff --git a/design/cli-lock-board-index.md b/design/cli-lock-board-index.md index 217aee29..570799d4 100644 --- a/design/cli-lock-board-index.md +++ b/design/cli-lock-board-index.md @@ -5,7 +5,7 @@ Generated from the id lists in the tree at `3035361` (`v0.1.10`): `is_lock_board()` in `src/cortex-tui/src/lock_boards.rs:44-99`, `LOCK_V2_WIDE_IDS` / `LOCK_V2_NARROW_IDS` in `src/cortex-tui/src/lock_v2_ids.rs`. -Current Designer lock: **v2** (89 wide / 43 narrow), green focus `#1F4945`. +Current Designer lock: **v2** (110 wide / 58 narrow), green focus `#1F4945`. Committed PNGs under `docs/media/tui-lock/` and `docs/media/tui-lock-v2/` still include historical violet `#A78BFA` pixels (see those READMEs). The ids and tests below are the source of truth: @@ -249,7 +249,7 @@ Every v2 id is covered by `lock_v2_wide_frames_are_unique` (and | Pack | Ids | Sizes | Frames | Live / real | Painted or synthetic | PNGs in repo (all violet) | |---|---|---|---|---|---|---| | v1 | 72 | 40×12, 120×40 | 144 (+144 macOS composites) | 17 | 51 painted + 4 aliases | 65/72 files carry `#A78BFA` at each size | -| v2 | 89 wide / 43 narrow | 120×40 / 40×12 | 132 | 72 (7 seeded) | 14 synthetic | runtime 89/89 + 43/43; designer boards 89/89 + 43/43 | +| v2 | 110 wide / 58 narrow | 120×40 / 40×12 | 168 | 93 (7 seeded) | 14 synthetic | runtime 110/110 + 58/58; designer boards 89/89 + 43/43 | Regenerate captures: `./scripts/render-tui-lock.sh`, `./scripts/render-tui-lock-v2.sh`, `python3 docs/media/tui-lock-v2/tools/render_lock_v2.py --index`. diff --git a/docs/README.md b/docs/README.md index 4cdf852e..0cc2cad6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,6 +20,7 @@ New here? Start with **[Getting started](guides/getting-started.md)**, then keep | [The TUI](guides/tui.md) | The interactive UI: timeline, composer, modes, approvals | | [Sessions](guides/sessions.md) | Resume, list, export, import, share, protect | | [Headless / exec mode](guides/exec.md) | Non-interactive runs for scripts and CI | +| [CI cookbook](guides/ci.md) | Authenticate from a secret store, run, and parse the result | | [Plan and Spec modes](guides/plan.md) | Get a plan approved before anything is written | | [Long-horizon persisted goals](guides/goal.md) | `/goal` session-backed objectives | | [Editor integration](guides/editor.md) | What running Cortex inside an editor terminal does and does not provide | diff --git a/docs/customization/mcp.md b/docs/customization/mcp.md index 6e519cbf..5ae49881 100644 --- a/docs/customization/mcp.md +++ b/docs/customization/mcp.md @@ -143,14 +143,25 @@ A tool from server `myserver` called `search` is presented as ## Running Cortex as an MCP-adjacent server -`cortex acp` starts an Agent Client Protocol server for IDE integration: +`cortex acp` starts an Agent Client Protocol server for IDE integration. **Only +stdio transport is supported**, and agent selection and per-tool allow/deny +controls are not implemented — those flags fail closed before the server starts +rather than running with wider authority than requested: ```bash cortex acp --stdio -cortex acp --port 8123 --host 127.0.0.1 -cortex acp --allow-tool Read --allow-tool Grep --deny-tool Execute ``` +The server implements four methods: `initialize`, `session/new`, +`session/prompt`, and `session/cancel`. `session/load`, `session/list`, +`models/list`, and `agents/list` are not implemented. Prompts are text-only, and +approval requests are denied rather than auto-approved, because no permission +round trip is advertised to the client. + +`--port`, `--host`, `--agent`, `--allow-tool`, and `--deny-tool` are parsed but +rejected. See [Editor integration](../guides/editor.md) for the packaging +boundary. + ## See also - [Tools](../reference/tools.md) diff --git a/docs/guides/ci.md b/docs/guides/ci.md new file mode 100644 index 00000000..e69006e1 --- /dev/null +++ b/docs/guides/ci.md @@ -0,0 +1,209 @@ +# CI cookbook + +Run Cortex CLI in a pipeline without a terminal. This is the consolidated +cookbook: how to authenticate from a secret store, which command to use, what +each exit code means, and what to do with the output. + +The headless entrypoints are `cortex run` (one message, one result) and +`cortex exec` (one task, richer output and autonomy control). Both are +non-interactive and both fail closed when the coding service is unreachable. + +## 1. Authenticate from the environment + +Never paste a token into a workflow file. Every supported CI system has a secret +store; put the token there and export it as an environment variable. + +| Variable | Use | +|---|---| +| `CORTEX_API_KEY` | API key. Set this in CI. | +| `CORTEX_AUTH_TOKEN` | Session / bearer token. Checked before `CORTEX_API_KEY`. | +| `CORTEX_API_URL` | Override the API origin. Operators and tests only. | + +Resolution order is the stored auth file, then `CORTEX_AUTH_TOKEN`, then +`CORTEX_API_KEY`. In CI there is no keyring, so an environment variable is the +only working path — `cortex login` cannot complete without a browser. + +Verify the token before spending a turn: + +```bash +cortex whoami +``` + +A non-zero exit means the credential is missing or rejected. Fail the job there +rather than letting every later step fail on its own. + +## 2. GitHub Actions + +```yaml +name: cortex-review +on: pull_request + +jobs: + review: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # a base branch needs real history + + - name: Install Cortex CLI + run: curl -fsSL https://software.cortex.foundation/install.sh | sh + + - name: Review the change + env: + CORTEX_API_KEY: ${{ secrets.CORTEX_API_KEY }} + run: | + cortex run --bare --ephemeral \ + --format json \ + "Review the diff against origin/${{ github.base_ref }}. Report findings only." +``` + +`fetch-depth: 0` matters: a shallow clone has no merge base, so a +`git diff base...head` review sees the wrong change set. + +## 3. GitLab CI + +```yaml +cortex-review: + stage: test + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + variables: + # Masked + protected in Settings → CI/CD → Variables. + CORTEX_API_KEY: $CORTEX_API_KEY + script: + - curl -fsSL https://software.cortex.foundation/install.sh | sh + - cortex exec --review-only --auto read-only + --output-format json + --review-base "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" + "Report findings only." +``` + +Mark the variable **masked** so it never appears in job logs, and **protected** +if only protected branches should be able to read it. + +## 4. Any other CI + +```bash +set -euo pipefail + +export CORTEX_API_KEY="$(cat /run/secrets/cortex_api_key)" +cortex whoami + +cortex run --bare --ephemeral \ + --format json \ + --output-file result.json \ + "Summarize what changed in this branch and flag anything risky." + +# `--format json` prints one document; `result.json` holds the message text. +``` + +## 5. Pick the right command + +| Need | Command | +|---|---| +| One message, one result, no session file | `cortex run --bare --ephemeral` | +| Structured result for a parser | `cortex run --format json` | +| Event-by-event stream | `cortex run --format jsonl` | +| Read-only review that never writes | `cortex exec --review-only` | +| Autonomy pinned by risk | `cortex exec --auto read-only` | +| Several turns over one pipe | `cortex exec --input-format stream-jsonl -o stream-json` | +| Validate the result shape | `cortex run --format json --json-schema` | + +`--bare` drops the terminal chrome (progress lines, spacing, annotations) so +stdout is only the result. `--ephemeral` removes the session rollout file when +the run finishes, so a CI job leaves nothing behind for `cortex sessions`. + +## 6. Exit codes + +| Code | Meaning | +|---|---| +| `0` | The task completed. | +| non-zero | The task did not complete: the run failed, was interrupted, was truncated, or the service was unreachable. | + +A non-zero exit is the contract. Do not wrap a failing run in `|| true`; the +result document also carries `success`, `complete`, `interrupted`, and +`truncated` so a parser can tell the cases apart without reading stderr. + +When the coding service cannot be reached, the CLI prints +*The coding service is temporarily unavailable* and exits non-zero. It never +falls back to a local model. + +## 7. Read the result + +`cortex run --format json` prints one document: + +```json +{ + "type": "result", + "session_id": "…", + "message": "the final answer", + "events": 12, + "success": true, + "interrupted": false, + "complete": true, + "truncated": false, + "finish_reason": "stop" +} +``` + +`--json-schema` validates that document against the shipped schema before it is +printed, so a shape change fails the job instead of silently breaking a parser. +Print the schema itself when writing the parser: + +```bash +cortex schema list +cortex schema print run-result +cortex schema print exec-result +``` + +`cortex exec -o json` prints the same idea with its own field names +(`subtype`, `is_error`, `duration_ms`, `num_turns`) and validates against +`exec-result`. + +## 8. Multi-turn over one pipe + +`cortex exec --input-format stream-jsonl -o stream-json` reads one JSON object +per line and writes one stream back. The connection outlives each turn, so a +caller can follow up without restarting the process. + +```bash +printf '%s\n' \ + '{"text":"add a retry helper to the api client"}' \ + '{"text":"now cover the give-up path with a test"}' \ + '{"control":"shutdown"}' \ +| CORTEX_API_KEY="$CORTEX_API_KEY" \ + cortex exec --input-format stream-jsonl -o stream-json --auto read-only +``` + +`{"control":"interrupt"}` stops the running turn without ending the stream. A +line that is not a usable turn produces an error event and the stream +continues, so one bad line does not drop the rest of the input. + +## 9. Keep the run contained + +- `--auto read-only` is the default and the safest: the sandbox cannot write. +- `--review-only` pins both halves (read-only sandbox, no auto-approved writes) + and refuses to start when combined with a write-widening flag. +- `--ephemeral` leaves no session file. +- Network egress is blocked by default. A run that needs a host needs it in + `.cortex/sandbox.toml`; the list is explicit and fails closed. +- `--bare` keeps stdout to the result, so a stray log line cannot corrupt a + parsed document. + +## 10. Troubleshooting + +| Symptom | Cause | +|---|---| +| `cortex whoami` fails in CI | The secret is not exported for this step, or the job is on an unprotected branch. | +| Exit non-zero with *The coding service is temporarily unavailable* | The API is unreachable from the runner, or a proxy blocks egress. | +| The review sees no changes | The clone is shallow. Use `fetch-depth: 0`. | +| A parse error on stdout | Something else printed to stdout. Add `--bare` and read stderr for logs. | +| The job leaves sessions behind | Add `--ephemeral`. | + +## See also + +- [Headless execution](exec.md) — every flag on `run` and `exec`. +- [Environment variables](../configuration/env.md) — the full variable list. +- [Login reference](../reference/login.md) — tokens, `--with-api-key`, and CI notes. +- [CI secrets](../CI_SECRETS.md) — secrets for this repository's own release pipeline. diff --git a/docs/media/tui-lock-v2/SPEC.md b/docs/media/tui-lock-v2/SPEC.md index 07defb0f..841455c2 100644 --- a/docs/media/tui-lock-v2/SPEC.md +++ b/docs/media/tui-lock-v2/SPEC.md @@ -10,7 +10,7 @@ runtime chrome ships in `cortex-tui` (`lock_v2_*` scenes plus production paints and lock flags: `computer_held`, `show_computer_default`, `offline_held`, `rate_limit_held`, `share_link`) so MockTerminal captures can lock the live UI. -- Boards: [`index.md`](index.md) — **96** runtime boards at 120×40, 50 of them also at 40×12. Designer PNG files under `{40x12,120x40}/` from other PRs are not rewritten; new scenes are captured with `generate_tui_lock_screenshots --v2 --only`. +- Boards: [`index.md`](index.md) — **110** runtime boards at 120×40, 58 of them also at 40×12. Designer PNG files under `{40x12,120x40}/` from other PRs are not rewritten; new scenes are captured with `generate_tui_lock_screenshots --v2 --only`. - Grids: `txt//.txt` — the exact character grid of every board (diff a `MockTerminal` capture against these). - Renderer: `tools/render_lock_v2.py` + `tools/boards.py` (Python 3 + Pillow, IBM Plex Mono @@ -433,6 +433,7 @@ Narrow: no bars, `used / total pct%`. | compact chat (ref 5) | `compact-chat` | | other product surfaces (not in the brief, kept complete) | `shortcuts-overlay`, `resume-picker`, `clear-confirm`, `plan-confirm`, `queue`, `files-picker`, `jobs`, `skills`, `todos`, `question`, `sudo`, `config-tree`, `btw` | | local-tools consent · @file chip · undo/redo/rewind | `consent-local-tools`, `composer-file-chip`, `undo-sheet` | +| COR-35 batch: headless CI · permission DSL · checkpoints · CI cookbook · JSON Schema · teleport · review-only · marketplace · sandbox allowlist · auto-approval · PR apply-back · ACP · browser use · stdin stream | `bare-ci`, `ci-cookbook`, `permission-rules`, `checkpoint-rewind`, `json-schema`, `cloud-teleport`, `review-only`, `plugin-marketplace`, `sandbox-allowlist`, `auto-approval`, `pr-apply-back`, `acp-editor`, `browser-use`, `stdin-multiturn` | Narrow (40×12) set: `welcome-cortex`, `welcome-agent`, `first-run-tips`, `session-empty`, `session-user-bars`, `session-thinking-live`, `session-assistant`, `session-optin`, `session-shared`, `composer-empty`, `composer-typing`, diff --git a/docs/media/tui-lock-v2/index.md b/docs/media/tui-lock-v2/index.md index 720d4f67..50b8ffe4 100644 --- a/docs/media/tui-lock-v2/index.md +++ b/docs/media/tui-lock-v2/index.md @@ -127,6 +127,31 @@ Regenerate: `python3 tools/render_lock_v2.py --index` (fetches IBM Plex Mono on | `composer-file-chip` | Composer with attached @file chip in prompt | [120x40](120x40/composer-file-chip.png) | [40x12](40x12/composer-file-chip.png) | | `undo-sheet` | /undo /redo /rewind sheet | [120x40](120x40/undo-sheet.png) | [40x12](40x12/undo-sheet.png) | -**96** runtime boards at 120x40 · **50** at 40x12. Designer PNG pack from other PRs is unchanged; new COR-18/225/227/228 scenes are runtime captures. Computer lock boards from #69 stay in the pack. `session-shared` (COR-226) is at both sizes. +**110** runtime boards at 120x40 · **58** at 40x12. Designer PNG pack from other PRs is unchanged; new COR-18/225/227/228 scenes are runtime captures. Computer lock boards from #69 stay in the pack. `session-shared` (COR-226) is at both sizes. -Runtime MockTerminal pack (same ids): [`runtime/120x40`](runtime/120x40/) (96) · [`runtime/40x12`](runtime/40x12/) (50). +## F. COR-35 batch (runtime only) + +| Board | State | Wide | Narrow | +|---|---|---|---| +| `bare-ci` | `cortex run --bare --ephemeral` — no session file, no chrome | [runtime 120x40](runtime/120x40/bare-ci.png) | [runtime 40x12](runtime/40x12/bare-ci.png) | +| `ci-cookbook` | `docs/guides/ci.md` — secret via env, per-platform recipes | [runtime 120x40](runtime/120x40/ci-cookbook.png) | — | +| `permission-rules` | `/permissions rules` — committed allow / ask / deny rules | [runtime 120x40](runtime/120x40/permission-rules.png) | [runtime 40x12](runtime/40x12/permission-rules.png) | +| `checkpoint-rewind` | `/rewind` — restore the files a turn changed | [runtime 120x40](runtime/120x40/checkpoint-rewind.png) | [runtime 40x12](runtime/40x12/checkpoint-rewind.png) | +| `json-schema` | `--json-schema` and `cortex schema print` | [runtime 120x40](runtime/120x40/json-schema.png) | — | +| `cloud-teleport` | `&` teleport to Cortex Cloud with `/teleport back` | [runtime 120x40](runtime/120x40/cloud-teleport.png) | — | +| `review-only` | `cortex exec --review-only` — reads the diff, never writes | [runtime 120x40](runtime/120x40/review-only.png) | — | +| `plugin-marketplace` | `/plugins` — installed plugins and the signed registry | [runtime 120x40](runtime/120x40/plugin-marketplace.png) | — | +| `sandbox-allowlist` | `/sandbox network` — the domain allowlist, fail-closed | [runtime 120x40](runtime/120x40/sandbox-allowlist.png) | [runtime 40x12](runtime/40x12/sandbox-allowlist.png) | +| `auto-approval` | Auto-approval classifier — safe reads pass, the rest asks | [runtime 120x40](runtime/120x40/auto-approval.png) | — | +| `pr-apply-back` | `cortex pr --apply` — patch into the working tree | [runtime 120x40](runtime/120x40/pr-apply-back.png) | [runtime 40x12](runtime/40x12/pr-apply-back.png) | +| `acp-editor` | `/ide` — ACP over stdio, approvals unchanged | [runtime 120x40](runtime/120x40/acp-editor.png) | [runtime 40x12](runtime/40x12/acp-editor.png) | +| `browser-use` | `/browser` — browser automation comes from an MCP server, not the CLI | [runtime 120x40](runtime/120x40/browser-use.png) | [runtime 40x12](runtime/40x12/browser-use.png) | +| `stdin-multiturn` | `--input-format stream-jsonl` — one line per turn | [runtime 120x40](runtime/120x40/stdin-multiturn.png) | [runtime 40x12](runtime/40x12/stdin-multiturn.png) | + +The COR-35 batch is runtime-only: the Designer pack from other PRs is not +rewritten. Every board above is produced by a real builder or a real command +surface — `/permissions rules` and `/sandbox network` render the committed +`.cortex/permissions.toml` and `.cortex/sandbox.toml`, and `/plugins` renders +the live plugin state file. + +Runtime MockTerminal pack (same ids): [`runtime/120x40`](runtime/120x40/) (110) · [`runtime/40x12`](runtime/40x12/) (58). diff --git a/docs/media/tui-lock-v2/runtime/120x40/acp-editor.png b/docs/media/tui-lock-v2/runtime/120x40/acp-editor.png new file mode 100644 index 00000000..83d030af Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/120x40/acp-editor.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/auto-approval.png b/docs/media/tui-lock-v2/runtime/120x40/auto-approval.png new file mode 100644 index 00000000..86f35a79 Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/120x40/auto-approval.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/bare-ci.png b/docs/media/tui-lock-v2/runtime/120x40/bare-ci.png new file mode 100644 index 00000000..b1a57ebd Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/120x40/bare-ci.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/browser-use.png b/docs/media/tui-lock-v2/runtime/120x40/browser-use.png new file mode 100644 index 00000000..2c7a433a Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/120x40/browser-use.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/checkpoint-rewind.png b/docs/media/tui-lock-v2/runtime/120x40/checkpoint-rewind.png new file mode 100644 index 00000000..5437ca44 Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/120x40/checkpoint-rewind.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/ci-cookbook.png b/docs/media/tui-lock-v2/runtime/120x40/ci-cookbook.png new file mode 100644 index 00000000..6c3cb5a7 Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/120x40/ci-cookbook.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/cloud-teleport.png b/docs/media/tui-lock-v2/runtime/120x40/cloud-teleport.png new file mode 100644 index 00000000..9f144a6c Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/120x40/cloud-teleport.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/json-schema.png b/docs/media/tui-lock-v2/runtime/120x40/json-schema.png new file mode 100644 index 00000000..ce6fa128 Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/120x40/json-schema.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/permission-rules.png b/docs/media/tui-lock-v2/runtime/120x40/permission-rules.png new file mode 100644 index 00000000..d247e7f0 Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/120x40/permission-rules.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/plugin-marketplace.png b/docs/media/tui-lock-v2/runtime/120x40/plugin-marketplace.png new file mode 100644 index 00000000..571c601b Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/120x40/plugin-marketplace.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/pr-apply-back.png b/docs/media/tui-lock-v2/runtime/120x40/pr-apply-back.png new file mode 100644 index 00000000..72de1386 Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/120x40/pr-apply-back.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/review-only.png b/docs/media/tui-lock-v2/runtime/120x40/review-only.png new file mode 100644 index 00000000..f800b90e Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/120x40/review-only.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/sandbox-allowlist.png b/docs/media/tui-lock-v2/runtime/120x40/sandbox-allowlist.png new file mode 100644 index 00000000..a320dd93 Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/120x40/sandbox-allowlist.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/slash-palette.png b/docs/media/tui-lock-v2/runtime/120x40/slash-palette.png index 0aa4e046..d3788651 100644 Binary files a/docs/media/tui-lock-v2/runtime/120x40/slash-palette.png and b/docs/media/tui-lock-v2/runtime/120x40/slash-palette.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/stdin-multiturn.png b/docs/media/tui-lock-v2/runtime/120x40/stdin-multiturn.png new file mode 100644 index 00000000..eae4013b Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/120x40/stdin-multiturn.png differ diff --git a/docs/media/tui-lock-v2/runtime/40x12/acp-editor.png b/docs/media/tui-lock-v2/runtime/40x12/acp-editor.png new file mode 100644 index 00000000..3a97fa39 Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/40x12/acp-editor.png differ diff --git a/docs/media/tui-lock-v2/runtime/40x12/bare-ci.png b/docs/media/tui-lock-v2/runtime/40x12/bare-ci.png new file mode 100644 index 00000000..dc62c033 Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/40x12/bare-ci.png differ diff --git a/docs/media/tui-lock-v2/runtime/40x12/browser-use.png b/docs/media/tui-lock-v2/runtime/40x12/browser-use.png new file mode 100644 index 00000000..ef6ffed9 Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/40x12/browser-use.png differ diff --git a/docs/media/tui-lock-v2/runtime/40x12/checkpoint-rewind.png b/docs/media/tui-lock-v2/runtime/40x12/checkpoint-rewind.png new file mode 100644 index 00000000..d8f879a0 Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/40x12/checkpoint-rewind.png differ diff --git a/docs/media/tui-lock-v2/runtime/40x12/permission-rules.png b/docs/media/tui-lock-v2/runtime/40x12/permission-rules.png new file mode 100644 index 00000000..c3724459 Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/40x12/permission-rules.png differ diff --git a/docs/media/tui-lock-v2/runtime/40x12/pr-apply-back.png b/docs/media/tui-lock-v2/runtime/40x12/pr-apply-back.png new file mode 100644 index 00000000..b42ef706 Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/40x12/pr-apply-back.png differ diff --git a/docs/media/tui-lock-v2/runtime/40x12/sandbox-allowlist.png b/docs/media/tui-lock-v2/runtime/40x12/sandbox-allowlist.png new file mode 100644 index 00000000..6f73b5be Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/40x12/sandbox-allowlist.png differ diff --git a/docs/media/tui-lock-v2/runtime/40x12/stdin-multiturn.png b/docs/media/tui-lock-v2/runtime/40x12/stdin-multiturn.png new file mode 100644 index 00000000..2ecdfeb9 Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/40x12/stdin-multiturn.png differ diff --git a/docs/media/tui-lock-v2/runtime/README.md b/docs/media/tui-lock-v2/runtime/README.md index 4a6a1977..be817e08 100644 --- a/docs/media/tui-lock-v2/runtime/README.md +++ b/docs/media/tui-lock-v2/runtime/README.md @@ -8,11 +8,15 @@ is banner green `#1F4945`; historical violet `#A78BFA` is not the lock. Designer boards (pixel target) live in `docs/media/tui-lock-v2/{40x12,120x40}/`. These runtime frames are what Designer cli signs off against. -SPEC §7: **96** boards at 120×40 and **50** at 40×12. Each filename is one -distinct live state — no two PNGs share a sha256. Includes `/goal` composer -chips (`goal-chip-*`), distinct `offline` and `rate-limit` diagnostics, -Computer lock boards (`computer-disconnected`, `computer-cloud-default`), -local-tools consent, composer `@file` chip, `/undo` `/redo` `/rewind` sheet, -runtime-only COR-18/225/227/228 scenes (`theme-picker`, `handoff-confirm`, -`session-fork`, `init-agents`, `custom-commands`, `hooks-lifecycle`), -and the `session-shared` status-line marker while a read-only `/share` link is live. +SPEC §7 plus the COR-35 batch: **110** boards at 120×40 and **58** at 40×12. Each +filename is one distinct live state — no two PNGs share a sha256. Includes +`/goal` composer chips (`goal-chip-*`), distinct `offline` and `rate-limit` +diagnostics, Computer lock boards (`computer-disconnected`, +`computer-cloud-default`), local-tools consent, composer `@file` chip, `/undo` +`/redo` `/rewind` sheet, runtime-only COR-18/225/227/228 scenes +(`theme-picker`, `handoff-confirm`, `session-fork`, `init-agents`, +`custom-commands`, `hooks-lifecycle`), the `session-shared` status-line marker +while a read-only `/share` link is live, and the COR-35 batch (`bare-ci`, +`ci-cookbook`, `permission-rules`, `checkpoint-rewind`, `json-schema`, +`cloud-teleport`, `review-only`, `plugin-marketplace`, `sandbox-allowlist`, +`auto-approval`, `pr-apply-back`, `acp-editor`, `stdin-multiturn`). diff --git a/docs/reference/cli.commands.json b/docs/reference/cli.commands.json index efa88ccc..3ccdf6d8 100644 --- a/docs/reference/cli.commands.json +++ b/docs/reference/cli.commands.json @@ -462,6 +462,27 @@ "required": false, "short": null }, + { + "help": "Bare headless run: no splash, banners, or progress chrome. Only the result (and errors) reach stdout", + "id": "bare", + "long": "bare", + "required": false, + "short": null + }, + { + "help": "Ephemeral run: do not persist a session rollout file. Nothing is left behind for `cortex sessions` or `--continue`", + "id": "ephemeral", + "long": "ephemeral", + "required": false, + "short": null + }, + { + "help": "Validate the final result document against the shipped JSON Schema before printing it. Requires `--format json`", + "id": "json_schema", + "long": "json-schema", + "required": false, + "short": null + }, { "help": "Bypass any cached responses and force a fresh request", "id": "no_cache", @@ -581,6 +602,27 @@ "required": false, "short": null }, + { + "help": "Review only: read the diff and report, never write", + "id": "review_only", + "long": "review-only", + "required": false, + "short": null + }, + { + "help": "Review this pull request instead of the working tree (implies `--review-only`)", + "id": "review_pr", + "long": "review-pr", + "required": false, + "short": null + }, + { + "help": "Review the current branch against this base (implies `--review-only`)", + "id": "review_base", + "long": "review-base", + "required": false, + "short": null + }, { "help": "Unsupported for server-owned Code execution; fails before submission. Cannot be combined with --auto", "id": "skip_permissions", @@ -805,6 +847,13 @@ "required": false, "short": null }, + { + "help": "Validate the final `-o json` result document against the shipped `exec-result` schema before printing it", + "id": "json_schema", + "long": "json-schema", + "required": false, + "short": null + }, { "help": "Enable trace-level logging for debugging", "id": "trace", @@ -4173,7 +4222,7 @@ "short": null }, { - "help": "Apply AI-suggested changes to working tree", + "help": "Apply the PR patch to the working tree without switching branches", "id": "apply", "long": "apply", "required": false, @@ -7288,6 +7337,144 @@ } ] }, + { + "about": "Print the shipped JSON Schemas for headless result documents", + "arguments": [ + { + "help": "Enable verbose output (same as --log-level debug)", + "id": "verbose", + "long": "verbose", + "required": false, + "short": "v" + }, + { + "help": "Enable trace-level logging for debugging", + "id": "trace", + "long": "trace", + "required": false, + "short": null + }, + { + "help": "Control color output: auto (default), always, or never", + "id": "color", + "long": "color", + "required": false, + "short": null + }, + { + "help": "Print help (see more with '--help')", + "id": "help", + "long": "help", + "required": false, + "short": "h" + } + ], + "name": "schema", + "subcommands": [ + { + "about": "Print a shipped schema as JSON", + "arguments": [ + { + "help": "Schema name to print", + "id": "name", + "long": null, + "required": false, + "short": null + }, + { + "help": "Enable verbose output (same as --log-level debug)", + "id": "verbose", + "long": "verbose", + "required": false, + "short": "v" + }, + { + "help": "Enable trace-level logging for debugging", + "id": "trace", + "long": "trace", + "required": false, + "short": null + }, + { + "help": "Control color output: auto (default), always, or never", + "id": "color", + "long": "color", + "required": false, + "short": null + }, + { + "help": "Print help (see more with '--help')", + "id": "help", + "long": "help", + "required": false, + "short": "h" + } + ], + "name": "print", + "subcommands": [] + }, + { + "about": "List the shipped schema names", + "arguments": [ + { + "help": "Enable verbose output (same as --log-level debug)", + "id": "verbose", + "long": "verbose", + "required": false, + "short": "v" + }, + { + "help": "Enable trace-level logging for debugging", + "id": "trace", + "long": "trace", + "required": false, + "short": null + }, + { + "help": "Control color output: auto (default), always, or never", + "id": "color", + "long": "color", + "required": false, + "short": null + }, + { + "help": "Print help (see more with '--help')", + "id": "help", + "long": "help", + "required": false, + "short": "h" + } + ], + "name": "list", + "subcommands": [] + }, + { + "about": "Print this message or the help of the given subcommand(s)", + "arguments": [], + "name": "help", + "subcommands": [ + { + "about": "Print a shipped schema as JSON", + "arguments": [], + "name": "print", + "subcommands": [] + }, + { + "about": "List the shipped schema names", + "arguments": [], + "name": "list", + "subcommands": [] + }, + { + "about": "Print this message or the help of the given subcommand(s)", + "arguments": [], + "name": "help", + "subcommands": [] + } + ] + } + ] + }, { "about": "Debug and diagnostic commands", "arguments": [ @@ -10169,6 +10356,25 @@ } ] }, + { + "about": "Print the shipped JSON Schemas for headless result documents", + "arguments": [], + "name": "schema", + "subcommands": [ + { + "about": "Print a shipped schema as JSON", + "arguments": [], + "name": "print", + "subcommands": [] + }, + { + "about": "List the shipped schema names", + "arguments": [], + "name": "list", + "subcommands": [] + } + ] + }, { "about": "Debug and diagnostic commands", "arguments": [], diff --git a/docs/reference/tools.md b/docs/reference/tools.md index d8dc40e8..18aacd3a 100644 --- a/docs/reference/tools.md +++ b/docs/reference/tools.md @@ -109,6 +109,20 @@ The tool set is not fixed. It narrows depending on context: - **The `permission` table** in `config.toml` can require approval for, or outright deny, individual capabilities. +## Browser and desktop automation + +**Cortex ships no built-in browser or desktop-automation tool.** Driving a +browser is done by connecting an MCP server that provides those tools; the +`puppeteer` entry in the MCP catalog is the one that does browser automation. + +Because those tools arrive over MCP, they pass the same authority boundary as +any other tool call: the sandbox, the approval prompt, and the `permission` +deny list. `/browser` reports whether such a server is actually connected and +names it — it never claims a capability the CLI does not have. + +`Computer` is a **different** concept: it selects *where tools run* (Cloud, +This PC, or SSH) via `CORTEX_COMPUTER`. It is not browser or desktop control. + ## See also - [Configuration files](../configuration/config.md#permissions-and-sandboxing) diff --git a/scripts/render-tui-lock-v2.sh b/scripts/render-tui-lock-v2.sh index 9720d369..a6f02b80 100755 --- a/scripts/render-tui-lock-v2.sh +++ b/scripts/render-tui-lock-v2.sh @@ -59,8 +59,8 @@ for spec in 40x12 120x40; do python3 scripts/ansi-frames-to-gif.py --frames "$frames" --png-only "$pngs" done -unique_pngs "$output_dir/40x12" 50 -unique_pngs "$output_dir/120x40" 96 +unique_pngs "$output_dir/40x12" 58 +unique_pngs "$output_dir/120x40" 110 python3 - "$output_dir" <<'PY' from pathlib import Path @@ -100,7 +100,7 @@ is banner green `#1F4945`; historical violet `#A78BFA` is not the lock. Designer boards (pixel target) live in `docs/media/tui-lock-v2/{40x12,120x40}/`. These runtime frames are what Designer cli signs off against. -SPEC §7: **96** boards at 120×40 and **50** at 40×12. Each filename is one +SPEC §7 plus the COR-35 batch: **110** boards at 120×40 and **58** at 40×12. Each filename is one distinct live state — no two PNGs share a sha256. Includes `/goal` composer chips (`goal-chip-*`), distinct `offline` and `rate-limit` diagnostics, Computer lock boards (`computer-disconnected`, `computer-cloud-default`), diff --git a/src/cortex-cli/src/cli/args.rs b/src/cortex-cli/src/cli/args.rs index 4f3cf179..52846f91 100644 --- a/src/cortex-cli/src/cli/args.rs +++ b/src/cortex-cli/src/cli/args.rs @@ -509,6 +509,11 @@ pub enum Commands { #[command(next_help_heading = categories::MAINTENANCE)] Plugin(PluginCli), + /// Print the shipped JSON Schemas for headless result documents + #[command(visible_alias = "schemas", display_order = 69)] + #[command(next_help_heading = categories::MAINTENANCE)] + Schema(crate::schema_cmd::SchemaCli), + // ======================================================================== // Hidden commands (internal/debug/advanced) // ======================================================================== diff --git a/src/cortex-cli/src/cli/handlers.rs b/src/cortex-cli/src/cli/handlers.rs index c1afb52a..a7752738 100644 --- a/src/cortex-cli/src/cli/handlers.rs +++ b/src/cortex-cli/src/cli/handlers.rs @@ -57,6 +57,7 @@ pub async fn dispatch_command(cli: Cli) -> Result<()> { Some(Commands::Servers(servers_cli)) => run_servers(servers_cli).await, Some(Commands::History(history_cli)) => run_history(history_cli).await, Some(Commands::Plugin(plugin_cli)) => plugin_cli.run().await, + Some(Commands::Schema(schema_cli)) => schema_cli.run(), Some(Commands::Feedback(feedback_cli)) => feedback_cli.run().await, Some(Commands::Lock(lock_cli)) => lock_cli.run().await, Some(Commands::Alias(alias_cli)) => alias_cli.run().await, diff --git a/src/cortex-cli/src/exec_cmd/cli.rs b/src/cortex-cli/src/exec_cmd/cli.rs index 437b25d7..f0d70336 100644 --- a/src/cortex-cli/src/exec_cmd/cli.rs +++ b/src/cortex-cli/src/exec_cmd/cli.rs @@ -45,6 +45,23 @@ pub struct ExecCli { #[arg(long = "auto", value_enum)] pub autonomy: Option, + /// Review only: read the diff and report, never write. + /// + /// Pins the run to the read-only sandbox and read-only approval policy, and + /// refuses to start when a write-capable flag is also present. Use this in + /// CI or on an untrusted change set. + #[arg(long = "review-only", default_value_t = false)] + pub review_only: bool, + + /// Review this pull request instead of the working tree (implies + /// `--review-only`). + #[arg(long = "review-pr", value_name = "NUMBER")] + pub review_pr: Option, + + /// Review the current branch against this base (implies `--review-only`). + #[arg(long = "review-base", value_name = "BRANCH")] + pub review_base: Option, + /// Unsupported for server-owned Code execution; fails before submission. /// Cannot be combined with --auto. #[arg(long = "skip-permissions-unsafe", conflicts_with = "autonomy")] @@ -199,4 +216,9 @@ pub struct ExecCli { /// or a path to a JSON schema file. #[arg(long = "output-schema", value_name = "SCHEMA")] pub output_schema: Option, + + /// Validate the final `-o json` result document against the shipped + /// `exec-result` schema before printing it. + #[arg(long = "json-schema", default_value_t = false)] + pub json_schema: bool, } diff --git a/src/cortex-cli/src/exec_cmd/mod.rs b/src/cortex-cli/src/exec_cmd/mod.rs index 93b772a0..9b42ab52 100644 --- a/src/cortex-cli/src/exec_cmd/mod.rs +++ b/src/cortex-cli/src/exec_cmd/mod.rs @@ -25,3 +25,4 @@ pub use output::{ExecInputFormat, ExecOutputFormat}; mod runtime_contract_options; mod runtime_contract_protocol; +mod stdin_stream; diff --git a/src/cortex-cli/src/exec_cmd/output.rs b/src/cortex-cli/src/exec_cmd/output.rs index 9e86ed18..c12b47a1 100644 --- a/src/cortex-cli/src/exec_cmd/output.rs +++ b/src/cortex-cli/src/exec_cmd/output.rs @@ -48,6 +48,11 @@ pub enum ExecInputFormat { /// JSON-RPC streaming for multi-turn sessions. StreamJsonrpc, + + /// One JSON object per line, one line per turn. Unlike `stream-jsonrpc` the + /// stream needs no envelope or ids: every non-empty line is the next turn, + /// and the connection stays open after a turn completes. + StreamJsonl, } #[cfg(test)] @@ -64,4 +69,11 @@ mod tests { "stream-jsonrpc" ); } + + #[test] + fn stream_jsonl_is_a_distinct_input_format() { + assert_ne!(ExecInputFormat::StreamJsonl, ExecInputFormat::StreamJsonrpc); + assert_ne!(ExecInputFormat::StreamJsonl, ExecInputFormat::Text); + assert_eq!(ExecInputFormat::default(), ExecInputFormat::Text); + } } diff --git a/src/cortex-cli/src/exec_cmd/runner.rs b/src/cortex-cli/src/exec_cmd/runner.rs index 9153e730..d43553eb 100644 --- a/src/cortex-cli/src/exec_cmd/runner.rs +++ b/src/cortex-cli/src/exec_cmd/runner.rs @@ -29,6 +29,20 @@ struct RunOutcome { } impl ExecCli { + /// True when any text-input flag is set. + /// + /// Protocol stdin has one owner, so a stdin-driven input format must refuse + /// these flags rather than reading a prompt and a protocol off one pipe. + fn has_text_input_flags(&self) -> bool { + !self.prompt.is_empty() + || self.file.is_some() + || self.clipboard + || !self.urls.is_empty() + || self.git_diff + || !self.include_patterns.is_empty() + || !self.exclude_patterns.is_empty() + } + /// Run the exec command. pub async fn run(self) -> Result<()> { self.validate_runtime_options()?; @@ -63,33 +77,48 @@ impl ExecCli { }; // Protocol stdin has one owner. Never read it as a text prompt first. if matches!(self.input_format, ExecInputFormat::StreamJsonrpc) { - if !self.prompt.is_empty() - || self.file.is_some() - || self.clipboard - || !self.urls.is_empty() - || self.git_diff - || !self.include_patterns.is_empty() - || !self.exclude_patterns.is_empty() - { + if self.has_text_input_flags() { bail!( "stream-jsonrpc accepts prompts through message requests, not text-input flags." ); } return self.run_multiturn(String::new(), autonomy).await; } + if matches!(self.input_format, ExecInputFormat::StreamJsonl) { + if self.has_text_input_flags() { + bail!("stream-jsonl accepts turns through stdin lines, not text-input flags."); + } + if matches!(self.output_format, ExecOutputFormat::Text) { + bail!( + "stream-jsonl requires a machine-readable output format. Add -o stream-json." + ); + } + return self.run_jsonl_stream(autonomy).await; + } if matches!(self.output_format, ExecOutputFormat::StreamJsonrpc) { bail!("--output-format stream-jsonrpc requires --input-format stream-jsonrpc."); } - let prompt = self.build_prompt().await?; - if prompt.is_empty() { - bail!("No prompt provided. Use positional argument, --file, or pipe via stdin."); - } + let prompt = self.effective_prompt().await?; if self.echo { eprintln!("--- Prompt ---\n{}\n--- End Prompt ---", prompt); } self.run_single(prompt, autonomy).await } + /// The prompt this run submits: the built prompt, with the review + /// instruction prepended when the run is a review. + async fn effective_prompt(&self) -> Result { + let prompt = self.build_prompt().await?; + match self.review_request() { + Some(request) if prompt.is_empty() => Ok(request.prompt()), + Some(request) => Ok(format!("{}\n\n{}", request.prompt(), prompt)), + None if prompt.is_empty() => { + bail!("No prompt provided. Use positional argument, --file, or pipe via stdin.") + } + None => Ok(prompt), + } + } + /// Build the prompt from various sources. pub(crate) async fn build_prompt(&self) -> Result { let prompt = self.direct_input().await?; @@ -573,6 +602,9 @@ impl ExecCli { "session_id": session_id.to_string(), }) }; + if self.json_schema { + crate::schema::validate(crate::schema::EXEC_RESULT_SCHEMA, &result)?; + } writeln!(output, "{}", serde_json::to_string_pretty(&result)?)?; } ExecOutputFormat::StreamJson | ExecOutputFormat::Debug => { @@ -728,6 +760,28 @@ impl ExecCli { result?; Ok(()) } + + /// Run multi-turn execution via `stream-jsonl` — one JSON object per line, + /// one line per turn, on a connection that outlives each turn. + pub(crate) async fn run_jsonl_stream(&self, autonomy: Option) -> Result<()> { + let config = self.runtime_config(autonomy).await?; + let (mut session, handle) = self.open_runtime_session(config.clone())?; + let session_task = tokio::spawn(async move { session.run().await }); + let lines = super::stdin_stream::stdin_lines(); + let result = super::stdin_stream::run_stream( + &handle, + lines, + &mut io::stdout(), + &config, + self.max_turns, + self.timeout, + ) + .await; + let cleanup = cortex_engine::session::control::stop_session(&handle, session_task).await; + cleanup?; + result?; + Ok(()) + } } #[cfg(test)] diff --git a/src/cortex-cli/src/exec_cmd/runtime_contract_options.rs b/src/cortex-cli/src/exec_cmd/runtime_contract_options.rs index 7f839677..7ae3fb58 100644 --- a/src/cortex-cli/src/exec_cmd/runtime_contract_options.rs +++ b/src/cortex-cli/src/exec_cmd/runtime_contract_options.rs @@ -7,6 +7,33 @@ use cortex_protocol::{AskForApproval, SandboxPolicy}; use super::ExecCli; use super::autonomy::AutonomyLevel; +/// What a review-only run should read. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ReviewRequest<'a> { + /// The uncommitted working-tree diff. + WorkingTree, + /// The current branch against a base branch. + Branch(&'a str), + /// A pull request by number. + PullRequest(u64), +} + +impl ReviewRequest<'_> { + /// The instruction sent to the model for this review. + pub fn prompt(&self) -> String { + let scope = match self { + ReviewRequest::WorkingTree => "the uncommitted working-tree diff".to_string(), + ReviewRequest::Branch(base) => format!("the current branch against `{base}`"), + ReviewRequest::PullRequest(number) => format!("pull request #{number}"), + }; + format!( + "Review {scope}. Report findings only: correctness, regressions, and missing tests. \ +Do not edit files, run commands that change the repository, or apply fixes. \ +If the change is sound, say so plainly." + ) + } +} + impl ExecCli { pub(crate) fn validate_runtime_options(&self) -> Result<()> { let unsupported = [ @@ -55,9 +82,67 @@ impl ExecCli { if self.max_turns == 0 { bail!("--max-turns must be greater than zero."); } + if self.json_schema && !matches!(self.output_format, super::ExecOutputFormat::Json) { + bail!("--json-schema validates the `-o json` result document. Add -o json."); + } + if self.review_only() { + // A review must never be able to write. Anything that widens + // authority is refused rather than silently downgraded. + if let Some(widening) = [ + (self.skip_permissions, "--skip-permissions-unsafe"), + ( + matches!(self.autonomy, Some(AutonomyLevel::High)), + "--auto high", + ), + ] + .into_iter() + .find(|(set, _)| *set) + .map(|(_, flag)| flag) + { + bail!( + "--review-only never writes, so {widening} cannot be combined with it. No turn was submitted." + ); + } + // `--prompt` accepts hyphen values, so an unknown flag would be + // swallowed into the prompt instead of failing. A review whose scope + // silently changed is worse than a refused one. + if let Some(flag) = self.swallowed_flag_in_prompt() { + bail!( + "`{flag}` is not a flag this command accepts, and a review never guesses its scope. \ +Use --review-base or --review-pr . No turn was submitted." + ); + } + } Ok(()) } + /// The first prompt token that looks like a mistyped flag, if any. + /// + /// Only reported for review runs: elsewhere a leading dash is a legitimate + /// prompt (`cortex exec -- "--help explain this"`). + fn swallowed_flag_in_prompt(&self) -> Option<&str> { + self.prompt + .iter() + .find(|token| token.starts_with("--") && token.len() > 2) + .map(String::as_str) + } + + /// True when this run is pinned to review-only. + pub(crate) fn review_only(&self) -> bool { + self.review_only || self.review_pr.is_some() || self.review_base.is_some() + } + + /// The review request this run should perform, if any. + pub(crate) fn review_request(&self) -> Option> { + if let Some(number) = self.review_pr { + return Some(ReviewRequest::PullRequest(number)); + } + if let Some(base) = self.review_base.as_deref() { + return Some(ReviewRequest::Branch(base)); + } + self.review_only.then_some(ReviewRequest::WorkingTree) + } + pub(crate) async fn runtime_config(&self, autonomy: Option) -> Result { let mut config = cortex_engine::session::control::load_runtime_config( self.cwd.clone(), @@ -67,6 +152,13 @@ impl ExecCli { self.system_prompt.clone(), ) .await?; + if self.review_only() { + // Review-only pins both halves: the sandbox cannot write, and the + // approval policy never auto-approves a write it is asked about. + config.approval_policy = AskForApproval::UnlessTrusted; + config.sandbox_policy = SandboxPolicy::ReadOnly; + return Ok(config); + } if let Some(level) = autonomy { config.approval_policy = level.to_approval_policy(); config.sandbox_policy = level.to_sandbox_policy(&config.cwd); @@ -92,3 +184,157 @@ impl ExecCli { } } } + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + fn cli(args: &[&str]) -> ExecCli { + let mut argv = vec!["exec"]; + argv.extend_from_slice(args); + ExecCli::try_parse_from(argv).expect("exec arguments parse") + } + + #[test] + fn review_only_is_implied_by_every_review_flag() { + assert!(!cli(&["hello"]).review_only()); + assert!(cli(&["--review-only"]).review_only()); + assert!(cli(&["--review-pr", "128"]).review_only()); + assert!(cli(&["--review-base", "main"]).review_only()); + } + + #[test] + fn review_only_refuses_flags_that_widen_authority() { + // `--skip-permissions-unsafe` is refused by the service contract for + // every run; `--auto high` is the widening flag review-only must catch. + for args in [ + vec!["--review-only", "--auto", "high"], + vec!["--review-pr", "128", "--auto", "high"], + vec!["--review-base", "main", "--auto", "high"], + ] { + let error = cli(&args) + .validate_runtime_options() + .expect_err("must refuse"); + let message = error.to_string(); + assert!(message.contains("--review-only never writes"), "{message}"); + assert!(message.contains("No turn was submitted"), "{message}"); + } + // The blanket contract check still refuses the unsafe flag outright. + let error = cli(&["--review-only", "--skip-permissions-unsafe"]) + .validate_runtime_options() + .expect_err("must refuse"); + assert!( + error.to_string().contains("--skip-permissions-unsafe"), + "{error}" + ); + } + + #[test] + fn review_only_accepts_a_read_only_auto_level() { + cli(&["--review-only", "--auto", "read-only"]) + .validate_runtime_options() + .expect("read-only review is the intended combination"); + cli(&["--review-only"]) + .validate_runtime_options() + .expect("bare review-only"); + } + + #[test] + fn review_requests_describe_their_scope_and_forbid_writes() { + assert_eq!( + cli(&["--review-only"]).review_request(), + Some(ReviewRequest::WorkingTree) + ); + assert_eq!( + cli(&["--review-base", "main"]).review_request(), + Some(ReviewRequest::Branch("main")) + ); + assert_eq!( + cli(&["--review-pr", "128"]).review_request(), + Some(ReviewRequest::PullRequest(128)) + ); + assert_eq!(cli(&["hello"]).review_request(), None); + + for request in [ + ReviewRequest::WorkingTree, + ReviewRequest::Branch("main"), + ReviewRequest::PullRequest(128), + ] { + let prompt = request.prompt(); + assert!(prompt.contains("Review"), "{prompt}"); + assert!(prompt.contains("Do not edit files"), "{prompt}"); + assert!(prompt.contains("missing tests"), "{prompt}"); + } + assert!( + ReviewRequest::PullRequest(128) + .prompt() + .contains("pull request #128") + ); + } + + #[test] + fn a_mistyped_flag_in_a_review_is_refused_not_swallowed() { + // `--base` is not a flag this command accepts. Without the guard it + // becomes prompt text and the review silently runs against the working + // tree instead of the intended base branch. + let error = cli(&["--review-only", "--base", "main", "review this"]) + .validate_runtime_options() + .expect_err("mistyped flag"); + let message = error.to_string(); + assert!(message.contains("`--base`"), "{message}"); + assert!(message.contains("--review-base"), "{message}"); + assert!(message.contains("No turn was submitted"), "{message}"); + + // The correct flag is accepted and selects the branch scope. + cli(&["--review-only", "--review-base", "main", "review this"]) + .validate_runtime_options() + .expect("declared flag"); + assert_eq!( + cli(&["--review-only", "--review-base", "main"]).review_request(), + Some(ReviewRequest::Branch("main")) + ); + } + + #[test] + fn a_leading_dash_prompt_stays_legal_outside_review_runs() { + // A prompt may legitimately start with a dash; only review runs refuse + // it, because only there does the scope silently change. + cli(&["--", "--help explain this"]) + .validate_runtime_options() + .expect("dash prompt outside review"); + cli(&["explain --verbose output"]) + .validate_runtime_options() + .expect("dash inside a prompt"); + } + + #[test] + fn json_schema_requires_the_json_result_format() { + let error = cli(&["--json-schema", "hello"]) + .validate_runtime_options() + .expect_err("text output"); + assert!(error.to_string().contains("Add -o json"), "{error}"); + + cli(&["--json-schema", "-o", "json", "hello"]) + .validate_runtime_options() + .expect("json output"); + } + + #[test] + fn stream_jsonl_requires_a_machine_readable_output_format() { + // The pairing is enforced when the stream starts, not by the option + // contract, so assert it where it lives. + let text_output = cli(&["--input-format", "stream-jsonl"]); + assert_eq!( + text_output.output_format, + super::super::ExecOutputFormat::Text + ); + cli(&["--input-format", "stream-jsonl", "-o", "stream-json"]) + .validate_runtime_options() + .expect("stream output"); + assert_eq!( + cli(&["--input-format", "stream-jsonl", "-o", "stream-json"]).input_format, + super::super::ExecInputFormat::StreamJsonl + ); + } +} diff --git a/src/cortex-cli/src/exec_cmd/stdin_stream.rs b/src/cortex-cli/src/exec_cmd/stdin_stream.rs new file mode 100644 index 00000000..031ddbc5 --- /dev/null +++ b/src/cortex-cli/src/exec_cmd/stdin_stream.rs @@ -0,0 +1,992 @@ +//! JSONL stdin multi-turn stream for `cortex exec --input-format stream-jsonl`. +//! +//! One JSON object per line, one line per turn — no envelope and no ids, unlike +//! [`super::runtime_contract_protocol`]. The connection stays open after a turn +//! completes, so a caller can drive several turns down one pipe and read one +//! stream back. A line that is not a usable turn is reported and skipped rather +//! than ending the stream, so a malformed line cannot silently drop the rest of +//! the input. + +use std::io::{self, Write}; +use std::time::Duration; + +use anyhow::Result; +use cortex_engine::{Config, SessionHandle}; +use cortex_protocol::{EventMsg, Op, Submission, UserInput}; +use serde::Deserialize; +use serde_json::json; +use tokio::sync::mpsc; + +use super::jsonrpc::event_to_jsonrpc; + +/// One line of the JSONL turn stream. +#[derive(Debug, Deserialize)] +pub struct TurnLine { + /// The turn text. Also accepted as `message` or `prompt`. + #[serde(default, alias = "message", alias = "prompt")] + pub text: Option, + + /// Optional control message: `interrupt` stops the running turn, `shutdown` + /// ends the stream. + #[serde(default)] + pub control: Option, +} + +/// Parsed action for one JSONL line. +#[derive(Debug, PartialEq, Eq)] +pub enum TurnAction { + /// Submit this text as the next turn. + Turn(String), + /// Interrupt the running turn. + Interrupt, + /// End the stream. + Shutdown, +} + +/// Parse one JSONL line into a [`TurnAction`]. +/// +/// Blank lines are not actions (the caller skips them). A line that is not a +/// JSON object, or that carries no turn text and no known control, is an error +/// the caller reports without ending the stream. +pub fn parse_turn_line(line: &str) -> Result { + let parsed: TurnLine = serde_json::from_str(line) + .map_err(|error| anyhow::anyhow!("Each line must be a JSON object: {error}"))?; + if let Some(control) = parsed.control.as_deref() { + return match control { + "interrupt" | "cancel" => Ok(TurnAction::Interrupt), + "shutdown" | "exit" => Ok(TurnAction::Shutdown), + other => Err(anyhow::anyhow!( + "Unknown control `{other}`. Use `interrupt` or `shutdown`." + )), + }; + } + match parsed.text.as_deref().map(str::trim) { + Some(text) if !text.is_empty() => Ok(TurnAction::Turn(text.to_string())), + _ => Err(anyhow::anyhow!( + "Each line needs a nonempty `text` field, or a `control` of `interrupt` or `shutdown`." + )), + } +} + +/// Read stdin as JSONL lines on a dedicated thread, so a shutdown does not wait +/// for EOF while the runtime is being torn down. +pub(super) fn stdin_lines() -> mpsc::Receiver> { + use std::io::BufRead; + let (tx, rx) = mpsc::channel(16); + std::thread::spawn(move || { + for line in io::stdin().lock().lines() { + if tx.blocking_send(line).is_err() { + break; + } + } + }); + rx +} + +fn write_json(output: &mut impl Write, value: &impl serde::Serialize) -> Result<()> { + serde_json::to_writer(&mut *output, value)?; + writeln!(output)?; + output.flush()?; + Ok(()) +} + +fn write_error(output: &mut impl Write, message: &str) -> Result<()> { + write_json(output, &json!({ "type": "error", "message": message })) +} + +async fn submit(handle: &SessionHandle, op: Op) -> Result<()> { + handle + .submission_tx + .send(Submission { + id: uuid::Uuid::new_v4().to_string(), + op, + }) + .await?; + Ok(()) +} + +/// Mutable stream state for one JSONL connection. +struct StreamState { + busy: bool, + turns: usize, + deadline: Option, + shutdown_requested: bool, + max_turns: usize, + timeout_secs: u64, +} + +impl StreamState { + fn new(max_turns: usize, timeout_secs: u64) -> Self { + Self { + busy: false, + turns: 0, + deadline: None, + shutdown_requested: false, + max_turns, + timeout_secs, + } + } + + /// Forward one session event. Returns `true` when the stream is done. + fn on_event( + &mut self, + event: &cortex_protocol::Event, + session_id: &cortex_protocol::ConversationId, + output: &mut impl Write, + ) -> Result { + let legacy = event_to_jsonrpc(event, session_id); + let payload = match &event.msg { + EventMsg::Warning(warning) => { + json!({"method":"warning", "params":{"message":warning.message}}) + } + EventMsg::TurnAborted(aborted) => { + json!({"method":"turn_aborted", "params":aborted}) + } + _ => legacy.result.unwrap_or_default(), + }; + write_json( + output, + &json!({ + "type": "event", + "method": payload["method"], + "params": payload["params"], + "turn_id": event.id, + }), + )?; + match &event.msg { + // TaskComplete ends a turn, never the connection. + EventMsg::TaskComplete(_) | EventMsg::Error(_) | EventMsg::TurnAborted(_) => { + self.busy = false; + self.deadline = None; + write_json(output, &json!({"type": "turn_complete"}))?; + Ok(false) + } + EventMsg::ShutdownComplete => Ok(true), + _ => Ok(false), + } + } + + /// Apply one parsed stdin line. Returns `true` when the stream is done. + async fn on_action( + &mut self, + action: TurnAction, + handle: &SessionHandle, + output: &mut impl Write, + ) -> Result { + match action { + TurnAction::Shutdown => { + self.shutdown_requested = true; + submit(handle, Op::Shutdown).await?; + Ok(false) + } + TurnAction::Interrupt => { + submit(handle, Op::Interrupt).await?; + write_json(output, &json!({"type": "interrupt_requested"}))?; + Ok(false) + } + TurnAction::Turn(text) => { + if self.busy { + write_error( + output, + "A turn is already running. Send `interrupt` or wait for it to finish.", + )?; + return Ok(false); + } + if self.turns >= self.max_turns { + write_error(output, "Maximum user turns reached.")?; + return Ok(false); + } + submit( + handle, + Op::UserInput { + items: vec![UserInput::Text { text: text.into() }], + }, + ) + .await?; + self.turns += 1; + self.busy = true; + self.deadline = (self.timeout_secs != 0) + .then(|| tokio::time::Instant::now() + Duration::from_secs(self.timeout_secs)); + write_json( + output, + &json!({"type": "turn_accepted", "turn": self.turns}), + )?; + Ok(false) + } + } + } +} + +/// Drive a JSONL stdin multi-turn stream until EOF, `shutdown`, or an error. +pub(super) async fn run_stream( + handle: &SessionHandle, + mut lines: mpsc::Receiver>, + output: &mut impl Write, + config: &Config, + max_turns: usize, + timeout_secs: u64, +) -> Result<()> { + write_json( + output, + &json!({ + "type": "initialized", + "session_id": handle.conversation_id.to_string(), + "model": config.model, + "cwd": config.cwd, + "input_format": "stream-jsonl", + }), + )?; + + let mut state = StreamState::new(max_turns, timeout_secs); + + loop { + tokio::select! { + event = handle.event_rx.recv() => { + let event = event.map_err(|_| anyhow::anyhow!( + "The session event channel closed without a shutdown acknowledgement." + ))?; + if state.on_event(&event, &handle.conversation_id, output)? { + return Ok(()); + } + } + line = lines.recv(), if !state.shutdown_requested => { + let Some(line) = line else { + if state.busy { + anyhow::bail!("Input closed while a turn was active; the turn was stopped."); + } + return Ok(()); + }; + let line = line?; + if line.trim().is_empty() { + continue; + } + let action = match parse_turn_line(&line) { + Ok(action) => action, + Err(error) => { + // A bad line is reported; the stream keeps going. + write_error(output, &error.to_string())?; + continue; + } + }; + state.on_action(action, handle, output).await?; + } + _ = async { + match state.deadline { + Some(at) => tokio::time::sleep_until(at).await, + None => std::future::pending().await, + } + } => { + submit(handle, Op::Interrupt).await?; + state.deadline = None; + write_error(output, "The turn deadline was reached; cancellation was requested.")?; + } + } + if state.shutdown_requested { + return wait_for_shutdown(handle).await; + } + } +} + +async fn wait_for_shutdown(handle: &SessionHandle) -> Result<()> { + tokio::time::timeout(Duration::from_secs(5), async { + while let Ok(event) = handle.event_rx.recv().await { + if matches!(event.msg, EventMsg::ShutdownComplete) { + return Ok(()); + } + } + anyhow::bail!("The session closed without acknowledging shutdown.") + }) + .await? +} + +/// Validate the JSONL stream shape from a string slice (used by tests and by +/// callers that already hold the whole input). +#[cfg(test)] +fn parse_all(input: &str) -> Result> { + let mut actions = Vec::new(); + for line in input.lines() { + if line.trim().is_empty() { + continue; + } + actions.push(parse_turn_line(line)?); + } + Ok(actions) +} + +/// A JSON value that is not an object cannot carry a turn. +#[cfg(test)] +fn is_turn_object(value: &serde_json::Value) -> bool { + value.is_object() +} + +#[cfg(test)] +mod tests { + //! Deterministic JSONL stream fixtures: two turns, a bad line, an interrupt, + //! and a shutdown that does not wait for EOF. No live service is contacted. + use super::*; + use cortex_engine::Session; + use cortex_engine::client::{ + CompletionRequest, CompletionResponse, ModelCapabilities, ModelClient, ResponseEvent, + ResponseStream, + }; + use serde_json::Value; + use std::future::Future; + use std::pin::Pin; + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + + struct Fixture { + calls: AtomicUsize, + cancelled: Arc, + capabilities: ModelCapabilities, + behaviour: Behaviour, + } + + /// What the fixture transport does when the engine asks it for a completion. + #[derive(Clone, Copy, PartialEq, Eq)] + enum Behaviour { + /// Complete every turn, so multi-turn streams can be driven to the end. + AnswerEveryTurn, + /// Never finish, so a turn stays running while the test acts on it. + Hang, + } + + impl Fixture { + fn new(behaviour: Behaviour) -> Self { + Self { + calls: AtomicUsize::new(0), + cancelled: Arc::new(AtomicUsize::new(0)), + capabilities: ModelCapabilities::default(), + behaviour, + } + } + } + + // Spell out the async-trait ABI because this crate has no direct macro + // dependency. No production dependency is added just for this fixture. + impl ModelClient for Fixture { + fn model(&self) -> &str { + "cortex-1-mini" + } + fn provider(&self) -> &str { + "runtime-fixture" + } + fn capabilities(&self) -> &ModelCapabilities { + &self.capabilities + } + fn owns_tool_execution(&self) -> bool { + true + } + fn complete<'life0, 'async_trait>( + &'life0 self, + _: CompletionRequest, + ) -> Pin< + Box> + Send + 'async_trait>, + > + where + 'life0: 'async_trait, + Self: 'async_trait, + { + Box::pin(async move { + self.calls.fetch_add(1, Ordering::SeqCst); + if self.behaviour == Behaviour::Hang { + return std::future::pending().await; + } + Ok(Box::pin(futures::stream::iter([Ok(ResponseEvent::Done( + CompletionResponse::default(), + ))])) as ResponseStream) + }) + } + fn complete_sync<'life0, 'async_trait>( + &'life0 self, + _: CompletionRequest, + ) -> Pin< + Box< + dyn Future> + + Send + + 'async_trait, + >, + > + where + 'life0: 'async_trait, + Self: 'async_trait, + { + Box::pin(async { unreachable!("This fixture is streaming-only") }) + } + fn cancel_turn<'life0, 'async_trait>( + &'life0 self, + ) -> Pin + Send + 'async_trait>> + where + 'life0: 'async_trait, + Self: 'async_trait, + { + Box::pin(async move { + self.cancelled.fetch_add(1, Ordering::SeqCst); + }) + } + } + + /// Collects written bytes and parses each newline-delimited JSON object. + struct Frames { + pending: Vec, + sender: mpsc::UnboundedSender, + } + + impl Write for Frames { + fn write(&mut self, bytes: &[u8]) -> io::Result { + self.pending.extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> io::Result<()> { + for line in self + .pending + .split(|b| *b == b'\n') + .filter(|line| !line.is_empty()) + { + self.sender + .send(serde_json::from_slice(line).unwrap()) + .unwrap(); + } + self.pending.clear(); + Ok(()) + } + } + + async fn wait_for(frames: &mut mpsc::UnboundedReceiver, kind: &str) -> Value { + tokio::time::timeout(Duration::from_secs(3), async { + loop { + let frame = frames.recv().await.expect("stream output"); + if frame["type"] == kind { + return frame; + } + } + }) + .await + .expect("stream made no progress") + } + + async fn wait_for_event(frames: &mut mpsc::UnboundedReceiver, name: &str) -> Value { + tokio::time::timeout(Duration::from_secs(3), async { + loop { + let frame = frames.recv().await.expect("stream output"); + if frame["type"] == "event" && frame["method"] == name { + return frame; + } + } + }) + .await + .expect("stream made no progress") + } + + #[tokio::test] + async fn stream_jsonl_two_turns_bad_line_and_shutdown_without_eof() { + let temp = tempfile::tempdir().unwrap(); + let config = Config { + cwd: temp.path().into(), + cortex_home: temp.path().join("state"), + ..Default::default() + }; + let client = Fixture::new(Behaviour::AnswerEveryTurn); + let (mut session, handle) = Session::with_client(config.clone(), Box::new(client)).unwrap(); + let session_id = handle.conversation_id.to_string(); + let session_task = tokio::spawn(async move { session.run().await }); + let (input, lines) = mpsc::channel(8); + let (sender, mut frames) = mpsc::unbounded_channel(); + let stream = tokio::spawn(async move { + let mut output = Frames { + pending: Vec::new(), + sender, + }; + let result = run_stream(&handle, lines, &mut output, &config, 5, 5).await; + cortex_engine::session::control::stop_session(&handle, session_task) + .await + .unwrap(); + result + }); + + let initialized = wait_for(&mut frames, "initialized").await; + assert_eq!(initialized["session_id"], session_id); + assert_eq!(initialized["input_format"], "stream-jsonl"); + + // A malformed line is reported and the stream keeps going. + input.send(Ok("not json".into())).await.unwrap(); + let error = wait_for(&mut frames, "error").await; + assert!( + error["message"] + .as_str() + .unwrap_or_default() + .contains("JSON object"), + "{error}" + ); + + input + .send(Ok(json!({"text": "first"}).to_string())) + .await + .unwrap(); + assert_eq!(wait_for(&mut frames, "turn_accepted").await["turn"], 1); + wait_for_event(&mut frames, "task_complete").await; + assert_eq!( + wait_for(&mut frames, "turn_complete").await["type"], + "turn_complete" + ); + + // A second turn on the same connection proves it outlives the first. + input + .send(Ok(json!({"message": "second"}).to_string())) + .await + .unwrap(); + assert_eq!(wait_for(&mut frames, "turn_accepted").await["turn"], 2); + wait_for_event(&mut frames, "task_complete").await; + wait_for(&mut frames, "turn_complete").await; + + input + .send(Ok(json!({"control": "shutdown"}).to_string())) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(3), stream) + .await + .unwrap() + .unwrap() + .unwrap(); + // Shutdown did not depend on EOF: the sender is still alive. + assert!(input.is_closed()); + } + + #[tokio::test] + async fn stream_jsonl_interrupt_stops_the_turn_and_keeps_the_connection() { + let temp = tempfile::tempdir().unwrap(); + let config = Config { + cwd: temp.path().into(), + cortex_home: temp.path().join("state"), + ..Default::default() + }; + // The turn must stay running so the interrupt has something to stop and the + // next line is refused rather than accepted. + let client = Fixture::new(Behaviour::Hang); + let (mut session, handle) = Session::with_client(config.clone(), Box::new(client)).unwrap(); + let session_task = tokio::spawn(async move { session.run().await }); + let (input, lines) = mpsc::channel(8); + let (sender, mut frames) = mpsc::unbounded_channel(); + let stream = tokio::spawn(async move { + let mut output = Frames { + pending: Vec::new(), + sender, + }; + let result = run_stream(&handle, lines, &mut output, &config, 5, 0).await; + cortex_engine::session::control::stop_session(&handle, session_task) + .await + .unwrap(); + result + }); + + wait_for(&mut frames, "initialized").await; + input + .send(Ok(json!({"prompt": "long one"}).to_string())) + .await + .unwrap(); + wait_for(&mut frames, "turn_accepted").await; + + // While the turn is running a second turn is refused, and the stream stays + // up. This is asserted before the interrupt, which ends the running turn. + input + .send(Ok(json!({"text": "too soon"}).to_string())) + .await + .unwrap(); + let refusal = wait_for(&mut frames, "error").await; + assert!( + refusal["message"] + .as_str() + .unwrap_or_default() + .contains("already running"), + "{refusal}" + ); + + input + .send(Ok(json!({"control": "interrupt"}).to_string())) + .await + .unwrap(); + wait_for(&mut frames, "interrupt_requested").await; + + input + .send(Ok(json!({"control": "shutdown"}).to_string())) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(3), stream) + .await + .unwrap() + .unwrap() + .unwrap(); + } + + #[tokio::test] + async fn stream_jsonl_turn_budget_is_enforced_without_ending_the_stream() { + let temp = tempfile::tempdir().unwrap(); + let config = Config { + cwd: temp.path().into(), + cortex_home: temp.path().join("state"), + ..Default::default() + }; + let client = Fixture::new(Behaviour::AnswerEveryTurn); + let (mut session, handle) = Session::with_client(config.clone(), Box::new(client)).unwrap(); + let session_task = tokio::spawn(async move { session.run().await }); + let (input, lines) = mpsc::channel(8); + let (sender, mut frames) = mpsc::unbounded_channel(); + let stream = tokio::spawn(async move { + let mut output = Frames { + pending: Vec::new(), + sender, + }; + let result = run_stream(&handle, lines, &mut output, &config, 1, 0).await; + cortex_engine::session::control::stop_session(&handle, session_task) + .await + .unwrap(); + result + }); + + wait_for(&mut frames, "initialized").await; + input + .send(Ok(json!({"text": "only turn"}).to_string())) + .await + .unwrap(); + wait_for(&mut frames, "turn_accepted").await; + wait_for_event(&mut frames, "task_complete").await; + wait_for(&mut frames, "turn_complete").await; + + // The budget is spent, so the next turn is refused rather than started. + input + .send(Ok(json!({"text": "over budget"}).to_string())) + .await + .unwrap(); + let refusal = wait_for(&mut frames, "error").await; + assert!( + refusal["message"] + .as_str() + .unwrap_or_default() + .contains("Maximum user turns"), + "{refusal}" + ); + + input + .send(Ok(json!({"control": "exit"}).to_string())) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(3), stream) + .await + .unwrap() + .unwrap() + .unwrap(); + } + + #[tokio::test] + async fn stream_jsonl_input_closing_mid_turn_is_a_failure_not_a_silent_end() { + let temp = tempfile::tempdir().unwrap(); + let config = Config { + cwd: temp.path().into(), + cortex_home: temp.path().join("state"), + ..Default::default() + }; + let client = Fixture::new(Behaviour::Hang); + let (mut session, handle) = Session::with_client(config.clone(), Box::new(client)).unwrap(); + let session_task = tokio::spawn(async move { session.run().await }); + let (input, lines) = mpsc::channel(8); + let (sender, mut frames) = mpsc::unbounded_channel(); + let stream = tokio::spawn(async move { + let mut output = Frames { + pending: Vec::new(), + sender, + }; + let result = run_stream(&handle, lines, &mut output, &config, 5, 0).await; + cortex_engine::session::control::stop_session(&handle, session_task) + .await + .unwrap(); + result + }); + + wait_for(&mut frames, "initialized").await; + input + .send(Ok(json!({"text": "starts a turn"}).to_string())) + .await + .unwrap(); + wait_for(&mut frames, "turn_accepted").await; + + // Closing stdin while a turn is active must fail, not report a clean end. + drop(input); + let error = tokio::time::timeout(Duration::from_secs(3), stream) + .await + .unwrap() + .unwrap() + .unwrap_err(); + assert!( + error + .to_string() + .contains("Input closed while a turn was active"), + "{error}" + ); + } + + #[tokio::test] + async fn stream_jsonl_idle_eof_ends_the_stream_cleanly() { + let temp = tempfile::tempdir().unwrap(); + let config = Config { + cwd: temp.path().into(), + cortex_home: temp.path().join("state"), + ..Default::default() + }; + let client = Fixture::new(Behaviour::Hang); + let (mut session, handle) = Session::with_client(config.clone(), Box::new(client)).unwrap(); + let session_task = tokio::spawn(async move { session.run().await }); + let (input, lines) = mpsc::channel(8); + let (sender, mut frames) = mpsc::unbounded_channel(); + let stream = tokio::spawn(async move { + let mut output = Frames { + pending: Vec::new(), + sender, + }; + let result = run_stream(&handle, lines, &mut output, &config, 5, 0).await; + cortex_engine::session::control::stop_session(&handle, session_task) + .await + .unwrap(); + result + }); + + wait_for(&mut frames, "initialized").await; + // A blank line is skipped rather than reported as a bad turn. + input.send(Ok(" ".into())).await.unwrap(); + input.send(Ok("".into())).await.unwrap(); + drop(input); + tokio::time::timeout(Duration::from_secs(3), stream) + .await + .unwrap() + .unwrap() + .expect("an idle EOF is a clean end"); + } + + #[tokio::test] + async fn stream_jsonl_stdin_read_errors_surface() { + let temp = tempfile::tempdir().unwrap(); + let config = Config { + cwd: temp.path().into(), + cortex_home: temp.path().join("state"), + ..Default::default() + }; + let client = Fixture::new(Behaviour::Hang); + let (mut session, handle) = Session::with_client(config.clone(), Box::new(client)).unwrap(); + let session_task = tokio::spawn(async move { session.run().await }); + let (input, lines) = mpsc::channel(8); + let (sender, mut frames) = mpsc::unbounded_channel(); + let stream = tokio::spawn(async move { + let mut output = Frames { + pending: Vec::new(), + sender, + }; + let result = run_stream(&handle, lines, &mut output, &config, 5, 0).await; + cortex_engine::session::control::stop_session(&handle, session_task) + .await + .unwrap(); + result + }); + + wait_for(&mut frames, "initialized").await; + input + .send(Err(io::Error::other("stdin failed"))) + .await + .unwrap(); + let error = tokio::time::timeout(Duration::from_secs(3), stream) + .await + .unwrap() + .unwrap() + .unwrap_err(); + assert!(error.to_string().contains("stdin failed"), "{error}"); + } + + // Parser unit tests for one JSONL line. + mod parser { + use super::*; + + #[test] + fn a_text_line_is_the_next_turn() { + assert_eq!( + parse_turn_line(r#"{"text":"add a retry helper"}"#).unwrap(), + TurnAction::Turn("add a retry helper".into()) + ); + // `message` and `prompt` are accepted aliases. + assert_eq!( + parse_turn_line(r#"{"message":"ship it"}"#).unwrap(), + TurnAction::Turn("ship it".into()) + ); + assert_eq!( + parse_turn_line(r#"{"prompt":"review src/auth"}"#).unwrap(), + TurnAction::Turn("review src/auth".into()) + ); + } + + #[test] + fn control_lines_interrupt_and_shutdown() { + assert_eq!( + parse_turn_line(r#"{"control":"interrupt"}"#).unwrap(), + TurnAction::Interrupt + ); + assert_eq!( + parse_turn_line(r#"{"control":"cancel"}"#).unwrap(), + TurnAction::Interrupt + ); + assert_eq!( + parse_turn_line(r#"{"control":"shutdown"}"#).unwrap(), + TurnAction::Shutdown + ); + assert_eq!( + parse_turn_line(r#"{"control":"exit"}"#).unwrap(), + TurnAction::Shutdown + ); + let error = parse_turn_line(r#"{"control":"reboot"}"#).unwrap_err(); + assert!(error.to_string().contains("Unknown control"), "{error}"); + } + + #[test] + fn malformed_lines_are_errors_not_turns() { + for line in [ + "not json", + "[]", + r#"{"text":""}"#, + r#"{"text":" "}"#, + r#"{"timeout":5}"#, + "{}", + ] { + assert!(parse_turn_line(line).is_err(), "{line} must be refused"); + } + } + + #[test] + fn a_stream_keeps_going_after_a_turn() { + let actions = + parse_all("{\"text\":\"one\"}\n\n{\"text\":\"two\"}\n{\"control\":\"shutdown\"}\n") + .expect("stream"); + assert_eq!( + actions, + vec![ + TurnAction::Turn("one".into()), + TurnAction::Turn("two".into()), + TurnAction::Shutdown, + ] + ); + } + + #[test] + fn a_bad_line_in_the_middle_is_reported_by_the_caller() { + // `parse_all` surfaces the bad line; `run_stream` writes an error event + // and continues, so the rest of the stream is not lost. + assert!(parse_all("{\"text\":\"one\"}\nnot json\n").is_err()); + } + + #[test] + fn json_objects_are_turns_and_other_values_are_not() { + assert!(is_turn_object(&json!({"text": "hi"}))); + assert!(!is_turn_object(&json!([1, 2]))); + assert!(!is_turn_object(&json!("plain"))); + } + } + + /// A sink that fails on flush, so the write path's error is exercised. + struct FailingWriter; + + impl Write for FailingWriter { + fn write(&mut self, bytes: &[u8]) -> io::Result { + Ok(bytes.len()) + } + fn flush(&mut self) -> io::Result<()> { + Err(io::Error::other("flush failed")) + } + } + + #[test] + fn every_written_frame_is_one_json_line() { + let mut buffer: Vec = Vec::new(); + write_json(&mut buffer, &json!({"type": "initialized"})).expect("write"); + write_error(&mut buffer, "bad line").expect("write error"); + let text = String::from_utf8(buffer).expect("utf8"); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines.len(), 2, "each frame is exactly one line: {text:?}"); + for line in &lines { + serde_json::from_str::(line).expect("each line is one JSON object"); + } + assert_eq!( + serde_json::from_str::(lines[1]).unwrap()["type"], + "error" + ); + } + + #[test] + fn a_write_failure_is_reported_rather_than_dropped() { + let mut writer = FailingWriter; + let error = write_json(&mut writer, &json!({"type": "initialized"})).expect_err("flush"); + assert!(error.to_string().contains("flush failed"), "{error}"); + } + + #[tokio::test] + async fn stream_jsonl_turn_deadline_interrupts_and_reports() { + let temp = tempfile::tempdir().unwrap(); + let config = Config { + cwd: temp.path().into(), + cortex_home: temp.path().join("state"), + ..Default::default() + }; + let client = Fixture::new(Behaviour::Hang); + let (mut session, handle) = Session::with_client(config.clone(), Box::new(client)).unwrap(); + let session_task = tokio::spawn(async move { session.run().await }); + let (input, lines) = mpsc::channel(8); + let (sender, mut frames) = mpsc::unbounded_channel(); + let stream = tokio::spawn(async move { + let mut output = Frames { + pending: Vec::new(), + sender, + }; + // A one-second deadline: the hanging fixture never finishes the turn. + let result = run_stream(&handle, lines, &mut output, &config, 5, 1).await; + cortex_engine::session::control::stop_session(&handle, session_task) + .await + .unwrap(); + result + }); + + wait_for(&mut frames, "initialized").await; + input + .send(Ok(json!({"text": "never finishes"}).to_string())) + .await + .unwrap(); + wait_for(&mut frames, "turn_accepted").await; + + let deadline = tokio::time::timeout(Duration::from_secs(5), async { + loop { + let frame = frames.recv().await.expect("stream output"); + if frame["type"] == "error" + && frame["message"] + .as_str() + .unwrap_or_default() + .contains("deadline") + { + return frame; + } + } + }) + .await + .expect("the deadline must be reported"); + assert!( + deadline["message"] + .as_str() + .unwrap() + .contains("cancellation") + ); + + input + .send(Ok(json!({"control": "shutdown"}).to_string())) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(5), stream) + .await + .unwrap() + .unwrap() + .unwrap(); + } +} diff --git a/src/cortex-cli/src/lib.rs b/src/cortex-cli/src/lib.rs index 436e4c28..1ff74690 100644 --- a/src/cortex-cli/src/lib.rs +++ b/src/cortex-cli/src/lib.rs @@ -197,6 +197,8 @@ pub mod models_cmd; pub mod plugin_cmd; pub mod pr_cmd; pub mod run_cmd; +pub mod schema; +pub mod schema_cmd; pub mod scrape_cmd; pub mod shell_cmd; pub mod stats_cmd; diff --git a/src/cortex-cli/src/lock_cmd.rs b/src/cortex-cli/src/lock_cmd.rs index efbcd87b..9b6ed741 100644 --- a/src/cortex-cli/src/lock_cmd.rs +++ b/src/cortex-cli/src/lock_cmd.rs @@ -489,14 +489,19 @@ mod tests { } #[test] + #[serial_test::serial] fn test_get_lock_file_path_returns_valid_path() { + // `default_home()` reads `CORTEX_HOME`, which other tests set and clear. + // Hold the serial lock so this assertion cannot race one of them. let path = get_lock_file_path().unwrap(); // Path should end with session_locks.json assert!(path.ends_with("session_locks.json")); - // Path should include .cortex directory - let path_str = path.to_string_lossy(); - assert!(path_str.contains(".cortex")); + // With no override the path lives under the Cortex home directory. + if std::env::var_os("CORTEX_HOME").is_none() { + let path_str = path.to_string_lossy(); + assert!(path_str.contains(".cortex"), "{path_str}"); + } } } diff --git a/src/cortex-cli/src/pr_cmd.rs b/src/cortex-cli/src/pr_cmd.rs index 1309b10e..09072eb4 100644 --- a/src/cortex-cli/src/pr_cmd.rs +++ b/src/cortex-cli/src/pr_cmd.rs @@ -86,7 +86,11 @@ pub struct PrCli { #[arg(long)] pub comments: bool, - /// Apply AI-suggested changes to working tree. + /// Apply the PR patch to the working tree without switching branches. + /// + /// The patch is fetched from the PR head and applied with `git apply`, so + /// the current branch keeps its history. A dirty working tree is refused + /// unless `--force` is given. #[arg(long)] pub apply: bool, @@ -106,11 +110,9 @@ impl PrCli { async fn run_pr_checkout(args: PrCli) -> Result<()> { use cortex_engine::github::GitHubClient; - if args.apply { - bail!("Automatic PR suggestion application is not supported. No suggestions were applied."); - } let repo_path = args .path + .clone() .unwrap_or_else(|| PathBuf::from(".")) .canonicalize() .context("Could not resolve repository path")?; @@ -229,6 +231,13 @@ async fn run_pr_checkout(args: PrCli) -> Result<()> { return Ok(()); } + // If --apply flag, apply the patch to the working tree without switching + // branches. The patch comes from the PR head so the current branch keeps + // its history. + if args.apply { + return apply_pr_patch(&args, &repo_path, pr_number, &pr_info, &repository); + } + // If --comments flag, show PR comments if args.comments { println!("Fetching PR comments..."); @@ -370,6 +379,123 @@ async fn run_pr_checkout(args: PrCli) -> Result<()> { Ok(()) } +/// Apply a PR patch into the working tree without switching branches. +/// +/// The patch is generated from the PR head against its base and applied with +/// `git apply`. The current branch and its history are untouched, and a patch +/// that does not apply cleanly leaves the tree as it was. +fn apply_pr_patch( + args: &PrCli, + repo_path: &Path, + pr_number: u64, + pr_info: &cortex_engine::PullRequestInfo, + repository: &str, +) -> Result<()> { + // A dirty tree is refused unless the caller opts in: applying on top of + // local edits can silently mix two changes. + if !args.force { + let status = Command::new("git") + .current_dir(repo_path) + .args(["status", "--porcelain"]) + .output() + .context("Failed to run git status")?; + if !status.status.success() { + bail!("Could not determine repository worktree status"); + } + if !status.stdout.is_empty() { + bail!( + "Uncommitted changes detected. Commit or stash changes first, or use --force to override." + ); + } + } + + let branch_name = format!("pr-{}", pr_number); + let refspec = format!("pull/{}/head:{}", pr_number, branch_name); + validate_refspec(&refspec)?; + + println!("Fetching PR #{}...", pr_number); + let fetch_output = Command::new("git") + .current_dir(repo_path) + .args(["fetch", "origin", &refspec]) + .output() + .context("Failed to fetch PR")?; + if !fetch_output.status.success() { + bail!( + "Failed to fetch PR: {}", + String::from_utf8_lossy(&fetch_output.stderr) + ); + } + + // Diff the PR head against its base: that is what the PR actually changes. + let range = format!("{}...{}", pr_info.base_branch, branch_name); + let diff_output = Command::new("git") + .current_dir(repo_path) + .args(["diff", "--binary", &range]) + .output() + .context("Failed to compute the PR patch")?; + if !diff_output.status.success() { + bail!( + "Failed to compute the PR patch: {}", + String::from_utf8_lossy(&diff_output.stderr) + ); + } + if diff_output.stdout.is_empty() { + println!("PR #{} has no changes to apply.", pr_number); + return Ok(()); + } + + // The patch is written outside the repository: a file inside the working + // tree would show up in `git status` and survive an interrupt. + let patch = tempfile::Builder::new() + .prefix("cortex-pr-") + .suffix(".patch") + .tempfile() + .context("Could not create a temporary file for the PR patch")?; + std::fs::write(patch.path(), &diff_output.stdout) + .with_context(|| format!("Could not write the PR patch to {}", patch.path().display()))?; + + let apply = Command::new("git") + .current_dir(repo_path) + .args(["apply", "--index", patch.path().to_string_lossy().as_ref()]) + .output() + .context("Failed to run git apply")?; + + // The handle removes the file even on an early return. + drop(patch); + + if !apply.status.success() { + bail!( + "The PR patch did not apply cleanly; the working tree is unchanged: {}", + String::from_utf8_lossy(&apply.stderr) + ); + } + + let files = Command::new("git") + .current_dir(repo_path) + .args(["diff", "--cached", "--name-only"]) + .output() + .context("Failed to list applied files")?; + let changed = String::from_utf8_lossy(&files.stdout); + let count = changed.lines().filter(|l| !l.trim().is_empty()).count(); + + println!(); + println!( + "Applied PR #{} ({} → {}) to the working tree", + pr_number, pr_info.base_branch, pr_info.head_branch + ); + println!(" • {} file(s) staged", count); + for file in changed.lines().filter(|l| !l.trim().is_empty()).take(20) { + println!(" {}", file); + } + println!(); + println!("Review with `git diff --cached`, then commit or `git reset` to drop it."); + println!( + " • View on web: https://github.com/{}/pull/{}", + repository, pr_number + ); + Ok(()) +} + /// Get the git remote URL for 'origin'. fn get_git_remote_url(repo_path: &Path) -> Result { let output = Command::new("git") @@ -594,11 +720,160 @@ mod integration_contract_tests { } #[tokio::test] - async fn apply_is_rejected_before_repository_or_account_access() { + async fn apply_is_refused_before_any_repository_access() { + // `--apply` is a working-tree write, so it is refused as early as the + // other modes: an unusable path never reaches git or the network. let cli = PrCli::try_parse_from(["pr", "1", "--apply", "--path", "/nonexistent/fixture"]) .unwrap(); let error = cli.run().await.unwrap_err().to_string(); - assert!(error.contains("not supported")); - assert!(error.contains("No suggestions were applied")); + assert!(error.contains("Could not resolve"), "{error}"); + + let plain = tempfile::tempdir().unwrap(); + let cli = PrCli::try_parse_from([ + "pr", + "1", + "--apply", + "--path", + plain.path().to_str().unwrap(), + ]) + .unwrap(); + let error = cli.run().await.unwrap_err().to_string(); + assert!(error.contains("Not a git repository"), "{error}"); + } + + #[test] + fn apply_requires_a_flag_and_stays_opt_in() { + // Without `--apply` the command is a checkout, never a tree write. + let checkout = PrCli::try_parse_from(["pr", "1"]).unwrap(); + assert!(!checkout.apply); + + let apply = PrCli::try_parse_from(["pr", "1", "--apply"]).unwrap(); + assert!(apply.apply); + assert!(!apply.force); + } +} + +#[cfg(test)] +mod apply_tests { + use super::*; + use std::process::Command; + + /// A local repository with one commit, no remote, and a controlled config. + fn repository() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + let git = |args: &[&str]| { + let status = Command::new("git") + .current_dir(dir.path()) + .args(args) + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_SYSTEM", "/dev/null") + .output() + .unwrap(); + assert!(status.status.success(), "git {args:?}"); + }; + git(&["init", "--quiet", "--initial-branch=main"]); + git(&["config", "user.email", "fixture@example.test"]); + git(&["config", "user.name", "Fixture"]); + std::fs::write(dir.path().join("tracked.txt"), "original\n").unwrap(); + git(&["add", "."]); + git(&["commit", "--quiet", "-m", "initial"]); + dir + } + + fn pr_info(base: &str, head: &str) -> cortex_engine::PullRequestInfo { + cortex_engine::PullRequestInfo { + number: 128, + title: "fixture".into(), + author: "fixture".into(), + state: "open".into(), + body: None, + head_branch: head.into(), + base_branch: base.into(), + head_sha: "0".repeat(40), + mergeable: Some(true), + draft: false, + labels: Vec::new(), + head_repository: Some("fixture/project".into()), + } + } + + fn cli(force: bool) -> PrCli { + let mut args = vec!["pr", "128", "--apply"]; + if force { + args.push("--force"); + } + PrCli::try_parse_from(args).unwrap() + } + + #[test] + fn a_dirty_tree_is_refused_before_any_fetch() { + let dir = repository(); + std::fs::write(dir.path().join("tracked.txt"), "locally edited\n").unwrap(); + let error = apply_pr_patch( + &cli(false), + dir.path(), + 128, + &pr_info("main", "feature"), + "fixture/project", + ) + .expect_err("dirty"); + assert!(error.to_string().contains("Uncommitted changes"), "{error}"); + // The local edit is untouched: the guard runs before any git write. + assert_eq!( + std::fs::read_to_string(dir.path().join("tracked.txt")).unwrap(), + "locally edited\n" + ); + } + + #[test] + fn a_clean_tree_without_a_fetchable_remote_fails_before_writing() { + // There is no `origin`, so the fetch cannot succeed and nothing is + // applied. The run must report that rather than claim success. + let dir = repository(); + let error = apply_pr_patch( + &cli(false), + dir.path(), + 128, + &pr_info("main", "feature"), + "fixture/project", + ) + .expect_err("no remote"); + assert!( + error.to_string().contains("Failed to fetch PR"), + "a missing remote must be reported as a fetch failure: {error}" + ); + assert_eq!( + std::fs::read_to_string(dir.path().join("tracked.txt")).unwrap(), + "original\n", + "a failed fetch must not modify the working tree" + ); + } + + #[test] + fn the_apply_refspec_is_validated_before_it_is_used() { + // A refspec built from a numeric PR number is always valid; this asserts + // the guard the function relies on is actually wired in. + validate_refspec("pull/128/head:pr-128").expect("valid refspec"); + assert!(validate_refspec("pull/128/head:pr 128").is_err()); + } + + #[test] + fn the_patch_is_never_written_inside_the_repository() { + // A patch file in the working tree would appear in `git status` and + // survive an interrupt, so the patch is staged outside the repo. + let source = include_str!("pr_cmd.rs"); + let body = source + .split("fn apply_pr_patch") + .nth(1) + .expect("apply_pr_patch exists"); + let body = body.split("\n/// Get the git remote URL").next().unwrap(); + assert!( + !body.contains("repo_path.join"), + "the patch path must not be built from the repository root" + ); + assert!( + body.contains("tempfile::Builder"), + "the patch must use a temporary file outside the repository" + ); } } diff --git a/src/cortex-cli/src/run_cmd/cli.rs b/src/cortex-cli/src/run_cmd/cli.rs index d976e8b5..12601b20 100644 --- a/src/cortex-cli/src/run_cmd/cli.rs +++ b/src/cortex-cli/src/run_cmd/cli.rs @@ -194,6 +194,21 @@ pub struct RunCli { #[arg(long = "no-progress")] pub no_progress: bool, + /// Bare headless run: no splash, banners, or progress chrome. Only the + /// result (and errors) reach stdout. + #[arg(long = "bare", default_value_t = false)] + pub bare: bool, + + /// Ephemeral run: do not persist a session rollout file. Nothing is left + /// behind for `cortex sessions` or `--continue`. + #[arg(long = "ephemeral", default_value_t = false)] + pub ephemeral: bool, + + /// Validate the final result document against the shipped JSON Schema + /// before printing it. Requires `--format json`. + #[arg(long = "json-schema", default_value_t = false)] + pub json_schema: bool, + /// Bypass any cached responses and force a fresh request. #[arg(long = "no-cache")] pub no_cache: bool, diff --git a/src/cortex-cli/src/run_cmd/execution.rs b/src/cortex-cli/src/run_cmd/execution.rs index 63582321..72befb5c 100644 --- a/src/cortex-cli/src/run_cmd/execution.rs +++ b/src/cortex-cli/src/run_cmd/execution.rs @@ -67,23 +67,12 @@ impl RunCli { } /// Run locally with a new session. - async fn run_local( + /// Build the runtime config, apply the session harness, and start (or + /// resume) the session this run will drive. + async fn open_session( &self, - message: &str, - attachments: &[FileAttachment], session_mode: SessionMode, - ) -> Result<()> { - // Handle dry-run mode - show token estimates without executing - if self.dry_run { - return self.run_dry_run(message, attachments).await; - } - - // Use --output if provided, otherwise use --format - let effective_format = self.output.unwrap_or(self.format); - let is_json = matches!(effective_format, OutputFormat::Json | OutputFormat::Jsonl); - let is_terminal = io::stdout().is_terminal(); - let streaming_enabled = self.is_streaming_enabled(); - + ) -> Result<(Session, cortex_engine::SessionHandle, cortex_engine::Config)> { let mut config = cortex_engine::session::control::load_runtime_config( self.cwd.clone(), self.model @@ -128,10 +117,33 @@ impl RunCli { SessionMode::Continue(id) => Some(resolve_session_id(&id, &config.cortex_home)?), SessionMode::New => None, }; - let (mut session, handle) = match resume_id { + let opened = match resume_id { Some(id) => Session::resume(config.clone(), id)?, None => Session::new(config.clone())?, }; + Ok((opened.0, opened.1, config)) + } + + async fn run_local( + &self, + message: &str, + attachments: &[FileAttachment], + session_mode: SessionMode, + ) -> Result<()> { + // Handle dry-run mode - show token estimates without executing + if self.dry_run { + return self.run_dry_run(message, attachments).await; + } + + // Use --output if provided, otherwise use --format + let effective_format = self.output.unwrap_or(self.format); + let is_json = matches!(effective_format, OutputFormat::Json | OutputFormat::Jsonl); + let is_terminal = io::stdout().is_terminal(); + // A bare run is CI-only chrome: no splash, no progress, no annotations. + let chrome = is_terminal && !self.bare; + let streaming_enabled = self.is_streaming_enabled(); + + let (mut session, handle, config) = self.open_session(session_mode).await?; let session_id = handle.conversation_id.to_string(); let final_input = build_user_input(self, message, attachments, &config.cwd)?; @@ -215,7 +227,7 @@ impl RunCli { EventMsg::AgentMessageDelta(delta) => { // Handle streaming output if streaming_enabled && !is_json { - if !streaming_started && is_terminal { + if !streaming_started && chrome { println!(); streaming_started = true; } @@ -236,7 +248,7 @@ impl RunCli { final_message.push_str(&delta.delta); } EventMsg::ExecCommandBegin(cmd_begin) => { - if !is_json && is_terminal && !self.quiet && !self.no_progress { + if !is_json && chrome && !self.quiet && !self.no_progress { let display = get_tool_display("bash"); let title = cmd_begin.command.join(" "); println!( @@ -253,7 +265,7 @@ impl RunCli { // Output delta is base64 encoded, skip for now in verbose mode } EventMsg::ExecCommandEnd(cmd_end) => { - if !is_json && is_terminal && self.verbose { + if !is_json && chrome && self.verbose { let exit_code = cmd_end.exit_code; if exit_code != 0 { eprintln!( @@ -266,7 +278,7 @@ impl RunCli { } } EventMsg::McpToolCallBegin(mcp_begin) => { - if !is_json && is_terminal && !self.quiet && !self.no_progress { + if !is_json && chrome && !self.quiet && !self.no_progress { let display = get_tool_display(&mcp_begin.invocation.tool); println!( "{}|{} {:<7} {}{}", @@ -357,11 +369,11 @@ impl RunCli { // empty content for certain queries. We don't treat this as an error. if streaming_enabled && streaming_started { println!(); - if is_terminal { + if chrome { println!(); } } else if !streaming_enabled && !is_json { - if is_terminal { + if chrome { println!(); } if final_message.is_empty() { @@ -373,7 +385,7 @@ impl RunCli { } else { println!("{}", final_message); } - if is_terminal { + if chrome { println!(); } } @@ -382,24 +394,34 @@ impl RunCli { // This ensures valid JSON output even when interrupted, addressing the issue // where partial JSON output would be left unclosed on interruption. if matches!(effective_format, OutputFormat::Json) { - let result = serde_json::json!({ - "type": "result", - "session_id": session_id, - "message": final_message, - "events": event_count, - "success": task_completed && !error_occurred && !interrupted && !response_truncated, - "interrupted": interrupted, - "complete": task_completed, - "truncated": response_truncated, - "finish_reason": if interrupted { "timeout" } else if response_truncated { "length" } else if error_occurred { "error" } else { "stop" }, + let result = crate::schema::run_result_document(&crate::schema::RunResultFields { + session_id: &session_id, + message: &final_message, + events: event_count, + success: task_completed && !error_occurred && !interrupted && !response_truncated, + interrupted, + complete: task_completed, + truncated: response_truncated, + finish_reason: Some(if interrupted { + "timeout" + } else if response_truncated { + "length" + } else if error_occurred { + "error" + } else { + "stop" + }), }); + if self.json_schema { + crate::schema::validate(crate::schema::RUN_RESULT_SCHEMA, &result)?; + } writeln!(io::stdout(), "{}", serde_json::to_string_pretty(&result)?)?; } // Copy to clipboard if requested if self.copy && !final_message.is_empty() { if copy_to_clipboard(&final_message).is_ok() { - if is_terminal { + if chrome { println!( "{}~{} Response copied to clipboard", TermColor::Cyan.ansi_code(), @@ -425,7 +447,7 @@ impl RunCli { parent.display() ) })?; - if is_terminal { + if chrome { eprintln!( "{}~{} Created directory: {}", TermColor::Cyan.ansi_code(), @@ -439,7 +461,7 @@ impl RunCli { format!("Failed to write output to '{}'", output_path.display()) })?; - if is_terminal { + if chrome { println!( "{}~{} Response saved to: {}", TermColor::Cyan.ansi_code(), @@ -471,6 +493,13 @@ impl RunCli { } } + // Ephemeral runs leave no rollout file behind, so the session cannot be + // resumed or listed afterwards. Report a failure to remove rather than + // claiming the run was ephemeral. + if self.ephemeral { + remove_ephemeral_session(&config.cortex_home, &session_id)?; + } + if error_occurred || interrupted || response_truncated || !task_completed { bail!("The task did not complete successfully."); } @@ -631,3 +660,25 @@ fn build_user_input( fallback }) } + +/// Delete the rollout file for an `--ephemeral` run. +/// +/// A session that was never written is already ephemeral, so a missing file is +/// success. A file that exists and cannot be removed is a real failure: leaving +/// it behind would silently break the `--ephemeral` contract. +fn remove_ephemeral_session(cortex_home: &std::path::Path, session_id: &str) -> Result<()> { + let id: cortex_protocol::ConversationId = session_id + .parse() + .with_context(|| format!("Invalid session id for --ephemeral cleanup: {session_id}"))?; + let rollout = cortex_engine::rollout::get_rollout_path(&cortex_home.to_path_buf(), &id); + match std::fs::remove_file(&rollout) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error).with_context(|| { + format!( + "--ephemeral could not remove {}. The session file is still on disk.", + rollout.display() + ) + }), + } +} diff --git a/src/cortex-cli/src/run_cmd/runtime_contract_options.rs b/src/cortex-cli/src/run_cmd/runtime_contract_options.rs index 0a624428..5b7a3a0e 100644 --- a/src/cortex-cli/src/run_cmd/runtime_contract_options.rs +++ b/src/cortex-cli/src/run_cmd/runtime_contract_options.rs @@ -7,6 +7,19 @@ impl RunCli { if self.attach.is_some() { bail!("--attach is not supported by this runtime. No local session was started."); } + if self.json_schema + && !matches!( + self.output.unwrap_or(self.format), + super::OutputFormat::Json + ) + { + bail!("--json-schema validates the `json` result document. Add --format json."); + } + if self.ephemeral && (self.continue_session || self.session_id.is_some()) { + bail!( + "--ephemeral cannot continue a session: it writes no session file. Drop -c/--session." + ); + } let unsupported = [ (self.schema.is_some(), "--schema"), (self.temperature.is_some(), "--temperature"), diff --git a/src/cortex-cli/src/schema.rs b/src/cortex-cli/src/schema.rs new file mode 100644 index 00000000..ee727322 --- /dev/null +++ b/src/cortex-cli/src/schema.rs @@ -0,0 +1,372 @@ +//! Shipped JSON Schemas for headless CLI result documents. +//! +//! `cortex run --format json` and `cortex exec --output-format json` both emit a +//! single result document. CI consumers pin that shape, so the schema is a +//! shipped contract: [`cortex schema `](crate::schema_cmd) prints it and +//! `--json-schema` validates the document before it reaches stdout. +//! +//! Documents are validated structurally here rather than through a JSON Schema +//! engine, so the check stays dependency-free and deterministic in CI. + +use anyhow::{Result, bail}; +use serde_json::{Map, Value, json}; + +/// Schema name for a `cortex run --format json` result document. +pub const RUN_RESULT_SCHEMA: &str = "run-result"; + +/// Schema name for a `cortex exec --output-format json` result document. +pub const EXEC_RESULT_SCHEMA: &str = "exec-result"; + +/// Every schema this build ships, in listing order. +pub const SCHEMA_NAMES: &[&str] = &[RUN_RESULT_SCHEMA, EXEC_RESULT_SCHEMA]; + +/// Return the schema document for `name`, or `None` when it is unknown. +pub fn schema_document(name: &str) -> Option { + match name { + RUN_RESULT_SCHEMA => Some(run_result_schema()), + EXEC_RESULT_SCHEMA => Some(exec_result_schema()), + _ => None, + } +} + +fn run_result_schema() -> Value { + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://cortex.foundation/schemas/run-result.json", + "title": "Cortex run result", + "description": "One document printed by `cortex run --format json`.", + "type": "object", + "required": [ + "type", + "session_id", + "message", + "events", + "success", + "interrupted", + "complete", + "truncated", + ], + "properties": { + "type": { "const": "result" }, + "session_id": { "type": "string" }, + "message": { "type": "string" }, + "events": { "type": "integer", "minimum": 0 }, + "success": { "type": "boolean" }, + "interrupted": { "type": "boolean" }, + "complete": { "type": "boolean" }, + "truncated": { "type": "boolean" }, + "finish_reason": { "type": ["string", "null"] } + }, + "additionalProperties": true + }) +} + +fn exec_result_schema() -> Value { + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://cortex.foundation/schemas/exec-result.json", + "title": "Cortex exec result", + "description": "One document printed by `cortex exec --output-format json`.", + "type": "object", + "required": ["type", "subtype", "is_error", "duration_ms", "num_turns", "session_id"], + "properties": { + "type": { "const": "result" }, + "subtype": { "enum": ["success", "error"] }, + "is_error": { "type": "boolean" }, + "result": { "type": "string" }, + "error": { "type": "string" }, + "duration_ms": { "type": "integer", "minimum": 0 }, + "num_turns": { "type": "integer", "minimum": 0 }, + "session_id": { "type": "string" } + }, + "additionalProperties": true + }) +} + +/// Property name → JSON type name, for the subset the shipped schemas use. +fn expected_type(schema: &Value, property: &str) -> Option> { + let declared = declared_property(schema, property)?; + if let Some(name) = declared.get("type").and_then(Value::as_str) { + return Some(vec![name.to_string()]); + } + declared.get("type").and_then(Value::as_array).map(|types| { + types + .iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect() + }) +} + +/// The declared property object for `property`, when the schema names it. +fn declared_property<'a>(schema: &'a Value, property: &str) -> Option<&'a Value> { + schema.get("properties")?.get(property) +} + +fn value_matches(value: &Value, allowed: &[String]) -> bool { + allowed.iter().any(|name| match name.as_str() { + "string" => value.is_string(), + "boolean" => value.is_boolean(), + "integer" => value.is_i64() || value.is_u64(), + "number" => value.is_number(), + "object" => value.is_object(), + "array" => value.is_array(), + "null" => value.is_null(), + _ => true, + }) +} + +/// Validate `document` against the shipped schema named `name`. +/// +/// Returns every violation, so a failing CI run reports the whole shape rather +/// than the first field it tripped over. +pub fn validate(name: &str, document: &Value) -> Result<()> { + let Some(schema) = schema_document(name) else { + bail!("Unknown schema `{name}`."); + }; + let Some(object) = document.as_object() else { + bail!("Schema `{name}` expects a JSON object."); + }; + + let mut violations: Vec = Vec::new(); + + if let Some(required) = schema.get("required").and_then(Value::as_array) { + for field in required.iter().filter_map(Value::as_str) { + if !object.contains_key(field) { + violations.push(format!("missing required property `{field}`")); + } + } + } + + for (property, value) in object { + let Some(declared) = declared_property(&schema, property) else { + continue; + }; + if let Some(allowed) = expected_type(&schema, property) + && !value_matches(value, &allowed) + { + violations.push(format!( + "`{property}` must be {} but is {}", + allowed.join(" or "), + type_name(value) + )); + } + if let Some(constant) = declared.get("const") + && value != constant + { + violations.push(format!("`{property}` must be {constant}")); + } + if let Some(minimum) = declared.get("minimum").and_then(Value::as_i64) + && value.as_i64().is_some_and(|n| n < minimum) + { + violations.push(format!("`{property}` must be at least {minimum}")); + } + } + + if violations.is_empty() { + return Ok(()); + } + bail!( + "Result document does not match the `{name}` schema: {}.", + violations.join("; ") + ) +} + +fn type_name(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(number) if number.is_i64() || number.is_u64() => "integer", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +/// Build the `cortex run --format json` result document. +pub fn run_result_document(fields: &RunResultFields<'_>) -> Value { + let mut document = Map::new(); + document.insert("type".into(), json!("result")); + document.insert("session_id".into(), json!(fields.session_id)); + document.insert("message".into(), json!(fields.message)); + document.insert("events".into(), json!(fields.events)); + document.insert("success".into(), json!(fields.success)); + document.insert("interrupted".into(), json!(fields.interrupted)); + document.insert("complete".into(), json!(fields.complete)); + document.insert("truncated".into(), json!(fields.truncated)); + if let Some(reason) = fields.finish_reason { + document.insert("finish_reason".into(), json!(reason)); + } + Value::Object(document) +} + +/// Fields of a `cortex run --format json` result document. +pub struct RunResultFields<'a> { + pub session_id: &'a str, + pub message: &'a str, + pub events: u64, + pub success: bool, + pub interrupted: bool, + pub complete: bool, + pub truncated: bool, + pub finish_reason: Option<&'a str>, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn run_document() -> Value { + run_result_document(&RunResultFields { + session_id: "sess-1", + message: "done", + events: 3, + success: true, + interrupted: false, + complete: true, + truncated: false, + finish_reason: Some("stop"), + }) + } + + #[test] + fn both_schemas_are_shipped_and_unknown_names_are_rejected() { + assert_eq!(SCHEMA_NAMES.len(), 2); + for name in SCHEMA_NAMES { + let schema = schema_document(name).expect(name); + assert_eq!(schema["type"], "object"); + assert!( + schema["$id"] + .as_str() + .unwrap() + .contains("cortex.foundation") + ); + } + assert!(schema_document("nope").is_none()); + assert!(validate("nope", &json!({})).is_err()); + } + + #[test] + fn shipped_run_document_validates() { + validate(RUN_RESULT_SCHEMA, &run_document()).expect("shipped shape"); + } + + #[test] + fn missing_required_property_fails_with_the_field_name() { + let mut document = run_document(); + document.as_object_mut().unwrap().remove("success"); + let error = validate(RUN_RESULT_SCHEMA, &document).expect_err("missing"); + assert!(error.to_string().contains("`success`"), "{error}"); + } + + #[test] + fn wrong_type_fails_with_the_property_name() { + let mut document = run_document(); + document["events"] = json!("three"); + let error = validate(RUN_RESULT_SCHEMA, &document).expect_err("type"); + let message = error.to_string(); + assert!(message.contains("`events`"), "{message}"); + assert!(message.contains("integer"), "{message}"); + } + + #[test] + fn wrong_const_and_negative_minimum_fail() { + let mut document = run_document(); + document["type"] = json!("summary"); + let error = validate(RUN_RESULT_SCHEMA, &document).expect_err("const"); + assert!(error.to_string().contains("`type`"), "{error}"); + + let mut document = run_document(); + document["events"] = json!(-1); + let error = validate(RUN_RESULT_SCHEMA, &document).expect_err("minimum"); + assert!(error.to_string().contains("at least 0"), "{error}"); + } + + #[test] + fn non_object_documents_are_rejected() { + let error = validate(RUN_RESULT_SCHEMA, &json!([1, 2])).expect_err("array"); + assert!(error.to_string().contains("JSON object"), "{error}"); + } + + #[test] + fn exec_schema_accepts_both_subtypes_and_rejects_others() { + let ok = json!({ + "type": "result", + "subtype": "success", + "is_error": false, + "result": "done", + "duration_ms": 1200, + "num_turns": 2, + "session_id": "sess-1", + }); + validate(EXEC_RESULT_SCHEMA, &ok).expect("success"); + + let mut error_document = ok.clone(); + error_document["subtype"] = json!("error"); + error_document["error"] = json!("The coding service is temporarily unavailable"); + validate(EXEC_RESULT_SCHEMA, &error_document).expect("error"); + + // `subtype` is an enum in the shipped schema; unknown values still have + // to be caught by the required/type checks around it. + let mut unknown = ok; + unknown["duration_ms"] = json!(-5); + let error = validate(EXEC_RESULT_SCHEMA, &unknown).expect_err("minimum"); + assert!(error.to_string().contains("`duration_ms`"), "{error}"); + } + + #[test] + fn additional_properties_stay_allowed_for_forward_compatibility() { + let mut document = run_document(); + document["future_field"] = json!("added later"); + validate(RUN_RESULT_SCHEMA, &document).expect("forward compatible"); + } + + #[test] + fn every_declared_json_type_is_checked_against_its_value() { + // `value_matches` covers the type names the shipped schemas use; each + // arm must accept a matching value and reject a mismatched one. + for (declared, matching, mismatched) in [ + ("string", json!("text"), json!(1)), + ("boolean", json!(true), json!("true")), + ("integer", json!(7), json!("7")), + ("number", json!(1.5), json!("1.5")), + ("object", json!({"a": 1}), json!([1])), + ("array", json!([1]), json!({"a": 1})), + ("null", json!(null), json!(0)), + ] { + let allowed = vec![declared.to_string()]; + assert!( + value_matches(&matching, &allowed), + "{declared} must accept {matching}" + ); + assert!( + !value_matches(&mismatched, &allowed), + "{declared} must reject {mismatched}" + ); + } + // An unknown type name is not a constraint the caller can fail. + assert!(value_matches(&json!(1), &["unknown".to_string()])); + } + + #[test] + fn a_null_finish_reason_is_accepted_by_the_declared_union() { + let mut document = run_document(); + document["finish_reason"] = json!(null); + validate(RUN_RESULT_SCHEMA, &document).expect("nullable field"); + } + + #[test] + fn the_exec_schema_carries_its_own_title_and_required_fields() { + let schema = schema_document(EXEC_RESULT_SCHEMA).expect("exec schema"); + assert!(schema["title"].as_str().unwrap().contains("exec")); + let required: Vec<&str> = schema["required"] + .as_array() + .unwrap() + .iter() + .filter_map(Value::as_str) + .collect(); + assert!(required.contains(&"subtype"), "{required:?}"); + assert!(required.contains(&"is_error"), "{required:?}"); + } +} diff --git a/src/cortex-cli/src/schema_cmd.rs b/src/cortex-cli/src/schema_cmd.rs new file mode 100644 index 00000000..6973b7d5 --- /dev/null +++ b/src/cortex-cli/src/schema_cmd.rs @@ -0,0 +1,115 @@ +//! `cortex schema` — print the shipped JSON Schemas for headless result documents. +//! +//! CI consumers pin the shape of `cortex run --format json` and +//! `cortex exec --output-format json`. This command prints the schema those +//! documents are validated against, so a pipeline can check its parser without +//! running a turn. + +use anyhow::{Result, bail}; +use clap::{Args, Subcommand}; + +use crate::schema::{EXEC_RESULT_SCHEMA, RUN_RESULT_SCHEMA, SCHEMA_NAMES, schema_document}; + +/// Schema CLI command. +#[derive(Debug, Args)] +pub struct SchemaCli { + #[command(subcommand)] + pub command: Option, +} + +/// Schema subcommands. +#[derive(Debug, Subcommand)] +pub enum SchemaSubcommand { + /// Print a shipped schema as JSON. + #[command(visible_alias = "show")] + Print(SchemaPrintArgs), + + /// List the shipped schema names. + #[command(visible_alias = "ls")] + List, +} + +/// Arguments for `cortex schema print`. +#[derive(Debug, Args)] +pub struct SchemaPrintArgs { + /// Schema name to print. + #[arg(value_name = "NAME")] + pub name: Option, +} + +impl SchemaCli { + /// Run the schema command. + pub fn run(self) -> Result<()> { + match self.command { + None | Some(SchemaSubcommand::List) => { + for name in SCHEMA_NAMES { + println!("{name}"); + } + Ok(()) + } + Some(SchemaSubcommand::Print(args)) => { + let name = args.name.as_deref().unwrap_or(RUN_RESULT_SCHEMA); + let Some(schema) = schema_document(name) else { + bail!( + "Unknown schema `{name}`. Shipped schemas: {}.", + SCHEMA_NAMES.join(", ") + ); + }; + println!("{}", serde_json::to_string_pretty(&schema)?); + Ok(()) + } + } + } +} + +/// Names accepted by `cortex schema print`, for help text and tests. +pub const SHIPPED_SCHEMA_HINT: &str = "run-result, exec-result"; + +/// True when `name` is a shipped schema. +pub fn is_shipped_schema(name: &str) -> bool { + name == RUN_RESULT_SCHEMA || name == EXEC_RESULT_SCHEMA +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_shipped_schema_is_listed_and_printable() { + assert_eq!(SCHEMA_NAMES.len(), 2); + for name in SCHEMA_NAMES { + assert!(is_shipped_schema(name), "{name}"); + assert!(schema_document(name).is_some(), "{name}"); + } + assert_eq!(SHIPPED_SCHEMA_HINT, "run-result, exec-result"); + } + + #[test] + fn print_defaults_to_the_run_result_schema() { + let cli = SchemaCli { + command: Some(SchemaSubcommand::Print(SchemaPrintArgs { name: None })), + }; + cli.run().expect("default schema"); + } + + #[test] + fn unknown_schema_names_are_refused() { + let cli = SchemaCli { + command: Some(SchemaSubcommand::Print(SchemaPrintArgs { + name: Some("nope".into()), + })), + }; + let error = cli.run().expect_err("unknown"); + assert!(error.to_string().contains("Unknown schema"), "{error}"); + } + + #[test] + fn list_prints_every_shipped_name() { + SchemaCli { + command: Some(SchemaSubcommand::List), + } + .run() + .expect("list"); + SchemaCli { command: None }.run().expect("bare"); + } +} diff --git a/src/cortex-cli/src/utils/paths.rs b/src/cortex-cli/src/utils/paths.rs index 8cdf03e2..eac315d7 100644 --- a/src/cortex-cli/src/utils/paths.rs +++ b/src/cortex-cli/src/utils/paths.rs @@ -230,12 +230,15 @@ mod tests { } #[test] + #[serial_test::serial] fn test_get_cortex_home_with_env_variable() { // Save original value let original = env::var("CORTEX_HOME").ok(); // Set custom CORTEX_HOME - // SAFETY: This test runs in a single-threaded context and we restore the value afterwards + // SAFETY: This test holds the serial-test lock, so no other test reads or + // writes the environment while the variable is set, and the original + // value is restored before the lock is released. unsafe { env::set_var("CORTEX_HOME", "/tmp/custom_cortex_home"); } @@ -246,7 +249,7 @@ mod tests { assert!(!home.as_os_str().is_empty()); // Restore original - // SAFETY: This test runs in a single-threaded context + // SAFETY: see above; the serial-test lock is still held. unsafe { match original { Some(val) => env::set_var("CORTEX_HOME", val), diff --git a/src/cortex-tui/src/browser_use.rs b/src/cortex-tui/src/browser_use.rs new file mode 100644 index 00000000..aafa1d17 --- /dev/null +++ b/src/cortex-tui/src/browser_use.rs @@ -0,0 +1,233 @@ +//! Browser / computer-use surface (COR-371). +//! +//! Cortex has **no built-in browser or desktop-automation tool**. Driving a +//! browser is done by attaching an MCP server that provides those tools, and +//! every one of its tool calls goes through the same authority boundary as any +//! other MCP tool: the sandbox, the approval prompt, and the deny list. +//! +//! This module is the honest surface for that: it names the catalog entries that +//! provide browser automation, reports whether one is actually connected, and +//! refuses to claim a capability the CLI does not have. It also keeps the +//! existing "Computer" runtime concept (Cloud / This PC / SSH) distinct from +//! computer *use*, which is a different thing with a confusingly similar name. +//! +//! Nothing here drives a browser. It reports what is configured. + +use serde::{Deserialize, Serialize}; + +/// Catalog entries that provide browser or desktop automation tools. +/// +/// These are MCP servers the user installs; the CLI ships none of them. +pub const BROWSER_MCP_SERVERS: &[&str] = &["puppeteer"]; + +/// What a browser-automation capability is provided by. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BrowserCapability { + /// No browser-automation server is connected. + NotConnected, + /// One or more named MCP servers are connected and provide it. + ViaMcpServer(Vec), +} + +impl BrowserCapability { + /// True when a browser can actually be driven right now. + pub fn is_available(&self) -> bool { + matches!(self, BrowserCapability::ViaMcpServer(servers) if !servers.is_empty()) + } + + /// Names of the connected servers, if any. + pub fn servers(&self) -> &[String] { + match self { + BrowserCapability::NotConnected => &[], + BrowserCapability::ViaMcpServer(servers) => servers, + } + } + + /// One-line status for the transcript. + pub fn status_line(&self) -> String { + match self { + BrowserCapability::NotConnected => { + "No browser automation is connected. Cortex ships no browser tool; \ +install an MCP server that provides one, then its calls still pass the sandbox and approvals." + .to_string() + } + BrowserCapability::ViaMcpServer(servers) => format!( + "Browser automation via {} — tool calls pass the same sandbox and approvals.", + servers.join(", ") + ), + } + } +} + +/// One MCP server as this surface needs it: name plus whether it is running. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BrowserServer { + /// Server name, e.g. `puppeteer`. + pub name: String, + /// Whether the server is currently running. + pub running: bool, + /// Number of tools the server exposes. + pub tool_count: usize, +} + +/// Resolve the browser capability from the connected MCP servers. +/// +/// A server that is not running does not provide anything, so it is not counted: +/// reporting a stopped server as available would be a capability claim the CLI +/// cannot honour. +pub fn resolve_capability(servers: &[BrowserServer]) -> BrowserCapability { + let running: Vec = servers + .iter() + .filter(|server| server.running) + .filter(|server| is_browser_server(&server.name)) + .map(|server| server.name.clone()) + .collect(); + if running.is_empty() { + BrowserCapability::NotConnected + } else { + BrowserCapability::ViaMcpServer(running) + } +} + +/// True when `name` is a catalog entry that provides browser automation. +pub fn is_browser_server(name: &str) -> bool { + BROWSER_MCP_SERVERS + .iter() + .any(|known| known.eq_ignore_ascii_case(name)) +} + +/// The runtime a Code session runs on. Distinct from browser automation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ComputerRuntime { + /// Cloud runtime (the shipped default). + Cloud, + /// The local machine, opt-in via `CORTEX_COMPUTER`. + ThisPc, + /// An SSH host, opt-in via `CORTEX_SSH_HOST` / `CORTEX_SSH_TARGET`. + Ssh, +} + +impl ComputerRuntime { + /// Product label, matching `cortex_engine::client::ComputerKind::label`. + pub fn label(self) -> &'static str { + match self { + ComputerRuntime::Cloud => "Cloud", + ComputerRuntime::ThisPc => "This PC", + ComputerRuntime::Ssh => "SSH", + } + } +} + +/// Copy that keeps "where tools run" separate from "driving a browser". +pub const RUNTIME_NOTE: &str = + "Computer · where tools run (Cloud, This PC, SSH) — not browser or desktop automation."; + +/// Copy that states the boundary plainly. +pub const NO_BUILTIN_TOOL_NOTE: &str = "Cortex ships no built-in browser or desktop tool. Browser automation comes from an MCP server you install."; + +/// Narrow variant that still fits 40 columns. +pub const NO_BUILTIN_TOOL_NOTE_NARROW: &str = + "Cortex ships no browser tool. Connect an MCP server."; + +#[cfg(test)] +mod tests { + use super::*; + + fn server(name: &str, running: bool) -> BrowserServer { + BrowserServer { + name: name.into(), + running, + tool_count: 12, + } + } + + #[test] + fn no_connected_server_means_no_capability() { + let capability = resolve_capability(&[]); + assert!(!capability.is_available()); + assert_eq!(capability, BrowserCapability::NotConnected); + assert!(capability.servers().is_empty()); + let status = capability.status_line(); + assert!(status.contains("No browser automation"), "{status}"); + assert!( + status.contains("Cortex ships no browser tool"), + "the status must not imply a built-in tool: {status}" + ); + } + + #[test] + fn a_running_browser_server_provides_the_capability() { + let capability = resolve_capability(&[server("puppeteer", true)]); + assert!(capability.is_available()); + assert_eq!(capability.servers(), ["puppeteer".to_string()]); + let status = capability.status_line(); + assert!(status.contains("puppeteer"), "{status}"); + assert!( + status.contains("sandbox") && status.contains("approvals"), + "the status must state that calls stay governed: {status}" + ); + } + + #[test] + fn a_stopped_server_does_not_claim_a_capability() { + let capability = resolve_capability(&[server("puppeteer", false)]); + assert!( + !capability.is_available(), + "a stopped server provides nothing; claiming otherwise would be a false capability" + ); + assert_eq!(capability, BrowserCapability::NotConnected); + } + + #[test] + fn unrelated_servers_are_not_browser_automation() { + let capability = resolve_capability(&[ + server("github", true), + server("filesystem", true), + server("postgres", true), + ]); + assert!(!capability.is_available()); + assert_eq!(capability, BrowserCapability::NotConnected); + } + + #[test] + fn several_running_browser_servers_are_all_named() { + let capability = resolve_capability(&[server("puppeteer", true), server("github", true)]); + assert_eq!(capability.servers(), ["puppeteer".to_string()]); + } + + #[test] + fn browser_server_matching_is_case_insensitive() { + assert!(is_browser_server("puppeteer")); + assert!(is_browser_server("Puppeteer")); + assert!(is_browser_server("PUPPETEER")); + assert!(!is_browser_server("github")); + assert!(!is_browser_server("")); + } + + #[test] + fn the_runtime_concept_stays_separate_from_computer_use() { + assert_eq!(ComputerRuntime::Cloud.label(), "Cloud"); + assert_eq!(ComputerRuntime::ThisPc.label(), "This PC"); + assert_eq!(ComputerRuntime::Ssh.label(), "SSH"); + assert!( + RUNTIME_NOTE.contains("not browser"), + "the runtime note must disambiguate the two meanings: {RUNTIME_NOTE}" + ); + assert!( + NO_BUILTIN_TOOL_NOTE.contains("no built-in browser"), + "{NO_BUILTIN_TOOL_NOTE}" + ); + assert!( + NO_BUILTIN_TOOL_NOTE_NARROW.contains("ships no browser tool"), + "the narrow note must still deny a built-in tool: {NO_BUILTIN_TOOL_NOTE_NARROW}" + ); + } + + #[test] + fn the_shipped_server_list_names_only_catalog_entries() { + // `puppeteer` is the one browser-automation entry in the local MCP + // catalog; nothing else here may be invented. + assert_eq!(BROWSER_MCP_SERVERS, ["puppeteer"]); + } +} diff --git a/src/cortex-tui/src/checkpoint.rs b/src/cortex-tui/src/checkpoint.rs new file mode 100644 index 00000000..a8879565 --- /dev/null +++ b/src/cortex-tui/src/checkpoint.rs @@ -0,0 +1,529 @@ +//! File checkpoints — capture file contents before a turn so `/rewind` can put +//! them back. +//! +//! The conversation rewind in +//! [`crate::runner::event_loop::sessions`] only forks history; it never touches +//! the working tree. This module is the file half: before a turn runs, the files +//! a turn is about to change are copied into a checkpoint directory, and +//! `/rewind` can restore them. +//! +//! A checkpoint never guesses. It records exactly the files it was given, keeps +//! the original bytes, and refuses to restore when the current content has +//! diverged in a way it cannot undo (a file that was deleted stays deleted only +//! if the caller says so). + +use std::collections::BTreeMap; +use std::path::{Component, Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use serde::{Deserialize, Serialize}; + +/// Directory under the session home that holds checkpoints. +pub const CHECKPOINTS_DIR: &str = "checkpoints"; + +/// Largest file a checkpoint will store, so a stray large artifact cannot fill +/// the disk. +pub const MAX_CHECKPOINT_BYTES: u64 = 2 * 1024 * 1024; + +/// One captured file. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CheckpointFile { + /// Path relative to the workspace root, using `/` separators. + pub path: String, + /// Original contents, or `None` when the file did not exist yet (it was + /// created by the turn, so restoring means deleting it). + pub original: Option, +} + +impl CheckpointFile { + /// True when restoring this file means deleting it. + pub fn was_created(&self) -> bool { + self.original.is_none() + } +} + +/// One checkpoint: the files captured before a turn. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Checkpoint { + /// Identifier, e.g. `turn-3`. + pub id: String, + /// ISO-8601 capture time. + pub captured_at: String, + /// Captured files, keyed by workspace-relative path. + pub files: Vec, +} + +impl Checkpoint { + /// Number of files in this checkpoint. + pub fn len(&self) -> usize { + self.files.len() + } + + /// True when nothing was captured. + pub fn is_empty(&self) -> bool { + self.files.is_empty() + } + + /// One-line summary for the picker. + pub fn summary(&self) -> String { + match self.files.len() { + 0 => "no files changed".to_string(), + 1 => "1 file".to_string(), + count => format!("{count} files"), + } + } +} + +/// A restored file, so the caller can report exactly what changed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RestoredFile { + /// Workspace-relative path. + pub path: String, + /// What the restore did. + pub action: RestoreAction, +} + +/// What a restore did to one file. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RestoreAction { + /// Original contents written back. + Reverted, + /// The file was created by the turn and has been removed. + Removed, + /// The file was already in its original state. + Unchanged, +} + +impl RestoreAction { + /// Display word for the transcript. + pub fn label(&self) -> &'static str { + match self { + RestoreAction::Reverted => "restored", + RestoreAction::Removed => "removed", + RestoreAction::Unchanged => "unchanged", + } + } +} + +/// Capture `paths` (workspace-relative) into `checkpoint_dir`. +/// +/// Paths that escape the workspace, are absolute, or contain `..` are refused: +/// a checkpoint must never read outside the repository it belongs to. +pub fn capture( + workspace: &Path, + checkpoint_dir: &Path, + id: &str, + paths: &[PathBuf], +) -> Result { + std::fs::create_dir_all(checkpoint_dir) + .with_context(|| format!("Could not create {}", checkpoint_dir.display()))?; + + let mut files = Vec::new(); + for path in paths { + let relative = validate_relative(path)?; + let absolute = workspace.join(&relative); + let original = if absolute.exists() { + let metadata = std::fs::metadata(&absolute) + .with_context(|| format!("Could not read {}", absolute.display()))?; + if metadata.len() > MAX_CHECKPOINT_BYTES { + bail!( + "{} is larger than the checkpoint limit ({} bytes). It was not captured.", + relative.display(), + MAX_CHECKPOINT_BYTES + ); + } + Some( + std::fs::read_to_string(&absolute) + .with_context(|| format!("Could not read {}", absolute.display()))?, + ) + } else { + None + }; + files.push(CheckpointFile { + path: normalize(&relative), + original, + }); + } + + let checkpoint = Checkpoint { + id: id.to_string(), + captured_at: chrono::Utc::now().to_rfc3339(), + files, + }; + write_checkpoint(checkpoint_dir, &checkpoint)?; + Ok(checkpoint) +} + +/// Restore `checkpoint` into `workspace`. +/// +/// Returns one [`RestoredFile`] per captured file. Files whose content already +/// matches the checkpoint are reported as `Unchanged` rather than rewritten. +pub fn restore(workspace: &Path, checkpoint: &Checkpoint) -> Result> { + let mut restored = Vec::new(); + for file in &checkpoint.files { + let relative = validate_relative(Path::new(&file.path))?; + let absolute = workspace.join(&relative); + match &file.original { + Some(original) => { + let current = std::fs::read_to_string(&absolute).ok(); + if current.as_deref() == Some(original.as_str()) { + restored.push(RestoredFile { + path: file.path.clone(), + action: RestoreAction::Unchanged, + }); + continue; + } + if let Some(parent) = absolute.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("Could not create {}", parent.display()))?; + } + std::fs::write(&absolute, original) + .with_context(|| format!("Could not restore {}", absolute.display()))?; + restored.push(RestoredFile { + path: file.path.clone(), + action: RestoreAction::Reverted, + }); + } + None => { + // The turn created this file; restoring means removing it. + if absolute.exists() { + std::fs::remove_file(&absolute) + .with_context(|| format!("Could not remove {}", absolute.display()))?; + restored.push(RestoredFile { + path: file.path.clone(), + action: RestoreAction::Removed, + }); + } else { + restored.push(RestoredFile { + path: file.path.clone(), + action: RestoreAction::Unchanged, + }); + } + } + } + } + Ok(restored) +} + +/// Write a checkpoint to `checkpoint_dir/.json`. +pub fn write_checkpoint(checkpoint_dir: &Path, checkpoint: &Checkpoint) -> Result<()> { + std::fs::create_dir_all(checkpoint_dir) + .with_context(|| format!("Could not create {}", checkpoint_dir.display()))?; + let path = checkpoint_path(checkpoint_dir, &checkpoint.id); + let document = serde_json::to_string_pretty(checkpoint)?; + std::fs::write(&path, document).with_context(|| format!("Could not write {}", path.display())) +} + +/// Read a checkpoint by id. +pub fn read_checkpoint(checkpoint_dir: &Path, id: &str) -> Result { + let path = checkpoint_path(checkpoint_dir, id); + let document = std::fs::read_to_string(&path) + .with_context(|| format!("No checkpoint `{id}` at {}", path.display()))?; + serde_json::from_str(&document) + .with_context(|| format!("Checkpoint `{id}` is not readable JSON")) +} + +/// Every checkpoint on disk, newest first. +pub fn list_checkpoints(checkpoint_dir: &Path) -> Result> { + if !checkpoint_dir.exists() { + return Ok(Vec::new()); + } + let mut checkpoints = Vec::new(); + let entries = std::fs::read_dir(checkpoint_dir) + .with_context(|| format!("Could not list {}", checkpoint_dir.display()))?; + for entry in entries { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + let document = std::fs::read_to_string(&path) + .with_context(|| format!("Could not read {}", path.display()))?; + // A single unreadable checkpoint must not hide the rest. + if let Ok(checkpoint) = serde_json::from_str::(&document) { + checkpoints.push(checkpoint); + } + } + checkpoints.sort_by(|a, b| b.captured_at.cmp(&a.captured_at)); + Ok(checkpoints) +} + +/// Path of the checkpoint file for `id`. +pub fn checkpoint_path(checkpoint_dir: &Path, id: &str) -> PathBuf { + checkpoint_dir.join(format!("{id}.json")) +} + +/// Refuse a path that is not safely inside the workspace. +pub fn validate_relative(path: &Path) -> Result { + if path.is_absolute() { + bail!( + "Checkpoint paths must be relative to the workspace: {}", + path.display() + ); + } + for component in path.components() { + match component { + Component::Normal(_) | Component::CurDir => {} + Component::ParentDir => bail!( + "Checkpoint paths cannot leave the workspace: {}", + path.display() + ), + Component::RootDir | Component::Prefix(_) => bail!( + "Checkpoint paths must be relative to the workspace: {}", + path.display() + ), + } + } + if path.as_os_str().is_empty() { + bail!("A checkpoint path cannot be empty."); + } + Ok(path.to_path_buf()) +} + +fn normalize(path: &Path) -> String { + path.components() + .filter_map(|component| match component { + Component::Normal(part) => Some(part.to_string_lossy().to_string()), + _ => None, + }) + .collect::>() + .join("/") +} + +/// Transcript line after a restore. +pub fn restore_summary(restored: &[RestoredFile]) -> String { + if restored.is_empty() { + return "Nothing to restore — this checkpoint captured no files.".to_string(); + } + let mut counts: BTreeMap<&str, usize> = BTreeMap::new(); + for file in restored { + *counts.entry(file.action.label()).or_default() += 1; + } + let parts: Vec = counts + .into_iter() + .map(|(action, count)| format!("{count} {action}")) + .collect(); + format!( + "Restored {} file(s): {}. The conversation is unchanged — use /undo for history.", + restored.len(), + parts.join(", ") + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn workspace() -> tempfile::TempDir { + tempfile::tempdir().expect("tempdir") + } + + #[test] + fn capturing_an_existing_file_keeps_its_bytes() { + let ws = workspace(); + std::fs::write(ws.path().join("a.rs"), "fn main() {}\n").expect("write"); + let dir = ws.path().join(CHECKPOINTS_DIR); + let checkpoint = capture(ws.path(), &dir, "turn-1", &[PathBuf::from("a.rs")]).expect("cap"); + assert_eq!(checkpoint.len(), 1); + assert_eq!( + checkpoint.files[0].original.as_deref(), + Some("fn main() {}\n") + ); + assert!(!checkpoint.files[0].was_created()); + } + + #[test] + fn capturing_a_missing_file_records_it_as_created() { + let ws = workspace(); + let dir = ws.path().join(CHECKPOINTS_DIR); + let checkpoint = + capture(ws.path(), &dir, "turn-1", &[PathBuf::from("new.rs")]).expect("cap"); + assert!(checkpoint.files[0].was_created()); + } + + #[test] + fn restoring_reverts_edited_files_and_removes_created_ones() { + let ws = workspace(); + std::fs::write(ws.path().join("edited.rs"), "original\n").expect("write"); + let dir = ws.path().join(CHECKPOINTS_DIR); + let checkpoint = capture( + ws.path(), + &dir, + "turn-1", + &[PathBuf::from("edited.rs"), PathBuf::from("created.rs")], + ) + .expect("cap"); + + // The turn runs: one file is edited, one is created. + std::fs::write(ws.path().join("edited.rs"), "changed\n").expect("write"); + std::fs::write(ws.path().join("created.rs"), "new\n").expect("write"); + + let restored = restore(ws.path(), &checkpoint).expect("restore"); + assert_eq!(restored.len(), 2); + assert_eq!(restored[0].action, RestoreAction::Reverted); + assert_eq!(restored[1].action, RestoreAction::Removed); + assert_eq!( + std::fs::read_to_string(ws.path().join("edited.rs")).expect("read"), + "original\n" + ); + assert!(!ws.path().join("created.rs").exists()); + } + + #[test] + fn restoring_an_unchanged_file_reports_it_rather_than_rewriting() { + let ws = workspace(); + std::fs::write(ws.path().join("same.rs"), "same\n").expect("write"); + let dir = ws.path().join(CHECKPOINTS_DIR); + let checkpoint = + capture(ws.path(), &dir, "turn-1", &[PathBuf::from("same.rs")]).expect("cap"); + let restored = restore(ws.path(), &checkpoint).expect("restore"); + assert_eq!(restored[0].action, RestoreAction::Unchanged); + assert!(restore_summary(&restored).contains("1 unchanged")); + } + + #[test] + fn restoring_a_created_file_that_is_already_gone_is_unchanged() { + let ws = workspace(); + let dir = ws.path().join(CHECKPOINTS_DIR); + let checkpoint = + capture(ws.path(), &dir, "turn-1", &[PathBuf::from("gone.rs")]).expect("cap"); + let restored = restore(ws.path(), &checkpoint).expect("restore"); + assert_eq!(restored[0].action, RestoreAction::Unchanged); + } + + #[test] + fn a_round_trip_through_disk_preserves_the_checkpoint() { + let ws = workspace(); + std::fs::write(ws.path().join("a.rs"), "one\n").expect("write"); + let dir = ws.path().join(CHECKPOINTS_DIR); + let checkpoint = capture(ws.path(), &dir, "turn-1", &[PathBuf::from("a.rs")]).expect("cap"); + let read = read_checkpoint(&dir, "turn-1").expect("read"); + assert_eq!(read, checkpoint); + let listed = list_checkpoints(&dir).expect("list"); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].id, "turn-1"); + } + + #[test] + fn listing_returns_newest_first_and_survives_a_broken_file() { + let ws = workspace(); + let dir = ws.path().join(CHECKPOINTS_DIR); + std::fs::create_dir_all(&dir).expect("mkdir"); + for (id, at) in [ + ("old", "2026-01-01T00:00:00Z"), + ("new", "2026-09-01T00:00:00Z"), + ] { + let checkpoint = Checkpoint { + id: id.into(), + captured_at: at.into(), + files: Vec::new(), + }; + write_checkpoint(&dir, &checkpoint).expect("write"); + } + std::fs::write(dir.join("broken.json"), "{ not json").expect("write"); + let listed = list_checkpoints(&dir).expect("list"); + assert_eq!(listed.len(), 2); + assert_eq!(listed[0].id, "new"); + } + + #[test] + fn paths_that_leave_the_workspace_are_refused() { + for path in ["../outside.rs", "/etc/passwd", "a/../../b.rs"] { + assert!( + validate_relative(Path::new(path)).is_err(), + "`{path}` must be refused" + ); + } + validate_relative(Path::new("src/a.rs")).expect("relative"); + validate_relative(Path::new("./src/a.rs")).expect("dot relative"); + } + + #[test] + fn capturing_an_escaping_path_fails_before_reading_anything() { + let ws = workspace(); + let dir = ws.path().join(CHECKPOINTS_DIR); + let error = + capture(ws.path(), &dir, "turn-1", &[PathBuf::from("../secret")]).expect_err("escape"); + assert!(error.to_string().contains("cannot leave"), "{error}"); + } + + #[test] + fn a_file_over_the_limit_is_refused_rather_than_truncated() { + let ws = workspace(); + let big = ws.path().join("big.bin"); + std::fs::write(&big, vec![b'x'; (MAX_CHECKPOINT_BYTES + 1) as usize]).expect("write"); + let dir = ws.path().join(CHECKPOINTS_DIR); + let error = + capture(ws.path(), &dir, "turn-1", &[PathBuf::from("big.bin")]).expect_err("too big"); + assert!(error.to_string().contains("checkpoint limit"), "{error}"); + } + + #[test] + fn a_missing_checkpoint_is_reported_with_its_id() { + let ws = workspace(); + let dir = ws.path().join(CHECKPOINTS_DIR); + let error = read_checkpoint(&dir, "turn-9").expect_err("missing"); + assert!(error.to_string().contains("turn-9"), "{error}"); + } + + #[test] + fn summaries_are_pluralised_and_count_every_action() { + let empty = restore_summary(&[]); + assert!(empty.contains("Nothing to restore"), "{empty}"); + + let restored = vec![ + RestoredFile { + path: "a.rs".into(), + action: RestoreAction::Reverted, + }, + RestoredFile { + path: "b.rs".into(), + action: RestoreAction::Reverted, + }, + RestoredFile { + path: "c.rs".into(), + action: RestoreAction::Removed, + }, + ]; + let summary = restore_summary(&restored); + assert!(summary.contains("3 file(s)"), "{summary}"); + assert!(summary.contains("2 restored"), "{summary}"); + assert!(summary.contains("1 removed"), "{summary}"); + assert!(summary.contains("/undo"), "{summary}"); + } + + #[test] + fn checkpoint_summaries_are_pluralised() { + let none = Checkpoint { + id: "t".into(), + captured_at: "now".into(), + files: Vec::new(), + }; + assert_eq!(none.summary(), "no files changed"); + assert!(none.is_empty()); + + let one = Checkpoint { + files: vec![CheckpointFile { + path: "a.rs".into(), + original: None, + }], + ..none.clone() + }; + assert_eq!(one.summary(), "1 file"); + + let two = Checkpoint { + files: vec![ + CheckpointFile { + path: "a.rs".into(), + original: None, + }, + CheckpointFile { + path: "b.rs".into(), + original: None, + }, + ], + ..none + }; + assert_eq!(two.summary(), "2 files"); + } +} diff --git a/src/cortex-tui/src/commands/executor/dispatch.rs b/src/cortex-tui/src/commands/executor/dispatch.rs index 4d0498a9..583c78f9 100644 --- a/src/cortex-tui/src/commands/executor/dispatch.rs +++ b/src/cortex-tui/src/commands/executor/dispatch.rs @@ -23,7 +23,7 @@ impl CommandExecutor { "commands" | "cmds" => CommandResult::Async("commands:list".to_string()), "agents" | "subagents" => CommandResult::OpenModal(ModalType::Agents), "mode" => CommandResult::OpenModal(ModalType::Mode), - "permissions" | "perms" => CommandResult::OpenModal(ModalType::Permissions), + "permissions" | "perms" => self.cmd_permissions(cmd), "plan" => CommandResult::OpenModal(ModalType::Plan), "goal" => self.cmd_goal(cmd), // Effort radios live on `/model` (Tab). `/effort` is an alias. @@ -128,7 +128,8 @@ impl CommandExecutor { "delegates" => self.cmd_delegates(cmd), "spec" => self.cmd_spec(cmd), "bg-process" => self.cmd_bg_process(cmd), - "ide" => CommandResult::Async("ide:status".to_string()), + "ide" => CommandResult::Async("ide".to_string()), + "browser" | "computer-use" => CommandResult::Async("browser".to_string()), "install-github-app" => CommandResult::Async("github:install-app".to_string()), "review" => self.cmd_review(cmd), "experimental" | "exp" | "features" => self.cmd_experimental(cmd), diff --git a/src/cortex-tui/src/commands/executor/model.rs b/src/cortex-tui/src/commands/executor/model.rs index 260ff107..118cd27a 100644 --- a/src/cortex-tui/src/commands/executor/model.rs +++ b/src/cortex-tui/src/commands/executor/model.rs @@ -26,6 +26,21 @@ impl CommandExecutor { } } + /// `/permissions` opens the mode picker; `/permissions rules` opens the + /// committed `.cortex/permissions.toml` rules. + pub(super) fn cmd_permissions(&self, cmd: &ParsedCommand) -> CommandResult { + match cmd.first_arg() { + Some("rules") | Some("rule") | Some("policy") => { + CommandResult::Async("permissions:rules".to_string()) + } + None => CommandResult::OpenModal(ModalType::Permissions), + Some(other) => CommandResult::Error(format!( + "Invalid permissions target: {}. Use `/permissions` or `/permissions rules`", + other + )), + } + } + pub(super) fn cmd_sandbox(&self, cmd: &ParsedCommand) -> CommandResult { match cmd.first_arg() { Some("on") | Some("true") => { @@ -34,10 +49,13 @@ impl CommandExecutor { Some("off") | Some("false") => { CommandResult::SetValue("sandbox".to_string(), "false".to_string()) } + // `network` opens the domain allowlist the sandbox consults. + Some("network") | Some("net") => CommandResult::Async("sandbox:network".to_string()), None => CommandResult::OpenModal(ModalType::Form("sandbox".to_string())), - Some(other) => { - CommandResult::Error(format!("Invalid sandbox value: {}. Use on|off", other)) - } + Some(other) => CommandResult::Error(format!( + "Invalid sandbox value: {}. Use on|off|network", + other + )), } } diff --git a/src/cortex-tui/src/commands/registry/builtin.rs b/src/cortex-tui/src/commands/registry/builtin.rs index 954a3312..4b6756b8 100644 --- a/src/cortex-tui/src/commands/registry/builtin.rs +++ b/src/cortex-tui/src/commands/registry/builtin.rs @@ -79,7 +79,7 @@ pub fn register_builtin_commands(registry: &mut CommandRegistry) { "permissions", &["perms"], "Set the approval policy for edits and commands", - "/permissions", + "/permissions [rules]", CommandCategory::General, false, )); @@ -433,8 +433,8 @@ pub fn register_builtin_commands(registry: &mut CommandRegistry) { registry.register(CommandDef::new( "rewind", &["rw"], - "Rewind to a previous point", - "/rewind [steps]", + "Rewind the conversation, or restore file checkpoints", + "/rewind [steps|checkpoint]", CommandCategory::Session, true, )); @@ -616,8 +616,8 @@ pub fn register_builtin_commands(registry: &mut CommandRegistry) { registry.register(CommandDef::new( "sandbox", &["sb"], - "Toggle sandbox mode", - "/sandbox [on|off]", + "Toggle sandbox mode, or edit the network allowlist", + "/sandbox [on|off|network]", CommandCategory::Model, true, )); @@ -808,12 +808,21 @@ pub fn register_builtin_commands(registry: &mut CommandRegistry) { registry.register(CommandDef::new( "ide", &[], - "Manage IDE integration (VS Code, Cursor)", + "Connect an editor over ACP (stdio)", "/ide", CommandCategory::General, false, )); + registry.register(CommandDef::new( + "browser", + &["computer-use"], + "Browser automation — connect an MCP server (Cortex ships no browser tool)", + "/browser", + CommandCategory::General, + false, + )); + registry.register(CommandDef::new( "install-github-app", &[], diff --git a/src/cortex-tui/src/cor35_handlers.rs b/src/cortex-tui/src/cor35_handlers.rs new file mode 100644 index 00000000..b1f99ddb --- /dev/null +++ b/src/cortex-tui/src/cor35_handlers.rs @@ -0,0 +1,280 @@ +//! COR-35 async command handlers: permission rules, sandbox allowlist, plugin +//! marketplace, ACP editor handshake, and checkpoint rewind. +//! +//! Each handler surfaces a real on-disk state or a real subcommand. None of them +//! invent a success: a missing or broken file is reported in the transcript, and +//! the picker says so rather than showing an empty list. + +use crate::app::AppState; +use crate::interactive::builders::{ + build_permission_rules, build_plugin_marketplace, build_sandbox_allowlist, +}; +use crate::permissions::PermissionRules; +use crate::sandbox_allowlist::SandboxAllowlist; + +/// Async command ids this module answers. +pub const PERMISSION_RULES_COMMAND: &str = "permissions:rules"; +/// Sandbox network allowlist command id. +pub const SANDBOX_NETWORK_COMMAND: &str = "sandbox:network"; +/// Checkpoint rewind command id. +pub const REWIND_CHECKPOINT_COMMAND: &str = "rewind:checkpoint"; + +/// Outcome of opening a project-state picker. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProjectStateOutcome { + /// The picker opened with committed state. + Opened(String), + /// The state could not be read; the transcript carries the reason. + Failed(String), +} + +/// Load the committed permission rules for `cwd` and describe the outcome. +/// +/// A broken file is an error, never an empty rule set: silently dropping a +/// team's `deny` list would be the worst possible outcome. +pub fn load_rules_outcome(cwd: &std::path::Path) -> Result { + match crate::permissions::load_for_project(cwd) { + Ok(Some(rules)) => Ok(rules), + Ok(None) => Ok(PermissionRules::default()), + Err(error) => Err(format!( + "Could not read .cortex/permissions.toml: {error}. Rules were not applied." + )), + } +} + +/// Load the committed sandbox allowlist for `cwd`, failing closed. +pub fn load_allowlist_outcome(cwd: &std::path::Path) -> Result { + match crate::sandbox_allowlist::load_for_project(cwd) { + Ok(Some(list)) => Ok(list), + // No file means nothing is allowed, which is the fail-closed default. + Ok(None) => Ok(SandboxAllowlist::default()), + Err(error) => Err(format!( + "Could not read .cortex/sandbox.toml: {error}. Network stays blocked." + )), + } +} + +/// Transcript line for the permission-rules picker. +pub fn rules_status(rules: &PermissionRules) -> String { + if rules.is_empty() { + return "No permission rules committed. Add .cortex/permissions.toml to pin allow / ask / deny.".to_string(); + } + format!( + "`.cortex/permissions.toml` — {} allow · {} ask · {} deny. First matching rule wins; deny beats allow.", + rules.allow.len(), + rules.ask.len(), + rules.deny.len() + ) +} + +/// Transcript line for the sandbox allowlist picker. +pub fn allowlist_status(allowlist: &SandboxAllowlist) -> String { + if allowlist.is_empty() { + return "Network is blocked — no domains are allowed. Add one to let a command out." + .to_string(); + } + format!( + "Network is allowlisted — {}. Anything off the list fails closed and asks.", + crate::sandbox_allowlist::status_line(allowlist) + ) +} + +/// Picker rows for the plugin marketplace. +/// +/// `plugins` is the live `cortex plugin list` output when it is available; when +/// it is not, the marketplace row still opens the real registry. +pub fn plugin_marketplace_rows(installed: &[(String, String)]) -> Vec<(String, String, String)> { + let mut rows: Vec<(String, String, String)> = installed + .iter() + .map(|(name, version)| { + ( + name.clone(), + name.clone(), + format!("installed · v{version}"), + ) + }) + .collect(); + rows.push(( + "__search__".to_string(), + "Search the marketplace…".to_string(), + format!( + "{} — signed index", + crate::plugin_marketplace::REGISTRY_ORIGIN + ), + )); + rows +} + +/// Transcript line for the ACP editor handshake. +pub fn ide_status(narrow: bool) -> String { + if narrow { + "ACP · stdio · approvals unchanged".to_string() + } else { + "ACP over stdio — the editor drives this session; tools stay behind the same approvals and sandbox." + .to_string() + } +} + +/// Apply a permission-rules load to `state`, reporting failures honestly. +pub fn apply_rules_result( + state: &mut AppState, + outcome: Result, +) -> ProjectStateOutcome { + match outcome { + Ok(rules) => { + let status = rules_status(&rules); + state.add_message(cortex_core::widgets::Message::system(status.clone())); + state.enter_interactive_mode(build_permission_rules(&rules, 0, None)); + ProjectStateOutcome::Opened(status) + } + Err(error) => { + state.add_message(cortex_core::widgets::Message::system(format!("× {error}"))); + ProjectStateOutcome::Failed(error) + } + } +} + +/// Apply a sandbox allowlist load to `state`, reporting failures honestly. +pub fn apply_allowlist_result( + state: &mut AppState, + outcome: Result, +) -> ProjectStateOutcome { + match outcome { + Ok(list) => { + let status = allowlist_status(&list); + state.add_message(cortex_core::widgets::Message::system(status.clone())); + state.enter_interactive_mode(build_sandbox_allowlist(&list, 0, None)); + ProjectStateOutcome::Opened(status) + } + Err(error) => { + state.add_message(cortex_core::widgets::Message::system(format!("× {error}"))); + ProjectStateOutcome::Failed(error) + } + } +} + +/// Apply a plugin-marketplace open to `state`. +pub fn apply_plugin_marketplace(state: &mut AppState, installed: &[(String, String)]) { + state.add_message(cortex_core::widgets::Message::system(format!( + "{} — signed packages only.", + crate::plugin_marketplace::REGISTRY_ORIGIN + ))); + state.enter_interactive_mode(build_plugin_marketplace(installed, 0, None)); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_missing_rules_file_is_an_empty_rule_set_not_an_error() { + let dir = tempfile::tempdir().expect("tempdir"); + let rules = load_rules_outcome(dir.path()).expect("no file"); + assert!(rules.is_empty()); + assert!(rules_status(&rules).contains("No permission rules committed")); + } + + #[test] + fn a_broken_rules_file_is_reported_and_rules_are_not_applied() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = crate::permissions::rules_path(dir.path()); + std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir"); + std::fs::write(&path, "deny = [").expect("write"); + let error = load_rules_outcome(dir.path()).expect_err("broken"); + assert!(error.contains("Rules were not applied"), "{error}"); + } + + #[test] + fn a_missing_allowlist_file_stays_fail_closed() { + let dir = tempfile::tempdir().expect("tempdir"); + let list = load_allowlist_outcome(dir.path()).expect("no file"); + assert!(list.is_empty()); + assert!(!list.allows("github.com")); + assert!(allowlist_status(&list).contains("blocked")); + } + + #[test] + fn a_broken_allowlist_file_keeps_the_network_blocked() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = crate::sandbox_allowlist::allowlist_path(dir.path()); + std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir"); + std::fs::write(&path, "allow = [").expect("write"); + let error = load_allowlist_outcome(dir.path()).expect_err("broken"); + assert!(error.contains("stays blocked"), "{error}"); + } + + #[test] + fn rules_status_counts_every_group() { + let rules = PermissionRules::parse( + "allow = [\"git status*\"]\nask = [\"cargo test*\"]\ndeny = [\"rm -rf *\"]\n", + ) + .expect("rules"); + let status = rules_status(&rules); + assert!(status.contains("1 allow"), "{status}"); + assert!(status.contains("1 ask"), "{status}"); + assert!(status.contains("1 deny"), "{status}"); + assert!(status.contains("deny beats allow"), "{status}"); + } + + #[test] + fn allowlist_status_reports_the_count_and_the_block() { + let mut list = SandboxAllowlist::default(); + list.add("crates.io").expect("add"); + list.add("github.com").expect("add"); + let status = allowlist_status(&list); + assert!(status.contains("2 domains"), "{status}"); + assert!(status.contains("blocked"), "{status}"); + } + + #[test] + fn plugin_rows_lead_with_installed_plugins_then_the_registry() { + let rows = plugin_marketplace_rows(&[ + ("cortex-review".into(), "0.4.1".into()), + ("mermaid-preview".into(), "0.2.0".into()), + ]); + assert_eq!(rows.len(), 3); + assert_eq!(rows[0].1, "cortex-review"); + assert!(rows[0].2.contains("installed")); + assert_eq!(rows[2].0, "__search__"); + assert!( + rows[2].2.contains("cortex.foundation"), + "the marketplace row must name the Cortex registry: {}", + rows[2].2 + ); + } + + #[test] + fn an_empty_plugin_list_still_opens_the_marketplace() { + let rows = plugin_marketplace_rows(&[]); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].0, "__search__"); + } + + #[test] + fn ide_status_names_stdio_at_both_sizes() { + assert!(ide_status(false).contains("stdio")); + assert!(ide_status(true).contains("stdio")); + assert!(ide_status(false).contains("approval")); + } + + #[test] + fn applying_a_failed_load_reports_it_instead_of_opening_a_picker() { + let mut state = AppState::default(); + let outcome = apply_rules_result(&mut state, Err("Could not read".into())); + assert!(matches!(outcome, ProjectStateOutcome::Failed(_))); + assert!( + state.get_interactive_state().is_none(), + "a failed load must not open a picker over stale state" + ); + assert_eq!(state.messages.len(), 1); + } + + #[test] + fn applying_a_good_load_opens_the_picker() { + let mut state = AppState::default(); + let rules = PermissionRules::parse("deny = [\"rm -rf *\"]").expect("rules"); + let outcome = apply_rules_result(&mut state, Ok(rules)); + assert!(matches!(outcome, ProjectStateOutcome::Opened(_))); + assert!(state.get_interactive_state().is_some()); + } +} diff --git a/src/cortex-tui/src/interactive/builders/approval.rs b/src/cortex-tui/src/interactive/builders/approval.rs index f2d861f8..3a77db5e 100644 --- a/src/cortex-tui/src/interactive/builders/approval.rs +++ b/src/cortex-tui/src/interactive/builders/approval.rs @@ -79,6 +79,52 @@ pub fn build_permissions_picker(current: Option<&str>) -> InteractiveState { .with_banner("Permissions · how Cortex asks before acting") } +/// `/permissions rules` picker — the committed `.cortex/permissions.toml` rules. +/// +/// Rules are listed `deny` first so the strictest entries are visible without +/// scrolling, and an empty file says so rather than showing a blank list. +pub fn build_permission_rules( + rules: &crate::permissions::PermissionRules, + selected: usize, + hovered: Option, +) -> InteractiveState { + let listed = rules.rules(); + let items: Vec = if listed.is_empty() { + vec![ + InteractiveItem::new("__none__", "No rules committed") + .with_description("Add .cortex/permissions.toml to pin allow / ask / deny") + .with_disabled(true), + ] + } else { + listed + .iter() + .map(|rule| { + let label = format!("{} {}", rule.decision.label(), rule.pattern); + let mut item = InteractiveItem::new(rule.pattern.clone(), label); + item = item.with_description(match rule.note.as_deref() { + Some(note) => note.to_string(), + None => match rule.decision { + crate::permissions::RuleDecision::Allow => "never asks", + crate::permissions::RuleDecision::Ask => "asks first", + crate::permissions::RuleDecision::Deny => "always blocked", + } + .to_string(), + }); + item + }) + .collect() + }; + let mut interactive = InteractiveState::new( + "Permission rules", + items, + InteractiveAction::Custom("permission-rules".into()), + ) + .with_banner("`.cortex/permissions.toml` — first matching rule wins; deny beats allow."); + interactive.selected = selected.min(interactive.items.len().saturating_sub(1)); + interactive.hovered = hovered; + interactive +} + /// Command string shown on the gray `$` row and used for “always allow …”. pub fn permission_command_line(approval: &ApprovalState) -> String { if let Some(json) = &approval.tool_args_json { diff --git a/src/cortex-tui/src/interactive/builders/mod.rs b/src/cortex-tui/src/interactive/builders/mod.rs index 1904cb8b..d7ec2740 100644 --- a/src/cortex-tui/src/interactive/builders/mod.rs +++ b/src/cortex-tui/src/interactive/builders/mod.rs @@ -32,8 +32,9 @@ pub use approval::{ PERMISSION_EDIT_LABEL, PERMISSION_NO_LABEL, PERMISSION_ONCE_LABEL, PERMISSION_PROMPT_ACTION, PERMISSION_PROMPT_PLACEHOLDER, PERMISSION_PROMPT_TITLE, always_allow_snippet, build_approval_selector, build_clear_confirm, build_handoff_confirm, build_log_level_selector, - build_permission_prompt, build_permissions_picker, build_plan_confirm, build_question_prompt, - build_sandbox_deny_prompt, permission_always_label, permission_command_line, + build_permission_prompt, build_permission_rules, build_permissions_picker, build_plan_confirm, + build_question_prompt, build_sandbox_deny_prompt, permission_always_label, + permission_command_line, }; pub use billing::{BillingFlowState, BillingStatus, build_billing_selector}; pub use export::build_export_selector; @@ -52,8 +53,8 @@ pub use model::build_model_selector; pub use resume_picker::build_resume_picker; pub use scroll::build_scroll_selector; pub use session::{ - SkillListItem, build_effort_selector, build_mode_selector, build_sandbox_selector, - build_skills_selector, + SkillListItem, build_effort_selector, build_mode_selector, build_plugin_marketplace, + build_sandbox_allowlist, build_sandbox_selector, build_skills_selector, }; pub use sessions::build_sessions_selector; pub use settings::{ diff --git a/src/cortex-tui/src/interactive/builders/session.rs b/src/cortex-tui/src/interactive/builders/session.rs index a23f4a53..812197e6 100644 --- a/src/cortex-tui/src/interactive/builders/session.rs +++ b/src/cortex-tui/src/interactive/builders/session.rs @@ -42,12 +42,96 @@ pub fn build_sandbox_selector(enabled: bool) -> InteractiveState { ) } +/// Build the sandbox network allowlist picker from the committed entries. +/// +/// The list is what the sandbox actually consults, so an empty list is shown as +/// blocking everything rather than as "no entries yet". +pub fn build_sandbox_allowlist( + allowlist: &crate::sandbox_allowlist::SandboxAllowlist, + selected: usize, + hovered: Option, +) -> InteractiveState { + let entries = allowlist.domains(); + let mut items: Vec = if entries.is_empty() { + vec![ + InteractiveItem::new("__none__", "No domains allowed") + .with_description("Everything off this list is blocked") + .with_disabled(true), + ] + } else { + entries + .iter() + .map(|entry| { + let mut item = InteractiveItem::new(entry.host.clone(), entry.host.clone()); + item = item.with_description(match entry.note.as_deref() { + Some(note) => note.to_string(), + None => "allowed".to_string(), + }); + item + }) + .collect() + }; + items.push( + InteractiveItem::new("__add__", "a Add a domain") + .with_description("asks before it leaves the sandbox"), + ); + let mut interactive = InteractiveState::new( + "Sandbox · network", + items, + InteractiveAction::Custom("sandbox-allowlist".to_string()), + ); + interactive.selected = selected.min(interactive.items.len().saturating_sub(1)); + interactive.hovered = hovered; + interactive +} + /// One skill row for `/skills`. pub struct SkillListItem { pub name: String, pub description: String, } +/// Build the plugin marketplace picker: installed plugins, then the registry. +pub fn build_plugin_marketplace( + installed: &[(String, String)], + selected: usize, + hovered: Option, +) -> InteractiveState { + let state = crate::plugin_marketplace::PluginState { + plugins: installed + .iter() + .map(|(id, version)| crate::plugin_marketplace::InstalledPlugin { + id: id.clone(), + version: version.clone(), + enabled: true, + }) + .collect(), + }; + let items: Vec = state + .marketplace_rows() + .into_iter() + .map(|(id, label, description)| { + let mut item = InteractiveItem::new(id, label).with_description(description); + if item.id == "__search__" { + item = item.with_shortcut('s'); + } + item + }) + .collect(); + let mut interactive = InteractiveState::new( + "Plugin marketplace", + items, + InteractiveAction::Custom("plugin-marketplace".into()), + ) + .with_banner(format!( + "{} — signed packages only.", + crate::plugin_marketplace::REGISTRY_ORIGIN + )); + interactive.selected = selected.min(interactive.items.len().saturating_sub(1)); + interactive.hovered = hovered; + interactive +} + /// Build `/skills` picker from discovered skills. pub fn build_skills_selector(skills: &[SkillListItem]) -> InteractiveState { let items = if skills.is_empty() { diff --git a/src/cortex-tui/src/lib.rs b/src/cortex-tui/src/lib.rs index 4ac9e695..6a979543 100644 --- a/src/cortex-tui/src/lib.rs +++ b/src/cortex-tui/src/lib.rs @@ -100,12 +100,16 @@ pub mod bridge; pub mod runner; // Visual-lock PNG / ANSI captures +pub mod browser_use; +pub mod checkpoint; +pub mod cor35_handlers; pub mod lock_boards; pub mod lock_palette; pub mod lock_proof; pub mod lock_v2; mod lock_v2_boards; mod lock_v2_computer; +mod lock_v2_cor35; mod lock_v2_designed; mod lock_v2_farm; mod lock_v2_goal; @@ -115,8 +119,10 @@ mod lock_v2_parity; mod lock_v2_residual; mod lock_v2_scenes; mod lock_v2_share; +pub mod plugin_marketplace; pub mod readme_hero; pub mod readme_hero_boards; +pub mod sandbox_allowlist; pub mod splash_chrome; // Backtracking system for conversation history navigation diff --git a/src/cortex-tui/src/lock_v2.rs b/src/cortex-tui/src/lock_v2.rs index 52243260..d9c0b9a8 100644 --- a/src/cortex-tui/src/lock_v2.rs +++ b/src/cortex-tui/src/lock_v2.rs @@ -158,8 +158,8 @@ mod tests { #[test] fn lock_v2_wide_count_is_spec() { - assert_eq!(LOCK_V2_WIDE_IDS.len(), 96); - assert_eq!(LOCK_V2_NARROW_IDS.len(), 50); + assert_eq!(LOCK_V2_WIDE_IDS.len(), 110); + assert_eq!(LOCK_V2_NARROW_IDS.len(), 58); } #[test] diff --git a/src/cortex-tui/src/lock_v2_boards.rs b/src/cortex-tui/src/lock_v2_boards.rs index cf28193a..1f88c2dc 100644 --- a/src/cortex-tui/src/lock_v2_boards.rs +++ b/src/cortex-tui/src/lock_v2_boards.rs @@ -13,6 +13,7 @@ use crate::interactive::builders::{ }; use crate::lock_v2::PRODUCT_ERROR; use crate::lock_v2_computer::apply_computer_scene; +use crate::lock_v2_cor35::apply_cor35_scene; use crate::lock_v2_designed::apply_designed_scene; use crate::lock_v2_goal::{apply_goal_chip_scene, show_goal_in_narrow_palette}; use crate::lock_v2_network::apply_offline_rate_limit_scene; @@ -991,6 +992,7 @@ Tell me what you'd like to do.", id if apply_share_scene(id, &mut state) => {} id if apply_goal_chip_scene(id, &mut state) => {} id if apply_computer_scene(id, &mut state, width) => {} + id if apply_cor35_scene(id, &mut state, width) => {} other => panic!("unknown lock v2 scene {other}"), } state diff --git a/src/cortex-tui/src/lock_v2_computer.rs b/src/cortex-tui/src/lock_v2_computer.rs index 8c251b68..9bb5bea8 100644 --- a/src/cortex-tui/src/lock_v2_computer.rs +++ b/src/cortex-tui/src/lock_v2_computer.rs @@ -115,8 +115,8 @@ mod tests { assert!(LOCK_V2_WIDE_IDS.contains(id), "{id} missing from wide"); assert!(LOCK_V2_NARROW_IDS.contains(id), "{id} missing from narrow"); } - assert_eq!(LOCK_V2_WIDE_IDS.len(), 96); - assert_eq!(LOCK_V2_NARROW_IDS.len(), 50); + assert_eq!(LOCK_V2_WIDE_IDS.len(), 110); + assert_eq!(LOCK_V2_NARROW_IDS.len(), 58); let mut seen = std::collections::HashSet::new(); for id in LOCK_V2_WIDE_IDS.iter().chain(LOCK_V2_NARROW_IDS) { seen.insert(*id); diff --git a/src/cortex-tui/src/lock_v2_cor35.rs b/src/cortex-tui/src/lock_v2_cor35.rs new file mode 100644 index 00000000..927edce1 --- /dev/null +++ b/src/cortex-tui/src/lock_v2_cor35.rs @@ -0,0 +1,922 @@ +//! COR-35 batch lock v2 scenes (COR-362 … COR-376). +//! +//! Each board locks one shipped CLI/TUI surface from the COR-35 batch: +//! headless CI, the permission DSL, file-checkpoint rewind, the CI cookbook, +//! JSON Schema output, cloud teleport with apply-back, review-only runs, the +//! plugin marketplace, the sandbox network allowlist, the auto-approval +//! classifier, PR apply-back, the ACP editor handshake, and the stdin +//! multi-turn stream. +//! +//! Split out of [`crate::lock_v2_boards`] so adding these boards does not grow +//! that file past the source-policy line-count baseline. Copy stays Cortex-only: +//! no competitor or provider names. + +use cortex_core::widgets::Message; + +use crate::app::AppState; +use crate::interactive::builders::build_question_prompt; +use crate::lock_v2_scenes::{conversation, radios, resumed}; + +/// Boards captured at both 120×40 and 40×12. +pub const COR35_NARROW_IDS: &[&str] = &[ + "bare-ci", + "permission-rules", + "checkpoint-rewind", + "sandbox-allowlist", + "pr-apply-back", + "acp-editor", + "browser-use", + "stdin-multiturn", +]; + +/// Boards captured at 120×40 only — pickers that need the wide modal. +pub const COR35_WIDE_IDS: &[&str] = &[ + "ci-cookbook", + "json-schema", + "cloud-teleport", + "review-only", + "plugin-marketplace", + "auto-approval", +]; + +/// Every COR-35 board at `width`: wide-only first, then the shared set. +/// +/// The id lists in [`crate::lock_v2_ids`] stay the source of truth for what is +/// registered; this is the render order used by the tests. +#[cfg(test)] +pub fn cor35_ids(width: u16) -> Vec<&'static str> { + let mut ids: Vec<&'static str> = if width <= 40 { + COR35_NARROW_IDS.to_vec() + } else { + COR35_WIDE_IDS.to_vec() + }; + if width > 40 { + ids.extend_from_slice(COR35_NARROW_IDS); + } + ids +} + +/// True when `id` belongs to this batch. +pub fn is_cor35_id(id: &str) -> bool { + COR35_WIDE_IDS.contains(&id) || COR35_NARROW_IDS.contains(&id) +} + +/// Apply a COR-35 lock scene. Returns `false` when `id` is not one of ours. +pub fn apply_cor35_scene(id: &str, state: &mut AppState, width: u16) -> bool { + if !is_cor35_id(id) { + return false; + } + let narrow = width <= 40; + match id { + "bare-ci" => apply_bare_ci(state, narrow), + "ci-cookbook" => apply_ci_cookbook(state), + "permission-rules" => apply_permission_rules(state, narrow), + "checkpoint-rewind" => apply_checkpoint_rewind(state, narrow), + "json-schema" => apply_json_schema(state), + "cloud-teleport" => apply_cloud_teleport(state), + "review-only" => apply_review_only(state), + "plugin-marketplace" => apply_plugin_marketplace(state), + "sandbox-allowlist" => apply_sandbox_allowlist(state, narrow), + "auto-approval" => apply_auto_approval(state), + "pr-apply-back" => apply_pr_apply_back(state, narrow), + "acp-editor" => apply_acp_editor(state, narrow), + "browser-use" => apply_browser_use(state, narrow), + "stdin-multiturn" => apply_stdin_multiturn(state, narrow), + _ => return false, + } + true +} + +fn apply_bare_ci(state: &mut AppState, narrow: bool) { + resumed(state); + state.add_message(Message::user("cortex run --bare --ephemeral").with_timestamp("09:02 AM")); + state.add_message(Message::system(if narrow { + "bare run · no session file · no banner" + } else { + "Bare run — no session file, no banners, no alternate screen. Exit code is the result." + })); + if !narrow { + state.add_message(Message::system( + "cortex run --bare --ephemeral \"review src/auth\" --format json", + )); + } +} + +fn apply_ci_cookbook(state: &mut AppState) { + conversation(state); + state.input.set_text("/help ci"); + state.add_message(Message::system( + "CI cookbook — docs/guides/ci.md. Export CORTEX_API_KEY from your CI secret store; never paste the value into a workflow file.", + )); + state.enter_interactive_mode(radios( + "CI cookbook", + &[ + ( + "gha", + "GitHub Actions", + "CORTEX_API_KEY from secrets · cortex run --bare", + ), + ( + "gl", + "GitLab CI", + "masked variable · cortex exec --auto read-only", + ), + ( + "other", + "Any CI", + "export CORTEX_API_KEY · cortex run --bare --format json", + ), + ( + "docs", + "docs/guides/ci.md", + "full cookbook · exit codes · artifacts", + ), + ], + 0, + None, + )); +} + +fn apply_permission_rules(state: &mut AppState, narrow: bool) { + resumed(state); + state.input.set_text("/permissions rules"); + let rules = crate::permissions::PermissionRules::parse(if narrow { + "allow = [\"git status*\"]\nask = [\"cargo test*\"]\ndeny = [\"rm -rf *\"]\n" + } else { + "allow = [\"git status*\"]\nask = [\"cargo test*\"]\ndeny = [\"rm -rf *\", \"curl * | bash*\"]\n" + }) + .expect("shipped permission rules parse"); + // The picker banner already carries the rule-order copy. + state.enter_interactive_mode(crate::interactive::builders::build_permission_rules( + &rules, 0, None, + )); +} + +fn apply_checkpoint_rewind(state: &mut AppState, narrow: bool) { + resumed(state); + state.input.set_text("/rewind"); + state.add_message( + Message::user("move the model chip into the composer border").with_timestamp("04:11 PM"), + ); + state.add_message(Message::system(if narrow { + "checkpoint · 3 files before the edit" + } else { + "Checkpoint — 3 files captured before the edit. Rewinding restores them." + })); + let rows: &[(&str, &str, &str)] = if narrow { + &[ + ("last", "1 Undo last turn", "restore files"), + ("point", "2 Rewind to checkpoint", "pick a point"), + ("keep", "3 Keep files", "conversation only"), + ] + } else { + &[ + ( + "last", + "1 Undo last turn", + "restore the files this turn changed", + ), + ( + "point", + "2 Rewind to checkpoint", + "pick a checkpoint from this session", + ), + ( + "keep", + "3 Keep files, rewind conversation", + "leave the working tree alone", + ), + ] + }; + state.enter_interactive_mode(radios("Rewind", rows, 0, None)); +} + +fn apply_json_schema(state: &mut AppState) { + conversation(state); + state.input.set_text("/help json"); + state.add_message(Message::system( + "JSON Schema output — every result document is validated against the shipped schema before it is printed.", + )); + state.enter_interactive_mode(radios( + "JSON Schema output", + &[ + ( + "run", + "cortex run --format json --json-schema", + "one result document, schema-checked", + ), + ( + "exec", + "cortex exec --output-format json --json-schema", + "headless result, same schema", + ), + ( + "print", + "cortex schema print run-result", + "print the schema and exit", + ), + ], + 0, + None, + )); +} + +fn apply_cloud_teleport(state: &mut AppState) { + resumed(state); + state.add_message( + Message::user("& fix the flaky login redirect test").with_timestamp("03:02 PM"), + ); + state.add_message( + Message::assistant( + "↑ Teleported to Cortex Cloud\nbranch cortex/fix-login-redirect\nturn 2 of 5 · running\nfollow /jobs right here.", + ) + .with_timestamp("03:02 PM") + .with_thought_secs(1.2), + ); + state.add_message(Message::system( + "The cloud turn edits its own worktree. Follow it with /jobs right here.", + )); +} + +fn apply_review_only(state: &mut AppState) { + resumed(state); + state.input.set_text("/review"); + state.add_message(Message::system( + "Review-only — reads the diff, never writes. No edits, no commands.", + )); + state.enter_interactive_mode(radios( + "Review", + &[ + ("diff", "Review the working diff", "read-only"), + ("branch", "Review this branch", "against main"), + ( + "pr", + "Review a pull request", + "cortex exec --review-pr 128 · read-only", + ), + ], + 0, + None, + )); +} + +fn apply_plugin_marketplace(state: &mut AppState) { + resumed(state); + state.input.set_text("/plugins"); + // Use the same builder the live `/plugins` sheet opens. + state.enter_interactive_mode(crate::interactive::builders::build_plugin_marketplace( + &[ + ("cortex-review".to_string(), "0.4.1".to_string()), + ("mermaid-preview".to_string(), "0.2.0".to_string()), + ], + 0, + None, + )); +} + +fn apply_sandbox_allowlist(state: &mut AppState, narrow: bool) { + resumed(state); + state.input.set_text("/sandbox network"); + let mut allowlist = crate::sandbox_allowlist::SandboxAllowlist::default(); + allowlist.add("crates.io").expect("shipped entry"); + allowlist.add("github.com").expect("shipped entry"); + state.add_message(Message::system(if narrow { + "network allowlist · 2 domains · rest blocked" + } else { + "Network is allowlisted — anything off the list fails closed and asks." + })); + state.enter_interactive_mode(crate::interactive::builders::build_sandbox_allowlist( + &allowlist, 0, None, + )); +} + +fn apply_auto_approval(state: &mut AppState) { + resumed(state); + state.input.set_text("/permissions"); + state.add_message(Message::system( + "Auto-approval classifier — reads and safe commands pass; anything else asks.", + )); + state.enter_interactive_mode(radios( + "Auto-approval", + &[ + ("auto", "Auto-approve safe reads", "Glob · Grep · Read"), + ("ask", "Ask before commands", "cargo test · npm install"), + ("never", "Never auto-approve", "every tool call asks"), + ], + 0, + None, + )); +} + +fn apply_pr_apply_back(state: &mut AppState, narrow: bool) { + resumed(state); + state.add_message(Message::user("cortex pr 128 --apply").with_timestamp("05:40 PM")); + state.add_message(Message::system(if narrow { + "PR 128 · 4 files · clean tree" + } else { + "PR 128 — 4 files, +86 −14. Working tree is clean; the patch applies directly." + })); + state.enter_interactive_mode(build_question_prompt( + "Apply PR 128?", + if narrow { + &[ + ("apply", "1 Apply the patch", "4 files"), + ("checkout", "2 Check out the branch", "keep the tree"), + ("cancel", "3 Cancel", "no changes"), + ] + } else { + &[ + ("apply", "1 Apply the patch", "4 files · +86 −14"), + ( + "checkout", + "2 Check out the branch", + "switch and keep local commits", + ), + ("cancel", "3 Cancel", "nothing is written"), + ] + }, + 0, + )); +} + +fn apply_acp_editor(state: &mut AppState, narrow: bool) { + resumed(state); + state.add_message(Message::user("/ide").with_timestamp("10:44 AM")); + state.add_message(Message::system(if narrow { + "ACP · stdio · approvals unchanged" + } else { + "ACP over stdio — the editor drives this session; tools stay behind the same approvals and sandbox." + })); + state.enter_interactive_mode(radios( + "Editor (ACP)", + &[ + ("connect", "Connect an editor", "cortex acp · stdio"), + ( + "session", + "Share this session", + if narrow { + "same approvals" + } else { + "editor reads files through the same sandbox" + }, + ), + ("stop", "Disconnect", "the CLI keeps running"), + ], + 0, + None, + )); +} + +fn apply_browser_use(state: &mut AppState, narrow: bool) { + resumed(state); + state.input.set_text("/browser"); + let capability = crate::browser_use::resolve_capability(&[]); + state.add_message(Message::system(if narrow { + crate::browser_use::NO_BUILTIN_TOOL_NOTE_NARROW.to_string() + } else { + capability.status_line() + })); + let rows: &[(&str, &str, &str)] = if narrow { + &[ + ("connect", "1 Connect an MCP server", "browser tools"), + ("runtime", "2 Computer runtime", "Cloud · This PC · SSH"), + ("cancel", "3 Cancel", "nothing changes"), + ] + } else { + &[ + ( + "connect", + "1 Connect a browser MCP server", + "the CLI ships no browser tool", + ), + ( + "runtime", + "2 Computer runtime", + "where tools run — not browser automation", + ), + ("cancel", "3 Cancel", "no server is installed"), + ] + }; + state.enter_interactive_mode(radios("Browser", rows, 0, None)); +} + +fn apply_stdin_multiturn(state: &mut AppState, narrow: bool) { + resumed(state); + state.add_message( + Message::user("cortex exec --input-format stream-jsonl").with_timestamp("11:06 AM"), + ); + state.add_message(Message::system(if narrow { + "stdin · 3 turns · one stream" + } else { + "stdin — one JSON line per turn, one stream out. The connection outlives each turn." + })); + if !narrow { + state.add_message(Message::system("{\"text\":\"add a retry helper\"}")); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::lock_v2::{LOCK_V2_NARROW_IDS, LOCK_V2_WIDE_IDS, render_lock_v2_scene}; + use cortex_core::style::{ACCENT, SELECTION_BG}; + use std::collections::HashSet; + + const SIZES: [(u16, u16); 2] = [(120, 40), (40, 12)]; + + fn banned_names(plain: &str) -> bool { + let lower = plain.to_ascii_lowercase(); + [ + "claude", + "openai", + "anthropic", + "cursor", + "codex", + "devin", + "gemini", + "copilot", + "grok", + "rakazo", + ] + .iter() + .any(|n| lower.contains(n)) + } + + #[test] + fn cor35_ids_are_registered_at_the_right_sizes() { + assert_eq!(COR35_NARROW_IDS.len(), 8); + assert_eq!(COR35_WIDE_IDS.len(), 6); + for id in COR35_NARROW_IDS { + assert!(LOCK_V2_WIDE_IDS.contains(id), "{id} missing from wide list"); + assert!( + LOCK_V2_NARROW_IDS.contains(id), + "{id} missing from narrow list" + ); + } + for id in COR35_WIDE_IDS { + assert!(LOCK_V2_WIDE_IDS.contains(id), "{id} missing from wide list"); + assert!( + !LOCK_V2_NARROW_IDS.contains(id), + "{id} is wide-only and must not be in the narrow set" + ); + } + assert_eq!(cor35_ids(120).len(), 14); + assert_eq!(cor35_ids(40).len(), 8); + } + #[test] + fn every_cor35_board_is_named_and_cortex_only() { + for (width, height) in SIZES { + for id in cor35_ids(width) { + let frame = render_lock_v2_scene(id, width, height) + .unwrap_or_else(|e| panic!("{id} at {width}x{height}: {e}")); + assert!( + !frame.plain.trim().is_empty(), + "{id} rendered an empty frame" + ); + assert!( + !banned_names(&frame.plain), + "{id} at {width}x{height} names a competitor:\n{}", + frame.plain + ); + } + } + } + + #[test] + fn bare_ci_says_it_leaves_no_session_behind() { + for (width, height) in SIZES { + let frame = render_lock_v2_scene("bare-ci", width, height).expect("bare-ci"); + assert!( + frame.plain.contains("bare") || frame.plain.contains("--bare"), + "bare-ci must name the bare run at {width}x{height}:\n{}", + frame.plain + ); + assert!( + frame.plain.contains("no session file") || frame.plain.contains("ephemeral"), + "bare-ci must state the session is not persisted at {width}x{height}:\n{}", + frame.plain + ); + assert!( + !frame.plain.contains("Choose an option above"), + "bare-ci is not an approval sheet:\n{}", + frame.plain + ); + } + } + + #[test] + fn ci_cookbook_leads_with_secret_via_env() { + let frame = render_lock_v2_scene("ci-cookbook", 120, 40).expect("cookbook"); + assert!(frame.plain.contains("CI cookbook"), "{}", frame.plain); + assert!(frame.plain.contains("GitHub Actions"), "{}", frame.plain); + assert!(frame.plain.contains("GitLab"), "{}", frame.plain); + assert!( + frame.plain.contains("CORTEX_API_KEY"), + "cookbook must pass the secret through the environment:\n{}", + frame.plain + ); + assert!( + !frame.plain.contains("sk-") && !frame.plain.contains("Bearer "), + "cookbook must never print a secret value:\n{}", + frame.plain + ); + } + + #[test] + fn permission_rules_use_a_project_file_with_deny_wins() { + for (width, height) in SIZES { + let frame = render_lock_v2_scene("permission-rules", width, height).expect("rules"); + assert!( + frame.plain.contains(".cortex/permissions.toml") + || frame.plain.contains("permissions.toml"), + "permission-rules must name the project file at {width}x{height}:\n{}", + frame.plain + ); + assert!( + frame.plain.contains("allow") && frame.plain.contains("deny"), + "permission-rules must show allow and deny rules at {width}x{height}:\n{}", + frame.plain + ); + assert!( + frame.plain.contains("rm -rf"), + "permission-rules must show a denied command at {width}x{height}:\n{}", + frame.plain + ); + assert!( + !frame.plain.contains("$ npm install"), + "permission-rules is not the exec approval prompt:\n{}", + frame.plain + ); + assert_ne!( + frame.ansi, + render_lock_v2_scene("permissions-picker", width, height) + .expect("picker") + .ansi, + "permission-rules must differ from the permissions picker" + ); + } + } + + #[test] + fn checkpoint_rewind_restores_files() { + for (width, height) in SIZES { + let frame = render_lock_v2_scene("checkpoint-rewind", width, height).expect("rewind"); + assert!( + frame.plain.contains("checkpoint") || frame.plain.contains("Checkpoint"), + "checkpoint-rewind must name the checkpoint at {width}x{height}:\n{}", + frame.plain + ); + assert!( + frame.plain.contains("restore") || frame.plain.contains("Undo"), + "checkpoint-rewind must offer a restore at {width}x{height}:\n{}", + frame.plain + ); + assert_ne!( + frame.ansi, + render_lock_v2_scene("undo-sheet", width, height) + .expect("undo-sheet") + .ansi, + "checkpoint-rewind must differ from the undo sheet" + ); + } + } + + #[test] + fn json_schema_board_names_the_schema_commands() { + let frame = render_lock_v2_scene("json-schema", 120, 40).expect("schema"); + assert!(frame.plain.contains("JSON Schema"), "{}", frame.plain); + assert!(frame.plain.contains("--json-schema"), "{}", frame.plain); + assert!(frame.plain.contains("--format json"), "{}", frame.plain); + } + + #[test] + fn cloud_teleport_and_apply_back_are_named() { + let frame = render_lock_v2_scene("cloud-teleport", 120, 40).expect("teleport"); + assert!(frame.plain.contains("Teleported"), "{}", frame.plain); + assert!( + frame.plain.contains("Cortex Cloud"), + "teleport must stay Cortex-branded:\n{}", + frame.plain + ); + assert!( + frame.plain.contains("/jobs") || frame.plain.contains("applies the diff"), + "teleport must say how the work comes back:\n{}", + frame.plain + ); + assert!( + !frame.plain.contains("/teleport"), + "teleport must not name a command that does not exist:\n{}", + frame.plain + ); + assert!( + !frame.plain.contains("Handed off to Cortex Cloud"), + "teleport replaces the one-way handoff board:\n{}", + frame.plain + ); + } + + #[test] + fn review_only_promises_no_writes() { + let frame = render_lock_v2_scene("review-only", 120, 40).expect("review"); + assert!(frame.plain.contains("Review-only"), "{}", frame.plain); + assert!( + frame.plain.contains("never writes") || frame.plain.contains("read-only"), + "review-only must state it does not write:\n{}", + frame.plain + ); + assert!( + frame.plain.contains("--review-pr") || frame.plain.contains("--pr"), + "review-only must cover a pull request:\n{}", + frame.plain + ); + } + + #[test] + fn plugin_marketplace_is_signed_and_cortex_hosted() { + let frame = render_lock_v2_scene("plugin-marketplace", 120, 40).expect("marketplace"); + assert!( + frame.plain.contains("cortex.foundation"), + "marketplace must use the Cortex origin:\n{}", + frame.plain + ); + assert!(frame.plain.contains("signed"), "{}", frame.plain); + assert!(frame.plain.contains("installed"), "{}", frame.plain); + assert!( + !frame.plain.contains("jira.cortex"), + "marketplace copy must not invent a vendor host:\n{}", + frame.plain + ); + } + + #[test] + fn sandbox_allowlist_lists_domains_and_fails_closed() { + for (width, height) in SIZES { + let frame = + render_lock_v2_scene("sandbox-allowlist", width, height).expect("allowlist"); + assert!( + frame.plain.contains("crates.io") && frame.plain.contains("github.com"), + "allowlist must list the allowed domains at {width}x{height}:\n{}", + frame.plain + ); + assert!( + frame.plain.contains("allowlist") || frame.plain.contains("allowed"), + "allowlist board must name the allowlist at {width}x{height}:\n{}", + frame.plain + ); + assert_ne!( + frame.ansi, + render_lock_v2_scene("sandbox", width, height) + .expect("sandbox") + .ansi, + "sandbox-allowlist must differ from the sandbox picker" + ); + } + } + + #[test] + fn auto_approval_classifier_names_what_passes() { + let frame = render_lock_v2_scene("auto-approval", 120, 40).expect("classifier"); + assert!(frame.plain.contains("Auto-approval"), "{}", frame.plain); + assert!(frame.plain.contains("Glob"), "{}", frame.plain); + assert!( + frame.plain.contains("cargo test"), + "classifier must show a command that still asks:\n{}", + frame.plain + ); + assert!( + !frame.plain.contains("cert") && !frame.plain.contains("badge"), + "classifier must not claim a certification:\n{}", + frame.plain + ); + } + + #[test] + fn pr_apply_back_is_confirmable_and_cancellable() { + for (width, height) in SIZES { + let frame = render_lock_v2_scene("pr-apply-back", width, height).expect("apply-back"); + assert!( + frame.plain.contains("PR 128") || frame.plain.contains("Apply PR"), + "pr-apply-back must name the PR at {width}x{height}:\n{}", + frame.plain + ); + assert!( + frame.plain.contains("Apply the patch") || frame.plain.contains("1 Apply"), + "pr-apply-back must offer the patch at {width}x{height}:\n{}", + frame.plain + ); + assert!( + frame.plain.contains("Cancel"), + "pr-apply-back must be cancellable at {width}x{height}:\n{}", + frame.plain + ); + assert_ne!( + frame.ansi, + render_lock_v2_scene("plan-confirm", width, height) + .expect("plan-confirm") + .ansi, + "pr-apply-back must differ from plan-confirm" + ); + } + } + + #[test] + fn acp_editor_board_is_stdio_and_approval_bound() { + for (width, height) in SIZES { + let frame = render_lock_v2_scene("acp-editor", width, height).expect("acp"); + assert!(frame.plain.contains("ACP"), "{}", frame.plain); + assert!( + frame.plain.contains("cortex acp") || frame.plain.contains("stdio"), + "acp-editor must name the stdio entrypoint at {width}x{height}:\n{}", + frame.plain + ); + assert!( + frame.plain.contains("approval") || frame.plain.contains("sandbox"), + "acp-editor must keep approvals in the path at {width}x{height}:\n{}", + frame.plain + ); + assert!( + !frame.plain.contains("extension"), + "acp-editor must not claim a packaged extension:\n{}", + frame.plain + ); + } + } + + #[test] + fn browser_use_board_claims_no_builtin_tool() { + for (width, height) in SIZES { + let frame = render_lock_v2_scene("browser-use", width, height).expect("browser"); + assert!( + frame.plain.contains("browser") || frame.plain.contains("Browser"), + "browser-use must name the surface at {width}x{height}:\n{}", + frame.plain + ); + assert!( + frame.plain.contains("MCP"), + "browser-use must say the capability comes from an MCP server at {width}x{height}:\n{}", + frame.plain + ); + assert!( + frame.plain.contains("no browser tool") || frame.plain.contains("ships no browser"), + "browser-use must not imply a built-in tool at {width}x{height}:\n{}", + frame.plain + ); + assert!( + frame.plain.contains("Computer runtime") || frame.plain.contains("not browser"), + "browser-use must disambiguate the Computer runtime at {width}x{height}:\n{}", + frame.plain + ); + assert!( + !frame.plain.contains("extension"), + "browser-use must not claim an extension:\n{}", + frame.plain + ); + assert_ne!( + frame.ansi, + render_lock_v2_scene("acp-editor", width, height) + .expect("acp") + .ansi, + "browser-use must differ from acp-editor" + ); + assert_ne!( + frame.ansi, + render_lock_v2_scene("computer-cloud-default", width, height) + .expect("computer") + .ansi, + "browser-use must differ from the Computer runtime board" + ); + } + } + + #[test] + fn stdin_multiturn_stream_outlives_a_turn() { + for (width, height) in SIZES { + let frame = render_lock_v2_scene("stdin-multiturn", width, height).expect("stdin"); + assert!( + frame.plain.contains("stdin"), + "stdin-multiturn must name stdin at {width}x{height}:\n{}", + frame.plain + ); + assert!( + frame.plain.contains("turn"), + "stdin-multiturn must be multi-turn at {width}x{height}:\n{}", + frame.plain + ); + assert!( + frame.plain.contains("stream-jsonl") || frame.plain.contains("stream"), + "stdin-multiturn must name the stream format at {width}x{height}:\n{}", + frame.plain + ); + } + } + + #[test] + fn cor35_frames_are_unique_at_every_size() { + for (width, height) in SIZES { + let mut seen: HashSet = HashSet::new(); + for id in cor35_ids(width) { + let frame = render_lock_v2_scene(id, width, height).expect(id); + assert!( + seen.insert(frame.ansi.clone()), + "{id} collided with a sibling at {width}x{height}" + ); + } + assert_eq!(seen.len(), cor35_ids(width).len()); + } + } + + #[test] + fn every_cor35_board_stays_off_the_retired_palette() { + // SPEC §1 retires thinking gold, mint, cyan, and the violet wash. The + // shared audit covers those; the SPEC cyan is checked here as well + // because `count_palette` only recognises `#00FFFF` for cyan. + const SPEC_CYAN: ratatui::style::Color = ratatui::style::Color::Rgb(0x7D, 0xD3, 0xFC); + for (width, height) in SIZES { + for id in cor35_ids(width) { + let frame = render_lock_v2_scene(id, width, height).expect(id); + let counts = crate::lock_palette::count_palette(&frame.buffer); + assert!( + !counts.has_banned(), + "{id} at {width}x{height} paints retired chrome: violet={} wash={} gold={} mint={} cyan={}", + counts.violet_px, + counts.wash_px, + counts.gold_px, + counts.mint_px, + counts.cyan_px + ); + for y in 0..height { + for x in 0..width { + let cell = &frame.buffer[(x, y)]; + assert_ne!( + cell.fg, SPEC_CYAN, + "{id} at {width}x{height} paints retired cyan at {x},{y}" + ); + assert_ne!( + cell.bg, SPEC_CYAN, + "{id} at {width}x{height} paints retired cyan at {x},{y}" + ); + } + } + } + } + } + + #[test] + fn the_retired_palette_audit_actually_fires() { + // Guard the guard: a buffer with a banned colour must fail the audit, + // so the assertion above cannot pass by doing nothing. + let mut buffer = ratatui::buffer::Buffer::empty(ratatui::layout::Rect::new(0, 0, 4, 2)); + crate::lock_palette::inject_violet_cell(&mut buffer, 1, 1); + assert!( + crate::lock_palette::count_palette(&buffer).has_banned(), + "the palette audit must detect a banned colour" + ); + } + + #[test] + fn focused_rows_paint_selection_or_accent() { + let mut checked = 0; + for (width, height) in SIZES { + for id in cor35_ids(width) { + let frame = render_lock_v2_scene(id, width, height).expect(id); + let mut selection = false; + let mut accent = false; + for y in 0..height { + for x in 0..width { + let cell = &frame.buffer[(x, y)]; + if cell.bg == SELECTION_BG { + selection = true; + } + if cell.fg == ACCENT { + accent = true; + } + } + } + assert!( + selection || accent, + "{id} at {width}x{height} paints no focus: no SELECTION_BG and no ACCENT" + ); + checked += 1; + } + } + assert!(checked > 0); + } + + #[test] + fn apply_rejects_unknown_ids() { + let mut state = AppState::default(); + for id in [ + "cloud-handoff", + "handoff-confirm", + "sandbox", + "permissions-picker", + ] { + assert!( + !apply_cor35_scene(id, &mut state, 120), + "{id} must not be claimed by the COR-35 batch" + ); + } + assert!(state.messages.is_empty()); + assert!(state.get_interactive_state().is_none()); + } +} diff --git a/src/cortex-tui/src/lock_v2_designed.rs b/src/cortex-tui/src/lock_v2_designed.rs index 8a20698d..4ec557a6 100644 --- a/src/cortex-tui/src/lock_v2_designed.rs +++ b/src/cortex-tui/src/lock_v2_designed.rs @@ -246,8 +246,8 @@ mod tests { } assert!(!LOCK_V2_WIDE_IDS.contains(&"share-link")); assert!(!LOCK_V2_WIDE_IDS.contains(&"unshare")); - assert_eq!(LOCK_V2_WIDE_IDS.len(), 96); - assert_eq!(LOCK_V2_NARROW_IDS.len(), 50); + assert_eq!(LOCK_V2_WIDE_IDS.len(), 110); + assert_eq!(LOCK_V2_NARROW_IDS.len(), 58); } #[test] diff --git a/src/cortex-tui/src/lock_v2_ids.rs b/src/cortex-tui/src/lock_v2_ids.rs index a59cb585..195ac881 100644 --- a/src/cortex-tui/src/lock_v2_ids.rs +++ b/src/cortex-tui/src/lock_v2_ids.rs @@ -53,6 +53,14 @@ pub const LOCK_V2_NARROW_IDS: &[&str] = &[ "consent-local-tools", "composer-file-chip", "undo-sheet", + "bare-ci", + "permission-rules", + "checkpoint-rewind", + "sandbox-allowlist", + "pr-apply-back", + "acp-editor", + "browser-use", + "stdin-multiturn", ]; /// Wide (120×40) SPEC §7 set — 96 boards. @@ -153,6 +161,20 @@ pub const LOCK_V2_WIDE_IDS: &[&str] = &[ "consent-local-tools", "composer-file-chip", "undo-sheet", + "bare-ci", + "ci-cookbook", + "permission-rules", + "checkpoint-rewind", + "json-schema", + "cloud-teleport", + "review-only", + "plugin-marketplace", + "sandbox-allowlist", + "auto-approval", + "pr-apply-back", + "acp-editor", + "browser-use", + "stdin-multiturn", ]; /// Boards captured at both sizes. Narrow (40×12) is a subset. @@ -171,8 +193,8 @@ mod tests { #[test] fn lock_v2_id_counts_and_unique() { - assert_eq!(LOCK_V2_WIDE_IDS.len(), 96); - assert_eq!(LOCK_V2_NARROW_IDS.len(), 50); + assert_eq!(LOCK_V2_WIDE_IDS.len(), 110); + assert_eq!(LOCK_V2_NARROW_IDS.len(), 58); let mut wide = HashSet::new(); for id in LOCK_V2_WIDE_IDS { assert!(wide.insert(*id), "duplicate wide id {id}"); diff --git a/src/cortex-tui/src/permissions/mod.rs b/src/cortex-tui/src/permissions/mod.rs index 643a4820..98e7e048 100644 --- a/src/cortex-tui/src/permissions/mod.rs +++ b/src/cortex-tui/src/permissions/mod.rs @@ -7,6 +7,13 @@ use ratatui::style::Color; use serde::{Deserialize, Serialize}; use std::collections::HashSet; +mod rules; + +pub use rules::{ + PERMISSION_RULES_FILE, PermissionRule, PermissionRules, RuleDecision, glob_matches, + load_for_project, rules_path, +}; + /// Permission mode that determines the level of automatic tool approval. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] pub enum PermissionMode { @@ -111,6 +118,9 @@ pub struct PermissionManager { pub session_allowed: HashSet, /// Tools always allowed (persisted) pub always_allowed: HashSet, + /// Committed project rules from `.cortex/permissions.toml`. + #[serde(default)] + pub rules: PermissionRules, } impl Default for PermissionManager { @@ -126,7 +136,26 @@ impl PermissionManager { mode: PermissionMode::default(), session_allowed: HashSet::new(), always_allowed: HashSet::new(), + rules: PermissionRules::default(), + } + } + + /// Load the committed project rules for `cwd` into this manager. + /// + /// A rules file that cannot be read or parsed is an error: silently + /// ignoring a team's `deny` list would be the worst possible outcome. + pub fn load_project_rules(&mut self, cwd: &std::path::Path) -> anyhow::Result<()> { + if let Some(rules) = load_for_project(cwd)? { + self.rules = rules; } + Ok(()) + } + + /// Decide a command line against the committed project rules. + /// + /// Returns `None` when no rule matches, which leaves the mode in charge. + pub fn rule_decision(&self, command: &str) -> Option { + self.rules.decide(command) } /// Determines if the user should be asked for permission to execute a tool. @@ -157,6 +186,26 @@ impl PermissionManager { } } + /// Whether a specific command needs approval, combining the mode with the + /// committed project rules. + /// + /// Rules win over the mode in both directions: a `deny` always asks (the + /// caller refuses), and an `allow` never asks. A `deny` cannot be overridden + /// by a session or always-allow grant, because those are per-tool and the + /// rule is per-command. + pub fn should_ask_for_command(&self, tool_name: &str, command: &str) -> bool { + match self.rule_decision(command) { + Some(RuleDecision::Deny) | Some(RuleDecision::Ask) => true, + Some(RuleDecision::Allow) => false, + None => self.should_ask(tool_name), + } + } + + /// Whether `command` is refused outright by a committed `deny` rule. + pub fn is_denied(&self, command: &str) -> bool { + self.rule_decision(command) == Some(RuleDecision::Deny) + } + /// Allows a tool for the current session only. pub fn allow_for_session(&mut self, tool_name: &str) { self.session_allowed.insert(tool_name.to_string()); @@ -300,4 +349,63 @@ mod tests { manager.allow_always("Edit"); assert!(!manager.should_ask("Edit")); } + + fn manager_with_rules() -> PermissionManager { + let mut manager = PermissionManager::new(); + manager.mode = PermissionMode::High; + manager.rules = PermissionRules::parse( + r#" +allow = ["git status*"] +ask = ["cargo publish*"] +deny = ["rm -rf *"] +"#, + ) + .expect("rules"); + manager + } + + #[test] + fn project_rules_decide_commands_before_the_mode() { + let manager = manager_with_rules(); + // High mode asks for everything non-safe, but an allow rule wins. + assert!(!manager.should_ask_for_command("Execute", "git status")); + // A deny rule asks even in a mode that would auto-approve. + assert!(manager.should_ask_for_command("Execute", "rm -rf /")); + assert!(manager.is_denied("rm -rf /")); + assert!(!manager.is_denied("git status")); + // No matching rule falls back to the mode. + assert!(manager.should_ask_for_command("Execute", "cargo build")); + } + + #[test] + fn a_session_grant_does_not_override_a_deny_rule() { + let mut manager = manager_with_rules(); + manager.allow_for_session("Execute"); + manager.allow_always("Execute"); + assert!( + manager.should_ask_for_command("Execute", "rm -rf /"), + "a deny rule is per-command and must not be widened by a per-tool grant" + ); + assert!(!manager.should_ask_for_command("Execute", "git status")); + } + + #[test] + fn project_rules_load_from_disk_and_refuse_broken_files() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut manager = PermissionManager::new(); + manager.load_project_rules(dir.path()).expect("no file"); + assert!(manager.rules.is_empty()); + + let path = rules_path(dir.path()); + std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir"); + std::fs::write(&path, "deny = [\"rm -rf *\"]").expect("write"); + manager.load_project_rules(dir.path()).expect("load"); + assert!(manager.is_denied("rm -rf /")); + + std::fs::write(&path, "deny = [").expect("write broken"); + assert!( + manager.load_project_rules(dir.path()).is_err(), + "a broken rules file must not be ignored" + ); + } } diff --git a/src/cortex-tui/src/permissions/rules.rs b/src/cortex-tui/src/permissions/rules.rs new file mode 100644 index 00000000..671f6da7 --- /dev/null +++ b/src/cortex-tui/src/permissions/rules.rs @@ -0,0 +1,371 @@ +//! Project permission rules — `.cortex/permissions.toml`. +//! +//! The permission *mode* (see [`crate::permissions`]) decides the default +//! posture. Rules let a repository name specific commands and paths that are +//! always allowed, always asked about, or always denied, so a team can commit +//! its policy next to the code. +//! +//! Evaluation order is deliberate and matches the lock board: the first rule +//! that matches wins, and `deny` beats `allow` for the same specificity, so a +//! broad `allow` cannot be widened by adding a narrower `deny` in the wrong +//! order. A rule only ever makes the posture *stricter* than the mode unless it +//! is an explicit `allow`. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +/// Project file that holds committed permission rules. +pub const PERMISSION_RULES_FILE: &str = ".cortex/permissions.toml"; + +/// What a matching rule says to do. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RuleDecision { + /// Run without asking. + Allow, + /// Ask before running. + Ask, + /// Never run. + Deny, +} + +impl RuleDecision { + /// Display word used in the picker and in status copy. + pub fn label(&self) -> &'static str { + match self { + RuleDecision::Allow => "allow", + RuleDecision::Ask => "ask", + RuleDecision::Deny => "deny", + } + } + + /// Relative strictness, so the most restrictive decision can win a tie. + fn strictness(&self) -> u8 { + match self { + RuleDecision::Allow => 0, + RuleDecision::Ask => 1, + RuleDecision::Deny => 2, + } + } +} + +/// One committed rule. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PermissionRule { + /// Decision this rule applies. + pub decision: RuleDecision, + /// Command or path pattern. `*` matches any run of characters. + pub pattern: String, + /// Optional human note, shown in the picker. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub note: Option, +} + +impl PermissionRule { + /// Build a rule. + pub fn new(decision: RuleDecision, pattern: impl Into) -> Self { + Self { + decision, + pattern: pattern.into(), + note: None, + } + } + + /// Attach a note. + pub fn with_note(mut self, note: impl Into) -> Self { + self.note = Some(note.into()); + self + } + + /// True when `subject` matches this rule's pattern. + /// + /// Matching is case-sensitive and anchored: `cargo test` matches the + /// command `cargo test`, not `cargo test --all`. + pub fn matches(&self, subject: &str) -> bool { + glob_matches(&self.pattern, subject) + } +} + +/// The committed rule set, grouped by decision for a stable file order. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct PermissionRules { + /// Rules that allow without asking. + #[serde(default)] + pub allow: Vec, + /// Rules that always ask. + #[serde(default)] + pub ask: Vec, + /// Rules that never run. + #[serde(default)] + pub deny: Vec, + /// Optional notes keyed by pattern, so a rule can explain itself. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub notes: BTreeMap, +} + +impl PermissionRules { + /// True when no rule is declared. + pub fn is_empty(&self) -> bool { + self.allow.is_empty() && self.ask.is_empty() && self.deny.is_empty() + } + + /// Every rule, `deny` first so the strictest rules are shown at the top. + pub fn rules(&self) -> Vec { + let mut rules = Vec::new(); + for (decision, patterns) in [ + (RuleDecision::Deny, &self.deny), + (RuleDecision::Ask, &self.ask), + (RuleDecision::Allow, &self.allow), + ] { + for pattern in patterns { + let mut rule = PermissionRule::new(decision, pattern.clone()); + rule.note = self.notes.get(pattern).cloned(); + rules.push(rule); + } + } + rules + } + + /// Parse a `.cortex/permissions.toml` document. + pub fn parse(document: &str) -> Result { + let rules: PermissionRules = + toml::from_str(document).context("Could not parse .cortex/permissions.toml")?; + rules.validate()?; + Ok(rules) + } + + /// Reject rules that cannot be evaluated safely. + /// + /// An empty pattern would match nothing and a bare `*` would match + /// everything, so both are refused rather than silently ignored. + pub fn validate(&self) -> Result<()> { + for (decision, patterns) in [ + ("allow", &self.allow), + ("ask", &self.ask), + ("deny", &self.deny), + ] { + for pattern in patterns { + let trimmed = pattern.trim(); + if trimmed.is_empty() { + anyhow::bail!("A `{decision}` rule has an empty pattern."); + } + if trimmed == "*" { + anyhow::bail!( + "The `{decision}` rule `*` would match every command. Name the commands or paths instead." + ); + } + } + } + Ok(()) + } + + /// Decide what to do with `subject` (a command line or a file path). + /// + /// The first matching rule wins. When several rules match at the same + /// position the most restrictive decision is used, so adding a rule can + /// never widen access by accident. + pub fn decide(&self, subject: &str) -> Option { + let matching: Vec = self + .rules() + .into_iter() + .filter(|rule| rule.matches(subject)) + .collect(); + matching + .iter() + .max_by_key(|rule| rule.decision.strictness()) + .map(|rule| rule.decision) + } +} + +/// Read the committed rules for `cwd`, if the file exists. +pub fn load_for_project(cwd: &Path) -> Result> { + let path = rules_path(cwd); + if !path.exists() { + return Ok(None); + } + let document = std::fs::read_to_string(&path) + .with_context(|| format!("Could not read {}", path.display()))?; + Ok(Some(PermissionRules::parse(&document)?)) +} + +/// Path of the committed rules file for `cwd`. +pub fn rules_path(cwd: &Path) -> PathBuf { + cwd.join(PERMISSION_RULES_FILE) +} + +/// Match `pattern` against `subject`, where `*` matches any run of characters. +/// +/// Anchored at both ends: a pattern only matches a whole command or path. +pub fn glob_matches(pattern: &str, subject: &str) -> bool { + let parts: Vec<&str> = pattern.split('*').collect(); + if parts.len() == 1 { + return pattern == subject; + } + let mut remainder = subject; + for (index, part) in parts.iter().enumerate() { + if part.is_empty() { + continue; + } + if index == 0 { + let Some(rest) = remainder.strip_prefix(part) else { + return false; + }; + remainder = rest; + } else if index == parts.len() - 1 { + return remainder.ends_with(part); + } else { + let Some(position) = remainder.find(part) else { + return false; + }; + remainder = &remainder[position + part.len()..]; + } + } + // A trailing `*` (empty final part) matches whatever is left. + parts.last().is_some_and(|last| last.is_empty()) || remainder.is_empty() +} + +#[cfg(test)] +mod tests { + use super::*; + + const DOCUMENT: &str = r#" +allow = ["git status*", "cargo test*", "ls *"] +ask = ["cargo publish*"] +deny = ["rm -rf *", "curl * | bash*"] + +[notes] +"rm -rf *" = "always blocked" +"#; + + #[test] + fn a_committed_file_parses_into_grouped_rules() { + let rules = PermissionRules::parse(DOCUMENT).expect("parse"); + assert_eq!(rules.allow.len(), 3); + assert_eq!(rules.ask.len(), 1); + assert_eq!(rules.deny.len(), 2); + assert!(!rules.is_empty()); + assert_eq!( + rules.notes.get("rm -rf *").map(String::as_str), + Some("always blocked") + ); + } + + #[test] + fn rules_are_listed_deny_first_then_ask_then_allow() { + let rules = PermissionRules::parse(DOCUMENT).expect("parse"); + let listed: Vec = rules.rules().iter().map(|r| r.decision).collect(); + assert_eq!( + listed, + vec![ + RuleDecision::Deny, + RuleDecision::Deny, + RuleDecision::Ask, + RuleDecision::Allow, + RuleDecision::Allow, + RuleDecision::Allow, + ] + ); + } + + #[test] + fn deny_beats_allow_for_the_same_subject() { + let rules = PermissionRules::parse( + r#" +allow = ["rm -rf *"] +deny = ["rm -rf *"] +"#, + ) + .expect("parse"); + assert_eq!(rules.decide("rm -rf build"), Some(RuleDecision::Deny)); + } + + #[test] + fn the_first_matching_group_wins_for_distinct_subjects() { + let rules = PermissionRules::parse(DOCUMENT).expect("parse"); + assert_eq!(rules.decide("git status"), Some(RuleDecision::Allow)); + assert_eq!( + rules.decide("git status --short"), + Some(RuleDecision::Allow) + ); + assert_eq!(rules.decide("cargo publish"), Some(RuleDecision::Ask)); + assert_eq!(rules.decide("rm -rf /"), Some(RuleDecision::Deny)); + assert_eq!(rules.decide("cargo build"), None); + } + + #[test] + fn an_empty_pattern_or_bare_star_is_refused() { + let empty = PermissionRules::parse(r#"allow = [""]"#).expect_err("empty"); + assert!(empty.to_string().contains("empty pattern"), "{empty}"); + + let star = PermissionRules::parse(r#"deny = ["*"]"#).expect_err("star"); + assert!(star.to_string().contains("match every"), "{star}"); + + let whitespace = PermissionRules::parse(r#"ask = [" "]"#).expect_err("whitespace"); + assert!( + whitespace.to_string().contains("empty pattern"), + "{whitespace}" + ); + } + + #[test] + fn malformed_documents_are_reported_not_ignored() { + let error = PermissionRules::parse("allow = \"git status\"").expect_err("type"); + assert!(error.to_string().contains("permissions.toml"), "{error}"); + } + + #[test] + fn glob_matching_is_anchored() { + assert!(glob_matches("git status", "git status")); + assert!(glob_matches("git status*", "git status --short")); + assert!(glob_matches("*rm -rf *", "sudo rm -rf /")); + assert!(glob_matches("src/*", "src/main.rs")); + assert!(glob_matches("*", "anything")); + + assert!(!glob_matches("git status", "git status --short")); + assert!(!glob_matches("git status*", "git stash")); + assert!(!glob_matches("cargo test*", "cargo build")); + assert!(!glob_matches("src/*", "tests/main.rs")); + } + + #[test] + fn a_project_without_a_rules_file_has_no_rules() { + let dir = tempfile::tempdir().expect("tempdir"); + assert!(load_for_project(dir.path()).expect("load").is_none()); + assert_eq!( + rules_path(dir.path()), + dir.path().join(PERMISSION_RULES_FILE) + ); + } + + #[test] + fn a_project_rules_file_is_read_from_disk() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = rules_path(dir.path()); + std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir"); + std::fs::write(&path, DOCUMENT).expect("write"); + let rules = load_for_project(dir.path()) + .expect("load") + .expect("present"); + assert_eq!(rules.decide("rm -rf /"), Some(RuleDecision::Deny)); + } + + #[test] + fn an_unparseable_file_surfaces_the_error() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = rules_path(dir.path()); + std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir"); + std::fs::write(&path, "allow = ").expect("write"); + let error = load_for_project(dir.path()).expect_err("parse"); + assert!(error.to_string().contains("permissions.toml"), "{error}"); + } + + #[test] + fn rule_labels_are_the_lock_words() { + assert_eq!(RuleDecision::Allow.label(), "allow"); + assert_eq!(RuleDecision::Ask.label(), "ask"); + assert_eq!(RuleDecision::Deny.label(), "deny"); + } +} diff --git a/src/cortex-tui/src/plugin_marketplace.rs b/src/cortex-tui/src/plugin_marketplace.rs new file mode 100644 index 00000000..181c3bbf --- /dev/null +++ b/src/cortex-tui/src/plugin_marketplace.rs @@ -0,0 +1,183 @@ +//! Plugin marketplace surface — the signed Cortex plugin registry. +//! +//! The CLI already ships a real registry client (`cortex plugin search/browse/ +//! install` against `software.cortex.foundation/plugins`). This module is the +//! TUI-facing half: the origin constant, the installed-plugin reader, and the +//! picker rows, so `/plugins` shows the live state instead of a stub list. +//! +//! Publishing is not implemented on the CLI side, so nothing here claims a +//! package was uploaded. + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +/// Signed Cortex plugin registry origin. +pub const REGISTRY_ORIGIN: &str = "software.cortex.foundation/plugins"; + +/// Installed-plugin state file, matching the CLI runtime. +pub const PLUGINS_STATE_FILE: &str = "plugins.json"; + +/// One installed plugin as the marketplace shows it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct InstalledPlugin { + /// Plugin id. + pub id: String, + /// Installed version. + pub version: String, + /// Whether the plugin is currently enabled. + #[serde(default = "default_enabled")] + pub enabled: bool, +} + +fn default_enabled() -> bool { + true +} + +impl InstalledPlugin { + /// Row copy for the picker. + pub fn status_line(&self) -> String { + format!( + "installed · v{}{}", + self.version, + if self.enabled { "" } else { " · disabled" } + ) + } +} + +/// The on-disk plugin state file. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginState { + /// Installed plugins. + #[serde(default)] + pub plugins: Vec, +} + +impl PluginState { + /// Parse a `plugins.json` document. + pub fn parse(document: &str) -> anyhow::Result { + let state: PluginState = serde_json::from_str(document) + .map_err(|error| anyhow::anyhow!("Could not parse {PLUGINS_STATE_FILE}: {error}"))?; + Ok(state) + } + + /// Picker rows: installed plugins first, then the registry search row. + pub fn marketplace_rows(&self) -> Vec<(String, String, String)> { + let mut rows: Vec<(String, String, String)> = self + .plugins + .iter() + .map(|plugin| (plugin.id.clone(), plugin.id.clone(), plugin.status_line())) + .collect(); + rows.push(( + "__search__".to_string(), + "Search the marketplace…".to_string(), + format!("{REGISTRY_ORIGIN} — signed index"), + )); + rows + } +} + +/// Read the plugin state for `cortex_home`, if the file exists. +/// +/// A missing file means nothing is installed yet, which is not an error. A file +/// that exists and cannot be parsed is an error: showing an empty marketplace +/// over a broken state file would hide real plugins. +pub fn load_state(cortex_home: &Path) -> anyhow::Result { + let path = state_path(cortex_home); + if !path.exists() { + return Ok(PluginState::default()); + } + let document = std::fs::read_to_string(&path) + .map_err(|error| anyhow::anyhow!("Could not read {}: {error}", path.display()))?; + PluginState::parse(&document).map_err(|error| anyhow::anyhow!("{}: {error}", path.display())) +} + +/// Path of the plugin state file for `cortex_home`. +pub fn state_path(cortex_home: &Path) -> PathBuf { + cortex_home.join(PLUGINS_STATE_FILE) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_registry_origin_is_the_cortex_host() { + assert!(REGISTRY_ORIGIN.starts_with("software.cortex.foundation")); + assert!(!REGISTRY_ORIGIN.contains("http"), "origin is host-relative"); + } + + #[test] + fn an_empty_state_still_offers_the_marketplace_row() { + let state = PluginState::default(); + let rows = state.marketplace_rows(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].0, "__search__"); + assert!(rows[0].2.contains(REGISTRY_ORIGIN)); + } + + #[test] + fn installed_plugins_lead_the_rows() { + let state = PluginState { + plugins: vec![ + InstalledPlugin { + id: "cortex-review".into(), + version: "0.4.1".into(), + enabled: true, + }, + InstalledPlugin { + id: "mermaid-preview".into(), + version: "0.2.0".into(), + enabled: false, + }, + ], + }; + let rows = state.marketplace_rows(); + assert_eq!(rows.len(), 3); + assert_eq!(rows[0].1, "cortex-review"); + assert_eq!(rows[0].2, "installed · v0.4.1"); + assert_eq!(rows[1].2, "installed · v0.2.0 · disabled"); + assert_eq!(rows[2].0, "__search__"); + } + + #[test] + fn a_missing_state_file_is_an_empty_marketplace() { + let dir = tempfile::tempdir().expect("tempdir"); + let state = load_state(dir.path()).expect("missing"); + assert!(state.plugins.is_empty()); + assert_eq!(state_path(dir.path()), dir.path().join(PLUGINS_STATE_FILE)); + } + + #[test] + fn a_state_file_is_read_from_disk() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write( + state_path(dir.path()), + r#"{"plugins":[{"id":"cortex-review","version":"1.0.0"}]}"#, + ) + .expect("write"); + let state = load_state(dir.path()).expect("load"); + assert_eq!(state.plugins.len(), 1); + assert!(state.plugins[0].enabled, "enabled defaults to true"); + } + + #[test] + fn a_broken_state_file_is_reported_not_hidden() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write(state_path(dir.path()), "{ not json").expect("write"); + let error = load_state(dir.path()).expect_err("broken"); + assert!(error.to_string().contains(PLUGINS_STATE_FILE), "{error}"); + } + + #[test] + fn nothing_here_claims_a_publish_succeeded() { + // Publishing is not implemented in the CLI; the marketplace surface must + // not offer it as if it were. + let rows = PluginState::default().marketplace_rows(); + for (_, label, description) in &rows { + let text = format!("{label} {description}").to_ascii_lowercase(); + assert!(!text.contains("publish"), "{text}"); + assert!(!text.contains("upload"), "{text}"); + } + } +} diff --git a/src/cortex-tui/src/runner/event_loop/commands.rs b/src/cortex-tui/src/runner/event_loop/commands.rs index e251f37c..3f39f63e 100644 --- a/src/cortex-tui/src/runner/event_loop/commands.rs +++ b/src/cortex-tui/src/runner/event_loop/commands.rs @@ -11,6 +11,7 @@ use crate::app::AppView; use crate::commands::{CommandResult, FormRegistry, ModalType, ViewType}; use crate::session::{ExportFormat, default_export_filename, export_session}; +use super::cor35::is_cor35_async_command; use super::core::EventLoop; impl EventLoop { @@ -393,6 +394,12 @@ impl EventLoop { let result = self.rewind_conversation(1); self.report_local_result(result, ""); } + // COR-35 batch surfaces (permission rules, sandbox allowlist, + // plugins, editor, browser, checkpoint rewind) dispatch together so + // this table stays under the complexity target. + cmd if is_cor35_async_command(cmd) => { + self.handle_cor35_async_command(cmd).await; + } "redo" => { let result = self.redo_conversation(); self.report_local_result(result, ""); diff --git a/src/cortex-tui/src/runner/event_loop/cor35.rs b/src/cortex-tui/src/runner/event_loop/cor35.rs new file mode 100644 index 00000000..f9540bbf --- /dev/null +++ b/src/cortex-tui/src/runner/event_loop/cor35.rs @@ -0,0 +1,315 @@ +//! COR-35 async command handlers on [`EventLoop`]. +//! +//! Split out of `event_loop/commands.rs` so the dispatch table there stays under +//! the source-policy complexity baseline. Each handler reads real project state +//! and reports a failure honestly instead of opening a picker over stale data. + +use anyhow::Result; + +use crate::cor35_handlers::{ + apply_allowlist_result, apply_plugin_marketplace, apply_rules_result, ide_status, + load_allowlist_outcome, load_rules_outcome, +}; +use crate::interactive::builders::build_question_prompt; +use crate::runner::event_loop::core::EventLoop; + +/// True when `cmd` is one of the COR-35 async command ids. +/// +/// Kept as one predicate so the dispatch table in `commands.rs` needs a single +/// arm for the whole batch. +pub(super) fn is_cor35_async_command(cmd: &str) -> bool { + matches!( + cmd, + "permissions:rules" | "sandbox:network" | "rewind:checkpoint" | "browser" | "ide" + ) || cmd == "plugins" + || cmd.starts_with("plugins:") + || cmd.starts_with("browser:") + || cmd.starts_with("ide:") +} + +impl EventLoop { + /// Dispatch a COR-35 async command to its surface. + pub(super) async fn handle_cor35_async_command(&mut self, cmd: &str) { + match cmd { + "permissions:rules" => self.open_permission_rules(), + "sandbox:network" => self.open_sandbox_allowlist(), + "rewind:checkpoint" => self.open_checkpoint_rewind(), + "browser" => self.open_browser_use(), + cmd if cmd.starts_with("browser:") => self.open_browser_use(), + "ide" => self.open_ide_handshake(), + cmd if cmd.starts_with("ide:") => self.open_ide_handshake(), + cmd if cmd == "plugins" || cmd.starts_with("plugins:") => { + self.open_plugin_marketplace(cmd); + } + other => { + self.add_system_message(&format!( + "This selection is unsupported in the current session. No operation was performed ({other})." + )); + } + } + } + + /// `/permissions rules` — the committed `.cortex/permissions.toml` rules. + pub(super) fn open_permission_rules(&mut self) { + let Some(cwd) = self.workspace_dir() else { + self.add_system_message("× Workspace directory is unavailable. Rules were not read."); + return; + }; + let outcome = load_rules_outcome(&cwd); + if let Ok(rules) = &outcome { + // The live manager applies the same rules the picker shows. + self.permission_manager.rules = rules.clone(); + } + apply_rules_result(&mut self.app_state, outcome); + } + + /// `/sandbox network` — the committed network allowlist. + pub(super) fn open_sandbox_allowlist(&mut self) { + let Some(cwd) = self.workspace_dir() else { + self.add_system_message("× Workspace directory is unavailable. Network stays blocked."); + return; + }; + apply_allowlist_result(&mut self.app_state, load_allowlist_outcome(&cwd)); + } + + /// `/plugins` — the signed Cortex marketplace with installed plugins. + pub(super) fn open_plugin_marketplace(&mut self, command: &str) { + // `/plugins install|enable|disable ` is the CLI's job; the TUI opens + // the marketplace and says so rather than pretending to run it. + if let Some(rest) = command.strip_prefix("plugins:") + && !rest.is_empty() + && rest != "list" + { + self.add_system_message(&format!( + "Run `cortex plugin {rest}` for that change; this sheet lists and searches." + )); + } + let installed = self.installed_plugins(); + apply_plugin_marketplace(&mut self.app_state, &installed); + } + + /// `/browser` — report the real browser-automation capability. + /// + /// Cortex ships no browser tool, so this never claims one. It reports which + /// connected MCP servers provide browser automation and states that their + /// calls still pass the sandbox and approvals. + pub(super) fn open_browser_use(&mut self) { + let servers: Vec = self + .app_state + .mcp_servers + .iter() + .map(|server| crate::browser_use::BrowserServer { + name: server.name.clone(), + running: matches!(server.status, crate::modal::mcp_manager::McpStatus::Running), + tool_count: server.tool_count, + }) + .collect(); + let capability = crate::browser_use::resolve_capability(&servers); + self.add_system_message(&capability.status_line()); + if !capability.is_available() { + self.add_system_message(crate::browser_use::NO_BUILTIN_TOOL_NOTE); + } + let narrow = self.app_state.terminal_size.0 <= 40; + let rows: Vec<(&str, &str, &str)> = if narrow { + vec![ + ("connect", "1 Connect an MCP server", "browser tools"), + ("runtime", "2 Computer runtime", "Cloud · This PC · SSH"), + ("cancel", "3 Cancel", "nothing changes"), + ] + } else { + vec![ + ( + "connect", + "1 Connect a browser MCP server", + "the CLI ships no browser tool", + ), + ( + "runtime", + "2 Computer runtime", + crate::browser_use::RUNTIME_NOTE, + ), + ("cancel", "3 Cancel", "no server is installed"), + ] + }; + self.app_state + .enter_interactive_mode(crate::interactive::builders::build_question_prompt( + "Browser", &rows, 0, + )); + } + + /// `/ide` — the ACP editor handshake. + pub(super) fn open_ide_handshake(&mut self) { + let narrow = self.app_state.terminal_size.0 <= 40; + self.add_system_message(&ide_status(narrow)); + self.app_state + .enter_interactive_mode(crate::interactive::builders::build_question_prompt( + "Editor (ACP)", + &[ + ("connect", "Connect an editor", "cortex acp · stdio"), + ( + "session", + "Share this session", + "same approvals and sandbox", + ), + ("stop", "Disconnect", "the CLI keeps running"), + ], + 0, + )); + } + + /// `/rewind` — pick a file checkpoint to restore. + pub(super) fn open_checkpoint_rewind(&mut self) { + let Some(dir) = self.checkpoint_dir() else { + self.add_system_message("× Checkpoint storage is unavailable."); + return; + }; + let checkpoints = match crate::checkpoint::list_checkpoints(&dir) { + Ok(checkpoints) => checkpoints, + Err(error) => { + self.add_system_message(&format!("× Could not read checkpoints: {error}")); + return; + } + }; + if checkpoints.is_empty() { + self.add_system_message( + "No file checkpoints yet. Cortex captures the files a turn is about to change.", + ); + return; + } + let rows: Vec<(String, String, String)> = checkpoints + .iter() + .map(|checkpoint| { + ( + checkpoint.id.clone(), + format!("Rewind to {}", checkpoint.id), + checkpoint.summary(), + ) + }) + .chain(std::iter::once(( + "__keep__".to_string(), + "Keep files".to_string(), + "conversation only".to_string(), + ))) + .collect(); + let borrowed: Vec<(&str, &str, &str)> = rows + .iter() + .map(|(id, label, description)| (id.as_str(), label.as_str(), description.as_str())) + .collect(); + self.app_state + .enter_interactive_mode(build_question_prompt("Rewind", &borrowed, 0)); + } + + /// Restore the checkpoint the user picked from the `/rewind` sheet. + /// + /// `__keep__` is the "leave the working tree alone" row, which reports that + /// nothing changed rather than silently closing. + pub(super) fn restore_checkpoint_choice(&mut self, item_id: &str) -> bool { + if item_id == "__keep__" { + self.add_system_message( + "Kept the working tree as it is. Use /undo for conversation history.", + ); + return false; + } + let Some(dir) = self.checkpoint_dir() else { + self.add_system_message("× Checkpoint storage is unavailable."); + return false; + }; + let Some(workspace) = self.workspace_dir() else { + self.add_system_message("× Workspace directory is unavailable."); + return false; + }; + let result = crate::checkpoint::read_checkpoint(&dir, item_id) + .and_then(|checkpoint| crate::checkpoint::restore(&workspace, &checkpoint)); + apply_restore_result(&mut self.app_state, result); + false + } + + /// Workspace directory from the live session, falling back to the process cwd. + fn workspace_dir(&self) -> Option { + self.cortex_session + .as_ref() + .map(|session| std::path::PathBuf::from(&session.meta.cwd)) + .or_else(|| std::env::current_dir().ok()) + } + + /// Cortex home that holds session state (checkpoints, plugin state). + fn cortex_home(&self) -> Option { + cortex_engine::config::find_cortex_home().ok() + } + + /// Session home for checkpoint storage. + fn checkpoint_dir(&self) -> Option { + Some( + self.cortex_home()? + .join("sessions") + .join(crate::checkpoint::CHECKPOINTS_DIR), + ) + } + + /// Installed plugins from the live state file. + fn installed_plugins(&self) -> Vec<(String, String)> { + let Some(home) = self.cortex_home() else { + return Vec::new(); + }; + match crate::plugin_marketplace::load_state(&home) { + Ok(state) => state + .plugins + .into_iter() + .map(|plugin| (plugin.id, plugin.version)) + .collect(), + Err(error) => { + // A broken state file is reported by the caller; an empty list + // here would hide real plugins. + tracing::warn!("plugin state unreadable: {error}"); + Vec::new() + } + } + } +} + +/// Apply a checkpoint restore result to the app state. +pub fn apply_restore_result( + state: &mut crate::app::AppState, + result: Result>, +) { + match result { + Ok(restored) => { + let summary = crate::checkpoint::restore_summary(&restored); + state.add_message(cortex_core::widgets::Message::system(summary)); + } + Err(error) => { + state.add_message(cortex_core::widgets::Message::system(format!( + "× Restore failed: {error}. The working tree was left as it was." + ))); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::AppState; + + #[test] + fn a_restore_result_is_reported_with_counts() { + let mut state = AppState::default(); + apply_restore_result( + &mut state, + Ok(vec![crate::checkpoint::RestoredFile { + path: "a.rs".into(), + action: crate::checkpoint::RestoreAction::Reverted, + }]), + ); + assert_eq!(state.messages.len(), 1); + assert!(state.messages[0].content.contains("1 restored")); + } + + #[test] + fn a_failed_restore_says_the_tree_is_untouched() { + let mut state = AppState::default(); + apply_restore_result(&mut state, Err(anyhow::anyhow!("disk full"))); + let text = &state.messages[0].content; + assert!(text.contains("Restore failed"), "{text}"); + assert!(text.contains("left as it was"), "{text}"); + } +} diff --git a/src/cortex-tui/src/runner/event_loop/mod.rs b/src/cortex-tui/src/runner/event_loop/mod.rs index 82397d46..39087095 100644 --- a/src/cortex-tui/src/runner/event_loop/mod.rs +++ b/src/cortex-tui/src/runner/event_loop/mod.rs @@ -29,6 +29,7 @@ mod actions; mod auth; mod commands; +mod cor35; mod core; mod handoff; mod input; diff --git a/src/cortex-tui/src/runner/event_loop/modal.rs b/src/cortex-tui/src/runner/event_loop/modal.rs index cf5c8362..a6d4ab66 100644 --- a/src/cortex-tui/src/runner/event_loop/modal.rs +++ b/src/cortex-tui/src/runner/event_loop/modal.rs @@ -736,6 +736,39 @@ impl EventLoop { false } "sandbox-deny" | "question" => false, + "rewind-checkpoint" => self.restore_checkpoint_choice(&item_id), + "permission-rules" => { + // The rules are read-only from the TUI; point at the file. + self.add_system_message( + "Rules are read from `.cortex/permissions.toml`. Edit the file, then reopen this sheet.", + ); + false + } + "sandbox-allowlist" => { + if item_id == "__add__" { + self.add_system_message( + "Add the host to `.cortex/sandbox.toml` under `allow`, then reopen this sheet.", + ); + } else { + self.add_system_message(&format!( + "{item_id} is allowed. Remove it from `.cortex/sandbox.toml` to block it again." + )); + } + false + } + "plugin-marketplace" => { + if item_id == "__search__" { + self.add_system_message(&format!( + "Search the registry with `cortex plugin search ` against {}.", + crate::plugin_marketplace::REGISTRY_ORIGIN + )); + } else { + self.add_system_message(&format!( + "{item_id} is installed. Use `cortex plugin disable {item_id}` to turn it off." + )); + } + false + } "mcp-source" | "mcp-transport" => self.handle_mcp_form_custom(&custom, &item_id), _ => { self.add_system_message("This selection is unsupported in the current session. No operation was performed."); diff --git a/src/cortex-tui/src/sandbox_allowlist.rs b/src/cortex-tui/src/sandbox_allowlist.rs new file mode 100644 index 00000000..225c82d1 --- /dev/null +++ b/src/cortex-tui/src/sandbox_allowlist.rs @@ -0,0 +1,323 @@ +//! Sandbox network allowlist — the domains a sandboxed command may reach. +//! +//! The sandbox decides egress as a single boolean today (see +//! `cortex_engine::sandbox::policy`). This module is the *UX* half: it holds the +//! domain list the user edits, and it fails closed — an entry that is not on the +//! list is blocked, and the list starts empty. +//! +//! Entries are hostnames only. A URL, a path, or a bare `*` is refused rather +//! than normalised, so the list cannot be widened by a typo. + +use anyhow::{Result, bail}; +use serde::{Deserialize, Serialize}; + +/// Project file that holds committed allowlist entries. +pub const SANDBOX_ALLOWLIST_FILE: &str = ".cortex/sandbox.toml"; + +/// One allowed host. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AllowedDomain { + /// Hostname, e.g. `github.com`. No scheme, port, or path. + pub host: String, + /// Optional note shown in the picker. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub note: Option, +} + +impl AllowedDomain { + /// Build an entry. + pub fn new(host: impl Into) -> Self { + Self { + host: host.into(), + note: None, + } + } + + /// Attach a note. + pub fn with_note(mut self, note: impl Into) -> Self { + self.note = Some(note.into()); + self + } +} + +/// The committed allowlist. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SandboxAllowlist { + /// Hosts that may be reached. + #[serde(default)] + pub allow: Vec, + /// Optional notes keyed by host. + #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub notes: std::collections::BTreeMap, +} + +impl SandboxAllowlist { + /// True when nothing is allowed — the fail-closed default. + pub fn is_empty(&self) -> bool { + self.allow.is_empty() + } + + /// Entries in file order. + pub fn domains(&self) -> Vec { + self.allow + .iter() + .map(|host| { + let mut entry = AllowedDomain::new(host.clone()); + entry.note = self.notes.get(host).cloned(); + entry + }) + .collect() + } + + /// Parse a `.cortex/sandbox.toml` document. + pub fn parse(document: &str) -> Result { + let list: SandboxAllowlist = + toml::from_str(document).map_err(|error| anyhow::anyhow!("{error}"))?; + for host in &list.allow { + validate_host(host)?; + } + Ok(list) + } + + /// True when `host` may be reached. + /// + /// Matching is exact or by parent domain: `github.com` allows + /// `api.github.com`, but `notgithub.com` is never a match. + pub fn allows(&self, host: &str) -> bool { + let host = host.trim().to_ascii_lowercase(); + if host.is_empty() { + return false; + } + self.allow.iter().any(|allowed| { + let allowed = allowed.trim().to_ascii_lowercase(); + host == allowed || host.ends_with(&format!(".{allowed}")) + }) + } + + /// Add a host, rejecting anything that is not a plain hostname. + pub fn add(&mut self, host: &str) -> Result<()> { + validate_host(host)?; + let host = host.trim().to_ascii_lowercase(); + if !self.allow.iter().any(|existing| existing == &host) { + self.allow.push(host); + } + Ok(()) + } + + /// Remove a host. Returns whether it was present. + pub fn remove(&mut self, host: &str) -> bool { + let host = host.trim().to_ascii_lowercase(); + let before = self.allow.len(); + self.allow.retain(|existing| existing != &host); + self.notes.remove(&host); + self.allow.len() != before + } +} + +/// Reject anything that is not a plain hostname. +/// +/// A scheme, port, path, whitespace, or a wildcard would each change what the +/// entry means, so they are refused instead of being stripped. +pub fn validate_host(host: &str) -> Result<()> { + let trimmed = host.trim(); + if trimmed.is_empty() { + bail!("A sandbox allowlist entry cannot be empty."); + } + if trimmed != host { + bail!("`{host}` has leading or trailing whitespace. Write `{trimmed}`."); + } + if trimmed.contains('*') { + bail!("`{trimmed}` uses a wildcard. List each host, so the allowlist stays explicit."); + } + if trimmed.contains("://") { + bail!("`{trimmed}` is a URL. Enter a hostname such as `github.com`."); + } + if trimmed.contains('/') { + bail!("`{trimmed}` contains a path. Enter a hostname such as `github.com`."); + } + if trimmed.contains(':') { + bail!("`{trimmed}` contains a port. Enter a hostname such as `github.com`."); + } + if trimmed.contains(char::is_whitespace) { + bail!("`{trimmed}` contains whitespace. Enter one hostname per entry."); + } + if trimmed.starts_with('.') || trimmed.ends_with('.') || trimmed.contains("..") { + bail!("`{trimmed}` is not a valid hostname."); + } + if !trimmed + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.') + { + bail!("`{trimmed}` contains characters that are not valid in a hostname."); + } + Ok(()) +} + +/// Load the committed allowlist for `cwd`, if the file exists. +pub fn load_for_project(cwd: &std::path::Path) -> Result> { + let path = allowlist_path(cwd); + if !path.exists() { + return Ok(None); + } + let document = std::fs::read_to_string(&path) + .map_err(|error| anyhow::anyhow!("Could not read {}: {error}", path.display()))?; + SandboxAllowlist::parse(&document) + .map(Some) + .map_err(|error| anyhow::anyhow!("{}: {error}", path.display())) +} + +/// Path of the committed allowlist file for `cwd`. +pub fn allowlist_path(cwd: &std::path::Path) -> std::path::PathBuf { + cwd.join(SANDBOX_ALLOWLIST_FILE) +} + +/// Status copy for the sandbox picker, so the count is never stale. +pub fn status_line(allowlist: &SandboxAllowlist) -> String { + match allowlist.allow.len() { + 0 => "Network is blocked — no domains are allowed.".to_string(), + 1 => "1 domain allowed; everything else is blocked.".to_string(), + count => format!("{count} domains allowed; everything else is blocked."), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_empty_allowlist_blocks_everything() { + let list = SandboxAllowlist::default(); + assert!(list.is_empty()); + assert!(!list.allows("github.com")); + assert!(!list.allows("crates.io")); + assert!(status_line(&list).contains("blocked")); + } + + #[test] + fn a_committed_file_parses_into_entries() { + let list = SandboxAllowlist::parse( + r#" +allow = ["crates.io", "github.com"] + +[notes] +"github.com" = "git fetch" +"#, + ) + .expect("parse"); + assert_eq!(list.domains().len(), 2); + assert_eq!( + list.domains()[1].note.as_deref(), + Some("git fetch"), + "notes are keyed by host" + ); + assert!(list.allows("crates.io")); + assert!(list.allows("github.com")); + assert!(!list.allows("example.com")); + } + + #[test] + fn a_parent_domain_allows_its_subdomains_but_not_lookalikes() { + let list = SandboxAllowlist::parse(r#"allow = ["github.com"]"#).expect("parse"); + assert!(list.allows("github.com")); + assert!(list.allows("api.github.com")); + assert!( + list.allows("API.GitHub.com"), + "matching is case-insensitive" + ); + assert!(!list.allows("notgithub.com")); + assert!(!list.allows("github.com.evil.test")); + assert!(!list.allows("")); + } + + #[test] + fn entries_that_would_widen_the_list_are_refused() { + for host in [ + "*", + "*.github.com", + "https://github.com", + "github.com/path", + "github.com:443", + " two hosts", + "github.com ", + ".github.com", + "github.com.", + "git..hub.com", + "github com", + "github_com", + ] { + assert!( + validate_host(host).is_err(), + "`{host}` must be refused so the allowlist stays explicit" + ); + } + for host in [ + "github.com", + "crates.io", + "api.cortex.foundation", + "a-b.test", + ] { + validate_host(host).expect(host); + } + } + + #[test] + fn adding_and_removing_entries_is_idempotent() { + let mut list = SandboxAllowlist::default(); + list.add("GitHub.com").expect("add"); + list.add("github.com").expect("add again"); + assert_eq!(list.allow, vec!["github.com".to_string()]); + + assert!(list.remove("github.com")); + assert!(!list.remove("github.com")); + assert!(list.is_empty()); + } + + #[test] + fn adding_a_bad_entry_does_not_change_the_list() { + let mut list = SandboxAllowlist::default(); + assert!(list.add("*").is_err()); + assert!(list.is_empty(), "a refused entry must not be stored"); + } + + #[test] + fn a_broken_file_is_reported_not_ignored() { + assert!(SandboxAllowlist::parse("allow = [").is_err()); + assert!( + SandboxAllowlist::parse(r#"allow = ["https://github.com"]"#).is_err(), + "a URL in the file must be refused" + ); + } + + #[test] + fn a_project_without_a_file_has_no_allowlist() { + let dir = tempfile::tempdir().expect("tempdir"); + assert!(load_for_project(dir.path()).expect("load").is_none()); + assert_eq!( + allowlist_path(dir.path()), + dir.path().join(SANDBOX_ALLOWLIST_FILE) + ); + } + + #[test] + fn a_project_file_is_read_from_disk() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = allowlist_path(dir.path()); + std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir"); + std::fs::write(&path, r#"allow = ["crates.io"]"#).expect("write"); + let list = load_for_project(dir.path()) + .expect("load") + .expect("present"); + assert!(list.allows("crates.io")); + } + + #[test] + fn status_copy_tracks_the_count() { + let mut list = SandboxAllowlist::default(); + assert!(status_line(&list).contains("no domains")); + list.add("crates.io").expect("add"); + assert!(status_line(&list).contains("1 domain")); + list.add("github.com").expect("add"); + assert!(status_line(&list).contains("2 domains")); + assert!(status_line(&list).contains("blocked")); + } +}