From a4546018a789c85ab59da5fddaa32327e67f7d7b Mon Sep 17 00:00:00 2001 From: blockmandev <194144408+blockmandev@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:48:04 -0600 Subject: [PATCH 1/3] remote: run a task with incode -t on GitHub Actions and print its result (refs #23) incode could only run a task on the machine it was started on. A long task held that machine and its session for as long as it ran, and nothing could hand it to a runner and collect the outcome later. `incode remote run ""` dispatches the repository's incode-remote.yml workflow (workflow_dispatch: task, model, id), finds the run by the id the workflow titles it with, polls it every 10 s for up to --timeout minutes (60 by default; a wait that runs out says the run is still going and cancels nothing), and prints result.json from the run's incode-result artifact: the `-t --output-format json` result plus `branch`, the incode/remote- branch the run pushed its changes to. It exits non-zero when the run did not succeed, the task reported is_error, or the wait ran out. `list` and `logs ` show past runs and a run's result. The token (GH_TOKEN, else GITHUB_TOKEN) travels only in a header, only to the API's own origin, and only over https or to this machine (key_may_travel_to). Redirects are followed one hop at a time, so the artifact download's redirect to blob storage is fetched without it. The repository is -R or the github.com repository origin names; both are held to GitHub's name characters so neither can reshape an API path. The workflow ships as a template (.github/workflows/incode-remote.yml.example, inert in this repository) that installs and configures incode as the GitHub Action does, passes every input through environment variables, and writes the result outside the checkout so it is never committed. A test holds the template to the names remote.rs relies on and refuses an input spliced into a script. Setup is in docs/remote-runs.md. Tests run each path against a loopback fake GitHub API: dispatch body and headers, discovery by id, polling, the artifact zip, the token kept off the redirect target, timeout, failed conclusion, remote parsing, missing token. --- .github/workflows/incode-remote.yml.example | 154 ++ README.md | 9 + docs/remote-runs.md | 91 + src/lib.rs | 1 + src/main.rs | 55 + src/remote.rs | 1691 +++++++++++++++++++ 6 files changed, 2001 insertions(+) create mode 100644 .github/workflows/incode-remote.yml.example create mode 100644 docs/remote-runs.md create mode 100644 src/remote.rs diff --git a/.github/workflows/incode-remote.yml.example b/.github/workflows/incode-remote.yml.example new file mode 100644 index 0000000..07e9b46 --- /dev/null +++ b/.github/workflows/incode-remote.yml.example @@ -0,0 +1,154 @@ +# The workflow `incode remote run` dispatches. Copy it to +# .github/workflows/incode-remote.yml on your repository's default branch and +# add the secret ANTHROPIC_API_KEY (or change the "Configure the model" step +# for another provider) and the variable INCODE_MODEL. See docs/remote-runs.md. +# +# What `incode remote` relies on, so keep these as they are: the inputs +# `task`, `model` and `id`; the run's title, `incode remote `, by which +# the run is found; the branch `incode/remote-`; and the artifact +# `incode-result` holding `result.json`. +# +# The task is untrusted text: it reaches the shell only through environment +# variables, never spliced into a script. +name: incode remote +run-name: "incode remote ${{ inputs.id }}" + +on: + workflow_dispatch: + inputs: + task: + description: The instruction for `incode -t`. + required: true + type: string + model: + description: The model id to use. Blank uses the INCODE_MODEL variable. + required: false + type: string + default: "" + id: + description: Set by `incode remote run`; names the run and its branch. + required: true + type: string + +permissions: + contents: write + +jobs: + run: + runs-on: ubuntu-latest + timeout-minutes: 120 + steps: + - name: Check the id + env: + ID: ${{ inputs.id }} + run: | + set -euo pipefail + case "$ID" in + "" | *[!A-Za-z0-9-]* ) echo "::error::incode remote: the id must be letters, digits and dashes."; exit 1 ;; + esac + + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install incode + env: + INCODE_VERSION: latest + INCODE_INSTALL_DIR: ${{ runner.temp }}/incode-bin + run: | + set -euo pipefail + curl --proto '=https' --tlsv1.2 -fsSL \ + https://raw.githubusercontent.com/incordai/incode/main/scripts/install.sh | sh + echo "$INCODE_INSTALL_DIR" >> "$GITHUB_PATH" + + # The same one-route admin.json the incode GitHub Action writes + # (.github/actions/incode): the key's variable NAME only, in a private + # INCODE_DIR. + - name: Configure the model + env: + API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + KEY_ENV: ANTHROPIC_API_KEY + API_URL: https://api.anthropic.com/v1/messages + API_FORMAT: anthropic + MODEL: ${{ inputs.model || vars.INCODE_MODEL }} + run: | + set -euo pipefail + if [ -z "$API_KEY" ]; then + echo "::error::incode remote: the ANTHROPIC_API_KEY secret is not set." + exit 1 + fi + if [ -z "$MODEL" ]; then + echo "::error::incode remote: no model: pass -m, or set the INCODE_MODEL variable." + exit 1 + fi + echo "::add-mask::$API_KEY" + incode_dir="$RUNNER_TEMP/incode-home" + mkdir -p "$incode_dir" + jq -n --arg url "$API_URL" --arg format "$API_FORMAT" \ + --arg key_env "$KEY_ENV" --arg model "$MODEL" \ + '{agent: "ci", + api_models: [{name: "ci", format: $format, url: $url, + key_env: $key_env, model: $model}]}' \ + > "$incode_dir/admin.json" + { + echo "INCODE_DIR=$incode_dir" + echo "INCODE_AGENT=ci" + echo "$KEY_ENV=$API_KEY" + } >> "$GITHUB_ENV" + + # The result is written outside the checkout, so it is never committed. + # A failed task does not stop the job here: its changes are still + # pushed and its result uploaded, and the last step fails the job. + - name: Run the task + id: task + env: + TASK: ${{ inputs.task }} + run: | + set -uo pipefail + set +e + mkdir -p "$RUNNER_TEMP/incode-result" + result="$RUNNER_TEMP/incode-result/result.json" + incode -t "$TASK" --spec-gate off --output-format json > "$result" + echo "exit=$?" >> "$GITHUB_OUTPUT" + if ! jq -e 'type == "object"' "$result" > /dev/null 2>&1; then + jq -n '{type: "result", is_error: true, + error: "incode did not report a result; see the workflow log"}' > "$result" + fi + + - name: Push the changes + env: + ID: ${{ inputs.id }} + TASK: ${{ inputs.task }} + run: | + set -euo pipefail + result="$RUNNER_TEMP/incode-result/result.json" + branch="" + if [ -n "$(git status --porcelain)" ]; then + branch="incode/remote-$ID" + git config user.name "incode[bot]" + git config user.email "incode[bot]@users.noreply.github.com" + git checkout -b "$branch" + git add -A + git commit -q -m "incode remote $ID" -m "$TASK" + git push -q origin "$branch" + fi + jq --arg branch "$branch" '. + {branch: (if $branch == "" then null else $branch end)}' \ + "$result" > "$result.tmp" + mv "$result.tmp" "$result" + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: incode-result + path: ${{ runner.temp }}/incode-result/result.json + if-no-files-found: ignore + + - name: Fail when the task failed + env: + EXIT: ${{ steps.task.outputs.exit }} + run: | + set -euo pipefail + if [ "$EXIT" != "0" ] || jq -e '.is_error == true' "$RUNNER_TEMP/incode-result/result.json" > /dev/null; then + echo "::error::incode remote: the task did not succeed; see result.json in the incode-result artifact." + exit 1 + fi diff --git a/README.md b/README.md index 7a099c4..35c4e49 100644 --- a/README.md +++ b/README.md @@ -614,6 +614,15 @@ pushing any change to a new branch), or reviews each pull request as it opens. Setup, inputs and the safety defaults are in [docs/github-action.md](docs/github-action.md). +`incode remote run ""` runs a task with `incode -t` on GitHub Actions +instead of here: it dispatches the repository's `incode-remote.yml` workflow, +waits for it (60 minutes by default, `--timeout`), and prints the result as +`-t --output-format json` does, with the branch `incode/remote-` the +changes were pushed to. `incode remote list` and `incode remote logs ` +show past runs. The workflow to copy, the secrets it needs and the token +`incode remote` reads (`GH_TOKEN` / `GITHUB_TOKEN`) are in +[docs/remote-runs.md](docs/remote-runs.md). + ## Custom commands and plugins A markdown file is a slash command. Put it in `.incode/commands/` (the diff --git a/docs/remote-runs.md b/docs/remote-runs.md new file mode 100644 index 0000000..4d789a0 --- /dev/null +++ b/docs/remote-runs.md @@ -0,0 +1,91 @@ +# Remote runs: `incode remote` + +`incode remote run ""` runs the task with `incode -t` on GitHub Actions +instead of on your machine, waits for it, and prints its result. The run +pushes whatever the agent changed to a branch `incode/remote-`; it opens +no pull request. + +```sh +incode remote run "fix the flaky test in tests/net.rs" +incode remote run "update the changelog" -R incordai/incode --ref release -m "$MODEL_ID" +incode remote run "bump the MSRV to 1.85" --no-wait # print the run's address and return +incode remote list # recent runs: id, state, title, address +incode remote logs 1234567890 # one run's state, address and result +``` + +## How a run goes + +1. `incode remote run` makes an id and dispatches the repository's + `.github/workflows/incode-remote.yml` (`workflow_dispatch`) with the inputs + `task`, `model` (when `-m` is given) and `id`, on `--ref` or the + repository's default branch. +2. It finds the run that started by its title, `incode remote `, and + prints its address (on stderr). +3. It polls the run every 10 seconds until it completes, for up to + `--timeout` minutes (60 by default). If the wait runs out it says the run + is still going, prints its address and exits non-zero; the run is **not** + cancelled, and `incode remote logs ` reads its result later. +4. It downloads the run's `incode-result` artifact and prints its + `result.json` on stdout: the object `incode -t --output-format json` + prints, plus `branch` — `incode/remote-` when the agent changed + something, else `null`. + +It exits non-zero when the run did not succeed or the result says +`"is_error": true`. + +## Setup + +1. **The workflow.** Copy + [`.github/workflows/incode-remote.yml.example`](../.github/workflows/incode-remote.yml.example) + to `.github/workflows/incode-remote.yml` in your repository and commit it + to the default branch (GitHub dispatches a workflow only once it knows + it), and to any branch you will pass as `--ref`. +2. **The model key.** Add the API key as a repository secret, + `ANTHROPIC_API_KEY`. For another provider, change the workflow's + "Configure the model" step (`KEY_ENV`, `API_URL`, `API_FORMAT` — the same + fields as the [GitHub Action](github-action.md)'s inputs). +3. **The model.** Set a repository variable `INCODE_MODEL` to the model id, + or pass `-m` on each run. +4. **Your token.** `incode remote` reads a GitHub token from `GH_TOKEN`, else + `GITHUB_TOKEN`. It needs **Actions: read and write** on the repository (a + fine-grained token), or the `repo` and `workflow` scopes (a classic one). + With the GitHub CLI signed in: + + ```sh + GH_TOKEN=$(gh auth token) incode remote run "..." + ``` + +The repository is `-R owner/name`, else the github.com repository your +`origin` remote names (https or ssh). GitHub Enterprise servers are not +supported. + +## What the workflow does + +`run-name: incode remote `; then, on `ubuntu-latest`: + +- checks the id is letters, digits and dashes (it names the branch); +- checks out the repository and installs the latest incode release with + `scripts/install.sh`, as the GitHub Action does; +- writes a one-route `admin.json` (the key's variable name only) into a + private `INCODE_DIR`; +- runs `incode -t "$TASK" --spec-gate off --output-format json`, writing + `result.json` outside the checkout; +- if the working tree changed, commits it to `incode/remote-` and pushes + that branch; +- adds `branch` to `result.json` and uploads it as the artifact + `incode-result`; +- fails the job when the task failed. + +## Safety + +- **Who can run it.** Dispatching a workflow takes write access to the + repository, and the run holds your model key and a token that can push. +- **The task is untrusted text.** The workflow passes it, and every other + input, through environment variables; none is spliced into a script. +- **No pull request, no existing branch.** The run pushes only a new + `incode/remote-` branch; a person reviews it and opens the pull request. + A push made with the workflow's `GITHUB_TOKEN` starts no other workflow. +- **Your token stays with GitHub.** `incode remote` sends it only to + `api.github.com` over https, in a header. The artifact download is + redirected to GitHub's storage; that request is made without the token. + The token is never printed or put in an address. diff --git a/src/lib.rs b/src/lib.rs index 5c3c3a3..61df97a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -43,6 +43,7 @@ pub mod plugin_approvals; pub mod plugins; pub mod recall; pub mod recorder; +pub mod remote; pub mod research; pub mod review; pub mod reviews; diff --git a/src/main.rs b/src/main.rs index ebde8d0..4ac7bac 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,6 +24,8 @@ USAGE: incode mcp ... (see `incode mcp help`) incode lifecycle This project's lifecycle commands, which run only once approved (see `incode lifecycle help`) + incode remote ... Run a task with `incode -t` on GitHub + Actions and print its result (see `incode remote help`) incode completions Print a completion script for that shell (the script says how to load it) @@ -260,6 +262,24 @@ const COMPLETION: completions::Command = { verbs: &["show", "approve", "help"], subcommands: &[], }, + Command { + name: "remote", + flags: &[ + flag( + "--repo", + Some("-R"), + Value::Text, + "GitHub repository owner/name", + ), + flag("--ref", None, Value::Text, "Branch to run on"), + flag("--model", Some("-m"), Value::Text, "Model for the run"), + flag("--timeout", None, Value::Text, "Minutes to wait"), + flag("--no-wait", None, Value::None, "Return once started"), + HELP, + ], + verbs: &["run", "list", "logs", "help"], + subcommands: &[], + }, Command { name: "mcp", flags: &[HELP], @@ -349,6 +369,19 @@ fn main() -> anyhow::Result<()> { println!("{}", said.trim_end()); return Ok(()); } + // `incode remote ...`: run a task on GitHub Actions and print its + // result; non-zero when the run or the task failed, or the wait ran + // out. + if raw.first().map(String::as_str) == Some("remote") { + let workspace = std::env::current_dir()?; + let outcome = + incord_code::remote::cli(&raw[1..], &workspace).map_err(|e| anyhow::anyhow!(e))?; + println!("{}", outcome.text.trim_end()); + if !outcome.ok { + std::process::exit(1); + } + return Ok(()); + } } // NOTE: the crate emits `tracing` events (MCP connection failures, tool // errors) but no subscriber is installed, so they currently go nowhere. @@ -2111,6 +2144,28 @@ mod tests { assert!(!e.starts_with("unknown"), "lifecycle {verb}: {e}"); } } + // `incode remote`: every verb and every flag (with its value) is read + // by the parser in full before `--help` answers, so this checks them + // without reading a token or reaching GitHub. + let remote = |args: &[&str]| { + let args: Vec = args.iter().map(|s| (*s).to_string()).collect(); + incord_code::remote::cli(&args, ws.path()) + }; + for verb in sub("remote").verbs { + let said = remote(&[verb, "--help"]).unwrap_or_else(|e| panic!("remote {verb}: {e}")); + assert_eq!(said.text, incord_code::remote::USAGE, "remote {verb}"); + } + for flag in sub("remote").flags { + for spelling in std::iter::once(flag.long).chain(flag.short) { + for args in with_values(flag, spelling) { + let args = [&["run", "a task"][..], &args, &["--help"]].concat(); + remote(&args).unwrap_or_else(|e| panic!("remote {args:?}: {e}")); + } + } + } + assert!(remote(&["launch"]) + .expect_err("an unknown verb") + .starts_with("unknown")); for verb in sub("mcp").verbs { if let Err(e) = incord_code::mcp_config::command(&[(*verb).to_string()], ws.path(), data.path()) diff --git a/src/remote.rs b/src/remote.rs new file mode 100644 index 0000000..36a808a --- /dev/null +++ b/src/remote.rs @@ -0,0 +1,1691 @@ +//! `incode remote`: run a task with `incode -t` on GitHub Actions, wait for +//! it, and print its result. +//! +//! # What a run is +//! +//! `incode remote run ""` dispatches the repository's +//! `.github/workflows/incode-remote.yml` (`workflow_dispatch`, inputs `task`, +//! `model` and `id`), finds the run that started by the id the workflow +//! titles it with (`run-name: incode remote `), polls it until it +//! completes or the wait runs out, and reads `result.json` from the run's +//! `incode-result` artifact: the `incode -t --output-format json` result +//! object plus `branch`, where the run pushed its changes +//! (`incode/remote-`), or null when there were none. A wait that runs +//! out says so and leaves the run going: nothing is cancelled. The workflow +//! to copy is `.github/workflows/incode-remote.yml.example`; setup is in +//! docs/remote-runs.md. +//! +//! # The token +//! +//! Read from `GH_TOKEN`, else `GITHUB_TOKEN` ([`TOKEN_VARS`]). It travels in +//! the `Authorization` header, only to the API's own origin, and only when +//! that origin is https or this machine +//! (`agent::catalogue::key_may_travel_to`). Redirects are followed here, one +//! hop at a time, over the shared client that follows none by itself: a hop +//! that leaves the API's origin — the artifact download, which GitHub +//! redirects to its blob storage — is fetched without the token. The token +//! is never printed, never put in an address and never in an error; a +//! transport error is reported without its address, since a blob address +//! carries a signature. +//! +//! # Bounds +//! +//! Each request [`REQUEST_BOUND`]; an API answer [`MAX_API_BYTES`]; the +//! artifact [`MAX_ARTIFACT_BYTES`]; `result.json` inside it +//! [`MAX_RESULT_BYTES`]. Finding the run: [`Pace::discover_for`]. +//! +//! # Wired to +//! +//! - [`cli`]: `incode remote …` (`main.rs`), which prints [`Outcome::text`] +//! and exits non-zero when [`Outcome::ok`] is false. +//! - HTTP: `runtime::http::{no_redirect_client, run_async, error_chain}`; the +//! token rule: `agent::catalogue::key_may_travel_to`. +//! - The artifact: the `zip` crate, one entry read in memory. +//! - The repository when `-R` is not given: `git remote get-url origin`. +//! - The other end: `.github/workflows/incode-remote.yml.example`, whose +//! inputs, `run-name`, artifact name and branch name this module relies on. +//! - Siblings: `ext_tools::fetch::follow` follows redirects one hop at a +//! time the same way, for release downloads that carry no credential; the +//! GitHub Action (`.github/actions/incode`) runs `incode -t` in CI from an +//! issue comment rather than from this command. + +use std::io::Read as _; +use std::path::Path; +use std::time::{Duration, Instant}; + +use serde_json::{json, Value}; + +/// `incode remote help`. +pub const USAGE: &str = "\ +incode remote run \"\" [-R owner/repo] [--ref ] [-m ] [--timeout ] [--no-wait] + Run the task with `incode -t` on GitHub Actions (the repository's + .github/workflows/incode-remote.yml), wait for it, and print its result as + `incode -t --output-format json` does, with `branch`: where the run pushed + its changes (incode/remote-), or null. Exits non-zero when the run or + the task failed, or the wait ran out (the run is left going, not + cancelled). --ref picks the branch to run on (default: the repository's + default branch); --timeout the wait in minutes (default 60); --no-wait + prints the run's address and returns. +incode remote list [-R owner/repo] recent runs: id, state, title, address +incode remote logs [-R owner/repo] one run's state, address, and result + +The repository is -R, else the github.com repository `origin` names. The token +is read from GH_TOKEN, else GITHUB_TOKEN, and needs Actions: read and write on +the repository (`gh auth token` prints yours). Setup: docs/remote-runs.md."; + +/// GitHub's REST API root. +const GITHUB_API: &str = "https://api.github.com"; +/// The workflow a run dispatches, by its file name. +const WORKFLOW: &str = "incode-remote.yml"; +/// The artifact a run leaves its result in, and the file inside it. +const ARTIFACT: &str = "incode-result"; +const RESULT_FILE: &str = "result.json"; +/// Where the token is read from, in order. +const TOKEN_VARS: [&str; 2] = ["GH_TOKEN", "GITHUB_TOKEN"]; +/// What every request says it is: the program, nothing about who runs it. +const AGENT_NAME: &str = "incode"; +const API_VERSION: &str = "2022-11-28"; +/// How long `run` waits by default, and at most. +const DEFAULT_WAIT_MINUTES: u64 = 60; +const MAX_WAIT_MINUTES: u64 = 7 * 24 * 60; +/// How long one request may take, start to finish. +const REQUEST_BOUND: Duration = Duration::from_secs(60); +/// The most redirects followed from one address. +const MAX_REDIRECTS: usize = 5; +/// The most of an API answer read. +const MAX_API_BYTES: usize = 8 * 1024 * 1024; +/// The largest artifact downloaded. +const MAX_ARTIFACT_BYTES: usize = 64 * 1024 * 1024; +/// The most of `result.json` read. +const MAX_RESULT_BYTES: u64 = 16 * 1024 * 1024; + +/// What `incode remote …` prints on stdout, and whether it exits 0. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Outcome { + pub text: String, + pub ok: bool, +} + +/// `incode remote …` in `workspace` (whose `origin` names the repository when +/// `-R` does not), against GitHub's API; progress goes to stderr. +/// +/// Called by `main.rs`; calls [`cli_with`] with the real API, the process +/// environment and the real polling pace. +/// +/// # Errors +/// +/// A bad argument, no token, no repository, or a request GitHub refused or +/// that could not be made. +pub fn cli(args: &[String], workspace: &Path) -> Result { + cli_with( + args, + workspace, + GITHUB_API, + &|name| std::env::var(name).ok(), + &Pace::REAL, + &mut |line| eprintln!("incode remote: {line}"), + ) +} + +/// How often a run is looked for and polled. +struct Pace { + /// Between two looks for the dispatched run. + discover_every: Duration, + /// How long the dispatched run may take to appear. + discover_for: Duration, + /// Between two polls of a run. + poll_every: Duration, +} + +impl Pace { + const REAL: Pace = Pace { + discover_every: Duration::from_secs(3), + discover_for: Duration::from_secs(60), + poll_every: Duration::from_secs(10), + }; +} + +/// [`cli`] with the API root, the environment (`env`), the pace and where +/// progress goes given: parses, reads the token and the repository, then +/// hands the verb to [`run`], [`list`] or [`logs`]. +fn cli_with( + args: &[String], + workspace: &Path, + api: &str, + env: &dyn Fn(&str) -> Option, + pace: &Pace, + progress: &mut dyn FnMut(&str), +) -> Result { + let verb = parse(args)?; + let given_repo = match &verb { + Verb::Help => { + return Ok(Outcome { + text: USAGE.to_string(), + ok: true, + }) + } + Verb::Run(run) => run.repo.clone(), + Verb::List { repo } | Verb::Logs { repo, .. } => repo.clone(), + }; + let token = token_from(env)?; + let repo = match given_repo { + Some(given) => Repo::parse(&given)?, + None => origin_repo(workspace)?, + }; + let gh = GitHub { + api, + repo: &repo, + token: &token, + }; + match verb { + Verb::Run(args) => run(&gh, &args, pace, progress), + Verb::List { .. } => list(&gh), + Verb::Logs { run_id, .. } => logs(&gh, run_id), + Verb::Help => unreachable!("answered above"), + } +} + +// --- arguments --------------------------------------------------------------- + +#[derive(Debug, PartialEq, Eq)] +enum Verb { + Help, + Run(RunArgs), + List { repo: Option }, + Logs { repo: Option, run_id: u64 }, +} + +#[derive(Debug, PartialEq, Eq)] +struct RunArgs { + task: String, + repo: Option, + git_ref: Option, + model: Option, + /// How long to wait for the run; `None` with `--no-wait`. + wait: Option, +} + +/// The value after `flag`. +fn value_of(rest: &mut std::slice::Iter<'_, String>, flag: &str) -> Result { + rest.next() + .cloned() + .ok_or_else(|| format!("`{flag}` needs a value")) +} + +/// `incode remote`'s arguments, read in full before anything runs: an +/// unknown option is refused even beside `--help`. Called by [`cli_with`]. +fn parse(args: &[String]) -> Result { + let Some((verb, rest)) = args.split_first() else { + return Ok(Verb::Help); + }; + let verb = verb.as_str(); + match verb { + "help" | "-h" | "--help" => return Ok(Verb::Help), + "run" | "list" | "logs" => {} + other => return Err(format!("unknown `incode remote {other}`\n\n{USAGE}")), + } + let only_run = |flag: &str| { + if verb == "run" { + Ok(()) + } else { + Err(format!( + "`{flag}` is not an option of `incode remote {verb}`" + )) + } + }; + let (mut help, mut no_wait) = (false, false); + let (mut repo, mut git_ref, mut model, mut timeout) = (None, None, None, None); + let mut positional = Vec::new(); + let mut rest = rest.iter(); + while let Some(arg) = rest.next() { + match arg.as_str() { + "-h" | "--help" => help = true, + "-R" | "--repo" => repo = Some(value_of(&mut rest, arg)?), + "--ref" => { + only_run(arg)?; + git_ref = Some(value_of(&mut rest, arg)?); + } + "-m" | "--model" => { + only_run(arg)?; + model = Some(value_of(&mut rest, arg)?); + } + "--timeout" => { + only_run(arg)?; + timeout = Some(value_of(&mut rest, arg)?); + } + "--no-wait" => { + only_run(arg)?; + no_wait = true; + } + "--" => positional.extend(rest.by_ref().cloned()), + other if other.starts_with('-') && other.len() > 1 => { + return Err(format!( + "unknown option `{other}` for `incode remote {verb}`\n\n{USAGE}" + )) + } + other => positional.push(other.to_string()), + } + } + if help { + return Ok(Verb::Help); + } + match verb { + "run" => { + let task = match positional.as_slice() { + [task] if !task.trim().is_empty() => task.clone(), + [] | [_] => return Err( + "`incode remote run` needs the task: incode remote run \"fix the flaky test\"" + .to_string(), + ), + more => { + return Err(format!( + "`incode remote run` takes one task, quoted; it was given {} words", + more.len() + )) + } + }; + let wait = match (no_wait, timeout) { + (true, Some(_)) => { + return Err("`--no-wait` and `--timeout` cannot be given together".to_string()) + } + (true, None) => None, + (false, None) => Some(Duration::from_secs(DEFAULT_WAIT_MINUTES * 60)), + (false, Some(minutes)) => match minutes.parse::() { + Ok(m) if (1..=MAX_WAIT_MINUTES).contains(&m) => { + Some(Duration::from_secs(m * 60)) + } + _ => { + return Err(format!( + "`--timeout` is a number of minutes from 1 to {MAX_WAIT_MINUTES}, \ + not `{minutes}` (`--no-wait` returns at once)" + )) + } + }, + }; + Ok(Verb::Run(RunArgs { + task, + repo, + git_ref: git_ref.filter(|r| !r.trim().is_empty()), + model: model.filter(|m| !m.trim().is_empty()), + wait, + })) + } + "list" => match positional.as_slice() { + [] => Ok(Verb::List { repo }), + _ => Err("`incode remote list` takes no arguments but -R".to_string()), + }, + _ => match positional.as_slice() { + [id] => id + .parse::() + .map(|run_id| Verb::Logs { repo, run_id }) + .map_err(|_| { + format!("`{id}` is not a run id (a number, as `incode remote list` shows)") + }), + _ => Err("`incode remote logs` needs one run id".to_string()), + }, + } +} + +/// The token, from the first of [`TOKEN_VARS`] that is set and not blank. +fn token_from(env: &dyn Fn(&str) -> Option) -> Result { + TOKEN_VARS + .iter() + .find_map(|name| { + env(name) + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + }) + .ok_or_else(|| { + format!( + "no GitHub token: set {} or {} to a token with Actions: read and write on the \ + repository (`gh auth token` prints yours)", + TOKEN_VARS[0], TOKEN_VARS[1] + ) + }) +} + +// --- the repository ---------------------------------------------------------- + +/// A repository on github.com, as `owner/name`. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Repo { + owner: String, + name: String, +} + +impl std::fmt::Display for Repo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}/{}", self.owner, self.name) + } +} + +impl Repo { + /// `owner/name` (a trailing `.git` dropped), each part made only of the + /// characters GitHub allows, so neither can reshape an API path. + fn parse(text: &str) -> Result { + let refused = || format!("`{text}` is not a GitHub repository written owner/name"); + let (owner, name) = text.trim().split_once('/').ok_or_else(refused)?; + let name = name.strip_suffix(".git").unwrap_or(name); + let owner_ok = !owner.is_empty() + && owner.len() <= 39 + && owner.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'); + let name_ok = !name.is_empty() + && name.len() <= 100 + && name != "." + && name != ".." + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')); + if owner_ok && name_ok { + Ok(Repo { + owner: owner.to_string(), + name: name.to_string(), + }) + } else { + Err(refused()) + } + } + + /// The github.com repository a git remote address names: https + /// (`https://github.com/o/r.git`, credentials or not), scp-like ssh + /// (`git@github.com:o/r.git`) or an `ssh://` address. `None` for any + /// other host or shape. + fn from_remote(url: &str) -> Option { + let url = url.trim(); + let path = if let Some(rest) = url.strip_prefix("git@github.com:") { + rest.to_string() + } else { + let parsed = reqwest::Url::parse(url).ok()?; + if !matches!(parsed.scheme(), "https" | "http" | "ssh" | "git") { + return None; + } + let host = parsed.host_str()?.to_ascii_lowercase(); + if !matches!( + host.as_str(), + "github.com" | "www.github.com" | "ssh.github.com" + ) { + return None; + } + parsed.path().trim_start_matches('/').to_string() + }; + let (owner, name) = path.trim_end_matches('/').split_once('/')?; + if name.contains('/') { + return None; + } + Repo::parse(&format!("{owner}/{name}")).ok() + } +} + +/// The repository `workspace`'s `origin` names. Its address is never echoed: +/// it may hold a credential. +fn origin_repo(workspace: &Path) -> Result { + let out = std::process::Command::new("git") + .arg("-C") + .arg(workspace) + .args(["remote", "get-url", "origin"]) + .output() + .map_err(|e| format!("git could not be run to read `origin` ({e}); pass -R owner/repo"))?; + if !out.status.success() { + return Err( + "no -R given, and this folder has no git remote `origin`; pass -R owner/repo" + .to_string(), + ); + } + Repo::from_remote(&String::from_utf8_lossy(&out.stdout)) + .ok_or_else(|| "`origin` is not a repository on github.com; pass -R owner/repo".to_string()) +} + +// --- GitHub's API -------------------------------------------------------------- + +/// An answer: its status and body. +struct Answer { + status: reqwest::StatusCode, + body: Vec, +} + +/// One repository on one API, with the token for it. +struct GitHub<'a> { + api: &'a str, + repo: &'a Repo, + token: &'a str, +} + +impl GitHub<'_> { + /// The address of `path` under this repository. + fn repo_url(&self, path: &str) -> String { + format!( + "{}/repos/{}/{}{path}", + self.api.trim_end_matches('/'), + self.repo.owner, + self.repo.name + ) + } + + /// One request to `url`, redirects followed by [`send`], within + /// [`REQUEST_BOUND`]: its answer, of at most `cap` bytes. + fn fetch( + &self, + method: reqwest::Method, + url: &str, + body: Option, + cap: usize, + ) -> Result { + let (api, token, url) = ( + self.api.to_string(), + self.token.to_string(), + url.to_string(), + ); + crate::runtime::http::run_async(async move { + tokio::time::timeout(REQUEST_BOUND, send(&api, &token, method, url, body, cap)) + .await + .unwrap_or_else(|_| { + Err(format!( + "GitHub gave no answer within {}s", + REQUEST_BOUND.as_secs() + )) + }) + }) + } + + /// `path` under this repository as JSON (`Null` for an empty answer); + /// `what` names the request in an error. Called for every API request + /// but the artifact download. + fn json( + &self, + method: reqwest::Method, + path: &str, + body: Option, + what: &str, + ) -> Result { + let answer = self.fetch(method, &self.repo_url(path), body, MAX_API_BYTES)?; + if !answer.status.is_success() { + return Err(self.refused(&answer, what)); + } + if answer.body.is_empty() { + return Ok(Value::Null); + } + serde_json::from_slice(&answer.body) + .map_err(|e| format!("GitHub's answer to {what} did not parse: {e}")) + } + + /// What to say when GitHub answered `answer` (not a success) to `what`: + /// the status, GitHub's own message, and what usually causes it. + fn refused(&self, answer: &Answer, what: &str) -> String { + let message = serde_json::from_slice::(&answer.body) + .ok() + .and_then(|v| v.get("message").and_then(Value::as_str).map(printable)) + .map(|m| format!(": {m}")) + .unwrap_or_default(); + let hint = match answer.status.as_u16() { + 401 => format!( + " (the token in {} / {} was not accepted)", + TOKEN_VARS[0], TOKEN_VARS[1] + ), + 403 => format!( + " (the token may not do this; it needs Actions: read and write on {})", + self.repo + ), + 404 => format!( + " (nothing the token can see by that name: does {} exist, and is \ + .github/workflows/{WORKFLOW} on the branch? See docs/remote-runs.md)", + self.repo + ), + _ => String::new(), + }; + format!( + "GitHub answered {} to {what} in {}{message}{hint}", + answer.status, self.repo + ) + } +} + +/// Whether `url` is on `api`'s origin: the same scheme, host and port. +fn same_origin(url: &reqwest::Url, api: &str) -> bool { + reqwest::Url::parse(api).is_ok_and(|api| { + api.scheme() == url.scheme() + && api.host_str().map(str::to_ascii_lowercase) + == url.host_str().map(str::to_ascii_lowercase) + && api.port_or_known_default() == url.port_or_known_default() + }) +} + +/// Send `method` to `url`, following up to [`MAX_REDIRECTS`] redirects one +/// hop at a time over `runtime::http::no_redirect_client`. Every hop must be +/// https or this machine (`key_may_travel_to`); the token and GitHub's +/// headers go only on a hop to `api`'s origin, so the artifact download's +/// redirect to blob storage is fetched without them. The body read is at +/// most `cap` bytes. Called by [`GitHub::fetch`]. +async fn send( + api: &str, + token: &str, + mut method: reqwest::Method, + url: String, + mut body: Option, + cap: usize, +) -> Result { + let mut at = url; + for _ in 0..=MAX_REDIRECTS { + let parsed = reqwest::Url::parse(&at) + .map_err(|e| format!("an address to fetch is not valid: {e}"))?; + let host = parsed.host_str().unwrap_or("?").to_string(); + let carries_token = same_origin(&parsed, api); + crate::agent::catalogue::key_may_travel_to(&at).map_err(|why| { + if carries_token { + format!("the GitHub token is not sent to {api}: {why}") + } else { + format!("{host} was not fetched: {why}") + } + })?; + let mut request = crate::runtime::http::no_redirect_client(&at)? + .request(method.clone(), parsed) + .header(reqwest::header::USER_AGENT, AGENT_NAME); + if carries_token { + request = request + .bearer_auth(token) + .header(reqwest::header::ACCEPT, "application/vnd.github+json") + .header("X-GitHub-Api-Version", API_VERSION); + } + if let Some(body) = &body { + request = request.json(body); + } + let mut response = request.send().await.map_err(|e| { + format!( + "the request to {host} failed: {}", + crate::runtime::http::error_chain(&e.without_url()) + ) + })?; + let status = response.status(); + if status.is_redirection() { + let next = response + .headers() + .get(reqwest::header::LOCATION) + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| format!("{host} redirected ({status}) to no address"))?; + at = response + .url() + .join(next) + .map_err(|e| format!("{host} redirected to an address that is not valid: {e}"))? + .to_string(); + // As a browser does: a 303, or a 301/302 after a POST, becomes a GET. + let to_get = status == reqwest::StatusCode::SEE_OTHER + || (method == reqwest::Method::POST + && matches!( + status, + reqwest::StatusCode::MOVED_PERMANENTLY | reqwest::StatusCode::FOUND + )); + if to_get { + method = reqwest::Method::GET; + body = None; + } + continue; + } + let mut out = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(|e| { + format!( + "reading {host}'s answer failed: {}", + crate::runtime::http::error_chain(&e.without_url()) + ) + })? { + if out.len() + chunk.len() > cap { + return Err(format!("{host}'s answer is larger than {cap} bytes")); + } + out.extend_from_slice(&chunk); + } + return Ok(Answer { status, body: out }); + } + Err(format!("more than {MAX_REDIRECTS} redirects")) +} + +/// `text` with control characters dropped and at most 300 characters kept: +/// what GitHub wrote, safe to put on a terminal. +fn printable(text: &str) -> String { + text.chars().filter(|c| !c.is_control()).take(300).collect() +} + +// --- runs ---------------------------------------------------------------------- + +/// A workflow run, as GitHub describes it. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Run { + id: u64, + url: String, + title: String, + status: String, + conclusion: Option, +} + +impl Run { + fn from_json(v: &Value) -> Option { + let text = |key: &str| v.get(key).and_then(Value::as_str).map(printable); + Some(Run { + id: v.get("id")?.as_u64()?, + url: text("html_url")?, + title: text("display_title") + .or_else(|| text("name")) + .unwrap_or_default(), + status: text("status").unwrap_or_default(), + conclusion: text("conclusion"), + }) + } + + /// Its conclusion once completed, else its status. + fn state(&self) -> &str { + match (&self.conclusion, self.status.as_str()) { + (Some(conclusion), "completed") => conclusion, + _ => &self.status, + } + } +} + +/// The runs of [`WORKFLOW`] that `listing` (a runs answer) holds. +fn runs_in(listing: &Value) -> Vec { + listing + .get("workflow_runs") + .and_then(Value::as_array) + .map(|runs| runs.iter().filter_map(Run::from_json).collect()) + .unwrap_or_default() +} + +/// Run `run_id` as it is now. +fn get_run(gh: &GitHub<'_>, run_id: u64) -> Result { + let v = gh.json( + reqwest::Method::GET, + &format!("/actions/runs/{run_id}"), + None, + &format!("the read of run {run_id}"), + )?; + Run::from_json(&v).ok_or_else(|| format!("GitHub's description of run {run_id} is incomplete")) +} + +/// `incode remote run`: dispatch [`WORKFLOW`] with the task and a new id, +/// find its run ([`discover`]), and unless `--no-wait` poll it every +/// [`Pace::poll_every`] until it completes or `args.wait` runs out, then +/// read its result ([`finish`]). Called by [`cli_with`]. +fn run( + gh: &GitHub<'_>, + args: &RunArgs, + pace: &Pace, + progress: &mut dyn FnMut(&str), +) -> Result { + let id = uuid::Uuid::new_v4().simple().to_string()[..12].to_string(); + let git_ref = match &args.git_ref { + Some(git_ref) => git_ref.clone(), + None => gh + .json(reqwest::Method::GET, "", None, "the read of the repository")? + .get("default_branch") + .and_then(Value::as_str) + .map(str::to_string) + .ok_or_else(|| format!("GitHub named no default branch for {}; pass --ref", gh.repo))?, + }; + let mut inputs = json!({"task": args.task, "id": id}); + if let Some(model) = &args.model { + inputs["model"] = json!(model); + } + gh.json( + reqwest::Method::POST, + &format!("/actions/workflows/{WORKFLOW}/dispatches"), + Some(json!({"ref": git_ref, "inputs": inputs})), + &format!("the dispatch of {WORKFLOW} on `{git_ref}`"), + )?; + progress(&format!("dispatched {id} on {} ({git_ref})", gh.repo)); + let found = discover(gh, &id, pace)?; + let Some(wait) = args.wait else { + return Ok(Outcome { + text: format!( + "Run {} started: {}\n`incode remote logs {} -R {}` shows its result once it ends.", + found.id, found.url, found.id, gh.repo + ), + ok: true, + }); + }; + progress(&format!( + "run {}: {} (waiting up to {} min)", + found.id, + found.url, + wait.as_secs().div_ceil(60) + )); + let deadline = Instant::now().checked_add(wait); + let finished = loop { + let now = get_run(gh, found.id)?; + if now.status == "completed" { + break now; + } + let left = deadline.map(|d| d.saturating_duration_since(Instant::now())); + if left.is_some_and(|left| left.is_zero()) { + return Ok(Outcome { + text: format!( + "Run {} is still running ({}) after {} min; it was not cancelled: {}\n\ + `incode remote logs {} -R {}` shows its result once it ends.", + now.id, + now.status, + wait.as_secs().div_ceil(60), + now.url, + now.id, + gh.repo + ), + ok: false, + }); + } + std::thread::sleep(left.map_or(pace.poll_every, |left| left.min(pace.poll_every))); + }; + finish(gh, &finished, progress) +} + +/// The run [`run`] dispatched: the one among the latest dispatched runs of +/// [`WORKFLOW`] whose title holds `id` as a word, looked for every +/// [`Pace::discover_every`] for [`Pace::discover_for`]. +fn discover(gh: &GitHub<'_>, id: &str, pace: &Pace) -> Result { + let deadline = Instant::now() + pace.discover_for; + loop { + let listing = gh.json( + reqwest::Method::GET, + &format!("/actions/workflows/{WORKFLOW}/runs?event=workflow_dispatch&per_page=20"), + None, + "the list of runs", + )?; + if let Some(found) = runs_in(&listing) + .into_iter() + .find(|r| r.title.split_whitespace().any(|word| word == id)) + { + return Ok(found); + } + if Instant::now() >= deadline { + return Err(format!( + "the run for {id} did not appear within {}s of its dispatch; \ + `incode remote list -R {}` shows the latest", + pace.discover_for.as_secs(), + gh.repo + )); + } + std::thread::sleep(pace.discover_every); + } +} + +/// A completed run's outcome: its result printed as `-t --output-format json` +/// prints it, `branch` always present; `ok` only when the run succeeded and +/// the task did not report an error. `Err` when it left no result. Called +/// by [`run`]. +fn finish( + gh: &GitHub<'_>, + finished: &Run, + progress: &mut dyn FnMut(&str), +) -> Result { + let conclusion = finished + .conclusion + .as_deref() + .unwrap_or("with no conclusion"); + let Some(mut result) = fetch_result(gh, finished.id)? else { + return Err(format!( + "run {} ended {conclusion} and left no {ARTIFACT} artifact: {}", + finished.id, finished.url + )); + }; + if let Some(fields) = result.as_object_mut() { + fields.entry("branch").or_insert(Value::Null); + } + let task_failed = result.get("is_error").and_then(Value::as_bool) == Some(true); + let ok = conclusion == "success" && !task_failed; + if !ok { + progress(&format!( + "run {} ended {conclusion}{}: {}", + finished.id, + if task_failed { + ", and the task reported an error" + } else { + "" + }, + finished.url + )); + } + Ok(Outcome { + text: result.to_string(), + ok, + }) +} + +/// The `result.json` in run `run_id`'s [`ARTIFACT`] artifact; `None` when the +/// run has no such artifact. The download is fetched by [`GitHub::fetch`], +/// so the token goes to the API and not where it redirects. +fn fetch_result(gh: &GitHub<'_>, run_id: u64) -> Result, String> { + let listing = gh.json( + reqwest::Method::GET, + &format!("/actions/runs/{run_id}/artifacts?per_page=100"), + None, + &format!("the list of run {run_id}'s artifacts"), + )?; + let Some(artifact) = listing + .get("artifacts") + .and_then(Value::as_array) + .and_then(|all| { + all.iter() + .find(|a| a.get("name").and_then(Value::as_str) == Some(ARTIFACT)) + }) + else { + return Ok(None); + }; + if artifact.get("expired").and_then(Value::as_bool) == Some(true) { + return Err(format!("run {run_id}'s {ARTIFACT} artifact has expired")); + } + let url = artifact + .get("archive_download_url") + .and_then(Value::as_str) + .ok_or_else(|| format!("run {run_id}'s {ARTIFACT} artifact has no download address"))?; + let answer = gh.fetch(reqwest::Method::GET, url, None, MAX_ARTIFACT_BYTES)?; + if !answer.status.is_success() { + return Err(gh.refused(&answer, &format!("the download of run {run_id}'s result"))); + } + read_result(&answer.body).map(Some) +} + +/// [`RESULT_FILE`] in the zip `archive`, as a JSON object, read in memory +/// and at most [`MAX_RESULT_BYTES`]. +fn read_result(archive: &[u8]) -> Result { + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(archive)) + .map_err(|e| format!("the {ARTIFACT} artifact is not a readable zip: {e}"))?; + let entry = zip + .by_name(RESULT_FILE) + .map_err(|_| format!("the {ARTIFACT} artifact holds no {RESULT_FILE}"))?; + let mut text = Vec::new(); + entry + .take(MAX_RESULT_BYTES + 1) + .read_to_end(&mut text) + .map_err(|e| format!("{RESULT_FILE} could not be read: {e}"))?; + if text.len() as u64 > MAX_RESULT_BYTES { + return Err(format!( + "{RESULT_FILE} is larger than {MAX_RESULT_BYTES} bytes" + )); + } + let result: Value = + serde_json::from_slice(&text).map_err(|e| format!("{RESULT_FILE} is not JSON: {e}"))?; + if result.is_object() { + Ok(result) + } else { + Err(format!("{RESULT_FILE} is not a JSON object")) + } +} + +/// `incode remote list`: the latest runs of [`WORKFLOW`], one a line. +fn list(gh: &GitHub<'_>) -> Result { + let listing = gh.json( + reqwest::Method::GET, + &format!("/actions/workflows/{WORKFLOW}/runs?per_page=20"), + None, + "the list of runs", + )?; + let runs = runs_in(&listing); + let text = if runs.is_empty() { + format!("No runs of {WORKFLOW} in {} yet.", gh.repo) + } else { + runs.iter() + .map(|r| format!("{:<12} {:<11} {} {}", r.id, r.state(), r.title, r.url)) + .collect::>() + .join("\n") + }; + Ok(Outcome { text, ok: true }) +} + +/// `incode remote logs `: the run's state and address, and its +/// result once it has completed. +fn logs(gh: &GitHub<'_>, run_id: u64) -> Result { + let found = get_run(gh, run_id)?; + let mut text = format!( + "Run {} ({}): {}\n{}\n", + found.id, + found.title, + found.state(), + found.url + ); + if found.status != "completed" { + text.push_str("Still running; its result can be read once it ends."); + } else { + match fetch_result(gh, run_id)? { + Some(result) => text.push_str(&result.to_string()), + None => text.push_str(&format!("It left no {ARTIFACT} artifact.")), + } + } + Ok(Outcome { text, ok: true }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write as _; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; + + const TOKEN: &str = "fake-token-for-tests"; + + /// A request the fake saw. + #[derive(Debug, Clone)] + struct Seen { + method: String, + target: String, + headers: Vec<(String, String)>, + body: Vec, + } + + impl Seen { + fn header(&self, name: &str) -> Option<&str> { + self.headers + .iter() + .find(|(n, _)| n.eq_ignore_ascii_case(name)) + .map(|(_, v)| v.as_str()) + } + fn path(&self) -> &str { + self.target.split('?').next().unwrap_or_default() + } + fn json(&self) -> Value { + serde_json::from_slice(&self.body).expect("a JSON body") + } + } + + struct Reply { + status: u16, + headers: Vec<(String, String)>, + body: Vec, + } + + fn reply(status: u16, body: Value) -> Reply { + Reply { + status, + headers: vec![("content-type".into(), "application/json".into())], + body: body.to_string().into_bytes(), + } + } + + type Log = Arc>>; + + /// A server on 127.0.0.1, addressed as `host` (`127.0.0.1` or + /// `localhost`), that hands each request to `answer` along with its own + /// base address: that base, and every request it saw. + fn serve(host: &str, answer: impl Fn(&Seen, &str) -> Reply + Send + 'static) -> (String, Log) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let base = format!( + "http://{host}:{}", + listener.local_addr().expect("addr").port() + ); + let log: Log = Arc::default(); + let (seen_by_thread, base_for_thread) = (Arc::clone(&log), base.clone()); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut stream) = stream else { continue }; + let Some(seen) = read_request(&mut stream) else { + continue; + }; + seen_by_thread.lock().expect("log").push(seen.clone()); + let out = answer(&seen, &base_for_thread); + let mut head = format!("HTTP/1.1 {} X\r\n", out.status); + for (name, value) in &out.headers { + head.push_str(&format!("{name}: {value}\r\n")); + } + head.push_str(&format!( + "content-length: {}\r\nconnection: close\r\n\r\n", + out.body.len() + )); + let _ = stream.write_all(head.as_bytes()); + let _ = stream.write_all(&out.body); + } + }); + (base, log) + } + + /// One request: its line, headers, and the body its content-length names. + fn read_request(stream: &mut std::net::TcpStream) -> Option { + let mut seen = Vec::new(); + let mut buf = [0u8; 8192]; + let end = loop { + let n = stream.read(&mut buf).ok()?; + if n == 0 { + return None; + } + seen.extend_from_slice(&buf[..n]); + if let Some(end) = seen.windows(4).position(|w| w == b"\r\n\r\n") { + break end; + } + }; + let head = String::from_utf8_lossy(&seen[..end]).to_string(); + let mut lines = head.lines(); + let mut first = lines.next()?.split_whitespace(); + let (method, target) = (first.next()?.to_string(), first.next()?.to_string()); + let headers: Vec<(String, String)> = lines + .filter_map(|l| l.split_once(':')) + .map(|(n, v)| (n.trim().to_string(), v.trim().to_string())) + .collect(); + let length = headers + .iter() + .find(|(n, _)| n.eq_ignore_ascii_case("content-length")) + .and_then(|(_, v)| v.parse::().ok()) + .unwrap_or(0); + let mut body = seen[end + 4..].to_vec(); + while body.len() < length { + let n = stream.read(&mut buf).ok()?; + if n == 0 { + break; + } + body.extend_from_slice(&buf[..n]); + } + Some(Seen { + method, + target, + headers, + body, + }) + } + + /// A zip holding `result.json` = `result`, written with the zip crate. + fn result_zip(result: &Value) -> Vec { + let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + writer + .start_file(RESULT_FILE, zip::write::SimpleFileOptions::default()) + .expect("entry"); + writer + .write_all(result.to_string().as_bytes()) + .expect("body"); + writer.finish().expect("finish").into_inner() + } + + /// How the fake run ends: its conclusion (`None`: it never completes) + /// and the `result.json` its artifact holds (`None`: no artifact). + struct Script { + conclusion: Option<&'static str>, + result: Option, + } + + /// A fake GitHub for repository `o/r`, with the artifact served by a + /// second server on another host name (`localhost`) that the API's + /// download address redirects to — as GitHub's blob storage is. + /// The dispatched run appears on the second listing and completes on + /// the third poll. Returns the API base, its log, and the blob's log. + fn fake_github(script: Script) -> (String, Log, Log) { + let zip = script.result.as_ref().map(result_zip); + let (blob, blob_log) = serve("localhost", move |seen, _| match &zip { + Some(zip) if seen.path() == "/blob/result.zip" => Reply { + status: 200, + headers: vec![("content-type".into(), "application/zip".into())], + body: zip.clone(), + }, + _ => reply(404, json!({"message": "no such blob"})), + }); + let dispatched_id: Arc>> = Arc::default(); + let listings = AtomicUsize::new(0); + let polls = AtomicUsize::new(0); + let has_artifact = script.result.is_some(); + let (api, api_log) = serve("127.0.0.1", move |seen, base| { + let run = |status: &str, conclusion: Option<&str>| { + json!({ + "id": 77, + "html_url": "https://github.com/o/r/actions/runs/77", + "display_title": format!( + "incode remote {}", + dispatched_id.lock().expect("id").clone().unwrap_or_default() + ), + "status": status, + "conclusion": conclusion, + }) + }; + match (seen.method.as_str(), seen.path()) { + ("GET", "/repos/o/r") => reply(200, json!({"default_branch": "main"})), + ("POST", "/repos/o/r/actions/workflows/incode-remote.yml/dispatches") => { + let id = seen.json()["inputs"]["id"].as_str().map(str::to_string); + *dispatched_id.lock().expect("id") = id; + Reply { + status: 204, + headers: Vec::new(), + body: Vec::new(), + } + } + ("GET", "/repos/o/r/actions/workflows/incode-remote.yml/runs") => { + let other = json!({ + "id": 76, "html_url": "https://github.com/o/r/actions/runs/76", + "display_title": "incode remote 000000000000", + "status": "completed", "conclusion": "success" + }); + if listings.fetch_add(1, Ordering::SeqCst) == 0 { + reply(200, json!({"workflow_runs": [other]})) + } else { + reply(200, json!({"workflow_runs": [run("queued", None), other]})) + } + } + ("GET", "/repos/o/r/actions/runs/77") => { + let n = polls.fetch_add(1, Ordering::SeqCst); + match script.conclusion { + Some(conclusion) if n >= 2 => { + reply(200, run("completed", Some(conclusion))) + } + _ => reply(200, run("in_progress", None)), + } + } + ("GET", "/repos/o/r/actions/runs/77/artifacts") => { + let artifacts = if has_artifact { + json!([{ + "name": ARTIFACT, + "expired": false, + "archive_download_url": format!("{base}/repos/o/r/actions/artifacts/5/zip"), + }]) + } else { + json!([]) + }; + reply(200, json!({"artifacts": artifacts})) + } + ("GET", "/repos/o/r/actions/artifacts/5/zip") => Reply { + status: 302, + headers: vec![( + "location".into(), + format!("{blob}/blob/result.zip?sig=signed"), + )], + body: Vec::new(), + }, + _ => reply(404, json!({"message": "Not Found"})), + } + }); + (api, api_log, blob_log) + } + + const FAST: Pace = Pace { + discover_every: Duration::from_millis(20), + discover_for: Duration::from_secs(5), + poll_every: Duration::from_millis(20), + }; + + fn repo() -> Repo { + Repo::parse("o/r").expect("repo") + } + + fn run_args(wait: Option) -> RunArgs { + RunArgs { + task: "fix the flaky test".to_string(), + repo: None, + git_ref: None, + model: Some("model-x".to_string()), + wait, + } + } + + /// Run `args` against `api`: the outcome and the progress lines. + fn run_against(api: &str, args: &RunArgs) -> (Result, Vec) { + let repo = repo(); + let gh = GitHub { + api, + repo: &repo, + token: TOKEN, + }; + let mut said = Vec::new(); + let outcome = run(&gh, args, &FAST, &mut |line| said.push(line.to_string())); + (outcome, said) + } + + fn seen(log: &Log) -> Vec { + log.lock().expect("log").clone() + } + + /// **remote::run DISPATCHES THE TASK, FINDS ITS RUN BY ID, WAITS FOR IT + /// AND PRINTS ITS RESULT**: the dispatch carries the task, model and id + /// on the default branch, with the token in the Authorization header and + /// never in an address; the run is told apart from another by the id in + /// its title; it is polled to completion; its artifact's result.json is + /// printed with its branch. + #[test] + fn remote_run_dispatches_finds_its_run_by_id_waits_and_prints_the_result() { + let result = json!({ + "type": "result", "is_error": false, "result": "fixed it", + "branch": "incode/remote-abc" + }); + let (api, api_log, blob_log) = fake_github(Script { + conclusion: Some("success"), + result: Some(result.clone()), + }); + let (outcome, said) = run_against(&api, &run_args(Some(Duration::from_secs(30)))); + let outcome = outcome.expect("the run finishes"); + assert!(outcome.ok, "{outcome:?}"); + assert_eq!( + serde_json::from_str::(&outcome.text).expect("JSON"), + result + ); + + let api_seen = seen(&api_log); + let dispatch = api_seen + .iter() + .find(|s| s.method == "POST") + .expect("a dispatch"); + assert_eq!( + dispatch.path(), + "/repos/o/r/actions/workflows/incode-remote.yml/dispatches" + ); + let body = dispatch.json(); + assert_eq!(body["ref"], "main", "the default branch: {body}"); + assert_eq!(body["inputs"]["task"], "fix the flaky test"); + assert_eq!(body["inputs"]["model"], "model-x"); + let id = body["inputs"]["id"].as_str().expect("an id").to_string(); + assert!( + id.len() == 12 && id.chars().all(|c| c.is_ascii_hexdigit()), + "{id}" + ); + for request in &api_seen { + assert_eq!( + request.header("authorization"), + Some(format!("Bearer {TOKEN}").as_str()), + "{} {}", + request.method, + request.target + ); + assert_eq!(request.header("x-github-api-version"), Some(API_VERSION)); + assert_eq!(request.header("user-agent"), Some(AGENT_NAME)); + assert!(!request.target.contains(TOKEN), "{}", request.target); + } + let listings = api_seen + .iter() + .filter(|s| s.path().ends_with("/incode-remote.yml/runs")) + .count(); + assert_eq!(listings, 2, "found on the second listing, not before"); + assert!(api_seen + .iter() + .filter(|s| s.path().ends_with("/incode-remote.yml/runs")) + .all(|s| s.target.contains("event=workflow_dispatch"))); + let polls = api_seen + .iter() + .filter(|s| s.path() == "/repos/o/r/actions/runs/77") + .count(); + assert_eq!(polls, 3, "polled until completed"); + assert!(said.iter().any(|l| l.contains(&id)), "{said:?}"); + assert!( + said.iter() + .any(|l| l.contains("https://github.com/o/r/actions/runs/77")), + "{said:?}" + ); + + let blob_seen = seen(&blob_log); + assert_eq!(blob_seen.len(), 1, "the artifact was downloaded once"); + assert_eq!(blob_seen[0].header("authorization"), None); + } + + /// **remote::fetch_result DOES NOT SEND THE TOKEN WHERE THE ARTIFACT'S + /// REDIRECT LEADS**: the API's download address gets it; the blob host + /// it redirects to (another host name) does not, nor any GitHub header. + #[test] + fn remote_fetch_result_keeps_the_token_off_the_redirect_target() { + let (api, api_log, blob_log) = fake_github(Script { + conclusion: Some("success"), + result: Some(json!({"type": "result", "is_error": false})), + }); + let repo = repo(); + let gh = GitHub { + api: &api, + repo: &repo, + token: TOKEN, + }; + let result = fetch_result(&gh, 77).expect("fetched").expect("a result"); + assert_eq!(result["type"], "result"); + + let download = seen(&api_log) + .into_iter() + .find(|s| s.path() == "/repos/o/r/actions/artifacts/5/zip") + .expect("the API's download address was asked"); + assert_eq!( + download.header("authorization"), + Some(format!("Bearer {TOKEN}").as_str()) + ); + let blob = seen(&blob_log); + assert_eq!(blob.len(), 1, "{blob:?}"); + assert_eq!(blob[0].target, "/blob/result.zip?sig=signed"); + assert_eq!( + blob[0].header("authorization"), + None, + "the token went to the redirect's host" + ); + assert_eq!(blob[0].header("x-github-api-version"), None); + } + + /// **remote::run, WHEN THE WAIT RUNS OUT, SAYS THE RUN IS STILL RUNNING + /// AND CANCELS NOTHING**: it exits non-zero with the run's address, and + /// asks GitHub nothing but reads after the dispatch. + #[test] + fn remote_run_reports_a_run_still_going_when_the_timeout_expires() { + let (api, api_log, _) = fake_github(Script { + conclusion: None, + result: None, + }); + let (outcome, _) = run_against(&api, &run_args(Some(Duration::from_millis(300)))); + let outcome = outcome.expect("an outcome"); + assert!(!outcome.ok, "{outcome:?}"); + assert!(outcome.text.contains("still running"), "{}", outcome.text); + assert!(outcome.text.contains("not cancelled"), "{}", outcome.text); + assert!( + outcome + .text + .contains("https://github.com/o/r/actions/runs/77"), + "{}", + outcome.text + ); + let api_seen = seen(&api_log); + assert_eq!( + api_seen.iter().filter(|s| s.method != "GET").count(), + 1, + "only the dispatch writes: {api_seen:?}" + ); + assert!(!api_seen.iter().any(|s| s.target.contains("cancel"))); + } + + /// **remote::run --no-wait RETURNS ONCE THE RUN IS FOUND**, with its + /// address, without polling it. + #[test] + fn remote_run_without_waiting_returns_the_runs_address() { + let (api, api_log, _) = fake_github(Script { + conclusion: Some("success"), + result: None, + }); + let (outcome, _) = run_against(&api, &run_args(None)); + let outcome = outcome.expect("an outcome"); + assert!(outcome.ok); + assert!(outcome + .text + .contains("Run 77 started: https://github.com/o/r/actions/runs/77")); + assert!(!seen(&api_log) + .iter() + .any(|s| s.path() == "/repos/o/r/actions/runs/77")); + } + + /// **remote::finish: A FAILED RUN, OR A TASK THAT REPORTED AN ERROR, + /// EXITS NON-ZERO** — still printing the result it left — and a failed + /// run that left none is an error naming its conclusion. + #[test] + fn remote_run_with_a_failed_conclusion_or_task_exits_non_zero() { + // The run failed though the task did not say so. + let (api, _, _) = fake_github(Script { + conclusion: Some("failure"), + result: Some(json!({"type": "result", "is_error": false, "result": "done"})), + }); + let (outcome, said) = run_against(&api, &run_args(Some(Duration::from_secs(30)))); + let outcome = outcome.expect("an outcome"); + assert!(!outcome.ok, "a failed run exited 0"); + let printed: Value = serde_json::from_str(&outcome.text).expect("JSON"); + assert_eq!(printed["result"], "done"); + assert_eq!(printed["branch"], Value::Null, "branch is always present"); + assert!(said.iter().any(|l| l.contains("ended failure")), "{said:?}"); + + // The run succeeded but the task reported an error. + let (api, _, _) = fake_github(Script { + conclusion: Some("success"), + result: Some(json!({"type": "result", "is_error": true, "error": "verify failed"})), + }); + let (outcome, _) = run_against(&api, &run_args(Some(Duration::from_secs(30)))); + assert!(!outcome.expect("an outcome").ok, "is_error exited 0"); + + let (api, _, _) = fake_github(Script { + conclusion: Some("cancelled"), + result: None, + }); + let (outcome, _) = run_against(&api, &run_args(Some(Duration::from_secs(30)))); + let said = outcome.expect_err("no result"); + assert!( + said.contains("ended cancelled") && said.contains(ARTIFACT), + "{said}" + ); + } + + /// **remote::Repo::from_remote READS THE REPOSITORY FROM HTTPS AND SSH + /// REMOTES** of github.com, and nothing else; `-R` is held to the same + /// characters, so it cannot reshape an API path. + #[test] + fn remote_repo_is_parsed_from_https_and_ssh_remotes() { + let want = Some(Repo { + owner: "incordai".into(), + name: "incode".into(), + }); + for url in [ + "https://github.com/incordai/incode.git", + "https://github.com/incordai/incode", + "https://github.com/incordai/incode/\n", + "https://user:secret@github.com/incordai/incode.git", + "git@github.com:incordai/incode.git", + "git@github.com:incordai/incode", + "ssh://git@github.com/incordai/incode.git", + "ssh://git@ssh.github.com:443/incordai/incode.git", + ] { + assert_eq!(Repo::from_remote(url), want, "{url}"); + } + for url in [ + "https://gitlab.com/incordai/incode.git", + "git@gitlab.com:incordai/incode.git", + "https://github.com/incordai", + "https://github.com/incordai/incode/tree/main", + "/home/me/incode", + "", + ] { + assert_eq!(Repo::from_remote(url), None, "{url}"); + } + assert_eq!( + Repo::parse("incordai/incode.git").map(|r| r.to_string()), + Ok("incordai/incode".to_string()) + ); + for bad in [ + "incordai", "a/b/c", "../x", "o/..", "o/r?x=1", "o r/x", "/r", + ] { + assert!(Repo::parse(bad).is_err(), "{bad}"); + } + } + + /// **remote::token_from: NO TOKEN IS A CLEAR ERROR NAMING THE + /// VARIABLES**, raised before any request; GH_TOKEN comes before + /// GITHUB_TOKEN, and a blank one is not a token. + #[test] + fn remote_without_a_token_names_the_variables_to_set() { + let none = |_: &str| None; + let args: Vec = ["list", "-R", "o/r"] + .iter() + .map(|s| s.to_string()) + .collect(); + let said = cli_with( + &args, + Path::new("."), + "http://127.0.0.1:9/", + &none, + &FAST, + &mut |_| {}, + ) + .expect_err("no token"); + assert!( + said.contains("GH_TOKEN") && said.contains("GITHUB_TOKEN"), + "{said}" + ); + + let both = |name: &str| match name { + "GH_TOKEN" => Some("first".to_string()), + "GITHUB_TOKEN" => Some("second".to_string()), + _ => None, + }; + assert_eq!(token_from(&both), Ok("first".to_string())); + let blank_first = |name: &str| match name { + "GH_TOKEN" => Some(" ".to_string()), + "GITHUB_TOKEN" => Some("second".to_string()), + _ => None, + }; + assert_eq!(token_from(&blank_first), Ok("second".to_string())); + } + + /// **remote::send NEVER SENDS THE TOKEN IN CLEAR TO ANOTHER MACHINE**, + /// and an error never holds the token: plain http elsewhere is refused + /// before connecting; a 401 says what was refused, not the token. + #[test] + fn remote_send_holds_the_token_to_https_or_this_machine() { + let repo = repo(); + let gh = GitHub { + api: "http://api.example.invalid", + repo: &repo, + token: TOKEN, + }; + let said = list(&gh).expect_err("plain http elsewhere"); + assert!(said.contains("not sent"), "{said}"); + assert!(!said.contains(TOKEN)); + + let (api, _) = serve("127.0.0.1", |_, _| { + reply(401, json!({"message": "Bad credentials"})) + }); + let gh = GitHub { + api: &api, + repo: &repo, + token: TOKEN, + }; + let said = list(&gh).expect_err("refused"); + assert!( + said.contains("401") && said.contains("Bad credentials"), + "{said}" + ); + assert!(said.contains("GH_TOKEN"), "{said}"); + assert!(!said.contains(TOKEN), "{said}"); + } + + /// **remote::list AND remote::logs SHOW RUNS AND A RESULT**: one line a + /// run (id, state, title, address); a completed run's result read from + /// its artifact. + #[test] + fn remote_list_and_logs_show_runs_and_a_result() { + let (api, _, _) = fake_github(Script { + conclusion: Some("success"), + result: Some(json!({"type": "result", "is_error": false, "result": "ok"})), + }); + let repo = repo(); + let gh = GitHub { + api: &api, + repo: &repo, + token: TOKEN, + }; + let listed = list(&gh).expect("listed"); + assert!(listed.ok); + assert!( + listed.text.contains("76") + && listed.text.contains("success") + && listed.text.contains("incode remote 000000000000") + && listed + .text + .contains("https://github.com/o/r/actions/runs/76"), + "{}", + listed.text + ); + + let running = logs(&gh, 77).expect("logs"); + assert!(running.text.contains("in_progress"), "{}", running.text); + assert!(running.text.contains("Still running"), "{}", running.text); + let _ = logs(&gh, 77).expect("logs"); + let done = logs(&gh, 77).expect("logs"); + assert!(done.text.contains("success"), "{}", done.text); + assert!(done.text.contains(r#""result":"ok""#), "{}", done.text); + } + + /// **remote::parse READS EACH VERB'S ARGUMENTS** and refuses what it + /// does not know, even beside `--help`. + #[test] + fn remote_parse_reads_each_verbs_arguments() { + let parse = |args: &[&str]| parse(&args.iter().map(|s| s.to_string()).collect::>()); + assert_eq!( + parse(&[ + "run", + "do it", + "-R", + "o/r", + "--ref", + "dev", + "-m", + "m1", + "--timeout", + "5" + ]), + Ok(Verb::Run(RunArgs { + task: "do it".into(), + repo: Some("o/r".into()), + git_ref: Some("dev".into()), + model: Some("m1".into()), + wait: Some(Duration::from_secs(300)), + })) + ); + assert!(matches!( + parse(&["run", "do it"]), + Ok(Verb::Run(RunArgs { wait: Some(w), .. })) if w == Duration::from_secs(3600) + )); + assert!(matches!( + parse(&["run", "do it", "--no-wait"]), + Ok(Verb::Run(RunArgs { wait: None, .. })) + )); + assert!(matches!( + parse(&["run", "--", "-starts with a dash"]), + Ok(Verb::Run(RunArgs { task, .. })) if task == "-starts with a dash" + )); + assert_eq!(parse(&["list"]), Ok(Verb::List { repo: None })); + assert_eq!( + parse(&["logs", "42", "-R", "o/r"]), + Ok(Verb::Logs { + repo: Some("o/r".into()), + run_id: 42 + }) + ); + for help in [&["help"][..], &[], &["--help"], &["run", "--help"]] { + assert_eq!(parse(help), Ok(Verb::Help), "{help:?}"); + } + for bad in [ + &["launch"][..], + &["run"], + &["run", "two", "words"], + &["run", "x", "--timeout", "0"], + &["run", "x", "--timeout", "soon"], + &["run", "x", "--no-wait", "--timeout", "5"], + &["run", "x", "--bogus", "--help"], + &["list", "--timeout", "5"], + &["list", "extra"], + &["logs"], + &["logs", "abc"], + &["run", "x", "-R"], + ] { + assert!(parse(bad).is_err(), "{bad:?}"); + } + } + + /// **THE WORKFLOW TEMPLATE MATCHES WHAT remote.rs RELIES ON**: the + /// workflow file name, the inputs, the run title the run is found by, the + /// branch, the artifact and its file — and an input reaches a script only + /// through an environment variable, never spliced into it. + #[test] + fn remote_workflow_template_matches_what_remote_relies_on() { + let template = include_str!("../.github/workflows/incode-remote.yml.example"); + assert!(template.contains(&format!(".github/workflows/{WORKFLOW}"))); + for needle in [ + "workflow_dispatch:", + " task:", + " model:", + " id:", + "run-name: \"incode remote ${{ inputs.id }}\"", + "branch=\"incode/remote-$ID\"", + &format!("name: {ARTIFACT}"), + &format!("incode-result/{RESULT_FILE}"), + "--output-format json", + ] { + assert!(template.contains(needle), "the template lacks {needle:?}"); + } + for line in template.lines().filter(|l| l.contains("${{ inputs.")) { + let line = line.trim(); + let env_mapping = line + .split_once(": ${{ inputs.") + .is_some_and(|(key, _)| key.chars().all(|c| c.is_ascii_uppercase() || c == '_')); + assert!( + line.starts_with("run-name:") || env_mapping, + "an input is used outside run-name and env: {line}" + ); + } + } + + /// **remote::read_result READS result.json FROM THE ARTIFACT'S ZIP**, + /// and says what is wrong with one that has none or is not a zip. + #[test] + fn remote_read_result_takes_result_json_from_the_zip() { + let zip = result_zip(&json!({"type": "result", "num_turns": 3})); + assert_eq!(read_result(&zip).expect("read")["num_turns"], 3); + let said = read_result(b"not a zip").expect_err("not a zip"); + assert!(said.contains("not a readable zip"), "{said}"); + let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + writer + .start_file("other.txt", zip::write::SimpleFileOptions::default()) + .expect("entry"); + let empty = writer.finish().expect("finish").into_inner(); + assert!(read_result(&empty) + .expect_err("no result.json") + .contains("holds no result.json")); + } +} From bf846a04e20e2734159bc681de92d23ecc46f34f Mon Sep 17 00:00:00 2001 From: blockmandev <194144408+blockmandev@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:07:13 -0600 Subject: [PATCH 2/3] remote: push from a job the agent never ran on; survive a stray failed poll (refs #23) The workflow ran the agent and pushed its branch in one job. A hosted runner gives the agent's commands sudo, and actions/checkout left the job's contents:write GITHUB_TOKEN in .git/config, so a prompt-injected task could push anywhere in the repository, or rewrite git so the later push step did. The template now has two jobs: `task` runs incode with contents:read and persist-credentials:false and uploads its result and a patch of everything it changed (against $GITHUB_SHA, so its own commits count) as data; `publish`, on a fresh runner with contents:write, applies the patch and pushes incode/remote-. A patch that fails to apply, or that holds the model key as written, is not pushed, and the result says so (is_error, publish_error) instead of reporting branch null as if nothing changed. `incode remote run` ended an hour-long wait on the first failed read of the run: the `?` on get_run turned one GitHub 502, rate limit or dropped connection into a failure. Up to POLL_MISSES_TOLERATED (5) failed reads in a row are now reported and asked again. `incode remote logs` exited 0 for a run that failed, so a script that dispatched with --no-wait could not tell failure from success; it now exits non-zero when a completed run did not succeed or its task reported an error, as `run` does. Tests: the template test holds the split (incode only in `task`, no contents:write there, no persisted credentials); a fake that answers 502 to the first polls; logs' exit for failed and is_error runs. Each was checked against a deliberately broken control. --- .github/workflows/incode-remote.yml.example | 108 ++++++++++++--- docs/remote-runs.md | 30 ++++- src/remote.rs | 141 +++++++++++++++++--- 3 files changed, 239 insertions(+), 40 deletions(-) diff --git a/.github/workflows/incode-remote.yml.example b/.github/workflows/incode-remote.yml.example index 07e9b46..2d0816e 100644 --- a/.github/workflows/incode-remote.yml.example +++ b/.github/workflows/incode-remote.yml.example @@ -10,6 +10,14 @@ # # The task is untrusted text: it reaches the shell only through environment # variables, never spliced into a script. +# +# Two jobs, because the agent owns the runner it works on: it runs commands, +# and a hosted runner gives them sudo, so anything later in that job -- git +# itself included -- runs as the agent left it. The `task` job therefore holds +# no token that can push (`contents: read`, and checkout keeps no credentials) +# and hands its result and a patch of its changes on as data. The `publish` +# job, on a fresh runner that never runs the agent's code, applies the patch +# and pushes the branch. name: incode remote run-name: "incode remote ${{ inputs.id }}" @@ -31,12 +39,16 @@ on: type: string permissions: - contents: write + contents: read jobs: - run: + task: runs-on: ubuntu-latest timeout-minutes: 120 + permissions: + contents: read + outputs: + exit: ${{ steps.task.outputs.exit }} steps: - name: Check the id env: @@ -50,6 +62,7 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 + persist-credentials: false - name: Install incode env: @@ -97,8 +110,8 @@ jobs: } >> "$GITHUB_ENV" # The result is written outside the checkout, so it is never committed. - # A failed task does not stop the job here: its changes are still - # pushed and its result uploaded, and the last step fails the job. + # A failed task does not stop the run here: its changes are still + # published and its result uploaded, and `publish` fails the run. - name: Run the task id: task env: @@ -106,33 +119,88 @@ jobs: run: | set -uo pipefail set +e - mkdir -p "$RUNNER_TEMP/incode-result" - result="$RUNNER_TEMP/incode-result/result.json" + mkdir -p "$RUNNER_TEMP/incode-work" + result="$RUNNER_TEMP/incode-work/result.json" incode -t "$TASK" --spec-gate off --output-format json > "$result" echo "exit=$?" >> "$GITHUB_OUTPUT" - if ! jq -e 'type == "object"' "$result" > /dev/null 2>&1; then - jq -n '{type: "result", is_error: true, - error: "incode did not report a result; see the workflow log"}' > "$result" - fi + # Everything the agent changed, committed or not, against the commit the + # run started from. It is data to `publish`, which applies it. + - name: Record the changes + if: always() + run: | + set -uo pipefail + mkdir -p "$RUNNER_TEMP/incode-work" + git add -A + git diff --cached --binary "$GITHUB_SHA" > "$RUNNER_TEMP/incode-work/changes.patch" + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: incode-work + path: ${{ runner.temp }}/incode-work/ + if-no-files-found: ignore + + publish: + needs: task + if: ${{ !cancelled() }} + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + continue-on-error: true + with: + name: incode-work + path: ${{ runner.temp }}/incode-work + + # The id is checked again: this job runs even when `task` failed at its + # own check. A patch that does not apply is reported, not pushed, and so + # is one that holds the model key -- the agent had it in its environment, + # and a branch would publish it to everyone who can read the repository. - name: Push the changes + id: push env: ID: ${{ inputs.id }} TASK: ${{ inputs.task }} + KEY: ${{ secrets.ANTHROPIC_API_KEY }} run: | - set -euo pipefail + set -uo pipefail + work="$RUNNER_TEMP/incode-work" + mkdir -p "$RUNNER_TEMP/incode-result" result="$RUNNER_TEMP/incode-result/result.json" + if jq -e 'type == "object"' "$work/result.json" > /dev/null 2>&1; then + cp "$work/result.json" "$result" + else + jq -n '{type: "result", is_error: true, + error: "incode did not report a result; see the workflow log"}' > "$result" + fi branch="" - if [ -n "$(git status --porcelain)" ]; then + pushed=ok + if [ ! -s "$work/changes.patch" ]; then + : + elif ! printf '%s' "$ID" | grep -Eq '^[A-Za-z0-9-]+$'; then + pushed="the id must be letters, digits and dashes" + elif [ -n "$KEY" ] && grep -qF -- "$KEY" "$work/changes.patch"; then + pushed="the changes hold the model key, so they were not pushed" + else branch="incode/remote-$ID" git config user.name "incode[bot]" git config user.email "incode[bot]@users.noreply.github.com" - git checkout -b "$branch" - git add -A - git commit -q -m "incode remote $ID" -m "$TASK" - git push -q origin "$branch" + if ! { git checkout -q -b "$branch" \ + && git apply --index --binary "$work/changes.patch" \ + && git commit -q -m "incode remote $ID" -m "$TASK" \ + && git push -q origin "$branch"; }; then + pushed="the changes could not be applied and pushed; see the workflow log" + branch="" + fi fi - jq --arg branch "$branch" '. + {branch: (if $branch == "" then null else $branch end)}' \ + jq --arg branch "$branch" --arg pushed "$pushed" \ + '. + {branch: (if $branch == "" then null else $branch end)} + + (if $pushed == "ok" then {} else {is_error: true, publish_error: $pushed} end)' \ "$result" > "$result.tmp" mv "$result.tmp" "$result" @@ -145,10 +213,12 @@ jobs: - name: Fail when the task failed env: - EXIT: ${{ steps.task.outputs.exit }} + EXIT: ${{ needs.task.outputs.exit }} + TASK_JOB: ${{ needs.task.result }} run: | set -euo pipefail - if [ "$EXIT" != "0" ] || jq -e '.is_error == true' "$RUNNER_TEMP/incode-result/result.json" > /dev/null; then + if [ "$TASK_JOB" != "success" ] || [ "$EXIT" != "0" ] \ + || jq -e '.is_error == true' "$RUNNER_TEMP/incode-result/result.json" > /dev/null; then echo "::error::incode remote: the task did not succeed; see result.json in the incode-result artifact." exit 1 fi diff --git a/docs/remote-runs.md b/docs/remote-runs.md index 4d789a0..5785139 100644 --- a/docs/remote-runs.md +++ b/docs/remote-runs.md @@ -61,25 +61,43 @@ supported. ## What the workflow does -`run-name: incode remote `; then, on `ubuntu-latest`: +`run-name: incode remote `; then two jobs on `ubuntu-latest`. + +**`task`** — with `contents: read` only: - checks the id is letters, digits and dashes (it names the branch); -- checks out the repository and installs the latest incode release with +- checks out the repository without keeping a token in it + (`persist-credentials: false`) and installs the latest incode release with `scripts/install.sh`, as the GitHub Action does; - writes a one-route `admin.json` (the key's variable name only) into a private `INCODE_DIR`; - runs `incode -t "$TASK" --spec-gate off --output-format json`, writing `result.json` outside the checkout; -- if the working tree changed, commits it to `incode/remote-` and pushes - that branch; +- records everything the agent changed as a patch against the starting + commit and uploads it with `result.json` as the artifact `incode-work`. + +**`publish`** — with `contents: write`, on a fresh runner: + +- applies the patch, commits it to `incode/remote-` and pushes that + branch, when there is one; - adds `branch` to `result.json` and uploads it as the artifact `incode-result`; -- fails the job when the task failed. +- fails the run when the task failed. + +The split is deliberate: the agent runs commands, and a hosted runner gives +them sudo, so nothing later in its own job can be trusted to be as the +workflow wrote it. The job that can push never runs anything the agent left +behind; it only applies a patch. ## Safety - **Who can run it.** Dispatching a workflow takes write access to the - repository, and the run holds your model key and a token that can push. + repository. The agent's job holds your model key but no token that can + push; only the `publish` job can, and it runs none of the agent's code. +- **The model key stays off the branch.** `publish` refuses to push changes + that hold the key as written. That catches an agent that writes its + environment into a file; it cannot catch one that encodes the key first, + so the key should be one you can revoke, scoped to this use. - **The task is untrusted text.** The workflow passes it, and every other input, through environment variables; none is spliced into a script. - **No pull request, no existing branch.** The run pushes only a new diff --git a/src/remote.rs b/src/remote.rs index 36a808a..7eaada0 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -98,6 +98,11 @@ const MAX_API_BYTES: usize = 8 * 1024 * 1024; const MAX_ARTIFACT_BYTES: usize = 64 * 1024 * 1024; /// The most of `result.json` read. const MAX_RESULT_BYTES: u64 = 16 * 1024 * 1024; +/// Reads of a run in a row that may fail while [`run`] waits for it. A wait +/// lasts up to an hour or more, and GitHub answers the odd poll with a 502 +/// or a rate limit, or the network drops when a laptop sleeps; one such +/// answer is no reason to abandon a run that is still going. +const POLL_MISSES_TOLERATED: u32 = 5; /// What `incode remote …` prints on stdout, and whether it exits 0. #[derive(Debug, Clone, PartialEq, Eq)] @@ -745,22 +750,34 @@ fn run( wait.as_secs().div_ceil(60) )); let deadline = Instant::now().checked_add(wait); + let mut status = found.status.clone(); + let mut misses = 0; let finished = loop { - let now = get_run(gh, found.id)?; - if now.status == "completed" { - break now; + match get_run(gh, found.id) { + Ok(now) if now.status == "completed" => break now, + Ok(now) => { + misses = 0; + status = now.status; + } + Err(said) if misses < POLL_MISSES_TOLERATED => { + misses += 1; + progress(&format!( + "run {}: {said}; asking again ({misses} of {POLL_MISSES_TOLERATED} misses in a row allowed)", + found.id + )); + } + Err(said) => return Err(said), } let left = deadline.map(|d| d.saturating_duration_since(Instant::now())); if left.is_some_and(|left| left.is_zero()) { return Ok(Outcome { text: format!( - "Run {} is still running ({}) after {} min; it was not cancelled: {}\n\ + "Run {} is still running ({status}) after {} min; it was not cancelled: {}\n\ `incode remote logs {} -R {}` shows its result once it ends.", - now.id, - now.status, + found.id, wait.as_secs().div_ceil(60), - now.url, - now.id, + found.url, + found.id, gh.repo ), ok: false, @@ -925,7 +942,10 @@ fn list(gh: &GitHub<'_>) -> Result { } /// `incode remote logs `: the run's state and address, and its -/// result once it has completed. +/// result once it has completed. `ok` unless the run completed without +/// succeeding or its task reported an error — as [`finish`] decides for +/// `run`, so a script that dispatched with `--no-wait` can tell the two +/// apart; a run still going is not a failure. fn logs(gh: &GitHub<'_>, run_id: u64) -> Result { let found = get_run(gh, run_id)?; let mut text = format!( @@ -937,13 +957,17 @@ fn logs(gh: &GitHub<'_>, run_id: u64) -> Result { ); if found.status != "completed" { text.push_str("Still running; its result can be read once it ends."); - } else { - match fetch_result(gh, run_id)? { - Some(result) => text.push_str(&result.to_string()), - None => text.push_str(&format!("It left no {ARTIFACT} artifact.")), + return Ok(Outcome { text, ok: true }); + } + let mut ok = found.conclusion.as_deref() == Some("success"); + match fetch_result(gh, run_id)? { + Some(result) => { + ok &= result.get("is_error").and_then(Value::as_bool) != Some(true); + text.push_str(&result.to_string()); } + None => text.push_str(&format!("It left no {ARTIFACT} artifact.")), } - Ok(Outcome { text, ok: true }) + Ok(Outcome { text, ok }) } #[cfg(test)] @@ -1097,6 +1121,13 @@ mod tests { /// The dispatched run appears on the second listing and completes on /// the third poll. Returns the API base, its log, and the blob's log. fn fake_github(script: Script) -> (String, Log, Log) { + fake_github_failing(script, 0) + } + + /// [`fake_github`] whose first `failing_polls` reads of the run answer + /// 502, as GitHub now and then does; the run then completes on the + /// third poll that succeeds. + fn fake_github_failing(script: Script, failing_polls: usize) -> (String, Log, Log) { let zip = script.result.as_ref().map(result_zip); let (blob, blob_log) = serve("localhost", move |seen, _| match &zip { Some(zip) if seen.path() == "/blob/result.zip" => Reply { @@ -1148,8 +1179,11 @@ mod tests { } ("GET", "/repos/o/r/actions/runs/77") => { let n = polls.fetch_add(1, Ordering::SeqCst); + if n < failing_polls { + return reply(502, json!({"message": "Server Error"})); + } match script.conclusion { - Some(conclusion) if n >= 2 => { + Some(conclusion) if n >= failing_polls + 2 => { reply(200, run("completed", Some(conclusion))) } _ => reply(200, run("in_progress", None)), @@ -1365,6 +1399,36 @@ mod tests { assert!(!api_seen.iter().any(|s| s.target.contains("cancel"))); } + /// **remote::run KEEPS WAITING THROUGH A FEW FAILED POLLS**: GitHub's + /// odd 502 mid-wait is reported and asked again, up to + /// [`POLL_MISSES_TOLERATED`] in a row; one more than that ends the wait + /// with GitHub's answer. + #[test] + fn remote_run_keeps_waiting_through_a_few_failed_polls() { + let (api, _, _) = fake_github_failing( + Script { + conclusion: Some("success"), + result: Some(json!({"type": "result", "is_error": false})), + }, + POLL_MISSES_TOLERATED as usize, + ); + let (outcome, said) = run_against(&api, &run_args(Some(Duration::from_secs(30)))); + let outcome = outcome.expect("the wait survives the failed polls"); + assert!(outcome.ok, "{outcome:?}"); + assert!(said.iter().any(|l| l.contains("asking again")), "{said:?}"); + + let (api, _, _) = fake_github_failing( + Script { + conclusion: Some("success"), + result: None, + }, + POLL_MISSES_TOLERATED as usize + 1, + ); + let (outcome, _) = run_against(&api, &run_args(Some(Duration::from_secs(30)))); + let said = outcome.expect_err("too many failed polls in a row"); + assert!(said.contains("502"), "{said}"); + } + /// **remote::run --no-wait RETURNS ONCE THE RUN IS FOUND**, with its /// address, without polling it. #[test] @@ -1567,8 +1631,32 @@ mod tests { assert!(running.text.contains("Still running"), "{}", running.text); let _ = logs(&gh, 77).expect("logs"); let done = logs(&gh, 77).expect("logs"); + assert!(done.ok && running.ok, "{done:?}"); assert!(done.text.contains("success"), "{}", done.text); assert!(done.text.contains(r#""result":"ok""#), "{}", done.text); + + // A run that failed, or whose task reported an error, exits non-zero. + for (conclusion, is_error) in [("failure", false), ("success", true)] { + let (api, _, _) = fake_github(Script { + conclusion: Some(conclusion), + result: Some(json!({"type": "result", "is_error": is_error})), + }); + let gh = GitHub { + api: &api, + repo: &repo, + token: TOKEN, + }; + for _ in 0..2 { + assert!( + logs(&gh, 77).expect("logs").ok, + "still running is not failure" + ); + } + assert!( + !logs(&gh, 77).expect("logs").ok, + "logs of a run ended {conclusion} (is_error {is_error}) exited 0" + ); + } } /// **remote::parse READS EACH VERB'S ARGUMENTS** and refuses what it @@ -1669,6 +1757,29 @@ mod tests { "an input is used outside run-name and env: {line}" ); } + // The agent owns the runner it works on, so the job that runs it + // holds nothing that can push; a fresh job publishes its patch. + let (task_job, publish_job) = template + .split_once("\n publish:\n") + .expect("the template has a publish job"); + assert!( + task_job.contains("incode -t"), + "incode runs in the task job" + ); + assert!( + !publish_job.contains("incode -t"), + "incode runs in the publish job" + ); + assert!( + !task_job.contains("contents: write"), + "the task job can push" + ); + assert!( + task_job.contains("persist-credentials: false"), + "the task job's checkout keeps a token" + ); + assert!(publish_job.contains("contents: write")); + assert!(publish_job.contains("git apply --index --binary")); } /// **remote::read_result READS result.json FROM THE ARTIFACT'S ZIP**, From 24d0a783448a7253239096cd041138aefa5f54ed Mon Sep 17 00:00:00 2001 From: blockmandev <194144408+blockmandev@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:28:30 -0600 Subject: [PATCH 3/3] remote: find the template's publish job by line, not by "\n" (refs #23) The Windows CI checkout has core.autocrlf on, so the workflow template that include_str! reads there has CRLF line endings and the template test split it on "\n publish:\n", which never matched: "the template has a publish job" failed on windows-latest only. The test now finds the job by its line and checks the template both as LF and as CRLF, so a run on an LF checkout reproduces the Windows case. --- src/remote.rs | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/remote.rs b/src/remote.rs index 7eaada0..3999b63 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -1759,9 +1759,27 @@ mod tests { } // The agent owns the runner it works on, so the job that runs it // holds nothing that can push; a fresh job publishes its patch. - let (task_job, publish_job) = template - .split_once("\n publish:\n") + // Split by lines, not on "\n": a Windows checkout (core.autocrlf) + // has the template with CRLF endings — checked here both ways. + for template in [ + template.replace("\r\n", "\n"), + template.replace('\n', "\r\n"), + ] { + assert_task_and_publish_jobs_are_split(&template); + } + } + + /// The `task` job of the workflow `template` runs incode and cannot push; + /// the `publish` job runs no incode and can. Called by + /// [`remote_workflow_template_matches_what_remote_relies_on`]. + fn assert_task_and_publish_jobs_are_split(template: &str) { + let lines: Vec<&str> = template.lines().collect(); + let publish_at = lines + .iter() + .position(|line| line.trim_end() == " publish:") .expect("the template has a publish job"); + let task_job = lines[..publish_at].join("\n"); + let publish_job = lines[publish_at..].join("\n"); assert!( task_job.contains("incode -t"), "incode runs in the task job"