Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
224 changes: 224 additions & 0 deletions .github/workflows/incode-remote.yml.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
# 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 <id>`, by which
# the run is found; the branch `incode/remote-<id>`; 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.
#
# 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 }}"

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: read

jobs:
task:
runs-on: ubuntu-latest
timeout-minutes: 120
permissions:
contents: read
outputs:
exit: ${{ steps.task.outputs.exit }}
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
persist-credentials: false

- 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 run here: its changes are still
# published and its result uploaded, and `publish` fails the run.
- name: Run the task
id: task
env:
TASK: ${{ inputs.task }}
run: |
set -uo pipefail
set +e
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"

# 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 -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=""
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"
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" --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"

- 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: ${{ needs.task.outputs.exit }}
TASK_JOB: ${{ needs.task.result }}
run: |
set -euo pipefail
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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<task>"` 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-<id>` the
changes were pushed to. `incode remote list` and `incode remote logs <run-id>`
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
Expand Down
109 changes: 109 additions & 0 deletions docs/remote-runs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# Remote runs: `incode remote`

`incode remote run "<task>"` 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-<id>`; 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 <id>`, 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 <run-id>` 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-<id>` 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 <id>`; 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 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;
- 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-<id>` and pushes that
branch, when there is one;
- adds `branch` to `result.json` and uploads it as the artifact
`incode-result`;
- 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. 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
`incode/remote-<id>` 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.
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading