From 92f4e5ed3e7b1607914e06bf4a7db4b7c4608b1d Mon Sep 17 00:00:00 2001 From: Hasan TASKIN Date: Fri, 28 Aug 2026 17:28:06 +0200 Subject: [PATCH 01/36] feat: microvm isolation, runbook scan and mechanical verification --- CHANGELOG.md | 17 + README.md | 54 +- bun.lock | 19 +- docs/internals.md | 27 + package.json | 2 +- packages/cli/package.json | 5 +- packages/cli/src/config.test.ts | 4 +- packages/cli/src/config.ts | 18 +- packages/cli/src/finding-repro.test.ts | 32 +- packages/cli/src/finding-repro.ts | 5 +- packages/cli/src/hub-client.test.ts | 308 ++- packages/cli/src/hub-client.ts | 134 ++ packages/cli/src/i18n.ts | 6 + packages/cli/src/index.test.ts | 29 +- packages/cli/src/index.ts | 11 +- packages/cli/src/microsandbox-driver.test.ts | 1699 +++++++++++++++++ packages/cli/src/microsandbox-driver.ts | 955 +++++++++ packages/cli/src/microvm-snapshot.test.ts | 436 +++++ packages/cli/src/microvm-snapshot.ts | 163 ++ packages/cli/src/microvm-turn.test.ts | 646 +++++++ packages/cli/src/microvm-turn.ts | 412 ++++ packages/cli/src/review.test.ts | 62 + packages/cli/src/review.ts | 18 + packages/cli/src/runbook-runner.test.ts | 776 ++++++++ packages/cli/src/runbook-runner.ts | 534 ++++++ packages/cli/src/runbook-setup.test.ts | 344 ++++ packages/cli/src/runbook-setup.ts | 332 ++++ packages/cli/src/runner-commands.test.ts | 189 +- packages/cli/src/runner-commands.ts | 121 +- packages/cli/src/runner-daemon.test.ts | 294 ++- packages/cli/src/runner-daemon.ts | 132 +- packages/cli/src/task-checks.test.ts | 593 ++++++ packages/cli/src/task-checks.ts | 324 +++- packages/cli/src/task-criteria.test.ts | 2 + packages/cli/src/task-isolation.test.ts | 208 ++ packages/cli/src/task-isolation.ts | 135 +- packages/cli/src/task-review.test.ts | 621 ++++++ packages/cli/src/task-review.ts | 221 ++- packages/cli/src/task-runner.test.ts | 240 +++ packages/cli/src/task-runner.ts | 123 +- packages/cli/src/task-server.test.ts | 898 +++++++++ packages/cli/src/task-server.ts | 431 ++++- packages/cli/src/task-ship.test.ts | 302 +++ packages/cli/src/task-ship.ts | 296 ++- packages/cli/src/task-verification.test.ts | 618 ++++++ packages/cli/src/task-verification.ts | 296 +++ packages/cli/src/workspace.ts | 13 +- packages/cli/tsdown.config.ts | 1 + .../hub-schemas/runbook-scan-fail.schema.json | 13 + .../runbook-scan-result.schema.json | 151 ++ .../hub-schemas/transitions.schema.json | 15 +- .../hub-schemas/verification.schema.json | 149 ++ packages/contract/package.json | 2 +- .../contract/scripts/sync-hub-schemas.mjs | 3 + packages/contract/src/arm.test.ts | 131 +- packages/contract/src/index.ts | 1 + packages/contract/src/runbook.test.ts | 481 +++++ packages/contract/src/runbook.ts | 324 ++++ .../src/schema-validator.test-helper.ts | 137 ++ packages/contract/src/tasks.test.ts | 277 +++ packages/contract/src/tasks.ts | 134 +- .../web/src/components/TaskComposer.test.ts | 7 +- packages/web/src/components/TaskComposer.vue | 4 +- .../web/src/composables/useIsolation.test.ts | 23 + packages/web/src/composables/useIsolation.ts | 23 +- .../web/src/composables/useTaskPlan.test.ts | 12 + packages/web/src/composables/useTaskPlan.ts | 7 +- packages/web/src/i18n.ts | 12 +- packages/web/src/types.ts | 4 +- 69 files changed, 14697 insertions(+), 319 deletions(-) create mode 100644 packages/cli/src/microsandbox-driver.test.ts create mode 100644 packages/cli/src/microsandbox-driver.ts create mode 100644 packages/cli/src/microvm-snapshot.test.ts create mode 100644 packages/cli/src/microvm-snapshot.ts create mode 100644 packages/cli/src/microvm-turn.test.ts create mode 100644 packages/cli/src/microvm-turn.ts create mode 100644 packages/cli/src/runbook-runner.test.ts create mode 100644 packages/cli/src/runbook-runner.ts create mode 100644 packages/cli/src/runbook-setup.test.ts create mode 100644 packages/cli/src/runbook-setup.ts create mode 100644 packages/cli/src/task-verification.test.ts create mode 100644 packages/cli/src/task-verification.ts create mode 100644 packages/contract/fixtures/hub-schemas/runbook-scan-fail.schema.json create mode 100644 packages/contract/fixtures/hub-schemas/runbook-scan-result.schema.json create mode 100644 packages/contract/fixtures/hub-schemas/verification.schema.json create mode 100644 packages/contract/src/runbook.test.ts create mode 100644 packages/contract/src/runbook.ts create mode 100644 packages/contract/src/schema-validator.test-helper.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4517d71..555e1b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,23 @@ All notable changes to `codesema` (the npm package in `packages/cli`) are documented here. Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [SemVer](https://semver.org). +## [0.20.0] - 2026-08-28 + +### Added + +- **`isolation: "microvm"`: a task's turn runs in a disposable Microsandbox VM.** Beside `container` and `policy`, the runner can now boot a throwaway microVM (libkrun/KVM, `microsandbox` SDK 0.6.15 as an optional dependency) per turn: the worktree is copied in, the agent runs as a non-root user with the same tools as the cage, the result is copied back and the VM is destroyed. Never picked by `auto`; `codesema runner serve` refuses to create a `microvm` task when the workspace was not probed for it (`msb doctor` is the way out). The seam is `SandboxDriver` (`microsandbox-driver.ts`, with a `FakeSandboxDriver` for every test) and `runMicrovmTurn`. +- **The runbook: how a repository is installed, started and tested, validated by actually doing it.** `codesema runbook scan` lets an agent propose `.codesema/runbook.json` (image, install, services, healthchecks, tests, egress allowlist, `depends_on_files`), then replays it in a microVM and only keeps a runbook whose tests pass. The runner daemon claims and runs one queued hub runbook scan per tick in `microvm` mode. `@codesema/contract` 0.11.0 carries `RunbookConfig`, `RunbookValidation`, `RunbookScan` and their sanitizers. +- **Mechanical verification of a microvm task.** After a turn's commit and before the review, `verifyTask` replays the validated runbook's `tests` in a fresh VM restored from the project snapshot; a drift of `depends_on_files` since validation refuses the verification outright. The verdict (`TaskVerification`) is persisted, reported to the hub, folded into the reviewer's prompt as a mandatory chapter, and a refused or failed verification sends the task back exactly like red checks. +- **Warm per-project snapshot.** `microvm-snapshot.ts` builds one snapshot per project (image + `install` + services), keyed by a fingerprint of lockfiles, compose file and canonical runbook; older ones are purged, and a runbook needing a flat root disk (dockerd) falls back to a cold boot. +- **Checks, review and ship run in their own sandboxes for a microvm task.** `runChecks`, `runAdHocCheck` and `bootstrapWorktreeInstall` accept a `StepExecutor` (`microvmStepExecutor`); the reviewer runs in a read-only VM distinct from the dev VM (`runMicrovmReview`, network limited to the Anthropic hosts); the push and the merge request come from a dedicated `codesema-gitops-` sandbox with the forge token carried as a placeholder secret only, never in argv or env. Orphaned `codesema-*` sandboxes are swept at boot. +- **Hub contract for the runbook loop.** New `hub-client` calls: `listRunbookScans`, `claimRunbookScan`, `reportRunbookScanResult`, `failRunbookScan`, `currentRunbook`, `verification`; the `merged` transition carries `changed_files` so the hub can mark a runbook stale. + +### Changed + +- **`TaskIsolation` gains `microvm`** (`@codesema/contract`, CLI config, web UI badge and plan): a task record carries `runbook_sha`/`runbook_integrity` when it was verified against a runbook. +- **The reviewer resolves the microvm snapshot, runbook and mechanical verification per task turn** (`resolveReviewContext`) instead of freezing them once per project, and finding repro checks of a VM-isolated task run through the injected microvm `StepExecutor` instead of always falling back to docker/podman. +- **The web workspace shows a microVM badge, plan label and build-image hint** for `microvm` tasks instead of folding them into `policy`. + ## [0.19.0] - 2026-08-28 ### Added diff --git a/README.md b/README.md index fb2dbac..db4f072 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,9 @@ From the page you can also: **Checks run in a sandbox.** Alongside the review, typecheck, tests and lint run in an ephemeral `docker` or `podman` container mounted on the task's worktree, with `--network none` and cpu/memory caps. The plan comes from your `checks` key, otherwise from what the repository already declares (lefthook hooks, CI workflow jobs, filtered through a command allowlist), otherwise from the lockfile and the `typecheck`/`test`/`lint` scripts of `package.json`. Checks never block a task: they are a second opinion next to the review. -**Tasks can be caged.** With a container runtime available, a task runs inside a container built from your `.devcontainer` (or `node:26`): the worktree is the only writable host mount, the git directory is mounted read-only, and the only network exit is a proxy restricted to the agent's own API domains (`isolationAllowedDomains`). Commits stay on the host, so your git credentials never enter the container. `isolation` picks the mode: `auto` (default, falls back to host hardening and says why), `container` (mandatory) or `policy` (always on the host). `claude` and `opencode` are cageable today. +**Tasks can be caged.** With a container runtime available, a task runs inside a container built from your `.devcontainer` (or `node:26`): the worktree is the only writable host mount, the git directory is mounted read-only, and the only network exit is a proxy restricted to the agent's own API domains (`isolationAllowedDomains`). Commits stay on the host, so your git credentials never enter the container. `isolation` picks the mode: `auto` (default, falls back to host hardening and says why), `container` (mandatory), `policy` (always on the host) or `microvm` (mandatory, a disposable Microsandbox VM instead of a container). `claude` and `opencode` are cageable today. + +**Or run in a disposable microVM.** Set `isolation: "microvm"` and a task's whole turn runs inside a fresh [Microsandbox](https://microsandbox.dev) VM instead of a container — stronger isolation than a container's namespaces, at the cost of a heavier, beta dependency. Requires Linux with `/dev/kvm` readable and writable by your user (`msb doctor` checks this) and a filesystem with reflink support (btrfs, xfs) — without it every VM copies its whole root disk on boot instead of a near-instant clone. The agent runs as a non-root guest user with no view of the host beyond a copy of the task's worktree (copied in before the turn, copied back after — there is no live mount), and the same domain allowlist as the container cage, substituted by the VM's own network proxy: the agent's provider credentials reach the guest only as `$MSB_` placeholders, never as a value it could read or leak. Unlike `container`, `auto` never picks `microvm` on its own — it is beta software with a heavier host requirement, so it only ever runs when you set it explicitly. **Statuses.** A task moves through `queued`, `running`, `waiting_for_you` (the agent ended its turn on a question), `reviewing`, then `review_ok` or `review_ko`, then `shipped` once the branch is pushed and the merge request opened via `gh`/`glab`. `interrupted` covers a turn cut short by Ctrl-C, a crash or the Stop button: the worktree and the agent session are kept, and a Resume button restarts that exact turn. Nothing restarts by itself at the next boot. @@ -122,31 +124,31 @@ Settings live in two files, and CLI flags win over both: Some keys are global only: they govern the machine (its load, its disk) or give a consent (merging, spending turns), so a cloned repository cannot set them on your behalf. A repository file that does is ignored, and says so at startup. -| Key | Default | Scope | -| ---------------------------------------------------------- | ----------------------------------- | --------------- | -| `agent`, `agentId`, `model`, `effort` | from the wizard | both | -| `language` | asked once (`en`, `fr`) | both | -| `target` | auto-detected | both | -| `port` | `4400` | both | -| `timeout` | `900` seconds | both | -| `reviewMode` | `simple` (or `dual`) | both | -| `maxAutoFixRounds` | `2` | both | -| `isolation` | `auto` (`container`, `policy`) | both | -| `isolationAllowedDomains` | the agent's own API domains | both | -| `forgeCycleLabels` | `false` | both | -| `checks` | inferred from the repository | repository file | -| `watchdogInactivitySeconds` | `1800` | both | -| `watchdogToolBudgetSeconds` | `7200` | both | -| `watchdogHeartbeatSeconds` | `30` | both | -| `maxConcurrentAgents` | `4` | global only | -| `taskRetentionCount` | `20` finished tasks per project | global only | -| `maxTaskTurns` | `30` | global only | -| `mergePolicy` | `human` (or `auto`) | global only | -| `mergeStrategy` | unset (`merge`, `squash`, `rebase`) | global only | -| `deleteBranchAfterMerge` | `false` | global only | -| `allowMergeWithoutChecks` | `false` | global only | -| `runnerAutoMerge` | `true` | global only | -| `syncUrl`, `syncWorkspaceId`, `syncSecret`, `syncAutoPush` | unset | global only | +| Key | Default | Scope | +| ---------------------------------------------------------- | ----------------------------------------- | --------------- | +| `agent`, `agentId`, `model`, `effort` | from the wizard | both | +| `language` | asked once (`en`, `fr`) | both | +| `target` | auto-detected | both | +| `port` | `4400` | both | +| `timeout` | `900` seconds | both | +| `reviewMode` | `simple` (or `dual`) | both | +| `maxAutoFixRounds` | `2` | both | +| `isolation` | `auto` (`container`, `policy`, `microvm`) | both | +| `isolationAllowedDomains` | the agent's own API domains | both | +| `forgeCycleLabels` | `false` | both | +| `checks` | inferred from the repository | repository file | +| `watchdogInactivitySeconds` | `1800` | both | +| `watchdogToolBudgetSeconds` | `7200` | both | +| `watchdogHeartbeatSeconds` | `30` | both | +| `maxConcurrentAgents` | `4` | global only | +| `taskRetentionCount` | `20` finished tasks per project | global only | +| `maxTaskTurns` | `30` | global only | +| `mergePolicy` | `human` (or `auto`) | global only | +| `mergeStrategy` | unset (`merge`, `squash`, `rebase`) | global only | +| `deleteBranchAfterMerge` | `false` | global only | +| `allowMergeWithoutChecks` | `false` | global only | +| `runnerAutoMerge` | `true` | global only | +| `syncUrl`, `syncWorkspaceId`, `syncSecret`, `syncAutoPush` | unset | global only | `maxParallelTasks` is the former name of `maxConcurrentAgents`. It is still honoured, with a warning at startup. diff --git a/bun.lock b/bun.lock index 8de2d1e..741641d 100644 --- a/bun.lock +++ b/bun.lock @@ -19,7 +19,7 @@ }, "packages/cli": { "name": "codesema", - "version": "0.15.0", + "version": "0.19.0", "bin": { "codesema": "dist/index.mjs", }, @@ -29,10 +29,13 @@ "tsdown": "^0.22.5", "typescript": "^6.0.3", }, + "optionalDependencies": { + "microsandbox": "0.6.15", + }, }, "packages/contract": { "name": "@codesema/contract", - "version": "0.8.0", + "version": "0.11.0", "devDependencies": { "@types/node": "^26.1.1", "ajv": "^8.20.0", @@ -309,6 +312,16 @@ "@simple-libs/stream-utils": ["@simple-libs/stream-utils@2.0.0", "", {}, "sha512-fCTuZK4QBa+39Oz9l4OGfJfz+GpwCp3AqO7Zch3to99xHPgstVsRFpeQ8LNd2o1Gv8raL2mCFwiaHh7bFSp5DQ=="], + "@superradcompany/microsandbox-darwin-arm64": ["@superradcompany/microsandbox-darwin-arm64@0.6.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-arT8juteFRWc27TcdSk5rGGqjAPOBGsjxYht1DJvPUQzktbU8D/KQbNnV2cujkKJGF2RcwM+FRhKdq1NokIucw=="], + + "@superradcompany/microsandbox-linux-arm64-gnu": ["@superradcompany/microsandbox-linux-arm64-gnu@0.6.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-0P1+TPzC7CB0VcxLR4fRyfXdkz5H3y/eZqHKDuMxS/sjf6frRTLYUSxLDOXU8pCY2XHmoZ4DD+cJJNSJrkV3Wg=="], + + "@superradcompany/microsandbox-linux-x64-gnu": ["@superradcompany/microsandbox-linux-x64-gnu@0.6.15", "", { "os": "linux", "cpu": "x64" }, "sha512-+ljPbTmcG9PCMPspNWDEbTXuyLxvOwI0Y5MJaiIs1IRwhrlrRHVlCOCrrdEIEvoVYo1t4So+OVLWYQIMyi8POQ=="], + + "@superradcompany/microsandbox-win32-arm64-msvc": ["@superradcompany/microsandbox-win32-arm64-msvc@0.6.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-/00pVSb5iRcxfZJHlKhIzmTxH72yG8RgLiBMsyf+olhHviJierV7cDDRB8Pqttz+ait3GgkS3ide6tuoLmy7Nw=="], + + "@superradcompany/microsandbox-win32-x64-msvc": ["@superradcompany/microsandbox-win32-x64-msvc@0.6.15", "", { "os": "win32", "cpu": "x64" }, "sha512-7BuxC/sC790Af/33y2Ti44y5+B8NBd91DYDqjrTWA61TTaEm+8jHVInIUVMios3e7VjTdluYWGXNNhtcrn71OQ=="], + "@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="], @@ -677,6 +690,8 @@ "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], + "microsandbox": ["microsandbox@0.6.15", "", { "optionalDependencies": { "@superradcompany/microsandbox-darwin-arm64": "0.6.15", "@superradcompany/microsandbox-linux-arm64-gnu": "0.6.15", "@superradcompany/microsandbox-linux-x64-gnu": "0.6.15", "@superradcompany/microsandbox-win32-arm64-msvc": "0.6.15", "@superradcompany/microsandbox-win32-x64-msvc": "0.6.15" }, "bin": { "microsandbox": "bin/microsandbox.cjs", "msb": "bin/microsandbox.cjs" } }, "sha512-YomYVgvlownnQTK6we1t5ppAOIbqjGMREuIGsedYwDotDZwj8SCeIkidNVczOoNOTUDbkj/ZGhzPmAvjArO2PA=="], + "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], diff --git a/docs/internals.md b/docs/internals.md index 8b2a692..ee6d84d 100644 --- a/docs/internals.md +++ b/docs/internals.md @@ -33,6 +33,33 @@ the environment of the processes it spawns. argv would be readable in `ps` on the whole host. `CLAUDE_CODE_OAUTH_TOKEN` also decides how the cage bootstraps its credentials — when it is set, nothing is copied out of `~/.claude`. +- **A microVM task agent never sees a credential at all, only a placeholder.** + `microvmSecretsFromEnv` (`task-isolation.ts`) turns the same names + `CAGE_FORWARDED_ENV` would forward as env into `SandboxSecret`s declared on + the `SandboxBuilder`, each scoped to the hosts it may reach + (`api.anthropic.com`/`platform.claude.com` for Claude, the opencode/model + gateway hosts for its provider keys). The guest's `env` shows only + `$MSB_`; the microVM runtime's network proxy substitutes the real + value ONLY on the way out, to an `allowedHosts` domain — never inside the + guest, never in argv, never in a plain env var (spike of 2026-08-28, + criterion 6). `ANTHROPIC_BASE_URL`/`ANTHROPIC_MODEL`/`ANTHROPIC_SMALL_FAST_MODEL` + are the one exception: configuration the agent CLI reads directly, not a + credential, so `microvmNonSecretEnv` forwards those three as plain sandbox + env instead. `isolation: "microvm"` is never chosen by `auto` — only an + explicit opt-in runs a task in a microVM (config.ts, `IsolationMode`). +- **A microVM task copies the worktree in and back out; it never mounts it.** + `runMicrovmTurn` (`microvm-turn.ts`) has no bind-mount to work with + (`SandboxHandle` only offers `copyFromHost`/`copyToHost`), so it copies the + worktree into the sandbox before the turn and copies `/work` back onto the + host worktree after — success or failure, always, before the sandbox is + destroyed. A linked worktree's `.git` is a one-line file pointing at a HOST + path, which means nothing inside the guest, so the shared git directory is + copied in too and the guest's `.git` is rewritten to point at that copy + instead (same visibility the container flow gets from a read-only bind + mount, reached here by copy). That synthetic pointer, and any + `node_modules` tree, are removed inside the guest before the copy back — + copying the pointer over the host's real `.git` would corrupt the worktree, + since the commit itself always happens on the host, never inside the guest. - **Git subprocesses lose the repo-location variables.** `subprocessEnv` (`git.ts`) strips the 8 variables git sets on the hooks it invokes — `GIT_DIR`, `GIT_WORK_TREE`, `GIT_INDEX_FILE`, `GIT_OBJECT_DIRECTORY`, diff --git a/package.json b/package.json index 73e0dc4..2a10d4c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codesema-tools", - "version": "0.19.0", + "version": "0.20.0", "private": true, "type": "module", "workspaces": [ diff --git a/packages/cli/package.json b/packages/cli/package.json index 0f18632..4052e9a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "codesema", - "version": "0.19.0", + "version": "0.20.0", "description": "Local merge request review, step by step. Your AI agent reviews, codesema displays.", "license": "MIT", "author": "Hasan TASKIN", @@ -42,5 +42,8 @@ "@codesema/contract": "workspace:*", "tsdown": "^0.22.5", "typescript": "^6.0.3" + }, + "optionalDependencies": { + "microsandbox": "0.6.15" } } diff --git a/packages/cli/src/config.test.ts b/packages/cli/src/config.test.ts index 975a328..8c29bd6 100644 --- a/packages/cli/src/config.test.ts +++ b/packages/cli/src/config.test.ts @@ -227,8 +227,8 @@ describe('isolation configuration', () => { rmSync(repoDir, { recursive: true, force: true }) }) - test('the three modes survive a round-trip, in either scope', () => { - for (const isolation of ['auto', 'container', 'policy'] as const) { + test('the four modes survive a round-trip, in either scope', () => { + for (const isolation of ['auto', 'container', 'policy', 'microvm'] as const) { saveRepoConfig(repoDir, { isolation }) expect(loadRepoConfig(repoDir).isolation).toBe(isolation) } diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index ce02566..4c5fa59 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -72,7 +72,9 @@ export type CodesemaConfig = { * per-task container when a container runtime is available and the agent * image builds, and falls back to the host policy hardening otherwise; * 'container' requires the cage (task creation 409s without it); 'policy' - * always runs on the host. + * always runs on the host; 'microvm' requires a disposable Microsandbox VM + * (task creation 409s without one) and is never chosen by 'auto' — it must + * be set explicitly. */ isolation?: IsolationMode | undefined /** Domains the caged agent may reach through the egress proxy (CONNECT only). */ @@ -331,10 +333,18 @@ export function resolveMaxTaskTurns(config: CodesemaConfig): number { return config.maxTaskTurns ?? 30 } -/** Configured isolation policy for workspace tasks (see CodesemaConfig.isolation). */ -export type IsolationMode = 'auto' | 'container' | 'policy' +/** + * Configured isolation policy for workspace tasks (see CodesemaConfig.isolation). + * 'microvm' is NEVER chosen by 'auto': it requires the explicit opt-in + * `isolation: "microvm"`, unlike 'container' which 'auto' does pick when a + * runtime is available. A microVM is a heavier, less common dependency + * (/dev/kvm, the Microsandbox SDK) than a container runtime, and it is beta + * software (spike of 2026-08-28) — 'auto' upgrading a project into it without + * being asked would be a surprise, not a convenience. + */ +export type IsolationMode = 'auto' | 'container' | 'policy' | 'microvm' -const ISOLATION_MODES: ReadonlySet = new Set(['auto', 'container', 'policy']) +const ISOLATION_MODES: ReadonlySet = new Set(['auto', 'container', 'policy', 'microvm']) export function isIsolationMode(value: unknown): value is IsolationMode { return typeof value === 'string' && ISOLATION_MODES.has(value) diff --git a/packages/cli/src/finding-repro.test.ts b/packages/cli/src/finding-repro.test.ts index 6cd0617..4740a46 100644 --- a/packages/cli/src/finding-repro.test.ts +++ b/packages/cli/src/finding-repro.test.ts @@ -9,7 +9,7 @@ import { isBehaviorAsserting, verifyFindingRepros, } from './finding-repro.js' -import type { ExecFn, ExecResult } from './task-checks.js' +import type { ExecFn, ExecResult, StepExecutor, StepExecutorInput } from './task-checks.js' let worktree: string @@ -185,6 +185,36 @@ describe('verifyFindingRepros', () => { expect(calls.some((c) => c.args.at(-1) === lastCommand)).toBe(false) }) + // task-review.ts wires a `microvmStepExecutor` in here for a 'microvm' + // task's review: an injected `executor` must be what actually runs the + // repro, docker/podman detection skipped entirely (task-checks.ts's own + // `runAdHocCheck` doc comment). + test('an injected executor runs the repro instead of docker/podman detection', async () => { + const finding = majorFinding({ repro: { command: 'exit 1', expected: 'the bug fires' } }) + const executorCalls: StepExecutorInput[] = [] + const executor: StepExecutor = (input) => { + executorCalls.push(input) + return Promise.resolve({ + code: 1, + stdout: '', + stderr: 'boom', + timedOut: false, + failure: null, + }) + } + const { exec, calls } = dockerRig(() => ok()) + + const result = await verifyFindingRepros([finding], { worktree, execFn: exec, executor }) + + expect(result.findings[0]).toBe(finding) + expect(result.report).toEqual({ demoted: 0, verified: 1 }) + expect(executorCalls).toHaveLength(1) + expect(executorCalls[0]?.command).toBe('exit 1') + // Docker/podman detection never ran: the injected executor bypasses it + // entirely, so the docker exec rig saw nothing at all. + expect(calls).toHaveLength(0) + }) + test('a finding outside the behavior-asserting set never consumes the execution cap', async () => { const findings: Finding[] = [ ...Array.from({ length: FINDING_REPRO_MAX_EXECUTIONS_PER_TURN }, (_, i) => diff --git a/packages/cli/src/finding-repro.ts b/packages/cli/src/finding-repro.ts index df29930..0a02732 100644 --- a/packages/cli/src/finding-repro.ts +++ b/packages/cli/src/finding-repro.ts @@ -7,7 +7,7 @@ // import FINDING_REPRO_RULE with no import cycle between them. import type { Finding } from './contract.js' -import { runAdHocCheck, type ExecFn } from './task-checks.js' +import { runAdHocCheck, type ExecFn, type StepExecutor } from './task-checks.js' /** * D24: the rule a reviewer's prompt states verbatim, shared by @@ -40,6 +40,8 @@ export type FindingReproContext = { /** The task's worktree: the same sandbox `resolveMechanicalCriteria`'s ad hoc `command` runs in (task-criteria-gate.ts). */ worktree: string execFn?: ExecFn + /** Where the repro command runs: the docker/podman executor by default, `microvmStepExecutor` for a 'microvm' task's review (task-review.ts). */ + executor?: StepExecutor } export type FindingReproReport = { @@ -95,6 +97,7 @@ export async function verifyFindingRepros( command: finding.repro.command, timeoutSeconds: FINDING_REPRO_TIMEOUT_SECONDS, ...(ctx.execFn ? { execFn: ctx.execFn } : {}), + ...(ctx.executor ? { executor: ctx.executor } : {}), }) report.verified++ // exit_code null covers both a timeout and a synthetic engine failure diff --git a/packages/cli/src/hub-client.test.ts b/packages/cli/src/hub-client.test.ts index d8a15a9..d63fa1a 100644 --- a/packages/cli/src/hub-client.test.ts +++ b/packages/cli/src/hub-client.test.ts @@ -2,26 +2,40 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, test } from 'bun:test' -import type { ArmTicket, ArmTicketRequest, RunnerListEntry } from './contract.js' +import type { + ArmTicket, + ArmTicketRequest, + RunbookConfig, + RunbookScan, + RunbookValidation, + RunnerListEntry, + TaskVerification, +} from './contract.js' import { claimPendingSecret, + claimRunbookScan, claimTicket, claimTicketRequest, createTicket, + currentRunbook, depositRunnerSecret, + failRunbookScan, failTicketRequest, getTicket, heartbeat, hubErrorMessage, listInFlightTickets, + listRunbookScans, listRunners, listTicketRequests, listTickets, parseHubToken, pushEvents, registerRunnerKey, + reportRunbookScanResult, submitTicketRequestTickets, transition, + verification, } from './hub-client.js' import { loadOrCreateRunnerIdentity } from './runner-identity.js' import type { SyncCredentials } from './sync.js' @@ -73,6 +87,45 @@ const validRequest: ArmTicketRequest = { created_at: '2026-01-01T00:00:00.000Z', } +const validRunbook: RunbookConfig = { + version: 1, + image: 'node:26', + install: ['npm install'], + services: { host_up: [], compose_file: null }, + healthchecks: [], + tests: ['npm test'], + egress: ['registry.npmjs.org'], + depends_on_files: ['package.json'], +} + +const validRunbookValidation: RunbookValidation = { + runbook_sha: '0123456789abcdef', + validated_sha: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2', + validated_at: '2026-01-01T00:00:00.000Z', + status: 'valid', +} + +const validRunbookScan: RunbookScan = { + id: '11111111-1111-1111-1111-111111111111', + repo_id: '22222222-2222-2222-2222-222222222222', + repo_full_name: 'o/r', + head_sha: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2', + status: 'queued', + requested_at: '2026-01-01T00:00:00.000Z', +} + +const validVerification: TaskVerification = { + head_sha: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2', + runbook_sha: '0123456789abcdef', + started_at: '2026-01-01T00:00:00.000Z', + finished_at: '2026-01-01T00:05:00.000Z', + status: 'passed', + checks: [], + integrity_ok: true, + changed_dependency_files: [], + error: null, +} + /** A sha256 hex digest (fingerprint) and a base64-encoded 32-byte key (public_key): the exact shapes `sanitizeRunnerListEntry` requires, not placeholders. */ const validRunnerListEntry: RunnerListEntry = { name: 'my-laptop', @@ -547,3 +600,256 @@ describe('runner identity header propagation', () => { expect(headers?.['x-codesema-runner']).toBe(identity.fingerprint) }) }) + +describe('verification', () => { + test('posts to the ticket-scoped route and parses id + created', async () => { + const calls: Call[] = [] + const result = await verification( + creds, + 't1', + { ...validVerification, idempotency_key: 't1:verify:1' }, + fetchStub(200, { id: 'v1', created: true }, calls), + ) + expect(result).toEqual({ ok: true, data: { id: 'v1', created: true } }) + expect(calls[0]?.url).toBe('https://hub.example/api/cli/tickets/t1/verification') + const body = JSON.parse(String(calls[0]?.init.body)) as Record + expect(body.status).toBe('passed') + expect(body.idempotency_key).toBe('t1:verify:1') + }) + + test('encodes the ticket id in the path', async () => { + const calls: Call[] = [] + await verification( + creds, + 't 1/weird', + { ...validVerification, idempotency_key: 'k' }, + fetchStub(200, { id: 'v1', created: false }, calls), + ) + expect(calls[0]?.url).toBe('https://hub.example/api/cli/tickets/t%201%2Fweird/verification') + }) + + test('a malformed response body is refused', async () => { + const result = await verification( + creds, + 't1', + { ...validVerification, idempotency_key: 'k' }, + fetchStub(200, { id: 'v1' }, []), + ) + expect(result.ok).toBe(false) + }) + + test('a 5xx carries its status and message', async () => { + const result = await verification( + creds, + 't1', + { ...validVerification, idempotency_key: 'k' }, + fetchStub(500, { error: 'db down' }, []), + ) + expect(result).toEqual({ ok: false, error: { kind: 'http', status: 500, error: 'db down' } }) + }) + + test('a network failure is reported as such', async () => { + const result = await verification( + creds, + 't1', + { ...validVerification, idempotency_key: 'k' }, + fetchOffline(), + ) + expect(result).toEqual({ ok: false, error: { kind: 'network' } }) + }) +}) + +describe('listRunbookScans', () => { + test('parses a valid collection response', async () => { + const calls: Call[] = [] + const result = await listRunbookScans( + creds, + fetchStub(200, { scans: [validRunbookScan] }, calls), + ) + expect(result).toEqual({ ok: true, data: [validRunbookScan] }) + expect(calls[0]?.url).toBe('https://hub.example/api/cli/runbook-scans') + expect(calls[0]?.init.method).toBe('GET') + }) + + test('a malformed item is dropped, not refusing the whole list', async () => { + const result = await listRunbookScans( + creds, + fetchStub(200, { scans: [validRunbookScan, { nope: true }] }, []), + ) + expect(result).toEqual({ ok: true, data: [validRunbookScan] }) + }) + + test('a 404 on this collection route degrades to unavailable, not a hard error', async () => { + const result = await listRunbookScans(creds, fetchStub(404, { error: 'not found' }, [])) + expect(result).toEqual({ ok: false, error: { kind: 'unavailable' } }) + }) + + test('a network failure is reported as such', async () => { + const result = await listRunbookScans(creds, fetchOffline()) + expect(result).toEqual({ ok: false, error: { kind: 'network' } }) + }) +}) + +describe('claimRunbookScan', () => { + test('parses the claimed scan and its lease', async () => { + const result = await claimRunbookScan( + creds, + validRunbookScan.id, + {}, + fetchStub(200, { scan: validRunbookScan, lease_expires_at: '2026-01-01T00:05:00.000Z' }, []), + ) + expect(result).toEqual({ + ok: true, + data: { scan: validRunbookScan, lease_expires_at: '2026-01-01T00:05:00.000Z' }, + }) + }) + + test('sends lease_seconds only when given', async () => { + const calls: Call[] = [] + await claimRunbookScan( + creds, + validRunbookScan.id, + { leaseSeconds: 120 }, + fetchStub( + 200, + { scan: validRunbookScan, lease_expires_at: '2026-01-01T00:05:00.000Z' }, + calls, + ), + ) + const body = JSON.parse(String(calls[0]?.init.body)) as { lease_seconds: number } + expect(body.lease_seconds).toBe(120) + }) + + test('a by-id 404 is a normal http error, not unavailable', async () => { + const result = await claimRunbookScan( + creds, + 'missing', + {}, + fetchStub(404, { error: 'gone' }, []), + ) + expect(result).toEqual({ ok: false, error: { kind: 'http', status: 404, error: 'gone' } }) + }) + + test('a malformed response body is refused', async () => { + const result = await claimRunbookScan( + creds, + validRunbookScan.id, + {}, + fetchStub(200, { scan: validRunbookScan }, []), + ) + expect(result.ok).toBe(false) + }) +}) + +describe('reportRunbookScanResult', () => { + test('sends the runbook and validation, parses the ack', async () => { + const calls: Call[] = [] + const result = await reportRunbookScanResult( + creds, + validRunbookScan.id, + { runbook: validRunbook, validation: validRunbookValidation, log_tail: 'all green' }, + fetchStub(200, { runbook_id: 'rb1', already_recorded: false }, calls), + ) + expect(result).toEqual({ ok: true, data: { runbook_id: 'rb1', already_recorded: false } }) + const body = JSON.parse(String(calls[0]?.init.body)) as Record + expect(body.runbook).toEqual(validRunbook) + expect(body.validation).toEqual(validRunbookValidation) + expect(body.log_tail).toBe('all green') + }) + + test('a malformed response body is refused', async () => { + const result = await reportRunbookScanResult( + creds, + validRunbookScan.id, + { runbook: validRunbook, validation: validRunbookValidation }, + fetchStub(200, { runbook_id: 'rb1' }, []), + ) + expect(result.ok).toBe(false) + }) + + test('a network failure is reported as such', async () => { + const result = await reportRunbookScanResult( + creds, + validRunbookScan.id, + { runbook: validRunbook, validation: validRunbookValidation }, + fetchOffline(), + ) + expect(result).toEqual({ ok: false, error: { kind: 'network' } }) + }) +}) + +describe('failRunbookScan', () => { + test('sends the error and acknowledges', async () => { + const calls: Call[] = [] + const result = await failRunbookScan( + creds, + validRunbookScan.id, + { error: 'no /dev/kvm' }, + fetchStub(200, {}, calls), + ) + expect(result).toEqual({ ok: true, data: {} }) + const body = JSON.parse(String(calls[0]?.init.body)) as { error: string } + expect(body).toEqual({ error: 'no /dev/kvm' }) + }) + + test('a network failure is reported as such', async () => { + const result = await failRunbookScan(creds, validRunbookScan.id, { error: 'x' }, fetchOffline()) + expect(result).toEqual({ ok: false, error: { kind: 'network' } }) + }) +}) + +describe('currentRunbook', () => { + test('parses both the runbook and its validation', async () => { + const result = await currentRunbook( + creds, + 'repo1', + fetchStub(200, { runbook: validRunbook, validation: validRunbookValidation }, []), + ) + expect(result).toEqual({ + ok: true, + data: { runbook: validRunbook, validation: validRunbookValidation }, + }) + }) + + test('a repository with no runbook yet parses as both null', async () => { + const result = await currentRunbook( + creds, + 'repo1', + fetchStub(200, { runbook: null, validation: null }, []), + ) + expect(result).toEqual({ ok: true, data: { runbook: null, validation: null } }) + }) + + test('a malformed runbook refuses the whole response', async () => { + const result = await currentRunbook( + creds, + 'repo1', + fetchStub(200, { runbook: { nope: true }, validation: null }, []), + ) + expect(result.ok).toBe(false) + }) + + test('a malformed validation refuses the whole response, even with a valid runbook', async () => { + const result = await currentRunbook( + creds, + 'repo1', + fetchStub(200, { runbook: validRunbook, validation: { nope: true } }, []), + ) + expect(result.ok).toBe(false) + }) + + test('encodes the repo id in the path', async () => { + const calls: Call[] = [] + await currentRunbook( + creds, + 'repo 1/weird', + fetchStub(200, { runbook: null, validation: null }, calls), + ) + expect(calls[0]?.url).toBe('https://hub.example/api/cli/repos/repo%201%2Fweird/runbook') + }) + + test('a network failure is reported as such', async () => { + const result = await currentRunbook(creds, 'repo1', fetchOffline()) + expect(result).toEqual({ ok: false, error: { kind: 'network' } }) + }) +}) diff --git a/packages/cli/src/hub-client.ts b/packages/cli/src/hub-client.ts index f1ac39b..d40744e 100644 --- a/packages/cli/src/hub-client.ts +++ b/packages/cli/src/hub-client.ts @@ -11,6 +11,9 @@ import { sanitizeArmClaimResult, sanitizeArmTicket, sanitizeArmTicketRequest, + sanitizeRunbookConfig, + sanitizeRunbookScan, + sanitizeRunbookValidation, sanitizeRunnerListEntry, sanitizeSealedSecretBlob, type ArmClaimResult, @@ -19,7 +22,11 @@ import { type ArmTicket, type ArmTicketRequest, type ArmTransition, + type RunbookConfig, + type RunbookScan, + type RunbookValidation, type RunnerListEntry, + type TaskVerification, } from './contract.js' import { tryGit } from './git.js' import { runnerIdentityHeader } from './runner-identity.js' @@ -473,3 +480,130 @@ export async function claimPendingSecret( fetchImpl, }) } + +// --------------------------------------------------------------------------- +// Runbook scans and mechanical verifications (plan microVM 2026-08-28). +// --------------------------------------------------------------------------- + +export async function verification( + creds: SyncCredentials, + ticketId: string, + input: TaskVerification & { idempotency_key: string }, + fetchImpl: typeof fetch = fetch, +): Promise> { + return request(creds, { + method: 'POST', + path: `/api/cli/tickets/${encodeURIComponent(ticketId)}/verification`, + body: input, + parse: (body) => { + const id = field(body, 'id') + const created = field(body, 'created') + return typeof id === 'string' && typeof created === 'boolean' ? { id, created } : null + }, + fetchImpl, + }) +} + +export async function listRunbookScans( + creds: SyncCredentials, + fetchImpl: typeof fetch = fetch, +): Promise> { + return request(creds, { + method: 'GET', + path: '/api/cli/runbook-scans', + collectionRoute: true, + parse: (body) => { + const scans = field(body, 'scans') + if (!Array.isArray(scans)) { + return null + } + return scans + .map((item) => sanitizeRunbookScan(item)) + .filter((s): s is RunbookScan => s !== null) + }, + fetchImpl, + }) +} + +export async function claimRunbookScan( + creds: SyncCredentials, + scanId: string, + opts: { leaseSeconds?: number } = {}, + fetchImpl: typeof fetch = fetch, +): Promise> { + return request(creds, { + method: 'POST', + path: `/api/cli/runbook-scans/${encodeURIComponent(scanId)}/claim`, + body: opts.leaseSeconds !== undefined ? { lease_seconds: opts.leaseSeconds } : {}, + parse: (body) => { + const scan = sanitizeRunbookScan(field(body, 'scan')) + const lease = field(body, 'lease_expires_at') + return scan && typeof lease === 'string' ? { scan, lease_expires_at: lease } : null + }, + fetchImpl, + }) +} + +export async function reportRunbookScanResult( + creds: SyncCredentials, + scanId: string, + input: { runbook: RunbookConfig; validation: RunbookValidation; log_tail?: string }, + fetchImpl: typeof fetch = fetch, +): Promise> { + return request(creds, { + method: 'POST', + path: `/api/cli/runbook-scans/${encodeURIComponent(scanId)}/result`, + body: input, + parse: (body) => { + const runbookId = field(body, 'runbook_id') + const already = field(body, 'already_recorded') + return typeof runbookId === 'string' && typeof already === 'boolean' + ? { runbook_id: runbookId, already_recorded: already } + : null + }, + fetchImpl, + }) +} + +export async function failRunbookScan( + creds: SyncCredentials, + scanId: string, + input: { error: string }, + fetchImpl: typeof fetch = fetch, +): Promise>> { + return request(creds, { + method: 'POST', + path: `/api/cli/runbook-scans/${encodeURIComponent(scanId)}/fail`, + body: input, + parse: ack, + fetchImpl, + }) +} + +export async function currentRunbook( + creds: SyncCredentials, + repoId: string, + fetchImpl: typeof fetch = fetch, +): Promise> { + return request(creds, { + method: 'GET', + path: `/api/cli/repos/${encodeURIComponent(repoId)}/runbook`, + parse: (body) => { + if (!body || typeof body !== 'object') { + return null + } + const rawRunbook = field(body, 'runbook') + const rawValidation = field(body, 'validation') + const runbook = rawRunbook === null ? null : sanitizeRunbookConfig(rawRunbook) + const validation = rawValidation === null ? null : sanitizeRunbookValidation(rawValidation) + if ( + (rawRunbook !== null && runbook === null) || + (rawValidation !== null && validation === null) + ) { + return null + } + return { runbook, validation } + }, + fetchImpl, + }) +} diff --git a/packages/cli/src/i18n.ts b/packages/cli/src/i18n.ts index 7f1c2ac..2692f8b 100644 --- a/packages/cli/src/i18n.ts +++ b/packages/cli/src/i18n.ts @@ -613,6 +613,9 @@ terminal, offers to upgrade when a newer version exists. Set CODESEMA_NO_UPDATE_ 'isolation.reasonNoRuntime': 'no container runtime found (install docker or podman)', 'isolation.reasonUnreachable': '{runtime} is installed but its engine does not answer', 'isolation.reasonReady': '{runtime} is available', + 'isolation.reasonNoMicrovm': + 'no microVM runtime found (microsandbox unavailable, run `msb doctor`)', + 'isolation.reasonMicrovmNotProbed': 'microvm isolation was not probed for this project', } export type MessageKey = keyof typeof en @@ -1238,6 +1241,9 @@ CODESEMA_NO_UPDATE_CHECK=1 pour désactiver. 'isolation.reasonNoRuntime': 'aucun runtime de container trouvé (installez docker ou podman)', 'isolation.reasonUnreachable': '{runtime} est installé mais son moteur ne répond pas', 'isolation.reasonReady': '{runtime} est disponible', + 'isolation.reasonNoMicrovm': + 'aucun runtime microVM trouvé (microsandbox indisponible, lancez `msb doctor`)', + 'isolation.reasonMicrovmNotProbed': "l'isolation microvm n'a pas été sondée pour ce projet", } /** diff --git a/packages/cli/src/index.test.ts b/packages/cli/src/index.test.ts index 7f0da1d..78f9bf8 100644 --- a/packages/cli/src/index.test.ts +++ b/packages/cli/src/index.test.ts @@ -151,8 +151,8 @@ describe('resolveCommand — version, help and unknown commands', () => { }) }) -describe('resolveCommand — the ten routed commands', () => { - test('COMMAND_NAMES lists the ten commands the switch handles', () => { +describe('resolveCommand — the eleven routed commands', () => { + test('COMMAND_NAMES lists the eleven commands the switch handles', () => { expect([...COMMAND_NAMES]).toEqual([ 'review', 'prep', @@ -164,10 +164,11 @@ describe('resolveCommand — the ten routed commands', () => { 'sync', 'link', 'runner', + 'runbook', ]) }) - test('each of the ten resolves to its own command, in a terminal and outside one', () => { + test('each of the eleven resolves to its own command, in a terminal and outside one', () => { for (const name of COMMAND_NAMES) { for (const ctx of [TERMINAL, PIPE]) { expect(resolveCommand({}, [name], ctx)).toEqual({ kind: 'command', name, arg: undefined }) @@ -354,3 +355,25 @@ describe('codesema runner passes its autoconfig/await-secrets flags on', () => { } }) }) + +// Same source-shape assertion as the two blocks above, and for the same +// reason: `runCommand` is not exported, `codesema runbook scan` runs a real +// microVM. +describe('codesema runbook passes its --timeout and --agent flags on', () => { + test('the block reaches runbookCommand() with action, timeoutSeconds and agent', () => { + const source = readFileSync(join(import.meta.dir, 'index.ts'), 'utf8') + .split('\n') + .filter((line) => !/^\s*(\*|\/\/)/.test(line)) + .join('\n') + const marker = "case 'runbook':" + const start = source.indexOf(marker) + expect(start).toBeGreaterThanOrEqual(0) + const block = source.slice(start, source.indexOf('break', start)) + expect(block).toContain('await runbookCommand({') + expect(block).toContain('action: arg') + expect(block).toContain('agent: values.agent') + expect( + /timeoutSeconds:\s*parseIntFlag\('timeout',\s*values\.timeout,\s*1,\s*86400\)/.test(block), + ).toBe(true) + }) +}) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 4508277..1c1afc4 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -9,7 +9,7 @@ import { setLanguage, t } from './i18n.js' import { reviewFlagsPassed, runMenu } from './menu.js' import { prep } from './prep.js' import { review, REVIEW_GATE_VALUES, type ReviewGate } from './review.js' -import { runnerCommand } from './runner-commands.js' +import { runbookCommand, runnerCommand } from './runner-commands.js' import { show } from './show.js' import { linkCommand, syncCommand } from './sync.js' import { isInteractive } from './tui.js' @@ -70,6 +70,7 @@ export const COMMAND_NAMES = [ 'sync', 'link', 'runner', + 'runbook', ] as const export type CommandName = (typeof COMMAND_NAMES)[number] @@ -237,6 +238,14 @@ async function runCommand( timeoutSeconds: parseIntFlag('timeout', values.timeout, 1, 86400), }) break + case 'runbook': + await runbookCommand({ + action: arg, + cwd: process.cwd(), + timeoutSeconds: parseIntFlag('timeout', values.timeout, 1, 86400), + agent: values.agent, + }) + break } } diff --git a/packages/cli/src/microsandbox-driver.test.ts b/packages/cli/src/microsandbox-driver.test.ts new file mode 100644 index 0000000..35d27ef --- /dev/null +++ b/packages/cli/src/microsandbox-driver.test.ts @@ -0,0 +1,1699 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Database } from 'bun:sqlite' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { + createMicrosandboxDriver, + FakeSandboxDriver, + SANDBOX_NAME_PREFIX, + sandboxName, + sweepOrphanedSandboxes, + type SandboxDriver, + type SandboxExecOptions, + type SandboxHandle, + type SandboxNetworkPolicy, + type SandboxSpec, +} from './microsandbox-driver.js' + +// --- shared fixtures --------------------------------------------------------- + +const NETWORK: SandboxNetworkPolicy = { allowedDomains: ['api.anthropic.com'] } + +function baseSpec(over: Partial = {}): SandboxSpec { + return { + name: sandboxName('dev', 't1'), + image: 'node:26', + cpus: 2, + memoryMib: 2048, + maxDurationSeconds: 3600, + network: NETWORK, + ...over, + } +} + +function snapshotSpec(over: Partial = {}): SandboxSpec { + return { + name: sandboxName('dev', 't1'), + fromSnapshot: 'snap-1', + cpus: 2, + memoryMib: 2048, + maxDurationSeconds: 3600, + network: NETWORK, + ...over, + } +} + +const EXEC_OPTS: SandboxExecOptions = { timeoutMs: 5_000 } + +const cleanups: string[] = [] + +afterEach(() => { + for (const dir of cleanups.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +function makeDir(prefix = 'codesema-microsandbox-'): string { + const dir = mkdtempSync(join(tmpdir(), prefix)) + cleanups.push(dir) + return dir +} + +// --- sandboxName / prefix ---------------------------------------------------- + +describe('sandboxName', () => { + test('joins the prefix, role and id', () => { + expect(sandboxName('dev', 'abc123')).toBe('codesema-dev-abc123') + expect(sandboxName('gitops', 't-9')).toBe(`${SANDBOX_NAME_PREFIX}gitops-t-9`) + }) +}) + +// --- FakeSandboxDriver: create ----------------------------------------------- + +describe('FakeSandboxDriver.create', () => { + test('refuses a spec with no network policy', async () => { + const driver = new FakeSandboxDriver() + // A decayed (non-typed) caller could reach this; the field is required by + // the type but the runtime guard is what actually protects the boundary. + const spec = { ...baseSpec(), network: undefined } as unknown as SandboxSpec + await expect(driver.create(spec)).rejects.toThrow(/network is required/) + }) + + test('refuses image and fromSnapshot together', async () => { + const driver = new FakeSandboxDriver() + await expect( + driver.create(baseSpec({ image: 'node:26', fromSnapshot: 'snap-1' })), + ).rejects.toThrow(/exactly one of image or fromSnapshot/) + }) + + test('refuses neither image nor fromSnapshot', async () => { + const driver = new FakeSandboxDriver() + const spec = { ...baseSpec(), image: undefined } as unknown as SandboxSpec + await expect(driver.create(spec)).rejects.toThrow(/exactly one of image or fromSnapshot/) + }) + + test('accepts fromSnapshot alone', async () => { + const driver = new FakeSandboxDriver() + const handle = await driver.create(snapshotSpec()) + expect(handle.name).toBe(sandboxName('dev', 't1')) + }) + + test('records the call', async () => { + const driver = new FakeSandboxDriver() + const spec = baseSpec() + await driver.create(spec) + expect(driver.calls).toContainEqual({ method: 'create', args: [spec] }) + expect(driver.sandboxes.get(spec.name)?.spec).toBe(spec) + expect(driver.sandboxes.get(spec.name)?.stopped).toBe(false) + expect(driver.sandboxes.get(spec.name)?.destroyed).toBe(false) + }) +}) + +// --- FakeSandboxDriver: exec / shell scripting ------------------------------- + +describe('FakeSandboxDriver exec/shell', () => { + test('default response is exit 0 with empty output', async () => { + const driver = new FakeSandboxDriver() + const handle = await driver.create(baseSpec()) + const result = await handle.exec('echo', ['hi'], EXEC_OPTS) + expect(result).toEqual({ code: 0, stdout: '', stderr: '', timedOut: false }) + }) + + test('constructor exec responder answers every unscripted call', async () => { + const driver = new FakeSandboxDriver({ + exec: ({ command }) => ({ code: 0, stdout: `ran ${command}` }), + }) + const handle = await driver.create(baseSpec()) + const result = await handle.exec('claude', ['-p'], EXEC_OPTS) + expect(result.stdout).toBe('ran claude') + expect(result.code).toBe(0) + }) + + test('script() takes priority over the constructor default, first match wins', async () => { + const driver = new FakeSandboxDriver({ exec: () => ({ stdout: 'default' }) }) + driver + .script( + (ctx) => ctx.command === 'claude', + () => ({ stdout: 'first' }), + ) + .script( + (ctx) => ctx.command === 'claude', + () => ({ stdout: 'second' }), + ) + const handle = await driver.create(baseSpec()) + const claude = await handle.exec('claude', [], EXEC_OPTS) + expect(claude.stdout).toBe('first') + const other = await handle.exec('git', ['status'], EXEC_OPTS) + expect(other.stdout).toBe('default') + }) + + test('onText receives stdout then stderr, in that order', async () => { + const driver = new FakeSandboxDriver({ exec: () => ({ stdout: 'out', stderr: 'err' }) }) + const handle = await driver.create(baseSpec()) + const chunks: string[] = [] + await handle.exec('cmd', [], { ...EXEC_OPTS, onText: (c) => chunks.push(c) }) + expect(chunks).toEqual(['out', 'err']) + }) + + test('shell() is recorded as `sh -lc