diff --git a/.env b/.env new file mode 100644 index 0000000..7bfa5a2 --- /dev/null +++ b/.env @@ -0,0 +1,11 @@ +RECERTIA_MODEL_PROVIDER=openai +RECERTIA_MODEL_ID=deepseek/deepseek-v4-flash-0731 +OPENAI_API_KEY=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 +RECERTIA_OPENAI_BASE_URL=https://openrouter.ai/api/v1/chat/completions +RECERTIA_OPENAI_HTTP_REFERER=https://github.com/quantrobs/fandea +RECERTIA_OPENAI_TITLE=Recertia +RECERTIA_OPENAI_EXTRA_BODY={"temperature":0.2,"max_tokens":1024} +RECERTIA_MODEL_PRICE_OPENAI_DEEPSEEK_DEEPSEEK_V4_FLASH_0731_IN=0.09 +RECERTIA_MODEL_PRICE_OPENAI_DEEPSEEK_DEEPSEEK_V4_FLASH_0731_OUT=0.18 +RECERTIA_EXECUTION_BACKEND=local +RECERTIA_API_ALLOW_LOCAL_EXEC=1 \ No newline at end of file diff --git a/README.md b/README.md index f521f16..f31883a 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,9 @@ Details: [`docs/architecture/go-live.md`](docs/architecture/go-live.md), | [`docs/architecture/production-readiness.md`](docs/architecture/production-readiness.md) | Phase-4 multi-tenant readiness gate checklist | | [`docs/architecture/product-console.md`](docs/architecture/product-console.md) | Product console (Pilot / Tower) architecture | | [`docs/specifications/product-console.md`](docs/specifications/product-console.md) | Console HTTP, SSE events, UX, and conformance tests | +| [`docs/specifications/registered-workspaces.md`](docs/specifications/registered-workspaces.md) | Registered host workspaces (Windows); Pilot workdir bind | | [`docs/implementation-plan-console.md`](docs/implementation-plan-console.md) | Console milestones C0–C5 | +| [`docs/implementation-plan-registered-workspaces.md`](docs/implementation-plan-registered-workspaces.md) | Registered workspaces milestones RW0–RW2 | | [`docs/adr/0012-product-console-surfaces.md`](docs/adr/0012-product-console-surfaces.md) | ADR: console as control plane over headless Recertia | | [`docs/specifications/`](docs/specifications/core-entities.md) | Data model, graph state, node contracts, retrieval/validation/distillation specs, failure taxonomy, capacity and retirement, concurrency and merge contracts, HTTP/CLI surface, metrics | | [`docs/specifications/goal-objects.md`](docs/specifications/goal-objects.md) | Goal as primary input (Variant B) | diff --git a/console/static/app.js b/console/static/app.js index 38fd461..9befd96 100644 --- a/console/static/app.js +++ b/console/static/app.js @@ -10,6 +10,61 @@ const state = { function apiKey() { return $("#apiKey").value.trim(); } function tenantHeader() { return $("#tenantHeader").value.trim(); } +function looksAbsolutePath(s) { + const v = (s || "").trim(); + if (!v) return false; + if (v.startsWith("/") || v.startsWith("\\")) return true; + if (/^[A-Za-z]:[\\/]/.test(v)) return true; + if (v.startsWith("//") || v.startsWith("\\\\")) return true; + return false; +} + +function selectedWorkspaceId() { + return ($("#workspaceSelect") && $("#workspaceSelect").value.trim()) || ""; +} + +function workspaceSubpath() { + return ($("#workspaceSubpath") && $("#workspaceSubpath").value.trim()) || ""; +} + +function buildRunSubmitBody(goal, mode) { + const body = { + goal, + task_class: goal.task_class || "repo-chore", + mode, + budget: { max_attempts: 2 }, + }; + const ws = selectedWorkspaceId(); + if (ws) { + body.workspace_id = ws; + body.workdir = workspaceSubpath(); + } + return body; +} + +async function loadWorkspaces() { + const sel = $("#workspaceSelect"); + if (!sel) return; + const current = sel.value; + try { + const data = await api("/v1/workspaces"); + const enabled = (data.workspaces || []).filter((w) => w.enabled !== false); + sel.innerHTML = ``; + for (const w of enabled) { + const opt = document.createElement("option"); + opt.value = w.workspace_id; + opt.textContent = `${w.display_name} (${w.workspace_id}) — ${w.host_root}`; + sel.appendChild(opt); + } + if (current && [...sel.options].some((o) => o.value === current)) sel.value = current; + if ($("#workspacesOut")) { + $("#workspacesOut").textContent = JSON.stringify(data.workspaces || [], null, 2); + } + } catch (e) { + if ($("#workspacesOut")) $("#workspacesOut").textContent = String(e); + } +} + async function api(path, opts = {}) { const headers = Object.assign({ "content-type": "application/json" }, opts.headers || {}); if (apiKey()) headers["X-API-Key"] = apiKey(); @@ -32,6 +87,9 @@ function showView(name) { document.querySelectorAll(".nav button[data-view]").forEach((el) => el.classList.remove("active")); $(`#view-${name}`).classList.add("active"); document.querySelector(`.nav button[data-view="${name}"]`).classList.add("active"); + if (name === "pilot" || name === "auth" || name === "programs") { + loadWorkspaces().catch(() => {}); + } } document.querySelectorAll(".nav button[data-view]").forEach((btn) => { @@ -47,7 +105,10 @@ function setPilotMode(mode) { } document.querySelectorAll("[data-pilot-mode]").forEach((btn) => { - btn.addEventListener("click", () => setPilotMode(btn.dataset.pilotMode)); + btn.addEventListener("click", () => { + setPilotMode(btn.dataset.pilotMode); + if (btn.dataset.pilotMode === "run") loadWorkspaces().catch(() => {}); + }); }); function desiredValue(prefill) { @@ -322,9 +383,16 @@ $("#previewGoal").onclick = async () => { $("#submitRun").onclick = async () => { try { + const sub = workspaceSubpath(); + if (looksAbsolutePath(sub)) { + throw new Error("Subpath must be relative to the registered workspace (absolute paths rejected)"); + } + if (!selectedWorkspaceId() && looksAbsolutePath(sub)) { + throw new Error("Sandbox workdir must be relative"); + } const goal = buildGoal(); const mode = $("#runMode").value; - const body = { goal, task_class: goal.task_class, mode, budget: { max_attempts: 2 } }; + const body = buildRunSubmitBody(goal, mode); const res = await fetch("/v1/runs", { method: "POST", headers: { @@ -529,8 +597,32 @@ $("#doSwitch").onclick = async () => { $("#authOut").textContent = JSON.stringify(d, null, 2); }; +$("#refreshWorkspaces").onclick = () => loadWorkspaces().catch((e) => { + $("#workspacesOut").textContent = String(e); +}); +$("#registerWorkspace").onclick = async () => { + try { + const host = $("#wsHostRoot").value.trim(); + if (!host) throw new Error("host_root required"); + const d = await api("/v1/workspaces", { + method: "POST", + body: JSON.stringify({ + workspace_id: $("#wsId").value.trim(), + display_name: $("#wsName").value.trim() || $("#wsId").value.trim(), + host_root: host, + notes: $("#wsNotes").value.trim() || null, + }), + }); + $("#workspacesOut").textContent = JSON.stringify(d, null, 2); + await loadWorkspaces(); + } catch (e) { + $("#workspacesOut").textContent = String(e); + } +}; + setPilotMode("compose"); loadTemplates(); +loadWorkspaces().catch(() => {}); /* ----- Migration programs (GP0 board) ----- */ state.programId = null; @@ -575,11 +667,31 @@ function renderProgramSteps(prog, warnings) { - + + `; + const wsSel = el.querySelector("[data-workspace]"); + const pilotSel = $("#workspaceSelect"); + if (pilotSel) { + for (const opt of [...pilotSel.options]) { + if (!opt.value) continue; + const clone = opt.cloneNode(true); + wsSel.appendChild(clone); + } + } + const readBind = () => ({ + workdir: el.querySelector("[data-workdir]").value, + workspace_id: wsSel.value || null, + }); el.querySelector('[data-act="preview"]').onclick = () => previewProgramStep(step.step_id); - el.querySelector('[data-act="envelope"]').onclick = () => envelopeProgramStep(step.step_id, el.querySelector("[data-workdir]").value); - el.querySelector('[data-act="submit-bind"]').onclick = () => submitBindProgramStep(step.step_id, el.querySelector("[data-workdir]").value); + el.querySelector('[data-act="envelope"]').onclick = () => { + const b = readBind(); + envelopeProgramStep(step.step_id, b.workdir, b.workspace_id); + }; + el.querySelector('[data-act="submit-bind"]').onclick = () => { + const b = readBind(); + submitBindProgramStep(step.step_id, b.workdir, b.workspace_id); + }; box.appendChild(el); }); if (warnings && warnings.length) { @@ -600,11 +712,15 @@ async function previewProgramStep(stepId) { } } -async function envelopeProgramStep(stepId, workdir) { +async function envelopeProgramStep(stepId, workdir, workspaceId) { try { const out = await api(`/v1/programs/${state.programId}/steps/${stepId}/run`, { method: "POST", - body: JSON.stringify({ plan_only: true, workdir: workdir || null }), + body: JSON.stringify({ + plan_only: true, + workdir: workdir || null, + workspace_id: workspaceId || null, + }), }); $("#programOut").textContent = JSON.stringify(out, null, 2); } catch (e) { @@ -612,11 +728,19 @@ async function envelopeProgramStep(stepId, workdir) { } } -async function submitBindProgramStep(stepId, workdir) { +async function submitBindProgramStep(stepId, workdir, workspaceId) { try { + if (looksAbsolutePath(workdir || "")) { + throw new Error("Subpath/workdir must be relative (absolute paths rejected)"); + } + const envBody = { + plan_only: false, + workdir: workspaceId ? (workdir || "") : (workdir || "ws"), + workspace_id: workspaceId || null, + }; const env = await api(`/v1/programs/${state.programId}/steps/${stepId}/run`, { method: "POST", - body: JSON.stringify({ plan_only: false, workdir: workdir || "ws" }), + body: JSON.stringify(envBody), }); if (!env.run_create) { $("#programOut").textContent = JSON.stringify(env, null, 2); @@ -634,7 +758,8 @@ async function submitBindProgramStep(stepId, workdir) { method: "POST", body: JSON.stringify({ bind_run_id: runId, - workdir: workdir || env.run_create.workdir || "ws", + workdir: envBody.workdir, + workspace_id: workspaceId || null, idempotency_key: `bind-${runId}`, }), }); diff --git a/console/static/index.html b/console/static/index.html index b3bae17..366dde2 100644 --- a/console/static/index.html +++ b/console/static/index.html @@ -83,6 +83,17 @@

