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
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,11 @@ agent-runtime/
│ ├── cli.py # CLI entry point
│ ├── serving.py # HTTP + HTTPS listeners with one shutdown
│ ├── tls.py # Loopback certificate and macOS keychain trust (Safari)
│ ├── labs.py # Named lab actions: lab.toml, pinned checkouts, approvals, runs
│ ├── api/
│ │ ├── health.py # Health check endpoints
│ │ ├── kernel.py # Kernel lifecycle endpoints
│ │ ├── labs.py # /labs: prepare, runs, stop
│ │ └── execute.py # Code execution endpoints
│ └── kernels/
│ ├── manager.py # Kernel lifecycle management
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,21 @@ DELETE /kernel - Shutdown the kernel
POST /cell/run - Execute code in the kernel
```

### Lab Actions

A lab repository can declare, in `lab.toml`, the commands a course page may run. The page pairs
with the narrower `actions` scope, which cannot reach `/kernel` or `/cell`. The learner approves
each lab version once, after seeing every command. See
[docs/protocol.md](docs/protocol.md#lab-actions).

```
POST /labs/prepare - Fetch a repo at a full commit SHA, read lab.toml, ask to approve
POST /labs/runs - Start a named action
GET /labs/runs/{id}?offset=N - Status and output since N
POST /labs/runs/{id}/stop - Stop it (SIGINT, then SIGKILL)
GET /labs/runs - This site's runs
```

## Architecture

```
Expand Down
38 changes: 38 additions & 0 deletions docs/integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,44 @@ async function runCell(runtimeUrl, token, labId, code) {
}
```

## Lab pages: named actions instead of code

A course page that only needs to run a lab's prepared commands should not hold a token that can
run any Python. Pair with the `actions` scope and use `/labs`:

1. Find the runtime (HTTPS first, as above) and pair with `{"scope": "actions"}`.
2. `POST /labs/prepare` with the lab repository and the full commit SHA the page was built
against. The first time, the learner approves the lab in the runtime's terminal, which lists
every command. Later visits to the same version do not ask again.
3. Show the returned actions as buttons. On click, `POST /labs/runs`, then poll
`GET /labs/runs/{id}?offset=` every half second, appending `output` and passing back
`next_offset`, until `status` is not `running`.
4. A Stop button calls `POST /labs/runs/{id}/stop`. After a reload, `GET /labs/runs` finds a
run that is still going.

```javascript
async function labRequest(runtimeUrl, token, path, body) {
const response = await fetch(`${runtimeUrl}${path}`, {
method: body ? "POST" : "GET",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) throw new Error((await response.json()).detail);
return response.json();
}

const lab = await labRequest(runtimeUrl, token, "/labs/prepare", { repo, commit });
let run = await labRequest(runtimeUrl, token, "/labs/runs",
{ lab_id: lab.lab_id, commit: lab.commit, action: "stable" });
let offset = 0;
while (run.status === "running") {
await new Promise((resolve) => setTimeout(resolve, 500));
run = await labRequest(runtimeUrl, token, `/labs/runs/${run.run_id}?offset=${offset}`);
output.textContent += run.output;
offset = run.next_offset;
}
```

## Browser security behavior

Measured on macOS, 2026-09-19, from an HTTPS page on a public origin:
Expand Down
142 changes: 139 additions & 3 deletions docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,19 @@ New origins must be paired before they can execute code:
4. On approval, the pending HTTP response returns a bearer token
5. Web app stores token and includes in subsequent requests

### Scopes

A pairing grants one scope. The page asks for it in the pairing request, and the terminal prompt
shows it.

| Scope | May use | Meaning |
|---|---|---|
| `code` (default) | everything, including `/kernel/*`, `/cell/*` and `/labs/*` | run any Python on the user's machine |
| `actions` | `/labs/*` only | run the named actions of labs the user approves one by one |

A token with scope `actions` gets `403` from code endpoints. Origins paired before scopes existed
have `code`. Loopback origins, and runtimes started with `--no-pairing`, have `code`.

### Authorization Header

```
Expand All @@ -47,15 +60,19 @@ plain-HTTP origin.
```
POST /pairing/request
Origin: https://course.example.com
Content-Type: application/json

{"scope": "actions"}
```

This request remains pending while the runtime asks the local user to approve
the exact `Origin` header. On approval:
The body is optional; `scope` defaults to `"code"`. This request remains pending while the
runtime asks the local user to approve the exact `Origin` header and scope. On approval:

```json
{
"origin": "https://course.example.com",
"token": "<origin-bound bearer token>"
"token": "<origin-bound bearer token>",
"scope": "actions"
}
```

Expand Down Expand Up @@ -241,6 +258,125 @@ Installs packages into the lab's virtual environment.
}
```

## Lab Actions

A lab repository declares the commands a page may run, in `lab.toml` at the repository root.
A page never sends a command: it names a repository and a full commit SHA, the user approves
that lab version once in the runtime's terminal, and from then on the page starts actions by
name. Commands run without a shell, in a checkout of that commit, with the lab's virtual
environment first on `PATH` (so `python` and `pip` are the lab's own).

### lab.toml (schema 1)

```toml
schema = 1
id = "restate-durable-agent-demo" # letters, digits, . _ -; also names the lab's venv
title = "Durable agents with Restate"

[actions.setup]
label = "Prepare the lab"
description = "Start Restate in Docker and install the demo's packages."
steps = [
["docker", "compose", "up", "-d"],
["python", "-m", "pip", "install", "--quiet", "-r", "requirements.txt"],
]
timeout = 600 # seconds, 1-7200, default 900

[actions.stable]
label = "1.1 The extra model call"
steps = [["python", "demo.py", "--agent", "naive", "--model", "stable"]]
```

Every step is an argv list. Steps run in order, and the first one that exits non-zero ends the
action. Unknown keys are errors. Limits: 50 actions, 20 steps each, 100 arguments per step.

### Prepare a Lab

```
POST /labs/prepare
{"repo": "https://github.com/SciMigo/restate-durable-agent-demo",
"commit": "<40-character SHA>"}
```

- Checks the source: `https` URLs only (git's `GIT_ALLOW_PROTOCOL`), no credentials, full SHA.
- Fetches exactly that commit (`--depth 1`, no submodules, no credential prompts) into
`<runtime dir>/labs/<repo>/<sha[:12]>`, reusing an existing checkout.
- Reads `lab.toml`.
- If this origin has not approved this repo, commit and manifest before, it prompts in the
terminal, listing every command. The request stays pending until the user answers, like
pairing.
- Creates the lab's virtual environment on first use.

Response:

```json
{
"lab_id": "restate-durable-agent-demo",
"title": "Durable agents with Restate",
"repo": "https://github.com/SciMigo/restate-durable-agent-demo",
"commit": "<sha>",
"path": "/Users/me/.agent-runtime/labs/restate-durable-agent-demo-1a2b3c4d/faf512d42fb8",
"actions": [
{"name": "setup", "label": "Prepare the lab", "description": "...",
"steps": ["docker compose up -d", "python -m pip install --quiet -r requirements.txt"],
"timeout": 600}
]
}
```

Errors: `400` for an invalid source or manifest, `403` when declined, `404` when the commit has no
`lab.toml`, `502` when git cannot fetch the commit.

### Start an Action

```
POST /labs/runs
{"lab_id": "restate-durable-agent-demo", "commit": "<sha>", "action": "stable"}
```

Returns a run (below) with status `running`. `409` if another action of the same lab is still
running (labs often share ports), or if the lab has not been prepared since the runtime started.

### Read a Run

```
GET /labs/runs/{run_id}?offset=0
```

```json
{
"run_id": "...", "lab_id": "...", "commit": "...", "action": "stable",
"status": "running",
"exit_code": null,
"started_at": "2026-09-19T05:00:00+00:00", "ended_at": null,
"output": "$ python demo.py --agent naive --model stable\n== agent: naive ...\n",
"next_offset": 123,
"truncated": false
}
```

Poll with `offset=next_offset` to get only new output. `status` is `running`, `succeeded`,
`failed`, `stopped` or `timed_out`. Each step's command is echoed as `$ ...`. The runtime keeps
the last 1,000,000 characters of each run; `truncated` means the offset asked for was older than
that. A run is visible only to the origin that started it.

### Stop a Run

```
POST /labs/runs/{run_id}/stop
```

Sends SIGINT to the running step's process group, then SIGKILL if it has not exited after 8
seconds, and skips the remaining steps. Returns the run.

### List Runs

```
GET /labs/runs?lab_id=<optional>
```

This origin's runs, without output: a reloaded page can find a run that is still going.

## Output Types

### Stream Output
Expand Down
25 changes: 25 additions & 0 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,28 @@ listener serves the same app as the HTTP one: pairing and bearer tokens apply
unchanged. `agent-runtime tls remove` removes the trust setting, the keychain
entry and the files.

### 8. Pairing Scopes and Named Lab Actions

A pairing is either `code` (any Python, through kernels) or `actions` (only `/labs`). A page
that runs a lab's prepared commands should pair with `actions`. Then a compromised or malicious
page holding the token can run only commands the user has already approved.

An action runs only if:

- the page named a repository over `https` (no `ssh`, `file` or `ext::` transports, via
`GIT_ALLOW_PROTOCOL`), without credentials, and a full commit SHA;
- that commit's `lab.toml` declares the action. Commands are argv lists and never go through a
shell;
- the user approved that origin, repository, commit and manifest, in the terminal, after seeing
every command;
- no other action of the same lab is running. Actions have a timeout, and Stop kills the whole
process group.

What approval does and does not mean: it pins **which** commands run and **which code** they
run, down to the commit. It does not sandbox them. Approving `python demo.py` trusts
`demo.py` at that commit, with your permissions, like cloning the repository and running it
yourself. A new commit needs a new approval.

## Known Limitations

### 1. No Code Sandboxing
Expand Down Expand Up @@ -224,6 +246,9 @@ rm ~/.agent-runtime/paired_origins.json

# Untrust and delete the loopback HTTPS certificate
agent-runtime tls remove

# Forget every approved lab version (the next prepare asks again)
rm ~/.agent-runtime/labs/approvals.json
```

### Cleaning Up Environments
Expand Down
Loading
Loading