test(restart-chaos): run chaos waves against Daytona sandboxes without Docker (#19544)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The restart chaos harness (`tests/restart-chaos/`) restarts a local
control plane on purpose while agent tasks run, and grades every task on
rules R1 to R6
> - The plan targets agent runs in Daytona sandboxes, but the harness
only supported `--env local`
> - A local controller needs two inputs that match the sandbox image
exactly: the image's Linux `paperclip-runnerd` and its provider pack.
The documented way to get them uses Docker, and Docker is not available
on the operator machine
> - The Daytona org that chaos uses also runs the live Cloud fleet, so
the harness must never change a sandbox it did not create through its
own Paperclip environment
> - This pull request adds `--env daytona`. It extracts both inputs from
the image without Docker, and refuses a controller whose runner source
differs from the image. It seeds a Daytona environment from the
runner-e2e fixtures and adds a read-only Daytona R6 sweep
> - The benefit is that chaos waves can now cross real Daytona sandboxes
with a local, killable control plane

## Linked Issues or Issue Description

**Feature description**
Run restart chaos waves with a local control plane and agent runs in
Daytona sandboxes (plan
`doc/plans/2026-11-06-control-plane-restart-chaos.md`, Phase 1b).

**Problem it solves**
`--env daytona` threw "not wired yet". The controller-side runnerd and
provider pack had to come from `docker cp`, which needs a Docker daemon.

**Proposed solution**
Use `skopeo copy` (no daemon) to fetch the linux/amd64 image into a
directory, walk the OCI layers from top to bottom, and extract only the
two paths. Before the wave starts, compare the image's content-ID label
with this checkout.

Refs #19477

## What Changed

- `tests/restart-chaos/daytona-image.ts`: a streaming tar reader and an
OCI layer walk (top to bottom, `.wh.<name>` and `.wh..wh..opq`
whiteouts, hard links, symlinks written last, no `..` paths). It
extracts `/usr/local/bin/paperclip-runnerd` and
`/opt/paperclip-runner/provider-pack/`. It checks the layer blob
digests, the ELF header (x86-64), the provider-pack manifest schema, and
its `runnerSourceRevision` against the image revision label. It records
digests in `receipt.json`, caches the result by image digest under
`results/daytona-images/`, and deletes the layer blobs. It also runs
standalone.
- Controller/image check: `assertControllerMatchesImage` compares the
image's `io.paperclip.runner.content-id` label with
`computeDaytonaImageContentId` for this checkout. On a mismatch it fails
and prints both IDs and the image's source revision.
- `seed.ts`: for `--env daytona`, it installs the bundled Daytona plugin
(`installFixtureDaytonaProvider`) and waits for provider readiness. It
creates the `daytona` environment fixture from `catalog.ts` with a
`DAYTONA_API_KEY` company secret and runs the native Codex
platform-qualification probe. Then it assigns the agents and the project
to that environment. `--daytona-cpu` sets the sandbox vCPU (default 4, 4
GiB, 10 GiB).
- `daytona-sweep.ts`: a read-only R6 for Daytona. The Daytona client is
a frozen wrapper that exposes only a label-filtered `list`, and no stop,
archive or delete exists. The sweep keeps only rows whose
`paperclip-company-id` and `paperclip-environment-id` both equal the
wave's own UUIDs, and it re-checks every row. It reconciles sandboxes
with the environment's leases:
  - A sandbox archived after its lease was released is expected.
- An orphaned sandbox or an `active`/`pending_cleanup` lease fails R6
for the owning task, or the wave.
- `assertHarnessMayMutate` refuses `paperclip-create-*` names and
`fleet-sbx-*` snapshots.
- Cleanup only deletes the wave's environment through Paperclip and
reports what is still running.
- `config.ts`: `--daytona-image` (or `PAPERCLIP_E2E_DAYTONA_IMAGE`)
needs an immutable digest. `--concurrency × --daytona-cpu` must stay
within `RESTART_CHAOS_DAYTONA_VCPU_BUDGET` (default 40). B5 is
local-only, because its oracle reads the live workspace.
- `main.ts`: Daytona preflight (credentials, image extraction,
content-ID check, Daytona plugin build as CI does). It passes
`PAPERCLIP_RUNNER_REMOTE_BINARY_PATH` and
`PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH` to every server start and
writes `sandbox-sweep.json`. Cleanup errors never skip stopping the
controller.
- README: a Daytona section (image matching, image-only CI dispatch,
vCPU budget, fleet safety, R6).
- Unit tests: synthetic tar layers for the tar reader, whiteouts, opaque
directories, overrides, hard links, symlinks and parent replacement.
Further tests cover the content-ID check, sweep reconciliation, exact
wave selection (unlabelled rows and other labels are never selected),
the read-only client, the fleet guard, and the CLI options.

## Verification

- `pnpm exec vitest run --config tests/restart-chaos/vitest.config.ts`:
62/62 pass, credential-free.
- `node scripts/typecheck-test-projects.mjs tests/restart-chaos`: clean.
- Real extraction without Docker, image `…@sha256:b0c71f4d…`: 42 layers,
about 8 minutes, a 3 GB pack.
- The content-ID check refused the current checkout. The image is
`b8b16e15…` from `7e6d882558`; the checkout is `aec45589…`.
- A checkout of `7e6d882558` computes `b8b16e15…`, so the check compares
like with like.
- Current image:
`ghcr.io/paperclipai/paperclip-daytona-runner@sha256:a84579522192de0393ae24d09e67c4e752df4c701bd315ff54ad9bbca42e2dc0`
(source `5d25117ffa`, content `4e9a221f…`). It matches this branch, and
the receipt shows the content ID match.
- Paid waves, `--env daytona --adapters codex,claude --tasks 3
--concurrency 2 --faults K1 --seed 11`:
- Wave 1 (10 vCPU org, 4 vCPU sandboxes): 0/3. Every create returned
HTTP 400 "Total CPU limit exceeded. Maximum allowed: 10", reproduced
directly with the SDK. This led to the vCPU budget, and the
archived-sandbox fix in R6.
- Wave 2 (same org, 2 vCPU sandboxes): **3/3 PASS**, all
restart-crossing. Codex completed through one working Retry; both Claude
tasks continued automatically. R6: 4 sandboxes archived after release, 0
orphans, 0 held leases.
- Wave 3 (company org, 4 vCPU sandboxes): 0/3. Two product failures were
found and reported to the campaign ledger: an ACPX Claude recovery
blocked on `ambiguous_remote_recovery_evidence` with no Retry, and a
false budget hard stop at 10.75¢ of 3000¢ that paused the company. The
harness changed no sandbox. R6 reported 2 held leases, and the
environment delete returned 409 `pending_sandbox_cleanup` because of
them.
- Fleet-org key check: one sandbox with a unique non-`paperclip-create`
name and label was created, then deleted by the exact ID that create
returned.

## Risks

- This change touches the test harness only, with no product code.
- The extracted provider pack is about 3 GB per image in the gitignored
results directory. Delete old `results/daytona-images/<digest>`
directories by hand.
- The harness never changes Daytona sandboxes. If Paperclip's
environment cleanup is blocked, sandboxes stay until their 60-minute ttl
or a person acts. The harness reports them in `sandbox-sweep.json` and
fails the wave.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, in Claude Code with
tool use (shell, file edits, GitHub CLI). Default context window,
extended thinking.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-06 14:26:45 -06:00
1 parent b7e28eb2f7
commit 1634f8b66b
15 files changed
+1252 -33

No files matched your search

+75 -2
View File
@@ -24,8 +24,79 @@ node scripts/typecheck-test-projects.mjs tests/restart-chaos # typecheck
Options: `--triggers time,comment,retry,queued,tool` (fault timing, below),
`--workloads A1,B1,B5,C1`, `--budget-cents` (company hard cap,
default 3000), `--poll-ms` (2000–5000), `--keep` (keep the temporary instance).
`--env daytona` is reserved; it fails until the Daytona image and environment
are wired (see the TODO in `seed.ts`).
## Daytona (`--env daytona`)
The control plane stays local; agent runs execute in Daytona sandboxes from an
immutable runner image. No Docker is needed.
```sh
set -a; source ~/.secrets; set +a # DAYTONA_API_KEY plus provider keys, process env only
node cli/node_modules/tsx/dist/cli.mjs tests/restart-chaos/main.ts --env daytona \
--daytona-image ghcr.io/paperclipai/paperclip-daytona-runner@sha256:<digest> \
--adapters codex,claude --tasks 3 --concurrency 2 --faults K1 --seed 11
```
`PAPERCLIP_E2E_DAYTONA_IMAGE` can replace `--daytona-image`. B5 is local-only
(its oracle reads the live workspace, which is inside the sandbox).
**vCPU budget.** The Daytona org is shared with the live Cloud fleet. Chaos
sandboxes use `--daytona-cpu` vCPU (default 4, with 4 GiB memory and 10 GiB
disk), and `--concurrency × --daytona-cpu` must stay within 40 vCPU
(`RESTART_CHAOS_DAYTONA_VCPU_BUDGET`). On a small org, set the budget below the
org limit with room for verification probes: a 10 vCPU org fails creates with
HTTP 400 "Total CPU limit exceeded", so use 8 there and `--daytona-cpu 2`.
**Fleet safety.** The harness never stops, archives or deletes a Daytona
sandbox. Its Daytona client is a frozen wrapper that exposes only a
label-filtered `list`. `assertHarnessMayMutate` refuses `paperclip-create-*`
names (every plugin-created sandbox has one) and `fleet-sbx-*` snapshots, so a
future mutating path cannot act on fleet sandboxes either. Cleanup goes only
through Paperclip, by deleting the wave's own environment.
**Image matching.** The controller needs the image's own Linux amd64
`/usr/local/bin/paperclip-runnerd` (`PAPERCLIP_RUNNER_REMOTE_BINARY_PATH`) and
`/opt/paperclip-runner/provider-pack/` (`PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH`).
`daytona-image.ts` gets them without a daemon: `skopeo copy --override-os linux
--override-arch amd64 docker://<image> dir:<cache>`, then walks the layers from
top to bottom (honouring `.wh.` and `.wh..wh..opq` whiteouts), extracts only
those two paths, checks the ELF header, the pack manifest and the image labels,
records their digests in `receipt.json`, and deletes the layer blobs. The result
is cached in `results/daytona-images/<digest>/`, so it runs once per image.
Run it alone with `node cli/node_modules/tsx/dist/cli.mjs tests/restart-chaos/daytona-image.ts <image>`.
Before starting, the harness compares the image's `io.paperclip.runner.content-id`
label with the content ID of this checkout (`pnpm test:e2e:runner:image-id`,
which hashes every runner input) and refuses to run on a mismatch. To get an
image for the current master, dispatch a narrow image build and reject its paid
gate:
```sh
gh workflow run runner-full-stack-e2e.yml --ref master -f cadence=manual -f all=false \
-f id=core-compatibility.runner-codex.daytona.message-marker
# when the run waits on the paid environment:
gh api -X POST repos/paperclipai/paperclip/actions/runs/<run>/pending_deployments \
-F "environment_ids[]=20786721735" -f state=rejected -f comment="Image build only"
```
The digest is in the "Publish verified Daytona image" job log (`pushing manifest
for …@sha256:…`), or check out the image's `org.opencontainers.image.revision`.
**Setup** reuses runner-e2e: the bundled Daytona plugin (`installFixtureDaytonaProvider`,
prepared outside the root workspace as CI does), the `daytona` environment
fixture from `catalog.ts`, and the native Codex platform-qualification probe.
**R6 sweep.** After the wave, `daytona-sweep.ts` lists (read-only) the
sandboxes whose `paperclip-company-id` and `paperclip-environment-id` labels
both equal the UUIDs this wave's isolated instance minted, re-checks every row,
and reconciles them with the environment's leases. A sandbox archived after its
lease was released is expected (the plugin archives on release and Daytona
deletes it after 60 minutes). Any other live sandbox without a held lease, or
an `active`/`pending_cleanup` lease, is a leak: it fails R6 for the
task whose run owns it, or the wave when no task does. The harness then deletes
the wave's environment through Paperclip and lists again: a wave sandbox that
is still neither archived nor destroyed after 90 seconds fails the wave and is
reported for a person to act on (the environment's 60-minute ttl also expires
it). The result is in `<wave>/sandbox-sweep.json`.
## Fault timing (`--triggers`)
@@ -59,6 +130,8 @@ re-arms the task's own triggered faults instead of using their offsets.
| `grader.ts` | Pure R1–R6 grading over the samples. |
| `workloads.ts` | A1 brief document, B1 python CLI with tests, B5 long bench, C1 comment while running, with their T-oracles. |
| `evidence.ts`, `ledger.ts` | Evidence bundle for each failure and the JSONL ledger plus `SUMMARY.md`. |
| `daytona-image.ts` | Docker-free extraction of the image's runnerd and provider pack, cached per digest, and the controller/image content-ID check. |
| `daytona-sweep.ts` | Daytona R6: read-only reconciliation of this wave's labelled sandboxes with leases; cleanup only through Paperclip. |
## Output (gitignored)
+4
View File
@@ -33,4 +33,8 @@ export class ChaosApi {
post<T>(route: string, body?: unknown, timeoutMs?: number) { return this.request<T>("POST", route, body ?? {}, timeoutMs); }
put<T>(route: string, body?: unknown) { return this.request<T>("PUT", route, body ?? {}); }
patch<T>(route: string, body?: unknown) { return this.request<T>("PATCH", route, body ?? {}); }
async delete(route: string, options?: { allowNotFound?: boolean; timeout?: number }): Promise<void> {
try { await this.request<unknown>("DELETE", route, undefined, options?.timeout); }
catch (error) { if (!(options?.allowNotFound && error instanceof ChaosApiError && error.status === 404)) throw error; }
}
}
+32 -2
View File
@@ -17,6 +17,17 @@ export const PROFILE_FOR_ADAPTER: Record<AdapterId, string> = {
opencode: "runner-opencode",
};
/**
* Chaos sandboxes run in the company Daytona org next to the live Cloud fleet.
* They use the runner-e2e fixture's 4 vCPU by default, and task sandboxes may
* use at most 40 vCPU in total, a small fraction of the fleet's capacity. Set
* RESTART_CHAOS_DAYTONA_VCPU_BUDGET lower for a smaller org (a 10 vCPU org
* fails creates with HTTP 400 "Total CPU limit exceeded"; use 8 there so
* verification probes still fit).
*/
export const DAYTONA_DEFAULT_CPU = 4;
export const DAYTONA_VCPU_BUDGET = 40;
export type ParsedCommand =
| { mode: "wave"; options: WaveOptions; keep: boolean }
| { mode: "replay"; ref: ReplayRef; keep: boolean; overrides: Partial<Pick<WaveOptions, "pollMs" | "budgetCents">> };
@@ -70,6 +81,8 @@ export function parseCommand(argv: readonly string[], now = Date.now()): ParsedC
"budget-cents": { type: "string" },
"poll-ms": { type: "string" },
replay: { type: "string" },
"daytona-image": { type: "string" },
"daytona-cpu": { type: "string" },
keep: { type: "boolean" },
},
});
@@ -77,7 +90,7 @@ export function parseCommand(argv: readonly string[], now = Date.now()): ParsedC
const pollMs = integer(values["poll-ms"], "poll-ms", 3_000, 2_000, 5_000);
const budgetCents = integer(values["budget-cents"], "budget-cents", 3_000, 1, 100_000);
if (values.replay !== undefined) {
for (const key of ["env", "adapters", "workloads", "faults", "triggers", "concurrency", "tasks", "seed"] as const) {
for (const key of ["env", "adapters", "workloads", "faults", "triggers", "concurrency", "tasks", "seed", "daytona-image", "daytona-cpu"] as const) {
if (values[key] !== undefined) throw new Error(`--replay reproduces the saved plan; do not combine it with --${key}`);
}
return { mode: "replay", ref: parseReplayRef(values.replay), keep,
@@ -85,10 +98,18 @@ export function parseCommand(argv: readonly string[], now = Date.now()): ParsedC
}
const env = (values.env ?? "local") as EnvironmentKind;
if (!ENVIRONMENT_KINDS.includes(env)) throw new Error(`Unknown env "${env}"; allowed: ${ENVIRONMENT_KINDS.join(", ")}`);
const daytonaImage = values["daytona-image"] ?? (env === "daytona" ? process.env.PAPERCLIP_E2E_DAYTONA_IMAGE : undefined);
if (env === "daytona" && !/^.+@sha256:[0-9a-f]{64}$/.test(daytonaImage ?? "")) {
throw new Error("--env daytona needs an immutable runner image: --daytona-image <registry/repository@sha256:…> or PAPERCLIP_E2E_DAYTONA_IMAGE");
}
if (env !== "daytona" && values["daytona-image"] !== undefined) throw new Error("--daytona-image applies only to --env daytona");
// B5 grades bench.sh progress read from the live workspace while it runs. A
// Daytona run's live workspace is inside the sandbox, so B5 is local-only.
const workloadChoices = env === "daytona" ? WORKLOAD_IDS.filter(id => id !== "B5") : WORKLOAD_IDS;
const options: WaveOptions = {
env,
adapters: list<AdapterId>(values.adapters, ADAPTER_IDS, "adapters", ["codex", "claude"]),
workloads: list<WorkloadId>(values.workloads, WORKLOAD_IDS, "workloads", WORKLOAD_IDS),
workloads: list<WorkloadId>(values.workloads, workloadChoices, "workloads", workloadChoices),
faults: list<FaultKind>(values.faults, FAULT_KINDS, "faults", ["K1", "K2", "K4"]),
triggers: list<TriggerId>(values.triggers, TRIGGER_IDS, "triggers", ["time"]),
concurrency: integer(values.concurrency, "concurrency", 4, 1, 20),
@@ -96,7 +117,16 @@ export function parseCommand(argv: readonly string[], now = Date.now()): ParsedC
seed: integer(values.seed, "seed", now % 2_147_483_647, 0, 2_147_483_647),
budgetCents,
pollMs,
...(env === "daytona" ? { daytonaImage, daytonaCpu: integer(values["daytona-cpu"], "daytona-cpu", DAYTONA_DEFAULT_CPU, 1, 4) } : {}),
};
if (env !== "daytona" && values["daytona-cpu"] !== undefined) throw new Error("--daytona-cpu applies only to --env daytona");
if (options.daytonaCpu !== undefined) {
const budget = integer(process.env.RESTART_CHAOS_DAYTONA_VCPU_BUDGET, "RESTART_CHAOS_DAYTONA_VCPU_BUDGET", DAYTONA_VCPU_BUDGET, 1, 1_000);
if (options.concurrency * options.daytonaCpu > budget) {
throw new Error(`--concurrency ${options.concurrency} × --daytona-cpu ${options.daytonaCpu} vCPU exceeds the Daytona vCPU budget of ${budget} `
+ "for chaos tasks. Lower one of them (RESTART_CHAOS_DAYTONA_VCPU_BUDGET sets the budget).");
}
}
return { mode: "wave", options, keep };
}
+193
View File
@@ -0,0 +1,193 @@
import { mkdtemp, readFile, readlink, rm, stat } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { assertControllerMatchesImage, extractFromLayers, normalizeLayerPath, walkTar, type LayerSource } from "./daytona-image.js";
type Entry =
| { path: string; type: "file"; content: string; mode?: number }
| { path: string; type: "dir" }
| { path: string; type: "symlink" | "hardlink"; target: string };
function header(name: string, typeflag: string, size: number, mode: number, link = ""): Buffer {
const block = Buffer.alloc(512);
const field = (value: string, offset: number, length: number) => block.write(value, offset, length, "utf8");
const octal = (value: number, offset: number, length: number) => field(`${value.toString(8).padStart(length - 1, "0")}\0`, offset, length);
field(name, 0, 100);
octal(mode, 100, 8); octal(0, 108, 8); octal(0, 116, 8); octal(size, 124, 12); octal(0, 136, 12);
block.write(typeflag, 156, 1, "latin1");
field(link, 157, 100);
block.write("ustar\0", 257, 6, "latin1");
block.write("00", 263, 2, "latin1");
block.fill(0x20, 148, 156);
let sum = 0;
for (const byte of block) sum += byte;
field(`${sum.toString(8).padStart(6, "0")}\0 `, 148, 8);
return block;
}
const pad = (body: Buffer) => Buffer.concat([body, Buffer.alloc((512 - (body.length % 512)) % 512)]);
/** Minimal ustar writer; names over 100 bytes use a pax `path` record. */
function tar(entries: Entry[]): Buffer {
const blocks: Buffer[] = [];
for (const entry of entries) {
if (Buffer.byteLength(entry.path) > 100) {
const record = (value: string) => {
let length = value.length + 3;
while (`${length} ${value}\n`.length !== length) length = `${length} ${value}\n`.length;
return `${length} ${value}\n`;
};
const body = Buffer.from(record(`path=${entry.path}`));
blocks.push(header("PaxHeader", "x", body.length, 0o644), pad(body));
}
const name = Buffer.byteLength(entry.path) > 100 ? "long" : entry.path;
if (entry.type === "file") {
const body = Buffer.from(entry.content);
blocks.push(header(name, "0", body.length, entry.mode ?? 0o644), pad(body));
} else if (entry.type === "dir") blocks.push(header(name, "5", 0, 0o755));
else blocks.push(header(name, entry.type === "symlink" ? "2" : "1", 0, 0o777, entry.target));
}
blocks.push(Buffer.alloc(1024));
return Buffer.concat(blocks);
}
/** Chunk the archive oddly to exercise header/body boundaries. */
function layer(digest: string, entries: Entry[]): LayerSource {
const bytes = tar(entries);
return { digest, open: async function* () { for (let offset = 0; offset < bytes.length; offset += 333) yield bytes.subarray(offset, offset + 333); } };
}
const RUNNERD = "usr/local/bin/paperclip-runnerd";
const PACK = "opt/paperclip-runner/provider-pack";
const directories: string[] = [];
async function outDir() { const directory = await mkdtemp(path.join(os.tmpdir(), "chaos-layers-")); directories.push(directory); return directory; }
afterEach(async () => { await Promise.all(directories.splice(0).map(directory => rm(directory, { recursive: true, force: true }))); });
const read = (root: string, relative: string) => readFile(path.join(root, relative), "utf8");
const exists = (root: string, relative: string) => stat(path.join(root, relative)).then(() => true, () => false);
describe("walkTar", () => {
it("reads ustar, pax long paths, links and normalizes ./ prefixes", async () => {
const long = `${PACK}/${"x".repeat(120)}/file.txt`;
const seen: Array<[string, string, string]> = [];
await walkTar(layer("l", [
{ path: "./usr/", type: "dir" },
{ path: long, type: "file", content: "deep" },
{ path: "a/link", type: "symlink", target: "../b" },
]).open(), async (entry, body) => {
let content = "";
if (entry.type === "file") await body.forward(chunk => { content += chunk.toString(); });
seen.push([entry.path, entry.type, content || entry.linkTarget]);
});
expect(seen).toEqual([["usr", "directory", ""], [long, "file", "deep"], ["a/link", "symlink", "../b"]]);
});
it("rejects parent-directory traversal", () => {
expect(() => normalizeLayerPath("opt/../../etc/passwd")).toThrow(/\.\./);
});
});
describe("extractFromLayers", () => {
it("takes the topmost version of each file and merges lower-only files", async () => {
const out = await outDir();
const report = await extractFromLayers([
layer("base", [{ path: "usr/local/bin/other", type: "file", content: "x" },
{ path: RUNNERD, type: "file", content: "old", mode: 0o755 },
{ path: `${PACK}/provider-pack.json`, type: "file", content: "{\"v\":1}" },
{ path: `${PACK}/lib/keep.js`, type: "file", content: "keep" }]),
layer("top", [{ path: RUNNERD, type: "file", content: "new", mode: 0o755 },
{ path: `${PACK}/provider-pack.json`, type: "file", content: "{\"v\":2}" }]),
], [RUNNERD, PACK], out);
expect(await read(out, RUNNERD)).toBe("new");
expect((await stat(path.join(out, RUNNERD))).mode & 0o111).not.toBe(0);
expect(await read(out, `${PACK}/provider-pack.json`)).toBe("{\"v\":2}");
expect(await read(out, `${PACK}/lib/keep.js`)).toBe("keep");
expect(await exists(out, "usr/local/bin/other")).toBe(false);
expect(report.layersWithMatches).toEqual(["top", "base"]);
});
it("honours .wh.<name> whiteouts for files and whole subtrees", async () => {
const out = await outDir();
await extractFromLayers([
layer("base", [{ path: RUNNERD, type: "file", content: "r" },
{ path: `${PACK}/provider-pack.json`, type: "file", content: "{}" },
{ path: `${PACK}/stale.js`, type: "file", content: "stale" },
{ path: `${PACK}/old-dir/a.js`, type: "file", content: "a" }]),
layer("top", [{ path: `${PACK}/.wh.stale.js`, type: "file", content: "" },
{ path: `${PACK}/.wh.old-dir`, type: "file", content: "" }]),
], [RUNNERD, PACK], out);
expect(await exists(out, `${PACK}/stale.js`)).toBe(false);
expect(await exists(out, `${PACK}/old-dir`)).toBe(false);
expect(await exists(out, `${PACK}/provider-pack.json`)).toBe(true);
});
it("honours opaque directories: only the opaque layer and above contribute children", async () => {
const out = await outDir();
await extractFromLayers([
layer("base", [{ path: RUNNERD, type: "file", content: "r" }, { path: `${PACK}/old.js`, type: "file", content: "old" }]),
layer("middle", [{ path: PACK, type: "dir" }, { path: `${PACK}/.wh..wh..opq`, type: "file", content: "" },
{ path: `${PACK}/provider-pack.json`, type: "file", content: "{}" }]),
layer("top", [{ path: `${PACK}/added.js`, type: "file", content: "added" }]),
], [RUNNERD, PACK], out);
expect(await exists(out, `${PACK}/old.js`)).toBe(false);
expect(await read(out, `${PACK}/provider-pack.json`)).toBe("{}");
expect(await read(out, `${PACK}/added.js`)).toBe("added");
});
it("deleting a target in a higher layer means it is missing", async () => {
const out = await outDir();
await expect(extractFromLayers([
layer("base", [{ path: RUNNERD, type: "file", content: "r" }, { path: `${PACK}/provider-pack.json`, type: "file", content: "{}" }]),
layer("top", [{ path: "usr/local/bin/.wh.paperclip-runnerd", type: "file", content: "" }]),
], [RUNNERD, PACK], out)).rejects.toThrow(/paperclip-runnerd was not found/);
});
it("a whiteout does not hide entries of its own layer", async () => {
const out = await outDir();
await extractFromLayers([
layer("base", [{ path: RUNNERD, type: "file", content: "old" }, { path: `${PACK}/provider-pack.json`, type: "file", content: "{}" }]),
layer("top", [{ path: `${PACK}/.wh..wh..opq`, type: "file", content: "" }, { path: `${PACK}/provider-pack.json`, type: "file", content: "{\"top\":1}" }]),
], [RUNNERD, PACK], out);
expect(await read(out, `${PACK}/provider-pack.json`)).toBe("{\"top\":1}");
});
it("a higher-layer file replacing a directory hides the lower tree", async () => {
const out = await outDir();
await extractFromLayers([
layer("base", [{ path: RUNNERD, type: "file", content: "r" }, { path: `${PACK}/sub/a.js`, type: "file", content: "a" }]),
layer("top", [{ path: `${PACK}/sub`, type: "file", content: "now a file" }]),
], [RUNNERD, PACK], out);
expect(await read(out, `${PACK}/sub`)).toBe("now a file");
});
it("resolves hard links and keeps symlinks inside the pack", async () => {
const out = await outDir();
await extractFromLayers([layer("only", [
{ path: RUNNERD, type: "file", content: "r", mode: 0o755 },
{ path: `${PACK}/node_modules/tool/cli.js`, type: "file", content: "cli", mode: 0o755 },
{ path: `${PACK}/node_modules/tool/cli-copy.js`, type: "hardlink", target: `${PACK}/node_modules/tool/cli.js` },
{ path: `${PACK}/node_modules/.bin/tool`, type: "symlink", target: "../tool/cli.js" },
])], [RUNNERD, PACK], out);
expect(await read(out, `${PACK}/node_modules/tool/cli-copy.js`)).toBe("cli");
expect(await readlink(path.join(out, PACK, "node_modules/.bin/tool"))).toBe("../tool/cli.js");
expect(await read(out, `${PACK}/node_modules/.bin/tool`)).toBe("cli");
});
it("refuses a parent directory replaced by a symlink", async () => {
const out = await outDir();
await expect(extractFromLayers([
layer("base", [{ path: RUNNERD, type: "file", content: "r" }, { path: `${PACK}/provider-pack.json`, type: "file", content: "{}" }]),
layer("top", [{ path: "opt/paperclip-runner", type: "symlink", target: "/elsewhere" }]),
], [RUNNERD, PACK], out)).rejects.toThrow(/replaces parent directory \/opt\/paperclip-runner/);
});
});
describe("assertControllerMatchesImage", () => {
const image = { image: "ghcr.io/x/y@sha256:" + "a".repeat(64), contentId: "c".repeat(64), sourceRevision: "d".repeat(40) };
it("passes when content IDs match", async () => {
await expect(assertControllerMatchesImage(image, "/unused", "c".repeat(64))).resolves.toEqual({ localContentId: "c".repeat(64) });
});
it("names both content IDs and the image revision when they differ", async () => {
await expect(assertControllerMatchesImage(image, "/unused", "e".repeat(64))).rejects.toThrow(/does not match the Daytona image[\s\S]*d{40}[\s\S]*e{64}/);
});
});
+441
View File
@@ -0,0 +1,441 @@
/**
* Extracts the two controller inputs that must match a Daytona runner image
* exactly, without Docker:
*
* - `/usr/local/bin/paperclip-runnerd` → PAPERCLIP_RUNNER_REMOTE_BINARY_PATH
* - `/opt/paperclip-runner/provider-pack/` → PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH
*
* `skopeo copy` writes the linux/amd64 manifest, config and layer blobs to a
* directory (no daemon). The layers are then walked from top to bottom,
* honouring OCI whiteouts, and only those two paths are written out. The
* result is cached by image digest and the layer blobs are deleted.
*
* node cli/node_modules/tsx/dist/cli.mjs tests/restart-chaos/daytona-image.ts <image@sha256:…>
*/
import { execFile } from "node:child_process";
import { createHash, randomBytes } from "node:crypto";
import { createReadStream } from "node:fs";
import { chmod, copyFile, mkdir, open, readFile, rename, rm, stat, symlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { promisify } from "node:util";
import { createGunzip, createZstdDecompress } from "node:zlib";
import type { Readable } from "node:stream";
const execFileAsync = promisify(execFile);
export const RUNNERD_PATH = "usr/local/bin/paperclip-runnerd";
export const PROVIDER_PACK_PATH = "opt/paperclip-runner/provider-pack";
export const CONTENT_ID_LABEL = "io.paperclip.runner.content-id";
export const REVISION_LABEL = "org.opencontainers.image.revision";
const RECEIPT_SCHEMA = "paperclip-restart-chaos/daytona-image-artifacts/v1";
const IMMUTABLE_IMAGE = /^[a-z0-9.-]+(?::[0-9]+)?\/[a-z0-9._/-]+@sha256:([0-9a-f]{64})$/;
// ---------------------------------------------------------------- tar reader
export type TarEntryType = "file" | "directory" | "symlink" | "hardlink" | "other";
export interface TarEntry { path: string; type: TarEntryType; mode: number; size: number; linkTarget: string }
/** Buffered reader over an async byte stream. */
class ByteReader {
private chunks: Buffer[] = [];
private length = 0;
private ended = false;
constructor(private readonly source: AsyncIterator<Buffer>) {}
private async fill(bytes: number) {
while (this.length < bytes && !this.ended) {
const next = await this.source.next();
if (next.done) this.ended = true;
else if (next.value.length) { this.chunks.push(next.value); this.length += next.value.length; }
}
}
/** Exactly `bytes` bytes, or null at a clean end of stream. */
async read(bytes: number): Promise<Buffer | null> {
await this.fill(bytes);
if (this.length === 0) return null;
if (this.length < bytes) throw new Error("Truncated tar stream");
const parts: Buffer[] = [];
let needed = bytes;
while (needed > 0) {
const head = this.chunks[0]!;
if (head.length <= needed) { parts.push(head); this.chunks.shift(); needed -= head.length; }
else { parts.push(head.subarray(0, needed)); this.chunks[0] = head.subarray(needed); needed = 0; }
}
this.length -= bytes;
return parts.length === 1 ? parts[0]! : Buffer.concat(parts);
}
/** Passes the next `bytes` bytes to `sink` without holding them all in memory. */
async forward(bytes: number, sink: (chunk: Buffer) => Promise<void> | void): Promise<void> {
let remaining = bytes;
while (remaining > 0) {
if (this.length === 0) await this.fill(1);
if (this.length === 0) throw new Error("Truncated tar entry");
const head = this.chunks[0]!;
const take = Math.min(head.length, remaining);
const part = take === head.length ? head : head.subarray(0, take);
if (take === head.length) this.chunks.shift(); else this.chunks[0] = head.subarray(take);
this.length -= take;
remaining -= take;
await sink(part);
}
}
}
const cString = (block: Buffer, start: number, end: number) => {
const field = block.subarray(start, end);
const nul = field.indexOf(0);
return (nul === -1 ? field : field.subarray(0, nul)).toString("utf8");
};
function numeric(block: Buffer, start: number, end: number): number {
const field = block.subarray(start, end);
if (field[0]! & 0x80) {
// GNU base-256: big-endian, high bit set on the first byte.
let value = field[0]! & 0x7f;
for (let index = 1; index < field.length; index += 1) value = value * 256 + field[index]!;
return value;
}
const text = cString(block, start, end).trim();
return text ? Number.parseInt(text, 8) : 0;
}
function parsePax(body: Buffer): Record<string, string> {
const out: Record<string, string> = {};
let offset = 0;
while (offset < body.length) {
const space = body.indexOf(0x20, offset);
if (space === -1) break;
const length = Number.parseInt(body.subarray(offset, space).toString("utf8"), 10);
if (!Number.isSafeInteger(length) || length <= 0) throw new Error("Malformed pax header");
const record = body.subarray(space + 1, offset + length - 1).toString("utf8");
const equals = record.indexOf("=");
if (equals > 0) out[record.slice(0, equals)] = record.slice(equals + 1);
offset += length;
}
return out;
}
/** Normalized layer path: no leading `./` or `/`, no trailing `/`, no `..`. */
export function normalizeLayerPath(raw: string): string {
const parts = raw.split("/").filter(part => part !== "" && part !== ".");
if (parts.some(part => part === "..")) throw new Error(`Refusing layer path with "..": ${raw}`);
return parts.join("/");
}
/**
* Streams a tar archive. `onEntry` must either call `body.forward` once or
* leave the body unread (it is skipped).
*/
export async function walkTar(source: AsyncIterable<Buffer>,
onEntry: (entry: TarEntry, body: { forward(sink: (chunk: Buffer) => Promise<void> | void): Promise<void> }) => Promise<void>): Promise<void> {
const reader = new ByteReader(source[Symbol.asyncIterator]());
let pax: Record<string, string> = {};
let longName: string | null = null;
let longLink: string | null = null;
for (;;) {
const header = await reader.read(512);
if (!header || header.every(byte => byte === 0)) return;
const stored = numeric(header, 148, 156);
let sum = 0;
for (let index = 0; index < 512; index += 1) sum += index >= 148 && index < 156 ? 0x20 : header[index]!;
if (sum !== stored) throw new Error("Tar header checksum mismatch");
const typeflag = String.fromCharCode(header[156]!);
const size = pax.size !== undefined ? Number(pax.size) : numeric(header, 124, 136);
const padded = Math.ceil(size / 512) * 512;
if (typeflag === "x" || typeflag === "g" || typeflag === "L" || typeflag === "K") {
const body = (await reader.read(padded)) ?? Buffer.alloc(0);
const content = body.subarray(0, size);
if (typeflag === "x") pax = { ...pax, ...parsePax(content) };
else if (typeflag === "L") longName = cString(content, 0, content.length);
else if (typeflag === "K") longLink = cString(content, 0, content.length);
continue;
}
const magic = header.subarray(257, 262).toString("latin1");
const prefix = magic === "ustar" ? cString(header, 345, 500) : "";
const name = cString(header, 0, 100);
const rawPath = pax.path ?? longName ?? (prefix ? `${prefix}/${name}` : name);
const linkTarget = pax.linkpath ?? longLink ?? cString(header, 157, 257);
pax = {}; longName = null; longLink = null;
const type: TarEntryType = typeflag === "0" || typeflag === "\0" || typeflag === "7" ? "file"
: typeflag === "5" ? "directory" : typeflag === "2" ? "symlink" : typeflag === "1" ? "hardlink" : "other";
const entry: TarEntry = { path: normalizeLayerPath(rawPath), type, mode: numeric(header, 100, 108) & 0o7777, size: type === "file" ? size : 0, linkTarget };
let consumed = false;
await onEntry(entry, { async forward(sink) {
if (consumed) throw new Error("Tar entry body already read");
consumed = true;
await reader.forward(size, sink);
} });
if (!consumed) await reader.forward(size, () => undefined);
if (padded > size) await reader.forward(padded - size, () => undefined);
}
}
// ---------------------------------------------------------------- layer walk
export interface LayerSource { digest: string; open(): AsyncIterable<Buffer> }
export interface ExtractionReport { extracted: Record<string, TarEntryType>; layersWithMatches: string[] }
const within = (candidate: string, root: string) => candidate === root || candidate.startsWith(`${root}/`);
const ancestors = (value: string) => value.split("/").slice(0, -1).map((_, index, parts) => parts.slice(0, index + 1).join("/"));
/**
* Resolves `roots` (files or directory trees) through an ordered layer stack,
* walking from the top layer down, and writes the effective result to `outDir`.
*
* Whiteouts follow the OCI image spec: `.wh.<name>` deletes `<name>` (and its
* subtree) in lower layers, `.wh..wh..opq` hides every lower-layer child of its
* directory. A higher-layer non-directory at a path also hides lower content
* under that path. Whiteouts affect only lower layers, never their own layer.
*/
export async function extractFromLayers(layersBottomToTop: readonly LayerSource[], roots: readonly string[], outDir: string): Promise<ExtractionReport> {
const normalizedRoots = roots.map(normalizeLayerPath);
const rootAncestors = new Set(normalizedRoots.flatMap(ancestors));
const deleted = new Set<string>();
const opaque = new Set<string>();
const resolved = new Map<string, TarEntryType>();
const symlinks: Array<{ path: string; target: string }> = [];
const directoryModes = new Map<string, number>();
const layersWithMatches: string[] = [];
const hidden = (candidate: string) => {
if (deleted.has(candidate)) return true;
for (const ancestor of ancestors(candidate)) if (deleted.has(ancestor) || opaque.has(ancestor)) return true;
return false;
};
await mkdir(outDir, { recursive: true });
for (const layer of [...layersBottomToTop].reverse()) {
const pendingDeleted = new Set<string>();
const pendingOpaque = new Set<string>();
const stagedFiles = new Map<string, string>();
let matched = false;
await walkTar(layer.open(), async (entry, body) => {
const base = path.posix.basename(entry.path);
const directory = path.posix.dirname(entry.path);
const parent = directory === "." ? "" : directory;
if (base === ".wh..wh..opq") { pendingOpaque.add(parent); return; }
if (base.startsWith(".wh.")) { pendingDeleted.add(parent ? `${parent}/${base.slice(4)}` : base.slice(4)); return; }
if (!entry.path || hidden(entry.path) || resolved.has(entry.path)) return;
const relevant = normalizedRoots.some(root => within(entry.path, root));
if (!relevant) {
// A file or symlink replacing a parent of a root would redirect the root.
if (rootAncestors.has(entry.path) && entry.type !== "directory") {
throw new Error(`Layer ${layer.digest} replaces parent directory /${entry.path} with a ${entry.type}; extraction is unsupported`);
}
if (rootAncestors.has(entry.path)) resolved.set(entry.path, entry.type);
return;
}
matched = true;
resolved.set(entry.path, entry.type);
if (entry.type !== "directory") pendingDeleted.add(entry.path);
const target = path.join(outDir, ...entry.path.split("/"));
if (entry.type === "directory") {
await mkdir(target, { recursive: true });
directoryModes.set(target, entry.mode);
} else if (entry.type === "file") {
await mkdir(path.dirname(target), { recursive: true });
const handle = await open(target, "wx", 0o600);
try { await body.forward(async chunk => { await handle.write(chunk); }); } finally { await handle.close(); }
await chmod(target, entry.mode & 0o755);
stagedFiles.set(entry.path, target);
} else if (entry.type === "hardlink") {
const source = stagedFiles.get(normalizeLayerPath(entry.linkTarget));
if (!source) throw new Error(`Hard link /${entry.path} → /${entry.linkTarget} points outside the extracted paths of layer ${layer.digest}`);
await mkdir(path.dirname(target), { recursive: true });
await copyFile(source, target);
await chmod(target, (await stat(source)).mode & 0o755);
resolved.set(entry.path, "file");
stagedFiles.set(entry.path, target);
} else if (entry.type === "symlink") {
symlinks.push({ path: target, target: entry.linkTarget });
} else {
throw new Error(`Unsupported tar entry type at /${entry.path} in layer ${layer.digest}`);
}
});
for (const value of pendingDeleted) deleted.add(value);
for (const value of pendingOpaque) opaque.add(value);
if (matched) layersWithMatches.push(layer.digest);
}
// Symlinks last, so no file is ever written through one.
for (const link of symlinks) {
await mkdir(path.dirname(link.path), { recursive: true });
await symlink(link.target, link.path);
}
for (const [directory, mode] of directoryModes) await chmod(directory, (mode & 0o755) | 0o700);
for (const root of normalizedRoots) if (![...resolved.keys()].some(key => within(key, root))) throw new Error(`/${root} was not found in the image layers`);
const extracted = Object.fromEntries([...resolved].filter(([key]) => normalizedRoots.some(root => within(key, root))));
return { extracted, layersWithMatches };
}
// ---------------------------------------------------------------- image
export interface DaytonaImageArtifacts {
schema: typeof RECEIPT_SCHEMA;
image: string;
platformManifestDigest: string;
contentId: string;
sourceRevision: string;
runnerdPath: string;
runnerdSha256: string;
providerPackPath: string;
providerPackManifestSha256: string;
providerPackDigest: string;
providerPackRunnerSourceRevision: string;
extractedAt: string;
}
const sha256 = (bytes: Buffer | string) => createHash("sha256").update(bytes).digest("hex");
async function fileSha256(file: string): Promise<string> {
const hash = createHash("sha256");
for await (const chunk of createReadStream(file)) hash.update(chunk as Buffer);
return hash.digest("hex");
}
/** Decompresses a layer blob. skopeo verified the blob digests when it copied them. */
function layerStream(file: string, mediaType: string): AsyncIterable<Buffer> {
if (!/tar/.test(mediaType)) throw new Error(`Unsupported layer media type ${mediaType}`);
const raw = createReadStream(file);
const decoder: Readable = /zstd/.test(mediaType) ? raw.pipe(createZstdDecompress())
: /gzip/.test(mediaType) ? raw.pipe(createGunzip()) : raw;
return decoder as AsyncIterable<Buffer>;
}
/** ELF64, little-endian, x86-64 executable or shared object. */
export async function assertLinuxAmd64Executable(file: string): Promise<void> {
const handle = await open(file, "r");
try {
const header = Buffer.alloc(20);
await handle.read(header, 0, 20, 0);
if (header.readUInt32BE(0) !== 0x7f454c46) throw new Error(`${file} is not an ELF binary`);
if (header[4] !== 2 || header[5] !== 1) throw new Error(`${file} is not a 64-bit little-endian ELF binary`);
if (header.readUInt16LE(18) !== 62) throw new Error(`${file} is not an x86-64 binary`);
} finally { await handle.close(); }
if (((await stat(file)).mode & 0o111) === 0) throw new Error(`${file} is not executable`);
}
async function readReceipt(directory: string): Promise<DaytonaImageArtifacts | null> {
const receipt = await readFile(path.join(directory, "receipt.json"), "utf8").then(text => JSON.parse(text) as DaytonaImageArtifacts).catch(() => null);
if (!receipt || receipt.schema !== RECEIPT_SCHEMA) return null;
// A cache hit must still be the exact bytes that were verified.
if (await fileSha256(receipt.runnerdPath).catch(() => null) !== receipt.runnerdSha256) return null;
const pack = await readFile(path.join(receipt.providerPackPath, "provider-pack.json")).catch(() => null);
if (!pack || sha256(pack) !== receipt.providerPackManifestSha256) return null;
return receipt;
}
/**
* Returns the verified runnerd and provider pack of `image`, extracting them
* once per image digest into `cacheRoot/<digest>/`.
*/
export async function prepareDaytonaImageArtifacts(input: { image: string; cacheRoot: string; skopeo?: string; log?: (line: string) => void }): Promise<DaytonaImageArtifacts> {
const match = IMMUTABLE_IMAGE.exec(input.image);
if (!match) throw new Error(`Daytona image must be an immutable registry/repository@sha256:<digest> reference, got "${input.image}"`);
const log = input.log ?? (() => undefined);
const finalDir = path.join(path.resolve(input.cacheRoot), match[1]!);
const cached = await readReceipt(finalDir);
if (cached) { log(`Daytona image artifacts cached at ${finalDir}`); return cached; }
await rm(finalDir, { recursive: true, force: true });
const staging = path.join(path.resolve(input.cacheRoot), `.staging-${match[1]!.slice(0, 12)}-${randomBytes(4).toString("hex")}`);
const blobs = path.join(staging, "blobs");
const rootfs = path.join(staging, "rootfs");
try {
await mkdir(blobs, { recursive: true, mode: 0o700 });
log(`skopeo copy ${input.image} (linux/amd64) → ${blobs}`);
await execFileAsync(input.skopeo ?? process.env.SKOPEO ?? "skopeo", ["copy", "--retry-times", "3",
"--override-os", "linux", "--override-arch", "amd64", `docker://${input.image}`, `dir:${blobs}`],
{ maxBuffer: 16 * 1024 * 1024 });
const manifestBytes = await readFile(path.join(blobs, "manifest.json"));
const manifest = JSON.parse(manifestBytes.toString("utf8")) as { config: { digest: string }; layers: Array<{ digest: string; mediaType: string }> };
if (!manifest.config?.digest || !Array.isArray(manifest.layers)) throw new Error("skopeo did not produce a single-platform image manifest");
const blobPath = (digest: string) => {
if (!/^sha256:[0-9a-f]{64}$/.test(digest)) throw new Error(`Unexpected blob digest ${digest}`);
return path.join(blobs, digest.slice("sha256:".length));
};
for (const layer of manifest.layers) {
if (`sha256:${await fileSha256(blobPath(layer.digest))}` !== layer.digest) throw new Error(`Layer blob digest mismatch for ${layer.digest}`);
}
const configBytes = await readFile(blobPath(manifest.config.digest));
if (`sha256:${sha256(configBytes)}` !== manifest.config.digest) throw new Error("Image config digest mismatch");
const config = JSON.parse(configBytes.toString("utf8")) as { os?: string; architecture?: string; config?: { Labels?: Record<string, string> } };
if (config.os !== "linux" || config.architecture !== "amd64") throw new Error(`Image platform is ${config.os}/${config.architecture}, expected linux/amd64`);
const contentId = config.config?.Labels?.[CONTENT_ID_LABEL] ?? "";
const sourceRevision = config.config?.Labels?.[REVISION_LABEL] ?? "";
if (!/^[0-9a-f]{64}$/.test(contentId)) throw new Error(`Image has no ${CONTENT_ID_LABEL} label`);
if (!/^[0-9a-f]{40}$/.test(sourceRevision)) throw new Error(`Image has no ${REVISION_LABEL} label`);
log(`Walking ${manifest.layers.length} layers for /${RUNNERD_PATH} and /${PROVIDER_PACK_PATH}`);
await extractFromLayers(manifest.layers.map(layer => ({ digest: layer.digest, open: () => layerStream(blobPath(layer.digest), layer.mediaType) })),
[RUNNERD_PATH, PROVIDER_PACK_PATH], rootfs);
await rm(blobs, { recursive: true, force: true });
const runnerd = path.join(rootfs, ...RUNNERD_PATH.split("/"));
await assertLinuxAmd64Executable(runnerd);
const packDir = path.join(rootfs, ...PROVIDER_PACK_PATH.split("/"));
const packBytes = await readFile(path.join(packDir, "provider-pack.json"));
const pack = JSON.parse(packBytes.toString("utf8")) as { schema?: string; digest?: string; payload?: { runnerSourceRevision?: string } };
if (pack.schema !== "paperclip-runner/remote-provider-pack/v1" || !/^sha256:[0-9a-f]{64}$/.test(pack.digest ?? "")) {
throw new Error("Image provider-pack.json is not a paperclip-runner/remote-provider-pack/v1 manifest");
}
if (pack.payload?.runnerSourceRevision !== sourceRevision) {
throw new Error(`Provider pack was built from ${pack.payload?.runnerSourceRevision ?? "an unknown revision"}, but the image revision label is ${sourceRevision}`);
}
const receiptFor = (root: string): DaytonaImageArtifacts => ({
schema: RECEIPT_SCHEMA, image: input.image, platformManifestDigest: `sha256:${sha256(manifestBytes)}`, contentId, sourceRevision,
runnerdPath: path.join(root, "rootfs", ...RUNNERD_PATH.split("/")), runnerdSha256: "",
providerPackPath: path.join(root, "rootfs", ...PROVIDER_PACK_PATH.split("/")), providerPackManifestSha256: sha256(packBytes),
providerPackDigest: pack.digest!, providerPackRunnerSourceRevision: sourceRevision, extractedAt: new Date().toISOString(),
});
const receipt = { ...receiptFor(finalDir), runnerdSha256: await fileSha256(runnerd) };
await writeFile(path.join(staging, "receipt.json"), `${JSON.stringify(receipt, null, 2)}\n`, { mode: 0o600 });
await rename(staging, finalDir);
log(`Daytona image artifacts ready at ${finalDir}`);
return receipt;
} catch (error) {
await rm(staging, { recursive: true, force: true }).catch(() => undefined);
throw error;
}
}
/**
* The controller and the image must come from the same runner source: the
* image's content-ID label must equal the content ID of this checkout, which
* hashes every runner input (Rust crates, runner src, provider-pack scripts,
* lockfile, Dockerfile).
*/
export async function assertControllerMatchesImage(artifacts: Pick<DaytonaImageArtifacts, "image" | "contentId" | "sourceRevision">,
repositoryRoot: string, localContentId?: string): Promise<{ localContentId: string }> {
const computed = localContentId ?? await (await import("../runner-e2e/daytona-image-content.js")).computeDaytonaImageContentId({ repositoryRoot });
if (computed !== artifacts.contentId) {
throw new Error([
"Controller runner source does not match the Daytona image.",
` image ${artifacts.image}`,
` image content ID ${artifacts.contentId} (source revision ${artifacts.sourceRevision})`,
` this checkout's content ID ${computed}`,
"Check out the image's source revision, or build a new image for this checkout",
"(gh workflow run runner-full-stack-e2e.yml, see tests/restart-chaos/README.md).",
].join("\n"));
}
return { localContentId: computed };
}
const invoked = process.argv[1] ? path.resolve(process.argv[1]) : null;
if (invoked && import.meta.url === pathToFileURL(invoked).href) {
const image = process.argv[2];
if (!image) { console.error("Usage: daytona-image.ts <registry/repository@sha256:digest>"); process.exit(2); }
const repositoryRoot = path.resolve(import.meta.dirname, "../..");
const cacheRoot = process.env.RESTART_CHAOS_IMAGE_CACHE ?? path.join(import.meta.dirname, "results", "daytona-images");
try {
const artifacts = await prepareDaytonaImageArtifacts({ image, cacheRoot, log: line => console.error(line) });
const check = await assertControllerMatchesImage(artifacts, repositoryRoot).then(() => "match", (error: Error) => error.message);
console.info(JSON.stringify({ ...artifacts, controllerMatch: check }, null, 2));
process.exit(check === "match" ? 0 : 1);
} catch (error) {
console.error(error instanceof Error ? error.message : error);
process.exit(2);
}
}
+103
View File
@@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";
import {
assertHarnessMayMutate, DaytonaWaveSweeper, isProtectedSandbox, leaksForTask, readOnlyDaytona, reconcileSandboxes, selectWaveSandboxes,
sweepHasLeaks, unattributedLeaks, type DaytonaSandboxRow, type DaytonaSweepResult,
} from "./daytona-sweep.js";
const COMPANY = "11111111-1111-4111-8111-111111111111";
const ENVIRONMENT = "22222222-2222-4222-8222-222222222222";
const OTHER = "33333333-3333-4333-8333-333333333333";
const wave = { companyId: COMPANY, environmentId: ENVIRONMENT };
const labelled = (id: string, labels: Record<string, string> = {}, extra: Partial<DaytonaSandboxRow> = {}): DaytonaSandboxRow =>
({ id, state: "started", labels, ...extra });
describe("wave sandbox selection is read-only and exact", () => {
const rows = [
labelled("mine", { "paperclip-company-id": COMPANY, "paperclip-environment-id": ENVIRONMENT }),
labelled("unlabelled"),
labelled("other-environment", { "paperclip-company-id": COMPANY, "paperclip-environment-id": OTHER }),
labelled("other-company", { "paperclip-company-id": OTHER, "paperclip-environment-id": ENVIRONMENT }),
labelled("company-only", { "paperclip-company-id": COMPANY }),
labelled("fleet", { "paperclip-company-id": OTHER }, { name: "paperclip-create-abc", snapshot: "fleet-sbx-1" }),
];
it("selects only sandboxes carrying both of the wave's UUID labels", () => {
expect(selectWaveSandboxes(rows, wave).map(row => row.id)).toEqual(["mine"]);
expect(() => selectWaveSandboxes(rows, { companyId: "", environmentId: "" })).toThrow(/UUIDs/);
});
it("re-checks rows even when the provider ignores the label filter", async () => {
const queries: unknown[] = [];
const sweeper = new DaytonaWaveSweeper({ apiKey: "unused", ...wave, api: {} as never });
(sweeper as unknown as { client: unknown }).client = readOnlyDaytona({ list: query => {
queries.push(query);
return (async function* () { yield* rows; })();
} });
expect((await sweeper.listWaveSandboxes()).map(row => row.id)).toEqual(["mine"]);
expect(queries).toEqual([{ labels: { "paperclip-company-id": COMPANY, "paperclip-environment-id": ENVIRONMENT } }]);
});
it("exposes no mutating Daytona operation", () => {
const client = readOnlyDaytona({ list: () => (async function* () {})(), delete: () => undefined, stop: () => undefined } as never);
expect(Object.keys(client)).toEqual(["list"]);
expect(Object.isFrozen(client)).toBe(true);
expect(() => readOnlyDaytona({} as never)).toThrow(/no list\(\)/);
});
it("hard-refuses fleet sandboxes and anything not labelled for the wave, even with matching labels", () => {
const waveLabels = { "paperclip-company-id": COMPANY, "paperclip-environment-id": ENVIRONMENT };
expect(isProtectedSandbox({ name: "paperclip-create-1" })).toBe(true);
expect(isProtectedSandbox({ name: "x", snapshot: "fleet-sbx-2" })).toBe(true);
expect(() => assertHarnessMayMutate(labelled("a", waveLabels, { name: "paperclip-create-1" }), wave)).toThrow(/protected/);
expect(() => assertHarnessMayMutate(labelled("b", waveLabels, { name: "probe", snapshot: "fleet-sbx-2" }), wave)).toThrow(/protected/);
expect(() => assertHarnessMayMutate(labelled("c", {}, { name: "probe" }), wave)).toThrow(/not labelled for this wave/);
expect(() => assertHarnessMayMutate(labelled("d", waveLabels, { name: "probe" }), wave)).not.toThrow();
});
});
describe("Daytona R6 reconciliation", () => {
const base = { companyId: "c", environmentId: "e", checkedAt: "now", error: null };
it("accepts live sandboxes backed by held leases and ignores destroyed ones", () => {
const result = reconcileSandboxes(
[{ id: "s1", state: "started" }, { id: "s2", state: "destroyed" }, { id: "s3", state: "stopped" }],
[{ id: "l1", status: "retained", providerLeaseId: "s1", heartbeatRunId: "r1" },
{ id: "l3", status: "released", providerLeaseId: "s3", heartbeatRunId: "r3" }],
);
expect(result.liveSandboxes).toEqual(["s1", "s3"]);
expect(result.orphanSandboxes).toEqual([expect.objectContaining({ sandboxId: "s3", leaseStatus: "released" })]);
expect(result.heldLeases).toEqual([]);
expect(result.retainedLeases.map(lease => lease.leaseId)).toEqual(["l1"]);
});
it("treats an archive behind a released lease as expected, but not a running one or one without a lease", () => {
const result = reconcileSandboxes(
[{ id: "a1", state: "archived" }, { id: "a2", state: "started" }, { id: "a3", state: "archived" }],
[{ id: "l1", status: "released", providerLeaseId: "a1" }, { id: "l2", status: "released", providerLeaseId: "a2" }],
);
expect(result.archivedSandboxes).toEqual(["a1"]);
expect(result.orphanSandboxes.map(leak => leak.sandboxId)).toEqual(["a2", "a3"]);
});
it("reports sandboxes with no lease and leases still active at rest", () => {
const result = reconcileSandboxes(
[{ id: "s9", state: "started", labels: { "paperclip-run-id": "r9" } }, { id: "s1", state: "started" }],
[{ id: "l1", status: "active", providerLeaseId: "s1", heartbeatRunId: "r1", issueId: "i1" }],
);
expect(result.orphanSandboxes).toEqual([expect.objectContaining({ sandboxId: "s9", runId: "r9", reason: "no lease references this sandbox" })]);
expect(result.heldLeases).toEqual([expect.objectContaining({ leaseId: "l1", status: "active" })]);
});
it("attributes leaks to the task that owns the run or issue; the rest stay wave-level", () => {
const sweep: DaytonaSweepResult = { ...base, liveSandboxes: ["s9", "s1", "s5"], retainedLeases: [], archivedSandboxes: [],
orphanSandboxes: [{ sandboxId: "s9", state: "started", runId: "r9", leaseId: null, leaseStatus: null, reason: "x" },
{ sandboxId: "s5", state: "started", runId: null, leaseId: null, leaseStatus: null, reason: "x" }],
heldLeases: [{ leaseId: "l1", status: "active", providerLeaseId: "s1", runId: null, issueId: "i1" }] };
const task = leaksForTask(sweep, new Set(["r9"]), "i1");
expect(task).toEqual(["orphan_sandbox=s9", "active_lease=l1"]);
expect(unattributedLeaks(sweep, new Set(task))).toEqual(["orphan_sandbox=s5"]);
expect(sweepHasLeaks(sweep)).toBe(true);
expect(sweepHasLeaks({ ...sweep, orphanSandboxes: [], heldLeases: [] })).toBe(false);
});
});
+205
View File
@@ -0,0 +1,205 @@
/**
* R6 for `--env daytona`: reconcile the Daytona sandboxes this wave created
* against the instance's environment leases and report leaks.
*
* The Daytona org is shared with the live Cloud fleet, so this module NEVER
* changes a sandbox. Its client exposes only `list`; stop, archive and delete
* do not exist here. Cleanup goes only through Paperclip: deleting the wave's
* own environment, which acts on the leases in the wave's isolated database.
* Leaks are reported for a person, and expire through the environment's ttl.
*
* Wave ownership comes from the labels the Daytona plugin puts on every
* sandbox it creates. Each wave's isolated instance mints a fresh company and
* environment UUID, and a row is selected only when both labels equal them.
*/
import { setTimeout as delay } from "node:timers/promises";
import { createRemoteFixtureClient } from "../runner-e2e/remote-native-bootstrap.js";
import { deleteDaytonaEnvironment } from "../runner-e2e/live-fixtures.js";
import type { ChaosApi } from "./api.js";
export interface DaytonaSandboxRow { id: string; name?: string; state?: string; snapshot?: string; labels?: Record<string, string> }
export interface LeaseRow { id: string; status: string; providerLeaseId?: string | null; heartbeatRunId?: string | null; issueId?: string | null }
/** Lease statuses where Paperclip still owns a provider resource. */
const HELD_LEASE_STATUSES = new Set(["active", "retained", "pending_cleanup"]);
/** Sandbox states that no longer hold anything. */
const GONE_SANDBOX_STATES = new Set(["destroyed", "destroying"]);
/**
* The Daytona plugin may archive a sandbox when it releases the lease
* (`archiveOnRelease`, auto-deleted after 60 min) and Daytona auto-archives
* stopped ones. An archive behind a lease Paperclip released holds no compute.
*/
const ARCHIVED_SANDBOX_STATES = new Set(["archived", "archiving"]);
export interface SandboxLeak { sandboxId: string; state: string | null; runId: string | null; leaseId: string | null; leaseStatus: string | null; reason: string }
export interface LeaseLeak { leaseId: string; status: string; providerLeaseId: string | null; runId: string | null; issueId: string | null }
export interface DaytonaSweepResult {
companyId: string;
environmentId: string;
checkedAt: string;
liveSandboxes: string[];
/** Live sandboxes no held lease accounts for, or whose lease Paperclip already released. */
orphanSandboxes: SandboxLeak[];
/** Leases still `active` or `pending_cleanup` with every task at rest. */
heldLeases: LeaseLeak[];
/** Leases kept on purpose (`retained`), reported but not a leak. */
retainedLeases: LeaseLeak[];
/** Archived sandboxes behind a released lease: expected and reported; Daytona deletes them. */
archivedSandboxes: string[];
error: string | null;
}
/** Pure reconciliation of one listing. */
export function reconcileSandboxes(sandboxes: readonly DaytonaSandboxRow[], leases: readonly LeaseRow[]):
Pick<DaytonaSweepResult, "liveSandboxes" | "orphanSandboxes" | "heldLeases" | "retainedLeases" | "archivedSandboxes"> {
const live = sandboxes.filter(sandbox => !GONE_SANDBOX_STATES.has(String(sandbox.state ?? "").toLowerCase()));
const byProviderId = new Map(leases.filter(lease => lease.providerLeaseId).map(lease => [lease.providerLeaseId!, lease]));
const orphanSandboxes: SandboxLeak[] = [];
const archivedSandboxes: string[] = [];
for (const sandbox of live) {
const lease = byProviderId.get(sandbox.id) ?? (sandbox.name ? byProviderId.get(sandbox.name) : undefined);
if (lease && HELD_LEASE_STATUSES.has(lease.status)) continue;
if (lease && ARCHIVED_SANDBOX_STATES.has(String(sandbox.state ?? "").toLowerCase())) { archivedSandboxes.push(sandbox.id); continue; }
orphanSandboxes.push({ sandboxId: sandbox.id, state: sandbox.state ?? null, runId: sandbox.labels?.["paperclip-run-id"] ?? null,
leaseId: lease?.id ?? null, leaseStatus: lease?.status ?? null,
reason: lease ? `sandbox alive after its lease became ${lease.status}` : "no lease references this sandbox" });
}
const toLeak = (lease: LeaseRow): LeaseLeak => ({ leaseId: lease.id, status: lease.status, providerLeaseId: lease.providerLeaseId ?? null,
runId: lease.heartbeatRunId ?? null, issueId: lease.issueId ?? null });
return {
liveSandboxes: live.map(sandbox => sandbox.id),
orphanSandboxes,
heldLeases: leases.filter(lease => lease.status === "active" || lease.status === "pending_cleanup").map(toLeak),
retainedLeases: leases.filter(lease => lease.status === "retained").map(toLeak),
archivedSandboxes,
};
}
export const sweepHasLeaks = (result: DaytonaSweepResult) => Boolean(result.error || result.orphanSandboxes.length || result.heldLeases.length);
/** Leak descriptions for one task, matched by its run ids and issue id. */
export function leaksForTask(result: DaytonaSweepResult, runIds: ReadonlySet<string>, issueId: string | null): string[] {
return [
...result.orphanSandboxes.filter(leak => leak.runId && runIds.has(leak.runId)).map(leak => `orphan_sandbox=${leak.sandboxId}`),
...result.heldLeases.filter(lease => (lease.runId && runIds.has(lease.runId)) || (issueId && lease.issueId === issueId))
.map(lease => `${lease.status}_lease=${lease.leaseId}`),
];
}
/** Leaks no task claimed (setup probes, sandboxes without a run label). */
export function unattributedLeaks(result: DaytonaSweepResult, attributed: ReadonlySet<string>): string[] {
return [
...result.orphanSandboxes.map(leak => `orphan_sandbox=${leak.sandboxId}`),
...result.heldLeases.map(lease => `${lease.status}_lease=${lease.leaseId}`),
].filter(leak => !attributed.has(leak));
}
/** The only Daytona operation the harness may perform. */
export interface ReadOnlyDaytona { list(query: { labels: Record<string, string> }): AsyncIterable<DaytonaSandboxRow> }
/**
* Wraps the SDK so nothing but a label-filtered `list` is reachable: the
* returned object has no stop, archive, delete or label-setting method.
*/
export function readOnlyDaytona(client: { list(query?: { labels?: Record<string, string> }): AsyncIterable<DaytonaSandboxRow> }): ReadOnlyDaytona {
// The SDK is untyped here (it lives in the plugin's own install), so check its shape at runtime.
if (typeof client?.list !== "function") throw new Error("Daytona SDK client has no list(); the pinned SDK version changed");
return Object.freeze({ list: (query: { labels: Record<string, string> }) => client.list(query) });
}
/** Fleet sandboxes the harness must never act on, even if their labels matched. */
export function isProtectedSandbox(sandbox: Pick<DaytonaSandboxRow, "name" | "snapshot">): boolean {
return (sandbox.name ?? "").startsWith("paperclip-create-") || (sandbox.snapshot ?? "").startsWith("fleet-sbx-");
}
/**
* Hard guard for any future mutating path. It refuses `paperclip-create-*`
* names and `fleet-sbx-*` snapshots outright, and anything not carrying both
* of the wave's UUID labels. Every plugin-created sandbox is
* `paperclip-create-*`, so the harness can never mutate one directly.
*/
export function assertHarnessMayMutate(sandbox: DaytonaSandboxRow, wave: { companyId: string; environmentId: string }): void {
if (isProtectedSandbox(sandbox)) throw new Error(`Refusing to change protected Daytona sandbox ${sandbox.id} (${sandbox.name ?? sandbox.snapshot})`);
if (selectWaveSandboxes([sandbox], wave).length !== 1) throw new Error(`Refusing to change Daytona sandbox ${sandbox.id}: it is not labelled for this wave`);
}
const UUID = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i;
/** Keeps only rows whose company AND environment labels equal this wave's UUIDs. */
export function selectWaveSandboxes(rows: readonly DaytonaSandboxRow[], wave: { companyId: string; environmentId: string }): DaytonaSandboxRow[] {
if (!UUID.test(wave.companyId) || !UUID.test(wave.environmentId)) throw new Error("Wave sandbox selection needs the wave's company and environment UUIDs");
return rows.filter(row => row.labels?.["paperclip-company-id"] === wave.companyId
&& row.labels?.["paperclip-environment-id"] === wave.environmentId);
}
export class DaytonaWaveSweeper {
private client: ReadOnlyDaytona | null = null;
constructor(private readonly input: { apiKey: string; companyId: string; environmentId: string; api: ChaosApi }) {}
private async daytona(): Promise<ReadOnlyDaytona> {
this.client ??= readOnlyDaytona(await createRemoteFixtureClient(this.input.apiKey) as unknown as Parameters<typeof readOnlyDaytona>[0]);
return this.client;
}
/** Server-side label filter, then the same check again on every row. */
async listWaveSandboxes(): Promise<DaytonaSandboxRow[]> {
const wave = { companyId: this.input.companyId, environmentId: this.input.environmentId };
const rows: DaytonaSandboxRow[] = [];
for await (const sandbox of (await this.daytona()).list({ labels: {
"paperclip-company-id": wave.companyId, "paperclip-environment-id": wave.environmentId } })) rows.push(sandbox);
return selectWaveSandboxes(rows, wave);
}
/** Reconcile, retrying for `settleMs` so normal per-turn teardown can finish. */
async sweep(settleMs = 120_000): Promise<DaytonaSweepResult> {
const deadline = Date.now() + settleMs;
for (;;) {
const result: DaytonaSweepResult = { companyId: this.input.companyId, environmentId: this.input.environmentId,
checkedAt: new Date().toISOString(), liveSandboxes: [], orphanSandboxes: [], heldLeases: [], retainedLeases: [], archivedSandboxes: [], error: null };
try {
const [sandboxes, leases] = await Promise.all([
this.listWaveSandboxes(),
this.input.api.get<LeaseRow[]>(`/api/environments/${this.input.environmentId}/leases`),
]);
Object.assign(result, reconcileSandboxes(sandboxes, leases));
} catch (error) {
result.error = error instanceof Error ? error.message : String(error);
}
if (!sweepHasLeaks(result) || Date.now() >= deadline) return result;
await delay(10_000);
}
}
/**
* Cleanup through Paperclip only: delete the wave's own environment (with its
* reusable leases). Then list, read-only, what is still not destroyed or
* archived, for the report. No sandbox is changed by the harness itself.
*/
async cleanUp(settleMs = 90_000): Promise<{ environmentDeleted: boolean; remainingSandboxes: string[]; errors: string[] }> {
const errors: string[] = [];
let environmentDeleted = false;
try {
await deleteDaytonaEnvironment(this.input.api, this.input.environmentId);
environmentDeleted = true;
} catch (error) { errors.push(`environment delete: ${error instanceof Error ? error.message : String(error)}`); }
let remainingSandboxes: string[] = [];
// Provider teardown is asynchronous; give it a bounded settle before reporting.
const deadline = Date.now() + settleMs;
for (;;) {
try {
remainingSandboxes = (await this.listWaveSandboxes())
.filter(sandbox => !GONE_SANDBOX_STATES.has(String(sandbox.state ?? "").toLowerCase())
&& !ARCHIVED_SANDBOX_STATES.has(String(sandbox.state ?? "").toLowerCase()))
.map(sandbox => `${sandbox.id}:${sandbox.state}`);
} catch (error) {
errors.push(`sandbox listing: ${error instanceof Error ? error.message : String(error)}`);
break;
}
if (!remainingSandboxes.length || Date.now() >= deadline) break;
await delay(10_000);
}
return { environmentDeleted, remainingSandboxes, errors };
}
}
+1
View File
@@ -179,6 +179,7 @@ export function gradeR6(record: TaskRecord): RuleResult {
...(sweep.activeRuns.length ? [`active_runs=${sweep.activeRuns.join(",")}`] : []),
...(sweep.runningWorkspaceOperations.length ? [`running_workspace_operations=${sweep.runningWorkspaceOperations.join(",")}`] : []),
...(sweep.activeRecoveryAction ? ["active_recovery_action"] : []),
...(sweep.daytonaLeaks ?? []),
];
return leaks.length ? { verdict: "fail", detail: `leak: ${leaks.join("; ")}` } : { verdict: "pass", detail: "clean rest" };
}
+89 -7
View File
@@ -14,6 +14,8 @@ import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { ChaosApi, isSocketTypeOfServiceRace } from "./api.js";
import { CREDENTIAL_FOR_ADAPTER, parseCommand, replayCommand, waveIdFor } from "./config.js";
import { assertControllerMatchesImage, prepareDaytonaImageArtifacts } from "./daytona-image.js";
import { DaytonaWaveSweeper, leaksForTask, unattributedLeaks, type DaytonaSweepResult } from "./daytona-sweep.js";
import { knownSecretValues, writeEvidenceBundle } from "./evidence.js";
import { gradeTask } from "./grader.js";
import { appendLedger, ledgerLine, writeSummary, type LedgerLine } from "./ledger.js";
@@ -48,8 +50,41 @@ async function loadPlan(): Promise<{ plan: WavePlan; keep: boolean }> {
return { plan: planWave(waveIdFor(command.options.seed), command.options), keep: command.keep };
}
function run(command: string, args: string[], cwd: string, label: string) {
const result = spawnSync(command, args, { cwd, stdio: "inherit" });
if (result.status !== 0) throw new Error(`${label} failed (${command} ${args.join(" ")})`);
}
/** The bundled Daytona plugin is outside the root workspace; prepare it as CI does. */
function ensureDaytonaPlugin() {
const root = path.join(repositoryRoot, "packages/plugins/sandbox-providers/daytona");
if (["dist/manifest.js", "dist/worker.js", "node_modules/@daytonaio/sdk"].every(file => existsSync(path.join(root, file)))) return;
run("pnpm", ["install", "--ignore-workspace", "--frozen-lockfile", "--ignore-scripts"], root, "Daytona plugin install");
run(process.execPath, [path.join(repositoryRoot, "scripts/link-plugin-dev-sdk.mjs")], repositoryRoot, "Plugin SDK link");
run("pnpm", ["--dir", root, "build"], repositoryRoot, "Daytona plugin build");
}
/**
* `--env daytona`: extract the image's Linux runnerd and provider pack (cached
* per image digest, no Docker) and refuse to run a controller whose runner
* source differs from the image.
*/
async function prepareDaytona(plan: WavePlan, timeline: Timeline): Promise<Record<string, string>> {
const image = plan.options.daytonaImage!;
const artifacts = await prepareDaytonaImageArtifacts({ image, cacheRoot: path.join(resultsRoot, "daytona-images"),
log: line => console.info(`[daytona-image] ${line}`) });
const { localContentId } = await assertControllerMatchesImage(artifacts, repositoryRoot);
timeline.add("daytona_image_matched", { image, contentId: artifacts.contentId, localContentId, sourceRevision: artifacts.sourceRevision,
runnerdSha256: artifacts.runnerdSha256, providerPackDigest: artifacts.providerPackDigest, platformManifestDigest: artifacts.platformManifestDigest });
ensureDaytonaPlugin();
timeline.add("daytona_vcpu_plan", { cpuPerSandbox: plan.options.daytonaCpu, concurrency: plan.options.concurrency,
taskVcpu: plan.options.concurrency * (plan.options.daytonaCpu ?? 0) });
return { PAPERCLIP_RUNNER_REMOTE_BINARY_PATH: artifacts.runnerdPath, PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH: artifacts.providerPackPath };
}
function preflight(plan: WavePlan) {
const missing = [...new Set(plan.tasks.map(task => CREDENTIAL_FOR_ADAPTER[task.adapter]))].filter(key => !process.env[key]);
const missing = [...new Set([...plan.tasks.map(task => CREDENTIAL_FOR_ADAPTER[task.adapter]),
...(plan.options.env === "daytona" ? ["DAYTONA_API_KEY"] : [])])].filter(key => !process.env[key]);
if (missing.length) throw new Error(`Missing credentials ${missing.join(", ")}. Run: set -a; source ~/.secrets; set +a`);
const runner = process.env.PAPERCLIP_RUNNER_BINARY
?? path.join(repositoryRoot, "packages/paperclip-runner/runner/target/debug/paperclip-runnerd");
@@ -71,8 +106,11 @@ async function main(): Promise<number> {
timeline.add("wave_planned", { waveId: plan.waveId, seed: plan.seed, gitSha, gitDirty, replayOf: plan.replayOf ?? null,
tasks: plan.tasks.map(task => `${task.taskId}:${task.adapter}/${task.workload}/${task.fault}${task.trigger ? `^${task.trigger}+${task.triggerDelayMs ?? 0}ms` : ""}@${task.fireAfterMs}ms${task.holdMs ? ` hold ${task.holdMs}ms` : ""}`) });
preflight(plan);
const serverEnvironment = plan.options.env === "daytona" ? await prepareDaytona(plan, timeline) : {};
const supervisor = await ChaosSupervisor.create({ repositoryRoot, waveDir, timeline });
let sweeper: DaytonaWaveSweeper | null = null;
let sandboxSweep: DaytonaSweepResult | null = null;
let interrupted = false;
const onSignal = (signal: NodeJS.Signals) => { if (!interrupted) { interrupted = true; timeline.add("harness_interrupted", { signal }); } };
process.on("SIGINT", onSignal); process.on("SIGTERM", onSignal);
@@ -90,11 +128,15 @@ async function main(): Promise<number> {
let faultFailures = 0;
let faultsQueued = 0;
try {
await supervisor.start();
await supervisor.start(serverEnvironment);
const api = new ChaosApi(supervisor.baseUrl);
const lab = await seedChaosLab({ api, env: plan.options.env, adapters: [...new Set(plan.tasks.map(task => task.adapter))],
const lab = await seedChaosLab({ api, env: plan.options.env, daytonaImage: plan.options.daytonaImage, daytonaCpu: plan.options.daytonaCpu,
adapters: [...new Set(plan.tasks.map(task => task.adapter))],
concurrency: plan.options.concurrency, budgetCents: plan.options.budgetCents,
fixtureRoot: path.join(supervisor.paths.temporaryRoot, "fixtures"), waveId: plan.waveId, timeline });
fixtureRoot: path.join(supervisor.paths.temporaryRoot, "fixtures"), waveId: plan.waveId, timeline,
onEnvironment: owned => {
sweeper = new DaytonaWaveSweeper({ apiKey: process.env.DAYTONA_API_KEY!, companyId: owned.companyId, environmentId: owned.environmentId, api });
} });
// The instance root holds the secrets master key and the database: oracle code may not read it.
const protectedRoots = [os.homedir(), supervisor.paths.temporaryRoot, resultsRoot];
const observers = plan.tasks.map(task => new TaskObserver(task, api, lab, timeline, supervisor, protectedRoots));
@@ -152,9 +194,20 @@ async function main(): Promise<number> {
const quietFor = Date.now() - lastDoneAt;
if (quietFor < QUIET_BEFORE_SWEEP_MS) await delay(QUIET_BEFORE_SWEEP_MS - quietFor);
timeline.add("wave_sweep", {});
if (sweeper) {
sandboxSweep = await (sweeper as DaytonaWaveSweeper).sweep();
timeline.add("daytona_sweep", { ...sandboxSweep });
}
const attributed = new Set<string>();
const secrets = knownSecretValues();
for (const observer of observers) {
observer.record.sweep = await observer.sweep();
if (sandboxSweep) {
const runIds = new Set(observer.record.samples.flatMap(sample => (sample.runs ?? []).map(run => run.id)));
const leaks = leaksForTask(sandboxSweep, runIds, observer.record.issueId);
for (const leak of leaks) attributed.add(leak);
observer.record.sweep.daytonaLeaks = leaks;
}
let artifacts: OracleArtifacts | null = null;
if (observer.record.issueId) artifacts = await observer.collectArtifacts().catch(() => null);
const oracle = artifacts ? observer.definition.grade(observer.plan.nonce, artifacts)
@@ -173,14 +226,43 @@ async function main(): Promise<number> {
rules: Object.fromEntries(Object.entries(grade.rules).map(([id, rule]) => [id, rule.verdict])), T: grade.T.verdict });
}
await writeFile(path.join(waveDir, "faults.json"), `${JSON.stringify(faults, null, 2)}\n`, { mode: 0o600 });
// R6 at wave level: a leak no task's runs account for (e.g. the setup probe) still fails the wave.
if (sandboxSweep) {
const unattributed = unattributedLeaks(sandboxSweep, attributed);
if (sandboxSweep.error || unattributed.length) {
anyFailure = true;
timeline.add("daytona_sweep_wave_leak", { error: sandboxSweep.error, unattributed });
}
}
} catch (error) {
harnessError = error;
timeline.add("harness_error", { error: error instanceof Error ? error.stack ?? error.message : String(error) });
} finally {
try { await supervisor.stop(); } catch (error) {
// Incomplete cleanup is a harness failure; keep the instance for inspection.
// A failure here (for example writing the report) must not skip stopping the controller and Postgres.
try { if (sweeper) try {
const owned = sweeper as DaytonaWaveSweeper;
// An interrupted wave still reports what it leaked before cleanup.
sandboxSweep ??= await owned.sweep(0);
// Cleanup only through Paperclip; the harness never changes a sandbox.
const cleanup = await owned.cleanUp();
timeline.add("daytona_cleanup", cleanup);
console.info(`Daytona R6 sweep: ${sandboxSweep.liveSandboxes.length} live, ${sandboxSweep.archivedSandboxes.length} archived after release, `
+ `${sandboxSweep.orphanSandboxes.length} orphaned, `
+ `${sandboxSweep.heldLeases.length} held leases${sandboxSweep.error ? `, error ${sandboxSweep.error}` : ""}; `
+ `environment deleted: ${cleanup.environmentDeleted}; still running after cleanup: ${cleanup.remainingSandboxes.join(", ") || "none"}`);
// A sandbox neither archived nor destroyed after cleanup is a leak for a person to act on.
if (cleanup.remainingSandboxes.length) anyFailure = true;
if (cleanup.errors.length) harnessError ??= new Error(`Daytona cleanup incomplete: ${cleanup.errors.join("; ")}`);
await writeFile(path.join(waveDir, "sandbox-sweep.json"), `${JSON.stringify({ sweep: sandboxSweep, cleanup }, null, 2)}\n`, { mode: 0o600 });
} catch (error) {
harnessError ??= error;
timeline.add("supervisor_stop_failed", { error: String(error) });
timeline.add("daytona_cleanup_failed", { error: String(error) });
} } finally {
try { await supervisor.stop(); } catch (error) {
// Incomplete cleanup is a harness failure; keep the instance for inspection.
harnessError ??= error;
timeline.add("supervisor_stop_failed", { error: String(error) });
}
}
if (lines.length) {
await appendLedger(resultsRoot, lines);
+1 -1
View File
@@ -11,7 +11,7 @@ import type { TaskPlan } from "./types.js";
import { BENCH_EXPECTED, parseBenchRecord } from "./workloads.js";
process.env.RESTART_CHAOS_QUIET = "1";
const lab: ChaosLab = { companyId: "c1", environmentId: "e1", projectId: "p1", fixtureRepositoryPath: "/tmp/none",
const lab: ChaosLab = { companyId: "c1", environmentId: "e1", environmentKind: "local", projectId: "p1", fixtureRepositoryPath: "/tmp/none",
agents: { codex: { id: "a1", name: "Chaos codex" }, claude: { id: "a2", name: "Chaos claude" }, opencode: { id: "a3", name: "Chaos opencode" } } };
const plan: TaskPlan = { taskId: "t01", index: 0, workload: "C1", adapter: "codex", fault: "K1", fireAfterMs: 999_999,
holdMs: 0, followUpAfterMs: 1_000, nonce: "abcd1234" };
+14
View File
@@ -114,3 +114,17 @@ describe("CLI parsing", () => {
expect(() => parseCommand(["--poll-ms", "500"])).toThrow(/between 2000 and 5000/);
});
});
describe("Daytona options", () => {
const image = `ghcr.io/paperclipai/paperclip-daytona-runner@sha256:${"a".repeat(64)}`;
it("defaults to 4 vCPU sandboxes, excludes B5, and keeps the image in the plan", () => {
const parsed = parseCommand(["--env", "daytona", "--daytona-image", image, "--concurrency", "10"]);
expect(parsed).toMatchObject({ mode: "wave", options: { daytonaImage: image, daytonaCpu: 4, concurrency: 10 } });
if (parsed.mode === "wave") expect(parsed.options.workloads).not.toContain("B5");
});
it("refuses waves over the 40 vCPU chaos budget, mutable images, and Daytona flags without Daytona", () => {
expect(() => parseCommand(["--env", "daytona", "--daytona-image", image, "--concurrency", "11"])).toThrow(/exceeds the Daytona vCPU budget of 40/);
expect(() => parseCommand(["--env", "daytona", "--daytona-image", "ghcr.io/x/y:latest"])).toThrow(/immutable runner image/);
expect(() => parseCommand(["--env", "local", "--daytona-cpu", "2"])).toThrow(/only to --env daytona/);
});
});
+84 -19
View File
@@ -1,8 +1,10 @@
import { execFileSync } from "node:child_process";
import { chmod, mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { runnerProfiles } from "../runner-e2e/catalog.js";
import type { SecretReference, SecretReferenceMap } from "../runner-e2e/types.js";
import type { RunnerApi } from "../runner-e2e/api.js";
import { runnerEnvironments, runnerProfiles } from "../runner-e2e/catalog.js";
import { installFixtureDaytonaProvider, waitForFixtureDaytonaProvider } from "../runner-e2e/live-fixtures.js";
import type { MatrixExecution, SecretReference, SecretReferenceMap } from "../runner-e2e/types.js";
import type { ChaosApi } from "./api.js";
import { CREDENTIAL_FOR_ADAPTER, PROFILE_FOR_ADAPTER } from "./config.js";
import type { Timeline } from "./timeline.js";
@@ -12,6 +14,7 @@ import { fixtureRepositoryFiles } from "./workloads.js";
export interface ChaosLab {
companyId: string;
environmentId: string;
environmentKind: EnvironmentKind;
projectId: string;
fixtureRepositoryPath: string;
agents: Record<AdapterId, { id: string; name: string }>;
@@ -35,6 +38,55 @@ export async function createFixtureRepository(directory: string, benchSeconds?:
return directory;
}
/** The runner-e2e fixture helpers take RunnerApi's `{ timeout }` options. */
function runnerApiView(api: ChaosApi) {
return {
get: <T>(route: string, options?: { timeout?: number }) => api.get<T>(route, options?.timeout),
post: <T>(route: string, body?: unknown, options?: { timeout?: number }) => api.post<T>(route, body, options?.timeout),
} as unknown as Pick<RunnerApi, "get" | "post">;
}
/**
* Daytona environment exactly as runner-e2e creates it: the bundled Daytona
* plugin (live-fixtures.ts installFixtureDaytonaProvider) and the `daytona`
* fixture from catalog.ts runnerEnvironments, pinned to the immutable image.
*/
export function chaosDaytonaShape(cpu: number) {
// The runner-e2e fixture's 4 GiB memory and 10 GiB disk, with the vCPU count
// chosen by --daytona-cpu (4 by default, like the fixture). 2 vCPU with this
// memory and disk passed a 3-task wave on a 10 vCPU org.
return { cpu, memory: 4, disk: 10 };
}
async function createDaytonaEnvironment(input: { api: ChaosApi; companyId: string; image: string; cpu: number; waveId: string;
secretRefs: SecretReferenceMap; timeline: Timeline }): Promise<string> {
const fixture = runnerEnvironments.find(candidate => candidate.id === "daytona");
if (!fixture) throw new Error("runner-e2e catalog has no daytona environment fixture");
const view = runnerApiView(input.api);
await waitForFixtureDaytonaProvider(view, { companyId: input.companyId, deadlineAt: Date.now() + 120_000 });
const payload = fixture.buildEnvironment({ secretRefs: input.secretRefs, daytonaImage: input.image, executionId: input.waveId });
const shape = chaosDaytonaShape(input.cpu);
const environment = await input.api.post<{ id: string; driver: string }>(`/api/companies/${input.companyId}/environments`,
{ ...payload, config: { ...(payload.config as Record<string, unknown>), ...shape } });
input.timeline.add("seed_environment", { environmentId: environment.id, driver: environment.driver, image: input.image, ...shape });
return environment.id;
}
/**
* Native Codex on a sandbox needs the image's platform qualification recorded
* first, as in live-fixtures.ts (the agent is not created otherwise).
*/
async function qualifyNativeCodex(input: { api: ChaosApi; companyId: string; environmentId: string; timeline: Timeline }) {
const startedAt = Date.now();
const probe = await input.api.post<{ ok: boolean; details?: { platformQualificationRecorded?: boolean; leaseId?: string } }>(
`/api/environments/${input.environmentId}/probe?companyId=${encodeURIComponent(input.companyId)}`, {}, 330_000);
input.timeline.add("seed_codex_qualification", { ok: probe.ok, recorded: probe.details?.platformQualificationRecorded ?? false,
leaseId: probe.details?.leaseId ?? null, durationMs: Date.now() - startedAt });
if (probe.ok !== true || probe.details?.platformQualificationRecorded !== true) {
throw new Error("Native Codex image qualification was not recorded; the Codex agent was not created.");
}
}
/**
* Seeds the isolated instance. Credentials cross into the server only as
* encrypted company secrets, as in tests/runner-e2e/live-fixtures.ts, and the
@@ -43,20 +95,23 @@ export async function createFixtureRepository(directory: string, benchSeconds?:
export async function seedChaosLab(input: {
api: ChaosApi;
env: EnvironmentKind;
daytonaImage?: string;
daytonaCpu?: number;
adapters: readonly AdapterId[];
concurrency: number;
budgetCents: number;
fixtureRoot: string;
waveId: string;
timeline: Timeline;
/** Called as soon as the wave owns external resources that need cleanup. */
onEnvironment?: (lab: Pick<ChaosLab, "companyId" | "environmentId" | "environmentKind">) => void;
}): Promise<ChaosLab> {
const { api, timeline } = input;
if (input.env === "daytona") {
// TODO(restart-chaos phase 1b): create the Daytona sandbox environment from
// the prepared image (see tests/runner-e2e/catalog.ts runnerEnvironments
// and live-fixtures.ts installFixtureDaytonaProvider) and add the R6
// sandbox sweep. The image and env are being prepared separately.
throw new Error("--env daytona is not wired yet; use --env local");
if (!input.daytonaImage) throw new Error("--env daytona needs --daytona-image");
// Installed before the company exists so the provider worker is warm.
await installFixtureDaytonaProvider(runnerApiView(input.api), { environment: { id: "daytona" } } as MatrixExecution);
timeline.add("seed_daytona_plugin", {});
}
const fixtureRepositoryPath = await createFixtureRepository(path.join(input.fixtureRoot, "chaos-sandbox"));
const company = await api.post<{ id: string }>("/api/companies", {
@@ -67,27 +122,37 @@ export async function seedChaosLab(input: {
timeline.add("seed_company", { companyId: company.id });
const secretRefs: SecretReferenceMap = {};
for (const adapter of input.adapters) {
const key = CREDENTIAL_FOR_ADAPTER[adapter];
if (secretRefs[key]) continue;
const credentialKeys = [...new Set([...input.adapters.map(adapter => CREDENTIAL_FOR_ADAPTER[adapter]),
...(input.env === "daytona" ? ["DAYTONA_API_KEY" as const] : [])])];
for (const key of credentialKeys) {
const value = process.env[key];
if (!value) throw new Error(`Missing ${key} for adapter ${adapter}; load ~/.secrets into the environment first`);
if (!value) throw new Error(`Missing ${key}; load ~/.secrets into the environment first`);
const created = await api.post<{ id: string }>(`/api/companies/${company.id}/secrets`, {
name: `Chaos ${key}`, key, value, description: "Restart chaos harness provider credential",
name: `Chaos ${key}`, key, value, description: "Restart chaos harness credential",
});
secretRefs[key] = { type: "secret_ref", secretId: created.id, version: "latest" } satisfies SecretReference;
}
timeline.add("seed_secrets", { keys: Object.keys(secretRefs) });
const environments = await api.get<Array<{ id: string; driver: string }>>(`/api/companies/${company.id}/environments?driver=local`);
const environment = environments.find(candidate => candidate.driver === "local");
if (!environment) throw new Error("Isolated instance has no managed local environment");
let environmentId: string;
if (input.env === "daytona") {
environmentId = await createDaytonaEnvironment({ api, companyId: company.id, image: input.daytonaImage!, cpu: input.daytonaCpu ?? 4,
waveId: input.waveId, secretRefs, timeline });
input.onEnvironment?.({ companyId: company.id, environmentId, environmentKind: "daytona" });
if (input.adapters.includes("codex")) await qualifyNativeCodex({ api, companyId: company.id, environmentId, timeline });
} else {
const environments = await api.get<Array<{ id: string; driver: string }>>(`/api/companies/${company.id}/environments?driver=local`);
const environment = environments.find(candidate => candidate.driver === "local");
if (!environment) throw new Error("Isolated instance has no managed local environment");
environmentId = environment.id;
}
const agents = {} as ChaosLab["agents"];
for (const adapter of input.adapters) {
const profile = runnerProfiles.find(candidate => candidate.id === PROFILE_FOR_ADAPTER[adapter]);
if (!profile) throw new Error(`runner-e2e catalog has no profile ${PROFILE_FOR_ADAPTER[adapter]}`);
const payload = profile.buildAgent({ environmentId: environment.id, environmentFixtureId: "local",
if (!profile.supportedEnvironments.includes(input.env)) throw new Error(`Profile ${profile.id} does not support ${input.env}`);
const payload = profile.buildAgent({ environmentId, environmentFixtureId: input.env,
workspacePath: fixtureRepositoryPath, secretRefs, executionId: input.waveId });
const name = `Chaos ${adapter}`;
const runtimeConfig = (payload.runtimeConfig ?? {}) as Record<string, unknown>;
@@ -99,7 +164,7 @@ export async function seedChaosLab(input: {
enabled: false, maxConcurrentRuns: Math.max(1, input.concurrency) } },
});
agents[adapter] = { id: created.id, name };
timeline.add("seed_agent", { adapter, agentId: created.id, profile: profile.id, model: profile.model });
timeline.add("seed_agent", { adapter, agentId: created.id, profile: profile.id, model: profile.model, environmentId });
}
// The project's git_worktree policy only takes effect when the instance has
@@ -119,11 +184,11 @@ export async function seedChaosLab(input: {
enabled: true,
defaultMode: "isolated_workspace",
allowIssueOverride: false,
environmentId: environment.id,
environmentId,
workspaceStrategy: { type: "git_worktree" },
},
workspace: { name: "Primary", sourceType: "local_path", cwd: fixtureRepositoryPath, isPrimary: true },
});
timeline.add("seed_project", { projectId: project.id, fixtureRepositoryPath });
return { companyId: company.id, environmentId: environment.id, projectId: project.id, fixtureRepositoryPath, agents };
return { companyId: company.id, environmentId, environmentKind: input.env, projectId: project.id, fixtureRepositoryPath, agents };
}
+3 -1
View File
@@ -112,7 +112,8 @@ export class ChaosSupervisor {
try { return statSync(this.paths.serverLogPath).size + this.log.writableLength; } catch { return 0; }
}
async start(): Promise<void> {
/** `extraEnvironment` is kept for every restart (e.g. the Daytona image's runnerd and provider pack paths). */
async start(extraEnvironment: Record<string, string> = {}): Promise<void> {
const { temporaryRoot, home, configPath } = this.paths;
const reservation = await prepareRunnerE2EServerConfig({ temporaryRoot, configPath, serverPort: this.port });
await reservation?.close();
@@ -138,6 +139,7 @@ export class ChaosSupervisor {
PAPERCLIP_STORAGE_LOCAL_DIR: path.join(temporaryRoot, "storage"), PAPERCLIP_SECRETS_PROVIDER: "local_encrypted",
PAPERCLIP_SECRETS_STRICT_MODE: "true", PAPERCLIP_DB_BACKUP_ENABLED: "false",
PAPERCLIP_DB_BACKUP_DIR: path.join(temporaryRoot, "backups"), PAPERCLIP_OPEN_ON_LISTEN: "false",
...extraEnvironment,
};
const environment = buildPaperclipServerEnvironment(source, overrides);
assertIsolatedServerEnvironment(environment, { temporaryRoot, paperclipHome: home, configPath });
+1 -1
View File
@@ -15,7 +15,7 @@ import { ledgerLine } from "./ledger.js";
import type { TaskGrade, TaskPlan, TaskRecord, TaskSample, WaveOptions, WavePlan } from "./types.js";
process.env.RESTART_CHAOS_QUIET = "1";
const lab: ChaosLab = { companyId: "c1", environmentId: "e1", projectId: "p1", fixtureRepositoryPath: "/tmp/none",
const lab: ChaosLab = { companyId: "c1", environmentId: "e1", environmentKind: "local", projectId: "p1", fixtureRepositoryPath: "/tmp/none",
agents: { codex: { id: "a1", name: "Chaos codex" }, claude: { id: "a2", name: "Chaos claude" }, opencode: { id: "a3", name: "Chaos opencode" } } };
const ready = { serverReady: true, faultInProgress: false };
const base: TaskPlan = { taskId: "t01", index: 0, workload: "A1", adapter: "codex", fault: "K1", fireAfterMs: 999_999,
+6
View File
@@ -28,6 +28,10 @@ export interface WaveOptions {
budgetCents: number;
/** Poll interval for the observer, 2–5 s per plan §5. */
pollMs: number;
/** `--env daytona`: the immutable runner image (registry/repository@sha256:…). */
daytonaImage?: string;
/** `--env daytona`: vCPU per sandbox (see seed.ts chaosDaytonaShape). */
daytonaCpu?: number;
}
export interface TaskPlan {
@@ -161,6 +165,8 @@ export interface RestSweep {
activeRuns: string[];
runningWorkspaceOperations: string[];
activeRecoveryAction: boolean;
/** `--env daytona`: orphaned sandboxes and held leases belonging to this task's runs. */
daytonaLeaks?: string[];
error: string | null;
}