mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 14:19:00 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - The runner E2E chat evals run real agents against a local Paperclip
instance on a CI host
> - PR #20044 hides the eval harness from local native agents with a
Linux Landlock domain around each runnerd launch
> - That domain is the same for every run in an attempt, so an eval
agent can still read another run's saved state
> - Recent chat-eval runs show agents that read other runs' OpenCode
session databases, another run's run log, the instance `pg_wal`,
`control-plane-state.json`, and `/proc/<pid>/cmdline` of harness
processes
> - This pull request scopes each runnerd launch to its own run and
moves the selected case id off the command line
> - The benefit is that an eval agent cannot learn about the case or
other runs from files outside its own run
## Linked Issues or Issue Description
Refs #20044
**What happened**
In local native OpenCode chat-eval cells, worker agents read files that
belong to other runs or to the harness:
- other runs' OpenCode sqlite session databases under
`runtime/paperclip-runner/durable-sessions/` (a status-cards run)
- `/proc/<pid>/cmdline` of harness processes, which shows harness paths
and the case id (`routines.pause-owned`)
- another run's run-log ndjson, and a listing of the instance
`db/pg_wal` (`routines.pause-owned`)
- a listing of `control-plane-state.json`
(`runs-adapters.list-model-options`)
The Landlock sandbox from #20044 hides only the harness (`tests/`, the
Git store, private attempt directories). All runnerd launches share one
hidden set, so the instance's per-run directories stay readable.
**Expected behavior**
An agent process sees only its own run's durable session, OpenCode
session and provider trace. Other runs' sessions, run logs and traces,
the instance database files, secrets and the shared control-plane state
in the attempt instance are denied. The case id is not on any harness
command line.
**Steps to reproduce**
1. Dispatch a local native OpenCode chat-features cell that has two
agents (for example a status-cards case).
2. In the worker's shell, run `ls
<instance>/runtime/paperclip-runner/durable-sessions/*/` and `ls
<instance>/db/pg_wal`.
3. Run `cat /proc/<launcher pid>/cmdline`.
4. Before this change, all three succeed and show other runs' files and
`--id chat-features...`.
## What Changed
- `agent-filesystem-sandbox.py`: new `--expose PATH` option. It grants a
path beneath a hidden directory. Landlock does not check traversal, so
the run uses its own entry while the parent stays unlistable. The helper
refuses a missing exposure, an exposure outside every hidden path, or
one that would reveal a hidden path. It fails the launch unless every
exposed path is readable.
- `agent-filesystem-sandbox.ts`: the launcher passes the instance root
in the sandbox configuration. `runnerdLaunchSandbox` adds per-launch
hidden paths: `durable-sessions/`, `runner/`, `control-plane/`,
`opencode/` under `runtime/paperclip-runner/`, and `data/run-logs/`,
`data/provider-traces/`, `data/backups/`, `db/`, `secrets/`. It exposes
only the launch's own durable-session directory (from `--state-dir`),
Codex runner state directory, OpenCode session directory (from
`--session-id`, named as the OpenCode driver names it) and provider
trace file.
- The wrapper creates the exposed entries, and the shared instance
directories that the product creates on first use, before runnerd
starts. It logs the run-scoped hidden and exposed paths for each launch.
- New `probeRunScopedSandbox`: at server start, a sandboxed shell runs
against a fixture instance. Another run's session, OpenCode database,
runner state, run log, trace, `pg_wal`, secrets, control-plane state and
the server's `/proc/<pid>/environ` must be denied. The run's own
session, OpenCode directory, ACPX directory and trace must be writable.
In CI any failure stops the attempt. The result goes to `server.log`.
- `selectors.ts` and `launch.ts`: the launcher reads the selected cell
from `PAPERCLIP_RUNNER_E2E_SELECTED_ID` and removes it from its
environment.
- `runner-full-stack-e2e.yml` (Run paid cell) and
`chat-catalog-testops-case.yml`: pass the cell in that variable instead
of `--id`. A target branch that does not read the variable still gets
`--id`.
- `SECURITY.md`: documents the run scope, the `/proc` limit and what
stays readable.
## Verification
- `pnpm exec vitest run --config tests/runner-e2e/vitest.config.ts
tests/runner-e2e/agent-filesystem-sandbox.test.ts
tests/runner-e2e/workflow-security.test.ts
tests/runner-e2e/manual-private-workflow.test.ts
tests/runner-e2e/private-trust-workflow-security.test.ts` passes on
macOS. The Landlock host tests (including the new fixture-instance probe
test) run only on Linux CI.
- `npx tsc --noEmit -p tests/runner-e2e` shows no errors in the changed
files.
- Paid run
[42611358428](https://github.com/paperclipai/paperclip/actions/runs/42611358428):
`chat-features.runner-opencode.local.agents.change-manager` on the same
commit applied to `chat-eval/master-candidate` (fca4bb7ce3). Result:
passed. In `server.log` the setup probe reports `failures: []` and
`run:otherDurableSession`, `run:durableSessionListing`,
`run:otherRunnerState`, `run:controlPlaneState`,
`run:otherOpencodeSession`, `run:otherRunLog`, `run:databaseWal`,
`run:secrets`, `run:otherProviderTrace` and `run:harnessEnviron` all
`denied`; `run:ownDurableSession`, `run:ownOpencodeSession`,
`run:acpxRuntime` and `run:ownProviderTrace` `allowed`;
`run:harnessCmdline` `allowed` (recorded, Landlock limit). The runnerd
launch line lists the nine run-scoped hidden instance paths and exposes
only its own durable-session and OpenCode session directories. The agent
finished the case inside that sandbox.
## Risks
- A sandboxed runnerd cannot create new direct entries in the instance
root, `runtime/`, `runtime/paperclip-runner/` or `data/`. The wrapper
creates the known ones first (`acpx`, `companies`, `projects`,
`workspaces`, `runtime-context-assets`, `skills`,
`runtime/provider-packs`). A provider that creates a different new
directory there fails in a sandboxed local cell. The paid run covers
OpenCode only. ACPX cells keep their runtime directory readable.
- A run still reads its own session's control-plane state, and other
agents' homes and task workspaces in the same instance.
- Landlock cannot hide `/proc/<pid>/cmdline`. Harness paths stay visible
there. Only the case id moves to the environment. Scheduled multi-cell
campaigns still pass `--id`.
- Harness-only change. No product code changes.
## Model Used
- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), extended thinking, tool
use (shell, file edits, GitHub CLI).
## 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>
271 lines
9.1 KiB
TypeScript
271 lines
9.1 KiB
TypeScript
import { runnerMatrix } from "./catalog.js";
|
|
import type { MatrixExecution, MatrixJob } from "./types.js";
|
|
|
|
export interface RunnerSelectorOptions {
|
|
all: boolean;
|
|
list: boolean;
|
|
matrixJson: boolean;
|
|
installedStartupOnly?: boolean;
|
|
ids: string[];
|
|
suites: string[];
|
|
groups: string[];
|
|
profiles: string[];
|
|
environments: string[];
|
|
cases: string[];
|
|
headed: boolean;
|
|
ui: boolean;
|
|
debug: boolean;
|
|
maxParallel: number;
|
|
maxAutomaticRetries: number;
|
|
connectionConfig?: string;
|
|
githubReviewConfig?: string;
|
|
provisionGitHubRepository?: boolean;
|
|
antigravityJourneyConfig?: string;
|
|
antigravityJourneyPreflight?: boolean;
|
|
antigravityJourneyRecover?: string;
|
|
}
|
|
|
|
export class RunnerSelectorError extends Error {}
|
|
|
|
/**
|
|
* CI passes the selected cell here rather than as `--id`. Every agent can read
|
|
* another process's argv in /proc, and the cell id names the case under test.
|
|
*/
|
|
export const RUNNER_SELECTED_ID_ENV = "PAPERCLIP_RUNNER_E2E_SELECTED_ID";
|
|
|
|
/** Adds the environment's selected cell to the arguments and removes it from the environment. */
|
|
export function selectorArgumentsWithEnvironment(args: readonly string[], env: NodeJS.ProcessEnv = process.env): string[] {
|
|
const selected = env[RUNNER_SELECTED_ID_ENV]?.trim();
|
|
delete env[RUNNER_SELECTED_ID_ENV];
|
|
return selected ? [...args, "--id", selected] : [...args];
|
|
}
|
|
|
|
function valueFor(args: string[], index: number, flag: string): string {
|
|
const value = args[index + 1];
|
|
if (!value || value.startsWith("--"))
|
|
throw new RunnerSelectorError(`${flag} requires a value`);
|
|
return value;
|
|
}
|
|
|
|
export function parseRunnerSelectors(
|
|
rawArgs: readonly string[],
|
|
): RunnerSelectorOptions {
|
|
const args = rawArgs.filter((value) => value !== "--");
|
|
const options: RunnerSelectorOptions = {
|
|
all: false,
|
|
list: false,
|
|
matrixJson: false,
|
|
ids: [],
|
|
suites: [],
|
|
groups: [],
|
|
profiles: [],
|
|
environments: [],
|
|
cases: [],
|
|
headed: false,
|
|
ui: false,
|
|
debug: false,
|
|
maxParallel: Number(process.env.PAPERCLIP_E2E_MAX_PARALLEL ?? "1"),
|
|
maxAutomaticRetries: 1,
|
|
};
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const flag = args[index];
|
|
if (flag === "--all") options.all = true;
|
|
else if (flag === "--list") options.list = true;
|
|
else if (flag === "--matrix-json") options.matrixJson = true;
|
|
else if (flag === "--installed-startup-only") options.installedStartupOnly = true;
|
|
else if (flag === "--headed") options.headed = true;
|
|
else if (flag === "--ui") options.ui = true;
|
|
else if (flag === "--debug") options.debug = true;
|
|
else if (flag === "--connection-config") {
|
|
options.connectionConfig = valueFor(args, index, flag);
|
|
index += 1;
|
|
}
|
|
else if (flag === "--github-review-config") {
|
|
options.githubReviewConfig = valueFor(args, index, flag);
|
|
index += 1;
|
|
}
|
|
else if (flag === "--provision-github-repository") options.provisionGitHubRepository = true;
|
|
else if (flag === "--antigravity-journey-config") {
|
|
options.antigravityJourneyConfig = valueFor(args, index, flag);
|
|
index += 1;
|
|
}
|
|
else if (flag === "--antigravity-journey-preflight") options.antigravityJourneyPreflight = true;
|
|
else if (flag === "--antigravity-journey-recover") {
|
|
options.antigravityJourneyRecover = valueFor(args, index, flag);
|
|
index += 1;
|
|
}
|
|
else if (flag === "--max-parallel") {
|
|
const value = valueFor(args, index, flag);
|
|
index += 1;
|
|
options.maxParallel = Number(value);
|
|
} else if (flag === "--max-automatic-retries") {
|
|
const value = valueFor(args, index, flag);
|
|
index += 1;
|
|
options.maxAutomaticRetries = Number(value);
|
|
} else if (
|
|
[
|
|
"--id",
|
|
"--suite",
|
|
"--group",
|
|
"--profile",
|
|
"--environment",
|
|
"--case",
|
|
].includes(flag)
|
|
) {
|
|
const value = valueFor(args, index, flag);
|
|
index += 1;
|
|
if (flag === "--id") options.ids.push(value);
|
|
else if (flag === "--suite") options.suites.push(value);
|
|
else if (flag === "--group") options.groups.push(value);
|
|
else if (flag === "--profile") options.profiles.push(value);
|
|
else if (flag === "--environment") options.environments.push(value);
|
|
else options.cases.push(value);
|
|
} else {
|
|
throw new RunnerSelectorError(`Unknown runner E2E argument: ${flag}`);
|
|
}
|
|
}
|
|
|
|
if (!Number.isInteger(options.maxParallel) || options.maxParallel < 1) {
|
|
throw new RunnerSelectorError("--max-parallel must be a positive integer");
|
|
}
|
|
if (![0, 1].includes(options.maxAutomaticRetries)) {
|
|
throw new RunnerSelectorError(
|
|
"--max-automatic-retries must be 0 or 1",
|
|
);
|
|
}
|
|
|
|
const hasDimensions =
|
|
options.suites.length +
|
|
options.groups.length +
|
|
options.profiles.length +
|
|
options.environments.length +
|
|
options.cases.length >
|
|
0;
|
|
if (options.ids.length > 0 && (hasDimensions || options.all)) {
|
|
throw new RunnerSelectorError(
|
|
"--id is exclusive with --all and dimension filters",
|
|
);
|
|
}
|
|
if (options.all && hasDimensions) {
|
|
throw new RunnerSelectorError("--all is exclusive with dimension filters");
|
|
}
|
|
if (
|
|
!options.list &&
|
|
!options.matrixJson &&
|
|
!options.all &&
|
|
options.ids.length === 0 &&
|
|
!hasDimensions
|
|
) {
|
|
throw new RunnerSelectorError(
|
|
"Billable runner E2E runs require --all or an explicit selector",
|
|
);
|
|
}
|
|
return options;
|
|
}
|
|
|
|
function assertKnown(
|
|
label: string,
|
|
selected: readonly string[],
|
|
known: Set<string>,
|
|
) {
|
|
const unknown = selected.filter((value) => !known.has(value));
|
|
if (unknown.length > 0)
|
|
throw new RunnerSelectorError(`Unknown ${label}: ${unknown.join(", ")}`);
|
|
}
|
|
|
|
export function selectRunnerExecutions(
|
|
options: RunnerSelectorOptions,
|
|
matrix: readonly MatrixExecution[] = runnerMatrix,
|
|
): MatrixExecution[] {
|
|
const knownGroups = new Set(matrix.flatMap((execution) => execution.groups));
|
|
assertKnown(
|
|
"suite",
|
|
options.suites,
|
|
new Set(matrix.map((execution) => execution.suite.id)),
|
|
);
|
|
assertKnown("group", options.groups, knownGroups);
|
|
assertKnown(
|
|
"profile",
|
|
options.profiles,
|
|
new Set(matrix.map((execution) => execution.profile.id)),
|
|
);
|
|
assertKnown(
|
|
"environment",
|
|
options.environments,
|
|
new Set(matrix.map((execution) => execution.environment.id)),
|
|
);
|
|
assertKnown(
|
|
"case",
|
|
options.cases,
|
|
new Set(matrix.map((execution) => execution.task.id)),
|
|
);
|
|
assertKnown(
|
|
"execution id",
|
|
options.ids,
|
|
new Set(matrix.map((execution) => execution.id)),
|
|
);
|
|
|
|
const selected = matrix.filter((execution) => {
|
|
if (options.ids.length > 0) return options.ids.includes(execution.id);
|
|
if (execution.suite.manualOnly && !options.suites.includes(execution.suite.id) && !options.list) return false;
|
|
const unfilteredList = options.list && options.groups.length + options.suites.length + options.profiles.length
|
|
+ options.environments.length + options.cases.length === 0;
|
|
const explicitGroup = execution.task.explicitGroup;
|
|
// Naming any of the task's own groups (for example `slow`) also opts in.
|
|
if (explicitGroup && !unfilteredList && !options.groups.some(group => group === explicitGroup || execution.task.groups.includes(group))
|
|
&& !options.cases.includes(execution.task.id)) return false;
|
|
if (
|
|
options.all ||
|
|
(options.list &&
|
|
options.groups.length === 0 &&
|
|
options.suites.length === 0 &&
|
|
options.profiles.length === 0 &&
|
|
options.environments.length === 0 &&
|
|
options.cases.length === 0)
|
|
)
|
|
return true;
|
|
return (
|
|
(options.suites.length === 0 ||
|
|
options.suites.includes(execution.suite.id)) &&
|
|
options.groups.every((group) => execution.groups.includes(group)) &&
|
|
(options.profiles.length === 0 ||
|
|
options.profiles.includes(execution.profile.id)) &&
|
|
(options.environments.length === 0 ||
|
|
options.environments.includes(execution.environment.id)) &&
|
|
(options.cases.length === 0 || options.cases.includes(execution.task.id))
|
|
);
|
|
});
|
|
if (selected.length === 0)
|
|
throw new RunnerSelectorError(
|
|
"Runner E2E selectors matched zero executions",
|
|
);
|
|
return selected;
|
|
}
|
|
|
|
export function buildMatrixJobs(
|
|
executions: readonly MatrixExecution[],
|
|
): MatrixJob[] {
|
|
if (executions.some(e => ["provider_connection", "github_review_journey", "antigravity_agent_journey"].includes(e.task.flow))) throw new RunnerSelectorError("Attended connection journeys require explicit private setup profiles; they are not admitted to paid CI matrix jobs yet.");
|
|
return executions
|
|
.map((execution) => ({
|
|
executionId: execution.id,
|
|
suiteId: execution.suite.id,
|
|
profileId: execution.profile.id,
|
|
credentialName: execution.profile.credential,
|
|
environmentId: execution.environment.id,
|
|
caseId: execution.task.id,
|
|
timeoutMinutes: Math.max(
|
|
execution.environment.id === "daytona" ? 40 : 25,
|
|
Math.ceil(
|
|
(2 *
|
|
(execution.task.attemptTimeoutMs[execution.environment.id] +
|
|
90_000) +
|
|
5 * 60_000) /
|
|
60_000,
|
|
),
|
|
),
|
|
needsDaytona: execution.environment.id === "daytona",
|
|
}))
|
|
.sort((left, right) => left.executionId.localeCompare(right.executionId));
|
|
}
|