diff --git a/Cargo.lock b/Cargo.lock index 9c75b731..bceef897 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1410,7 +1410,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wally" -version = "0.8.13" +version = "0.8.14" dependencies = [ "brotli-decompressor", "ctrlc", diff --git a/Cargo.toml b/Cargo.toml index 65db49c6..b941c579 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ [package] name = "wally" # Must equal [product] version in versions.toml (scripts/ci/check-versions.py). -version = "0.8.13" +version = "0.8.14" edition = "2021" rust-version = "1.93" license = "Apache-2.0" diff --git a/Formula/wally.rb b/Formula/wally.rb index e6ea4156..5b7bc9ea 100644 --- a/Formula/wally.rb +++ b/Formula/wally.rb @@ -3,15 +3,15 @@ class Wally < Formula desc "Run models on your machine or your RunAnywhere account" homepage "https://github.com/RunanywhereAI/wally" - version "0.8.13" + version "0.8.14" license "MIT" # macOS arm64 ships the Swift MLX host # (llama.cpp + ONNX + Sherpa + MLX). Linux is not in this cut. on_macos do on_arm do - url "https://github.com/RunanywhereAI/wally/releases/download/v0.8.13/wally-0.8.13-macos-arm64.tar.gz" - # Placeholder -- the v0.8.13 release has not published a wally-named asset + url "https://github.com/RunanywhereAI/wally/releases/download/v0.8.14/wally-0.8.14-macos-arm64.tar.gz" + # Placeholder -- the v0.8.14 release has not published a wally-named asset # yet. scripts/release/update-tap.sh re-stamps this from the real release # checksum; a stale value here fails brew install's own hash check # rather than installing something unverified. diff --git a/README.md b/README.md index 52a562b2..59fc878d 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,23 @@ wally opencode The default model is `glm-5.3-flash`. To change it, run `wally models default `. +**Test an app you are authorised to test with an AI pentester:** + +```bash +wally shannon -- -u https://staging.example.com -r ./my-repo +``` + +Needs Docker. Node 18+ is required unless Shannon 3.4.0 is installed on PATH. See [Shannon](docs/EDITORS.md#shannon). + +**Scan repos you are authorised to test:** + +```bash +wally deepsec -- init . ./my-repo +wally vvah -- scan --repo ./my-repo +``` + +DeepSec 2.3.10 needs Node 18+ for `npx` unless it is on PATH. VVAH 1.5.1 needs Python 3.11–3.14 (it installs with pip into a venv). Models come from your signed-in account or `WALLY_CLOUD_KEY`. See [Security tools](docs/EDITORS.md#security-tools). + **Get help:** ```bash diff --git a/docs/EDITORS.md b/docs/EDITORS.md index 9fbbc347..fec4477f 100644 --- a/docs/EDITORS.md +++ b/docs/EDITORS.md @@ -31,6 +31,9 @@ tool starts the way you already have it configured, and wally wires nothing. | `deepseek` | a `--patch` overlay on the argv that adds the provider to dsh's `llm-pi-ai` row; nothing enters `$DSH_HOME` | | `prime-agent` | a temporary extension loaded with `-e` that registers the provider for the run, with the key in `RUNANYWHERE_API_KEY`; nothing enters `~/.prime/agent` | | `command-code` | a `runanywhere-wally` provider merged into `~/.commandcode/providers.json` (it reads providers from nowhere else) and taken out when it exits: the original bytes go back if the file is unchanged, otherwise only that provider comes out. The file names `$WALLY_COMMAND_CODE_API_KEY` rather than holding the key. A local model, or a person with no Command Code sign-in, runs with `CMD_LOCAL_ONLY=1` (no Command Code gateway models, `/share`, `/usage` or server-side web tools); with no sign-in a placeholder `COMMAND_CODE_API_KEY` gets past Command Code's login screen. `--skip-onboarding --no-auto-update` are passed, `-t` is left to you. One run at a time (a lock in `~/.commandcode/`). Its own subcommands (`-- status`, `-- login`, …) run untouched; `-- acp` is wired, and there the editor picks the model | +| `shannon` | not a coding tool: it runs [Shannon](#shannon) on a model from your account. A `models.json` in your wally profile and `SHANNON_AI_MODEL` / `SHANNON_AI_API_KEY` / `SHANNON_AI_BASE_URL` in the child's environment only | +| `deepsec` | not a coding tool: it runs [DeepSec](#deepsec) on a hosted model from your account. Pi custom routing flags are injected for `init`, `setup`, `process` and `revalidate`; the API key is in `WALLY_DEEPSEC_API_KEY` for the child only | +| `vvah` | not a coding tool: it runs [VVAH](#vvah) on a hosted model from your account. A `config.local.yaml` names the model per role; `OPENAI_API_KEY` and `OPENAI_BASE_URL` are set for the child only | Two flags go with `-m`. `--serve` holds the endpoint open and prints it instead of launching anything, which is how a tool nobody has taught wally about gets @@ -303,3 +306,212 @@ Any other OpenAI-compatible client sends the field itself, for example `"reasoning_effort": "max"` beside `model` and `messages`, or `extra_body={"reasoning_effort": "max"}` in the OpenAI Python SDK. +## Shannon + +[Shannon](https://github.com/KeygraphHQ/shannon) is an open-source AI pentester +(AGPL-3.0, by Keygraph). It is a separate project that wally does not bundle. +`wally shannon` points it at the models on your account. + +**Only scan systems you own or are explicitly authorised to test.** A scan +sends real attack traffic at the target URL. + +What you need: + +- Docker, running. Shannon starts its scan in containers. +- Node 18 or later, which brings `npx`. Skip this if you already have Shannon + `3.4.0` installed as `shannon` on your PATH. +- A signed-in account (`wally account login`), or a Cloud key from the console. +- Linux, macOS, or Windows through WSL2. Run wally inside WSL2 on Windows; + native Windows is refused. + +```bash +wally account login +wally shannon -- -u https://staging.example.com -r ./my-repo +``` + +The arguments after `--` are Shannon's own, and with no Shannon command wally +runs `start`. Other commands pass through untouched: + +```bash +wally shannon -- scans +wally shannon -- status my-scan +wally shannon -- logs my-scan +wally shannon -- stop my-scan +wally shannon -m glm-5.3-flash -- -u https://staging.example.com -r ./my-repo +``` + +`-m` picks the model; without it Shannon uses your default model +(`wally models default `) if your account has it. A model your account +does not have is refused before anything starts. + +For `start`, wally: + +- runs the Shannon release it was checked against (`3.4.0`): your `shannon` on + PATH if it reports exactly that version, otherwise + `npx --yes @keygraph/shannon@3.4.0`; +- refreshes your sign-in first if it would end within four hours; +- writes every model your account can use, with its real context and output + limits, to `shannon/models.json` in your wally profile (owner-only, replaced + on every run) and passes it as `--models-config`; +- sets `SHANNON_AI_MODEL=runanywhere:`, `SHANNON_AI_API_KEY` and + `SHANNON_AI_BASE_URL` (wally's endpoint) for Shannon's process only. The key + is never in the arguments or in `models.json`; +- removes `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `XAI_API_KEY` and + `WALLY_CLOUD_KEY` from Shannon's environment so none of them can take over + from your key. Your own shell is never changed. + +`--models-config` is wally's to set, so passing your own with `start` is refused. + +A signed-in session is short-lived. For a scan that runs longer than that, +create a Cloud key in the console and export it as `WALLY_CLOUD_KEY` before +running `wally shannon`; wally uses it instead of the session. It is read from +the environment only. + +### Without wally + +Shannon needs a Pi `models.json` describing the endpoint, and the model named +with the `runanywhere:` prefix chosen below. Write this as `models.json`: + +```json +{ + "providers": { + "runanywhere": { + "baseUrl": "https://inference.runanywhere.ai/v1", + "api": "openai-completions", + "authHeader": true, + "compat": { "supportsDeveloperRole": false }, + "models": [ + { "id": "glm-5.3-flash", "input": ["text"] } + ] + } + } +} +``` + +then: + +```bash +unset OPENAI_API_KEY ANTHROPIC_API_KEY XAI_API_KEY # these take over from the key below +export SHANNON_USE_PI_AUTH=0 # do not reuse a local Pi login +export SHANNON_AI_API_KEY='YOUR_CLOUD_KEY' +export SHANNON_AI_MODEL=runanywhere:glm-5.3-flash +npx --yes @keygraph/shannon@3.4.0 start -u https://staging.example.com -r ./my-repo \ + --models-config ./models.json +``` + +For each model from `GET /v1/models`, map `max_input_tokens` to +`contextWindow`, `max_output_tokens` to `maxTokens`, and `input_modalities` to +`input` so Shannon knows the model's real limits and inputs. Do not use the +`openai:` prefix. Shannon's built-in `openai` provider speaks the Responses +API, which the endpoint does not serve; the `runanywhere` provider above speaks +chat completions. `GET /v1/models` lists the ids and limits available to your +account. + +### Models that need approval + +Some models need approval before an account can use them. If a model is not on +your account's list, wally says so. That can also mean a mistyped or nonexistent +id: check the id against `GET /v1/models` first, and request access in the +console only if the model is gated. + +## Security tools + +`wally deepsec` and `wally vvah` point open-source repo scanners at models on +your account. They are hosted-only: there is no local-model path. **Only scan +repos and systems you own or are explicitly authorised to test.** + +Both need a signed-in account (`wally account login`), or a Cloud key from the +console. The key is never passed on the command line or written into config +files wally creates. For a run longer than a sign-in lasts, export a Cloud key +as `WALLY_CLOUD_KEY` before you start (for example +`export WALLY_CLOUD_KEY='YOUR_CLOUD_KEY'`); wally reads it from the environment +only and overrides competing variables for the child process only, never in +your shell. + +## DeepSec + +[DeepSec](https://www.npmjs.com/package/deepsec) is an npm CLI wally does not +bundle. Wally runs the release it was checked against (`2.3.10`): your `deepsec` +on PATH if it reports exactly that version, otherwise `npx --yes deepsec@2.3.10` +(same as Shannon). Bare `wally deepsec` is DeepSec `init` in +the current directory: run it from a repo you own, not from `$HOME`. DeepSec +still links a Vercel project (Sandbox identity) even when models go through +your RunAnywhere account. + +What you need: + +- Node 18 or later (`npx`). Skip this if `deepsec` `2.3.10` is on your PATH. +- A model on your account (`-m` or your default), from a signed-in account or + `WALLY_CLOUD_KEY`. + +```bash +wally account login +wally deepsec -- init . ./my-repo +``` + +With no DeepSec command after `--`, wally runs `init`. Other subcommands pass +through unless they are `init`, `setup`, `process` or `revalidate`, which get +the hosted route: + +```bash +wally deepsec -- status +wally deepsec -m glm-5.3-flash -- process +``` + +For those routed commands wally injects Pi-style flags ahead of yours: +`--agent pi`, `--model runanywhere/` (Pi requires `provider/model`), `--model-auth custom`, `--ai-provider runanywhere`, +`--ai-base-url `, `--ai-api-key-env WALLY_DEEPSEC_API_KEY`, and +`--ai-credential-header authorization:bearer`. It writes `models.json` under the +Wally profile and sets `PI_CODING_AGENT_DIR` plus `WALLY_DEEPSEC_API_KEY` for +DeepSec's process, and removes `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, +`AI_GATEWAY_API_KEY` and `WALLY_CLOUD_KEY` from the child so nothing else can +override your key. Passing your own copy of any of those flags (`--agent`, +`--model`, `--model-auth`, `--ai-provider`, `--ai-base-url`, `--ai-api-key-env`, +`--ai-credential-header`) on those commands is refused; choose the model with +wally's `-m`. + +## VVAH + +[VVAH](https://github.com/visa/visa-vulnerability-agentic-harness) is a Python +CLI wally does not bundle. Wally runs the release it was checked against +(`1.5.1`). If `vvaharness` is missing, wally prints the install command, asks +``Install it now with `…`? [y/N]``, and on yes creates a venv under the Wally +profile (`/vvah/`) and pip-installs the `v1.5.1` tag. + +What you need: + +- Python 3.11–3.14 on PATH (`python3.12` is preferred; Homebrew's `python3` is + often 3.15, which VVAH cannot install on). Checked after you confirm install. +- A model on your account (`-m` or your default), from a signed-in account or + `WALLY_CLOUD_KEY`. + +```bash +wally account login +wally vvah +``` + +With no VVAH command after `--`, wally runs `scan --repo . --remediate`. Run +that from a project directory, not `$HOME` (the profile config lives under +`~/.config/wally`, and VVAH refuses a `--config` inside the scan target). Other +commands pass through: + +```bash +wally vvah -- scan --repo ./my-repo +wally vvah -m glm-5.3-flash -- scan --repo ./my-repo +``` + +Each run rewrites `/vvah/config.local.yaml` (owner-only, 0600 on macOS +and Linux; on Windows it inherits the folder's permissions). If the profile +directory is inside the scan target, wally stages the config in a folder under +the system temp directory instead. Every active scanner role maps to your chosen +model with `via: openai`; the file never contains the API key. The child gets +`OPENAI_API_KEY` and `OPENAI_BASE_URL`, and wally removes `ANTHROPIC_API_KEY`, +`ANTHROPIC_SDK_API_KEY`, `ANTHROPIC_AUTH_TOKEN` and `WALLY_CLOUD_KEY` from it. VVAH's S10 stage can edit files on disk as part of a +scan; run it only on trees you are allowed to change. + +If you confirm install and no supported Python (3.11–3.14, found as +`python3.12`, `python3.11`, `python3.13`, `python3.14` or `python3`) is on PATH, +wally prints `python 3.11 is not installed on this machine, Please install it first, Thank You` +and exits. If `python3` is newer than 3.14 it prints +`VVAH needs Python 3.11–3.14 (python3 is 3.15). Install Python 3.12, then run this again.` +instead. The venv is left in place for the next run. diff --git a/scripts/test/golden-capture.py b/scripts/test/golden-capture.py index 4a9794d2..c2f82348 100755 --- a/scripts/test/golden-capture.py +++ b/scripts/test/golden-capture.py @@ -21,8 +21,9 @@ """ import argparse, json, os, re, shutil, subprocess, sys, tempfile -PASSTHROUGH = {'opencode', 'claude-code', 'claude-desktop', 'hermes', 'openclaw', 'deepseek'} -SECTIONS = {'Commands', 'Chat', 'Models', 'Coding tools', 'Account', 'Wally'} +PASSTHROUGH = {'opencode', 'claude-code', 'claude-desktop', 'hermes', 'openclaw', 'deepseek', + 'shannon', 'deepsec', 'vvah'} +SECTIONS = {'Commands', 'Chat', 'Models', 'Coding tools', 'Security tools', 'Account', 'Wally'} # Leaf commands whose invalid invocations are safe to run (parse fails first). SAFE_LEAVES = ['run', 'llm generate', 'llm stream', 'llm tool-call', 'serve', 'models list', 'models show', 'models pull', 'models rm', 'models default', diff --git a/skills/runanywhere/SKILL.md b/skills/runanywhere/SKILL.md index cf555c16..162d73a4 100644 --- a/skills/runanywhere/SKILL.md +++ b/skills/runanywhere/SKILL.md @@ -62,6 +62,33 @@ Offer to explain a harness before running it. Someone who has just signed up does not yet know what opencode is, and "would you like me to explain how the opencode harness works?" is a better second message than a launched TUI. +## Shannon and other tools + +`wally shannon -- -u "$APP_URL" -r "$REPO_PATH"` runs Shannon, an open-source AI +pentester, on a model from the person's account. Only suggest it for a system +they own or are authorised to test. It needs Docker running, plus Node 18+ unless Shannon 3.4.0 is +installed on PATH, and wally sets up the model config and key itself. For a scan longer than a +sign-in lasts, the person exports a console Cloud key as `WALLY_CLOUD_KEY`. +Setup details, and how to point any other OpenAI-compatible tool at the +endpoint by hand, are in `docs/EDITORS.md`. + +## Security tools + +`wally deepsec` and `wally vvah` run hosted security scanners on a model from the +person's account. Only suggest them for a repo or system they own or are +authorised to test. DeepSec (2.3.10) needs Node 18+ for `npx` unless a `deepsec` on PATH reports exactly 2.3.10; VVAH (1.5.1) needs +Python 3.11–3.14. Wally sets up routing per tool (command-line flags for DeepSec, a +config file in the Wally profile for VVAH); keys go in the child environment +only, never on the command line, and competing variables are removed for that +child only. For a +run longer than a sign-in lasts, they export a console Cloud key as +`WALLY_CLOUD_KEY`. Details are in `docs/EDITORS.md`. + +```bash +wally deepsec -- init . "$REPO_PATH" +wally vvah -- scan --repo "$REPO_PATH" +``` + ## Running a model directly ```bash @@ -103,6 +130,10 @@ harness; those surfaces refuse it and point back here. ## When something is wrong - **"not signed in"** — `wally account login`. +- **"not available on this account" or "not on your account's model list"** — + that model may need approval. Check the model id first; if it is right and the + model is gated, request access in the console. Do not log in again. +- **DeepSec or VVAH prerequisites missing** — install Node 18+ (DeepSec) or Python 3.11–3.14 (VVAH); see `docs/EDITORS.md`. - **"that key is not valid"** — the key was revoked or expired; `wally account login` again. - **opencode not installed** — `npm i -g opencode-ai`. - **a model is slow or unavailable** — `wally backends` shows which engines this diff --git a/src/app.rs b/src/app.rs index 64690462..a0f7c627 100644 --- a/src/app.rs +++ b/src/app.rs @@ -134,8 +134,13 @@ pub fn configure_app(app: &mut App) { "deepseek", "prime-agent", "command-code", + "shannon", ], }, + Section { + group: "Security tools", + names: &["deepsec", "vvah"], + }, Section { group: "Account", names: &["account"], @@ -178,6 +183,9 @@ fn is_passthrough_command(token: &str) -> bool { "deepseek", "prime-agent", "command-code", + "shannon", + "deepsec", + "vvah", ]; NAMES.contains(&token) } diff --git a/src/cli_formatter.rs b/src/cli_formatter.rs index 3eac2828..8f34e927 100644 --- a/src/cli_formatter.rs +++ b/src/cli_formatter.rs @@ -1200,6 +1200,11 @@ mod tests { "Open OpenCode with a local or cloud model", "Coding tools", ), + leaf( + "shannon", + "Test your own apps with Shannon, an AI pentester", + "Coding tools", + ), leaf("hermes", "Open Hermes with a model", "Coding tools"), leaf("openclaw", "Open OpenClaw with a model", "Coding tools"), leaf( @@ -1217,6 +1222,12 @@ mod tests { "Open Command Code with a model", "Coding tools", ), + leaf( + "deepsec", + "Scan your own repos with DeepSec", + "Security tools", + ), + leaf("vvah", "Scan your own repos with VVAH", "Security tools"), namespace( "account", "Account", diff --git a/src/commands/cmd_harness.rs b/src/commands/cmd_harness.rs index 945b004f..5c6bf9a6 100644 --- a/src/commands/cmd_harness.rs +++ b/src/commands/cmd_harness.rs @@ -68,6 +68,112 @@ pub fn register_harness(app: &mut App) { harness::launch("opencode", &effective, &p.get_strs("args"), g) }); + // Shannon is not a coding harness: it is an open-source tool for + // authorised security testing that runs its own Docker and Temporal stack. + // So it has its own launcher, which writes the model config it needs and + // runs the pinned release, rather than a row in the agents table. + let shannon = app.add_subcommand( + "shannon", + "Test your own apps with Shannon, an AI pentester", + ); + shannon.footer(&examples_footer(&[ + Example::new( + "wally shannon -- -u https://staging.example.com -r ./my-repo", + "Scan a target you are authorised to test (starts a scan)", + ), + Example::new( + "wally shannon -m glm-5.3-flash -- -u https://staging.example.com -r ./my-repo", + "Choose the model for the scan", + ), + Example::new("wally shannon -- scans", "List running and finished scans"), + Example::new("wally shannon -- logs my-scan", "Follow a scan's log"), + ])); + shannon.add_option( + "-m,--model", + ValueType::Text, + "A model from your account (default: your default model)", + ); + shannon + .add_option( + "args", + ValueType::Text, + "Shannon's own command and options; with none, start", + ) + .multi(); + shannon.prefix_command(true); + shannon.callback(|p, _g| { + if harness::refuse_decisions_model(p.get_str("--model").as_deref()) { + return 2; + } + harness::shannon::launch(p.get_str("--model").as_deref(), &p.get_strs("args")) + }); + + let deepsec = app.add_subcommand("deepsec", "Scan your own repos with DeepSec"); + deepsec.footer(&examples_footer(&[ + Example::new( + "wally deepsec -- init . ./my-repo", + "Start a scan on a repo you are authorised to test (runs init)", + ), + Example::new( + "wally deepsec -m glm-5.3-flash -- process", + "Choose the model", + ), + Example::new( + "wally deepsec -- status", + "Other DeepSec commands pass through", + ), + ])); + deepsec.add_option( + "-m,--model", + ValueType::Text, + "A model from your account (default: your default model)", + ); + deepsec + .add_option( + "args", + ValueType::Text, + "DeepSec's own command and options; with none, init", + ) + .multi(); + deepsec.prefix_command(true); + deepsec.callback(|p, _g| { + if harness::refuse_decisions_model(p.get_str("--model").as_deref()) { + return 2; + } + harness::deepsec::launch(p.get_str("--model").as_deref(), &p.get_strs("args")) + }); + + let vvah = app.add_subcommand("vvah", "Scan your own repos with VVAH"); + vvah.footer(&examples_footer(&[ + Example::new( + "wally vvah", + "Scan and remediate (edits files in) the current repo", + ), + Example::new( + "wally vvah -m glm-5.3-flash -- scan --repo ./my-repo", + "Scan another path with a chosen model", + ), + Example::new("wally vvah -- report", "Other VVAH commands pass through"), + ])); + vvah.add_option( + "-m,--model", + ValueType::Text, + "A model from your account (default: your default model)", + ); + vvah.add_option( + "args", + ValueType::Text, + "VVAH's own command and options; with none, scan --repo . --remediate", + ) + .multi(); + vvah.prefix_command(true); + vvah.callback(|p, _g| { + if harness::refuse_decisions_model(p.get_str("--model").as_deref()) { + return 2; + } + harness::vvah::launch(p.get_str("--model").as_deref(), &p.get_strs("args")) + }); + // The OpenAI-shaped agents, one subcommand per row of the table. They need // no translator, so there is nothing here but resolving the model and // handing the endpoint over the way each one takes it. diff --git a/src/harness/catalog_models.rs b/src/harness/catalog_models.rs index 892d5d8a..f5879712 100644 --- a/src/harness/catalog_models.rs +++ b/src/harness/catalog_models.rs @@ -5,7 +5,7 @@ //! model the console advertises rather than only the one the person launched. //! The launched model stays first so it remains the default. -use crate::account::{ConsoleClient, ReasoningSupport}; +use crate::account::{ConsoleClient, IdentityResult, ReasoningSupport}; use super::harness::Endpoint; use super::local_models::{local_context_size, local_output_size}; @@ -109,20 +109,23 @@ fn primary_first(models: &mut Vec, primary: &str) { } } -/// Every hosted model the console advertises, each with its limits and price, -/// `primary` first so it stays the harness default. Fetches the live catalog -/// through `console` from `console_url` with `access_token`; `primary` is -/// always present even if the catalog does not name it. The `console` overload -/// is the test seam. -pub fn catalog_models_with( +/// Every hosted model the account can use, each with its limits and price, in +/// the console's order, and what happened asking for them: the identity result +/// and message of the model-list call, so a caller that must not guess (an +/// empty list from an offline machine is not an empty account) can tell. The +/// price call is best effort; a failed one leaves the prices at zero. +pub fn account_models_with( console: &ConsoleClient, console_url: &str, access_token: &str, - primary: &str, -) -> Vec { +) -> (IdentityResult, Vec, String) { let mut out = Vec::new(); - let (_, models, _) = console.fetch_models(console_url, access_token); + let (result, models, error) = console.fetch_models(console_url, access_token); + if !matches!(result, IdentityResult::Ok) { + // Nothing to price; callers stop on a failed model list. + return (result, out, error); + } let (_, prices, _) = console.fetch_catalog(console_url, access_token); let price_for = |id: &str| -> (i64, i64, i64) { prices @@ -150,6 +153,21 @@ pub fn catalog_models_with( reasoning: info.reasoning.clone(), }); } + (result, out, error) +} + +/// Every hosted model the console advertises, each with its limits and price, +/// `primary` first so it stays the harness default. Fetches the live catalog +/// through `console` from `console_url` with `access_token`; `primary` is +/// always present even if the catalog does not name it. The `console` overload +/// is the test seam. +pub fn catalog_models_with( + console: &ConsoleClient, + console_url: &str, + access_token: &str, + primary: &str, +) -> Vec { + let (_, mut out, _) = account_models_with(console, console_url, access_token); primary_first(&mut out, primary); out } diff --git a/src/harness/deepsec.rs b/src/harness/deepsec.rs new file mode 100644 index 00000000..31505015 --- /dev/null +++ b/src/harness/deepsec.rs @@ -0,0 +1,865 @@ +//! `wally deepsec`: DeepSec pointed at the models on a RunAnywhere account. +//! +//! DeepSec's custom OpenAI-compatible route is the Pi backend. This launcher +//! injects that route, sets the key for the child only, and otherwise gets +//! out of the way. + +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use serde_json::json; + +use crate::account::{self, ConsoleClient}; +use crate::io::output as out; + +use super::hosted::{self, CLOUD_KEY_VARIABLE}; + +/// The DeepSec release this launcher was written against. +pub const PINNED_VERSION: &str = "2.3.10"; + +/// The npm package that ships the `deepsec` command. +pub const PACKAGE: &str = "deepsec"; + +/// The provider name passed as `--ai-provider`. +pub const PROVIDER_ID: &str = "runanywhere"; + +/// Where DeepSec reads the key. Only the name is stored in its config. +pub const KEY_VARIABLE: &str = "WALLY_DEEPSEC_API_KEY"; + +/// Pi reads `models.json` from this directory. Isolated under the Wally profile +/// so we do not rewrite `~/.pi/agent`. +pub const PI_AGENT_DIR_VARIABLE: &str = "PI_CODING_AGENT_DIR"; + +pub const PREREQUISITES: &str = + "DeepSec needs Node 18 or later (it provides npx). Install Node from https://nodejs.org, \ +then run this again."; + +pub fn npx_invoke() -> String { + format!("npx --yes {PACKAGE}@{PINNED_VERSION}") +} + +/// Bare `wally deepsec` runs DeepSec `init` in the current directory. Home is +/// not a repo to scan: DeepSec will treat it as the workspace (and may walk +/// `~/.Trash`). +pub fn refuse_home_workspace(args: &[String], cwd: &Path, home: Option<&Path>) -> bool { + if !args.is_empty() { + return false; + } + let Some(home) = home else { + return false; + }; + paths_are_same_dir(cwd, home) +} + +fn paths_are_same_dir(a: &Path, b: &Path) -> bool { + if a == b { + return true; + } + match (std::fs::canonicalize(a), std::fs::canonicalize(b)) { + (Ok(a), Ok(b)) => a == b, + _ => false, + } +} + +const VERSION_PROBE_SECONDS: u64 = 20; + +const ROUTE_COMMANDS: [&str; 4] = ["init", "setup", "process", "revalidate"]; + +/// Every flag `route_flags` injects. A later copy from the person could win and +/// break the route (a bare `--model` is not in the generated `models.json`). +const OWNED_FLAGS: [&str; 7] = [ + "--agent", + "--model", + "--model-auth", + "--ai-provider", + "--ai-base-url", + "--ai-api-key-env", + "--ai-credential-header", +]; + +const OVERRIDING_VARIABLES: [&str; 4] = [ + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "AI_GATEWAY_API_KEY", + CLOUD_KEY_VARIABLE, +]; + +// --------------------------------------------------------------------------- +// The command line +// --------------------------------------------------------------------------- + +/// What the person asked DeepSec to do. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Invocation { + /// A command that takes Wally's Pi custom route. + Routed { command: String, rest: Vec }, + /// Anything else, forwarded untouched. + Passthrough(Vec), +} + +fn is_help_or_version_flag(arg: &str) -> bool { + matches!(arg, "-h" | "--help" | "-v" | "--version") +} + +fn names_owned_flag(arg: &str) -> bool { + OWNED_FLAGS + .iter() + .any(|flag| arg == *flag || arg.starts_with(&format!("{flag}="))) +} + +fn route_error() -> String { + "wally deepsec sets the model route itself, from your account. Remove --agent, --model, \ + --model-auth, --ai-provider, --ai-base-url, --ai-api-key-env and --ai-credential-header \ + (choose the model with wally's -m), or run DeepSec directly to use your own route." + .to_string() +} + +/// Reads DeepSec's arguments. No subcommand, or a first argument that is an +/// option, means `init`; `setup`/`process`/`revalidate` also get the route. +pub fn classify(args: &[String]) -> Result { + let first = args.first().map(String::as_str); + let (command, rest): (String, Vec) = match first { + None => ("init".to_string(), Vec::new()), + Some(flag) if is_help_or_version_flag(flag) => { + return Ok(Invocation::Passthrough(args.to_vec())) + } + Some(flag) if flag.starts_with('-') => ("init".to_string(), args.to_vec()), + Some(command) if ROUTE_COMMANDS.contains(&command) => { + (command.to_string(), args[1..].to_vec()) + } + Some(_) => return Ok(Invocation::Passthrough(args.to_vec())), + }; + if rest.iter().any(|arg| names_owned_flag(arg)) { + return Err(route_error()); + } + if rest + .iter() + .any(|arg| matches!(arg.as_str(), "-h" | "--help")) + { + let mut argv = vec![command]; + argv.extend(rest); + return Ok(Invocation::Passthrough(argv)); + } + Ok(Invocation::Routed { command, rest }) +} + +/// Pi requires `provider/model` and looks that pair up in `models.json`. +/// Custom OpenAI-compatible routes are registered as `runanywhere/`. +pub fn pi_model_id(model: &str) -> String { + if model.contains('/') { + model.to_string() + } else { + format!("{PROVIDER_ID}/{model}") + } +} + +pub fn models_json_model_id(model: &str) -> &str { + model.rsplit_once('/').map(|(_, id)| id).unwrap_or(model) +} + +/// Pi's registry for the hosted provider. No key; auth is the child env var. +pub fn models_json(base_url: &str, model: &str) -> String { + serde_json::to_string_pretty(&json!({ + "providers": { + PROVIDER_ID: { + "baseUrl": base_url, + "api": "openai-completions", + "authHeader": true, + "compat": { "supportsDeveloperRole": false }, + "models": [{ + "id": models_json_model_id(model), + "name": models_json_model_id(model), + "input": ["text"] + }] + } + } + })) + .expect("models.json is a closed object") +} + +pub fn pi_agent_directory() -> Option { + let profile = account::profile_directory(); + if profile.is_empty() { + return None; + } + Some(PathBuf::from(profile).join("deepsec").join("agent")) +} + +pub fn write_pi_models_json(dir: &Path, base_url: &str, model: &str) -> Result { + if std::fs::create_dir_all(dir).is_err() { + return Err(format!("could not create {}", dir.display())); + } + let path = dir.join("models.json"); + std::fs::write(&path, models_json(base_url, model)) + .map_err(|error| format!("could not write {}: {error}", path.display()))?; + Ok(path) +} + +/// The Pi custom-route flags, then the person's own options. +pub fn route_flags(model: &str, base_url: &str) -> Vec { + vec![ + "--agent".to_string(), + "pi".to_string(), + "--model".to_string(), + pi_model_id(model), + "--model-auth".to_string(), + "custom".to_string(), + "--ai-provider".to_string(), + PROVIDER_ID.to_string(), + "--ai-base-url".to_string(), + base_url.to_string(), + "--ai-api-key-env".to_string(), + KEY_VARIABLE.to_string(), + "--ai-credential-header".to_string(), + "authorization:bearer".to_string(), + ] +} + +impl Invocation { + pub fn argv(&self, model: &str, base_url: &str) -> Vec { + match self { + Invocation::Routed { command, rest } => { + let mut argv = vec![command.clone()]; + argv.extend(route_flags(model, base_url)); + argv.extend(rest.iter().cloned()); + argv + } + Invocation::Passthrough(args) => args.clone(), + } + } +} + +// --------------------------------------------------------------------------- +// The child's environment +// --------------------------------------------------------------------------- + +/// The environment changes for the DeepSec child. Holds the key, so it has no +/// `Debug`. +pub struct ChildEnv { + pub set: Vec<(&'static str, String)>, + pub remove: Vec<&'static str>, +} + +impl ChildEnv { + pub fn for_start(key: &str, agent_dir: &Path) -> Self { + ChildEnv { + set: vec![ + (KEY_VARIABLE, key.to_string()), + ( + PI_AGENT_DIR_VARIABLE, + agent_dir.to_string_lossy().into_owned(), + ), + ], + remove: OVERRIDING_VARIABLES.to_vec(), + } + } + + pub fn unchanged() -> Self { + ChildEnv { + set: Vec::new(), + remove: Vec::new(), + } + } + + fn apply(&self, command: &mut Command) { + for name in &self.remove { + command.env_remove(name); + } + for (name, value) in &self.set { + command.env(name, value); + } + } +} + +// --------------------------------------------------------------------------- +// Which DeepSec runs +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Runner { + Installed(PathBuf), + Npx(PathBuf), +} + +impl Runner { + fn program(&self) -> &Path { + match self { + Runner::Installed(path) | Runner::Npx(path) => path, + } + } + + fn prefix(&self) -> Vec { + match self { + Runner::Installed(_) => Vec::new(), + Runner::Npx(_) => vec!["--yes".to_string(), format!("{PACKAGE}@{PINNED_VERSION}")], + } + } +} + +fn is_executable(path: &Path) -> bool { + let Ok(metadata) = std::fs::metadata(path) else { + return false; + }; + if !metadata.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + metadata.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + true + } +} + +/// DeepSec init drops the CLI into `/.deepsec/node_modules`. That is an +/// install even when `deepsec` is not on PATH. +pub fn workspace_deepsec(cwd: &Path) -> Option { + let path = cwd + .join(".deepsec") + .join("node_modules") + .join(".bin") + .join("deepsec"); + is_executable(&path).then_some(path) +} + +pub fn find_on_path(name: &str, search_path: &OsStr) -> Option { + let names: Vec = if cfg!(windows) { + vec![ + format!("{name}.exe"), + format!("{name}.cmd"), + name.to_string(), + ] + } else { + vec![name.to_string()] + }; + for dir in std::env::split_paths(search_path) { + if dir.as_os_str().is_empty() { + continue; + } + for candidate in &names { + let path = dir.join(candidate); + if is_executable(&path) { + return Some(path); + } + } + } + None +} + +pub fn parse_version(output: &str) -> Option { + output + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .and_then(|line| { + line.split_whitespace().find_map(|word| { + let word = word.strip_prefix('v').unwrap_or(word); + word.chars() + .next() + .is_some_and(|c| c.is_ascii_digit()) + .then(|| word.to_string()) + }) + }) +} + +fn retry_busy(mut start: impl FnMut() -> std::io::Result) -> std::io::Result { + let mut attempts = 0; + loop { + match start() { + Err(error) + if error.kind() == std::io::ErrorKind::ExecutableFileBusy && attempts < 40 => + { + attempts += 1; + std::thread::sleep(Duration::from_millis(25)); + } + other => return other, + } + } +} + +pub fn probe_version(deepsec: &Path) -> Option { + let mut child = retry_busy(|| { + Command::new(deepsec) + .arg("--version") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + }) + .ok()?; + let deadline = Instant::now() + Duration::from_secs(VERSION_PROBE_SECONDS); + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(25)), + _ => { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + } + }; + if !status.success() { + return None; + } + let mut text = String::new(); + std::io::Read::read_to_string(&mut child.stdout.take()?, &mut text).ok()?; + parse_version(&text) +} + +pub fn select_runner( + search_path: &OsStr, + probe: &dyn Fn(&Path) -> Option, + preferred: Option<&Path>, +) -> Result<(Runner, Option), String> { + let mut note = None; + let installed = preferred + .filter(|path| is_executable(path)) + .map(Path::to_path_buf) + .or_else(|| find_on_path("deepsec", search_path)); + if let Some(installed) = installed { + match probe(&installed) { + Some(version) + if version == PINNED_VERSION + || version.strip_prefix('v') == Some(PINNED_VERSION) => + { + return Ok((Runner::Installed(installed), None)); + } + Some(version) => { + note = Some(format!( + "the installed deepsec is {version}, not {PINNED_VERSION}; using npx \ + {PACKAGE}@{PINNED_VERSION} instead" + )); + } + None => { + note = Some(format!( + "could not read the version of the installed deepsec; using npx \ + {PACKAGE}@{PINNED_VERSION} instead" + )); + } + } + } + match find_on_path("npx", search_path) { + Some(npx) => Ok((Runner::Npx(npx), note)), + None => Err(match note { + Some(note) => format!("{note}, but npx was not found. {PREREQUISITES}"), + None => format!("deepsec and npx were not found. {PREREQUISITES}"), + }), + } +} + +// --------------------------------------------------------------------------- +// Running it +// --------------------------------------------------------------------------- + +pub fn run_child(runner: &Runner, argv: &[String], env: &ChildEnv) -> i32 { + let interrupted = Arc::new(AtomicBool::new(false)); + let noted = Arc::clone(&interrupted); + let _interrupt = crate::util::interrupt::on_interrupt(move || { + noted.store(true, Ordering::SeqCst); + }); + + let mut command = Command::new(runner.program()); + command.args(runner.prefix()).args(argv); + env.apply(&mut command); + let status = match retry_busy(|| command.status()) { + Ok(status) => status, + Err(error) => { + out::error_line(&format!("could not start DeepSec: {error}")); + return 127; + } + }; + if let Some(code) = status.code() { + return code; + } + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + if let Some(signal) = status.signal() { + return 128 + signal; + } + } + if interrupted.load(Ordering::SeqCst) { + 130 + } else { + 1 + } +} + +// --------------------------------------------------------------------------- +// The command +// --------------------------------------------------------------------------- + +/// `wally deepsec [-m ] [--] [] [args...]`. +pub fn launch(requested_model: Option<&str>, args: &[String]) -> i32 { + if let Err(code) = hosted::require_safe_model(requested_model) { + return code; + } + let invocation = match classify(args) { + Ok(invocation) => invocation, + Err(message) => { + out::error_line(&message); + return 2; + } + }; + + let cwd = std::env::current_dir().unwrap_or_default(); + let home = hosted::home_directory(); + if refuse_home_workspace(args, &cwd, home.as_deref()) { + out::error_line( + "wally deepsec with no extra args runs DeepSec init in the current directory. \ + That is your home directory, which DeepSec would treat as the workspace. \ + cd into a repo you are authorised to test.", + ); + return 2; + } + + let search_path = std::env::var_os("PATH").unwrap_or_default(); + let (runner, note) = match select_runner( + &search_path, + &probe_version, + workspace_deepsec(&cwd).as_deref(), + ) { + Ok(selected) => selected, + Err(message) => { + out::error_line(&message); + return 127; + } + }; + if let Some(note) = note { + out::status_line(¬e); + } else if matches!(runner, Runner::Npx(_)) { + out::status_line(&format!("using `{}`", npx_invoke())); + out::status_line( + "DeepSec init also links a Vercel project for Sandbox identity; models still use your RunAnywhere account.", + ); + } + + let Invocation::Routed { .. } = &invocation else { + if requested_model.is_some() { + out::status_line("-m only applies to init, setup, process and revalidate; passing the command through unchanged"); + } + return run_child(&runner, &invocation.argv("", ""), &ChildEnv::unchanged()); + }; + + let console = ConsoleClient::default(); + let Some(hosted) = hosted::hosted_access(&console, "DeepSec") else { + return 1; + }; + let (model, base_url) = match hosted::pick_model(&console, &hosted, requested_model) { + Ok(chosen) => chosen, + Err(code) => return code, + }; + + out::status_line(&format!("DeepSec will use {model} through {base_url}")); + let Some(agent_dir) = pi_agent_directory() else { + out::error_line("no user profile directory is available"); + return 1; + }; + if let Err(message) = write_pi_models_json(&agent_dir, &base_url, &model) { + out::error_line(&message); + return 1; + } + run_child( + &runner, + &invocation.argv(&model, &base_url), + &ChildEnv::for_start(&hosted.key, &agent_dir), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::OsStr; + use std::fs; + + use crate::util::env_lock::lock as env_lock; + + fn strings(tokens: &[&str]) -> Vec { + tokens.iter().map(|t| t.to_string()).collect() + } + + #[test] + fn pinned_package_and_provider_constants() { + assert_eq!(PINNED_VERSION, "2.3.10"); + assert_eq!(PACKAGE, "deepsec"); + assert_eq!(PROVIDER_ID, "runanywhere"); + assert_eq!(KEY_VARIABLE, "WALLY_DEEPSEC_API_KEY"); + assert_eq!(npx_invoke(), "npx --yes deepsec@2.3.10"); + } + + #[test] + fn refuse_home_workspace_only_for_bare_init() { + let home = Path::new("/Users/me"); + assert!(refuse_home_workspace(&[], home, Some(home))); + assert!(!refuse_home_workspace( + &strings(&["init", "--repo", "./x"]), + home, + Some(home) + )); + assert!(!refuse_home_workspace( + &[], + Path::new("/Users/me/src"), + Some(home) + )); + } + + #[test] + fn classify_empty_or_option_starts_routed_init() { + assert_eq!( + classify(&[]).unwrap(), + Invocation::Routed { + command: "init".to_string(), + rest: vec![], + } + ); + assert_eq!( + classify(&strings(&["--repo", "./x"])).unwrap(), + Invocation::Routed { + command: "init".to_string(), + rest: strings(&["--repo", "./x"]), + } + ); + } + + #[test] + fn classify_help_and_version_pass_through() { + for flag in ["-h", "--help", "-v", "--version"] { + let args = strings(&[flag]); + assert_eq!(classify(&args).unwrap(), Invocation::Passthrough(args)); + } + } + + #[test] + fn classify_routed_subcommands_and_scan_passthrough() { + assert_eq!( + classify(&strings(&["process", "--all"])).unwrap(), + Invocation::Routed { + command: "process".to_string(), + rest: strings(&["--all"]), + } + ); + assert_eq!( + classify(&strings(&["status"])).unwrap(), + Invocation::Passthrough(strings(&["status"])) + ); + } + + #[test] + fn classify_refuses_owned_route_flags_on_routed_commands() { + for flag in [ + "--agent", + "--model", + "--model-auth", + "--ai-provider", + "--ai-base-url", + "--ai-api-key-env", + "--ai-credential-header", + ] { + assert!(classify(&strings(&["init", flag])).is_err()); + assert!(classify(&strings(&["revalidate", &format!("{flag}=x")])).is_err()); + } + } + + #[test] + fn classify_help_on_routed_command_passes_through() { + assert_eq!( + classify(&strings(&["init", "--help"])).unwrap(), + Invocation::Passthrough(strings(&["init", "--help"])) + ); + } + + #[test] + fn pi_model_id_prefixes_runanywhere_when_bare() { + assert_eq!(pi_model_id("glm-5.3-flash"), "runanywhere/glm-5.3-flash"); + assert_eq!(pi_model_id("acme/custom-model"), "acme/custom-model"); + } + + #[test] + fn route_flags_inject_pi_custom_route_before_rest() { + assert_eq!( + route_flags("glm-5.3-flash", "https://api.example/v1"), + strings(&[ + "--agent", + "pi", + "--model", + "runanywhere/glm-5.3-flash", + "--model-auth", + "custom", + "--ai-provider", + "runanywhere", + "--ai-base-url", + "https://api.example/v1", + "--ai-api-key-env", + "WALLY_DEEPSEC_API_KEY", + "--ai-credential-header", + "authorization:bearer", + ]) + ); + let routed = Invocation::Routed { + command: "init".to_string(), + rest: strings(&["--repo", "."]), + }; + assert_eq!( + routed.argv("m", "https://x/v1"), + strings(&[ + "init", + "--agent", + "pi", + "--model", + "runanywhere/m", + "--model-auth", + "custom", + "--ai-provider", + "runanywhere", + "--ai-base-url", + "https://x/v1", + "--ai-api-key-env", + "WALLY_DEEPSEC_API_KEY", + "--ai-credential-header", + "authorization:bearer", + "--repo", + ".", + ]) + ); + } + + #[test] + fn models_json_registers_runanywhere_provider_without_a_key() { + let text = models_json("https://api.example/v1", "glm-5.3-flash"); + let parsed: serde_json::Value = serde_json::from_str(&text).unwrap(); + assert_eq!( + parsed["providers"]["runanywhere"]["baseUrl"], + "https://api.example/v1" + ); + assert_eq!( + parsed["providers"]["runanywhere"]["api"], + "openai-completions" + ); + assert_eq!( + parsed["providers"]["runanywhere"]["models"][0]["id"], + "glm-5.3-flash" + ); + assert!(!text.contains("apiKey")); + assert!(!text.contains("sk-")); + assert_eq!(models_json_model_id("runanywhere/m"), "m"); + } + + #[test] + fn child_env_for_start_sets_key_and_strips_overrides() { + let env = ChildEnv::for_start("secret", Path::new("/tmp/wally-deepsec-agent")); + assert_eq!( + env.set, + vec![ + (KEY_VARIABLE, "secret".to_string()), + ( + PI_AGENT_DIR_VARIABLE, + "/tmp/wally-deepsec-agent".to_string() + ), + ] + ); + assert_eq!( + env.remove, + vec![ + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "AI_GATEWAY_API_KEY", + CLOUD_KEY_VARIABLE, + ] + ); + assert!(ChildEnv::unchanged().set.is_empty()); + assert!(ChildEnv::unchanged().remove.is_empty()); + } + + #[test] + fn parse_version_reads_first_numeric_token() { + assert_eq!( + parse_version("deepsec 2.3.10\n"), + Some("2.3.10".to_string()) + ); + assert_eq!(parse_version("version 2.3.10"), Some("2.3.10".to_string())); + } + + #[cfg(unix)] + #[test] + fn select_runner_prefers_pinned_install() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().expect("tempdir"); + let deepsec = dir.path().join("deepsec"); + fs::write(&deepsec, "#!/bin/sh\necho deepsec 2.3.10\n").unwrap(); + fs::set_permissions(&deepsec, fs::Permissions::from_mode(0o700)).unwrap(); + let (runner, note) = select_runner( + dir.path().as_os_str(), + &|_| Some(PINNED_VERSION.to_string()), + None, + ) + .unwrap(); + assert!(note.is_none()); + assert_eq!(runner, Runner::Installed(deepsec)); + } + + #[cfg(unix)] + #[test] + fn select_runner_prefers_workspace_install_over_path() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().expect("tempdir"); + let workspace = dir.path().join("deepsec"); + fs::write(&workspace, "#!/bin/sh\necho deepsec 2.3.10\n").unwrap(); + fs::set_permissions(&workspace, fs::Permissions::from_mode(0o700)).unwrap(); + let (runner, note) = select_runner( + OsStr::new("/empty-wally-path"), + &|_| Some(PINNED_VERSION.to_string()), + Some(&workspace), + ) + .unwrap(); + assert!(note.is_none()); + assert_eq!(runner, Runner::Installed(workspace)); + } + + #[test] + fn select_runner_without_deepsec_or_npx_names_node() { + let err = select_runner(OsStr::new("/empty-wally-path"), &|_| None, None).unwrap_err(); + assert!(err.contains("npx were not found")); + assert!(err.contains("Node 18")); + } + + #[cfg(unix)] + #[test] + fn run_child_applies_env_only_on_the_child() { + use std::os::unix::fs::PermissionsExt; + + let _lock = env_lock(); + let dir = tempfile::tempdir().expect("tempdir"); + let stub = dir.path().join("deepsec-stub"); + fs::write( + &stub, + "#!/bin/sh\nif test -n \"$WALLY_DEEPSEC_API_KEY\" && test -z \"$OPENAI_API_KEY\"; then exit 42; fi\nexit 9\n", + ) + .unwrap(); + fs::set_permissions(&stub, fs::Permissions::from_mode(0o700)).unwrap(); + + let previous = std::env::var("OPENAI_API_KEY").ok(); + std::env::set_var("OPENAI_API_KEY", "parent"); + let code = run_child( + &Runner::Installed(stub), + &strings(&["status"]), + &ChildEnv::for_start("child-key", Path::new("/tmp/wally-deepsec-agent")), + ); + let remaining = std::env::var("OPENAI_API_KEY"); + match previous { + Some(value) => std::env::set_var("OPENAI_API_KEY", value), + None => std::env::remove_var("OPENAI_API_KEY"), + } + assert_eq!(code, 42); + assert_eq!(remaining.as_deref(), Ok("parent")); + } +} diff --git a/src/harness/harness.rs b/src/harness/harness.rs index 1f410764..44fb17b8 100644 --- a/src/harness/harness.rs +++ b/src/harness/harness.rs @@ -87,7 +87,7 @@ fn epoch_seconds() -> i64 { /// The refresh half of the same dance `wally account usage` uses: exchange the /// refresh token for a new access token and persist it, so later commands in /// the same session do not pay for the refresh again. -fn refresh_session( +pub(crate) fn refresh_session( console: &ConsoleClient, credentials: &mut Credentials, ) -> Result<(), SessionError> { @@ -1063,6 +1063,16 @@ pub fn report_not_signed_in() { )); } +/// What a person is told when a hosted model is not on their account's list: +/// a model that needs approval is asked for in the console, so this never +/// sends them to log in again. +pub fn not_on_account_message(model: &str) -> String { + format!( + "'{model}' is not on your account's model list. If it needs approval, request access \ + in the console." + ) +} + pub fn refresh_and_recheck_model(credentials: &Credentials, model: &str) -> bool { let pal = cli_color::make_palette(color_output_enabled(false)); out::status_line(&format!("could not find '{model}' in the catalog")); @@ -1082,7 +1092,7 @@ pub fn refresh_and_recheck_model(credentials: &Credentials, model: &str) -> bool pal.green, pal.reset )); if !account::model_is_cached(model) { - out::error_line(&format!("unknown model '{model}'")); + out::error_line(¬_on_account_message(model)); // The cache is fresh (just refreshed), so this list is accurate. let available = account::cached_model_ids(); if !available.is_empty() { @@ -1474,6 +1484,17 @@ mod tests { ); } + #[test] + fn a_model_off_the_account_list_points_at_the_console_not_at_login() { + let message = not_on_account_message("some-model"); + assert_eq!( + message, + "'some-model' is not on your account's model list. If it needs approval, request \ + access in the console." + ); + assert!(!message.contains("log in") && !message.contains("login")); + } + #[test] fn unknown_tool_has_no_installer() { assert_eq!(installer("nope"), None); diff --git a/src/harness/hosted.rs b/src/harness/hosted.rs new file mode 100644 index 00000000..8c77ef27 --- /dev/null +++ b/src/harness/hosted.rs @@ -0,0 +1,251 @@ +//! Shared hosted-account access for security-tool launchers. + +use crate::account::{self, ConsoleClient, IdentityResult}; +use crate::harness::catalog_models::{account_models_with, CatalogModel}; +use crate::harness::harness::{ + model_id_is_safe, refresh_session, report_cloud_session_invalid, report_not_signed_in, + verify_cloud_session, +}; +use crate::io::output as out; + +/// A console Cloud key, for a scan that outlasts a signed-in session. +pub const CLOUD_KEY_VARIABLE: &str = "WALLY_CLOUD_KEY"; + +/// A session that would end sooner than this is refreshed before the scan +/// starts: a scan runs for hours, and these tools cannot refresh anything. +const REFRESH_WITHIN_SECONDS: i64 = 4 * 60 * 60; + +/// Where the models are served from and the key that reads them. +pub struct Hosted { + pub console_url: String, + pub key: String, + pub session: bool, +} + +fn first_nonempty_path( + home: Option, + profile: Option, +) -> Option { + [home, profile] + .into_iter() + .flatten() + .find(|value| !value.is_empty()) + .map(std::path::PathBuf::from) +} + +/// The user's home directory: `HOME`, else `USERPROFILE` (a Windows shell that +/// leaves `HOME` unset). Used only to refuse scanning the home directory. +pub fn home_directory() -> Option { + first_nonempty_path(std::env::var_os("HOME"), std::env::var_os("USERPROFILE")) +} + +fn cloud_key_from_environment() -> Option { + let value = std::env::var(CLOUD_KEY_VARIABLE).ok()?; + let value = value.trim(); + (!value.is_empty()).then(|| value.to_string()) +} + +/// `WALLY_CLOUD_KEY` if set, else the signed-in session, refreshed first when +/// it would end within four hours. +pub fn hosted_access(console: &ConsoleClient, tool: &str) -> Option { + let mut credentials = match account::load() { + Ok(credentials) => credentials, + Err(message) => { + out::error_line(&message); + return None; + } + }; + + if let Some(key) = cloud_key_from_environment() { + if !account::session_token_is_safe(&key) { + out::error_line(&format!( + "{CLOUD_KEY_VARIABLE} holds characters a key cannot contain" + )); + return None; + } + return Some(Hosted { + console_url: credentials.console_url, + key, + session: false, + }); + } + + if !credentials.signed_in() { + report_not_signed_in(); + out::status_line(&format!( + "or set {CLOUD_KEY_VARIABLE} to a Cloud key from the console" + )); + return None; + } + if !credentials.refresh_token.is_empty() + && credentials.access_token_expired(account::epoch_seconds(), REFRESH_WITHIN_SECONDS) + { + if let Err(error) = refresh_session(console, &mut credentials) { + let usable = + error.unverified && !credentials.access_token_expired(account::epoch_seconds(), 60); + if !usable { + report_cloud_session_invalid(tool); + return None; + } + } + } + match verify_cloud_session(console, &mut credentials) { + Ok(email) => { + if !email.is_empty() { + out::status_line(&format!("signed in as {email}")); + } + } + Err(error) if error.unverified => { + out::status_line(&format!( + "could not confirm the cloud session ({}) - continuing on the stored session", + error.message + )); + } + Err(_) => { + report_cloud_session_invalid(tool); + return None; + } + } + let remaining = credentials.expires_at - account::epoch_seconds(); + if credentials.expires_at > 0 && remaining < REFRESH_WITHIN_SECONDS { + out::status_line(&format!( + "this sign-in stays valid for about {} minutes. For a longer scan, create a Cloud \ + key in the console and set {CLOUD_KEY_VARIABLE}.", + remaining.max(0) / 60 + )); + } + Some(Hosted { + console_url: credentials.console_url, + key: credentials.access_token, + session: true, + }) +} + +pub fn not_available_message(model: &str) -> String { + format!( + "'{model}' is not available on this account. If it needs approval, request access in \ + the console." + ) +} + +/// The model the scan is pointed at: `requested` if the account has it, else +/// the preferred default if it has that, else the first model on the list. +pub fn choose_model( + models: &[CatalogModel], + requested: Option<&str>, + preferred_default: &str, +) -> Result { + if let Some(id) = requested { + return if models.iter().any(|model| model.id == id) { + Ok(id.to_string()) + } else { + Err(not_available_message(id)) + }; + } + if models.iter().any(|model| model.id == preferred_default) { + return Ok(preferred_default.to_string()); + } + models + .first() + .map(|model| model.id.clone()) + .ok_or_else(|| "your account has no models available for a security scan".to_string()) +} + +pub fn require_safe_model(requested: Option<&str>) -> Result<(), i32> { + if let Some(id) = requested { + if !model_id_is_safe(id) { + out::error_line(&format!("'{id}' is not a valid model id")); + return Err(2); + } + } + Ok(()) +} + +/// The account's own models, as the console lists them. A requested model the +/// account does not have must stay absent so `choose_model` can refuse it. +pub fn load_models(console: &ConsoleClient, hosted: &Hosted) -> Result, i32> { + let (result, mut models, error) = + account_models_with(console, &hosted.console_url, &hosted.key); + match result { + IdentityResult::Ok => {} + IdentityResult::Unauthorized if hosted.session => { + report_cloud_session_invalid("this scan"); + return Err(1); + } + IdentityResult::Unauthorized => { + out::error_line(&format!( + "the console did not accept {CLOUD_KEY_VARIABLE}; check the key in the console" + )); + return Err(1); + } + _ => { + out::error_line(&format!("could not read your model list: {error}")); + return Err(1); + } + } + models.retain(|model| !super::is_decisions_model(&model.id)); + Ok(models) +} + +pub fn pick_model( + console: &ConsoleClient, + hosted: &Hosted, + requested: Option<&str>, +) -> Result<(String, String), i32> { + let preferred = crate::config::preferences::resolve_model(""); + let models = load_models(console, hosted)?; + let model = match choose_model(&models, requested, &preferred) { + Ok(model) => model, + Err(message) => { + out::error_line(&message); + return Err(1); + } + }; + if requested.is_none() { + out::status_line(&format!("model not provided, using {model}")); + } + let base_url = format!("{}/v1", hosted.console_url); + Ok((model, base_url)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn model(id: &str) -> CatalogModel { + CatalogModel { + id: id.to_string(), + ..CatalogModel::default() + } + } + + #[test] + fn home_falls_back_to_userprofile_only_when_home_is_unset_or_empty() { + use std::ffi::OsString; + let os = |text: &str| Some(OsString::from(text)); + assert_eq!( + first_nonempty_path(os("/home/a"), os("C:\\Users\\a")), + Some("/home/a".into()) + ); + assert_eq!( + first_nonempty_path(None, os("C:\\Users\\a")), + Some("C:\\Users\\a".into()) + ); + assert_eq!( + first_nonempty_path(os(""), os("/profile")), + Some("/profile".into()) + ); + assert_eq!(first_nonempty_path(None, None), None); + assert_eq!(first_nonempty_path(os(""), os("")), None); + } + + #[test] + fn choose_model_prefers_requested_then_default_then_first() { + let models = vec![model("a"), model("b"), model("c")]; + assert_eq!(choose_model(&models, Some("b"), "a").unwrap(), "b"); + assert_eq!(choose_model(&models, None, "c").unwrap(), "c"); + assert_eq!(choose_model(&models, None, "missing").unwrap(), "a"); + assert!(choose_model(&models, Some("nope"), "a").is_err()); + assert!(choose_model(&[], None, "a").is_err()); + } +} diff --git a/src/harness/mod.rs b/src/harness/mod.rs index 2e5407cf..d4750265 100644 --- a/src/harness/mod.rs +++ b/src/harness/mod.rs @@ -1,6 +1,7 @@ //! Launching coding tools (Claude Code, Codex, opencode, Hermes, OpenClaw, DeepSeek, -//! Prime Agent, Command Code) -//! against a hosted or local model. One namespace, as C++ `wally::harness` was. +//! Prime Agent, Command Code) against a hosted or local model, and the hosted +//! security harnesses (Shannon, DeepSec, VVAH), which run only against hosted +//! account models. One namespace, as C++ `wally::harness` was. pub mod agents; pub mod catalog_models; @@ -9,12 +10,16 @@ pub mod claude_effort; pub mod codex; pub mod command_code; pub mod declared_harness; +pub mod deepsec; #[allow(clippy::module_inception)] pub mod harness; +pub mod hosted; pub mod image_gate; pub mod local_models; pub mod opencode; pub mod path_reload; +pub mod shannon; +pub mod vvah; pub use agents::*; pub use catalog_models::*; diff --git a/src/harness/shannon.rs b/src/harness/shannon.rs new file mode 100644 index 00000000..922a2dea --- /dev/null +++ b/src/harness/shannon.rs @@ -0,0 +1,1174 @@ +//! `wally shannon`: Shannon, the open-source AI pentester for authorised +//! security testing, pointed at the models on a RunAnywhere account. +//! +//! Shannon is a Docker and Temporal command-line tool. Its `start` command +//! reads a model from `SHANNON_AI_MODEL=:`, the key from +//! `SHANNON_AI_API_KEY`, and any provider its built-in catalogue lacks from a +//! Pi `models.json` passed as `--models-config`. This launcher writes that file +//! from the account's own model list, sets the two variables for the child +//! only, and otherwise gets out of the way: every other Shannon command runs +//! untouched. +//! +//! The provider speaks Chat Completions (`openai-completions`). Shannon's +//! built-in `openai:` provider speaks the Responses API instead, which this +//! endpoint does not serve, so the model is always named `runanywhere:`. +//! +//! The key goes in the child's environment and nowhere else: not in argv, not +//! in `models.json`, not in a message. + +use std::ffi::{OsStr, OsString}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use serde_json::{json, Value}; + +use crate::account::{self, ConsoleClient, IdentityResult}; +use crate::io::json::dump_pretty; +use crate::io::output as out; + +use super::catalog_models::{account_models_with, CatalogModel}; +use super::harness::{ + model_id_is_safe, refresh_session, report_cloud_session_invalid, report_not_signed_in, + verify_cloud_session, +}; + +/// The Shannon release this launcher was written and checked against. Both the +/// installed command and the `npx` fallback have to be exactly this. +pub const PINNED_VERSION: &str = "3.4.0"; + +/// The npm package that ships the `shannon` command. +pub const PACKAGE: &str = "@keygraph/shannon"; + +/// The provider name in `models.json` and in `SHANNON_AI_MODEL`. +pub const PROVIDER_ID: &str = "runanywhere"; + +/// Where Shannon reads the key for the provider `SHANNON_AI_MODEL` names. +pub const KEY_VARIABLE: &str = "SHANNON_AI_API_KEY"; + +/// Where Shannon reads `:`. +pub const MODEL_VARIABLE: &str = "SHANNON_AI_MODEL"; + +/// A console Cloud key, for a scan that outlasts a signed-in session. The only +/// way to supply one: a flag would put it in argv and shell history. +pub const CLOUD_KEY_VARIABLE: &str = "WALLY_CLOUD_KEY"; + +/// Where Shannon sends model traffic. Set rather than removed: Shannon fills a +/// missing value from its own saved settings, which could name another host. +pub const BASE_URL_VARIABLE: &str = "SHANNON_AI_BASE_URL"; + +/// Shannon's switch for reusing a local Pi login instead of the key it is +/// given. Forced off so the Wally key is the one that is used. +pub const PI_AUTH_VARIABLE: &str = "SHANNON_USE_PI_AUTH"; + +/// Variables that would override the key Shannon is given. Shannon prefers +/// its provider's native key variable over `SHANNON_AI_API_KEY`. +const OVERRIDING_VARIABLES: [&str; 4] = [ + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "XAI_API_KEY", + // Wally's own input, already handed over as SHANNON_AI_API_KEY. + CLOUD_KEY_VARIABLE, +]; + +/// A session that would end sooner than this is refreshed before the scan +/// starts: a scan runs for hours, and Shannon cannot refresh anything itself. +const REFRESH_WITHIN_SECONDS: i64 = 4 * 60 * 60; + +/// How long `shannon version` gets to answer before it is treated as absent. +const VERSION_PROBE_SECONDS: u64 = 20; + +pub const WINDOWS_REFUSAL: &str = + "Shannon runs on Linux, macOS, or Windows through WSL2. Run wally inside WSL2."; + +pub const PREREQUISITES: &str = "Shannon needs Node 18 or later (it provides npx) and Docker \ +running. Install Node from https://nodejs.org and Docker from https://docs.docker.com/get-docker/, \ +then run this again."; + +/// What a person is told when `-m` names a model their account does not have. +pub fn not_available_message(model: &str) -> String { + format!( + "'{model}' is not available on this account. If it needs approval, request access in \ + the console." + ) +} + +// --------------------------------------------------------------------------- +// The command line +// --------------------------------------------------------------------------- + +/// What the person asked Shannon to do. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Invocation { + /// `start`: Wally supplies the model, the key and `--models-config`. + /// Holds Shannon's own arguments after `start`. + Start(Vec), + /// Anything else, forwarded untouched. + Passthrough(Vec), +} + +fn is_help_or_version_flag(arg: &str) -> bool { + matches!(arg, "-h" | "--help" | "-v" | "--version") +} + +fn names_models_config(arg: &str) -> bool { + arg == "--models-config" || arg.starts_with("--models-config=") +} + +/// Reads Shannon's arguments. No subcommand, or a first argument that is an +/// option, means `start`; a first argument that is Shannon's own help or version +/// flag, or a `start` that asks for its own help, is left alone. +pub fn classify(args: &[String]) -> Result { + let first = args.first().map(String::as_str); + let start_rest: Vec = match first { + Some("start") => args[1..].to_vec(), + Some(flag) if is_help_or_version_flag(flag) => { + return Ok(Invocation::Passthrough(args.to_vec())) + } + Some(flag) if flag.starts_with('-') => args.to_vec(), + None => Vec::new(), + Some(_) => return Ok(Invocation::Passthrough(args.to_vec())), + }; + if start_rest.iter().any(|arg| names_models_config(arg)) { + return Err( + "wally shannon writes the --models-config file itself, from your account's models. \ + Remove --models-config, or run Shannon directly to use your own file." + .to_string(), + ); + } + if start_rest + .iter() + .any(|arg| matches!(arg.as_str(), "-h" | "--help")) + { + let mut argv = vec!["start".to_string()]; + argv.extend(start_rest); + return Ok(Invocation::Passthrough(argv)); + } + Ok(Invocation::Start(start_rest)) +} + +impl Invocation { + /// The arguments Shannon is run with. `start` gets `--models-config` right + /// after it, ahead of the person's own options. + pub fn argv(&self, models_config: &str) -> Vec { + match self { + Invocation::Start(rest) => { + let mut argv = vec![ + "start".to_string(), + "--models-config".to_string(), + models_config.to_string(), + ]; + argv.extend(rest.iter().cloned()); + argv + } + Invocation::Passthrough(args) => args.clone(), + } + } +} + +// --------------------------------------------------------------------------- +// The child's environment +// --------------------------------------------------------------------------- + +/// The environment changes for the Shannon child. Holds the key, so it has no +/// `Debug`. +pub struct ChildEnv { + pub set: Vec<(&'static str, String)>, + pub remove: Vec<&'static str>, +} + +impl ChildEnv { + /// For `start`: the model, the key and the endpoint, Pi login reuse off, + /// and none of the variables that would override the key. + pub fn for_start(model: &str, key: &str, base_url: &str) -> Self { + ChildEnv { + set: vec![ + (MODEL_VARIABLE, format!("{PROVIDER_ID}:{model}")), + (KEY_VARIABLE, key.to_string()), + (BASE_URL_VARIABLE, base_url.to_string()), + (PI_AUTH_VARIABLE, "0".to_string()), + ], + remove: OVERRIDING_VARIABLES.to_vec(), + } + } + + /// Everything else runs in the environment it was given. + pub fn unchanged() -> Self { + ChildEnv { + set: Vec::new(), + remove: Vec::new(), + } + } + + fn apply(&self, command: &mut Command) { + for name in &self.remove { + command.env_remove(name); + } + for (name, value) in &self.set { + command.env(name, value); + } + } +} + +// --------------------------------------------------------------------------- +// models.json +// --------------------------------------------------------------------------- + +/// A Pi `models.json` naming every model the account can use. It carries no +/// key: Shannon takes that from `SHANNON_AI_API_KEY`. +pub fn models_config(base_url: &str, models: &[CatalogModel]) -> Value { + let entries: Vec = models + .iter() + .map(|model| { + let mut input = vec!["text"]; + if model.image_input { + input.push("image"); + } + let mut entry = json!({ "id": model.id, "input": input }); + if model.context_window > 0 { + entry["contextWindow"] = json!(model.context_window); + } + if model.max_output > 0 { + entry["maxTokens"] = json!(model.max_output); + } + entry + }) + .collect(); + json!({ + "providers": { + PROVIDER_ID: { + "baseUrl": base_url, + "api": "openai-completions", + "authHeader": true, + // The gateway takes `system`; a `developer` role would be refused. + "compat": { "supportsDeveloperRole": false }, + "models": entries, + } + } + }) +} + +/// Where the file goes: under the wally profile, in a directory of its own +/// (the profile already keeps its model-id cache in a `models.json`). +pub fn models_config_path() -> Option { + let profile = account::profile_directory(); + if profile.is_empty() { + return None; + } + Some(PathBuf::from(profile).join("shannon").join("models.json")) +} + +/// Replaces `path` with `text`, readable by the owner alone. Written to a +/// sibling at that mode and renamed over the old file, so the contents are +/// never visible at a looser mode and a file left over from an earlier run +/// cannot keep its own. +pub fn write_private_file(path: &Path, text: &str) -> std::io::Result<()> { + use std::io::Write; + + let Some(parent) = path.parent() else { + return Err(std::io::Error::other("no parent directory")); + }; + if !parent.is_dir() { + std::fs::create_dir_all(parent)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))?; + } + } + let mut temporary_name = OsString::from("."); + temporary_name.push(path.file_name().unwrap_or_default()); + temporary_name.push(format!(".{}.tmp", std::process::id())); + let temporary = parent.join(temporary_name); + let _ = std::fs::remove_file(&temporary); + + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let written = options.open(&temporary).and_then(|mut file| { + file.write_all(text.as_bytes())?; + file.sync_all() + }); + match written.and_then(|()| std::fs::rename(&temporary, path)) { + Ok(()) => Ok(()), + Err(error) => { + let _ = std::fs::remove_file(&temporary); + Err(error) + } + } +} + +// --------------------------------------------------------------------------- +// Which Shannon runs +// --------------------------------------------------------------------------- + +/// The program that runs Shannon. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Runner { + /// A `shannon` on PATH that reports the pinned version. + Installed(PathBuf), + /// `npx --yes @keygraph/shannon@`. + Npx(PathBuf), +} + +impl Runner { + fn program(&self) -> &Path { + match self { + Runner::Installed(path) | Runner::Npx(path) => path, + } + } + + /// Arguments that come before Shannon's own. + fn prefix(&self) -> Vec { + match self { + Runner::Installed(_) => Vec::new(), + Runner::Npx(_) => vec!["--yes".to_string(), format!("{PACKAGE}@{PINNED_VERSION}")], + } + } +} + +fn is_executable(path: &Path) -> bool { + let Ok(metadata) = std::fs::metadata(path) else { + return false; + }; + if !metadata.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + metadata.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + true + } +} + +/// The first `name` on `search_path` that can be run. +pub fn find_on_path(name: &str, search_path: &OsStr) -> Option { + let names: Vec = if cfg!(windows) { + vec![ + format!("{name}.exe"), + format!("{name}.cmd"), + name.to_string(), + ] + } else { + vec![name.to_string()] + }; + for dir in std::env::split_paths(search_path) { + // An empty entry is the working directory to execvp, but a launcher + // that runs whatever sits in the current directory is not one to copy. + if dir.as_os_str().is_empty() { + continue; + } + for candidate in &names { + let path = dir.join(candidate); + if is_executable(&path) { + return Some(path); + } + } + } + None +} + +/// The version `shannon version` prints (`shannon 3.4.0`): its last word. +pub fn parse_version(output: &str) -> Option { + output + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .and_then(|line| line.split_whitespace().last()) + .map(str::to_string) +} + +/// Tries `start` again while the program is reported busy: a script that was +/// written a moment ago can still be open for writing in a process forked in +/// between, and the kernel refuses to run it until that process execs. +fn retry_busy(mut start: impl FnMut() -> std::io::Result) -> std::io::Result { + let mut attempts = 0; + loop { + match start() { + Err(error) + if error.kind() == std::io::ErrorKind::ExecutableFileBusy && attempts < 40 => + { + attempts += 1; + std::thread::sleep(Duration::from_millis(25)); + } + other => return other, + } + } +} + +/// Runs ` version`, giving it a bounded time, and returns the version +/// it reports. None if it does not run, fails, or does not answer. +pub fn probe_version(shannon: &Path) -> Option { + let mut child = retry_busy(|| { + Command::new(shannon) + .arg("version") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + }) + .ok()?; + let deadline = Instant::now() + Duration::from_secs(VERSION_PROBE_SECONDS); + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(25)), + _ => { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + } + }; + if !status.success() { + return None; + } + let mut text = String::new(); + std::io::Read::read_to_string(&mut child.stdout.take()?, &mut text).ok()?; + parse_version(&text) +} + +/// Picks the program: a `shannon` on `search_path` whose `version` reports the +/// pinned release, else `npx` at that release. The second value is a note for +/// the person when an installed Shannon was passed over. +pub fn select_runner( + search_path: &OsStr, + probe: &dyn Fn(&Path) -> Option, +) -> Result<(Runner, Option), String> { + let mut note = None; + if let Some(installed) = find_on_path("shannon", search_path) { + match probe(&installed) { + Some(version) if version == PINNED_VERSION => { + return Ok((Runner::Installed(installed), None)); + } + Some(version) => { + note = Some(format!( + "the shannon on PATH is {version}, not {PINNED_VERSION}; using npx \ + {PACKAGE}@{PINNED_VERSION} instead" + )); + } + None => { + note = Some(format!( + "could not read the version of the shannon on PATH; using npx \ + {PACKAGE}@{PINNED_VERSION} instead" + )); + } + } + } + match find_on_path("npx", search_path) { + Some(npx) => Ok((Runner::Npx(npx), note)), + None => Err(match note { + Some(note) => format!("{note}, but npx was not found. {PREREQUISITES}"), + None => format!("shannon and npx were not found. {PREREQUISITES}"), + }), + } +} + +// --------------------------------------------------------------------------- +// Running it +// --------------------------------------------------------------------------- + +/// Runs `runner` with `argv` and `env`, waits, and returns its exit code. A +/// Ctrl-C reaches the child through the terminal; this process waits for it +/// rather than dying first. +pub fn run_child(runner: &Runner, argv: &[String], env: &ChildEnv) -> i32 { + let interrupted = Arc::new(AtomicBool::new(false)); + let noted = Arc::clone(&interrupted); + let _interrupt = crate::util::interrupt::on_interrupt(move || { + noted.store(true, Ordering::SeqCst); + }); + + let mut command = Command::new(runner.program()); + command.args(runner.prefix()).args(argv); + env.apply(&mut command); + let status = match retry_busy(|| command.status()) { + Ok(status) => status, + Err(error) => { + out::error_line(&format!("could not start Shannon: {error}")); + return 127; + } + }; + if let Some(code) = status.code() { + return code; + } + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + if let Some(signal) = status.signal() { + return 128 + signal; + } + } + if interrupted.load(Ordering::SeqCst) { + 130 + } else { + 1 + } +} + +// --------------------------------------------------------------------------- +// The account +// --------------------------------------------------------------------------- + +/// Where the models are served from and the key that reads them. +struct Hosted { + console_url: String, + key: String, + /// A signed-in session rather than a Cloud key. + session: bool, +} + +/// `WALLY_CLOUD_KEY`, if it is set to something. +fn cloud_key_from_environment() -> Option { + let value = std::env::var(CLOUD_KEY_VARIABLE).ok()?; + let value = value.trim(); + (!value.is_empty()).then(|| value.to_string()) +} + +/// The key to scan with: `WALLY_CLOUD_KEY` if set, else the signed-in session, +/// refreshed first when it would end within four hours. Prints why and returns +/// None when there is neither. +fn hosted_access(console: &ConsoleClient) -> Option { + let mut credentials = match account::load() { + Ok(credentials) => credentials, + Err(message) => { + out::error_line(&message); + return None; + } + }; + + if let Some(key) = cloud_key_from_environment() { + if !account::session_token_is_safe(&key) { + out::error_line(&format!( + "{CLOUD_KEY_VARIABLE} holds characters a key cannot contain" + )); + return None; + } + return Some(Hosted { + console_url: credentials.console_url, + key, + session: false, + }); + } + + if !credentials.signed_in() { + report_not_signed_in(); + out::status_line(&format!( + "or set {CLOUD_KEY_VARIABLE} to a Cloud key from the console" + )); + return None; + } + if !credentials.refresh_token.is_empty() + && credentials.access_token_expired(account::epoch_seconds(), REFRESH_WITHIN_SECONDS) + { + if let Err(error) = refresh_session(console, &mut credentials) { + // The console could not be asked and the token is still good for + // now: go on with it, as the other tools do. + let usable = + error.unverified && !credentials.access_token_expired(account::epoch_seconds(), 60); + if !usable { + report_cloud_session_invalid("Shannon"); + return None; + } + } + } + match verify_cloud_session(console, &mut credentials) { + Ok(email) => { + if !email.is_empty() { + out::status_line(&format!("signed in as {email}")); + } + } + Err(error) if error.unverified => { + out::status_line(&format!( + "could not confirm the cloud session ({}) - continuing on the stored session", + error.message + )); + } + Err(_) => { + report_cloud_session_invalid("Shannon"); + return None; + } + } + let remaining = credentials.expires_at - account::epoch_seconds(); + if credentials.expires_at > 0 && remaining < REFRESH_WITHIN_SECONDS { + out::status_line(&format!( + "this sign-in stays valid for about {} minutes. For a longer scan, create a Cloud \ + key in the console and set {CLOUD_KEY_VARIABLE}.", + remaining.max(0) / 60 + )); + } + Some(Hosted { + console_url: credentials.console_url, + key: credentials.access_token, + session: true, + }) +} + +/// The model Shannon is pointed at: `requested` if the account has it, else the +/// preferred default if it has that, else the first model on the list. +pub fn choose_model( + models: &[CatalogModel], + requested: Option<&str>, + preferred_default: &str, +) -> Result { + if let Some(id) = requested { + return if models.iter().any(|model| model.id == id) { + Ok(id.to_string()) + } else { + Err(not_available_message(id)) + }; + } + if models.iter().any(|model| model.id == preferred_default) { + return Ok(preferred_default.to_string()); + } + models + .first() + .map(|model| model.id.clone()) + .ok_or_else(|| "your account has no models available for Shannon".to_string()) +} + +// --------------------------------------------------------------------------- +// The command +// --------------------------------------------------------------------------- + +/// `wally shannon [-m ] [--] [] [args...]`. +pub fn launch(requested_model: Option<&str>, args: &[String]) -> i32 { + if cfg!(windows) { + out::error_line(WINDOWS_REFUSAL); + return 1; + } + if let Some(id) = requested_model { + if !model_id_is_safe(id) { + out::error_line(&format!("'{id}' is not a valid model id")); + return 2; + } + } + let invocation = match classify(args) { + Ok(invocation) => invocation, + Err(message) => { + out::error_line(&message); + return 2; + } + }; + + // First, before any account call: a person without the tools should see + // only that they are missing. + let search_path = std::env::var_os("PATH").unwrap_or_default(); + let (runner, note) = match select_runner(&search_path, &probe_version) { + Ok(selected) => selected, + Err(message) => { + out::error_line(&message); + return 127; + } + }; + if let Some(note) = note { + out::status_line(¬e); + } + + let Invocation::Start(_) = &invocation else { + if requested_model.is_some() { + out::status_line("-m only applies to `start`; passing the command through unchanged"); + } + return run_child(&runner, &invocation.argv(""), &ChildEnv::unchanged()); + }; + + let console = ConsoleClient::default(); + let Some(hosted) = hosted_access(&console) else { + return 1; + }; + let (result, mut models, error) = + account_models_with(&console, &hosted.console_url, &hosted.key); + match result { + IdentityResult::Ok => {} + IdentityResult::Unauthorized if hosted.session => { + report_cloud_session_invalid("Shannon"); + return 1; + } + IdentityResult::Unauthorized => { + out::error_line(&format!( + "the console did not accept {CLOUD_KEY_VARIABLE}; check the key in the console" + )); + return 1; + } + _ => { + out::error_line(&format!("could not read your model list: {error}")); + return 1; + } + } + // Decision models score text; they cannot run an agent. + models.retain(|model| !super::is_decisions_model(&model.id)); + + let preferred = crate::config::preferences::resolve_model(""); + let model = match choose_model(&models, requested_model, &preferred) { + Ok(model) => model, + Err(message) => { + out::error_line(&message); + return 1; + } + }; + if requested_model.is_none() { + out::status_line(&format!("model not provided, using {model}")); + } + + let Some(path) = models_config_path() else { + out::error_line("no user profile directory is available"); + return 1; + }; + let base_url = format!("{}/v1", hosted.console_url); + let text = dump_pretty(&models_config(&base_url, &models), 2) + "\n"; + if let Err(error) = write_private_file(&path, &text) { + out::error_line(&format!("could not write {}: {error}", path.display())); + return 1; + } + + out::status_line(&format!("Shannon will use {model} through {base_url}")); + run_child( + &runner, + &invocation.argv(&path.to_string_lossy()), + &ChildEnv::for_start(&model, &hosted.key, &base_url), + ) +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use std::os::unix::fs::PermissionsExt; + + fn strings(items: &[&str]) -> Vec { + items.iter().map(|item| item.to_string()).collect() + } + + fn model(id: &str, context: i64, output: i64, image: bool) -> CatalogModel { + CatalogModel { + id: id.to_string(), + context_window: context, + max_output: output, + image_input: image, + ..Default::default() + } + } + + fn scratch(name: &str) -> PathBuf { + // Other tests point TMPDIR at a directory they then sweep; resolving + // it under the same lock keeps this one out of their way. + let _lock = crate::util::env_lock::lock(); + let dir = std::env::temp_dir().join(format!("wally-shannon-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn stub(dir: &Path, name: &str, body: &str) -> PathBuf { + let path = dir.join(name); + std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + path + } + + // --- argv ------------------------------------------------------------- + + #[test] + fn no_command_means_start_with_the_models_config_first() { + let invocation = classify(&strings(&["-u", "https://app.test", "-r", "./repo"])).unwrap(); + assert_eq!( + invocation.argv("/p/models.json"), + strings(&[ + "start", + "--models-config", + "/p/models.json", + "-u", + "https://app.test", + "-r", + "./repo" + ]) + ); + assert!(matches!(invocation, Invocation::Start(_))); + } + + #[test] + fn an_explicit_start_is_not_doubled() { + let invocation = classify(&strings(&["start", "-u", "https://app.test"])).unwrap(); + assert_eq!( + invocation.argv("/p/models.json"), + strings(&[ + "start", + "--models-config", + "/p/models.json", + "-u", + "https://app.test" + ]) + ); + } + + #[test] + fn a_bare_invocation_is_start_too() { + assert_eq!(classify(&[]).unwrap(), Invocation::Start(Vec::new())); + } + + #[test] + fn a_models_config_of_the_users_is_refused_in_either_spelling() { + for args in [ + strings(&["start", "-u", "x", "--models-config", "mine.json"]), + strings(&["-u", "x", "--models-config=mine.json"]), + ] { + let message = classify(&args).unwrap_err(); + assert!(message.contains("--models-config"), "{message}"); + } + } + + #[test] + fn other_commands_pass_through_unchanged() { + for args in [ + strings(&["version"]), + strings(&["stop", "q1-audit", "--yes"]), + strings(&["logs", "q1-audit", "--models-config", "x"]), + strings(&["status", "--json"]), + strings(&["scans"]), + ] { + let invocation = classify(&args).unwrap(); + assert_eq!(invocation, Invocation::Passthrough(args.clone())); + assert_eq!(invocation.argv("/p/models.json"), args); + } + } + + #[test] + fn shannons_own_help_and_version_flags_are_left_alone() { + for args in [ + strings(&["--help"]), + strings(&["-h"]), + strings(&["--version"]), + strings(&["start", "--help"]), + ] { + assert!( + matches!(classify(&args).unwrap(), Invocation::Passthrough(_)), + "{args:?}" + ); + } + assert_eq!( + classify(&strings(&["start", "-h"])).unwrap().argv("/x"), + strings(&["start", "-h"]) + ); + } + + // --- env -------------------------------------------------------------- + + #[test] + fn the_key_is_in_the_environment_and_nowhere_in_argv() { + let key = "ra-secret-key-123"; + let invocation = classify(&strings(&["-u", "https://app.test", "-r", "./repo"])).unwrap(); + let argv = invocation.argv("/p/models.json"); + assert!(argv.iter().all(|arg| !arg.contains(key))); + + let env = ChildEnv::for_start("glm-5.3-flash", key, "https://api.test/v1"); + assert!(env.set.contains(&(KEY_VARIABLE, key.to_string()))); + assert!(env + .set + .contains(&(MODEL_VARIABLE, "runanywhere:glm-5.3-flash".to_string()))); + assert!( + !models_config("https://api.test/v1", &[model("a", 1, 1, false)]) + .to_string() + .contains(key) + ); + } + + #[test] + fn start_removes_the_variables_that_would_override_the_key() { + let env = ChildEnv::for_start("m", "k", "https://api.test/v1"); + for name in [ + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "XAI_API_KEY", + "WALLY_CLOUD_KEY", + ] { + assert!(env.remove.contains(&name), "{name}"); + } + // Set, not removed: Shannon would refill a missing endpoint from its + // saved settings, and Pi login reuse would ignore the key. + assert!(env + .set + .contains(&(BASE_URL_VARIABLE, "https://api.test/v1".to_string()))); + assert!(env.set.contains(&(PI_AUTH_VARIABLE, "0".to_string()))); + assert!(!env.remove.contains(&BASE_URL_VARIABLE)); + let unchanged = ChildEnv::unchanged(); + assert!(unchanged.set.is_empty() && unchanged.remove.is_empty()); + } + + #[test] + fn the_child_sees_the_key_and_not_the_stray_ones() { + let dir = scratch("env"); + let _guard = crate::util::env_lock::lock(); + let record = dir.join("record"); + let shannon = stub( + &dir, + "shannon", + &format!( + "echo \"$@\" > {r}\n\ + echo \"SHANNON_AI_API_KEY=$SHANNON_AI_API_KEY\" >> {r}\n\ + echo \"SHANNON_AI_MODEL=$SHANNON_AI_MODEL\" >> {r}\n\ + echo \"OPENAI=${{OPENAI_API_KEY-unset}}\" >> {r}\n\ + echo \"ANTHROPIC=${{ANTHROPIC_API_KEY-unset}}\" >> {r}\n\ + echo \"BASE=${{SHANNON_AI_BASE_URL-unset}}\" >> {r}\n\ + echo \"PIAUTH=${{SHANNON_USE_PI_AUTH-unset}}\" >> {r}\n\ + exit 7", + r = record.display() + ), + ); + let names = [ + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "SHANNON_AI_BASE_URL", + "SHANNON_USE_PI_AUTH", + ]; + let prior: Vec> = names.iter().map(std::env::var_os).collect(); + // SAFETY: serialised by the env lock; restored below. + unsafe { + std::env::set_var("OPENAI_API_KEY", "stray-openai"); + std::env::set_var("ANTHROPIC_API_KEY", "stray-anthropic"); + std::env::set_var("SHANNON_AI_BASE_URL", "https://elsewhere.test"); + std::env::set_var("SHANNON_USE_PI_AUTH", "1"); + } + let code = run_child( + &Runner::Installed(shannon), + &strings(&["start", "-u", "https://app.test"]), + &ChildEnv::for_start("glm-5.3-flash", "the-key", "https://api.test/v1"), + ); + for (name, value) in names.iter().zip(prior) { + // SAFETY: serialised by the env lock. + unsafe { + match value { + Some(value) => std::env::set_var(name, value), + None => std::env::remove_var(name), + } + } + } + assert_eq!(code, 7); + let seen = std::fs::read_to_string(&record).unwrap(); + assert!(seen.contains("start -u https://app.test"), "{seen}"); + assert!(seen.contains("SHANNON_AI_API_KEY=the-key"), "{seen}"); + assert!( + seen.contains("SHANNON_AI_MODEL=runanywhere:glm-5.3-flash"), + "{seen}" + ); + assert!(seen.contains("OPENAI=unset"), "{seen}"); + assert!(seen.contains("ANTHROPIC=unset"), "{seen}"); + assert!(seen.contains("BASE=https://api.test/v1"), "{seen}"); + assert!(seen.contains("PIAUTH=0"), "{seen}"); + assert!(!seen.contains("stray"), "{seen}"); + } + + // --- models.json ------------------------------------------------------ + + #[test] + fn models_json_lists_every_model_with_its_real_limits() { + let document = models_config( + "https://api.test/v1", + &[ + model("glm-5.3-flash", 202752, 131072, false), + model("vision-model", 131072, 0, true), + model("unknown-limits", 0, 0, false), + ], + ); + let provider = &document["providers"][PROVIDER_ID]; + assert_eq!(provider["baseUrl"], "https://api.test/v1"); + assert_eq!(provider["api"], "openai-completions"); + assert_eq!(provider["authHeader"], true); + assert!(provider.get("apiKey").is_none()); + let models = provider["models"].as_array().unwrap(); + assert_eq!(models.len(), 3); + assert_eq!( + models[0], + json!({"id": "glm-5.3-flash", "input": ["text"], "contextWindow": 202752, "maxTokens": 131072}) + ); + assert_eq!( + models[1], + json!({"id": "vision-model", "input": ["text", "image"], "contextWindow": 131072}) + ); + assert_eq!( + models[2], + json!({"id": "unknown-limits", "input": ["text"]}) + ); + } + + #[test] + fn models_json_is_owner_only_and_replaces_an_earlier_file() { + let dir = scratch("perm"); + let path = dir.join("shannon").join("models.json"); + write_private_file(&path, "first").unwrap(); + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + assert_eq!( + std::fs::metadata(path.parent().unwrap()) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o700 + ); + + // A file an earlier version or a person left at a looser mode. + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); + write_private_file(&path, "second").unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "second"); + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + let leftovers: Vec<_> = std::fs::read_dir(path.parent().unwrap()) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + assert_eq!(leftovers.len(), 1, "{leftovers:?}"); + } + + // --- which Shannon ---------------------------------------------------- + + #[test] + fn version_output_is_read_from_its_last_word() { + assert_eq!(parse_version("shannon 3.4.0\n").as_deref(), Some("3.4.0")); + assert_eq!( + parse_version("\n\nshannon 3.3.0"), + Some("3.3.0".to_string()) + ); + assert_eq!(parse_version(""), None); + } + + #[test] + fn an_installed_shannon_at_the_pinned_version_is_run_directly() { + let bin = scratch("installed"); + let shannon = stub(&bin, "shannon", &format!("echo 'shannon {PINNED_VERSION}'")); + stub(&bin, "npx", "exit 0"); + let (runner, note) = select_runner(bin.as_os_str(), &probe_version).unwrap(); + assert_eq!(runner, Runner::Installed(shannon)); + assert_eq!(note, None); + assert!(runner.prefix().is_empty()); + } + + #[test] + fn a_different_installed_version_falls_back_to_npx_at_the_pin() { + let bin = scratch("mismatch"); + stub(&bin, "shannon", "echo 'shannon 3.3.0'"); + let npx = stub(&bin, "npx", "exit 0"); + let (runner, note) = select_runner(bin.as_os_str(), &probe_version).unwrap(); + assert_eq!(runner, Runner::Npx(npx)); + assert_eq!( + runner.prefix(), + strings(&["--yes", &format!("@keygraph/shannon@{PINNED_VERSION}")]) + ); + let note = note.unwrap(); + assert!( + note.contains("3.3.0") && note.contains(PINNED_VERSION), + "{note}" + ); + } + + #[test] + fn a_shannon_that_will_not_say_its_version_is_not_trusted() { + let bin = scratch("broken"); + stub(&bin, "shannon", "exit 3"); + stub(&bin, "npx", "exit 0"); + let (runner, note) = select_runner(bin.as_os_str(), &probe_version).unwrap(); + assert!(matches!(runner, Runner::Npx(_))); + assert!(note.is_some()); + } + + #[test] + fn without_shannon_npx_runs_the_pinned_package() { + let bin = scratch("npx-only"); + let npx = stub(&bin, "npx", "exit 0"); + let (runner, note) = select_runner(bin.as_os_str(), &probe_version).unwrap(); + assert_eq!(runner, Runner::Npx(npx)); + assert_eq!(note, None); + } + + #[test] + fn with_neither_the_prerequisites_are_named() { + let bin = scratch("empty"); + let message = select_runner(bin.as_os_str(), &probe_version).unwrap_err(); + assert!(message.contains("Node 18"), "{message}"); + assert!(message.contains("Docker"), "{message}"); + } + + #[test] + fn a_mismatched_shannon_with_no_npx_says_both() { + let bin = scratch("mismatch-no-npx"); + stub(&bin, "shannon", "echo 'shannon 9.9.9'"); + let message = select_runner(bin.as_os_str(), &probe_version).unwrap_err(); + assert!( + message.contains("9.9.9") && message.contains("Node 18"), + "{message}" + ); + } + + #[test] + fn a_file_that_is_not_executable_is_not_found() { + let bin = scratch("not-exec"); + std::fs::write(bin.join("shannon"), "#!/bin/sh\n").unwrap(); + assert_eq!(find_on_path("shannon", bin.as_os_str()), None); + } + + #[test] + fn npx_is_run_with_yes_and_the_pinned_package_ahead_of_shannons_arguments() { + let bin = scratch("npx-run"); + let record = bin.join("record"); + let npx = stub(&bin, "npx", &format!("echo \"$@\" > {}", record.display())); + let code = run_child( + &Runner::Npx(npx), + &strings(&["version"]), + &ChildEnv::unchanged(), + ); + assert_eq!(code, 0); + assert_eq!( + std::fs::read_to_string(&record).unwrap().trim(), + format!("--yes @keygraph/shannon@{PINNED_VERSION} version") + ); + } + + // --- the account's models --------------------------------------------- + + #[test] + fn a_model_the_account_does_not_have_gets_the_approval_message() { + let models = [model("glm-5.3-flash", 1, 1, false)]; + assert_eq!( + choose_model(&models, Some("gated-model"), "glm-5.3-flash").unwrap_err(), + "'gated-model' is not available on this account. If it needs approval, request \ + access in the console." + ); + assert_eq!( + choose_model(&models, Some("glm-5.3-flash"), "x").unwrap(), + "glm-5.3-flash" + ); + } + + #[test] + fn without_m_the_default_is_used_when_the_account_has_it_else_the_first() { + let models = [model("a", 1, 1, false), model("b", 1, 1, false)]; + assert_eq!(choose_model(&models, None, "b").unwrap(), "b"); + assert_eq!(choose_model(&models, None, "zzz").unwrap(), "a"); + assert!(choose_model(&[], None, "b").is_err()); + } +} + +#[cfg(all(test, windows))] +mod windows_tests { + use super::*; + + #[test] + fn native_windows_is_refused_with_the_wsl_message() { + assert_eq!(launch(None, &[]), 1); + assert_eq!( + WINDOWS_REFUSAL, + "Shannon runs on Linux, macOS, or Windows through WSL2. Run wally inside WSL2." + ); + } +} diff --git a/src/harness/vvah.rs b/src/harness/vvah.rs new file mode 100644 index 00000000..15a5e34b --- /dev/null +++ b/src/harness/vvah.rs @@ -0,0 +1,1011 @@ +//! `wally vvah`: Visa Vulnerability Agentic Harness against account models. +//! +//! VVAH is a Python tool. This launcher installs a pinned release into the +//! Wally profile when asked, writes a `config.local.yaml` of OpenAI-compatible +//! roles, and sets `OPENAI_BASE_URL` / `OPENAI_API_KEY` for the child only. + +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use crate::account::{self, ConsoleClient}; +use crate::io::output as out; + +use super::harness::confirm; +use super::hosted::{self, CLOUD_KEY_VARIABLE}; + +/// The VVAH release this launcher installs and runs. +pub const PINNED_VERSION: &str = "1.5.1"; + +pub const MISSING_PYTHON: &str = + "python 3.11 is not installed on this machine, Please install it first, Thank You"; + +pub const SOURCE: &str = + "git+https://github.com/visa/visa-vulnerability-agentic-harness.git@v1.5.1"; + +pub const S10_WARNING: &str = + "VVAH's remediate step can edit files in the target repo. Scan only code you are authorised to test."; + +const MIN_PYTHON: (u32, u32) = (3, 11); +/// Inclusive. `arcadedb-embedded==26.10.1` (a VVAH pin) ships wheels for +/// 3.10–3.14 only, so 3.15's pip reports "from versions: none". +const MAX_PYTHON: (u32, u32) = (3, 14); + +const PYTHON_CANDIDATES: &[&str] = &[ + "python3.12", + "python3.11", + "python3.13", + "python3.14", + "python3", +]; + +const KEY_VARIABLE: &str = "OPENAI_API_KEY"; +const BASE_URL_VARIABLE: &str = "OPENAI_BASE_URL"; + +const ROUTE_COMMANDS: [&str; 3] = ["scan", "remediate", "doctor"]; + +const OVERRIDING_VARIABLES: [&str; 4] = [ + "ANTHROPIC_API_KEY", + "ANTHROPIC_SDK_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + CLOUD_KEY_VARIABLE, +]; + +// --------------------------------------------------------------------------- +// The command line +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Invocation { + Routed { command: String, rest: Vec }, + Passthrough(Vec), +} + +fn is_help_or_version_flag(arg: &str) -> bool { + matches!(arg, "-h" | "--help" | "-v" | "--version") +} + +fn names_config(arg: &str) -> bool { + arg == "--config" || arg.starts_with("--config=") +} + +fn config_error() -> String { + "wally vvah writes the --config file itself, from your account's model. Remove --config, \ + or run VVAH directly to use your own file." + .to_string() +} + +/// No subcommand means `scan --repo . --remediate`. `scan`/`remediate`/`doctor` +/// get the Wally config; everything else is left alone. +pub fn classify(args: &[String]) -> Result { + let first = args.first().map(String::as_str); + let (command, rest): (String, Vec) = match first { + None => ( + "scan".to_string(), + vec![ + "--repo".to_string(), + ".".to_string(), + "--remediate".to_string(), + ], + ), + Some(flag) if is_help_or_version_flag(flag) => { + return Ok(Invocation::Passthrough(args.to_vec())) + } + Some(flag) if flag.starts_with('-') => ("scan".to_string(), args.to_vec()), + Some(command) if ROUTE_COMMANDS.contains(&command) => { + (command.to_string(), args[1..].to_vec()) + } + Some(_) => return Ok(Invocation::Passthrough(args.to_vec())), + }; + if rest.iter().any(|arg| names_config(arg)) { + return Err(config_error()); + } + if rest + .iter() + .any(|arg| matches!(arg.as_str(), "-h" | "--help")) + { + let mut argv = vec![command]; + argv.extend(rest); + return Ok(Invocation::Passthrough(argv)); + } + Ok(Invocation::Routed { command, rest }) +} + +impl Invocation { + pub fn argv(&self, config: &str) -> Vec { + match self { + Invocation::Routed { command, rest } => { + let mut argv = vec![command.clone(), "--config".to_string(), config.to_string()]; + argv.extend(rest.iter().cloned()); + argv + } + Invocation::Passthrough(args) => args.clone(), + } + } +} + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- + +/// A YAML flow-mapping entry for one role. The model id is a double-quoted +/// scalar (JSON strings are valid YAML): an id may hold `{`, `}`, `,` or `:`, +/// which would otherwise end the mapping or add fields. +fn role_entry(id: &str, model: &str) -> String { + let model = serde_json::to_string(model).expect("a string serialises"); + format!("{id}: {{id: {model}, via: openai}}") +} + +fn role_line(id: &str, model: &str) -> String { + format!(" {}", role_entry(id, model)) +} + +/// OpenAI-compatible overlay for every active VVAH role. No key. +pub fn config_yaml(model: &str) -> String { + let mut out = String::from("models:\n"); + for id in [ + "autoexclude", + "preprocess", + "threatmodel", + "decompose", + "deepdive", + "verify", + "dedup", + "chain", + "remediate", + ] { + out.push_str(&role_line(id, model)); + out.push('\n'); + } + out.push_str(" validate:\n"); + for id in [ + "orchestrator", + "security_architect", + "penetration_tester", + "cross_repo_analyzer", + ] { + out.push_str(&format!(" {}\n", role_entry(id, model))); + } + out.push_str(" exploit_verification:\n"); + for id in ["attacker", "judge", "classify", "mapper"] { + out.push_str(&format!(" {}\n", role_entry(id, model))); + } + out.push_str( + "step1:\n auto_exclude_effort: none\n\ + step2:\n max_turns: 60\n\ + step4:\n max_turns: 60\n\ + step6_verify:\n max_turns: 60\n\ + step_remediate:\n enabled: true\n\ + step_validate:\n enabled: true\n", + ); + out +} + +pub fn config_path() -> Option { + let profile = account::profile_directory(); + if profile.is_empty() { + return None; + } + Some( + PathBuf::from(profile) + .join("vvah") + .join("config.local.yaml"), + ) +} + +pub fn repo_from_rest(rest: &[String]) -> &str { + let mut i = 0; + while i < rest.len() { + if rest[i] == "--repo" { + return rest.get(i + 1).map(String::as_str).unwrap_or("."); + } + if let Some(value) = rest[i].strip_prefix("--repo=") { + return value; + } + i += 1; + } + "." +} + +/// `path` canonicalised, or its nearest ancestor that exists (the profile +/// directory and its parent may not exist yet). +fn existing_or_parent(path: &Path) -> Option { + path.ancestors() + .find_map(|candidate| std::fs::canonicalize(candidate).ok()) +} + +/// True when `path` is `root` or a file under it (VVAH's CONFIG_POLICY_REFUSED). +pub fn path_is_inside(path: &Path, root: &Path) -> bool { + let Some(path) = existing_or_parent(path) else { + return false; + }; + let Ok(root) = std::fs::canonicalize(root) else { + return false; + }; + path == root || path.starts_with(&root) +} + +/// Profile config sits under `$HOME/.config/wally`. A default scan of `.` from +/// home is that tree, so VVAH refuses the file. Put the config directory in +/// temp instead. `--config` must be `config.yaml`; VVAH then loads sibling +/// `config.local.yaml` as the overlay. +pub fn config_dir_for_target( + preferred_dir: PathBuf, + cwd: &Path, + repo: &str, +) -> Result { + let root = { + let repo = Path::new(repo); + if repo.is_absolute() { + repo.to_path_buf() + } else { + cwd.join(repo) + } + }; + if !path_is_inside(&preferred_dir, &root) { + return Ok(preferred_dir); + } + let fallback = std::env::temp_dir().join("wally-vvah"); + if path_is_inside(&fallback, &root) { + return Err( + "the scan target contains Wally's VVAH config path; pass --repo at a project directory" + .to_string(), + ); + } + Ok(fallback) +} + +pub fn packaged_default_yaml(vvaharness: &Path) -> Option { + let venv = vvaharness.parent()?.parent()?; + let relative = Path::new("vvaharness") + .join("config") + .join("profiles") + .join("default.yaml"); + let windows = venv.join("Lib").join("site-packages").join(&relative); + if windows.is_file() { + return Some(windows); + } + let lib = venv.join("lib"); + let entries = std::fs::read_dir(&lib).ok()?; + for entry in entries.flatten() { + let candidate = entry.path().join("site-packages").join(&relative); + if candidate.is_file() { + return Some(candidate); + } + } + None +} + +pub fn stage_vvah_config(dir: &Path, program: &Path, model: &str) -> Result { + if std::fs::create_dir_all(dir).is_err() { + return Err(format!("could not create {}", dir.display())); + } + let overlay = dir.join("config.local.yaml"); + write_private_file(&overlay, &config_yaml(model)) + .map_err(|error| format!("could not write {}: {error}", overlay.display()))?; + let primary = dir.join("config.yaml"); + match packaged_default_yaml(program) { + Some(src) => { + std::fs::copy(&src, &primary) + .map_err(|error| format!("could not copy VVAH default.yaml: {error}"))?; + Ok(primary) + } + None => Ok(overlay), + } +} + +pub fn refuse_home_workspace(args: &[String], cwd: &Path, home: Option<&Path>) -> bool { + if !args.is_empty() { + return false; + } + let Some(home) = home else { + return false; + }; + if cwd == home { + return true; + } + match (std::fs::canonicalize(cwd), std::fs::canonicalize(home)) { + (Ok(cwd), Ok(home)) => cwd == home, + _ => false, + } +} + +pub fn write_private_file(path: &Path, text: &str) -> std::io::Result<()> { + let Some(parent) = path.parent() else { + return Err(std::io::Error::other("no parent directory")); + }; + if !parent.is_dir() { + std::fs::create_dir_all(parent)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))?; + } + } + let mut temporary_name = std::ffi::OsString::from("."); + temporary_name.push(path.file_name().unwrap_or_default()); + temporary_name.push(format!(".{}.tmp", std::process::id())); + let temporary = parent.join(temporary_name); + let _ = std::fs::remove_file(&temporary); + + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let written = options.open(&temporary).and_then(|mut file| { + file.write_all(text.as_bytes())?; + file.sync_all() + }); + match written.and_then(|()| std::fs::rename(&temporary, path)) { + Ok(()) => Ok(()), + Err(error) => { + let _ = std::fs::remove_file(&temporary); + Err(error) + } + } +} + +// --------------------------------------------------------------------------- +// Python / install +// --------------------------------------------------------------------------- + +pub fn parse_python_version(output: &str) -> Option<(u32, u32)> { + let line = output + .lines() + .map(str::trim) + .find(|line| !line.is_empty())?; + let version = line.split_whitespace().last()?; + let mut parts = version.split('.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next()?.parse().ok()?; + Some((major, minor)) +} + +pub fn python_is_new_enough(major: u32, minor: u32) -> bool { + (major, minor) >= MIN_PYTHON +} + +pub fn python_is_supported(major: u32, minor: u32) -> bool { + python_is_new_enough(major, minor) && (major, minor) <= MAX_PYTHON +} + +pub fn too_new_python_message(major: u32, minor: u32) -> String { + format!( + "VVAH needs Python 3.11–3.14 (python3 is {major}.{minor}). Install Python 3.12, then run this again." + ) +} + +pub fn install_dir() -> Option { + let profile = account::profile_directory(); + if profile.is_empty() { + return None; + } + Some(PathBuf::from(profile).join("vvah")) +} + +fn venv_bin() -> &'static str { + if cfg!(windows) { + "Scripts" + } else { + "bin" + } +} + +pub fn venv_vvaharness(dir: &Path) -> PathBuf { + let mut name = dir.join("venv").join(venv_bin()).join("vvaharness"); + if cfg!(windows) { + name.set_extension("exe"); + } + name +} + +/// `text` as one shell word: left alone when it is plainly safe, else quoted +/// for the platform's shell (single quotes on Unix, double quotes on Windows). +fn shell_word(text: &str) -> String { + let plain = !text.is_empty() + && text + .chars() + .all(|c| c.is_ascii_alphanumeric() || "-_./:\\+=@%".contains(c)); + if plain { + text.to_string() + } else if cfg!(windows) { + format!("\"{}\"", text.replace('"', "\"\"")) + } else { + format!("'{}'", text.replace('\'', "'\\''")) + } +} + +pub fn install_command(dir: &Path, python: &Path) -> String { + let venv = dir.join("venv"); + let pip = venv + .join(venv_bin()) + .join(if cfg!(windows) { "pip.exe" } else { "pip" }); + format!( + "{} -m venv {} && {} install {SOURCE}", + shell_word(&python.display().to_string()), + shell_word(&venv.display().to_string()), + shell_word(&pip.display().to_string()) + ) +} + +fn is_executable(path: &Path) -> bool { + let Ok(metadata) = std::fs::metadata(path) else { + return false; + }; + if !metadata.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + metadata.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + true + } +} + +pub fn probe_python_at(program: &Path) -> Option<(u32, u32)> { + let output = Command::new(program) + .arg("--version") + .stdin(Stdio::null()) + .output() + .ok()?; + if !output.status.success() { + return None; + } + parse_python_version(&String::from_utf8_lossy(&output.stdout)) + .or_else(|| parse_python_version(&String::from_utf8_lossy(&output.stderr))) +} + +pub fn probe_python() -> Option<(u32, u32)> { + probe_python_at(Path::new("python3")) +} + +/// First interpreter on PATH that VVAH's pinned ArcadeDB wheel will install on. +pub fn find_supported_python() -> Option<(PathBuf, u32, u32)> { + for name in PYTHON_CANDIDATES { + let Some(path) = find_on_path(name) else { + continue; + }; + let Some((major, minor)) = probe_python_at(&path) else { + continue; + }; + if python_is_supported(major, minor) { + return Some((path, major, minor)); + } + } + None +} + +fn retry_busy(mut start: impl FnMut() -> std::io::Result) -> std::io::Result { + let mut attempts = 0; + loop { + match start() { + Err(error) + if error.kind() == std::io::ErrorKind::ExecutableFileBusy && attempts < 40 => + { + attempts += 1; + std::thread::sleep(Duration::from_millis(25)); + } + other => return other, + } + } +} + +fn find_on_path(name: &str) -> Option { + let search_path = std::env::var_os("PATH")?; + let names: Vec = if cfg!(windows) { + vec![format!("{name}.exe"), name.to_string()] + } else { + vec![name.to_string()] + }; + for dir in std::env::split_paths(&search_path) { + if dir.as_os_str().is_empty() { + continue; + } + for candidate in &names { + let path = dir.join(candidate); + if is_executable(&path) { + return Some(path); + } + } + } + None +} + +/// A `vvaharness` we can run: the profile venv first, then PATH. +pub fn resolve_vvaharness() -> Option { + if let Some(dir) = install_dir() { + let path = venv_vvaharness(&dir); + if is_executable(&path) { + return Some(path); + } + } + find_on_path("vvaharness") +} + +fn run_status(mut command: Command) -> bool { + retry_busy(|| command.status()) + .ok() + .is_some_and(|status| status.success()) +} + +pub fn install_vvah(dir: &Path, python: &Path) -> Result<(), String> { + if std::fs::create_dir_all(dir).is_err() { + return Err(format!("could not create {}", dir.display())); + } + let venv = dir.join("venv"); + if !run_status({ + let mut command = Command::new(python); + command.args(["-m", "venv", "--clear"]).arg(&venv); + command + }) { + return Err("could not create a Python virtual environment for VVAH".to_string()); + } + let pip = venv + .join(venv_bin()) + .join(if cfg!(windows) { "pip.exe" } else { "pip" }); + if !run_status({ + let mut command = Command::new(&pip); + command.args(["install", SOURCE]); + command + }) { + return Err("could not install VVAH into the virtual environment".to_string()); + } + if !is_executable(&venv_vvaharness(dir)) { + return Err("VVAH installed but wally could not find vvaharness in the venv".to_string()); + } + Ok(()) +} + +fn ensure_installed() -> Option { + if let Some(path) = resolve_vvaharness() { + return Some(path); + } + let Some(dir) = install_dir() else { + out::error_line("no user profile directory is available"); + return None; + }; + let python = find_supported_python().map(|(path, _, _)| path); + let shown = python.as_deref().unwrap_or(Path::new("python3")); + let command = install_command(&dir, shown); + out::error_line("vvaharness is not installed on this machine"); + out::status_line(&format!("install it with `{command}`")); + if !confirm(&format!("Install it now with `{command}`? [y/N] ")) { + return None; + } + let Some(python) = python.or_else(|| find_supported_python().map(|(path, _, _)| path)) else { + match probe_python() { + Some((major, minor)) if (major, minor) > MAX_PYTHON => { + out::error_line(&too_new_python_message(major, minor)); + } + Some(_) | None => out::error_line(MISSING_PYTHON), + } + return None; + }; + if let Err(error) = install_vvah(&dir, &python) { + out::error_line(&error); + return None; + } + Some(venv_vvaharness(&dir)) +} + +// --------------------------------------------------------------------------- +// Child env and run +// --------------------------------------------------------------------------- + +pub struct ChildEnv { + pub set: Vec<(&'static str, String)>, + pub remove: Vec<&'static str>, +} + +impl ChildEnv { + pub fn for_start(key: &str, base_url: &str) -> Self { + ChildEnv { + set: vec![ + (KEY_VARIABLE, key.to_string()), + (BASE_URL_VARIABLE, base_url.to_string()), + ], + remove: OVERRIDING_VARIABLES.to_vec(), + } + } + + pub fn unchanged() -> Self { + ChildEnv { + set: Vec::new(), + remove: Vec::new(), + } + } + + fn apply(&self, command: &mut Command) { + for name in &self.remove { + command.env_remove(name); + } + for (name, value) in &self.set { + command.env(name, value); + } + } +} + +pub fn run_child(program: &Path, argv: &[String], env: &ChildEnv) -> i32 { + let interrupted = Arc::new(AtomicBool::new(false)); + let noted = Arc::clone(&interrupted); + let _interrupt = crate::util::interrupt::on_interrupt(move || { + noted.store(true, Ordering::SeqCst); + }); + + let mut command = Command::new(program); + command.args(argv); + env.apply(&mut command); + let status = match retry_busy(|| command.status()) { + Ok(status) => status, + Err(error) => { + out::error_line(&format!("could not start VVAH: {error}")); + return 127; + } + }; + if let Some(code) = status.code() { + return code; + } + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + if let Some(signal) = status.signal() { + return 128 + signal; + } + } + if interrupted.load(Ordering::SeqCst) { + 130 + } else { + 1 + } +} + +/// `wally vvah [-m ] [--] [] [args...]`. +pub fn launch(requested_model: Option<&str>, args: &[String]) -> i32 { + if let Err(code) = hosted::require_safe_model(requested_model) { + return code; + } + let invocation = match classify(args) { + Ok(invocation) => invocation, + Err(message) => { + out::error_line(&message); + return 2; + } + }; + let cwd = std::env::current_dir().unwrap_or_default(); + let home = hosted::home_directory(); + if refuse_home_workspace(args, &cwd, home.as_deref()) { + out::error_line( + "wally vvah with no extra args scans the current directory. \ + That is your home directory. cd into a repo you are authorised to test, \ + or pass --repo after --.", + ); + return 2; + } + let Some(program) = ensure_installed() else { + return 127; + }; + + let Invocation::Routed { .. } = &invocation else { + if requested_model.is_some() { + out::status_line("-m only applies to scan, remediate and doctor; passing the command through unchanged"); + } + return run_child(&program, &invocation.argv(""), &ChildEnv::unchanged()); + }; + + let console = ConsoleClient::default(); + let Some(hosted) = hosted::hosted_access(&console, "VVAH") else { + return 1; + }; + let (model, base_url) = match hosted::pick_model(&console, &hosted, requested_model) { + Ok(chosen) => chosen, + Err(code) => return code, + }; + + let Some(preferred_dir) = install_dir() else { + out::error_line("no user profile directory is available"); + return 1; + }; + let repo = match &invocation { + Invocation::Routed { rest, .. } => repo_from_rest(rest), + Invocation::Passthrough(_) => ".", + }; + let dir = match config_dir_for_target(preferred_dir, &cwd, repo) { + Ok(dir) => dir, + Err(message) => { + out::error_line(&message); + return 2; + } + }; + let path = match stage_vvah_config(&dir, &program, &model) { + Ok(path) => path, + Err(error) => { + out::error_line(&error); + return 1; + } + }; + + out::status_line(S10_WARNING); + out::status_line(&format!("VVAH will use {model} through {base_url}")); + run_child( + &program, + &invocation.argv(&path.to_string_lossy()), + &ChildEnv::for_start(&hosted.key, &base_url), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + use crate::util::env_lock::lock as env_lock; + + fn strings(tokens: &[&str]) -> Vec { + tokens.iter().map(|t| t.to_string()).collect() + } + + #[test] + fn pinned_version_and_missing_python_message() { + assert_eq!(PINNED_VERSION, "1.5.1"); + assert_eq!( + MISSING_PYTHON, + "python 3.11 is not installed on this machine, Please install it first, Thank You" + ); + } + + #[test] + fn repo_from_rest_reads_flag() { + assert_eq!( + repo_from_rest(&strings(&["--repo", ".", "--remediate"])), + "." + ); + assert_eq!(repo_from_rest(&strings(&["--repo=./x"])), "./x"); + assert_eq!(repo_from_rest(&[]), "."); + } + + #[test] + fn config_dir_for_target_leaves_profile_when_repo_is_elsewhere() { + let dir = tempfile::tempdir().expect("tempdir"); + let cwd = dir.path().join("proj"); + let profile = dir.path().join("profile"); + std::fs::create_dir_all(&cwd).unwrap(); + std::fs::create_dir_all(&profile).unwrap(); + let chosen = config_dir_for_target(profile.clone(), &cwd, ".").unwrap(); + assert_eq!(chosen, profile); + } + + #[test] + fn config_dir_for_target_moves_out_when_repo_contains_profile() { + let dir = tempfile::tempdir().expect("tempdir"); + let cwd = dir.path(); + let profile = cwd.join(".config").join("wally"); + std::fs::create_dir_all(&profile).unwrap(); + let chosen = config_dir_for_target(profile, cwd, ".").unwrap(); + assert!(!path_is_inside(&chosen, cwd)); + } + + #[test] + fn packaged_default_yaml_walks_venv_lib() { + let dir = tempfile::tempdir().expect("tempdir"); + let bin = dir.path().join("venv").join("bin"); + let yaml = dir + .path() + .join("venv") + .join("lib") + .join("python3.12") + .join("site-packages") + .join("vvaharness") + .join("config") + .join("profiles") + .join("default.yaml"); + std::fs::create_dir_all(yaml.parent().unwrap()).unwrap(); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::write(&yaml, "models: {}\n").unwrap(); + let program = bin.join("vvaharness"); + std::fs::write(&program, "").unwrap(); + assert_eq!( + packaged_default_yaml(&program).as_deref(), + Some(yaml.as_path()) + ); + } + + #[test] + fn refuse_home_workspace_only_for_bare_scan() { + let home = Path::new("/Users/me"); + assert!(refuse_home_workspace(&[], home, Some(home))); + assert!(!refuse_home_workspace( + &strings(&["scan", "--repo", "./x"]), + home, + Some(home) + )); + } + + #[test] + fn classify_default_is_routed_scan_with_remediate() { + assert_eq!( + classify(&[]).unwrap(), + Invocation::Routed { + command: "scan".to_string(), + rest: strings(&["--repo", ".", "--remediate"]), + } + ); + } + + #[test] + fn classify_help_passes_through_and_scan_is_routed() { + let help = strings(&["--help"]); + assert_eq!(classify(&help).unwrap(), Invocation::Passthrough(help)); + assert_eq!( + classify(&strings(&["scan", "--repo", "./x"])).unwrap(), + Invocation::Routed { + command: "scan".to_string(), + rest: strings(&["--repo", "./x"]), + } + ); + assert_eq!( + classify(&strings(&["report"])).unwrap(), + Invocation::Passthrough(strings(&["report"])) + ); + } + + #[test] + fn classify_refuses_user_config_on_routed_commands() { + assert!(classify(&strings(&["scan", "--config", "mine.yaml"])).is_err()); + assert!(classify(&strings(&["doctor", "--config=mine.yaml"])).is_err()); + } + + #[test] + fn python_version_parse_and_floor() { + assert_eq!(parse_python_version("Python 3.11.9\n"), Some((3, 11))); + assert!(python_is_new_enough(3, 11)); + assert!(python_is_new_enough(3, 12)); + assert!(!python_is_new_enough(3, 10)); + assert!(python_is_supported(3, 11)); + assert!(python_is_supported(3, 14)); + assert!(!python_is_supported(3, 10)); + assert!(!python_is_supported(3, 15)); + assert!(too_new_python_message(3, 15).contains("3.11–3.14")); + } + + #[test] + fn config_yaml_maps_roles_without_a_key() { + let yaml = config_yaml("glm-5.3-flash"); + assert!(yaml.contains("autoexclude: {id: \"glm-5.3-flash\", via: openai}")); + assert!(yaml.contains("remediate: {id: \"glm-5.3-flash\", via: openai}")); + assert!(yaml.contains(" orchestrator: {id: \"glm-5.3-flash\", via: openai}")); + // An id with flow-mapping characters stays one scalar. + let odd = config_yaml("a},b: {c:d"); + assert!(odd.contains("decompose: {id: \"a},b: {c:d\", via: openai}")); + assert!(yaml.contains("max_turns: 60")); + assert!(yaml.contains("auto_exclude_effort: none")); + assert!(!yaml.contains("inject:")); + assert!(!yaml.to_lowercase().contains("api_key")); + assert!(!yaml.contains("sk-")); + } + + #[test] + fn install_command_shows_venv_and_pip_install() { + let dir = tempfile::tempdir().expect("tempdir"); + let command = install_command(dir.path(), Path::new("python3.12")); + assert!(command.contains("python3.12 -m venv")); + assert!(command.contains("pip")); + assert!(command.contains(SOURCE)); + } + + #[test] + fn install_command_quotes_paths_with_spaces() { + assert_eq!( + shell_word("/opt/py/bin/python3.12"), + "/opt/py/bin/python3.12" + ); + if !cfg!(windows) { + assert_eq!(shell_word("/Users/a b/vvah"), "'/Users/a b/vvah'"); + assert_eq!(shell_word("it's"), "'it'\\''s'"); + let dir = Path::new("/Users/a b/vvah"); + let command = install_command(dir, Path::new("/usr/bin/python3.12")); + assert!( + command.contains("-m venv '/Users/a b/vvah/venv' &&"), + "{command}" + ); + assert!( + command.contains("'/Users/a b/vvah/venv/bin/pip' install"), + "{command}" + ); + } + } + + #[test] + fn containment_walks_up_to_the_nearest_existing_ancestor() { + let root = tempfile::tempdir().expect("tempdir"); + let missing = root.path().join("not").join("yet").join("config"); + assert!(path_is_inside(&missing, root.path())); + let other = tempfile::tempdir().expect("tempdir"); + assert!(!path_is_inside( + &other.path().join("a").join("b"), + root.path() + )); + } + + #[test] + fn argv_routed_injects_config_before_rest() { + let routed = Invocation::Routed { + command: "scan".to_string(), + rest: strings(&["--repo", "."]), + }; + assert_eq!( + routed.argv("/tmp/cfg.yaml"), + strings(&["scan", "--config", "/tmp/cfg.yaml", "--repo", "."]) + ); + } + + #[cfg(unix)] + #[test] + fn write_private_file_is_owner_only_and_contains_no_key() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("config.local.yaml"); + let yaml = config_yaml("glm-5.3-flash"); + write_private_file(&path, &yaml).unwrap(); + let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + let text = fs::read_to_string(&path).unwrap(); + assert_eq!(text, yaml); + assert!(!text.to_lowercase().contains("api_key")); + assert!(!text.contains("sk-")); + } + + #[test] + fn child_env_sets_openai_vars_only_on_child() { + let env = ChildEnv::for_start("key", "https://api.example/v1"); + assert_eq!( + env.set, + vec![ + ("OPENAI_API_KEY", "key".to_string()), + ("OPENAI_BASE_URL", "https://api.example/v1".to_string()), + ] + ); + assert!(env.remove.contains(&CLOUD_KEY_VARIABLE)); + } + + #[cfg(unix)] + #[test] + fn run_child_applies_openai_env_on_child_only() { + use std::os::unix::fs::PermissionsExt; + + let _lock = env_lock(); + let dir = tempfile::tempdir().expect("tempdir"); + let stub = dir.path().join("vvah-stub"); + fs::write( + &stub, + "#!/bin/sh\nif test \"$OPENAI_API_KEY\" = child && test \"$OPENAI_BASE_URL\" = https://x/v1 && test -z \"$WALLY_CLOUD_KEY\"; then exit 43; fi\nexit 9\n", + ) + .unwrap(); + fs::set_permissions(&stub, fs::Permissions::from_mode(0o700)).unwrap(); + + let previous = std::env::var("WALLY_CLOUD_KEY").ok(); + std::env::set_var("WALLY_CLOUD_KEY", "parent"); + let code = run_child( + &stub, + &strings(&["scan", "--config", "c.yaml"]), + &ChildEnv::for_start("child", "https://x/v1"), + ); + let remaining = std::env::var("WALLY_CLOUD_KEY"); + match previous { + Some(value) => std::env::set_var("WALLY_CLOUD_KEY", value), + None => std::env::remove_var("WALLY_CLOUD_KEY"), + } + assert_eq!(code, 43); + assert_eq!(remaining.as_deref(), Ok("parent")); + } +} diff --git a/tests/golden/README.md b/tests/golden/README.md index 8b311790..9fa99306 100644 --- a/tests/golden/README.md +++ b/tests/golden/README.md @@ -1,6 +1,6 @@ # Golden CLI corpus -Byte-exact stdout, stderr and exit code for 371 invocations of `wally`, +Byte-exact stdout, stderr and exit code for 379 invocations of `wally`, captured from the C++ build of `origin/main` at `d1e9c0b` (the last C++ release line) on macOS arm64 with kit 0.20.37. `tests/cli_golden.rs` replays every case against the Rust binary in the same isolated environment and fails @@ -112,3 +112,17 @@ and its `--json` twin. `wally command-code` added a line to every command list, so the same 31 cases were re-captured from the Rust binary; that line is the only difference in each. `help__command-code` and `help__help__command-code` are new cases. + +`wally shannon` added a line to every command list, so the same 31 cases were +re-captured from the Rust binary; that line is the only difference in each. +`help__shannon` and `help__help__shannon` are new cases. + +`wally deepsec` and `wally vvah` added a **Security tools** group to every +command list, so the heading, the `deepsec` and `vvah` entries and the blank +separator line (four lines in all) are the only extra difference in each. +`help__deepsec`, `help__help__deepsec`, `help__vvah` and `help__help__vvah` are +new cases. + +The first `wally vvah` example's description was reworded to say bare +`wally vvah` also remediates (edits files); `help__vvah` and +`help__help__vvah` were updated by that one line. diff --git a/tests/golden/cases.json b/tests/golden/cases.json index d54035c4..55c13b24 100644 --- a/tests/golden/cases.json +++ b/tests/golden/cases.json @@ -299,6 +299,27 @@ "command-code" ] }, + { + "name": "help__shannon", + "args": [ + "help", + "shannon" + ] + }, + { + "name": "help__deepsec", + "args": [ + "help", + "deepsec" + ] + }, + { + "name": "help__vvah", + "args": [ + "help", + "vvah" + ] + }, { "name": "help__codex", "args": [ @@ -482,6 +503,30 @@ "--help" ] }, + { + "name": "help__help__shannon", + "args": [ + "help", + "shannon", + "--help" + ] + }, + { + "name": "help__help__deepsec", + "args": [ + "help", + "deepsec", + "--help" + ] + }, + { + "name": "help__help__vvah", + "args": [ + "help", + "vvah", + "--help" + ] + }, { "name": "help__help__codex", "args": [ diff --git a/tests/golden/expected/alt_help__--no-color__--help.stdout b/tests/golden/expected/alt_help__--no-color__--help.stdout index f08642f5..f4775c3c 100644 --- a/tests/golden/expected/alt_help__--no-color__--help.stdout +++ b/tests/golden/expected/alt_help__--no-color__--help.stdout @@ -20,12 +20,17 @@ Coding tools: codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model opencode Open OpenCode with a local or cloud model + shannon Test your own apps with Shannon, an AI pentester hermes Open Hermes with a model openclaw Open OpenClaw with a model deepseek Open DeepSeek Harness with a model prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/alt_help__-h.stdout b/tests/golden/expected/alt_help__-h.stdout index f08642f5..f4775c3c 100644 --- a/tests/golden/expected/alt_help__-h.stdout +++ b/tests/golden/expected/alt_help__-h.stdout @@ -20,12 +20,17 @@ Coding tools: codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model opencode Open OpenCode with a local or cloud model + shannon Test your own apps with Shannon, an AI pentester hermes Open Hermes with a model openclaw Open OpenClaw with a model deepseek Open DeepSeek Harness with a model prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/alt_help__help.stdout b/tests/golden/expected/alt_help__help.stdout index f08642f5..f4775c3c 100644 --- a/tests/golden/expected/alt_help__help.stdout +++ b/tests/golden/expected/alt_help__help.stdout @@ -20,12 +20,17 @@ Coding tools: codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model opencode Open OpenCode with a local or cloud model + shannon Test your own apps with Shannon, an AI pentester hermes Open Hermes with a model openclaw Open OpenClaw with a model deepseek Open DeepSeek Harness with a model prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/alt_help__help__nope.stdout b/tests/golden/expected/alt_help__help__nope.stdout index f08642f5..f4775c3c 100644 --- a/tests/golden/expected/alt_help__help__nope.stdout +++ b/tests/golden/expected/alt_help__help__nope.stdout @@ -20,12 +20,17 @@ Coding tools: codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model opencode Open OpenCode with a local or cloud model + shannon Test your own apps with Shannon, an AI pentester hermes Open Hermes with a model openclaw Open OpenClaw with a model deepseek Open DeepSeek Harness with a model prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/help__ROOT.stdout b/tests/golden/expected/help__ROOT.stdout index f08642f5..f4775c3c 100644 --- a/tests/golden/expected/help__ROOT.stdout +++ b/tests/golden/expected/help__ROOT.stdout @@ -20,12 +20,17 @@ Coding tools: codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model opencode Open OpenCode with a local or cloud model + shannon Test your own apps with Shannon, an AI pentester hermes Open Hermes with a model openclaw Open OpenClaw with a model deepseek Open DeepSeek Harness with a model prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/help__deepsec.code b/tests/golden/expected/help__deepsec.code new file mode 100644 index 00000000..573541ac --- /dev/null +++ b/tests/golden/expected/help__deepsec.code @@ -0,0 +1 @@ +0 diff --git a/tests/golden/expected/help__deepsec.stderr b/tests/golden/expected/help__deepsec.stderr new file mode 100644 index 00000000..e69de29b diff --git a/tests/golden/expected/help__deepsec.stdout b/tests/golden/expected/help__deepsec.stdout new file mode 100644 index 00000000..44d0dc6a --- /dev/null +++ b/tests/golden/expected/help__deepsec.stdout @@ -0,0 +1,21 @@ +Scan your own repos with DeepSec + +Usage: wally deepsec [OPTIONS] [args...] + +Arguments: + args DeepSec's own command and options; with none, init + +Options: + -h, --help Show help + -m, --model TEXT A model from your account (default: your default + model) + +Examples: + # Start a scan on a repo you are authorised to test (runs init) + wally deepsec -- init . ./my-repo + + # Choose the model + wally deepsec -m glm-5.3-flash -- process + + # Other DeepSec commands pass through + wally deepsec -- status diff --git a/tests/golden/expected/help__help.stdout b/tests/golden/expected/help__help.stdout index f08642f5..f4775c3c 100644 --- a/tests/golden/expected/help__help.stdout +++ b/tests/golden/expected/help__help.stdout @@ -20,12 +20,17 @@ Coding tools: codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model opencode Open OpenCode with a local or cloud model + shannon Test your own apps with Shannon, an AI pentester hermes Open Hermes with a model openclaw Open OpenClaw with a model deepseek Open DeepSeek Harness with a model prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/help__help__deepsec.code b/tests/golden/expected/help__help__deepsec.code new file mode 100644 index 00000000..573541ac --- /dev/null +++ b/tests/golden/expected/help__help__deepsec.code @@ -0,0 +1 @@ +0 diff --git a/tests/golden/expected/help__help__deepsec.stderr b/tests/golden/expected/help__help__deepsec.stderr new file mode 100644 index 00000000..e69de29b diff --git a/tests/golden/expected/help__help__deepsec.stdout b/tests/golden/expected/help__help__deepsec.stdout new file mode 100644 index 00000000..44d0dc6a --- /dev/null +++ b/tests/golden/expected/help__help__deepsec.stdout @@ -0,0 +1,21 @@ +Scan your own repos with DeepSec + +Usage: wally deepsec [OPTIONS] [args...] + +Arguments: + args DeepSec's own command and options; with none, init + +Options: + -h, --help Show help + -m, --model TEXT A model from your account (default: your default + model) + +Examples: + # Start a scan on a repo you are authorised to test (runs init) + wally deepsec -- init . ./my-repo + + # Choose the model + wally deepsec -m glm-5.3-flash -- process + + # Other DeepSec commands pass through + wally deepsec -- status diff --git a/tests/golden/expected/help__help__shannon.code b/tests/golden/expected/help__help__shannon.code new file mode 100644 index 00000000..573541ac --- /dev/null +++ b/tests/golden/expected/help__help__shannon.code @@ -0,0 +1 @@ +0 diff --git a/tests/golden/expected/help__help__shannon.stderr b/tests/golden/expected/help__help__shannon.stderr new file mode 100644 index 00000000..e69de29b diff --git a/tests/golden/expected/help__help__shannon.stdout b/tests/golden/expected/help__help__shannon.stdout new file mode 100644 index 00000000..c7e344db --- /dev/null +++ b/tests/golden/expected/help__help__shannon.stdout @@ -0,0 +1,25 @@ +Test your own apps with Shannon, an AI pentester + +Usage: wally shannon [OPTIONS] [args...] + +Arguments: + args Shannon's own command and options; with none, + start + +Options: + -h, --help Show help + -m, --model TEXT A model from your account (default: your default + model) + +Examples: + # Scan a target you are authorised to test (starts a scan) + wally shannon -- -u https://staging.example.com -r ./my-repo + + # Choose the model for the scan + wally shannon -m glm-5.3-flash -- -u https://staging.example.com -r ./my-repo + + # List running and finished scans + wally shannon -- scans + + # Follow a scan's log + wally shannon -- logs my-scan diff --git a/tests/golden/expected/help__help__vvah.code b/tests/golden/expected/help__help__vvah.code new file mode 100644 index 00000000..573541ac --- /dev/null +++ b/tests/golden/expected/help__help__vvah.code @@ -0,0 +1 @@ +0 diff --git a/tests/golden/expected/help__help__vvah.stderr b/tests/golden/expected/help__help__vvah.stderr new file mode 100644 index 00000000..e69de29b diff --git a/tests/golden/expected/help__help__vvah.stdout b/tests/golden/expected/help__help__vvah.stdout new file mode 100644 index 00000000..d7974ebc --- /dev/null +++ b/tests/golden/expected/help__help__vvah.stdout @@ -0,0 +1,22 @@ +Scan your own repos with VVAH + +Usage: wally vvah [OPTIONS] [args...] + +Arguments: + args VVAH's own command and options; with none, scan + --repo . --remediate + +Options: + -h, --help Show help + -m, --model TEXT A model from your account (default: your default + model) + +Examples: + # Scan and remediate (edits files in) the current repo + wally vvah + + # Scan another path with a chosen model + wally vvah -m glm-5.3-flash -- scan --repo ./my-repo + + # Other VVAH commands pass through + wally vvah -- report diff --git a/tests/golden/expected/help__shannon.code b/tests/golden/expected/help__shannon.code new file mode 100644 index 00000000..573541ac --- /dev/null +++ b/tests/golden/expected/help__shannon.code @@ -0,0 +1 @@ +0 diff --git a/tests/golden/expected/help__shannon.stderr b/tests/golden/expected/help__shannon.stderr new file mode 100644 index 00000000..e69de29b diff --git a/tests/golden/expected/help__shannon.stdout b/tests/golden/expected/help__shannon.stdout new file mode 100644 index 00000000..c7e344db --- /dev/null +++ b/tests/golden/expected/help__shannon.stdout @@ -0,0 +1,25 @@ +Test your own apps with Shannon, an AI pentester + +Usage: wally shannon [OPTIONS] [args...] + +Arguments: + args Shannon's own command and options; with none, + start + +Options: + -h, --help Show help + -m, --model TEXT A model from your account (default: your default + model) + +Examples: + # Scan a target you are authorised to test (starts a scan) + wally shannon -- -u https://staging.example.com -r ./my-repo + + # Choose the model for the scan + wally shannon -m glm-5.3-flash -- -u https://staging.example.com -r ./my-repo + + # List running and finished scans + wally shannon -- scans + + # Follow a scan's log + wally shannon -- logs my-scan diff --git a/tests/golden/expected/help__vvah.code b/tests/golden/expected/help__vvah.code new file mode 100644 index 00000000..573541ac --- /dev/null +++ b/tests/golden/expected/help__vvah.code @@ -0,0 +1 @@ +0 diff --git a/tests/golden/expected/help__vvah.stderr b/tests/golden/expected/help__vvah.stderr new file mode 100644 index 00000000..e69de29b diff --git a/tests/golden/expected/help__vvah.stdout b/tests/golden/expected/help__vvah.stdout new file mode 100644 index 00000000..d7974ebc --- /dev/null +++ b/tests/golden/expected/help__vvah.stdout @@ -0,0 +1,22 @@ +Scan your own repos with VVAH + +Usage: wally vvah [OPTIONS] [args...] + +Arguments: + args VVAH's own command and options; with none, scan + --repo . --remediate + +Options: + -h, --help Show help + -m, --model TEXT A model from your account (default: your default + model) + +Examples: + # Scan and remediate (edits files in) the current repo + wally vvah + + # Scan another path with a chosen model + wally vvah -m glm-5.3-flash -- scan --repo ./my-repo + + # Other VVAH commands pass through + wally vvah -- report diff --git a/tests/golden/expected/parse__--bogus.stderr b/tests/golden/expected/parse__--bogus.stderr index bd36b3aa..97285fb6 100644 --- a/tests/golden/expected/parse__--bogus.stderr +++ b/tests/golden/expected/parse__--bogus.stderr @@ -21,12 +21,17 @@ Coding tools: codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model opencode Open OpenCode with a local or cloud model + shannon Test your own apps with Shannon, an AI pentester hermes Open Hermes with a model openclaw Open OpenClaw with a model deepseek Open DeepSeek Harness with a model prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/parse__-U__--json.stderr b/tests/golden/expected/parse__-U__--json.stderr index 380bcab7..96fb9b96 100644 --- a/tests/golden/expected/parse__-U__--json.stderr +++ b/tests/golden/expected/parse__-U__--json.stderr @@ -21,12 +21,17 @@ Coding tools: codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model opencode Open OpenCode with a local or cloud model + shannon Test your own apps with Shannon, an AI pentester hermes Open Hermes with a model openclaw Open OpenClaw with a model deepseek Open DeepSeek Harness with a model prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/parse__-u__-v.stderr b/tests/golden/expected/parse__-u__-v.stderr index 48c7db57..84ddc118 100644 --- a/tests/golden/expected/parse__-u__-v.stderr +++ b/tests/golden/expected/parse__-u__-v.stderr @@ -21,12 +21,17 @@ Coding tools: codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model opencode Open OpenCode with a local or cloud model + shannon Test your own apps with Shannon, an AI pentester hermes Open Hermes with a model openclaw Open OpenClaw with a model deepseek Open DeepSeek Harness with a model prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/parse__-z.stderr b/tests/golden/expected/parse__-z.stderr index 99255651..09171f7b 100644 --- a/tests/golden/expected/parse__-z.stderr +++ b/tests/golden/expected/parse__-z.stderr @@ -21,12 +21,17 @@ Coding tools: codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model opencode Open OpenCode with a local or cloud model + shannon Test your own apps with Shannon, an AI pentester hermes Open Hermes with a model openclaw Open OpenClaw with a model deepseek Open DeepSeek Harness with a model prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/unknown__auth.stderr b/tests/golden/expected/unknown__auth.stderr index 18d23552..6a3c13c6 100644 --- a/tests/golden/expected/unknown__auth.stderr +++ b/tests/golden/expected/unknown__auth.stderr @@ -21,12 +21,17 @@ Coding tools: codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model opencode Open OpenCode with a local or cloud model + shannon Test your own apps with Shannon, an AI pentester hermes Open Hermes with a model openclaw Open OpenClaw with a model deepseek Open DeepSeek Harness with a model prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/unknown__chat.stderr b/tests/golden/expected/unknown__chat.stderr index f6abe390..4a1a8bdc 100644 --- a/tests/golden/expected/unknown__chat.stderr +++ b/tests/golden/expected/unknown__chat.stderr @@ -21,12 +21,17 @@ Coding tools: codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model opencode Open OpenCode with a local or cloud model + shannon Test your own apps with Shannon, an AI pentester hermes Open Hermes with a model openclaw Open OpenClaw with a model deepseek Open DeepSeek Harness with a model prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/unknown__diarize.stderr b/tests/golden/expected/unknown__diarize.stderr index 748a2ba6..c5186bbb 100644 --- a/tests/golden/expected/unknown__diarize.stderr +++ b/tests/golden/expected/unknown__diarize.stderr @@ -21,12 +21,17 @@ Coding tools: codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model opencode Open OpenCode with a local or cloud model + shannon Test your own apps with Shannon, an AI pentester hermes Open Hermes with a model openclaw Open OpenClaw with a model deepseek Open DeepSeek Harness with a model prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/unknown__embed.stderr b/tests/golden/expected/unknown__embed.stderr index ea90af69..764114d4 100644 --- a/tests/golden/expected/unknown__embed.stderr +++ b/tests/golden/expected/unknown__embed.stderr @@ -21,12 +21,17 @@ Coding tools: codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model opencode Open OpenCode with a local or cloud model + shannon Test your own apps with Shannon, an AI pentester hermes Open Hermes with a model openclaw Open OpenClaw with a model deepseek Open DeepSeek Harness with a model prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/unknown__foo.stderr b/tests/golden/expected/unknown__foo.stderr index 24134d51..a5dcde70 100644 --- a/tests/golden/expected/unknown__foo.stderr +++ b/tests/golden/expected/unknown__foo.stderr @@ -21,12 +21,17 @@ Coding tools: codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model opencode Open OpenCode with a local or cloud model + shannon Test your own apps with Shannon, an AI pentester hermes Open Hermes with a model openclaw Open OpenClaw with a model deepseek Open DeepSeek Harness with a model prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/unknown__image.stderr b/tests/golden/expected/unknown__image.stderr index 704271ab..c44e9332 100644 --- a/tests/golden/expected/unknown__image.stderr +++ b/tests/golden/expected/unknown__image.stderr @@ -21,12 +21,17 @@ Coding tools: codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model opencode Open OpenCode with a local or cloud model + shannon Test your own apps with Shannon, an AI pentester hermes Open Hermes with a model openclaw Open OpenClaw with a model deepseek Open DeepSeek Harness with a model prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/unknown__list.stderr b/tests/golden/expected/unknown__list.stderr index ec3d005b..90dfb4af 100644 --- a/tests/golden/expected/unknown__list.stderr +++ b/tests/golden/expected/unknown__list.stderr @@ -21,12 +21,17 @@ Coding tools: codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model opencode Open OpenCode with a local or cloud model + shannon Test your own apps with Shannon, an AI pentester hermes Open Hermes with a model openclaw Open OpenClaw with a model deepseek Open DeepSeek Harness with a model prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/unknown__lora.stderr b/tests/golden/expected/unknown__lora.stderr index 4731b50a..99518b27 100644 --- a/tests/golden/expected/unknown__lora.stderr +++ b/tests/golden/expected/unknown__lora.stderr @@ -21,12 +21,17 @@ Coding tools: codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model opencode Open OpenCode with a local or cloud model + shannon Test your own apps with Shannon, an AI pentester hermes Open Hermes with a model openclaw Open OpenClaw with a model deepseek Open DeepSeek Harness with a model prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/unknown__ls.stderr b/tests/golden/expected/unknown__ls.stderr index 28414645..65ca1f3c 100644 --- a/tests/golden/expected/unknown__ls.stderr +++ b/tests/golden/expected/unknown__ls.stderr @@ -21,12 +21,17 @@ Coding tools: codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model opencode Open OpenCode with a local or cloud model + shannon Test your own apps with Shannon, an AI pentester hermes Open Hermes with a model openclaw Open OpenClaw with a model deepseek Open DeepSeek Harness with a model prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/unknown__pull.stderr b/tests/golden/expected/unknown__pull.stderr index 538436b1..d9064832 100644 --- a/tests/golden/expected/unknown__pull.stderr +++ b/tests/golden/expected/unknown__pull.stderr @@ -21,12 +21,17 @@ Coding tools: codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model opencode Open OpenCode with a local or cloud model + shannon Test your own apps with Shannon, an AI pentester hermes Open Hermes with a model openclaw Open OpenClaw with a model deepseek Open DeepSeek Harness with a model prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/unknown__rag.stderr b/tests/golden/expected/unknown__rag.stderr index 08dcdd3c..755bcbbf 100644 --- a/tests/golden/expected/unknown__rag.stderr +++ b/tests/golden/expected/unknown__rag.stderr @@ -21,12 +21,17 @@ Coding tools: codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model opencode Open OpenCode with a local or cloud model + shannon Test your own apps with Shannon, an AI pentester hermes Open Hermes with a model openclaw Open OpenClaw with a model deepseek Open DeepSeek Harness with a model prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/unknown__rerank.stderr b/tests/golden/expected/unknown__rerank.stderr index fe1ae10b..3c0f04a4 100644 --- a/tests/golden/expected/unknown__rerank.stderr +++ b/tests/golden/expected/unknown__rerank.stderr @@ -21,12 +21,17 @@ Coding tools: codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model opencode Open OpenCode with a local or cloud model + shannon Test your own apps with Shannon, an AI pentester hermes Open Hermes with a model openclaw Open OpenClaw with a model deepseek Open DeepSeek Harness with a model prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/unknown__rm.stderr b/tests/golden/expected/unknown__rm.stderr index 9d4dcf52..f61aeb4a 100644 --- a/tests/golden/expected/unknown__rm.stderr +++ b/tests/golden/expected/unknown__rm.stderr @@ -21,12 +21,17 @@ Coding tools: codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model opencode Open OpenCode with a local or cloud model + shannon Test your own apps with Shannon, an AI pentester hermes Open Hermes with a model openclaw Open OpenClaw with a model deepseek Open DeepSeek Harness with a model prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/unknown__segment.stderr b/tests/golden/expected/unknown__segment.stderr index 948ade8a..81ebc7c3 100644 --- a/tests/golden/expected/unknown__segment.stderr +++ b/tests/golden/expected/unknown__segment.stderr @@ -21,12 +21,17 @@ Coding tools: codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model opencode Open OpenCode with a local or cloud model + shannon Test your own apps with Shannon, an AI pentester hermes Open Hermes with a model openclaw Open OpenClaw with a model deepseek Open DeepSeek Harness with a model prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/unknown__show.stderr b/tests/golden/expected/unknown__show.stderr index cdc4c2cf..06b9ba46 100644 --- a/tests/golden/expected/unknown__show.stderr +++ b/tests/golden/expected/unknown__show.stderr @@ -21,12 +21,17 @@ Coding tools: codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model opencode Open OpenCode with a local or cloud model + shannon Test your own apps with Shannon, an AI pentester hermes Open Hermes with a model openclaw Open OpenClaw with a model deepseek Open DeepSeek Harness with a model prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/unknown__stt.stderr b/tests/golden/expected/unknown__stt.stderr index 2dafe23c..a7c314d5 100644 --- a/tests/golden/expected/unknown__stt.stderr +++ b/tests/golden/expected/unknown__stt.stderr @@ -21,12 +21,17 @@ Coding tools: codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model opencode Open OpenCode with a local or cloud model + shannon Test your own apps with Shannon, an AI pentester hermes Open Hermes with a model openclaw Open OpenClaw with a model deepseek Open DeepSeek Harness with a model prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/unknown__tts.stderr b/tests/golden/expected/unknown__tts.stderr index 382785c4..1b44a057 100644 --- a/tests/golden/expected/unknown__tts.stderr +++ b/tests/golden/expected/unknown__tts.stderr @@ -21,12 +21,17 @@ Coding tools: codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model opencode Open OpenCode with a local or cloud model + shannon Test your own apps with Shannon, an AI pentester hermes Open Hermes with a model openclaw Open OpenClaw with a model deepseek Open DeepSeek Harness with a model prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/unknown__vad.stderr b/tests/golden/expected/unknown__vad.stderr index d4cc89da..7c2f3274 100644 --- a/tests/golden/expected/unknown__vad.stderr +++ b/tests/golden/expected/unknown__vad.stderr @@ -21,12 +21,17 @@ Coding tools: codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model opencode Open OpenCode with a local or cloud model + shannon Test your own apps with Shannon, an AI pentester hermes Open Hermes with a model openclaw Open OpenClaw with a model deepseek Open DeepSeek Harness with a model prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/unknown__vlm.stderr b/tests/golden/expected/unknown__vlm.stderr index 9770b825..fbf9d7dd 100644 --- a/tests/golden/expected/unknown__vlm.stderr +++ b/tests/golden/expected/unknown__vlm.stderr @@ -21,12 +21,17 @@ Coding tools: codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model opencode Open OpenCode with a local or cloud model + shannon Test your own apps with Shannon, an AI pentester hermes Open Hermes with a model openclaw Open OpenClaw with a model deepseek Open DeepSeek Harness with a model prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/unknown__voice.stderr b/tests/golden/expected/unknown__voice.stderr index 4e4b7164..847768c0 100644 --- a/tests/golden/expected/unknown__voice.stderr +++ b/tests/golden/expected/unknown__voice.stderr @@ -21,12 +21,17 @@ Coding tools: codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model opencode Open OpenCode with a local or cloud model + shannon Test your own apps with Shannon, an AI pentester hermes Open Hermes with a model openclaw Open OpenClaw with a model deepseek Open DeepSeek Harness with a model prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/tests/golden/expected/version__bare.stderr b/tests/golden/expected/version__bare.stderr index 3f063721..b31e25b9 100644 --- a/tests/golden/expected/version__bare.stderr +++ b/tests/golden/expected/version__bare.stderr @@ -20,12 +20,17 @@ Coding tools: codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model opencode Open OpenCode with a local or cloud model + shannon Test your own apps with Shannon, an AI pentester hermes Open Hermes with a model openclaw Open OpenClaw with a model deepseek Open DeepSeek Harness with a model prime-agent Open Prime Agent with a model command-code Open Command Code with a model +Security tools: + deepsec Scan your own repos with DeepSec + vvah Scan your own repos with VVAH + Account: account login Sign in through the browser account logout Sign out and revoke the session diff --git a/versions.toml b/versions.toml index 66edae6c..d5174e0c 100644 --- a/versions.toml +++ b/versions.toml @@ -7,7 +7,7 @@ [product] # wally's own release version; git tag is v (auto-tag.yml reads it here). -version = "0.8.13" +version = "0.8.14" [contracts] # Vendored from InferenceInfra at this exact revision.