Merge remote-tracking branch 'origin/master' into codex/provider-routing-connections

* origin/master:
  fix(tasks): surface Codex ChatGPT model rejection (#15299)
  fix(runner): continue restart-interrupted Codex turns (#15297)
  fix(agents): grant configuration access by default (#15283)
  Guard routine UUID lookups without narrowing valid inputs (#15313)
  fix(ssh): transport project repositories as their own git checkouts (#14782)
  feat(exe-dev): copy a source VM with exe.dev cp (#14975)
  Diagnose native model rejections and preserve repairable reviews (#15304)
  Verify native semantic input against its raw wire digest (#15301)
  Retry pooled execution projection reads after disconnects (#15300)
  Fix browser polling for unsaved agent chats (#15298)
  test(ui): keep initial reasoning with the saved reply (#15107)
  docs: add a product feature map (#15288)
This commit is contained in:
Dotta committed 2026-10-06 08:42:29 -05:00
commit 7cbd61349a
126 files changed
+5961 -150

No files matched your search

@@ -1,5 +1,5 @@
import path from "node:path";
import { GIT_ARCHIVE_EXCLUDES } from "./git-workspace-sync.js";
import { GIT_ARCHIVE_EXCLUDES, PROJECT_REPOSITORIES_DIR } from "./git-workspace-sync.js";
import {
type SshRemoteExecutionSpec,
prepareWorkspaceForSshExecution,
@@ -13,7 +13,7 @@ import {
type SandboxAdditionalSource,
type SandboxManagedRuntimeAssetRestoreContext,
} from "./sandbox-managed-runtime.js";
import { captureDirectorySnapshot } from "./workspace-restore-merge.js";
import { captureDirectorySnapshot, type DirectorySnapshot } from "./workspace-restore-merge.js";
import type { RuntimeProgressSink } from "./runtime-progress.js";
// The fixed heavy-directory excludes every referenced project drops,
@@ -145,13 +145,27 @@ export async function prepareRemoteManagedRuntime(input: {
workspaceExclude: input.workspaceExclude,
})
: null;
const projectRepositories = preparedWorkspace?.repositories ?? [];
const baselineSnapshot = preparedWorkspace
? await captureDirectorySnapshot(input.workspaceLocalDir, {
exclude: preparedWorkspace.gitBacked
? [...GIT_ARCHIVE_EXCLUDES, ".paperclip-runtime"]
? [
...GIT_ARCHIVE_EXCLUDES,
".paperclip-runtime",
...(projectRepositories.length > 0 ? [PROJECT_REPOSITORIES_DIR] : []),
]
: [".paperclip-runtime", ...(input.workspaceFileMode === "all" ? input.workspaceExclude ?? [] : [])],
})
: null;
const repositoryBaselines: Array<{ path: string; baselineSnapshot: DirectorySnapshot }> = [];
for (const repository of projectRepositories) {
repositoryBaselines.push({
path: repository,
baselineSnapshot: await captureDirectorySnapshot(path.join(input.workspaceLocalDir, repository), {
exclude: [...GIT_ARCHIVE_EXCLUDES, ".paperclip-runtime"],
}),
});
}
const assetDirs: Record<string, string> = {};
try {
@@ -177,6 +191,7 @@ export async function prepareRemoteManagedRuntime(input: {
baselineSnapshot,
restoreGitHistory: preparedWorkspace.gitBacked,
onProgress: input.onProgress,
repositories: repositoryBaselines,
});
}
throw error;
@@ -244,6 +259,7 @@ export async function prepareRemoteManagedRuntime(input: {
baselineSnapshot,
restoreGitHistory: preparedWorkspace.gitBacked,
onProgress,
repositories: repositoryBaselines,
});
}
for (const asset of input.assets ?? []) {
@@ -21,6 +21,16 @@ import {
import { prepareRemoteManagedRuntime } from "./remote-managed-runtime.js";
const SSH_FIXTURE_TEST_TIMEOUT_MS = 30_000;
const UNREACHABLE_SSH_SPEC = {
host: "127.0.0.1",
port: 1,
username: "nobody",
remoteWorkspacePath: "/nonexistent",
remoteCwd: "/nonexistent",
privateKey: null,
knownHosts: null,
strictHostKeyChecking: false,
} as const;
let sshEnvLabUnsupportedReason: string | null = null;
// One entry per fixture root directory, registered at creation time so
@@ -850,6 +860,205 @@ describe("ssh env-lab fixture", () => {
await expect(readFile(path.join(localRepo, "run-b.txt"), "utf8")).resolves.toBe("from run b\n");
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
it("round-trips project repositories nested in .paperclip-repositories across consecutive SSH runs", async () => {
const rootDir = await createFixtureRootDir();
const statePath = path.join(rootDir, "state.json");
const localRepo = path.join(rootDir, "local-workspace");
const nestedRelative = ".paperclip-repositories/frontend-0123456789ab";
const nestedRepo = path.join(localRepo, nestedRelative);
await mkdir(localRepo, { recursive: true });
await git(localRepo, ["init"]);
await git(localRepo, ["checkout", "-b", "main"]);
await git(localRepo, ["config", "user.name", "Paperclip Test"]);
await git(localRepo, ["config", "user.email", "test@paperclip.dev"]);
await writeFile(path.join(localRepo, "backend.txt"), "backend base\n", "utf8");
await git(localRepo, ["add", "backend.txt"]);
await git(localRepo, ["commit", "-m", "backend initial"]);
await writeFile(path.join(localRepo, ".git", "info", "exclude"), "\n/.paperclip-repositories/\n", { flag: "a" });
await mkdir(nestedRepo, { recursive: true });
await git(nestedRepo, ["init"]);
await git(nestedRepo, ["checkout", "-b", "main"]);
await git(nestedRepo, ["config", "user.name", "Paperclip Test"]);
await git(nestedRepo, ["config", "user.email", "test@paperclip.dev"]);
await writeFile(path.join(nestedRepo, "frontend.txt"), "frontend base\n", "utf8");
await writeFile(path.join(nestedRepo, "obsolete.txt"), "tracked\n", "utf8");
await git(nestedRepo, ["add", "frontend.txt", "obsolete.txt"]);
await git(nestedRepo, ["commit", "-m", "frontend initial"]);
await writeFile(path.join(nestedRepo, "frontend.txt"), "frontend dirty local\n", "utf8");
await rm(path.join(nestedRepo, "obsolete.txt"));
const started = await startSshEnvLabFixtureOrSkip(statePath, "SSH nested project repositories test");
if (!started) return;
const config = await buildSshEnvLabFixtureConfig(started);
const spec = {
...config,
remoteCwd: started.workspaceDir,
} as const;
const first = await prepareRemoteManagedRuntime({
spec,
runId: "run-1",
adapterKey: "test-adapter",
workspaceLocalDir: localRepo,
});
const remoteNested = path.posix.join(first.workspaceRemoteDir, nestedRelative);
const remoteNestedStatus = await runSshCommand(
config,
`cd ${JSON.stringify(remoteNested)} && git log -1 --pretty=%s && git status --short`,
);
expect(remoteNestedStatus.stdout).toContain("frontend initial");
expect(remoteNestedStatus.stdout).toContain("M frontend.txt");
expect(remoteNestedStatus.stdout).toContain("D obsolete.txt");
const remoteAnchorStatus = await runSshCommand(
config,
`cd ${JSON.stringify(first.workspaceRemoteDir)} && git status --short --untracked-files=all`,
);
expect(remoteAnchorStatus.stdout).not.toContain(".paperclip-repositories");
await runSshCommand(
config,
[
`cd ${JSON.stringify(remoteNested)}`,
`git config user.name "Paperclip SSH"`,
`git config user.email "ssh@paperclip.dev"`,
`git add frontend.txt`,
`git commit -m "remote frontend update" >/dev/null`,
`printf "frontend remote dirty\\n" > frontend.txt`,
`cd ${JSON.stringify(first.workspaceRemoteDir)}`,
`git config user.name "Paperclip SSH"`,
`git config user.email "ssh@paperclip.dev"`,
`printf "backend remote\\n" > backend.txt`,
`git add backend.txt`,
`git commit -m "remote backend update" >/dev/null`,
].join(" && "),
{ timeoutMs: 30_000, maxBuffer: 256 * 1024 },
);
await first.restoreWorkspace();
await expect(stat(path.join(nestedRepo, ".git"))).resolves.toBeDefined();
expect(await git(nestedRepo, ["log", "-1", "--pretty=%s"])).toBe("remote frontend update");
await expect(readFile(path.join(nestedRepo, "frontend.txt"), "utf8")).resolves.toBe("frontend remote dirty\n");
expect(await git(localRepo, ["log", "-1", "--pretty=%s"])).toBe("remote backend update");
expect(await git(localRepo, ["status", "--short", "--untracked-files=all"])).not.toContain(".paperclip-repositories");
const second = await prepareRemoteManagedRuntime({
spec,
runId: "run-2",
adapterKey: "test-adapter",
workspaceLocalDir: localRepo,
});
const secondNestedLog = await runSshCommand(
config,
`cd ${JSON.stringify(path.posix.join(second.workspaceRemoteDir, nestedRelative))} && git log -1 --pretty=%s`,
);
expect(secondNestedLog.stdout.trim()).toBe("remote frontend update");
await second.restoreWorkspace();
await expect(stat(path.join(nestedRepo, ".git"))).resolves.toBeDefined();
await expect(readFile(path.join(nestedRepo, "frontend.txt"), "utf8")).resolves.toBe("frontend remote dirty\n");
}, SSH_FIXTURE_TEST_TIMEOUT_MS * 2);
it("restores project repositories through the direct SSH restore path", async () => {
const rootDir = await createFixtureRootDir();
const statePath = path.join(rootDir, "state.json");
const localRepo = path.join(rootDir, "local-workspace");
const nestedRelative = ".paperclip-repositories/frontend-0123456789ab";
const nestedRepo = path.join(localRepo, nestedRelative);
for (const repo of [localRepo, nestedRepo]) {
await mkdir(repo, { recursive: true });
await git(repo, ["init"]);
await git(repo, ["checkout", "-b", "main"]);
await git(repo, ["config", "user.name", "Paperclip Test"]);
await git(repo, ["config", "user.email", "test@paperclip.dev"]);
await writeFile(path.join(repo, "tracked.txt"), "base\n", "utf8");
await git(repo, ["add", "tracked.txt"]);
await git(repo, ["commit", "-m", "initial"]);
}
await writeFile(path.join(localRepo, ".git", "info", "exclude"), "\n/.paperclip-repositories/\n", { flag: "a" });
const started = await startSshEnvLabFixtureOrSkip(statePath, "SSH direct restore with project repositories test");
if (!started) return;
const config = await buildSshEnvLabFixtureConfig(started);
const spec = {
...config,
remoteCwd: started.workspaceDir,
} as const;
const prepared = await prepareWorkspaceForSshExecution({
spec,
localDir: localRepo,
remoteDir: started.workspaceDir,
});
expect(prepared).toEqual({ gitBacked: true, repositories: [nestedRelative] });
await runSshCommand(
config,
[
`cd ${JSON.stringify(path.posix.join(started.workspaceDir, nestedRelative))}`,
`git config user.name "Paperclip SSH"`,
`git config user.email "ssh@paperclip.dev"`,
`printf "remote\\n" > tracked.txt`,
`git commit -am "remote nested update" >/dev/null`,
].join(" && "),
{ timeoutMs: 30_000, maxBuffer: 256 * 1024 },
);
await restoreWorkspaceFromSshExecution({
spec,
localDir: localRepo,
remoteDir: started.workspaceDir,
repositories: (prepared.repositories ?? []).map((repository) => ({ path: repository })),
});
await expect(stat(path.join(nestedRepo, ".git"))).resolves.toBeDefined();
expect(await git(nestedRepo, ["log", "-1", "--pretty=%s"])).toBe("remote nested update");
await expect(readFile(path.join(nestedRepo, "tracked.txt"), "utf8")).resolves.toBe("remote\n");
expect(await git(localRepo, ["status", "--short", "--untracked-files=all"])).toBe("");
}, SSH_FIXTURE_TEST_TIMEOUT_MS * 2);
it("fails closed when .paperclip-repositories holds something other than a git checkout", async () => {
const rootDir = await createFixtureRootDir();
const localRepo = path.join(rootDir, "local-workspace");
await mkdir(path.join(localRepo, ".paperclip-repositories", "not-a-repo"), { recursive: true });
await git(localRepo, ["init"]);
await git(localRepo, ["checkout", "-b", "main"]);
await git(localRepo, ["config", "user.name", "Paperclip Test"]);
await git(localRepo, ["config", "user.email", "test@paperclip.dev"]);
await writeFile(path.join(localRepo, "tracked.txt"), "base\n", "utf8");
await git(localRepo, ["add", "tracked.txt"]);
await git(localRepo, ["commit", "-m", "initial"]);
await expect(prepareWorkspaceForSshExecution({ spec: UNREACHABLE_SSH_SPEC, localDir: localRepo, remoteDir: "/nonexistent" }))
.rejects.toThrow("Project repository is not a Git checkout: .paperclip-repositories/not-a-repo");
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
it("rejects inconsistent project repository restores before any SSH connection", async () => {
const localDir = await createFixtureRootDir();
const repositoryBaseline = { exclude: [".git", ".git/*", ".paperclip-runtime"], entries: new Map() };
const restore = (input: Partial<Parameters<typeof restoreWorkspaceFromSshExecution>[0]>) =>
restoreWorkspaceFromSshExecution({
spec: UNREACHABLE_SSH_SPEC,
localDir,
remoteDir: "/nonexistent",
baselineSnapshot: { exclude: [".git", ".git/*", ".paperclip-runtime", ".paperclip-repositories"], entries: new Map() },
restoreGitHistory: true,
...input,
});
await expect(restore({ repositories: [{ path: "../outside", baselineSnapshot: repositoryBaseline }] }))
.rejects.toThrow("Invalid project repository path: ../outside");
await expect(restore({ repositories: [{ path: ".paperclip-repositories/frontend-0123456789ab" }] }))
.rejects.toThrow("Project repository has no workspace baseline: .paperclip-repositories/frontend-0123456789ab");
await expect(restore({
baselineSnapshot: { exclude: [".git", ".git/*", ".paperclip-runtime"], entries: new Map() },
repositories: [{ path: ".paperclip-repositories/frontend-0123456789ab", baselineSnapshot: repositoryBaseline }],
})).rejects.toThrow("Workspace baseline must exclude .paperclip-repositories");
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
it("preserves nested per-run files across sequential SSH restores with stale baselines", async () => {
const rootDir = await createFixtureRootDir();
const statePath = path.join(rootDir, "state.json");
+167 -20
View File
@@ -9,6 +9,7 @@ import type { CommandManagedRuntimeRunner } from "./command-managed-runtime.js";
import {
createUnrelatedHistoryGraftCommit,
GIT_SYNC_COMMIT_IDENTITY_ARGS,
PROJECT_REPOSITORIES_DIR,
readSanitizedOriginRemoteUrl,
} from "./git-workspace-sync.js";
import type { RunProcessResult } from "./server-utils.js";
@@ -1071,6 +1072,91 @@ async function removeDeletedPathsOnSsh(input: {
});
}
const PROJECT_REPOSITORY_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
async function isLocalGitRepositoryRoot(localDir: string): Promise<boolean> {
try {
const toplevel = await runLocalGit(localDir, ["rev-parse", "--show-toplevel"], {
timeout: 10_000,
maxBuffer: 16 * 1024,
});
const [directory, repository] = await Promise.all([
fs.realpath(localDir),
fs.realpath(toplevel.stdout.trim()),
]);
return directory === repository;
} catch {
return false;
}
}
async function listLocalProjectRepositories(localDir: string): Promise<string[]> {
const root = path.join(localDir, PROJECT_REPOSITORIES_DIR);
const rootStat = await fs.lstat(root).catch((error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT") return null;
throw error;
});
if (!rootStat) return [];
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
throw new Error("Invalid project repositories directory");
}
const repositories: string[] = [];
for (const entry of await fs.readdir(root, { withFileTypes: true })) {
if (!entry.isDirectory() || !PROJECT_REPOSITORY_NAME_PATTERN.test(entry.name)) {
throw new Error("Invalid project repository directory");
}
const relative = `${PROJECT_REPOSITORIES_DIR}/${entry.name}`;
if (!(await isLocalGitRepositoryRoot(path.join(localDir, relative)))) {
throw new Error(`Project repository is not a Git checkout: ${relative}`);
}
repositories.push(relative);
}
return repositories.sort();
}
async function transportGitWorkspaceToSsh(input: {
spec: SshRemoteExecutionSpec;
localDir: string;
remoteDir: string;
snapshot: LocalGitWorkspaceSnapshot;
exclude?: string[];
onProgress?: RuntimeProgressSink;
progressLabel: string;
}): Promise<void> {
await importGitWorkspaceToSsh({
spec: input.spec,
localDir: input.localDir,
remoteDir: input.remoteDir,
snapshot: input.snapshot,
onProgress: input.onProgress,
});
await syncDirectoryToSsh({
spec: input.spec,
localDir: input.localDir,
remoteDir: input.remoteDir,
exclude: [".git", ".paperclip-runtime", ...(input.exclude ?? [])],
onProgress: input.onProgress,
progressLabel: input.progressLabel,
});
await removeDeletedPathsOnSsh({
spec: input.spec,
remoteDir: input.remoteDir,
deletedPaths: input.snapshot.deletedPaths,
});
}
async function excludeProjectRepositoriesOnSsh(spec: SshConnectionConfig, remoteDir: string): Promise<void> {
const pattern = `/${PROJECT_REPOSITORIES_DIR}/`;
const excludeFile = path.posix.join(remoteDir, ".git", "info", "exclude");
await runSshScript(
spec,
`mkdir -p ${shellQuote(path.posix.dirname(excludeFile))} && ` +
`{ grep -qxF ${shellQuote(pattern)} ${shellQuote(excludeFile)} 2>/dev/null || ` +
`printf '\\n%s\\n' ${shellQuote(pattern)} >> ${shellQuote(excludeFile)}; }`,
{ timeoutMs: 30_000 },
);
}
async function allocateLoopbackPort(host: string): Promise<number> {
return await new Promise<number>((resolve, reject) => {
const server = net.createServer();
@@ -1560,32 +1646,38 @@ export async function prepareWorkspaceForSshExecution(input: {
onProgress?: RuntimeProgressSink;
workspaceFileMode?: "all";
workspaceExclude?: string[];
}): Promise<{ gitBacked: boolean }> {
}): Promise<{ gitBacked: boolean; repositories?: string[] }> {
const remoteDir = input.remoteDir ?? input.spec.remoteCwd;
const gitSnapshot = input.workspaceFileMode === "all" ? null : await readLocalGitWorkspaceSnapshot(input.localDir);
if (gitSnapshot) {
await importGitWorkspaceToSsh({
const repositories = await listLocalProjectRepositories(input.localDir);
await transportGitWorkspaceToSsh({
spec: input.spec,
localDir: input.localDir,
remoteDir,
snapshot: gitSnapshot,
onProgress: input.onProgress,
});
await syncDirectoryToSsh({
spec: input.spec,
localDir: input.localDir,
remoteDir,
exclude: [".git", ".paperclip-runtime"],
exclude: repositories.length > 0 ? [PROJECT_REPOSITORIES_DIR] : [],
onProgress: input.onProgress,
progressLabel: "workspace",
});
await removeDeletedPathsOnSsh({
spec: input.spec,
remoteDir,
deletedPaths: gitSnapshot.deletedPaths,
});
return { gitBacked: true };
if (repositories.length > 0) {
await excludeProjectRepositoriesOnSsh(input.spec, remoteDir);
}
for (const relative of repositories) {
const localDir = path.join(input.localDir, relative);
const snapshot = await readLocalGitWorkspaceSnapshot(localDir);
if (!snapshot) throw new Error(`Cannot read the Git state of project repository: ${relative}`);
await transportGitWorkspaceToSsh({
spec: input.spec,
localDir,
remoteDir: path.posix.join(remoteDir, relative),
snapshot,
onProgress: input.onProgress,
progressLabel: relative,
});
}
return { gitBacked: true, ...(repositories.length > 0 ? { repositories } : {}) };
}
await clearRemoteDirectory({
@@ -1611,8 +1703,62 @@ export async function restoreWorkspaceFromSshExecution(input: {
baselineSnapshot?: DirectorySnapshot;
restoreGitHistory?: boolean;
onProgress?: RuntimeProgressSink;
repositories?: Array<{ path: string; baselineSnapshot?: DirectorySnapshot }>;
}): Promise<void> {
const remoteDir = input.remoteDir ?? input.spec.remoteCwd;
const repositories = input.repositories ?? [];
for (const repository of repositories) {
if (
path.posix.dirname(repository.path) !== PROJECT_REPOSITORIES_DIR ||
!PROJECT_REPOSITORY_NAME_PATTERN.test(path.posix.basename(repository.path))
) {
throw new Error(`Invalid project repository path: ${repository.path}`);
}
if (input.baselineSnapshot && !repository.baselineSnapshot) {
throw new Error(`Project repository has no workspace baseline: ${repository.path}`);
}
}
if (
input.baselineSnapshot &&
repositories.length > 0 &&
!input.baselineSnapshot.exclude.includes(PROJECT_REPOSITORIES_DIR)
) {
throw new Error(`Workspace baseline must exclude ${PROJECT_REPOSITORIES_DIR} when project repositories are restored separately`);
}
for (const repository of repositories) {
await restoreWorkspaceRootFromSsh({
spec: input.spec,
localDir: path.join(input.localDir, repository.path),
remoteDir: path.posix.join(remoteDir, repository.path),
baselineSnapshot: repository.baselineSnapshot,
restoreGitHistory: input.restoreGitHistory,
onProgress: input.onProgress,
progressLabel: repository.path,
});
}
await restoreWorkspaceRootFromSsh({
spec: input.spec,
localDir: input.localDir,
remoteDir,
baselineSnapshot: input.baselineSnapshot,
restoreGitHistory: input.restoreGitHistory,
onProgress: input.onProgress,
progressLabel: "workspace",
hasProjectRepositories: repositories.length > 0,
});
}
async function restoreWorkspaceRootFromSsh(input: {
spec: SshRemoteExecutionSpec;
localDir: string;
remoteDir: string;
baselineSnapshot?: DirectorySnapshot;
restoreGitHistory?: boolean;
onProgress?: RuntimeProgressSink;
progressLabel: string;
hasProjectRepositories?: boolean;
}): Promise<void> {
const remoteDir = input.remoteDir;
if (input.baselineSnapshot) {
const stagingDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-sync-back-"));
const importedRef = input.restoreGitHistory
@@ -1635,7 +1781,7 @@ export async function restoreWorkspaceFromSshExecution(input: {
localDir: stagingDir,
exclude: input.baselineSnapshot.exclude,
onProgress: input.onProgress,
progressLabel: "workspace",
progressLabel: input.progressLabel,
});
await mergeDirectoryWithBaseline({
baseline: input.baselineSnapshot,
@@ -1666,6 +1812,7 @@ export async function restoreWorkspaceFromSshExecution(input: {
const gitSnapshot = await readLocalGitWorkspaceSnapshot(input.localDir);
if (gitSnapshot) {
const projectRepositoryEntries = input.hasProjectRepositories ? [PROJECT_REPOSITORIES_DIR] : [];
await exportGitWorkspaceFromSsh({
spec: input.spec,
remoteDir,
@@ -1676,10 +1823,10 @@ export async function restoreWorkspaceFromSshExecution(input: {
spec: input.spec,
remoteDir,
localDir: input.localDir,
exclude: [".git", ".paperclip-runtime"],
preserveLocalEntries: [".git"],
exclude: [".git", ".paperclip-runtime", ...projectRepositoryEntries],
preserveLocalEntries: [".git", ...projectRepositoryEntries],
onProgress: input.onProgress,
progressLabel: "workspace",
progressLabel: input.progressLabel,
});
return;
}
@@ -1690,7 +1837,7 @@ export async function restoreWorkspaceFromSshExecution(input: {
localDir: input.localDir,
exclude: [".paperclip-runtime"],
onProgress: input.onProgress,
progressLabel: "workspace",
progressLabel: input.progressLabel,
});
}
@@ -95,6 +95,15 @@ welcome and every control envelope against the authenticated connection,
runner, environment lease, run, normalized session, turn, item, protocol,
lease ID, expiry, and revocation metadata before applying an ACK or command.
Semantic input integrity uses SHA-256 of the complete transmitted input's
canonical JSON, with UTF-16 object-key order and JavaScript number formatting.
The controller checks this proof after authentication and exact run/session/
turn/item correlation, before committing, dispatching, or acknowledging the
input. Receipt redaction has a separate digest; a redacted digest cannot stand
in for wire integrity, including when only a protected field changes. A failed
integrity proof keeps the existing operator-required recovery fence. Updating
the verifier does not clear a previously failed run or replay its work.
The daemon captures and removes the bootstrap environment variable before it
parses arguments or starts child work. Secret buffers are overwritten when they
are dropped. It resolves the destination once before sending a bearer value and
@@ -652,6 +652,9 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
.iter()
.any(|value| value == "--require-codex-home-auth");
let durable_turn_ids = args.iter().any(|value| value == "--durable-turn-ids");
let hold_first_durable_turn = args
.iter()
.any(|value| value == "--hold-first-durable-turn");
let durable_tool_ids = args.iter().any(|value| value == "--durable-tool-ids");
let expected_canonical_task_context_file =
argument(&args, "--expected-canonical-task-context-file");
@@ -1348,6 +1351,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
.checked_add(1)
.ok_or("fake provider turn sequence exhausted")?;
}
let hold_turn = hold_turn || (hold_first_durable_turn && state.next_turn == 1);
if reject_second_turn_start && turn_start_count == 2 {
send(json!({
"method": "warning",
@@ -2103,17 +2103,29 @@ impl CodexCommandExecutor {
"providerTurnId": previous_active_turn_id,
"status": "failed",
"providerTerminalObserved": false,
"error": if provider_label == "codex" { json!({
"code": "provider_turn_lost_on_restore",
"recoverable": true,
"message": "The runner restored the conversation after process loss, but the previous turn is no longer active.",
}) } else { Value::Null },
}),
};
let outcome = terminal_events(
state,
"turn.failed",
state.goal.as_ref().map(|goal| goal.status.as_str()),
);
state.extend_terminal_events(with_terminal_outcome(
vec![provider_terminal],
outcome,
))?;
if provider_label == "codex" {
// No provider result was observed. Preserve the lost
// turn as a fact, but leave the run open for the
// controller's bounded same-conversation recovery.
state.push_terminal_event(provider_terminal)?;
} else {
let outcome = terminal_events(
state,
"turn.failed",
state.goal.as_ref().map(|goal| goal.status.as_str()),
);
state.extend_terminal_events(with_terminal_outcome(
vec![provider_terminal],
outcome,
))?;
}
}
} else {
state.push_event(reconciled)?;
@@ -3484,6 +3484,65 @@ fn durable_backend_replays_pending_tool_calls_without_mutating_the_event_queue()
fs::remove_dir_all(directory).expect("remove Codex integration-test directory");
}
#[test]
fn restart_lost_turn_retains_a_recoverable_cause_without_inventing_a_result() {
let directory = temporary_directory("restart-lost-turn");
let config = provider_config(
&directory,
&["--durable-turn-ids", "--linger-after-turn-start"],
);
let mut first = CodexCommandExecutor::new(&directory);
first
.execute(&command(
"prepare",
1,
"run.prepare",
json!({"provider": config}),
))
.unwrap();
first
.execute(&command("open", 2, "session.open", json!({})))
.unwrap();
first
.execute(&command(
"start",
3,
"turn.start",
json!({"text": "Watch the existing check run."}),
))
.unwrap();
// The process disappeared while its command was in flight. Codex restores
// the conversation, but cannot restore the running turn.
drop(first);
fs::write(
directory.join("fake-state.json"),
serde_json::to_vec(&json!({
"threadId": "codex-thread-1", "activeTurnId": null, "nextTurn": 1,
}))
.unwrap(),
)
.unwrap();
let mut restored = CodexCommandExecutor::new(&directory);
restored
.execute(&command("snapshot", 4, "session.snapshot", json!({})))
.unwrap();
let events = poll_and_ack(&mut restored).unwrap();
let interrupted = events
.iter()
.find(|event| event.event_type == "turn.failed")
.unwrap();
assert_eq!(
interrupted.payload["error"]["code"],
"provider_turn_lost_on_restore"
);
assert_eq!(interrupted.payload["error"]["recoverable"], true);
assert!(!events
.iter()
.any(|event| event.event_type == "run.result.proposed"));
restored.shutdown().unwrap();
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn durable_backend_settles_pending_tools_when_recovery_finds_the_turn_ended() {
let directory = temporary_directory("durable-tool-ended-offline");
@@ -0,0 +1,18 @@
use paperclip_runner_core::provider_bridge::semantic_value_digest;
use serde_json::Value;
#[test]
fn semantic_input_digests_match_the_authenticated_controller_fixtures() {
let fixtures: Value = serde_json::from_str(include_str!(
"../../../../test/fixtures/semantic-input-digests.json"
))
.expect("parse shared semantic input digest fixtures");
for fixture in fixtures.as_array().expect("digest fixture array") {
assert_eq!(
semantic_value_digest(&fixture["input"]),
fixture["digest"].as_str().expect("fixture digest"),
"fixture {}",
fixture["name"]
);
}
}
@@ -673,6 +673,7 @@ class HarnessNativeSession implements NativeSession {
// The provider has no active turn to interrupt yet. Do not launch work
// whose events cancellation would suppress and leave the owner waiting.
if (this.#explicitlyCancelled) throw new Error("native_session_cancelled");
this.#terminal = null;
try {
const started = await this.#session.startTurn(input);
this.#assertProtocolIntegrity();
@@ -63,7 +63,7 @@ export interface PersistedNativeSession {
terminal?: PrpTerminalState | null;
activeTurnId?: string | null;
terminalTurns?: PersistedHarnessTurnTerminal[];
/** Durable at-most-once marker for a resultless terminal recovery turn. */
/** At-most-once marker for resultless disposition repair or restart continuation. */
dispositionOnlyRecoveryConsumed?: boolean;
dispositionOnlyRecoveryTurnId?: string | null;
pendingRuntimeRequests?: HarnessRuntimeRequest[];
@@ -77,6 +77,31 @@ export interface NativeSessionRecoveryResult {
reason?: string;
}
/** Runner-owned cause: the process was restored, but its active turn was lost. */
export const NATIVE_RESTART_INTERRUPTION_CODE = "provider_turn_lost_on_restore";
export function isNativeRestartInterruption(error: unknown): boolean {
if (!error || typeof error !== "object" || Array.isArray(error)) return false;
const failure = error as Record<string, unknown>;
return failure.code === NATIVE_RESTART_INTERRUPTION_CODE && failure.recoverable === true;
}
/** The terminal fingerprint survives a crash between interruption and continuation. */
export function nativeRestartInterruptedTurnId(snapshot: Pick<PersistedNativeSession,
"driverKind" | "activeTurnId" | "semanticResult" | "terminalTurns" | "dispositionOnlyRecoveryConsumed" | "pendingRuntimeRequests" | "goal"
>): string | null {
if (snapshot.driverKind !== "codex_app_server" || snapshot.activeTurnId != null ||
snapshot.semanticResult != null || snapshot.dispositionOnlyRecoveryConsumed ||
snapshot.goal != null || snapshot.pendingRuntimeRequests?.length) return null;
const terminal = Array.isArray(snapshot.terminalTurns) ? snapshot.terminalTurns.at(-1) : null;
if (!terminal || typeof terminal.fingerprint !== "string" || !terminal.turnId) return null;
try {
const fingerprint = JSON.parse(terminal.fingerprint);
return fingerprint?.terminalState === "failed" && fingerprint.result === null &&
isNativeRestartInterruption(fingerprint.error) ? terminal.turnId : null;
} catch { return null; }
}
/**
* Cancellation is a synchronous authority transition followed by passive
* provider cleanup. Once `cancel` returns, the provider session must no longer
@@ -26,6 +26,7 @@ import { validatePrpEvent } from "../protocol/replay-contract.js";
import { digestPaperclipSemanticContent } from "../semantic-tools/receipts.js";
import {
DurablePrpControlPlane,
durableRecoveryInternals,
SemanticToolNotDispatchedError,
inspectWarmRunTransition,
spawnRunner,
@@ -1170,7 +1171,17 @@ async function receiveSecure(
return value;
}
function semanticInputEvent(sourceSeq = 1): Record<string, unknown> {
const semanticInputDigestFixtures = JSON.parse(readFileSync(
new URL("../../test/fixtures/semantic-input-digests.json", import.meta.url),
"utf8",
)) as Array<{ name: string; input: Record<string, unknown>; digest: string }>;
function rawSemanticInputDigest(input: unknown): string {
return `sha256:${createHash("sha256")
.update(durableRecoveryInternals.canonicalJson(input)).digest("hex")}`;
}
function semanticInputEvent(sourceSeq = 1, input: Record<string, unknown> = {}): Record<string, unknown> {
return {
protocol: "paperclip.runner",
version: 1,
@@ -1210,11 +1221,11 @@ function semanticInputEvent(sourceSeq = 1): Record<string, unknown> {
},
idempotencyKey: null,
content: {
digest: digestPaperclipSemanticContent({}),
digest: rawSemanticInputDigest(input),
redactionDisposition: "digest_only",
references: [],
},
input: {},
input,
},
},
},
@@ -1693,6 +1704,144 @@ describe.sequential("DurablePrpControlPlane", () => {
},
);
it.each(semanticInputDigestFixtures.flatMap((fixture) =>
["semantic_tool.input", "mcp_app.tool_input"].map((eventType) => ({ ...fixture, eventType })),
))(
"accepts the Rust wire digest and preserves exact input: $eventType / $name",
async ({ input, digest, eventType }) => {
// The JSON transport normalizes negative zero before the receiver sees it.
const wireInput = JSON.parse(JSON.stringify(input)) as Record<string, unknown>;
const root = mkdtempSync(resolve(tmpdir(), "paperclip-prp-raw-input-"));
const onProtocolIntegrityError = vi.fn();
const onSemanticToolInput = vi.fn(async () => ({ result: { ok: true } }));
const core = new DurablePrpControlPlane({
stateDirectory: root, identity, expectedRunnerVersion, expectedRunnerDigest,
onProtocolIntegrityError, onSemanticToolInput,
});
try {
await core.start();
const client = (await authenticate(core, core.issueBootstrapTicket()))!;
const event = semanticInputEvent(1, wireInput);
(event.payload as any).eventType = eventType;
const semantic = (event.payload as any).payload.semantic_tool;
// This shared golden also runs against the Rust producer's hash.
semantic.content.digest = digest;
expect(rawSemanticInputDigest(input)).toBe(digest);
sendSecure(client, event);
const frames = [await receiveSecure(client), await receiveSecure(client)];
expect(frames).toContainEqual(expect.objectContaining({
kind: "ack", payload: expect.objectContaining({ ackedSourceSeq: 1 }),
}));
const command = frames.find((frame) => frame?.kind === "command")!.payload as any;
expect(command.payload.inputDigest).toBe(digest.slice("sha256:".length));
expect(onSemanticToolInput).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ input: wireInput }));
expect(core.store.state.committedEvents).toHaveLength(1);
expect(core.store.state.committedEvents[0]?.envelope).toEqual(event);
expect(onProtocolIntegrityError).not.toHaveBeenCalled();
client.socket.destroy();
} finally { await core.stop(); rmSync(root, { recursive: true, force: true }); }
},
);
it.each([
"redacted_digest", "protected_field_tampering", "forged_digest",
"over_node_limit", "over_depth_limit",
] as const)(
"rejects invalid raw semantic integrity without commit, dispatch or ACK: %s",
async (fault) => {
const root = mkdtempSync(resolve(tmpdir(), "paperclip-prp-raw-integrity-"));
const onProtocolIntegrityError = vi.fn();
const onCommittedEvent = vi.fn(async () => undefined);
const onSemanticToolInput = vi.fn(async () => ({ result: { ok: true } }));
const core = new DurablePrpControlPlane({
stateDirectory: root, identity, expectedRunnerVersion, expectedRunnerDigest,
onProtocolIntegrityError, onCommittedEvent, onSemanticToolInput,
});
try {
await core.start();
const client = (await authenticate(core, core.issueBootstrapTicket()))!;
const input = { password: "fixture-before", safe: true };
const event = semanticInputEvent(1, input);
const semantic = (event.payload as any).payload.semantic_tool;
const redactedDigest = digestPaperclipSemanticContent(input);
expect(redactedDigest).not.toBe(semantic.content.digest);
if (fault === "redacted_digest") semantic.content.digest = redactedDigest;
if (fault === "forged_digest") semantic.content.digest = `sha256:${"0".repeat(64)}`;
if (fault === "protected_field_tampering") {
semantic.input = { ...input, password: "fixture-after" };
// Redacted receipt hashes intentionally cannot prove byte integrity.
expect(digestPaperclipSemanticContent(semantic.input)).toBe(redactedDigest);
}
if (fault === "over_node_limit") semantic.input = { values: Array(10_001).fill(0) };
if (fault === "over_depth_limit") {
let nested: unknown = {};
for (let depth = 0; depth < 65; depth += 1) nested = { child: nested };
semantic.input = nested;
}
if (fault === "over_node_limit" || fault === "over_depth_limit") {
// Each fixture object has one key, so JSON.stringify is canonical.
// Hash independently of the bounded helper: the limits themselves
// must reject even a matching raw digest and keep the permanent fence.
semantic.content.digest = `sha256:${createHash("sha256")
.update(JSON.stringify(semantic.input)).digest("hex")}`;
}
sendSecure(client, event);
await expect(receiveSecure(client)).resolves.toBeNull();
expect(onProtocolIntegrityError).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({
code: "native_event_replay_conflict", reason: "semantic_input_digest_mismatch",
recovery: "operator_required",
}));
expect(core.store.state.ackedSourceSeq).toBe(0);
expect(core.store.state.committedEvents).toEqual([]);
expect(onCommittedEvent).not.toHaveBeenCalled();
expect(onSemanticToolInput).not.toHaveBeenCalled();
expect(JSON.stringify(onProtocolIntegrityError.mock.calls)).not.toContain("fixture-before");
expect(JSON.stringify(onProtocolIntegrityError.mock.calls)).not.toContain("fixture-after");
const replay = (await authenticate(core, client.leaseToken!))!;
sendSecure(replay, semanticInputEvent());
await expect(receiveSecure(replay)).resolves.toBeNull();
expect(onProtocolIntegrityError).toHaveBeenCalledTimes(1);
expect(onSemanticToolInput).not.toHaveBeenCalled();
} finally { await core.stop(); rmSync(root, { recursive: true, force: true }); }
},
);
it.each(["authentication", "envelope", "event", "correlation"] as const)(
"rejects raw semantic input outside its exact authenticated scope: %s",
async (boundary) => {
const root = mkdtempSync(resolve(tmpdir(), "paperclip-prp-raw-scope-"));
const onProtocolIntegrityError = vi.fn();
const onCommittedEvent = vi.fn(async () => undefined);
const onSemanticToolInput = vi.fn(async () => ({ result: { ok: true } }));
const core = new DurablePrpControlPlane({
stateDirectory: root, identity, expectedRunnerVersion, expectedRunnerDigest,
onProtocolIntegrityError, onCommittedEvent, onSemanticToolInput,
});
try {
await core.start();
const wrongRunId = "00000000-0000-4000-8000-000000000999";
const client = await authenticate(core, core.issueBootstrapTicket(),
boundary === "authentication" ? { ...identity, runId: wrongRunId } : identity);
if (boundary === "authentication") {
expect(client).toBeNull();
} else {
const event = semanticInputEvent(1, { password: "fixture-only" });
const payload = event.payload as any;
if (boundary === "envelope") event.runId = wrongRunId;
if (boundary === "event") payload.runId = wrongRunId;
if (boundary === "correlation") payload.payload.semantic_tool.correlation.runId = wrongRunId;
sendSecure(client!, event);
await expect(receiveSecure(client!)).resolves.toBeNull();
}
expect(core.store.state.ackedSourceSeq).toBe(0);
expect(core.store.state.committedEvents).toEqual([]);
expect(onCommittedEvent).not.toHaveBeenCalled();
expect(onSemanticToolInput).not.toHaveBeenCalled();
expect(onProtocolIntegrityError).not.toHaveBeenCalled();
} finally { await core.stop(); rmSync(root, { recursive: true, force: true }); }
},
);
it("latches an authenticated semantic integrity fault without ACK or dispatch and still permits suspension", async () => {
const root = mkdtempSync(resolve(tmpdir(), "paperclip-prp-integrity-"));
const onProtocolIntegrityError = vi.fn();
@@ -1854,7 +2003,7 @@ describe.sequential("DurablePrpControlPlane", () => {
).semantic_tool as Record<string, unknown>;
semantic.input = { changed: true };
(semantic.content as Record<string, unknown>).digest =
digestPaperclipSemanticContent(semantic.input);
rawSemanticInputDigest(semantic.input);
sendSecure(client, changed);
await expect(receiveSecure(client)).resolves.toBeNull();
expect(onProtocolIntegrityError).toHaveBeenCalledTimes(1);
@@ -2238,13 +2387,13 @@ describe.sequential("DurablePrpControlPlane", () => {
const event = semanticInputEvent();
const semantic = ((event.payload as any).payload as any).semantic_tool;
semantic.input = args;
semantic.content.digest = digestPaperclipSemanticContent(args);
semantic.content.digest = rawSemanticInputDigest(args);
sendSecure(client, event);
const frames = [await receiveSecure(client), await receiveSecure(client)];
const wire = frames.find((frame) => frame?.kind === "command")!.payload as any;
expect(wire.payload.result.content).toBe(content);
expect(wire.payload.input).toBeUndefined();
expect(wire.payload.inputDigest).toBe(digestPaperclipSemanticContent(args).slice("sha256:".length));
expect(wire.payload.inputDigest).toBe(rawSemanticInputDigest(args).slice("sha256:".length));
expect(Buffer.byteLength(JSON.stringify(wire))).toBeLessThan(1024 * 1024);
sendSecure(client, { protocol: "paperclip.runner", version: 1, kind: "command_result", payload: {
commandId: wire.commandId, controllerSeq: wire.controllerSeq, commandType: wire.type,
@@ -35,7 +35,6 @@ import {
validatePrpEvent,
type PrpEvent,
} from "../protocol/replay-contract.js";
import { digestPaperclipSemanticContent } from "../semantic-tools/receipts.js";
import {
type DurableRecoveryCommittedEvent,
type DurableRecoveryCoreCommand,
@@ -416,6 +415,18 @@ function canonicalDigest(value: unknown): string {
return createHash("sha256").update(canonicalJson(value)).digest("hex");
}
function matchesSemanticInputDigest(input: unknown, digest: unknown): boolean {
try {
// Wire integrity covers the complete input, including protected fields.
// Receipt redaction can erase those differences and has a separate hash.
return digest === `sha256:${canonicalDigest(input)}`;
} catch {
// Canonicalization bounds must keep the same permanent integrity fence.
// Never attach an input-derived error or payload to the diagnostic.
return false;
}
}
function exactIdentity(value: unknown): value is DurableRecoveryIdentity {
return (
isRecord(value) &&
@@ -3141,8 +3152,10 @@ export class DurablePrpControlPlane {
if (
isSemanticInput &&
semantic !== undefined &&
(semantic.content as Record<string, unknown>).digest !==
digestPaperclipSemanticContent(semantic.input)
!matchesSemanticInputDigest(
semantic.input,
(semantic.content as Record<string, unknown>).digest,
)
) {
// Only the authenticated, schema-valid, exactly correlated input may
// permanently fail its owner. Never commit, dispatch, or ACK these bytes.
@@ -17,7 +17,7 @@ import type {
PersistedHarnessSession,
PersistedHarnessTurnTerminal,
} from "../../contracts/harness-driver.js";
import { NativeSessionProtocolIntegrityError } from "../../contracts/native-session-backend.js";
import { NativeSessionProtocolIntegrityError, nativeRestartInterruptedTurnId } from "../../contracts/native-session-backend.js";
import { HarnessReconciliationError } from "../../contracts/harness-driver.js";
import {
CODEX_CODEX_PROTOCOL_VERSION,
@@ -528,6 +528,12 @@ export class CodexAppServerDriver implements HarnessDriver {
turns.forEach((turn, index) => {
if (terminalIds.has(text(turn.id))) lastKnownTerminalIndex = index;
});
if (nativeRestartInterruptedTurnId({
...snapshot, semanticResult: null,
}) && (!providerHistoryIsArray || lastKnownTerminalIndex < 0)) {
await cancellation.wait(cancellation.close());
return { recovered: false, reason: "restart interruption history is incomplete" };
}
// Releasing a consumed marker requires both an actual history array
// and a checkpointed terminal that anchors its ordering. An array that
// omits every durable terminal may be truncated or inconsistent, so
@@ -45,6 +45,29 @@ import {
import { NativeSessionProtocolIntegrityError } from "../../contracts/native-session-backend.js";
describe("Codex app-server Codex driver", () => {
it("does not resend a restart continuation when its history anchor is missing", async () => {
const first = new FakeCodexTransport();
const second = new FakeCodexTransport();
const driver = makeDriver([first, second]);
const original = await driver.openSession({
runId: "run-restart-anchor", normalizedSessionId: "session-restart-anchor", workingDirectory: WORKSPACE,
});
await original.startTurn({ message: { role: "user", text: "Finish the request." } });
first.push("turn/completed", { threadId: "thread-1", turn: {
id: "turn-1", status: "failed", error: { code: "provider_turn_lost_on_restore", recoverable: true }, items: [],
} });
await collectUntilTerminal(original.events());
const snapshot = await original.snapshot();
await original.close({ reason: "controller lost before continuation checkpoint" });
second.readResponse = { thread: { id: "thread-1", sessionId: "provider-session-1", cwd: WORKSPACE,
turns: [{ id: "uncheckpointed-turn", status: "completed", items: [] }],
} };
await expect(driver.recoverSession?.(snapshot)).resolves.toMatchObject({
recovered: false, reason: "restart interruption history is incomplete",
});
expect(second.calls.some(call => call.method === "turn/start")).toBe(false);
});
it.each([null, "checkpointed-prior-turn"])("recovers an autonomous goal turn beyond checkpoint %s", async (checkpointTurnId) => {
const first = new FakeCodexTransport();
const second = new FakeCodexTransport();
@@ -1,4 +1,5 @@
import { HarnessReconciliationError } from "../../contracts/harness-driver.js";
import { isNativeRestartInterruption } from "../../contracts/native-session-backend.js";
import type { PrpStructuredRunResult } from "../../protocol/replay-contract.js";
import { boundedCodexPayload as boundedPayload, isRetainableCodexPayload } from "./codex-boundaries.js";
import type { CodexSessionState } from "./codex-session-state.js";
@@ -168,6 +169,13 @@ export function mapTerminalTurn(
{ turnId },
);
finalize(state, status);
if (status === "failed" && isNativeRestartInterruption(turn.error) && state.result === null &&
!state.dispositionOnlyRecoveryConsumed) {
// Orchestration may use the paid restart attempt for one same-thread
// continuation. Its accepted turn id uses the existing durable marker.
state.terminal = false;
state.dispositionOnlyRecoveryAvailable = true;
}
}
function terminalFingerprint(
@@ -3583,20 +3583,31 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
if (method === "thread/turns/list") {
data = turns.map(turn => ({ ...turn, items: [], itemsView: "notLoaded" }));
} else {
if (params.turnId !== this.#turnId) throw new Error("codex_history_unavailable: requested turn is outside the retained runner event window");
if (!turns.some(turn => turn.id === params.turnId)) throw new Error("codex_history_unavailable: requested turn is outside the retained runner event window");
const items = new Map<string, Record<string, unknown>>();
let semanticResultItem: Record<string, unknown> | null = null;
let observedTurn = "";
let observedStart = false;
for (const event of this.#core?.store.state.committedEvents ?? []) {
if (event.envelope.runId !== this.#core?.store.state.identity.runId) continue;
const payload = record(record(event.envelope.payload).payload);
if (event.eventType === "turn.started") observedTurn = String(payload.providerTurnId ?? payload.turnId ?? record(payload.turn).id ?? "");
if (event.eventType === "turn.started" && observedTurn === params.turnId) observedStart = true;
if (event.eventType === "run.result.proposed" && observedTurn === params.turnId) {
// Reconciliation can precede notification delivery. Recover the
// runner's authoritative result with the exact retained turn,
// rather than launching work again just to obtain a disposition.
const id = `runner-result-${event.sourceSeq}`;
semanticResultItem = { turnId: observedTurn, item: { id, type: "agentMessage", text: JSON.stringify(payload) } };
}
if (event.eventType !== "item.completed" || observedTurn !== params.turnId) continue;
const item = record(rehydrateRunnerdItemNotification(payload, this.#threadId, observedTurn).item);
if (typeof item.id === "string") items.set(item.id, { turnId: observedTurn, item });
}
if (!observedStart) throw new Error("codex_history_incomplete: requested turn start is outside the retained runner event window");
data = [...items.values()];
// Runner authority wins over schema-shaped prose in an assistant item.
if (semanticResultItem) data.push(semanticResultItem);
}
if (params.sortDirection === "desc") data.reverse();
const offset = params.cursor == null ? 0 : Number(params.cursor);
@@ -3657,8 +3668,12 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
await new Promise((resolveWait) => setTimeout(resolveWait, 10));
}
if (terminal !== undefined) {
const payload = record(record(terminal.envelope.payload).payload);
recoveredTurns.push({
id: this.#turnId,
// Reconciliation must retain the cause, including the runner's
// explicit process-loss marker, rather than inventing error:null.
error: payload.error ?? record(payload.turn).error ?? null,
status:
terminal.eventType === "turn.completed"
? "completed"
@@ -3670,6 +3685,24 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
});
}
}
// A controller can lose the checkpoint after a continuation is accepted.
// Keep its prior terminal as the history anchor, so driver recovery can
// adopt the later accepted turn instead of submitting it again. Items
// remain lazy and fail closed if their start left the retained window.
const priorTerminals = new Map<string, Record<string, unknown>>();
for (const event of this.#core?.store.state.committedEvents ?? []) {
if (event.envelope.runId !== this.#core?.store.state.identity.runId ||
!["turn.completed", "turn.failed", "turn.interrupted", "turn.cancelled"].includes(event.eventType)) continue;
const payload = record(record(event.envelope.payload).payload);
const turnId = payload.providerTurnId ?? payload.turnId ?? record(payload.turn).id;
if (typeof turnId !== "string" || !turnId || turnId === this.#turnId) continue;
priorTerminals.set(turnId, {
id: turnId,
status: event.eventType.slice("turn.".length),
error: payload.error ?? record(payload.turn).error ?? null,
});
}
recoveredTurns.unshift(...priorTerminals.values());
this.#recoveryTurnBindingPending = false;
this.#pumpEvents();
return {
@@ -0,0 +1,125 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { expect, it, vi } from "vitest";
import { createRunnerdNativeSessionBackend } from "../backends/codex-native-backend.js";
import type { NativeExecutionInputV1 } from "../contracts/native-execution.js";
import type { PersistedNativeSession } from "../contracts/native-session-backend.js";
import type { PrpEvent } from "../protocol/replay-contract.js";
import { executeNativeSession } from "../native-session-runtime.js";
import { createRunnerdCodexTransport, defaultCapabilityRunnerdBinary } from "./runnerd-codex-transport.js";
it.each(["none", "before-start", "after-start"] as const)("completes after the real runner and provider are killed (second crash: %s)", async (crash) => {
const root = await mkdtemp(join(tmpdir(), "runner-restart-continuation-"));
const identity = { runId: "run-restart", sessionId: "session-restart", companyId: "company-restart", issueId: "issue-restart", agentId: "agent-restart" };
const execution: NativeExecutionInputV1 = {
schema: "paperclip.native-execution-input.v1",
binding: { runId: identity.runId, companyId: identity.companyId, issueId: identity.issueId, agentId: identity.agentId, executionWorkspaceId: "workspace-restart" },
session: { normalizedSessionId: identity.sessionId, driverKind: "codex_app_server", protocolVersion: 1 },
task: { identifier: "TEST-1", title: "Finish checks", description: null, prompt: "Watch the already running checks and finish the request.", workMode: "standard" },
workspace: { cwd: root, repoUrl: null, repoRef: null, branchName: null },
provider: { kind: "codex", model: null },
completionContract: { id: "contract-restart", sha256: "digest", schemaVersion: "paperclip.completion-contract.v1", contract: {
revision: "1", objective: "Finish checks", criteria: [{ id: "checks", requirement: "Check the outcome" }],
} },
interactionResponses: [], credentialBindings: [],
};
const options = {
runnerBinary: defaultCapabilityRunnerdBinary(),
codexCommand: resolve(import.meta.dirname, "../../runner/target/debug/fake-codex-app-server"),
codexArgs: ["--state-file", join(root, "fake-state.json"), "--call-log", join(root, "calls.log"), "--durable-turn-ids", "--hold-first-durable-turn"],
stateDirectory: root,
prpIdentity: { runnerInstanceId: "runner-restart", environmentLeaseId: "lease-restart", runId: identity.runId, normalizedSessionId: identity.sessionId, turnId: "turn-restart", itemId: "item-restart" },
};
const first = createRunnerdCodexTransport(options);
let restored: ReturnType<typeof createRunnerdCodexTransport> | undefined;
const alive = (pid: number | null) => {
if (!pid) return false;
try { process.kill(pid, 0); return true; } catch { return false; }
};
const kill = (pid: number | null) => {
if (!pid) return;
try { process.kill(pid, "SIGKILL"); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; }
};
try {
const backend = createRunnerdNativeSessionBackend(execution, { runnerInstanceId: "runner-restart", transportFactory: () => first.transport });
const session = await backend.openSession({ identity, workingDirectory: root });
await session.startTurn({ message: { role: "user", text: execution.task.prompt } });
const persisted = await session.snapshot();
expect(persisted.activeTurnId).toBe("provider-turn-1");
const durableEvents: PrpEvent[] = [];
const initialEvents = session.events()[Symbol.asyncIterator]();
while ((durableEvents.at(-1)?.sourceSeq ?? 0) < Number(persisted.cursor)) {
const event = await initialEvents.next();
if (event.done) throw new Error("initial event history ended early");
durableEvents.push(event.value);
}
const owner = first.evidence();
await first.detachControllerForRestart();
kill(owner.runnerPid);
kill(owner.providerPid);
await vi.waitFor(() => { expect(alive(owner.runnerPid)).toBe(false); expect(alive(owner.providerPid)).toBe(false); });
// Simulate Codex's process-loss history: the thread remains, its in-memory
// turn does not. Keep the turn counter to prevent provider identity reuse.
const providerState = JSON.parse(await readFile(join(root, "fake-state.json"), "utf8"));
providerState.activeTurnId = null;
await writeFile(join(root, "fake-state.json"), JSON.stringify(providerState));
restored = createRunnerdCodexTransport({ ...options, resumeDynamicTools: [], resumeActiveTurnId: persisted.activeTurnId });
let recovering = createRunnerdNativeSessionBackend(execution, { runnerInstanceId: "runner-restart", transportFactory: () => restored!.transport });
const events: PrpEvent[] = [];
const checkpoints: PersistedNativeSession[] = [];
let recoveryCheckpoint = persisted;
let failCheckpoint = crash !== "none";
const interruptedController = new Error("controller lost before continuation submission");
const execute = () => executeNativeSession({
input: execution, backend: recovering, persistedSession: recoveryCheckpoint, resumeInterruptedTurn: true,
runnerInstanceId: "runner-restart", controlPlaneInstanceId: "controller-restart", timeoutMs: 15_000,
controlPlane: {
async openRun() {},
async checkpointSession(snapshot) {
if (failCheckpoint && crash === "after-start" && snapshot.dispositionOnlyRecoveryTurnId === "provider-turn-2") {
failCheckpoint = false;
throw interruptedController;
}
checkpoints.push(structuredClone(snapshot));
recoveryCheckpoint = structuredClone(snapshot);
if (failCheckpoint && crash === "before-start" && snapshot.terminal?.runTerminalState === "failed") {
failCheckpoint = false;
throw interruptedController;
}
},
async appendEvent(event) { events.push(event); durableEvents.push(event); return { cursor: durableEvents.length, highestContiguousSourceSeq: event.sourceSeq, disposition: "committed" }; },
async replayEvents(request) {
const source = durableEvents.filter(event => event.sourceInstanceId === request.sourceInstanceId);
return { events: source.filter(event => event.sourceSeq > request.afterSourceSeq), highestContiguousSourceSeq: source.at(-1)?.sourceSeq ?? 0 };
},
async completeRun() {},
},
});
if (crash !== "none") {
await expect(execute()).rejects.toBe(interruptedController);
await restored.transport.close().catch(() => undefined);
restored = createRunnerdCodexTransport({ ...options, resumeDynamicTools: [], resumeActiveTurnId: recoveryCheckpoint.activeTurnId });
recovering = createRunnerdNativeSessionBackend(execution, { runnerInstanceId: "runner-restart", transportFactory: () => restored!.transport });
}
const completion = await execute();
expect(completion).toMatchObject({ providerSessionId: persisted.providerSessionId, turnId: "provider-turn-2", terminal: { runTerminalState: "succeeded" } });
expect(events.filter(event => event.eventType === "turn.failed")).toHaveLength(1);
expect(events.filter(event => event.eventType === "run.terminal")).toHaveLength(1);
if (crash !== "after-start") expect(checkpoints.some(snapshot => snapshot.activeTurnId === "provider-turn-2" && snapshot.terminal === null)).toBe(true);
expect((await readFile(join(root, "calls.log"), "utf8")).match(/^turn\/start$/gm)).toHaveLength(2);
const journal = JSON.parse(await readFile(join(root, "control-plane/control-plane-state.json"), "utf8"));
const submissions = journal.commands.filter((command: { type: string }) => command.type === "turn.start");
expect(submissions).toHaveLength(2);
expect(submissions[1].payload.text).toContain("Reconcile any unfinished tool or command");
} finally {
await restored?.transport.close().catch(() => undefined);
for (const bundle of [first, restored]) {
if (!bundle) continue;
const evidence = bundle.evidence();
kill(evidence.runnerPid);
kill(evidence.providerPid);
}
await rm(root, { recursive: true, force: true });
}
}, 30_000);
@@ -225,6 +225,71 @@ function highestContiguous(events: PrpEvent[]): number {
}
describe("executeNativeSession recovery", () => {
it.each([
{ label: "continues a restart-lost Codex turn", allowed: true, error: { code: "provider_turn_lost_on_restore", recoverable: true } },
{ label: "requires the control plane restart claim", allowed: false, error: { code: "provider_turn_lost_on_restore", recoverable: true } },
{ label: "preserves real provider errors", allowed: true, error: { code: "model_not_found", recoverable: false } },
{ label: "does not reopen historical error-null failures", allowed: true, error: null },
])("$label without replaying the task", async ({ label, allowed, error }) => {
const checkpoint: PersistedNativeSession = {
backendKind: "runner", driverKind: "codex_app_server", sessionId: "thread-recovery", identity,
providerSessionId: "provider-recovery", cursor: "0", activeTurnId: "turn-recovery",
terminalTurns: [], semanticResult: null, pendingRuntimeRequests: [], lineage: [],
};
const failure = error;
const interrupted: PersistedNativeSession = {
...checkpoint, activeTurnId: null,
terminalTurns: [{ turnId: "turn-recovery", fingerprint: JSON.stringify({ terminalState: "failed", error: failure, result: null }) }],
};
const startTurn = vi.fn<NativeSession["startTurn"]>(async () => ({ turnId: "turn-continuation" }));
const session: NativeSession = {
identity: () => identity,
async capabilities() { return { resume: true, typedEvents: true, steering: false, interruption: true, structuredResult: true }; },
async *events() {
yield runnerEvent(1, "turn.failed", { status: "failed", error: failure });
if (!startTurn.mock.calls.length) return;
yield { ...runnerEvent(2, "turn.completed", { status: "completed" }), turnId: "turn-continuation" };
},
startTurn,
async result() { return startTurn.mock.calls.length ? { result, terminal, turnId: "turn-continuation" } : null; },
async snapshot() { return structuredClone(interrupted); },
async close() {},
};
const events: PrpEvent[] = [];
const checkpoints: PersistedNativeSession[] = [];
const backend: NativeSessionBackend = {
async descriptor() { return { kind: "runner", name: "restart", version: "1", capabilities: await session.capabilities() }; },
async openSession() { throw new Error("must preserve the provider session"); },
async recoverSession() { return { recovered: true, session }; },
};
const port: ControlPlanePort = {
async openRun() {},
async checkpointSession(snapshot) { checkpoints.push(structuredClone(snapshot)); },
async appendEvent(event) { events.push(event); return { cursor: events.length, highestContiguousSourceSeq: event.sourceSeq, disposition: "committed" }; },
async replayEvents() { return { events: [], highestContiguousSourceSeq: 0 }; },
async completeRun() {},
};
const execution = executeNativeSession({
input, backend, controlPlane: port, persistedSession: checkpoint,
runnerInstanceId: "runner-recovery", controlPlaneInstanceId: "control-recovery",
resumeInterruptedTurn: allowed,
});
if (label !== "continues a restart-lost Codex turn") {
await expect(execution).rejects.toMatchObject({ code: "native_provider_terminal_failed" });
expect(startTurn).not.toHaveBeenCalled();
return;
}
await expect(execution).resolves.toMatchObject({ result, turnId: "turn-continuation", terminal: { runTerminalState: "succeeded" } });
expect(startTurn).toHaveBeenCalledOnce();
const submitted = startTurn.mock.calls[0]![0] as Parameters<NativeSession["startTurn"]>[0];
expect(submitted.continuation).toBe(true);
expect(submitted.message.text).toContain("Reconcile any unfinished tool or command");
expect(submitted.message.text).not.toContain(input.task.prompt);
expect(events.filter(event => event.eventType === "turn.failed")).toHaveLength(1);
expect(events.filter(event => event.eventType === "run.terminal")).toHaveLength(1);
expect(checkpoints.some(snapshot => snapshot.terminalTurns?.some(turn => turn.turnId === "turn-recovery"))).toBe(true);
});
it.each([undefined, 0, 7 * 24 * 60 * 60 * 1000, 30 * 24 * 60 * 60 * 1000])(
"honors long-lived turn duration independently of operation bounds (%s)",
async (turnTimeoutMs) => {
@@ -6234,7 +6299,12 @@ describe("executeNativeSession recovery", () => {
expect(openRun).not.toHaveBeenCalled();
});
it.each([false, true])("replaces a provider session that already ended with a failed terminal (prepared: %s)", async (preparedMode) => {
it.each([
{ preparedMode: false, interrupted: false },
{ preparedMode: true, interrupted: false },
{ preparedMode: false, interrupted: true },
{ preparedMode: true, interrupted: true },
])("replaces a failed provider with full task context (prepared: $preparedMode, interrupted: $interrupted)", async ({ preparedMode, interrupted }) => {
const digest = "0".repeat(64);
const skill = {
key: "company/recovery-skill",
@@ -6286,6 +6356,7 @@ describe("executeNativeSession recovery", () => {
} as const;
const checkpoint: PersistedNativeSession = {
backendKind: "mock",
driverKind: "codex_app_server",
sessionId: "driver-failed",
identity,
providerSessionId: "provider-failed",
@@ -6299,7 +6370,10 @@ describe("executeNativeSession recovery", () => {
runTerminalState: "failed",
reportedWorkDisposition: "yielded",
},
terminalTurns: [{ turnId: "turn-failed", fingerprint: "failed" }],
terminalTurns: [{ turnId: "turn-failed", fingerprint: interrupted ? JSON.stringify({
terminalState: "failed", result: null,
error: { code: "provider_turn_lost_on_restore", recoverable: true },
}) : "failed" }],
pendingRuntimeRequests: [],
lineage: [],
};
@@ -6335,8 +6409,8 @@ describe("executeNativeSession recovery", () => {
async close() {},
};
const recoverSession = vi.fn(async () => ({
recovered: true as const,
session: replacementSession,
recovered: false as const,
reason: "provider thread is unavailable",
}));
const openReplacementSession = vi.fn(async () => replacementSession);
const onContinuityBreak = vi.fn(async () => undefined);
@@ -6397,7 +6471,7 @@ describe("executeNativeSession recovery", () => {
).resolves.toMatchObject({ providerSessionId: "provider-replacement" });
expect(getFreshSessionHandoff).toHaveBeenCalledOnce();
expect(recoverSession).not.toHaveBeenCalled();
expect(recoverSession).toHaveBeenCalledTimes(interrupted ? 1 : 0);
expect(openReplacementSession).toHaveBeenCalledOnce();
const replacementEnvelope = JSON.parse(
startTurn.mock.calls[0]![0].message.text,
@@ -6419,7 +6493,7 @@ describe("executeNativeSession recovery", () => {
expect(JSON.stringify(replacementEnvelope)).not.toContain("ONLY_NEW_COMMENT");
expect(startTurn.mock.calls[0]![0]).not.toHaveProperty("continuation");
expect(onContinuityBreak).toHaveBeenCalledWith({
reason: "provider session ended with a failed terminal",
reason: interrupted ? "provider thread is unavailable" : "provider session ended with a failed terminal",
previousDriverSessionId: "driver-failed",
previousProviderSessionId: "provider-failed",
replacementDriverSessionId: "driver-replacement",
@@ -23,6 +23,8 @@ import {
NativeSessionCloseUnrecoverableError,
NativeSessionCleanupQuarantinedError,
NativeSessionProtocolIntegrityError,
isNativeRestartInterruption,
nativeRestartInterruptedTurnId,
} from "./contracts/native-session-backend.js";
import {
validatePrpStructuredRunResult,
@@ -184,6 +186,8 @@ export interface NativeSessionGoalControl {
}
export interface ExecuteNativeSessionOptions {
/** The control plane proved the old local runner stopped and charged a recovery attempt. */
resumeInterruptedTurn?: boolean;
/** Read bounded task history only after recovery requires a fresh conversation. */
getFreshSessionHandoff?: () => Promise<string | null>;
/** Durable launch intent, after cleanup admission and before provider calls. */
@@ -865,6 +869,7 @@ async function consumeTurn(
requestId: string;
},
initialGoal?: HarnessThreadGoal | null,
resumeInterruptedTurn?: (event: PrpEvent, signal: AbortSignal) => Promise<boolean>,
) {
let timer: ReturnType<typeof setTimeout> | undefined;
const appendAbort = new AbortController();
@@ -1174,6 +1179,8 @@ async function consumeTurn(
}
if (isTurnTerminal(event)) {
if (providerFailure) throw providerFailure;
if (semanticResultProposal === null && governedResult === null &&
inputTimers.size === 0 && await resumeInterruptedTurn?.(event, appendAbort.signal)) continue;
return {
event,
eventCount,
@@ -1871,6 +1878,12 @@ export async function executeNativeSession(
previousProviderSessionId: string | null;
} | null = null;
let reconciledRecoveryCheckpoint: PersistedNativeSession | null = null;
const checkpointedInterruption = persistedSession && nativeRestartInterruptedTurnId(persistedSession);
if (checkpointedInterruption && persistedSession) {
// The turn failed because its process vanished; the conversation is still
// usable. Keep its terminal fingerprint so replay cannot execute it twice.
persistedSession = { ...persistedSession, terminal: null };
}
await options.onSessionAdmission?.();
if (options.existingSession) {
if (options.existingSession.attachRun === undefined) {
@@ -2157,6 +2170,42 @@ export async function executeNativeSession(
await persistCheckpoint(snapshot, signal);
};
const recoveredSnapshot = await session.snapshot();
const restartContinuation = () => ({
message: { role: "user" as const, text: JSON.stringify({
schema: "paperclip.native-continuation.v1",
events: [
"Paperclip restarted and restored this same conversation. The previous turn was interrupted.",
"Continue the current user request from the recorded progress and preserved workspace.",
"Reconcile any unfinished tool or command before proceeding: inspect its current state and recorded outcomes.",
"Do not repeat completed work or blindly rerun an action whose outcome is unknown. If an external action cannot be reconciled, report the specific blocker.",
].join("\n"),
completion: {
revision: input.completionContract.contract.revision,
criterionIds: input.completionContract.contract.criteria.map(criterion => criterion.id),
},
}) },
continuation: true as const,
requestedCollaborationMode: "executionMode" in input ? input.executionMode : "default" as const,
});
let restartContinuationStarted = false;
const resumeInterruptedTurn = async (event: PrpEvent, signal: AbortSignal) => {
if (restartContinuationStarted || !recovered || !options.resumeInterruptedTurn ||
input.provider.kind !== "codex" || options.sessionGoalControl || options.resumeSessionGoalHeartbeat ||
recoveredSnapshot.goal || persistedSession?.semanticResult || persistedSession?.pendingRuntimeRequests?.length ||
event.eventType !== "turn.failed" || !isNativeRestartInterruption(event.payload.error) ||
event.turnId !== persistedSession?.activeTurnId) return false;
const snapshot = await session.snapshot();
signal.throwIfAborted();
if (nativeRestartInterruptedTurnId(snapshot) !== event.turnId || snapshot.pendingRuntimeRequests?.length) return false;
restartContinuationStarted = true;
// Persist the old terminal before launching. Recovery inspects provider
// history to adopt an accepted continuation if its checkpoint was lost.
await persistCheckpoint(snapshot, signal);
signal.throwIfAborted();
await session.startTurn(restartContinuation());
await checkpoint(signal);
return true;
};
const recoveredActiveTurnId = recovered
? (recoveredSnapshot.activeTurnId ?? null)
: (persistedSession?.activeTurnId ?? null);
@@ -2294,6 +2343,7 @@ export async function executeNativeSession(
}
: undefined,
recoveredSnapshot.goal,
resumeInterruptedTurn,
)
: Promise.resolve({
event: recoveryTerminal,
@@ -2333,6 +2383,10 @@ export async function executeNativeSession(
await session.goal({ action: "get", requestId });
}
await checkpoint();
} else if (shouldStartFreshTurn && recovered && checkpointedInterruption) {
restartContinuationStarted = true;
await session.startTurn(restartContinuation());
await checkpoint();
} else if (shouldStartFreshTurn) {
let modelEnvelope = recovered
? buildNativeModelEnvelope(input, { resumedSession: true })
@@ -0,0 +1,42 @@
[
{
"name": "plain document",
"input": {
"body": "Plain fixture document"
},
"digest": "sha256:2ba38770004c4e7672a5097c4d8e3241c62750b21e034903796fb035fcf9a2a4"
},
{
"name": "protected fields",
"input": {
"password": "fixture-only-not-a-real-credential",
"safe": true
},
"digest": "sha256:98ecc80f0e6d2d4b99ce3620d18a21392356f99def55ed599c1b1c002ccd2d47"
},
{
"name": "credential-like document text",
"input": {
"body": "Example Bearer fixture_token_123456\nhttps://example.test/callback?token=fixture-only"
},
"digest": "sha256:aa7f429cc5dd63e85fd11aa93e0659fa1c7d5041f5f35843fef62092ff86fdc0"
},
{
"name": "canonical Unicode and numbers",
"input": {
"": "private-use key",
"😀": "astral key",
"values": [
-0,
0.000001,
9.99999e-7,
100000000000000000000,
1e+21,
true,
null,
"日本語\n\"text\""
]
},
"digest": "sha256:3104a5e5db1fe2c6dcdaa0bfcf362d7bd36441e7e08905a48ae03be06eea9dc4"
}
]
@@ -23,7 +23,7 @@ Configure exe.dev from `Instance Settings -> Environments`, not from the plugin'
To use the provider successfully, the environment/host needs all of the following:
- An exe.dev API token that allows the lifecycle commands the provider uses: `new`, `ls`, and `rm`. `whoami` and `help` are recommended for manual debugging. `restart` is only needed if you extend the provider to restart retained VMs.
- An exe.dev API token that allows the lifecycle commands the provider uses: `new`, `ls`, and `rm`, plus `cp` when `sourceVm` is set. exe.dev answers 403 for a command the token does not list. `whoami` and `help` are recommended for manual debugging. `restart` is only needed if you extend the provider to restart retained VMs.
- SSH access from the Paperclip host to the resulting `*.exe.xyz` VMs.
- An SSH private key that exe.dev already recognizes. You can either:
- paste the private key into the environment config via `sshPrivateKey`
@@ -37,6 +37,7 @@ Operational notes:
- Reusable leases keep the VM alive between runs. exe.dev does not expose a documented "stop and later resume" command in the public CLI docs, so `reuseLease: true` means "retain the VM" rather than "suspend it."
- The provisioning path uses `https://exe.dev/exec`, which exe.dev documents as a command-style HTTPS API with a 30-second request timeout. Typical `new` calls are expected to fit inside that limit; command execution itself does not use `/exec`.
- Probes still create and delete a real exe.dev VM through `/exec`, and so do the `new`/`rm` calls inside the normal acquire/release lifecycle. Treat all of those as real provisioning cost, not just probes.
- Set `sourceVm` ("Source VM" in the form) to the name of an existing exe.dev VM to copy it for each run with `exe.dev cp`, disk and config included, instead of creating a fresh VM with `exe.dev new`. This lets you prepare one VM with your toolchain and caches and start every run from it. `cp` accepts only the VM name, `cpu`, `memory`, and `disk`, so config validation rejects `sourceVm` together with `image`, `command`, `comment`, `env`, `integrations`, `tags`, `setupScript`, or `prompt`. The default setup script does not run either, so the source VM must already have Node 24.11+ and accept the configured SSH key. Do not keep secrets on the source VM: every copy inherits its disk. `cp` also goes through `/exec`, so a large disk copy must finish inside the same 30-second limit.
- exe.dev runs `--setup-script` as the unprivileged `exedev` user, not as root. That user has passwordless `sudo`, so any system-level steps in a custom `setupScript` must invoke `sudo` explicitly (for example `sudo apt-get install -y …`). When you omit `setupScript`, the plugin supplies a default that installs Node 24 via the official nodesource script — Paperclip's sandbox callback bridge is a Node program, so the VM needs `node` on `PATH` before the bridge can launch.
## Local development
@@ -31,7 +31,7 @@ const manifest: PaperclipPluginManifestV1 = {
type: "string",
format: "secret-ref",
description:
"Paste your exe.dev API token, or pick a saved Paperclip secret. Create one at exe.dev → Settings → API tokens with `/exec` scope (`new`, `ls`, `rm`).",
"Paste your exe.dev API token, or pick a saved Paperclip secret. Create one at exe.dev → Settings → API tokens with `/exec` scope (`new`, `ls`, `rm`, plus `cp` if you use Source VM).",
},
sshPrivateKey: {
type: "string",
@@ -99,6 +99,14 @@ const manifest: PaperclipPluginManifestV1 = {
"x-paperclip-group": "VM resources",
},
// ---- Advanced: VM creation ----
sourceVm: {
type: "string",
title: "Source VM",
description:
"Name of an existing exe.dev VM to copy for each run with `exe.dev cp`, disk and config included. Leave blank to create a fresh VM with `exe.dev new`. Your API token must allow `cp`: tokens list their permitted commands, and one without `cp` fails with a 403. When set, leave image, command, env, integrations, tags, setup script, prompt, and comment empty: `cp` cannot apply them, and saving fails until they are cleared.",
"x-paperclip-advanced": true,
"x-paperclip-group": "VM creation",
},
command: {
type: "string",
description: "Optional container command passed to `exe.dev new --command`.",
@@ -106,6 +106,7 @@ describe("exe.dev sandbox provider plugin", () => {
integrations: ["github"],
tags: ["prod", "sandbox"],
setupScript: null,
sourceVm: null,
prompt: null,
timeoutMs: 450000,
reuseLease: true,
@@ -319,6 +320,57 @@ describe("exe.dev sandbox provider plugin", () => {
});
});
it("copies the source VM with exe.dev cp instead of creating a new VM", async () => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({
vm_name: "paperclip-env1-run1",
ssh_dest: "paperclip-env1-run1.exe.xyz",
status: "running",
}), { status: 200 }),
);
queueSpawnResult({ stdout: "/home/exedev\nbash\n" });
queueSpawnResult({});
const lease = await plugin.definition.onEnvironmentAcquireLease?.({
driverKey: "exe-dev",
companyId: "company-1",
environmentId: "env-1",
runId: "run-1",
config: {
apiKey: "api-key",
sourceVm: " golden-vm ",
image: "ubuntu:22.04",
env: { FOO: "bar" },
cpu: 4,
memory: "8GB",
disk: "40GB",
},
});
expect(String(fetchMock.mock.calls[0]?.[1]?.body ?? "")).toBe(
"cp 'golden-vm' 'paperclip-env1-run1' --json --cpu='4' --memory='8GB' --disk='40GB'",
);
expect(lease).toMatchObject({ providerLeaseId: "paperclip-env1-run1" });
});
it("rejects VM creation settings that exe.dev cp cannot apply when sourceVm is set", async () => {
const result = await plugin.definition.onEnvironmentValidateConfig?.({
driverKey: "exe-dev",
config: {
apiKey: "api-key",
sourceVm: "golden-vm",
image: "ubuntu:22.04",
env: { FOO: "bar" },
setupScript: "echo hi",
},
});
expect(result?.ok).toBe(false);
expect(result?.errors).toContain(
"sourceVm copies an existing VM with `exe.dev cp`, which cannot apply image, env, setupScript. Clear these settings or clear sourceVm.",
);
});
it("uses a pasted sshPrivateKey when connecting to the VM", async () => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({
@@ -860,6 +912,12 @@ describe("exe-dev manifest form defaults", () => {
expect(properties.disk?.default).toBe("20GB");
});
it("explains the cp token permission on the Source VM field", () => {
const sourceVm = properties.sourceVm as { title?: string; description?: string } | undefined;
expect(sourceVm?.title).toBe("Source VM");
expect(sourceVm?.description).toMatch(/token must allow `cp`.*403/);
});
it("declares no default on secret-ref fields, which would be persisted as a company secret", () => {
for (const prop of Object.values(properties)) {
if (prop.format === "secret-ref") {
@@ -24,6 +24,7 @@ interface ExeDevDriverConfig {
apiKey: string | null;
apiUrl: string;
namePrefix: string;
sourceVm: string | null;
image: string | null;
command: string | null;
cpu: number | null;
@@ -246,6 +247,7 @@ function parseDriverConfig(raw: Record<string, unknown>): ExeDevDriverConfig {
apiKey: parseOptionalString(raw.apiKey),
apiUrl: normalizeApiUrl(parseOptionalString(raw.apiUrl)),
namePrefix: normalizeNamePrefix(parseOptionalString(raw.namePrefix)),
sourceVm: parseOptionalString(raw.sourceVm),
image: parseOptionalString(raw.image),
command: parseOptionalString(raw.command),
cpu: parseOptionalInteger(raw.cpu),
@@ -313,10 +315,23 @@ function resolveSetupScript(config: ExeDevDriverConfig): string | null {
return trimmed.length > 0 ? config.setupScript : null;
}
function buildCopyCommand(sourceVm: string, config: ExeDevDriverConfig, vmName: string): string {
return [
"cp",
shellQuote(sourceVm),
shellQuote(vmName),
"--json",
...buildFlag("cpu", config.cpu),
...buildFlag("memory", config.memory),
...buildFlag("disk", config.disk),
].join(" ");
}
function buildCreateCommand(
config: ExeDevDriverConfig,
vmName: string,
): string {
if (config.sourceVm) return buildCopyCommand(config.sourceVm, config, vmName);
return [
"new",
"--json",
@@ -774,6 +789,23 @@ const plugin = definePlugin({
warnings.push(
"The Paperclip host must have SSH access to the created exe.dev VM, and its SSH key must be registered with exe.dev. The API token only covers provisioning.",
);
if (config.sourceVm) {
const ignored = Object.entries({
image: config.image,
command: config.command,
comment: config.comment,
env: Object.keys(config.env).length > 0,
integrations: config.integrations.length > 0,
tags: config.tags.length > 0,
setupScript: config.setupScript,
prompt: config.prompt,
}).filter(([, value]) => value).map(([key]) => key);
if (ignored.length > 0) {
errors.push(
`sourceVm copies an existing VM with \`exe.dev cp\`, which cannot apply ${ignored.join(", ")}. Clear these settings or clear sourceVm.`,
);
}
}
if (config.reuseLease) {
warnings.push("reuseLease keeps the VM alive between runs; this provider does not suspend retained VMs.");
}