From 8853f686239627204568f59f8b95175203231d5c Mon Sep 17 00:00:00 2001 From: thinkinginmath Date: Sat, 19 Sep 2026 04:59:53 +0000 Subject: [PATCH] Named lab actions, and pairing scopes so a lab page cannot run arbitrary code A course page that only runs a lab's prepared commands no longer needs a token that can run any Python on the learner's machine. Pairing scopes - POST /pairing/request takes {"scope": "code" | "actions"}. The default stays "code", so existing clients keep working. The terminal prompt says what the scope allows. - "actions" tokens get 403 from /kernel and /cell; require_auth now means code scope, and require_lab_auth accepts both. Pairings saved before scopes existed, loopback origins and --no-pairing count as "code". Named lab actions (labs.py, api/labs.py) - A lab repository declares actions in lab.toml (schema 1): argv steps run without a shell, with a label, description and timeout. Unknown keys and anything out of range are errors. - POST /labs/prepare {repo, commit}: - accepts https URLs only (GIT_ALLOW_PROTOCOL), no credentials, full SHA; - fetches exactly that commit (--depth 1, no submodules, no credential prompt) into /labs//; - reads lab.toml; - once per origin + repo + commit + manifest hash, prompts in the terminal with every command, then creates the lab's venv. - POST /labs/runs starts an action, one run at a time per lab. Poll GET /labs/runs/{id}?offset for status and new output; POST .../stop sends SIGINT to the process group, then SIGKILL. GET /labs/runs lists this origin's runs; a run is visible only to the origin that started it. Output is kept to the last 1M characters, and shutdown stops and awaits running actions. - runtime/info advertises lab_actions and pairing_scopes. Verified end to end on Linux: - The branch runtime ran with pairing required, against SciMigo/restate-durable-agent-demo @ e7bdf44 (branch lab-toml, which adds its lab.toml). - Pairing as https://scimigo.com with scope "actions" worked, and /cell/run returned 403. - Prepare asked for approval once, listing all nine actions' commands, in 14.5 s; the second prepare did not ask. - setup ran docker compose up and pip install. 1.1 gave "model calls: 3 weather calls: 1", the same as the recorded run, and teardown removed the container. Tests: tests/test_labs.py covers the manifest rules, source rules, fetching a commit from a file:// repo (test-only protocol) and reusing it, approval once and a declined approval, and runs: success, a failing step ending the action, stop, one at a time, timeout, and other origins not seeing a run. It also covers scope enforcement and the scope in pairing requests. 167 passed; ruff, format and mypy clean. Docs: protocol.md (scopes, lab actions, lab.toml), integration.md (the flow for a lab page), security.md (what approving a lab does and does not mean: it pins which commands and which code run, it does not sandbox them), README, CLAUDE.md. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 2 + README.md | 15 + docs/integration.md | 38 +++ docs/protocol.md | 142 ++++++++- docs/security.md | 25 ++ src/agent_runtime/api/labs.py | 195 ++++++++++++ src/agent_runtime/api/pairing.py | 22 +- src/agent_runtime/auth.py | 66 +++- src/agent_runtime/config.py | 10 + src/agent_runtime/labs.py | 515 +++++++++++++++++++++++++++++++ src/agent_runtime/server.py | 9 +- tests/test_labs.py | 351 +++++++++++++++++++++ tests/test_pairing_api.py | 2 +- 13 files changed, 1366 insertions(+), 26 deletions(-) create mode 100644 src/agent_runtime/api/labs.py create mode 100644 src/agent_runtime/labs.py create mode 100644 tests/test_labs.py diff --git a/CLAUDE.md b/CLAUDE.md index e32c18a..f44f33b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md index e387ab4..e84c809 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/docs/integration.md b/docs/integration.md index 46f667e..a41df9d 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -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: diff --git a/docs/protocol.md b/docs/protocol.md index d4c8396..9117ccf 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -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 ``` @@ -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": "" + "token": "", + "scope": "actions" } ``` @@ -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 + `/labs//`, 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": "", + "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": "", "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= +``` + +This origin's runs, without output: a reloaded page can find a run that is still going. + ## Output Types ### Stream Output diff --git a/docs/security.md b/docs/security.md index d52ce20..9ad0990 100644 --- a/docs/security.md +++ b/docs/security.md @@ -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 @@ -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 diff --git a/src/agent_runtime/api/labs.py b/src/agent_runtime/api/labs.py new file mode 100644 index 0000000..8c3d574 --- /dev/null +++ b/src/agent_runtime/api/labs.py @@ -0,0 +1,195 @@ +"""Lab action endpoints: prepare an approved lab version, then start, read and stop its actions. + +These accept tokens of either scope. A site paired with "actions" can use only these; it +cannot reach /kernel or /cell. See agent_runtime.labs for the manifest and the approval rules. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import shlex + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, Field +from starlette.concurrency import run_in_threadpool + +from agent_runtime import labs +from agent_runtime.auth import require_lab_auth, terminal_prompt_lock +from agent_runtime.config import settings +from agent_runtime.envs import LAB_ID_PATTERN + +router = APIRouter() + + +class PrepareRequest(BaseModel): + repo: str = Field(max_length=500, description="Public repository URL (https)") + commit: str = Field(description="Full 40-character commit SHA") + + +class ActionInfo(BaseModel): + name: str + label: str + description: str + steps: list[str] + timeout: int + + +class PrepareResponse(BaseModel): + lab_id: str + title: str + repo: str + commit: str + path: str + actions: list[ActionInfo] + + +class RunRequest(BaseModel): + lab_id: str = Field(pattern=LAB_ID_PATTERN) + commit: str + action: str = Field(max_length=40) + + +class RunResponse(BaseModel): + run_id: str + lab_id: str + commit: str + action: str + status: str + exit_code: int | None + started_at: str + ended_at: str | None + output: str = "" + next_offset: int = 0 + truncated: bool = False + + +def _error(error: labs.LabError) -> HTTPException: + return HTTPException(status_code=error.status, detail=str(error)) + + +def _snapshot(run: labs.Run, offset: int | None) -> RunResponse: + output, next_offset, truncated = run.read(offset) if offset is not None else ("", 0, False) + return RunResponse( + run_id=run.run_id, + lab_id=run.lab_id, + commit=run.commit, + action=run.action, + status=run.status, + exit_code=run.exit_code, + started_at=run.started_at, + ended_at=run.ended_at, + output=output, + next_offset=next_offset, + truncated=truncated, + ) + + +@router.post("/prepare", response_model=PrepareResponse) +async def prepare_lab( + body: PrepareRequest, origin: str = Depends(require_lab_auth) +) -> PrepareResponse: + """Fetch the commit, read lab.toml, and ask the user to approve this lab version once. + + Like pairing, the request stays open while the terminal prompt waits for an answer. + """ + try: + labs.validate_source(body.repo, body.commit) + dest = labs.checkout_dir(labs.repo_key(body.repo), body.commit) + path = await run_in_threadpool(labs.fetch_commit, body.repo, body.commit, dest) + manifest_file = path / labs.MANIFEST_NAME + if not manifest_file.is_file(): + raise labs.LabError(f"no {labs.MANIFEST_NAME} at that commit", 404) + manifest = labs.parse_manifest(manifest_file.read_bytes()) + + if settings.require_pairing: + async with terminal_prompt_lock: + if not labs.is_approved(origin, body.repo, body.commit, manifest.sha256): + description = labs.describe_for_approval( + origin, body.repo, body.commit, manifest, path + ) + if not await run_in_threadpool(labs.approval_prompt, description): + raise labs.LabError("the lab was not approved", 403) + labs.record_approval(origin, body.repo, body.commit, manifest.sha256) + + bin_dir = await run_in_threadpool(labs.lab_env, manifest.lab_id) + except labs.LabError as error: + raise _error(error) from error + + labs.lab_manager.register( + labs.PreparedLab( + repo=body.repo, commit=body.commit, path=path, manifest=manifest, bin_dir=bin_dir + ) + ) + return PrepareResponse( + lab_id=manifest.lab_id, + title=manifest.title, + repo=body.repo, + commit=body.commit, + path=str(path), + actions=[ + ActionInfo( + name=a.name, + label=a.label, + description=a.description, + steps=[shlex.join(argv) for argv in a.steps], + timeout=a.timeout, + ) + for a in manifest.actions.values() + ], + ) + + +@router.post("/runs", response_model=RunResponse) +async def start_run(body: RunRequest, origin: str = Depends(require_lab_auth)) -> RunResponse: + """Start one of a prepared lab's actions. One run at a time per lab (409 otherwise).""" + try: + prepared = labs.lab_manager.lab(body.lab_id, body.commit) + if settings.require_pairing and not labs.is_approved( + origin, prepared.repo, prepared.commit, prepared.manifest.sha256 + ): + raise labs.LabError("this site has not been approved for this lab version", 403) + run = await labs.lab_manager.start(origin, prepared, body.action) + except labs.LabError as error: + raise _error(error) from error + return _snapshot(run, 0) + + +@router.get("/runs", response_model=list[RunResponse]) +async def list_runs( + lab_id: str | None = None, origin: str = Depends(require_lab_auth) +) -> list[RunResponse]: + """This site's runs, without output: lets a reloaded page find a run still going.""" + return [_snapshot(run, None) for run in labs.lab_manager.runs_for(origin, lab_id)] + + +@router.get("/runs/{run_id}", response_model=RunResponse) +async def read_run( + run_id: str, + offset: int = Query(0, ge=0, description="Return output from this character on"), + origin: str = Depends(require_lab_auth), +) -> RunResponse: + """Status plus output since `offset`; poll with the returned `next_offset`.""" + try: + run = labs.lab_manager.get_run(run_id, origin) + except labs.LabError as error: + raise _error(error) from error + return _snapshot(run, offset) + + +@router.post("/runs/{run_id}/stop", response_model=RunResponse) +async def stop_run( + run_id: str, + offset: int = Query(0, ge=0), + origin: str = Depends(require_lab_auth), +) -> RunResponse: + """SIGINT to the running step's process group; SIGKILL if it has not exited after a grace.""" + try: + run = labs.lab_manager.get_run(run_id, origin) + except labs.LabError as error: + raise _error(error) from error + await labs.lab_manager.stop(run) + if run._task is not None: + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(asyncio.shield(run._task), timeout=2) + return _snapshot(run, offset) diff --git a/src/agent_runtime/api/pairing.py b/src/agent_runtime/api/pairing.py index fabbb02..03608e2 100644 --- a/src/agent_runtime/api/pairing.py +++ b/src/agent_runtime/api/pairing.py @@ -1,16 +1,21 @@ """Browser-accessible pairing endpoint.""" -import asyncio +from typing import Literal from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel from starlette.concurrency import run_in_threadpool -from agent_runtime.auth import is_valid_web_origin, pairing_manager +from agent_runtime.auth import is_valid_web_origin, pairing_manager, terminal_prompt_lock from agent_runtime.config import settings router = APIRouter() -_prompt_lock = asyncio.Lock() + + +class PairingRequest(BaseModel): + """What the page asks for. "actions" is the least privilege a lab page needs.""" + + scope: Literal["code", "actions"] = "code" class PairingResponse(BaseModel): @@ -18,11 +23,13 @@ class PairingResponse(BaseModel): origin: str token: str + scope: str @router.post("/request", response_model=PairingResponse) -async def request_pairing(request: Request) -> PairingResponse: +async def request_pairing(request: Request, body: PairingRequest | None = None) -> PairingResponse: """Prompt the local operator to approve the browser's Origin header.""" + scope = (body or PairingRequest()).scope if not settings.require_pairing: raise HTTPException(status_code=409, detail="Pairing is disabled") @@ -33,12 +40,13 @@ async def request_pairing(request: Request) -> PairingResponse: detail="A valid HTTP(S) Origin header is required", ) - async with _prompt_lock: - pairing_code = pairing_manager.initiate_pairing(origin) + async with terminal_prompt_lock: + pairing_code = pairing_manager.initiate_pairing(origin, scope) approved = await run_in_threadpool( pairing_manager.prompt_for_pairing, origin, pairing_code, + scope, ) if not approved: pairing_manager.cancel_pairing(pairing_code) @@ -49,4 +57,4 @@ async def request_pairing(request: Request) -> PairingResponse: if token is None: raise HTTPException(status_code=410, detail="Pairing request expired") - return PairingResponse(origin=origin, token=token) + return PairingResponse(origin=origin, token=token, scope=scope) diff --git a/src/agent_runtime/auth.py b/src/agent_runtime/auth.py index 8ff5322..38d04a2 100644 --- a/src/agent_runtime/auth.py +++ b/src/agent_runtime/auth.py @@ -6,6 +6,7 @@ - Token-based authentication """ +import asyncio import hashlib import ipaddress import json @@ -18,6 +19,18 @@ from agent_runtime.config import settings +# What a paired origin may do. "code" runs any Python through kernels (/kernel, /cell) and +# includes lab actions; "actions" only runs the named actions of labs the user approved (/labs). +# Origins paired before scopes existed have "code". +SCOPES = ("code", "actions") +SCOPE_DESCRIPTIONS = { + "code": "run ANY Python code on this computer, with your permissions", + "actions": "run only the named actions of labs you approve one by one", +} + +# One approval prompt at a time in the terminal, for pairing and lab approvals alike. +terminal_prompt_lock = asyncio.Lock() + def _is_loopback_hostname(hostname: str) -> bool: if hostname == "localhost": @@ -99,18 +112,21 @@ def get_allowed_origins(self) -> list[str]: """ return sorted(self._paired_origins) - def initiate_pairing(self, origin: str) -> str: + def initiate_pairing(self, origin: str, scope: str = "code") -> str: """Start a pairing request for a new origin. Returns a pairing code that must be approved. """ if not is_valid_web_origin(origin): raise ValueError("Pairing requires a valid HTTP(S) Origin header") + if scope not in SCOPES: + raise ValueError(f"scope must be one of {', '.join(SCOPES)}") pairing_code = secrets.token_urlsafe(8) expires_at = datetime.utcnow() + timedelta(seconds=settings.pairing_timeout) self._pending_pairings[pairing_code] = { "origin": origin, + "scope": scope, "created_at": datetime.utcnow().isoformat(), "expires_at": expires_at.isoformat(), } @@ -120,7 +136,7 @@ def cancel_pairing(self, pairing_code: str) -> None: """Discard a pending pairing request.""" self._pending_pairings.pop(pairing_code, None) - def prompt_for_pairing(self, origin: str, pairing_code: str) -> bool: + def prompt_for_pairing(self, origin: str, pairing_code: str, scope: str = "code") -> bool: """Prompt user to approve pairing (CLI interaction). Returns True if approved. @@ -129,6 +145,7 @@ def prompt_for_pairing(self, origin: str, pairing_code: str) -> bool: print("New pairing request") print(f"{'=' * 50}") print(f"Origin: {origin}") + print(f"Access: {scope} - this site could {SCOPE_DESCRIPTIONS.get(scope, scope)}") print(f"Pairing code: {pairing_code}") print(f"{'=' * 50}") @@ -161,6 +178,7 @@ def approve_pairing(self, pairing_code: str) -> str | None: self._paired_origins[origin] = { "paired_at": datetime.utcnow().isoformat(), "token_hash": hashlib.sha256(token.encode()).hexdigest(), + "scope": pairing.get("scope", "code"), } self._tokens[token] = origin self._save_paired_origins() @@ -185,6 +203,10 @@ def validate_token(self, token: str, origin: str) -> bool: return False + def scope_of(self, origin: str) -> str: + """The access a paired origin was granted; "code" for pairings made before scopes.""" + return str(self._paired_origins.get(origin, {}).get("scope", "code")) + def revoke_origin(self, origin: str) -> bool: """Revoke pairing for an origin.""" if origin in self._paired_origins: @@ -205,21 +227,15 @@ def get_allowed_origins() -> list[str]: return pairing_manager.get_allowed_origins() -async def require_auth( - request: Request, - authorization: str | None = Header(None), -) -> str: - """Dependency to require authentication. - - Returns the validated origin. - """ +async def _authenticate(request: Request, authorization: str | None) -> tuple[str, str]: + """Return the request's origin and the scope it holds, or raise 401/403.""" origin = request.headers.get("origin", "") if not settings.require_pairing: - return origin + return origin, "code" if is_loopback_origin(origin): - return origin + return origin, "code" # Require authorization header if not authorization: @@ -243,4 +259,30 @@ async def require_auth( detail="Invalid or expired token", ) + return origin, pairing_manager.scope_of(origin) + + +async def require_auth( + request: Request, + authorization: str | None = Header(None), +) -> str: + """Dependency for code execution (kernels, cells): requires the "code" scope. + + Returns the validated origin. + """ + origin, scope = await _authenticate(request, authorization) + if scope != "code": + raise HTTPException( + status_code=403, + detail=f"This origin is paired for '{scope}' only; running code needs 'code'", + ) + return origin + + +async def require_lab_auth( + request: Request, + authorization: str | None = Header(None), +) -> str: + """Dependency for lab actions (/labs): either scope. Returns the validated origin.""" + origin, _scope = await _authenticate(request, authorization) return origin diff --git a/src/agent_runtime/config.py b/src/agent_runtime/config.py index 4d0e134..3134a2e 100644 --- a/src/agent_runtime/config.py +++ b/src/agent_runtime/config.py @@ -48,6 +48,11 @@ class Settings(BaseSettings): # Environment settings python_version: str | None = None # Use system Python if not specified + # Lab actions (labs.py) + lab_git_protocols: str = "https" # GIT_ALLOW_PROTOCOL for fetching lab repositories + lab_output_limit: int = 1_000_000 # characters of output kept per run + lab_stop_grace: float = 8.0 # seconds between SIGINT and SIGKILL when stopping a run + @property def envs_dir(self) -> Path: """Directory for virtual environments.""" @@ -58,6 +63,11 @@ def config_file(self) -> Path: """Path to the config file.""" return self.runtime_dir / "config.toml" + @property + def labs_dir(self) -> Path: + """Checkouts of lab repositories, and the record of approved lab versions.""" + return self.runtime_dir / "labs" + @property def tls_dir(self) -> Path: """Directory for the loopback TLS certificate and its private key.""" diff --git a/src/agent_runtime/labs.py b/src/agent_runtime/labs.py new file mode 100644 index 0000000..a51937d --- /dev/null +++ b/src/agent_runtime/labs.py @@ -0,0 +1,515 @@ +"""Named lab actions: run only the commands a lab repository declares, at an approved commit. + +A site paired with the "actions" scope cannot send code. It names a lab repository and a full +commit SHA; the runtime fetches that commit, reads its `lab.toml`, and shows the user every +command it declares. After the user approves that lab version once, the site may start any of +those actions by name, stop them, and read their output. Commands run without a shell, in the +lab's checkout, with the lab's own virtual environment first on PATH. + +`lab.toml`, schema 1: + + schema = 1 + id = "restate-durable-agent-demo" # lab id: letters, digits, . _ - + title = "Durable agents with Restate" + + [actions.setup] + label = "Prepare the lab" + description = "Start Restate in Docker; install the demo's packages." + steps = [ + ["docker", "compose", "up", "-d"], + ["python", "-m", "pip", "install", "--quiet", "-r", "requirements.txt"], + ] + timeout = 600 # seconds, default 900 + +Each step is an argv list. Steps run in order; the first one that fails ends the action. +""" + +from __future__ import annotations + +import asyncio +import codecs +import contextlib +import hashlib +import json +import os +import re +import secrets +import shlex +import shutil +import signal +import subprocess +import time +import tomllib +from collections.abc import Callable +from dataclasses import dataclass, field +from datetime import UTC, datetime +from pathlib import Path +from typing import Any +from urllib.parse import urlsplit + +from agent_runtime.config import settings +from agent_runtime.envs import LAB_ID_PATTERN + +MANIFEST_NAME = "lab.toml" +COMMIT_PATTERN = re.compile(r"^[0-9a-f]{40}$") +ACTION_NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,39}$") +DEFAULT_TIMEOUT = 900 +MAX_TIMEOUT = 7200 +MAX_ACTIONS = 50 +MAX_STEPS = 20 +MAX_ARGS = 100 +MAX_ARG_LENGTH = 4096 +GIT_TIMEOUT = 300 + + +class LabError(Exception): + """A lab request that cannot be served; `status` is the HTTP status to answer with.""" + + def __init__(self, message: str, status: int = 400) -> None: + super().__init__(message) + self.status = status + + +# -------------------------------------------------------------------------------------------- +# Manifest +# -------------------------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class Action: + name: str + label: str + description: str + steps: tuple[tuple[str, ...], ...] + timeout: int + + +@dataclass(frozen=True) +class Manifest: + lab_id: str + title: str + actions: dict[str, Action] + sha256: str + + +def _string(value: Any, where: str, limit: int, required: bool = True) -> str: + if value is None and not required: + return "" + if not isinstance(value, str) or (required and not value.strip()) or len(value) > limit: + raise LabError(f"{where} must be a non-empty string of at most {limit} characters") + return value + + +def _unknown_keys(table: dict[str, Any], allowed: set[str], where: str) -> None: + extra = sorted(set(table) - allowed) + if extra: + raise LabError(f"{where}: unknown key(s) {', '.join(extra)}") + + +def parse_manifest(data: bytes) -> Manifest: + """Parse and validate a lab.toml. Every rule is strict: a typo fails loudly.""" + try: + doc = tomllib.loads(data.decode("utf-8")) + except (UnicodeDecodeError, tomllib.TOMLDecodeError) as error: + raise LabError(f"{MANIFEST_NAME} is not valid TOML: {error}") from error + + _unknown_keys(doc, {"schema", "id", "title", "actions"}, MANIFEST_NAME) + if doc.get("schema") != 1: + raise LabError(f"{MANIFEST_NAME}: schema must be 1") + lab_id = _string(doc.get("id"), "id", 64) + if re.fullmatch(LAB_ID_PATTERN, lab_id) is None: + raise LabError("id must be 1-64 letters, digits, dots, underscores or hyphens") + title = _string(doc.get("title"), "title", 200) + + raw_actions = doc.get("actions") + if not isinstance(raw_actions, dict) or not raw_actions: + raise LabError(f"{MANIFEST_NAME}: declare at least one [actions.] table") + if len(raw_actions) > MAX_ACTIONS: + raise LabError(f"{MANIFEST_NAME}: at most {MAX_ACTIONS} actions") + + actions: dict[str, Action] = {} + for name, table in raw_actions.items(): + where = f"actions.{name}" + if ACTION_NAME_PATTERN.fullmatch(name) is None: + raise LabError(f"{where}: action names are lowercase letters, digits, _ and -") + if not isinstance(table, dict): + raise LabError(f"{where} must be a table") + _unknown_keys(table, {"label", "description", "steps", "timeout"}, where) + steps = table.get("steps") + if not isinstance(steps, list) or not 1 <= len(steps) <= MAX_STEPS: + raise LabError(f"{where}.steps must list 1-{MAX_STEPS} commands") + parsed_steps = [] + for i, argv in enumerate(steps): + if ( + not isinstance(argv, list) + or not 1 <= len(argv) <= MAX_ARGS + or not all(isinstance(a, str) and 0 < len(a) <= MAX_ARG_LENGTH for a in argv) + or any("\x00" in a for a in argv) + ): + raise LabError(f"{where}.steps[{i}] must be a list of non-empty strings") + parsed_steps.append(tuple(argv)) + timeout = table.get("timeout", DEFAULT_TIMEOUT) + valid_timeout = isinstance(timeout, int) and not isinstance(timeout, bool) + if not valid_timeout or not 1 <= timeout <= MAX_TIMEOUT: + raise LabError(f"{where}.timeout must be 1-{MAX_TIMEOUT} seconds") + actions[name] = Action( + name=name, + label=_string(table.get("label"), f"{where}.label", 120), + description=_string(table.get("description"), f"{where}.description", 1000, False), + steps=tuple(parsed_steps), + timeout=timeout, + ) + return Manifest(lab_id, title, actions, hashlib.sha256(data).hexdigest()) + + +# -------------------------------------------------------------------------------------------- +# Fetching a pinned commit +# -------------------------------------------------------------------------------------------- + + +def allowed_protocols() -> list[str]: + return [p for p in settings.lab_git_protocols.split(":") if p] + + +def validate_source(repo: str, commit: str) -> None: + """Accept only allowed-protocol URLs without credentials, and full commit SHAs.""" + if COMMIT_PATTERN.fullmatch(commit) is None: + raise LabError("commit must be a full 40-character lowercase SHA") + parts = urlsplit(repo) + if parts.scheme not in allowed_protocols(): + raise LabError(f"repo must be a {' or '.join(allowed_protocols())} URL") + if parts.username or parts.password or parts.query or parts.fragment: + raise LabError("repo must not contain credentials, a query or a fragment") + if parts.scheme != "file" and not parts.hostname: + raise LabError("repo must name a host") + + +def checkout_dir(lab_key: str, commit: str) -> Path: + return settings.labs_dir / lab_key / commit[:12] + + +def _git(args: list[str], cwd: Path | None = None) -> str: + env = dict( + os.environ, + GIT_ALLOW_PROTOCOL=settings.lab_git_protocols, # no ext::, ssh, or file smuggling + GIT_TERMINAL_PROMPT="0", # public repositories only; never ask for credentials + ) + try: + result = subprocess.run( + ["git", *args], + cwd=cwd, + env=env, + capture_output=True, + text=True, + timeout=GIT_TIMEOUT, + check=False, + ) + except FileNotFoundError as error: + raise LabError("git is not installed", 500) from error + except subprocess.TimeoutExpired as error: + raise LabError("git timed out fetching the lab", 504) from error + if result.returncode != 0: + raise LabError(f"git {args[0]} failed: {(result.stderr or result.stdout).strip()}", 502) + return result.stdout.strip() + + +def fetch_commit(repo: str, commit: str, dest: Path) -> Path: + """Check out exactly `commit` of `repo` into `dest`, reusing a finished checkout.""" + if (dest / ".git").is_dir(): + with contextlib.suppress(LabError): + if _git(["rev-parse", "HEAD"], cwd=dest) == commit: + return dest + partial = dest.with_name(dest.name + ".partial") + shutil.rmtree(partial, ignore_errors=True) + partial.mkdir(parents=True) + _git(["init", "--quiet"], cwd=partial) + _git(["fetch", "--quiet", "--depth", "1", "--no-tags", repo, commit], cwd=partial) + _git( + ["-c", "advice.detachedHead=false", "checkout", "--quiet", "--detach", "FETCH_HEAD"], + cwd=partial, + ) + if _git(["rev-parse", "HEAD"], cwd=partial) != commit: + raise LabError("the fetched commit does not match the requested SHA", 502) + shutil.rmtree(dest, ignore_errors=True) + partial.rename(dest) + return dest + + +def repo_key(repo: str) -> str: + """A directory name for a repository URL: readable, and unique per URL.""" + last = urlsplit(repo).path.rstrip("/").rsplit("/", 1)[-1].removesuffix(".git") or "lab" + safe = re.sub(r"[^A-Za-z0-9._-]", "-", last)[:40] + return f"{safe}-{hashlib.sha256(repo.encode()).hexdigest()[:8]}" + + +# -------------------------------------------------------------------------------------------- +# Approvals +# -------------------------------------------------------------------------------------------- + + +def _approvals_file() -> Path: + return settings.labs_dir / "approvals.json" + + +def _approval_key(origin: str, repo: str, commit: str) -> str: + return f"{origin} {repo} {commit}" + + +def is_approved(origin: str, repo: str, commit: str, manifest_sha256: str) -> bool: + try: + approvals = json.loads(_approvals_file().read_text()) + except (OSError, ValueError): + return False + entry = approvals.get(_approval_key(origin, repo, commit)) + return isinstance(entry, dict) and entry.get("manifest_sha256") == manifest_sha256 + + +def record_approval(origin: str, repo: str, commit: str, manifest_sha256: str) -> None: + path = _approvals_file() + try: + approvals = json.loads(path.read_text()) + except (OSError, ValueError): + approvals = {} + approvals[_approval_key(origin, repo, commit)] = { + "manifest_sha256": manifest_sha256, + "approved_at": datetime.now(UTC).isoformat(), + } + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(approvals, indent=2)) + + +def describe_for_approval(origin: str, repo: str, commit: str, lab: Manifest, path: Path) -> str: + lines = [ + "", + "=" * 60, + "Lab approval request", + "=" * 60, + f"Site: {origin}", + f"Lab: {lab.lab_id} - {lab.title}", + f"Source: {repo}", + f" commit {commit}", + f"Files: {path}", + "It may run these commands, each only when you start that action on the page:", + ] + for action in lab.actions.values(): + lines.append(f" {action.name}: {action.label}") + lines += [f" $ {shlex.join(argv)}" for argv in action.steps] + lines.append("=" * 60) + return "\n".join(lines) + + +def prompt_for_approval(description: str) -> bool: + print(description) + try: + return input("Approve this lab? [y/N]: ").strip().lower() in ("y", "yes") + except (EOFError, KeyboardInterrupt): + return False + + +# -------------------------------------------------------------------------------------------- +# Prepared labs and runs +# -------------------------------------------------------------------------------------------- + + +@dataclass +class PreparedLab: + repo: str + commit: str + path: Path + manifest: Manifest + bin_dir: Path # the lab environment's executables, first on PATH + + +def ensure_lab_env(lab_id: str) -> Path: + """The lab's virtual environment (shared with its kernels), created on first use.""" + from agent_runtime.envs import env_manager + + venv = env_manager.get_env_path(lab_id) or env_manager.create_env(lab_id) + return venv / "bin" + + +@dataclass +class Run: + run_id: str + origin: str + lab_id: str + commit: str + action: str + started_at: str + status: str = "running" # running | succeeded | failed | stopped | timed_out + exit_code: int | None = None + ended_at: str | None = None + _chunks: list[str] = field(default_factory=list) + _base: int = 0 # characters dropped from the front of the output + _length: int = 0 + _process: asyncio.subprocess.Process | None = None + _stop_reason: str | None = None + _task: asyncio.Task[None] | None = None + + def append(self, text: str) -> None: + self._chunks.append(text) + self._length += len(text) + limit = settings.lab_output_limit + if self._length > limit: + joined = "".join(self._chunks) + drop = len(joined) - limit + self._chunks = [joined[drop:]] + self._base += drop + self._length = limit + + def read(self, offset: int) -> tuple[str, int, bool]: + """Output from `offset` on, the next offset, and whether earlier output was dropped.""" + text = "".join(self._chunks) + truncated = offset < self._base + start = max(offset - self._base, 0) + return text[start:], self._base + len(text), truncated + + +def _now() -> str: + return datetime.now(UTC).isoformat() + + +def _signal_group(process: asyncio.subprocess.Process, sig: int) -> None: + with contextlib.suppress(ProcessLookupError, PermissionError): + os.killpg(process.pid, sig) + + +class LabManager: + """Prepared labs, and the runs of their actions. One run at a time per lab.""" + + def __init__(self) -> None: + self._labs: dict[tuple[str, str], PreparedLab] = {} # (lab_id, commit) + self._runs: dict[str, Run] = {} + self._active: dict[str, str] = {} # lab_id -> run_id + + def register(self, lab: PreparedLab) -> None: + self._labs[(lab.manifest.lab_id, lab.commit)] = lab + + def lab(self, lab_id: str, commit: str) -> PreparedLab: + prepared = self._labs.get((lab_id, commit)) + if prepared is None: + raise LabError("prepare this lab and commit first (POST /labs/prepare)", 409) + return prepared + + def get_run(self, run_id: str, origin: str) -> Run: + run = self._runs.get(run_id) + if run is None or run.origin != origin: + raise LabError("no such run", 404) + return run + + def runs_for(self, origin: str, lab_id: str | None = None) -> list[Run]: + return [r for r in self._runs.values() if r.origin == origin and lab_id in (None, r.lab_id)] + + async def start(self, origin: str, lab: PreparedLab, action_name: str) -> Run: + action = lab.manifest.actions.get(action_name) + if action is None: + raise LabError(f"lab {lab.manifest.lab_id} has no action '{action_name}'", 404) + lab_id = lab.manifest.lab_id + active = self._runs.get(self._active.get(lab_id, "")) + if active and active.status == "running": + raise LabError( + f"'{active.action}' is still running in this lab; stop it or wait for it", 409 + ) + run = Run( + run_id=secrets.token_urlsafe(12), + origin=origin, + lab_id=lab_id, + commit=lab.commit, + action=action.name, + started_at=_now(), + ) + self._runs[run.run_id] = run + self._active[lab_id] = run.run_id + run._task = asyncio.create_task(self._execute(run, lab, action)) + return run + + async def stop(self, run: Run, reason: str = "stopped") -> None: + """SIGINT to the step's process group, then SIGKILL after the grace period.""" + if run.status != "running": + return + run._stop_reason = run._stop_reason or reason + process = run._process + if process is None or process.returncode is not None: + return + _signal_group(process, signal.SIGINT) + try: + await asyncio.wait_for(process.wait(), timeout=settings.lab_stop_grace) + except TimeoutError: + _signal_group(process, signal.SIGKILL) + + async def _execute(self, run: Run, lab: PreparedLab, action: Action) -> None: + env = dict(os.environ) + env.pop("PYTHONHOME", None) + env["PATH"] = f"{lab.bin_dir}{os.pathsep}{env.get('PATH', '')}" + env["VIRTUAL_ENV"] = str(lab.bin_dir.parent) + env["PYTHONUNBUFFERED"] = "1" + deadline = time.monotonic() + action.timeout + watchdog = asyncio.create_task(self._watch(run, deadline)) + try: + for argv in action.steps: + if run._stop_reason: + break + run.append(f"$ {shlex.join(argv)}\n") + code = await self._step(run, lab, argv, env) + if run._stop_reason or code != 0: + run.exit_code = code + break + else: + run.exit_code = 0 + except Exception as error: # noqa: BLE001 - a run must always end with a status + run.append(f"\n[runtime] {error}\n") + run.exit_code = run.exit_code if run.exit_code is not None else 1 + finally: + watchdog.cancel() + if run._stop_reason: + run.status = run._stop_reason + else: + run.status = "succeeded" if run.exit_code == 0 else "failed" + run.ended_at = _now() + run._process = None + + async def _step( + self, run: Run, lab: PreparedLab, argv: tuple[str, ...], env: dict[str, str] + ) -> int: + executable = shutil.which(argv[0], path=env["PATH"]) if "/" not in argv[0] else argv[0] + if executable is None: + run.append(f"[runtime] command not found: {argv[0]}\n") + return 127 + process = await asyncio.create_subprocess_exec( + executable, + *argv[1:], + cwd=lab.path, + env=env, + stdin=asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + start_new_session=True, # its own process group, so stop reaches its children + ) + run._process = process + assert process.stdout is not None + decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") # chunks split characters + while chunk := await process.stdout.read(4096): + run.append(decoder.decode(chunk)) + run.append(decoder.decode(b"", final=True)) + return await process.wait() + + async def _watch(self, run: Run, deadline: float) -> None: + await asyncio.sleep(max(deadline - time.monotonic(), 0)) + run.append("\n[runtime] timed out\n") + await self.stop(run, reason="timed_out") + + async def shutdown(self) -> None: + """Stop every running action and wait for it to finish, so no process outlives us.""" + running = [run for run in self._runs.values() if run.status == "running"] + for run in running: + await self.stop(run) + tasks = [run._task for run in running if run._task is not None] + if tasks: + await asyncio.wait(tasks, timeout=settings.lab_stop_grace + 2) + + +lab_manager = LabManager() + +# Seams for tests: the prompt and the environment can be replaced. +approval_prompt: Callable[[str], bool] = prompt_for_approval +lab_env: Callable[[str], Path] = ensure_lab_env diff --git a/src/agent_runtime/server.py b/src/agent_runtime/server.py index 68a0481..83b5258 100644 --- a/src/agent_runtime/server.py +++ b/src/agent_runtime/server.py @@ -7,7 +7,8 @@ from fastapi.middleware.cors import CORSMiddleware from agent_runtime import __protocol_version__, __version__ -from agent_runtime.api import execute, health, kernel, pairing +from agent_runtime import labs as lab_runtime +from agent_runtime.api import execute, health, kernel, labs, pairing from agent_runtime.config import settings from agent_runtime.kernels.manager import kernel_manager @@ -17,7 +18,8 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: """Manage application lifecycle.""" # Startup yield - # Shutdown - cleanup all kernels + # Shutdown - stop running lab actions, then clean up all kernels + await lab_runtime.lab_manager.shutdown() await kernel_manager.shutdown_all() @@ -57,6 +59,7 @@ async def private_network_access( app.include_router(pairing.router, prefix="/pairing", tags=["pairing"]) app.include_router(kernel.router, prefix="/kernel", tags=["kernel"]) app.include_router(execute.router, tags=["execute"]) +app.include_router(labs.router, prefix="/labs", tags=["labs"]) @app.get("/runtime/info") @@ -65,7 +68,7 @@ async def runtime_info() -> dict[str, object]: return { "runtime_version": __version__, "protocol_version": __protocol_version__, - "capabilities": ["python", "jupyter", "local_fs"], + "capabilities": ["python", "jupyter", "local_fs", "lab_actions", "pairing_scopes"], } diff --git a/tests/test_labs.py b/tests/test_labs.py new file mode 100644 index 0000000..dc4bb6e --- /dev/null +++ b/tests/test_labs.py @@ -0,0 +1,351 @@ +"""Tests for named lab actions (agent_runtime.labs, /labs) and pairing scopes.""" + +import subprocess +import sys +import time +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from fastapi import HTTPException +from fastapi.testclient import TestClient + +from agent_runtime import labs +from agent_runtime.auth import PairingManager, require_auth, require_lab_auth +from agent_runtime.config import Settings +from agent_runtime.server import app + +SITE = "https://scimigo.com" + +MANIFEST = b""" +schema = 1 +id = "demo-lab" +title = "Demo lab" + +[actions.hello] +label = "Say hello" +description = "Prints one line." +steps = [["python", "-c", "print('hello from the lab')"]] + +[actions.two] +label = "Second step fails" +steps = [ + ["python", "-c", "print('first')"], + ["python", "-c", "import sys; sys.exit(3)"], + ["python", "-c", "print('never')"], +] + +[actions.slow] +label = "Counts slowly" +steps = [[ + "python", "-c", + "import time\\nfor i in range(400):\\n print(i, flush=True)\\n time.sleep(0.05)", +]] + +[actions.hangs] +label = "Times out" +timeout = 1 +steps = [["python", "-c", "import time; time.sleep(30)"]] +""" + + +def _git(cwd: Path, *args: str) -> str: + return subprocess.run( + ["git", "-c", "user.name=t", "-c", "user.email=t@example.com", *args], + cwd=cwd, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + +@pytest.fixture +def lab_repo(tmp_path: Path) -> tuple[str, str]: + """A repository with a lab.toml, served over file:// (allowed only in these tests).""" + repo = tmp_path / "lab-repo" + repo.mkdir() + _git(repo, "init", "--quiet") + _git(repo, "config", "uploadpack.allowReachableSHA1InWant", "true") + (repo / "lab.toml").write_bytes(MANIFEST) + _git(repo, "add", "lab.toml") + _git(repo, "commit", "--quiet", "-m", "lab") + return repo.as_uri(), _git(repo, "rev-parse", "HEAD") + + +@pytest.fixture +def lab_settings(tmp_path: Path): + test_settings = Settings( + runtime_dir=tmp_path / "runtime", + require_pairing=True, + lab_git_protocols="file", + lab_stop_grace=2.0, + ) + with ( + patch("agent_runtime.labs.settings", test_settings), + patch("agent_runtime.api.labs.settings", test_settings), + ): + yield test_settings + + +@pytest.fixture +def client(lab_settings): + """The real app, with /labs authenticated as SITE and the lab env = this Python.""" + prompts: list[str] = [] + + def approve(description: str) -> bool: + prompts.append(description) + return True + + app.dependency_overrides[require_lab_auth] = lambda: SITE + with ( + patch.object(labs, "lab_env", lambda lab_id: Path(sys.executable).parent), + patch.object(labs, "approval_prompt", approve), + patch.object(labs, "lab_manager", labs.LabManager()), + TestClient(app) as test_client, + ): + test_client.prompts = prompts # type: ignore[attr-defined] + yield test_client + app.dependency_overrides.clear() + + +def _wait(client: TestClient, run_id: str, timeout: float = 30) -> dict: + deadline = time.time() + timeout + while time.time() < deadline: + run = client.get(f"/labs/runs/{run_id}").json() + if run["status"] != "running": + return run + time.sleep(0.05) + raise AssertionError(f"run {run_id} still running") + + +def _prepare(client: TestClient, lab_repo: tuple[str, str]) -> dict: + repo, commit = lab_repo + response = client.post("/labs/prepare", json={"repo": repo, "commit": commit}) + assert response.status_code == 200, response.text + return response.json() + + +def _start(client: TestClient, lab: dict, action: str): + return client.post( + "/labs/runs", json={"lab_id": lab["lab_id"], "commit": lab["commit"], "action": action} + ) + + +HEAD = 'schema = 1\nid = "a"\ntitle = "t"\n' +ACTION = '[actions.x]\nlabel = "x"\n' +STEPS = 'steps = [["a"]]' + + +class TestManifest: + def test_valid(self): + manifest = labs.parse_manifest(MANIFEST) + assert manifest.lab_id == "demo-lab" + assert list(manifest.actions) == ["hello", "two", "slow", "hangs"] + assert manifest.actions["two"].steps[1] == ("python", "-c", "import sys; sys.exit(3)") + assert manifest.actions["hello"].timeout == labs.DEFAULT_TIMEOUT + assert manifest.actions["hangs"].timeout == 1 + + @pytest.mark.parametrize( + "text, message", + [ + (HEAD.replace("schema = 1", "schema = 2") + ACTION + STEPS, "schema"), + (HEAD.replace('id = "a"', 'id = "../x"') + ACTION + STEPS, "id"), + (HEAD, "at least one"), + (HEAD + ACTION.replace("actions.x", "actions.Bad") + STEPS, "action names"), + (HEAD + ACTION + "steps = []", "steps"), + (HEAD + ACTION + 'steps = [["a", 1]]', "steps[0]"), + (HEAD + ACTION + 'command = "ls"\n' + STEPS, "unknown key"), + (HEAD + ACTION + STEPS + "\ntimeout = 0", "timeout"), + (HEAD + ACTION + STEPS + "\ntimeout = true", "timeout"), + (HEAD + "shell = true\n" + ACTION + STEPS, "unknown key"), + ("not = toml = at all", "not valid TOML"), + ], + ) + def test_rejects(self, text: str, message: str): + with pytest.raises(labs.LabError, match=message.replace("[", r"\[")): + labs.parse_manifest(text.encode()) + + +class TestSource: + SHA = "0123456789abcdef0123456789abcdef01234567" + + @pytest.mark.parametrize( + "repo", + [ + "ssh://github.com/SciMigo/x.git", + "git@github.com:SciMigo/x.git", + "ext::sh -c touch% /tmp/pwned", + "file:///tmp/x", + "http://github.com/SciMigo/x", + "https://user:secret@github.com/SciMigo/x", + "https://github.com/SciMigo/x?ref=main", + ], + ) + def test_rejects_other_protocols_and_credentials(self, repo: str): + with pytest.raises(labs.LabError): + labs.validate_source(repo, self.SHA) + + def test_accepts_https_and_requires_a_full_sha(self): + labs.validate_source("https://github.com/SciMigo/restate-durable-agent-demo", self.SHA) + with pytest.raises(labs.LabError, match="full 40-character"): + labs.validate_source("https://github.com/SciMigo/x", "faf512d") + + def test_fetches_exactly_the_commit_and_reuses_it(self, lab_settings, lab_repo, tmp_path): + repo, commit = lab_repo + dest = tmp_path / "checkout" + labs.fetch_commit(repo, commit, dest) + assert _git(dest, "rev-parse", "HEAD") == commit + assert (dest / "lab.toml").read_bytes() == MANIFEST + marker = dest / "learner-notes.txt" + marker.write_text("kept") + labs.fetch_commit(repo, commit, dest) # already there: not fetched again + assert marker.read_text() == "kept" + + def test_unknown_commit_fails(self, lab_settings, lab_repo, tmp_path): + repo, _ = lab_repo + with pytest.raises(labs.LabError) as error: + labs.fetch_commit(repo, "f" * 40, tmp_path / "checkout") + assert error.value.status == 502 + + +class TestPrepare: + def test_asks_once_per_lab_version(self, client, lab_repo): + lab = _prepare(client, lab_repo) + assert lab["lab_id"] == "demo-lab" and lab["commit"] == lab_repo[1] + assert [a["name"] for a in lab["actions"]] == ["hello", "two", "slow", "hangs"] + assert len(client.prompts) == 1 + prompt = client.prompts[0] + assert SITE in prompt and lab_repo[1] in prompt + assert "$ python -c 'print('\"'\"'hello from the lab'\"'\"')'" in prompt + + _prepare(client, lab_repo) + assert len(client.prompts) == 1 # approved already: no second prompt + + def test_declined_lab_cannot_run(self, client, lab_repo): + repo, commit = lab_repo + with patch.object(labs, "approval_prompt", lambda description: False): + declined = client.post("/labs/prepare", json={"repo": repo, "commit": commit}) + assert declined.status_code == 403 + started = client.post( + "/labs/runs", json={"lab_id": "demo-lab", "commit": commit, "action": "hello"} + ) + assert started.status_code == 409 # never prepared + + def test_missing_manifest(self, client, lab_settings, tmp_path): + repo = tmp_path / "empty" + repo.mkdir() + _git(repo, "init", "--quiet") + _git(repo, "config", "uploadpack.allowReachableSHA1InWant", "true") + (repo / "README").write_text("no lab here") + _git(repo, "add", "README") + _git(repo, "commit", "--quiet", "-m", "x") + response = client.post( + "/labs/prepare", json={"repo": repo.as_uri(), "commit": _git(repo, "rev-parse", "HEAD")} + ) + assert response.status_code == 404 + + +class TestRuns: + def test_runs_steps_in_order_and_reads_output_by_offset(self, client, lab_repo): + lab = _prepare(client, lab_repo) + run = _start(client, lab, "hello").json() + done = _wait(client, run["run_id"]) + assert done["status"] == "succeeded" and done["exit_code"] == 0 + assert done["output"].startswith("$ python -c") + assert "hello from the lab\n" in done["output"] + later = client.get(f"/labs/runs/{run['run_id']}", params={"offset": done["next_offset"]}) + assert later.json()["output"] == "" + + def test_first_failing_step_ends_the_action(self, client, lab_repo): + lab = _prepare(client, lab_repo) + done = _wait(client, _start(client, lab, "two").json()["run_id"]) + assert done["status"] == "failed" and done["exit_code"] == 3 + assert "first" in done["output"] and "never" not in done["output"] + + def test_stop_and_one_run_at_a_time(self, client, lab_repo): + lab = _prepare(client, lab_repo) + run = _start(client, lab, "slow").json() + busy = _start(client, lab, "hello") + assert busy.status_code == 409 + time.sleep(0.5) + stopped = client.post(f"/labs/runs/{run['run_id']}/stop").json() + assert stopped["status"] == "stopped" + assert "399" not in stopped["output"] + assert _start(client, lab, "hello").status_code == 200 # the lab is free again + + def test_timeout(self, client, lab_repo): + lab = _prepare(client, lab_repo) + done = _wait(client, _start(client, lab, "hangs").json()["run_id"]) + assert done["status"] == "timed_out" and "timed out" in done["output"] + + def test_unknown_action(self, client, lab_repo): + lab = _prepare(client, lab_repo) + assert _start(client, lab, "rm-rf").status_code == 404 + + def test_other_sites_cannot_see_or_stop_a_run(self, client, lab_repo): + lab = _prepare(client, lab_repo) + run = _start(client, lab, "hello").json() + app.dependency_overrides[require_lab_auth] = lambda: "https://evil.example" + assert client.get(f"/labs/runs/{run['run_id']}").status_code == 404 + assert client.post(f"/labs/runs/{run['run_id']}/stop").status_code == 404 + assert client.get("/labs/runs").json() == [] + app.dependency_overrides[require_lab_auth] = lambda: SITE + assert [r["run_id"] for r in client.get("/labs/runs").json()] == [run["run_id"]] + + +class TestScopes: + def _request(self, origin: str) -> MagicMock: + request = MagicMock() + request.headers = {"origin": origin} + return request + + def _paired(self, mock_settings, scope: str) -> tuple[PairingManager, str]: + manager = PairingManager() + code = manager.initiate_pairing(SITE, scope) + token = manager.approve_pairing(code) + assert token is not None + return manager, token + + async def test_actions_scope_cannot_run_code(self, mock_settings): + mock_settings.require_pairing = True + manager, token = self._paired(mock_settings, "actions") + with patch("agent_runtime.auth.pairing_manager", manager): + assert await require_lab_auth(self._request(SITE), f"Bearer {token}") == SITE + with pytest.raises(HTTPException) as error: + await require_auth(self._request(SITE), f"Bearer {token}") + assert error.value.status_code == 403 and "actions" in error.value.detail + + async def test_code_scope_and_old_pairings_can_do_both(self, mock_settings): + mock_settings.require_pairing = True + manager, token = self._paired(mock_settings, "code") + del manager._paired_origins[SITE]["scope"] # as saved before scopes existed + with patch("agent_runtime.auth.pairing_manager", manager): + assert await require_auth(self._request(SITE), f"Bearer {token}") == SITE + assert await require_lab_auth(self._request(SITE), f"Bearer {token}") == SITE + + def test_unknown_scope_is_refused(self, mock_settings): + with pytest.raises(ValueError, match="scope"): + PairingManager().initiate_pairing(SITE, "root") + + def test_pairing_request_passes_the_scope_to_the_prompt(self): + with ( + patch("agent_runtime.api.pairing.settings.require_pairing", True), + patch( + "agent_runtime.api.pairing.pairing_manager.initiate_pairing", return_value="c" + ) as initiate, + patch( + "agent_runtime.api.pairing.pairing_manager.prompt_for_pairing", return_value=True + ) as prompt, + patch("agent_runtime.api.pairing.pairing_manager.approve_pairing", return_value="t"), + TestClient(app) as test_client, + ): + ok = test_client.post( + "/pairing/request", json={"scope": "actions"}, headers={"Origin": SITE} + ) + bad = test_client.post( + "/pairing/request", json={"scope": "root"}, headers={"Origin": SITE} + ) + assert ok.json() == {"origin": SITE, "token": "t", "scope": "actions"} + assert initiate.call_args.args == (SITE, "actions") + assert prompt.call_args.args[2] == "actions" + assert bad.status_code == 422 diff --git a/tests/test_pairing_api.py b/tests/test_pairing_api.py index 7eaa4ac..d8b8cbe 100644 --- a/tests/test_pairing_api.py +++ b/tests/test_pairing_api.py @@ -21,7 +21,7 @@ def test_browser_pairing_returns_token_only_after_approval(): ) assert response.status_code == 200 - assert response.json() == {"origin": "https://scimigo.com", "token": "token"} + assert response.json() == {"origin": "https://scimigo.com", "token": "token", "scope": "code"} def test_browser_pairing_rejects_missing_origin():