Goal pack (prefer for large work)

+
+ + +
+

Registered workspaces edit the real host tree. Sandbox is disposable under .recertia/workspaces/….

Desired states

@@ -156,7 +167,23 @@

Ops — metrics & canary

Auth / tenant (C3–C5)

-

Dev login issues a console session. Switch tenant when membership has more than one.

+

Dev login issues a console session. Switch tenant when membership has more than one. Register host workspaces (admin) for Pilot binds.

+
+

Registered workspaces

+
+ + +
+ + +
+ + +
+

+        
diff --git a/contracts/workspace.py b/contracts/workspace.py new file mode 100644 index 0000000..fe7cd4e --- /dev/null +++ b/contracts/workspace.py @@ -0,0 +1,25 @@ +"""Registered host workspaces for Pilot / API workdir binding (RW0). + +See ``docs/specifications/registered-workspaces.md``. +""" + +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + + +class RegisteredWorkspace(BaseModel): + """Allowlisted host directory a tenant may bind as a run workdir.""" + + model_config = ConfigDict(extra="forbid") + + workspace_id: str = Field(min_length=1, max_length=64) + tenant_id: str = Field(min_length=1, max_length=64) + display_name: str = Field(min_length=1, max_length=128) + host_root: str = Field(min_length=1, description="Absolute host path (Windows drive-letter)") + enabled: bool = True + created_at: datetime | None = None + created_by: str = Field(min_length=1) + notes: str | None = None diff --git a/docs/architecture/go-live.md b/docs/architecture/go-live.md index b16218c..affe42b 100644 --- a/docs/architecture/go-live.md +++ b/docs/architecture/go-live.md @@ -136,17 +136,43 @@ python -m uvicorn recertia.api.app:app --host 127.0.0.1 --port 8080 | Surface | Use | | --- | --- | -| Pilot | Goal form, templates, sync/async submit, live event stream | +| Pilot | Goal form, templates, sync/async submit, live event stream; workspace select | | Runs / Skills | Browse transcripts, promote (golden-gated) | | Tower | Proposals, jobs (`dry_run` default), practice / pressure panels | | Metrics | `MetricReport` + canary (unavailable reasons preserved) | -| Auth | Dev login / OIDC session; tenant switcher (Phase-4 gated) | +| Auth | Dev login / OIDC session; tenant switcher; **register workspaces** (admin) | Issue an API key with `runs` (+ `metrics` / `exec` as needed) for the sidebar. Browser sessions (`RECERTIA_CONSOLE_AUTH=dev` or `oidc`) carry human roles; do not embed long-lived keys in frontend source. Specs: [`../specifications/product-console.md`](../specifications/product-console.md). Plan: [`../implementation-plan-console.md`](../implementation-plan-console.md). +### Registered workspaces (real repo bind) + +Pilot cannot take raw absolute `workdir` paths. Register an allowlisted host root first +(API process must resolve Windows drive-letter paths — run uvicorn on Windows for +`D:\…` roots): + +```powershell +# Admin key (or console role admin + API key with runs) +recertia keys issue --tenant default --scopes runs,admin,metrics --actor dev + +recertia workspaces register ` + --id recertia ` + --name "quantrobs/recertia" ` + --host-root D:\src\recertia ` + --tenant default ` + --runs-root .recertia + +# CLI sugar +recertia run --goal evals/golden/repo-chore/add-editorconfig/goal.json ` + --workspace-id recertia --local-exec --runs-root .recertia +``` + +In the console: **Auth / Tenant → Register workspace**, then Pilot → Run → Workspace +select → Submit. Spec: +[`../specifications/registered-workspaces.md`](../specifications/registered-workspaces.md). + ## Soak and durability (operator GA) Durability unit: the entire `.recertia/` tree (checkpoints, operations, ledger, diff --git a/docs/architecture/product-console.md b/docs/architecture/product-console.md index 1dbb4d0..810ab6c 100644 --- a/docs/architecture/product-console.md +++ b/docs/architecture/product-console.md @@ -62,8 +62,9 @@ quota and identity, not necessarily self-serve key minting. - **Programs board** — durable migration programs (`/v1/programs`): ordered steps, freeze/mutate hints, per-step preview/run/bind (see [goal-packs.md](goal-packs.md)). Distinct from Tower **ReplayPack** evidence. -- **Workdir picker** — path or registered workspace; never accept arbitrary host escapes - beyond the existing API workdir rules. +- **Workdir picker** — sandbox (default) or **registered workspace** (allowlisted Windows + host root + optional subpath). Never accept raw absolute `workdir` on create-run; see + [registered-workspaces.md](../specifications/registered-workspaces.md). - **Runs browser** — list/filter by tenant, task class, terminal, time; open detail. - **Run detail** — route log, spend, manifest pins, failure class, links to transcript and trajectory. diff --git a/docs/implementation-plan-console.md b/docs/implementation-plan-console.md index 48d18b8..9a46122 100644 --- a/docs/implementation-plan-console.md +++ b/docs/implementation-plan-console.md @@ -60,6 +60,11 @@ Each milestone lists **engineering gates** (merge requirements) and **explicit n Conformance: `tests/unit/test_product_console.py` (PC-1…PC-6). C5 UI must not be marketed as multi-tenant-safe until production-readiness criteria pass. +**Registered workspaces (Pilot real-repo bind):** planned as RW0–RW2 in +[`implementation-plan-registered-workspaces.md`](implementation-plan-registered-workspaces.md) +(spec: [`specifications/registered-workspaces.md`](specifications/registered-workspaces.md)). +Does not reopen absolute `workdir` on create-run. + --- ## C0 — Read-only console (Pilot + Ops) diff --git a/docs/implementation-plan-registered-workspaces.md b/docs/implementation-plan-registered-workspaces.md new file mode 100644 index 0000000..7a1f229 --- /dev/null +++ b/docs/implementation-plan-registered-workspaces.md @@ -0,0 +1,149 @@ +# Registered workspaces — implementation plan + +Build order for Pilot workdir binding via allowlisted Windows host roots. +Normative contracts: [`specifications/registered-workspaces.md`](specifications/registered-workspaces.md). +Architecture note: [`architecture/product-console.md`](architecture/product-console.md) §3.1 Workdir picker. + +## Guiding rules + +1. **No raw absolute `workdir` on create-run.** Registry is the only API path to host trees. +2. **Preserve sandbox default.** Omitting `workspace_id` keeps today’s + `workspaces///` behaviour and existing tests. +3. **Windows drive-letter roots first.** Reject UNC / extended-length until a later revision. +4. **Admin registers; runners bind.** `runs` may use enabled workspaces; `admin` (or console + `admin`) creates/disables them. +5. **Resume is strict.** Disabled, missing, or drifted `host_root` → hard fail (409), never + silent sandbox fallback. + +## Milestones + +```text +RW0 Registry + create/resume resolution + tests +RW1 Pilot UI (select + subpath) + register form + Programs wire-up +RW2 Docs/go-live polish + optional CLI --workspace-id sugar +``` + +| Milestone | Status | Notes | +| --- | --- | --- | +| RW0 | Implemented | Backend contracts, store, API, workdir.json kind | +| RW1 | Implemented | `/console` Pilot + Auth/Ops registration | +| RW2 | Implemented | go-live.md, README index, CLI `workspaces` + `run --workspace-id` | + +Depends on: existing console C0–C3 (API keys, `/v1/runs`, Pilot SPA, console auth). + +--- + +## RW0 — Registry and run binding + +### Scope + +- Contract `RegisteredWorkspace` in `contracts/workspace.py`; regenerate schemas. +- Store: `src/recertia/workspaces/registry.py` (SQLite under api root), mirroring + `programs/store.py` patterns. +- Path helpers: extend [`src/recertia/paths.py`](../src/recertia/paths.py) with + `normalize_windows_host_root()` / `split_rel_subpath()` (split on `/` and `\`). +- Resolve path in [`src/recertia/api/__init__.py`](../src/recertia/api/__init__.py): + - Extend `RunCreate` with `workspace_id: str | None`. + - Branch `_resolve_create_workdir` (or replace with `_resolve_run_workdir`) per spec §5.2. + - Persist/load `workdir.json` with `kind`. + - Resume enforcement per §5.3. +- Routes in [`src/recertia/api/console_routes.py`](../src/recertia/api/console_routes.py) + (or small `workspace_routes.py` registered from `create_app`): CRUD from §5.1. +- Wire Programs step `/run` envelope to pass `workspace_id` through `run_create`. + +### Acceptance + +- RW-1…RW-6, RW-8, RW-9 green in `tests/unit/test_registered_workspaces.py` (+ extend + `test_api_runs.py` so absolute-without-id still fails). +- `pytest -v` full suite green; no Docker required. + +### Out of scope + +- Console HTML/JS (RW1). +- UNC paths. + +--- + +## RW1 — Pilot and registration UI + +### Scope + +- [`console/static/index.html`](../console/static/index.html): Workspace ``: `(sandbox — new empty workdir)` plus `GET /v1/workspaces` + enabled entries (`display_name` + `workspace_id` + short `host_root`). +2. **Subpath** — text input, placeholder `(repo root)`, optional; sent as `workdir` only when + a registered workspace is selected. +3. Helper text: registered binds edit the **real host tree**; sandbox is disposable. + +Compose mode does not need these fields (drafts do not create runs). + +### 6.2 Submit payload + +```json +{ + "goal": { "...": "..." }, + "task_class": "repo-chore", + "mode": "async", + "budget": { "max_attempts": 2 }, + "workspace_id": "recertia", + "workdir": "" +} +``` + +Sandbox selection omits `workspace_id` (and omits `workdir`, or sends relative sandbox +subdir if provided later). + +### 6.3 Client-side validation + +The console MUST refuse submit when: + +- Subpath looks absolute (`/`, `\`, or `X:\` prefix), or +- Workspace select is sandbox but subpath is non-empty **and** absolute. + +Server remains authoritative (PC posture: console refuses what API would reject). + +### 6.4 Auth / Ops registration UI (minimum) + +Auth or Ops panel: list workspaces; form to register (`workspace_id`, `display_name`, +`host_root`); disable toggle. No filesystem browser required in RW0 (paste path). + +## 7. Security and threat model + +| Threat | Control | +| --- | --- | +| Arbitrary host write via API | Absolute `workdir` without registry still 400; only admin registers roots | +| Cross-tenant bind | Registry keyed by tenant; resolve uses caller tenant only | +| Path escape via `..` / symlink | `contained_path` / resolve-before-check | +| Stale / moved repo | Resume 409 if root missing, disabled, or host_root drift | +| Accidental prod damage | UX warning; operators SHOULD use a throwaway branch; optional later + `read_only` flag out of scope | +| Multi-tenant GA | Registered host roots on a shared API host are a **single-operator** feature; + Phase-4 MUST re-review (likely disable host bind or require per-tenant volume mounts) | + +Ledger: registration / disable SHOULD append an audit row (API key audit table or ledger +entry) with actor, tenant, workspace_id, host_root. + +## 8. Conformance tests (RW-*) + +| ID | Assertion | +| --- | --- | +| RW-1 | `POST /v1/runs` with absolute `workdir` and no `workspace_id` → 400 | +| RW-2 | Register `D:\…\repo`, create run with `workspace_id` → effective workdir is that root; criterion paths resolve there | +| RW-3 | `workdir` / subpath `..\\other` under registered root → 400 | +| RW-4 | Tenant B cannot list or bind tenant A’s `workspace_id` | +| RW-5 | Disable workspace → create/resume → 403/409 | +| RW-6 | Resume after create reuses same effective path; mutating registry host_root out of band → 409 | +| RW-7 | Pilot submit JSON includes `workspace_id` when select is non-sandbox (contract / static fixture or API-level test of payload builder) | +| RW-8 | Non-admin cannot `POST /v1/workspaces` | +| RW-9 | Mixed separators `subdir/foo` and `subdir\foo` resolve identically under host root | + +Existing PC-1…PC-6 and `test_create_run_rejects_absolute_and_escaped_workdir` remain green. + +## 9. Non-goals (RW0–RW1) + +- UNC / `\\?\` extended paths +- In-browser native folder picker / OS file dialog +- Auto-clone from GitHub URL into sandbox +- Copy-on-write or snapshot of registered roots before solve +- Changing CLI `--workdir` (already free-form); CLI MAY later `--workspace-id` as sugar +- Replacing `repo_bindings` / `git_tip` (orthogonal; may point at the same disk tree) + +## 10. Error surface (informative) + +| HTTP | detail (substring) | +| --- | --- | +| 400 | `absolute paths rejected` / `workdir escapes` / `host_root must be a Windows drive-letter absolute directory` | +| 403 | `admin required to register workspace` / `workspace disabled` | +| 404 | `workspace not found` | +| 409 | `workspace_id exists` / `workspace host_root changed` / `registered workdir missing` | diff --git a/schema/registered_workspace.schema.json b/schema/registered_workspace.schema.json new file mode 100644 index 0000000..9359a94 --- /dev/null +++ b/schema/registered_workspace.schema.json @@ -0,0 +1,76 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/quantrobs/recertia/schema/RegisteredWorkspace", + "additionalProperties": false, + "description": "Allowlisted host directory a tenant may bind as a run workdir.", + "properties": { + "workspace_id": { + "maxLength": 64, + "minLength": 1, + "title": "Workspace Id", + "type": "string" + }, + "tenant_id": { + "maxLength": 64, + "minLength": 1, + "title": "Tenant Id", + "type": "string" + }, + "display_name": { + "maxLength": 128, + "minLength": 1, + "title": "Display Name", + "type": "string" + }, + "host_root": { + "description": "Absolute host path (Windows drive-letter)", + "minLength": 1, + "title": "Host Root", + "type": "string" + }, + "enabled": { + "default": true, + "title": "Enabled", + "type": "boolean" + }, + "created_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Created At" + }, + "created_by": { + "minLength": 1, + "title": "Created By", + "type": "string" + }, + "notes": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Notes" + } + }, + "required": [ + "workspace_id", + "tenant_id", + "display_name", + "host_root", + "created_by" + ], + "title": "RegisteredWorkspace", + "type": "object" +} diff --git a/scripts/generate_schemas.py b/scripts/generate_schemas.py index bcfb436..9f0a54f 100644 --- a/scripts/generate_schemas.py +++ b/scripts/generate_schemas.py @@ -35,6 +35,7 @@ from contracts.skill import SkillVersion # noqa: E402 from contracts.stats import RetrievalAblationEffect, SkillStats # noqa: E402 from contracts.status import SkillStatus # noqa: E402 +from contracts.workspace import RegisteredWorkspace # noqa: E402 MODELS: dict[str, type] = { "skill_version.schema.json": SkillVersion, @@ -60,6 +61,7 @@ "metric_report.schema.json": MetricReport, "goal.schema.json": Goal, "migration_program.schema.json": MigrationProgram, + "registered_workspace.schema.json": RegisteredWorkspace, } diff --git a/skills/add-gitignore-entry/v1/version.json b/skills/add-gitignore-entry/v1/version.json index d4cf20b..9ad57f0 100644 --- a/skills/add-gitignore-entry/v1/version.json +++ b/skills/add-gitignore-entry/v1/version.json @@ -36,7 +36,7 @@ "tool": "shell", "intent": "Append {{pattern}} to .gitignore if missing.", "inputs": { - "command": "pattern='*.pyc'; grep -qxF \"$pattern\" .gitignore || echo \"$pattern\" >> .gitignore" + "command": "python -c \"from pathlib import Path; p=Path('.gitignore'); line='*.pyc'; text=p.read_text(encoding='utf-8') if p.exists() else ''; lines=text.splitlines(); p.write_text(text + ('' if (not text or text.endswith(chr(10))) else chr(10)) + line + chr(10), encoding='utf-8') if line not in lines else None\"" }, "outputs": [], "input_bindings": [], @@ -49,7 +49,7 @@ { "id": "has-entry", "kind": "command", - "run": "grep -qxF '*.pyc' .gitignore", + "run": "python -c \"from pathlib import Path; import sys; sys.exit(0 if '*.pyc' in Path('.gitignore').read_text(encoding='utf-8').splitlines() else 1)\"", "expect_exit": 0, "expr": null, "target": null, @@ -68,7 +68,7 @@ "rejected": true, "checked_at": "2026-07-31T00:00:00Z", "checked_against": "sha256:m1-seed-env", - "evidence_hash": "b5299accfd4ed38cf83992df906226de0413db5bf21820a4dd247cda9a22e648" + "evidence_hash": "b32e33ebf8b27d583d9c74bc43c76dfb7f7c9bf51e422be6b9d34b1e926e3da0" }, "authored_by": "human", "preregistered": true diff --git a/src/recertia/api/__init__.py b/src/recertia/api/__init__.py index 5162409..01d5e55 100644 --- a/src/recertia/api/__init__.py +++ b/src/recertia/api/__init__.py @@ -32,11 +32,13 @@ from recertia.bootstrap import build_default_orchestrator, resolve_task_class from recertia.graph.engine import GraphOrchestrator from recertia.ids import InvalidIdError, validate_run_id +from recertia.paths import HostRootError, looks_absolute, resolve_under_host_root from recertia.solver.container import configured_backend, ensure_api_execution_ready from recertia.solver.sandbox import SandboxError from recertia.store.blobs import FilesystemBlobStore, normalize_blob_digest from recertia.telemetry import get_telemetry, render_dashboard from recertia.workers.run_worker import AsyncRunRequest +from recertia.workspaces.registry import WorkspaceRegistry DEFAULT_ROOT = Path(".recertia") _MAX_BLOB_BYTES = int(os.environ.get("RECERTIA_MAX_BLOB_BYTES", str(16 * 1024 * 1024))) @@ -98,22 +100,20 @@ def _canonical_run_workdir(root: Path, tenant_id: str, run_id: str) -> Path: return candidate -def _resolve_create_workdir(root: Path, tenant_id: str, run_id: str, workdir: str | None) -> Path: - """Map optional caller ``workdir`` under the canonical run workspace only. - - Absolute paths and ``..`` escapes are rejected. Relative values are resolved under - ``root/workspaces//``. - """ +def _resolve_sandbox_workdir( + root: Path, tenant_id: str, run_id: str, workdir: str | None +) -> Path: + """Map optional caller ``workdir`` under the canonical run workspace only.""" base = _canonical_run_workdir(root, tenant_id, run_id) if workdir is None or workdir == "": return base - ref = Path(workdir) - if ref.is_absolute(): + if looks_absolute(workdir): raise HTTPException( status_code=400, detail="workdir must be relative to the run workspace (absolute paths rejected)", ) + ref = Path(workdir) candidate = (base / ref).resolve() try: candidate.relative_to(base) @@ -122,27 +122,109 @@ def _resolve_create_workdir(root: Path, tenant_id: str, run_id: str, workdir: st return candidate +def _resolve_create_workdir( + root: Path, + tenant_id: str, + run_id: str, + workdir: str | None, + *, + workspace_id: str | None = None, + registry: WorkspaceRegistry | None = None, +) -> tuple[Path, dict[str, Any]]: + """Resolve create-run workdir; return ``(path, workdir.json payload)``.""" + + if workspace_id: + if registry is None: + raise HTTPException(status_code=500, detail="workspace registry unavailable") + ws = registry.get(workspace_id, tenant_id=tenant_id, enabled_only=False) + if ws is None: + raise HTTPException(status_code=404, detail="workspace not found") + if not ws.enabled: + raise HTTPException(status_code=403, detail="workspace disabled") + try: + effective = resolve_under_host_root(ws.host_root, workdir) + except HostRootError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if not effective.is_dir(): + raise HTTPException(status_code=400, detail="registered workdir missing") + meta = { + "kind": "registered", + "workspace_id": ws.workspace_id, + "subpath": workdir or "", + "workdir": str(effective.resolve()), + "host_root": ws.host_root, + } + return effective, meta + + if workdir is not None and looks_absolute(workdir): + raise HTTPException( + status_code=400, + detail="workdir must be relative to the run workspace (absolute paths rejected)", + ) + effective = _resolve_sandbox_workdir(root, tenant_id, run_id, workdir) + meta = {"kind": "sandbox", "workdir": str(effective.resolve())} + return effective, meta + + def _workdir_meta_path(root: Path, tenant_id: str, run_id: str) -> Path: return root / "runs" / tenant_id / run_id / "workdir.json" -def _persist_workdir(root: Path, tenant_id: str, run_id: str, workdir: Path) -> None: - meta = _workdir_meta_path(root, tenant_id, run_id) - meta.parent.mkdir(parents=True, exist_ok=True) - meta.write_text( - json.dumps({"workdir": str(workdir.resolve())}) + "\n", - encoding="utf-8", - ) +def _persist_workdir( + root: Path, + tenant_id: str, + run_id: str, + workdir: Path, + *, + meta: dict[str, Any] | None = None, +) -> None: + payload = dict(meta) if meta is not None else {"kind": "sandbox", "workdir": str(workdir.resolve())} + payload.setdefault("workdir", str(workdir.resolve())) + dest = _workdir_meta_path(root, tenant_id, run_id) + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(json.dumps(payload) + "\n", encoding="utf-8") -def _load_persisted_workdir(root: Path, tenant_id: str, run_id: str) -> Path | None: - meta = _workdir_meta_path(root, tenant_id, run_id) - if not meta.exists(): +def _load_persisted_workdir( + root: Path, + tenant_id: str, + run_id: str, + *, + registry: WorkspaceRegistry | None = None, +) -> Path | None: + meta_path = _workdir_meta_path(root, tenant_id, run_id) + if not meta_path.exists(): + return None + try: + payload = json.loads(meta_path.read_text(encoding="utf-8")) + kind = str(payload.get("kind") or "sandbox") + except (json.JSONDecodeError, OSError, TypeError): return None + + if kind == "registered": + if registry is None: + raise HTTPException(status_code=500, detail="workspace registry unavailable") + workspace_id = str(payload.get("workspace_id") or "") + stored_host = str(payload.get("host_root") or "") + subpath = payload.get("subpath") + ws = registry.get(workspace_id, tenant_id=tenant_id, enabled_only=False) + if ws is None: + raise HTTPException(status_code=409, detail="workspace not found") + if not ws.enabled: + raise HTTPException(status_code=409, detail="workspace disabled") + if stored_host and ws.host_root != stored_host: + raise HTTPException(status_code=409, detail="workspace host_root changed") + try: + effective = resolve_under_host_root(ws.host_root, str(subpath) if subpath is not None else "") + except HostRootError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + if not effective.is_dir(): + raise HTTPException(status_code=409, detail="registered workdir missing") + return effective + try: - payload = json.loads(meta.read_text(encoding="utf-8")) stored = Path(str(payload["workdir"])).resolve() - except (json.JSONDecodeError, KeyError, OSError, TypeError): + except (KeyError, OSError, TypeError): return None base = _canonical_run_workdir(root, tenant_id, run_id) try: @@ -160,6 +242,7 @@ class RunCreate(BaseModel): script: list[str] | None = None budget: dict[str, Any] | None = None workdir: str | None = None + workspace_id: str | None = None run_id: str | None = None arm: Arm = "treatment" mode: str = "sync" # sync | async (console C2) @@ -189,6 +272,8 @@ class RunRecord(BaseModel): cost_usd: float | None = None mode: str | None = None criteria_hash: str | None = None + workspace_id: str | None = None + workdir: str | None = None def create_app( @@ -203,6 +288,7 @@ def create_app( facts_root = Path(facts_root) if facts_root is not None else Path("facts") key_store = ApiKeyStore(root / "api_keys.sqlite") quota_store = QuotaStore(root / "quotas.sqlite") + workspace_registry = WorkspaceRegistry(root / "workspaces_registry.sqlite") blobs_by_tenant: dict[str, FilesystemBlobStore] = {} # Keyed by (tenant_id, run_id) so tenants cannot collide on run_id. runs: dict[tuple[str, str], RunRecord] = {} @@ -226,6 +312,7 @@ def create_app( principal_may_exec=_principal_may_exec, require_scope=require_scope, validate_run_id=_validate_run_id, + workspace_registry=workspace_registry, ) register_console_routes(app, console_ctx) @@ -262,9 +349,17 @@ def create_run( if run_key in runs: raise HTTPException(status_code=409, detail="run_id already exists") - workdir = _resolve_create_workdir(root, principal.tenant_id, run_id, body.workdir) - workdir.mkdir(parents=True, exist_ok=True) - _persist_workdir(root, principal.tenant_id, run_id, workdir) + workdir, wd_meta = _resolve_create_workdir( + root, + principal.tenant_id, + run_id, + body.workdir, + workspace_id=body.workspace_id, + registry=workspace_registry, + ) + if wd_meta.get("kind") != "registered": + workdir.mkdir(parents=True, exist_ok=True) + _persist_workdir(root, principal.tenant_id, run_id, workdir, meta=wd_meta) request = body.request if body.goal is not None and body.goal.context and not request: @@ -296,6 +391,8 @@ def create_run( terminal=None, has_goal=body.goal is not None, mode="async", + workspace_id=body.workspace_id, + workdir=str(workdir), ) runs[run_key] = placeholder console_ctx.worker.submit( @@ -327,6 +424,8 @@ def create_run( "mode": "async", "tenant_id": principal.tenant_id, "task_class": task_class, + "workspace_id": body.workspace_id, + "workdir": str(workdir), }, ) @@ -403,7 +502,14 @@ def create_run( created_at=datetime.now(timezone.utc), has_goal=body.goal is not None, ) - rec = rec.model_copy(update={"cost_usd": cost_usd, "mode": "sync"}) + rec = rec.model_copy( + update={ + "cost_usd": cost_usd, + "mode": "sync", + "workspace_id": body.workspace_id, + "workdir": str(workdir), + } + ) runs[run_key] = rec get_telemetry().emit( "run.finished", @@ -437,7 +543,9 @@ def resume_run( run_id = _validate_run_id(run_id) run_key = (principal.tenant_id, run_id) # Resume MUST reuse the persisted create workdir — never invent a new path. - workdir = _load_persisted_workdir(root, principal.tenant_id, run_id) + workdir = _load_persisted_workdir( + root, principal.tenant_id, run_id, registry=workspace_registry + ) if workdir is None: workdir = _canonical_run_workdir(root, principal.tenant_id, run_id) if not workdir.exists(): @@ -529,6 +637,7 @@ def dashboard( app.state.facts_root = facts_root app.state.api_keys = key_store app.state.quota_store = quota_store + app.state.workspace_registry = workspace_registry app.state.blobs_by_tenant = blobs_by_tenant app.state.runs = runs app.state.console_ctx = console_ctx diff --git a/src/recertia/api/console_routes.py b/src/recertia/api/console_routes.py index 1e42b85..a16fde9 100644 --- a/src/recertia/api/console_routes.py +++ b/src/recertia/api/console_routes.py @@ -172,11 +172,26 @@ class StepSkipBody(BaseModel): class StepRunBody(BaseModel): plan_only: bool = False workdir: str | None = None + workspace_id: str | None = None budget: dict[str, Any] | None = None bind_run_id: str | None = None idempotency_key: str | None = None +class WorkspaceCreate(BaseModel): + workspace_id: str + display_name: str + host_root: str + notes: str | None = None + + +class WorkspacePatch(BaseModel): + display_name: str | None = None + notes: str | None = None + enabled: bool | None = None + clear_notes: bool = False + + class ConsoleContext: def __init__( self, @@ -197,6 +212,7 @@ def __init__( principal_may_exec: Any, require_scope: Any, validate_run_id: Any, + workspace_registry: Any = None, ) -> None: self.root = root self.skills_root = skills_root @@ -214,6 +230,7 @@ def __init__( self.principal_may_exec = principal_may_exec self.require_scope = require_scope self.validate_run_id = validate_run_id + self.workspace_registry = workspace_registry self.sessions = SessionStore() self.proposals = ProposalStore(root / "proposals.sqlite") self.programs = ProgramStore(root / "programs.sqlite") @@ -295,6 +312,16 @@ def _resolve_tenant( return user.active_tenant return principal.tenant_id + def _require_workspace_admin(request: Request, principal: Any) -> str: + """Admin API key or console role admin may mutate the registry.""" + + user = _optional_console_user(request) + if user is not None and user.may("admin"): + return user.user_id + if "admin" in principal.scopes: + return principal.key_id + raise HTTPException(status_code=403, detail="admin required to register workspace") + # ----- C3 auth ----- @app.get("/v1/me") def me(request: Request) -> dict[str, Any]: @@ -351,6 +378,105 @@ def switch_tenant(body: TenantSwitch, request: Request, response: Response) -> d response.set_cookie("recertia_session", token, httponly=True, samesite="lax") return {"active_tenant": switched.active_tenant, "session": token, "tenants": list(switched.tenants)} + # ----- Registered workspaces (RW0) ----- + @app.get("/v1/workspaces") + def list_workspaces( + request: Request, + principal=Depends(require_runs), + x_recertia_tenant: str | None = Header(default=None, alias="X-Recertia-Tenant"), + ) -> dict[str, Any]: + if ctx.workspace_registry is None: + raise HTTPException(status_code=500, detail="workspace registry unavailable") + tenant_id = _resolve_tenant(principal, request, x_recertia_tenant) + items = ctx.workspace_registry.list(tenant_id=tenant_id) + return {"workspaces": [w.model_dump(mode="json") for w in items]} + + @app.get("/v1/workspaces/{workspace_id}") + def get_workspace( + workspace_id: str, + request: Request, + principal=Depends(require_runs), + x_recertia_tenant: str | None = Header(default=None, alias="X-Recertia-Tenant"), + ) -> dict[str, Any]: + if ctx.workspace_registry is None: + raise HTTPException(status_code=500, detail="workspace registry unavailable") + tenant_id = _resolve_tenant(principal, request, x_recertia_tenant) + ws = ctx.workspace_registry.get(workspace_id, tenant_id=tenant_id) + if ws is None: + raise HTTPException(status_code=404, detail="workspace not found") + return ws.model_dump(mode="json") + + @app.post("/v1/workspaces", status_code=201) + def create_workspace( + body: WorkspaceCreate, + request: Request, + principal=Depends(require_runs), + x_recertia_tenant: str | None = Header(default=None, alias="X-Recertia-Tenant"), + ) -> dict[str, Any]: + if ctx.workspace_registry is None: + raise HTTPException(status_code=500, detail="workspace registry unavailable") + actor = _require_workspace_admin(request, principal) + tenant_id = _resolve_tenant(principal, request, x_recertia_tenant) + from recertia.paths import HostRootError + + try: + ws = ctx.workspace_registry.register( + tenant_id=tenant_id, + workspace_id=body.workspace_id, + display_name=body.display_name, + host_root=body.host_root, + created_by=actor, + notes=body.notes, + ) + except LookupError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except (HostRootError, ValueError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return ws.model_dump(mode="json") + + @app.patch("/v1/workspaces/{workspace_id}") + def patch_workspace( + workspace_id: str, + body: WorkspacePatch, + request: Request, + principal=Depends(require_runs), + x_recertia_tenant: str | None = Header(default=None, alias="X-Recertia-Tenant"), + ) -> dict[str, Any]: + if ctx.workspace_registry is None: + raise HTTPException(status_code=500, detail="workspace registry unavailable") + _require_workspace_admin(request, principal) + tenant_id = _resolve_tenant(principal, request, x_recertia_tenant) + try: + ws = ctx.workspace_registry.patch( + workspace_id, + tenant_id=tenant_id, + display_name=body.display_name, + notes=body.notes, + enabled=body.enabled, + clear_notes=body.clear_notes, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if ws is None: + raise HTTPException(status_code=404, detail="workspace not found") + return ws.model_dump(mode="json") + + @app.delete("/v1/workspaces/{workspace_id}") + def delete_workspace( + workspace_id: str, + request: Request, + principal=Depends(require_runs), + x_recertia_tenant: str | None = Header(default=None, alias="X-Recertia-Tenant"), + ) -> dict[str, Any]: + if ctx.workspace_registry is None: + raise HTTPException(status_code=500, detail="workspace registry unavailable") + _require_workspace_admin(request, principal) + tenant_id = _resolve_tenant(principal, request, x_recertia_tenant) + ws = ctx.workspace_registry.set_enabled(workspace_id, tenant_id=tenant_id, enabled=False) + if ws is None: + raise HTTPException(status_code=404, detail="workspace not found") + return ws.model_dump(mode="json") + @app.get("/v1/auth/oidc/login") def oidc_login(request: Request) -> dict[str, str]: if auth_mode() != "oidc" or not oidc_configured(): @@ -1394,7 +1520,11 @@ def run_step( try: goal = materialize_step_goal(prog, step) assert_gp0_execution_prereqs( - prog, step, workdir=body.workdir, plan_only=body.plan_only + prog, + step, + workdir=body.workdir, + workspace_id=body.workspace_id, + plan_only=body.plan_only, ) except MaterializeError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @@ -1428,6 +1558,7 @@ def run_step( "task_class": goal.task_class or prog.task_class, "budget": budget.model_dump(mode="json"), "workdir": body.workdir, + "workspace_id": body.workspace_id, }, "criteria_preview_hash": ph, "warnings": [w.to_dict() for w in warnings], diff --git a/src/recertia/cli/main.py b/src/recertia/cli/main.py index c2de048..55f52ad 100644 --- a/src/recertia/cli/main.py +++ b/src/recertia/cli/main.py @@ -26,6 +26,7 @@ skills_promote, skills_search, ) +from recertia.cli.workspaces import register_workspaces_commands app = typer.Typer(help="Recertia: a self-improving agent system.") register_run_commands(app) @@ -35,6 +36,7 @@ register_metrics_commands(app) register_jobs_commands(app) register_gc_commands(app) +register_workspaces_commands(app) __all__ = [ "app", diff --git a/src/recertia/cli/runs.py b/src/recertia/cli/runs.py index 1074d68..432276e 100644 --- a/src/recertia/cli/runs.py +++ b/src/recertia/cli/runs.py @@ -65,6 +65,16 @@ def run_cmd( runs_root: Path = typer.Option(Path(".recertia"), "--runs-root", help="Where run state is persisted."), run_id: Optional[str] = typer.Option(None, "--run-id", help="Defaults to a fresh UUID."), workdir: Optional[Path] = typer.Option(None, "--workdir"), + workspace_id: Optional[str] = typer.Option( + None, + "--workspace-id", + help="Use a registered host workspace under --runs-root (RW2). Optional --workdir as subpath.", + ), + tenant: str = typer.Option( + "default", + "--tenant", + help="Tenant for --workspace-id lookup.", + ), skills_root: Path = typer.Option( Path("skills"), "--skills-root", help="Procedural skill library root." ), @@ -147,8 +157,30 @@ def run_cmd( criteria = compile_goal(task_goal) budget = Budget(**data["budget"]) if "budget" in data else Budget() script = data.get("script") - wd = workdir or (Path(data["workdir"]) if "workdir" in data else runs_root / "workspaces" / rid) - wd.mkdir(parents=True, exist_ok=True) + if workspace_id: + from recertia.paths import HostRootError, resolve_under_host_root + from recertia.workspaces.registry import WorkspaceRegistry + + registry = WorkspaceRegistry(runs_root / "workspaces_registry.sqlite") + try: + ws = registry.get(workspace_id, tenant_id=tenant, enabled_only=False) + if ws is None: + typer.echo(f"workspace not found: {workspace_id}", err=True) + raise typer.Exit(code=2) + if not ws.enabled: + typer.echo(f"workspace disabled: {workspace_id}", err=True) + raise typer.Exit(code=2) + sub = str(workdir) if workdir is not None else "" + try: + wd = resolve_under_host_root(ws.host_root, sub) + except HostRootError as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(code=2) from exc + finally: + registry.close() + else: + wd = workdir or (Path(data["workdir"]) if "workdir" in data else runs_root / "workspaces" / rid) + wd.mkdir(parents=True, exist_ok=True) arm = data.get("arm", "treatment") if ablation: diff --git a/src/recertia/cli/workspaces.py b/src/recertia/cli/workspaces.py new file mode 100644 index 0000000..3dd9654 --- /dev/null +++ b/src/recertia/cli/workspaces.py @@ -0,0 +1,89 @@ +"""CLI: ``recertia workspaces register|list|disable``.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Optional + +import typer + +from recertia.paths import HostRootError +from recertia.workspaces.registry import WorkspaceRegistry + +workspaces_app = typer.Typer(help="Registered host workspaces (Pilot / API bind allowlist).") + + +def register_workspaces_commands(app: typer.Typer) -> None: + app.add_typer(workspaces_app, name="workspaces") + + +@workspaces_app.command("register") +def workspaces_register( + workspace_id: str = typer.Option(..., "--id", help="Stable workspace_id slug."), + host_root: Path = typer.Option(..., "--host-root", help="Absolute host directory."), + display_name: Optional[str] = typer.Option(None, "--name", help="Display label."), + tenant: str = typer.Option("default", "--tenant"), + runs_root: Path = typer.Option(Path(".recertia"), "--runs-root"), + notes: Optional[str] = typer.Option(None, "--notes"), + actor: str = typer.Option("cli", "--actor"), +) -> None: + """Register an allowlisted host root for API/Pilot ``workspace_id`` binds.""" + + registry = WorkspaceRegistry(runs_root / "workspaces_registry.sqlite") + try: + ws = registry.register( + tenant_id=tenant, + workspace_id=workspace_id, + display_name=display_name or workspace_id, + host_root=str(host_root), + created_by=actor, + notes=notes, + ) + except LookupError as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(code=1) from exc + except (HostRootError, ValueError) as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(code=2) from exc + finally: + registry.close() + typer.echo( + f"workspace_id={ws.workspace_id} tenant={ws.tenant_id} host_root={ws.host_root}" + ) + + +@workspaces_app.command("list") +def workspaces_list( + tenant: str = typer.Option("default", "--tenant"), + runs_root: Path = typer.Option(Path(".recertia"), "--runs-root"), +) -> None: + registry = WorkspaceRegistry(runs_root / "workspaces_registry.sqlite") + try: + items = registry.list(tenant_id=tenant) + finally: + registry.close() + if not items: + typer.echo("(none)") + return + for ws in items: + flag = "on" if ws.enabled else "off" + typer.echo( + f"{ws.workspace_id}\t{flag}\t{ws.display_name}\t{ws.host_root}" + ) + + +@workspaces_app.command("disable") +def workspaces_disable( + workspace_id: str = typer.Argument(...), + tenant: str = typer.Option("default", "--tenant"), + runs_root: Path = typer.Option(Path(".recertia"), "--runs-root"), +) -> None: + registry = WorkspaceRegistry(runs_root / "workspaces_registry.sqlite") + try: + ws = registry.set_enabled(workspace_id, tenant_id=tenant, enabled=False) + finally: + registry.close() + if ws is None: + typer.echo("workspace not found", err=True) + raise typer.Exit(code=1) + typer.echo(f"workspace_id={ws.workspace_id} enabled=false") diff --git a/src/recertia/memory/procedural/seeds/factories.py b/src/recertia/memory/procedural/seeds/factories.py index 0d0869d..723c14d 100644 --- a/src/recertia/memory/procedural/seeds/factories.py +++ b/src/recertia/memory/procedural/seeds/factories.py @@ -28,16 +28,31 @@ def add_gitignore_entry() -> SkillVersion: id="append", tool="shell", intent="Append {{pattern}} to .gitignore if missing.", + # Portable Python (cmd.exe + sh): bash `$pattern` breaks under Windows local-exec. inputs={ "command": ( - "pattern='*.pyc'; " - "grep -qxF \"$pattern\" .gitignore || echo \"$pattern\" >> .gitignore" + "python -c \"from pathlib import Path; " + "p=Path('.gitignore'); line='*.pyc'; " + "text=p.read_text(encoding='utf-8') if p.exists() else ''; " + "lines=text.splitlines(); " + "p.write_text(" + "text + ('' if (not text or text.endswith(chr(10))) else chr(10)) " + "+ line + chr(10), encoding='utf-8') " + "if line not in lines else None\"" ) }, ), ], certification_criteria=[ - _cmd_criterion("has-entry", "grep -qxF '*.pyc' .gitignore", "gitignore without *.pyc"), + _cmd_criterion( + "has-entry", + ( + "python -c \"from pathlib import Path; import sys; " + "sys.exit(0 if '*.pyc' in Path('.gitignore').read_text(" + "encoding='utf-8').splitlines() else 1)\"" + ), + "gitignore without *.pyc", + ), ], provenance=_prov("add-gitignore-entry"), hygiene=_HYGIENE, diff --git a/src/recertia/paths.py b/src/recertia/paths.py index 734d936..3636552 100644 --- a/src/recertia/paths.py +++ b/src/recertia/paths.py @@ -2,13 +2,23 @@ from __future__ import annotations +import os +import re +import sys from pathlib import Path +_WINDOWS_DRIVE_RE = re.compile(r"^[A-Za-z]:[\\/]") +_WORKSPACE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$") + class PathEscapeError(ValueError): """Raised when a resolved path escapes its allowed root.""" +class HostRootError(ValueError): + """Raised when a registered host root is invalid.""" + + def contained_path(root: Path | str, *parts: str) -> Path: """Join ``parts`` under ``root`` and require the result stay inside ``root``. @@ -32,3 +42,127 @@ def is_within(root: Path | str, path: Path | str) -> bool: return True except ValueError: return False + + +def validate_workspace_id(workspace_id: str) -> str: + if not _WORKSPACE_ID_RE.fullmatch(workspace_id): + raise ValueError( + "workspace_id must match ^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$ " + "(no path separators or traversal)" + ) + return workspace_id + + +def looks_absolute(path: str) -> bool: + """True for OS-absolute paths and Windows drive-letter / UNC forms.""" + + s = path.strip() + if not s: + return False + if Path(s).is_absolute(): + return True + if _WINDOWS_DRIVE_RE.match(s): + return True + if s.startswith("\\\\") or s.startswith("//"): + return True + return False + + +def split_rel_subpath(subpath: str | None) -> tuple[str, ...]: + """Split a relative subpath on ``/`` and ``\\``; empty / ``.`` → ().""" + + if subpath is None: + return () + raw = subpath.strip() + if raw in {"", "."}: + return () + if looks_absolute(raw): + raise HostRootError("workdir must be relative to the registered workspace (absolute paths rejected)") + parts = [p for p in re.split(r"[\\/]+", raw) if p and p != "."] + if any(p == ".." for p in parts): + # Still allow join+resolve containment to catch encoded forms; explicit .. fails early. + pass + return tuple(parts) + + +def _posix_roots_allowed() -> bool: + return os.environ.get("RECERTIA_ALLOW_POSIX_WORKSPACE_ROOTS", "").strip().lower() in { + "1", + "true", + "yes", + } + + +def normalize_host_root(host_root: str, *, must_exist: bool = True) -> str: + """Validate and normalize a registered host root for storage. + + Primary profile: Windows drive-letter absolute paths (``D:\\src\\repo``). + When ``RECERTIA_ALLOW_POSIX_WORKSPACE_ROOTS=1`` (CI / non-Windows tests), POSIX + absolute directories are also accepted. + """ + + raw = host_root.strip().rstrip("/\\") + if not raw or "\x00" in raw: + raise HostRootError("host_root must be a non-empty directory path") + + if raw.startswith("\\\\") or raw.startswith("//") or raw.startswith("\\\\?\\"): + raise HostRootError("UNC and extended-length paths are not supported") + + is_windows_form = bool(_WINDOWS_DRIVE_RE.match(raw)) + if is_windows_form: + if sys.platform == "win32": + path = Path(raw) + if not path.is_absolute(): + raise HostRootError( + "host_root must be a Windows drive-letter absolute directory" + ) + try: + resolved = path.resolve() + except OSError as exc: + raise HostRootError(f"host_root could not be resolved: {exc}") from exc + if must_exist and not resolved.is_dir(): + raise HostRootError("host_root must exist as a directory") + # Prefer backslash storage on Windows. + return str(resolved) + # Non-Windows cannot resolve D:\… — reject unless callers use POSIX escape. + raise HostRootError( + "Windows host_root cannot be resolved on this host; " + "run the API on Windows or set RECERTIA_ALLOW_POSIX_WORKSPACE_ROOTS=1 " + "with a POSIX absolute host_root for tests" + ) + + if _posix_roots_allowed(): + path = Path(raw) + if not path.is_absolute(): + raise HostRootError( + "host_root must be a Windows drive-letter absolute directory " + "(or POSIX absolute when RECERTIA_ALLOW_POSIX_WORKSPACE_ROOTS=1)" + ) + try: + resolved = path.resolve() + except OSError as exc: + raise HostRootError(f"host_root could not be resolved: {exc}") from exc + if must_exist and not resolved.is_dir(): + raise HostRootError("host_root must exist as a directory") + return str(resolved) + + raise HostRootError( + "host_root must be a Windows drive-letter absolute directory " + "(e.g. D:\\src\\recertia)" + ) + + +def resolve_under_host_root(host_root: str, subpath: str | None) -> Path: + """Resolve ``subpath`` under a stored host root; refuse escapes.""" + + try: + root = Path(host_root).resolve() + except OSError as exc: + raise HostRootError(f"host_root could not be resolved: {exc}") from exc + parts = split_rel_subpath(subpath) + if not parts: + return root + try: + return contained_path(root, *parts) + except PathEscapeError as exc: + raise HostRootError("workdir escapes registered workspace root") from exc diff --git a/src/recertia/programs/materialize.py b/src/recertia/programs/materialize.py index bc408d9..1d63073 100644 --- a/src/recertia/programs/materialize.py +++ b/src/recertia/programs/materialize.py @@ -138,24 +138,26 @@ def assert_gp0_execution_prereqs( *, workdir: str | None, plan_only: bool, + workspace_id: str | None = None, ) -> None: """GP0 honesty: empty isolated workspaces are not a migration handoff.""" if plan_only: return + has_workdir = bool(workdir) or bool(workspace_id) if program.handoff == "none": # External git handoff or explicit operator workdir required to execute. eh = step.external_handoff has_ext = eh is not None and any( [eh.branch, eh.pr_url, eh.base_sha, eh.head_sha] ) - if not workdir and not has_ext: + if not has_workdir and not has_ext: raise MaterializeError( "GP0 execution requires operator workdir and/or external_handoff " "(branch/pr_url/sha); use plan_only=true for board/preview without a run" ) - if program.handoff == "operator_workdir" and not workdir: - raise MaterializeError("handoff=operator_workdir requires workdir") + if program.handoff == "operator_workdir" and not has_workdir: + raise MaterializeError("handoff=operator_workdir requires workdir or workspace_id") if program.handoff == "git_tip": if program.repo_binding is None: raise MaterializeError("handoff=git_tip requires a registered repo_binding") diff --git a/src/recertia/solver/container.py b/src/recertia/solver/container.py index ddc81bf..b26c332 100644 --- a/src/recertia/solver/container.py +++ b/src/recertia/solver/container.py @@ -109,11 +109,16 @@ def ensure_workdir_writable_by_container(workdir: Path) -> None: by the invoking user, which blocks writes inside the sandbox. Default mode adds owner/group write (``0770``). World-write (``0777``) is opt-in via ``RECERTIA_WORKDIR_WORLD_WRITE=1`` for hosts without a shared GID / rootless map. + + On Windows, POSIX mode bits are not enforced on NTFS; this is a no-op after the + directory-existence check (Linux bind-mount hosts are the intended audience). """ workdir = workdir.resolve() if not workdir.is_dir(): raise SandboxError(f"workdir does not exist: {workdir}") + if sys.platform == "win32": + return mode = workdir.stat().st_mode new_mode = mode | 0o0770 flag = os.environ.get("RECERTIA_WORKDIR_WORLD_WRITE", "").strip().lower() diff --git a/src/recertia/workspaces/__init__.py b/src/recertia/workspaces/__init__.py new file mode 100644 index 0000000..c7835a8 --- /dev/null +++ b/src/recertia/workspaces/__init__.py @@ -0,0 +1,5 @@ +"""Registered host workspace registry (Pilot RW0).""" + +from recertia.workspaces.registry import WorkspaceRegistry + +__all__ = ["WorkspaceRegistry"] diff --git a/src/recertia/workspaces/registry.py b/src/recertia/workspaces/registry.py new file mode 100644 index 0000000..d5e7374 --- /dev/null +++ b/src/recertia/workspaces/registry.py @@ -0,0 +1,186 @@ +"""SQLite-backed registered workspace store.""" + +from __future__ import annotations + +import sqlite3 +import threading +from datetime import datetime, timezone +from pathlib import Path + +from contracts.workspace import RegisteredWorkspace +from recertia.paths import HostRootError, normalize_host_root, validate_workspace_id + + +class WorkspaceRegistry: + def __init__(self, path: Path | str) -> None: + self.path = Path(path) + self.path.parent.mkdir(parents=True, exist_ok=True) + self._lock = threading.Lock() + self._conn = sqlite3.connect(self.path, check_same_thread=False) + self._conn.row_factory = sqlite3.Row + with self._conn: + self._conn.execute( + """ + CREATE TABLE IF NOT EXISTS workspaces ( + tenant_id TEXT NOT NULL, + workspace_id TEXT NOT NULL, + display_name TEXT NOT NULL, + host_root TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + created_by TEXT NOT NULL, + notes TEXT, + PRIMARY KEY (tenant_id, workspace_id) + ) + """ + ) + + def close(self) -> None: + self._conn.close() + + def _now(self) -> str: + return datetime.now(timezone.utc).isoformat() + + def _row_to_model(self, row: sqlite3.Row) -> RegisteredWorkspace: + return RegisteredWorkspace( + workspace_id=row["workspace_id"], + tenant_id=row["tenant_id"], + display_name=row["display_name"], + host_root=row["host_root"], + enabled=bool(row["enabled"]), + created_at=datetime.fromisoformat(row["created_at"]), + created_by=row["created_by"], + notes=row["notes"], + ) + + def register( + self, + *, + tenant_id: str, + workspace_id: str, + display_name: str, + host_root: str, + created_by: str, + notes: str | None = None, + ) -> RegisteredWorkspace: + workspace_id = validate_workspace_id(workspace_id) + try: + normalized = normalize_host_root(host_root, must_exist=True) + except HostRootError: + raise + now = self._now() + with self._lock, self._conn: + existing = self._conn.execute( + "SELECT workspace_id FROM workspaces WHERE tenant_id = ? AND workspace_id = ?", + (tenant_id, workspace_id), + ).fetchone() + if existing is not None: + raise LookupError(f"workspace_id exists: {workspace_id}") + self._conn.execute( + """ + INSERT INTO workspaces( + tenant_id, workspace_id, display_name, host_root, + enabled, created_at, created_by, notes + ) VALUES (?, ?, ?, ?, 1, ?, ?, ?) + """, + ( + tenant_id, + workspace_id, + display_name.strip(), + normalized, + now, + created_by, + notes, + ), + ) + return RegisteredWorkspace( + workspace_id=workspace_id, + tenant_id=tenant_id, + display_name=display_name.strip(), + host_root=normalized, + enabled=True, + created_at=datetime.fromisoformat(now), + created_by=created_by, + notes=notes, + ) + + def get( + self, workspace_id: str, *, tenant_id: str, enabled_only: bool = False + ) -> RegisteredWorkspace | None: + row = self._conn.execute( + """ + SELECT * FROM workspaces + WHERE tenant_id = ? AND workspace_id = ? + """, + (tenant_id, workspace_id), + ).fetchone() + if row is None: + return None + ws = self._row_to_model(row) + if enabled_only and not ws.enabled: + return None + return ws + + def list(self, *, tenant_id: str) -> list[RegisteredWorkspace]: + rows = self._conn.execute( + """ + SELECT * FROM workspaces WHERE tenant_id = ? + ORDER BY workspace_id ASC + """, + (tenant_id,), + ).fetchall() + return [self._row_to_model(r) for r in rows] + + def set_enabled( + self, workspace_id: str, *, tenant_id: str, enabled: bool + ) -> RegisteredWorkspace | None: + with self._lock, self._conn: + cur = self._conn.execute( + """ + UPDATE workspaces SET enabled = ? + WHERE tenant_id = ? AND workspace_id = ? + """, + (1 if enabled else 0, tenant_id, workspace_id), + ) + if cur.rowcount == 0: + return None + return self.get(workspace_id, tenant_id=tenant_id) + + def patch( + self, + workspace_id: str, + *, + tenant_id: str, + display_name: str | None = None, + notes: str | None = None, + enabled: bool | None = None, + clear_notes: bool = False, + ) -> RegisteredWorkspace | None: + ws = self.get(workspace_id, tenant_id=tenant_id) + if ws is None: + return None + new_name = display_name.strip() if display_name is not None else ws.display_name + if not new_name: + raise ValueError("display_name must be non-empty") + new_notes = ws.notes + if clear_notes: + new_notes = None + elif notes is not None: + new_notes = notes + new_enabled = ws.enabled if enabled is None else enabled + with self._lock, self._conn: + self._conn.execute( + """ + UPDATE workspaces + SET display_name = ?, notes = ?, enabled = ? + WHERE tenant_id = ? AND workspace_id = ? + """, + ( + new_name, + new_notes, + 1 if new_enabled else 0, + tenant_id, + workspace_id, + ), + ) + return self.get(workspace_id, tenant_id=tenant_id) diff --git a/tests/conftest.py b/tests/conftest.py index 196aff5..0ad37ed 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,3 +12,5 @@ # Tests exercise the HTTP API against the local backend; production API refuses local # unless this break-glass flag is set. os.environ.setdefault("RECERTIA_API_ALLOW_LOCAL_EXEC", "1") +# CI / non-Windows hosts: allow POSIX absolute registered roots (RW0 tests). +os.environ.setdefault("RECERTIA_ALLOW_POSIX_WORKSPACE_ROOTS", "1") diff --git a/tests/e2e/test_container_smoke.py b/tests/e2e/test_container_smoke.py index 7115087..8cb4a2b 100644 --- a/tests/e2e/test_container_smoke.py +++ b/tests/e2e/test_container_smoke.py @@ -24,6 +24,7 @@ probe_container_runtime, ) from recertia.solver.sandbox import SandboxError +from tests.support.platform import skip_posix_mode_bits def _require_working_container(monkeypatch: pytest.MonkeyPatch) -> str: @@ -51,6 +52,7 @@ def test_default_container_image_accepts_digest_pin(monkeypatch: pytest.MonkeyPa assert default_container_image().startswith("python:3.12-slim@sha256:") +@skip_posix_mode_bits def test_container_workdir_chmod_defaults_without_world_write( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -63,6 +65,7 @@ def test_container_workdir_chmod_defaults_without_world_write( assert mode & 0o002 == 0 # other-write off by default +@skip_posix_mode_bits def test_container_workdir_chmod_world_write_opt_in( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/support/__init__.py b/tests/support/__init__.py new file mode 100644 index 0000000..c47a64a --- /dev/null +++ b/tests/support/__init__.py @@ -0,0 +1 @@ +"""Shared test helpers (platform skips, symlink probes, …).""" diff --git a/tests/support/platform.py b/tests/support/platform.py new file mode 100644 index 0000000..5e59cd1 --- /dev/null +++ b/tests/support/platform.py @@ -0,0 +1,12 @@ +"""Platform-specific pytest helpers for Windows / POSIX differences.""" + +from __future__ import annotations + +import sys + +import pytest + +skip_posix_mode_bits = pytest.mark.skipif( + sys.platform == "win32", + reason="POSIX permission bits are not enforced on Windows/NTFS", +) diff --git a/tests/support/symlinks.py b/tests/support/symlinks.py new file mode 100644 index 0000000..65d24c0 --- /dev/null +++ b/tests/support/symlinks.py @@ -0,0 +1,37 @@ +"""Probe whether the host can create symlinks (Windows Developer Mode / privilege).""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +import pytest + + +def require_symlink_support(tmp_path: Path) -> None: + """Skip the calling test when symlink creation is denied (WinError 1314).""" + + probe_dir = tmp_path / "_symlink_probe" + probe_dir.mkdir(exist_ok=True) + target = probe_dir / "target.txt" + link = probe_dir / "link.txt" + target.write_text("ok", encoding="utf-8") + try: + os.symlink("target.txt", link) + except OSError as exc: + winerror = getattr(exc, "winerror", None) + # 1314: ERROR_PRIVILEGE_NOT_HELD — common without Developer Mode. + if sys.platform == "win32" and winerror == 1314: + pytest.skip( + "symlink privilege required (enable Windows Developer Mode " + "or SeCreateSymbolicLinkPrivilege)" + ) + if isinstance(exc, (NotImplementedError, OSError)) and sys.platform == "win32": + pytest.skip(f"symlink creation unsupported on this host: {exc}") + raise + finally: + if link.exists() or link.is_symlink(): + link.unlink(missing_ok=True) + if target.exists(): + target.unlink(missing_ok=True) diff --git a/tests/unit/test_api_runs.py b/tests/unit/test_api_runs.py index 53fcc89..59d60a2 100644 --- a/tests/unit/test_api_runs.py +++ b/tests/unit/test_api_runs.py @@ -136,7 +136,11 @@ def test_relative_workdir_persists_for_resume(tmp_path: Path) -> None: meta = tmp_path / "api-root" / "runs" / "t1" / "persist-wd" / "workdir.json" assert meta.exists() - assert "nested/job" in meta.read_text() or str(expected.resolve()) in meta.read_text() + import json + + payload = json.loads(meta.read_text(encoding="utf-8")) + assert Path(payload["workdir"]).resolve() == expected.resolve() + assert payload.get("kind", "sandbox") == "sandbox" # Clear in-memory cache to force resume to load persisted workdir. app.state.runs.clear() diff --git a/tests/unit/test_api_security.py b/tests/unit/test_api_security.py index 7f07a52..db5e26c 100644 --- a/tests/unit/test_api_security.py +++ b/tests/unit/test_api_security.py @@ -13,6 +13,7 @@ from recertia.api.auth import ApiKeyStore from recertia.solver.container import ensure_api_execution_ready from recertia.solver.sandbox import SandboxError +from tests.support.platform import skip_posix_mode_bits def test_api_refuses_local_backend_without_break_glass(monkeypatch: pytest.MonkeyPatch) -> None: @@ -71,6 +72,7 @@ def test_blob_upload_rejects_oversized_payload( assert resp.status_code == 413 +@skip_posix_mode_bits def test_workdir_not_world_writable_by_default( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/unit/test_code_review_hardening.py b/tests/unit/test_code_review_hardening.py index b7fb792..a832dce 100644 --- a/tests/unit/test_code_review_hardening.py +++ b/tests/unit/test_code_review_hardening.py @@ -33,6 +33,7 @@ from recertia.validation.assertions import UnsafeAssertionError, evaluate_assertion from recertia.validation.sensitivity import author_sensitivity_proof from recertia.workspace import WorkspaceManager +from tests.support.symlinks import require_symlink_support def _ctx(tmp_path: Path, *, node: str, episodic: EpisodicStore | None = None) -> NodeContext: @@ -114,6 +115,7 @@ def test_bare_path_method_attribute_is_not_truthy(tmp_path: Path) -> None: def test_grep_skips_symlinks_outside_workspace(tmp_path: Path) -> None: + require_symlink_support(tmp_path) work = tmp_path / "work" work.mkdir() secret = tmp_path / "secret.txt" diff --git a/tests/unit/test_gitignore_seed_local_exec.py b/tests/unit/test_gitignore_seed_local_exec.py new file mode 100644 index 0000000..d187baa --- /dev/null +++ b/tests/unit/test_gitignore_seed_local_exec.py @@ -0,0 +1,45 @@ +"""Regression: add-gitignore-entry must work under Windows local-exec (cmd.exe).""" + +from __future__ import annotations + +from pathlib import Path + +from recertia.memory.procedural.seeds import SEED_SKILLS +from recertia.solver.container import _local_run +from recertia.solver.sandbox import SandboxLimits + + +def test_add_gitignore_entry_command_appends_pyc_via_local_run(tmp_path: Path) -> None: + version = next(s for s in SEED_SKILLS if s.skill_id == "add-gitignore-entry") + command = version.steps[0].inputs["command"] + assert isinstance(command, str) + assert "$pattern" not in command + + work = tmp_path / "repo" + work.mkdir() + (work / ".gitignore").write_text(".venv/\n", encoding="utf-8") + + proc = _local_run(command, workdir=work, limits=SandboxLimits(), timeout_s=30) + assert proc.returncode == 0, proc.stderr + lines = (work / ".gitignore").read_text(encoding="utf-8").splitlines() + assert "*.pyc" in lines + assert '"$pattern"' not in lines + assert "$pattern" not in lines + + # Idempotent second run + proc2 = _local_run(command, workdir=work, limits=SandboxLimits(), timeout_s=30) + assert proc2.returncode == 0, proc2.stderr + assert lines.count("*.pyc") == (work / ".gitignore").read_text(encoding="utf-8").splitlines().count( + "*.pyc" + ) + + +def test_add_gitignore_cert_command_passes_via_local_run(tmp_path: Path) -> None: + version = next(s for s in SEED_SKILLS if s.skill_id == "add-gitignore-entry") + cert = version.certification_criteria[0].run + assert cert is not None + work = tmp_path / "repo" + work.mkdir() + (work / ".gitignore").write_text(".venv/\n*.pyc\n", encoding="utf-8") + proc = _local_run(cert, workdir=work, limits=SandboxLimits(), timeout_s=30) + assert proc.returncode == 0, proc.stderr diff --git a/tests/unit/test_registered_workspaces.py b/tests/unit/test_registered_workspaces.py new file mode 100644 index 0000000..3771a7d --- /dev/null +++ b/tests/unit/test_registered_workspaces.py @@ -0,0 +1,271 @@ +"""RW-* conformance: registered host workspaces (Pilot workdir bind).""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +pytest.importorskip("fastapi") +from fastapi.testclient import TestClient + +from recertia.api import create_app +from recertia.paths import looks_absolute, normalize_host_root, resolve_under_host_root, split_rel_subpath + + +def _proven_output_criterion() -> dict: + return { + "id": "output-exists", + "kind": "command", + "run": "test -f output.txt", + "source": "caller", + "weight": 1.0, + "sensitivity_proof": { + "criterion_id": "output-exists", + "negative_fixture": "empty workspace", + "rejected": True, + "checked_at": "2026-01-01T00:00:00Z", + }, + } + + +def _client(tmp_path: Path, *, scopes: set[str] | None = None): + app = create_app(root=tmp_path / "api-root") + scopes = scopes or {"runs", "exec", "admin"} + issued = app.state.api_keys.issue(tenant_id="t1", scopes=scopes, actor="test") + return app, TestClient(app), {"X-API-Key": issued.secret} + + +def test_rw1_absolute_workdir_without_workspace_still_rejected(tmp_path: Path) -> None: + _, client, headers = _client(tmp_path) + outside = tmp_path / "outside" + outside.mkdir() + res = client.post( + "/v1/runs", + json={ + "request": "x", + "run_id": "abs-wd", + "workdir": str(outside), + "budget": {"max_attempts": 1}, + }, + headers=headers, + ) + assert res.status_code == 400 + assert "absolute" in res.json()["detail"].lower() + + +def test_rw2_register_and_bind_run(tmp_path: Path) -> None: + app, client, headers = _client(tmp_path) + repo = tmp_path / "repo" + repo.mkdir() + reg = client.post( + "/v1/workspaces", + json={ + "workspace_id": "recertia", + "display_name": "test repo", + "host_root": str(repo), + }, + headers=headers, + ) + assert reg.status_code == 201, reg.text + body = reg.json() + assert body["workspace_id"] == "recertia" + assert Path(body["host_root"]) == repo.resolve() + + created = client.post( + "/v1/runs", + json={ + "request": "write output.txt", + "run_id": "bind-run", + "workspace_id": "recertia", + "script": ["python3 -c \"open('output.txt','w').write('done')\""], + "criteria": [_proven_output_criterion()], + }, + headers=headers, + ) + assert created.status_code == 200, created.text + assert (repo / "output.txt").read_text() == "done" + assert created.json()["workspace_id"] == "recertia" + meta = json.loads( + (tmp_path / "api-root" / "runs" / "t1" / "bind-run" / "workdir.json").read_text() + ) + assert meta["kind"] == "registered" + assert meta["workspace_id"] == "recertia" + + +def test_rw3_subpath_escape_rejected(tmp_path: Path) -> None: + _, client, headers = _client(tmp_path) + repo = tmp_path / "repo" + repo.mkdir() + client.post( + "/v1/workspaces", + json={"workspace_id": "w1", "display_name": "r", "host_root": str(repo)}, + headers=headers, + ) + res = client.post( + "/v1/runs", + json={ + "request": "x", + "run_id": "esc", + "workspace_id": "w1", + "workdir": "../other", + "budget": {"max_attempts": 1}, + }, + headers=headers, + ) + assert res.status_code == 400 + assert "escape" in res.json()["detail"].lower() + + +def test_rw4_cross_tenant_isolation(tmp_path: Path) -> None: + app, client, headers = _client(tmp_path) + repo = tmp_path / "repo" + repo.mkdir() + client.post( + "/v1/workspaces", + json={"workspace_id": "secret", "display_name": "r", "host_root": str(repo)}, + headers=headers, + ) + other = app.state.api_keys.issue(tenant_id="t2", scopes={"runs", "admin"}, actor="test") + other_h = {"X-API-Key": other.secret} + listed = client.get("/v1/workspaces", headers=other_h) + assert listed.status_code == 200 + assert listed.json()["workspaces"] == [] + got = client.get("/v1/workspaces/secret", headers=other_h) + assert got.status_code == 404 + bind = client.post( + "/v1/runs", + json={"request": "x", "workspace_id": "secret", "budget": {"max_attempts": 1}}, + headers=other_h, + ) + assert bind.status_code == 404 + + +def test_rw5_disabled_workspace_blocks_create(tmp_path: Path) -> None: + _, client, headers = _client(tmp_path) + repo = tmp_path / "repo" + repo.mkdir() + client.post( + "/v1/workspaces", + json={"workspace_id": "w1", "display_name": "r", "host_root": str(repo)}, + headers=headers, + ) + disabled = client.delete("/v1/workspaces/w1", headers=headers) + assert disabled.status_code == 200 + assert disabled.json()["enabled"] is False + res = client.post( + "/v1/runs", + json={"request": "x", "workspace_id": "w1", "budget": {"max_attempts": 1}}, + headers=headers, + ) + assert res.status_code == 403 + assert "disabled" in res.json()["detail"].lower() + + +def test_rw6_resume_registered_and_host_root_drift(tmp_path: Path) -> None: + app, client, headers = _client(tmp_path) + repo = tmp_path / "repo" + repo.mkdir() + client.post( + "/v1/workspaces", + json={"workspace_id": "w1", "display_name": "r", "host_root": str(repo)}, + headers=headers, + ) + created = client.post( + "/v1/runs", + json={ + "request": "write output.txt", + "run_id": "resume-reg", + "workspace_id": "w1", + "script": ["python3 -c \"open('output.txt','w').write('a')\""], + "criteria": [_proven_output_criterion()], + "budget": {"max_attempts": 1}, + }, + headers=headers, + ) + assert created.status_code == 200, created.text + + # Tamper stored host_root to force drift detection on resume. + meta_path = tmp_path / "api-root" / "runs" / "t1" / "resume-reg" / "workdir.json" + meta = json.loads(meta_path.read_text()) + meta["host_root"] = str(tmp_path / "other-root") + meta_path.write_text(json.dumps(meta) + "\n") + resumed = client.post("/v1/runs/resume-reg/resume", headers=headers) + assert resumed.status_code == 409 + assert "host_root" in resumed.json()["detail"].lower() + + +def test_rw7_pilot_submit_body_builder_includes_workspace() -> None: + """Mirror console submit payload builder (RW-7).""" + + def build_submit_body(*, workspace_id: str, subpath: str, goal: dict) -> dict: + body: dict = { + "goal": goal, + "task_class": goal.get("task_class") or "repo-chore", + "mode": "sync", + "budget": {"max_attempts": 2}, + } + if workspace_id: + body["workspace_id"] = workspace_id + body["workdir"] = subpath or "" + return body + + goal = {"goal_id": "g", "desired": [], "constraints": [], "task_class": "repo-chore"} + body = build_submit_body(workspace_id="recertia", subpath="", goal=goal) + assert body["workspace_id"] == "recertia" + assert body["workdir"] == "" + sandbox = build_submit_body(workspace_id="", subpath="", goal=goal) + assert "workspace_id" not in sandbox + + +def test_rw8_non_admin_cannot_register(tmp_path: Path) -> None: + app = create_app(root=tmp_path / "api-root") + issued = app.state.api_keys.issue(tenant_id="t1", scopes={"runs"}, actor="test") + client = TestClient(app) + repo = tmp_path / "repo" + repo.mkdir() + res = client.post( + "/v1/workspaces", + json={"workspace_id": "w1", "display_name": "r", "host_root": str(repo)}, + headers={"X-API-Key": issued.secret}, + ) + assert res.status_code == 403 + assert "admin" in res.json()["detail"].lower() + + +def test_rw9_mixed_separators_resolve_identically(tmp_path: Path) -> None: + root = tmp_path / "repo" + nested = root / "subdir" / "foo" + nested.mkdir(parents=True) + a = resolve_under_host_root(str(root), "subdir/foo") + b = resolve_under_host_root(str(root), "subdir\\foo") + assert a == b == nested.resolve() + assert split_rel_subpath("subdir/foo") == ("subdir", "foo") + assert split_rel_subpath("subdir\\foo") == ("subdir", "foo") + + +def test_normalize_host_root_posix_escape(tmp_path: Path) -> None: + root = tmp_path / "repo" + root.mkdir() + stored = normalize_host_root(str(root)) + assert Path(stored) == root.resolve() + assert looks_absolute(str(root)) + + +def test_duplicate_workspace_id_conflict(tmp_path: Path) -> None: + _, client, headers = _client(tmp_path) + repo = tmp_path / "repo" + repo.mkdir() + first = client.post( + "/v1/workspaces", + json={"workspace_id": "w1", "display_name": "r", "host_root": str(repo)}, + headers=headers, + ) + assert first.status_code == 201 + second = client.post( + "/v1/workspaces", + json={"workspace_id": "w1", "display_name": "r2", "host_root": str(repo)}, + headers=headers, + ) + assert second.status_code == 409 diff --git a/tests/unit/test_workspace_security.py b/tests/unit/test_workspace_security.py index 5221c6d..51efa3a 100644 --- a/tests/unit/test_workspace_security.py +++ b/tests/unit/test_workspace_security.py @@ -10,6 +10,7 @@ from recertia.ids import InvalidIdError, validate_run_id from recertia.paths import PathEscapeError, contained_path from recertia.workspace import WorkspaceManager +from tests.support.symlinks import require_symlink_support def test_validate_run_id_rejects_path_escape() -> None: @@ -28,6 +29,7 @@ def test_contained_path_rejects_escape(tmp_path: Path) -> None: def test_snapshot_skips_outbound_symlink_exfil(tmp_path: Path) -> None: + require_symlink_support(tmp_path) secrets = tmp_path / "secrets" secrets.mkdir() secret_file = secrets / "api_keys.sqlite" @@ -60,6 +62,7 @@ def test_snapshot_skips_outbound_symlink_exfil(tmp_path: Path) -> None: def test_snapshot_preserves_internal_relative_symlink(tmp_path: Path) -> None: + require_symlink_support(tmp_path) workdir = tmp_path / "workdir" workdir.mkdir() (workdir / "target.txt").write_text("inside", encoding="utf-8")