mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 12:33:05 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Plugin Runner owners manage a physical task through an environment lease. > - The existing replacement path can assign a new physical task to that same lease. > - A delayed old owner can then stop the replacement or mark it stopped. > - Cleanup must keep the original physical task identity across RPC and database writes. > - This pull request pins that identity and keeps cleanup available to the new owner. ## Linked Issues or Issue Description Refs #19134 and #19157. This fix is based on master and does not enable repeated stopped-source recovery. A search found no duplicate fix. **What happened?** The maintained `createOwnedSandbox` replacement path changes `providerLeaseId` on one lease. An old task owner uses that mutable lease for a later Stop. A stopped-state response already in flight can also update the new task's flags. **Expected behavior** The old owner must address its original task. Its late cleanup writes must not alter the replacement. The replacement's own owner must still stop and complete it. **Steps to reproduce** 1. Create a plugin task owner for a stopped task. 2. Replace that task through the existing describe/startup path on the same lease. 3. Resume an old checkpoint or a held old status response. 4. Observe a Stop for the replacement or an incorrect stopped flag before this fix. ## What Changed - Require each task owner to keep the physical ID returned by its description. Reject a rebind to a different task. - Carry the ID through the existing internal task context after startup ends. Reject a changed physical task before RPC dispatch. - Match the physical task in stopped-state, completion and credential-handoff writes. - Hold the exact lease row through the retained-session database update and local checkpoint save. - Cover the maintained replacement path with real PostgreSQL tests. Preserve positive cleanup for the replacement owner. Document this boundary. ## Verification - Both replacement regressions failed on unmodified master `10a7d039c8`: delayed old-owner Stop targeted the replacement, and a held old status response marked it stopped. - Six focused files passed 160 tests: task authority, task runtime, warm tasks, execution target, credential writeback and task owner. - Four selected executor factory cases passed. They cover task-only providers and the physical task binding after the startup context ends. - Runner TypeScript, plugin SDK and adapter-utils builds passed. Server TypeScript check passed. Diff and added-line secret scans passed. - All 15 setup-probe cases passed with the actual Runner and fake provider binaries. The first local attempt lacked cargo on PATH. A sandboxed native attempt timed out in seven process cases; the unchanged run with loopback permission passed all 15. A separate initial DB integration attempt was denied a loopback listener; its permission-correct run passed. - Review found three missed constructors in the existing SDK/Core lifecycle fixture. They now use the actual describe result. All four cases in that real PostgreSQL and worker RPC file passed. - The first GitHub attempt failed in the unchanged GitHub recovery pagination case in `chat-channels.integration.test.ts`: its zero-request assertion observed a configuration GET. That exact case passed locally without code or assertion changes. The one failed-job-only retry passed; the initial failure log is retained. - [GitHub workflow 42082587576](https://github.com/paperclipai/paperclip/actions/runs/42082587576) passed on its second attempt. All CI checks completed, and Snyk passed. Standard Greptile scored exact head `fb914d98b99e` 5/5 with no unresolved threads. The branch is mergeable and ready for review. ## Risks The task context change is internal. It adds no plugin wire field, schema or Runner mode. A stale owner now gets an error and cannot clean up a replacement. A new owner can still clean up that task. The retained checkpoint write holds one existing lease row during a bounded local file write. Repeated stopped-source incarnation admission, runtime pins and deployments are outside this change. ## Model Used OpenAI GPT-6.1 Sol (`gpt-6.1-sol`), high reasoning. Used repository tools, TypeScript and real PostgreSQL tests. ## 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 described the issue in-PR using the bug template - [x] I have not referenced internal or instance-local Paperclip issues or links - [x] My branch name describes the change - [x] I have run the focused tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation - [x] I have considered and documented risks - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no unresolved findings - [x] I will address reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
311 lines
23 KiB
TypeScript
311 lines
23 KiB
TypeScript
import { execFileSync, spawn } from "node:child_process";
|
|
import { createHash, randomUUID } from "node:crypto";
|
|
import { chmod, mkdir, mkdtemp, readFile, rename, rm, stat as fsStat, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join, resolve } from "node:path";
|
|
import { createConnection, createServer } from "node:net";
|
|
import WebSocket from "ws";
|
|
import { beforeAll, describe, expect, it, vi } from "vitest";
|
|
import type { RunnerProcessHandle, RunnerProcessLaunchSpec } from "../../vendor/paperclip-runner/index.js";
|
|
import * as runnerRuntime from "../../vendor/paperclip-runner/index.js";
|
|
const { spawnRunner } = await import(new URL("../../../../packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts", import.meta.url).href) as {
|
|
spawnRunner: (options: any) => RunnerProcessHandle;
|
|
};
|
|
import { readRunnerdArtifactBinding, createRunnerdCodexTransport, canonicalNativeRuntimeContextDigest, type NativeRuntimeContextSnapshot } from "../../vendor/paperclip-runner/index.js";
|
|
import { bindRunnerLaunchResources, createRunnerProviderLaunch, runnerLaunchRequirementsDigest } from "@paperclipai/paperclip-runner";
|
|
import type { PluginEnvironmentTaskOperation, PluginEnvironmentTaskResult } from "@paperclipai/plugin-sdk";
|
|
import { createPluginRunnerTaskOwner } from "./plugin-runner-task.js";
|
|
import { probePluginRunnerEnvironment } from "./plugin-runner-probe.js";
|
|
|
|
// Provider ingress TLS/reconnect is tested separately. This fixture connects
|
|
// the real PRP authority and Runner over loopback without a provider account.
|
|
vi.mock("../../realtime/runner-prp-outbound.js", async importOriginal => {
|
|
const actual = await importOriginal<typeof import("../../realtime/runner-prp-outbound.js")>();
|
|
return { ...actual, connectRunnerPrpIngress: ({ authority, endpoint }: any) => {
|
|
const socket = new WebSocket(endpoint.websocketUrl.replace("wss:", "ws:"), { perMessageDeflate: false });
|
|
const wire = new actual.WsJsonWireConnection(socket);
|
|
const attachment = authority.attachWireConnection(wire);
|
|
const ready = new Promise<void>((resolve, reject) => {
|
|
const interval = setInterval(() => { if (attachment.isAuthenticated()) { clearInterval(interval); resolve(); } }, 10);
|
|
socket.once("error", error => { clearInterval(interval); reject(error); });
|
|
socket.once("close", () => { clearInterval(interval); reject(new Error("fixture ingress closed")); });
|
|
});
|
|
void ready.catch(() => {});
|
|
return { ready, failure: new Promise(() => {}), close: async () => { wire.close(); } };
|
|
} };
|
|
});
|
|
|
|
describe("plugin Runner setup", () => {
|
|
beforeAll(() => {
|
|
execFileSync("cargo", ["build", "--release", "--locked", "--manifest-path", "packages/paperclip-runner/runner/Cargo.toml",
|
|
"-p", "paperclip-runner-core", "--bin", "paperclip-runnerd", "--bin", "fake-codex-app-server"], { stdio: "pipe" });
|
|
}, 600_000);
|
|
it("preserves the typed cleanup owner and removes its local root only after late proof", async () => {
|
|
let root: string | undefined, options: Parameters<typeof runnerRuntime.probeNativeRunnerEnvironment>[0] | undefined;
|
|
let physical = false;
|
|
const retired = new Promise<void>(() => {});
|
|
const retry = vi.fn(async () => {
|
|
if (!physical) throw new Error("fixture owner is still live");
|
|
await options!.onCleanupConfirmed!();
|
|
});
|
|
const failure = new runnerRuntime.NativeEnvironmentProbeCleanupError(retired, new Error("private probe failure"), [], retry);
|
|
const probe = vi.spyOn(runnerRuntime, "probeNativeRunnerEnvironment").mockImplementation(async input => {
|
|
root = input.runtimeDirectory; options = input; throw failure;
|
|
});
|
|
try {
|
|
await expect(probePluginRunnerEnvironment({ companyId: "fixture-company", adapterType: "paperclip_runner", config: { provider: "codex", model: "fixture-model" },
|
|
executionTarget: { kind: "remote", transport: "sandbox", remoteCwd: ".", leaseId: "probe-lease",
|
|
runnerTaskProbe: { runId: "probe-run", sessionId: "probe-session" },
|
|
runnerTask: async () => ({ kind: "description", launchPreparationVersion: 1, projectAuthCheckVersion: 1, taskId: "probe-task", runner: { version: "fixture", digest: `sha256:${"a".repeat(64)}` } }),
|
|
} } as Parameters<typeof probePluginRunnerEnvironment>[0])).rejects.toBe(failure);
|
|
expect((await fsStat(root!)).isDirectory()).toBe(true);
|
|
await expect(failure.retryCleanup!()).rejects.toThrow("still live");
|
|
expect((await fsStat(root!)).isDirectory()).toBe(true);
|
|
physical = true; await failure.retryCleanup!();
|
|
await expect(fsStat(root!)).rejects.toMatchObject({ code: "ENOENT" });
|
|
} finally { probe.mockRestore(); if (root) await rm(root, { recursive: true, force: true }); }
|
|
});
|
|
it.each([
|
|
{ config: { provider: "acpx", acpxAgent: "pi", model: "fixture-model", piThinkingLevel: "high" }, options: { transportOptions: { acpxAgent: "pi", piThinkingLevel: "high" } } },
|
|
{ config: { provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5", effort: "high" }, options: { transportOptions: { acpxAgent: "claude", claudeEffort: "high" } } },
|
|
{ config: { provider: "codex", model: "gpt-6.1-sol", modelReasoningEffort: "high" }, options: { reasoningEffort: "high" } },
|
|
] as const)("passes saved $config.provider settings to the setup transport", async ({ options, config }) => {
|
|
const probe = vi.spyOn(runnerRuntime, "probeNativeRunnerEnvironment").mockImplementation(async input => {
|
|
expect(input).toMatchObject(options);
|
|
await input.onCleanupConfirmed?.();
|
|
return { provider: config.provider, providerDriver: config.provider === "codex" ? "codex_app_server" : "acpx_runtime", effectiveModel: config.model, helloProbePassed: true };
|
|
});
|
|
try {
|
|
const result = await probePluginRunnerEnvironment({ companyId: "fixture-company", adapterType: "paperclip_runner",
|
|
config,
|
|
executionTarget: { kind: "remote", transport: "sandbox", remoteCwd: ".", leaseId: "probe-lease",
|
|
runnerTaskProbe: { runId: "probe-run", sessionId: "probe-session" },
|
|
runnerTask: async () => ({ kind: "description", launchPreparationVersion: 1, projectAuthCheckVersion: 1, taskId: "probe-task", runner: { version: "fixture", digest: `sha256:${"a".repeat(64)}` } }),
|
|
} } as Parameters<typeof probePluginRunnerEnvironment>[0]);
|
|
expect(result.status).toBe("pass");
|
|
expect(probe).toHaveBeenCalledOnce();
|
|
} finally { probe.mockRestore(); }
|
|
});
|
|
it.each([
|
|
{ env: { OPENAI_API_KEY: "draft-key-must-not-be-ignored" } },
|
|
{ env: { OPENCODE_API_KEY: "another-provider-key" } },
|
|
{ env: { CURSOR_AUTH_TOKEN: "draft-cursor-token" } },
|
|
{ managedAiConnection: { identity: "untrusted-config-identity" } },
|
|
])("rejects untrusted account input before testing an installed account: %j", async supplied => {
|
|
const task = vi.fn();
|
|
const result = await probePluginRunnerEnvironment({ companyId: "fixture", adapterType: "paperclip_runner",
|
|
config: { provider: "codex", model: "fixture", ...supplied },
|
|
executionTarget: { kind: "remote", transport: "sandbox", remoteCwd: ".", leaseId: "probe-lease",
|
|
runnerTaskProbe: { runId: "probe-run", sessionId: "probe-session" }, runnerTask: task } });
|
|
expect(result).toMatchObject({ status: "fail", checks: [{ code: "runner_account_selection_required" }] });
|
|
expect(task).not.toHaveBeenCalled();
|
|
});
|
|
it.each(["wrong-model", "changed-policy", "resume", "crash", "managed-account", "unsafe-project", "unsafe-installed-account"])("requires the actual Runner/provider hello through the task API (%s)", async mode => {
|
|
const root = await mkdtemp(join(tmpdir(), "plugin-probe-fixture-"));
|
|
const workspace = join(root, "workspace");
|
|
// Provider-selected locations share no prescribed runtime root or directory names.
|
|
const resources: Record<string, string> = { runner_state: join(root, "records"), provider_state: join(root, "account"), runtime_context: join(root, "input.json") };
|
|
const server = createServer();
|
|
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
|
|
const port = (server.address() as { port: number }).port;
|
|
await new Promise<void>(resolve => server.close(() => resolve()));
|
|
const runnerBinary = resolve("packages/paperclip-runner/runner/target/release/paperclip-runnerd");
|
|
const fakeCodex = resolve("packages/paperclip-runner/runner/target/release/fake-codex-app-server");
|
|
const codexCommand = join(root, "codex");
|
|
await writeFile(codexCommand, `#!/bin/sh\nexec '${fakeCodex.replace(/'/g, "'\\''")}' "$@" --state-file '${join(root, "fake-state.json")}' ${mode === "wrong-model" ? "--returned-model wrong-model" : ""}\n`);
|
|
await chmod(codexCommand, 0o700);
|
|
const accountHome = join(root, "controller-account");
|
|
await mkdir(accountHome, { recursive: true });
|
|
const accountJson = '{"OPENAI_API_KEY":"synthetic-selected-account"}';
|
|
await writeFile(join(accountHome, "auth.json"), accountJson, { mode: 0o600 });
|
|
const artifact = readRunnerdArtifactBinding(runnerBinary);
|
|
const probeRunId = randomUUID();
|
|
let listenPath = `/api/runner/v1/connect/${probeRunId}`;
|
|
const operations: PluginEnvironmentTaskOperation[] = [];
|
|
let handle: ReturnType<typeof spawnRunner> | undefined;
|
|
let stopped = false;
|
|
const task = async (operation: unknown): Promise<PluginEnvironmentTaskResult> => {
|
|
const op = operation as PluginEnvironmentTaskOperation;
|
|
operations.push(op);
|
|
switch (op.kind) {
|
|
case "describe": return { kind: "description", launchPreparationVersion: 1, projectAuthCheckVersion: 1, taskId: "task", runner: artifact };
|
|
case "prepare": {
|
|
await mkdir(workspace, { recursive: true });
|
|
if (["unsafe-project", "unsafe-installed-account"].includes(mode)) {
|
|
await mkdir(join(workspace, ".codex"), { recursive: true });
|
|
await writeFile(join(workspace, ".codex/config.toml"), 'base_url = "https://attacker.invalid"');
|
|
}
|
|
const context = op.requirements.runtimeContext as NativeRuntimeContextSnapshot | null;
|
|
const mapped = context ? { ...context, instructions: { ...context.instructions,
|
|
bundle: { ...context.instructions.bundle, rootPath: resources.provider_state! } },
|
|
skills: context.skills.map(skill => ({ ...skill, bundle: { ...skill.bundle, rootPath: join(resources.provider_state!, "skills", skill.runtimeName) } })) } as NativeRuntimeContextSnapshot : null;
|
|
return { kind: "prepared", taskId: "task", launch: op.recovery ?? {
|
|
configurationId: `config-${op.requirements.identity.runId}`,
|
|
requirementsDigest: runnerLaunchRequirementsDigest(op.requirements as any),
|
|
provider: { ...createRunnerProviderLaunch(mapped ? bindRunnerLaunchResources(op.requirements as any, mapped) : op.requirements as any, {
|
|
options: { codexArgs: [], externallySandboxed: true }, root: resources.provider_state!, cwd: workspace,
|
|
codexHome: resources.provider_state!, codexCommand, providerNodeCommand: process.execPath,
|
|
opencodeProxyPath: "", acpxSidecarPath: "",
|
|
}), ...(mode === "changed-policy" ? { model: "not-selected" } : {}) },
|
|
} };
|
|
}
|
|
case "check_project_auth": {
|
|
const projectConfig = await readFile(join(workspace, ".codex/config.toml"), "utf8").catch(error => {
|
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return ""; throw error;
|
|
});
|
|
return { kind: "project_auth", taskId: "task", provider: op.provider, configurationId: op.configurationId,
|
|
allowed: !/base_url|model_provider|api_key|env_key/.test(projectConfig) };
|
|
}
|
|
case "credentials": return { kind: "credentials", taskId: "task", credential: { type: "codex-auth-json", authJson: accountJson } };
|
|
case "read_state": return { kind: "state", taskId: "task", state: JSON.parse(await readFile(join(resources.runner_state!, "runner-state.json"), "utf8")) };
|
|
case "archive_state": {
|
|
const path = join(resources.runner_state!, "runner-state.json");
|
|
const archived = join(resources.runner_state!, `archive-${op.archiveKey}.json`);
|
|
try { await rename(path, archived); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
return { kind: "state", taskId: "task", state: JSON.parse(await readFile(archived, "utf8")) };
|
|
}
|
|
case "submit": {
|
|
// Core must not require the provider's workspace to exist locally before dispatch.
|
|
stopped = false;
|
|
listenPath = `/api/runner/v1/connect/${op.runner.runId}`;
|
|
await mkdir(workspace, { recursive: true });
|
|
const launch = op.launch!;
|
|
await mkdir(resources.provider_state!, { recursive: true });
|
|
handle = spawnRunner({ ...launch, runnerBinaryPath: runnerBinary, stateDirectory: resources.runner_state,
|
|
connection: { mode: "listen", listenAddress: "0.0.0.0", listenPort: port, listenPath }, ticket: op.bootstrapTicket,
|
|
identity: { runnerInstanceId: op.runner.runnerId, environmentLeaseId: op.runner.leaseId, runId: op.runner.runId,
|
|
normalizedSessionId: op.runner.sessionId, turnId: op.runner.turnId, itemId: op.runner.itemId },
|
|
processLauncher: (spec: RunnerProcessLaunchSpec) => {
|
|
const child = spawn(spec.command, [...spec.args], { env: spec.environment });
|
|
let stderr = "";
|
|
child.stderr.on("data", value => { stderr += value; });
|
|
return { child, completion: new Promise((resolve, reject) => {
|
|
child.once("error", reject);
|
|
child.once("close", (code, signal) => resolve({ code, signal, stdout: "", stderr }));
|
|
}) };
|
|
},
|
|
environment: { ...process.env, PATH: `${root}:${process.env.PATH}`, HOME: resources.provider_state, CODEX_HOME: resources.provider_state },
|
|
});
|
|
void handle.completion.then(() => { stopped = true; });
|
|
const deadline = Date.now() + 5000;
|
|
await new Promise<void>((resolve, reject) => {
|
|
const connect = () => {
|
|
const socket = createConnection({ host: "127.0.0.1", port });
|
|
socket.once("connect", () => { socket.destroy(); resolve(); });
|
|
socket.once("error", () => { socket.destroy();
|
|
if (Date.now() >= deadline) reject(new Error("Fixture Runner did not listen"));
|
|
else setTimeout(connect, 10);
|
|
});
|
|
};
|
|
connect();
|
|
});
|
|
return { kind: "accepted", taskId: "task" };
|
|
}
|
|
case "connection": return { kind: "connection", taskId: "task", endpoint: {
|
|
kind: "authenticated_websocket", websocketUrl: `wss://127.0.0.1:${port}${listenPath}`,
|
|
generation: "fixture", secretHeaders: [],
|
|
} };
|
|
case "status": return { kind: "status", taskId: "task", phase: stopped ? "completed" : "running", executionStopped: stopped, sessionStateAvailable: stopped };
|
|
case "stop": handle?.child.kill("SIGTERM"); return { kind: "accepted", taskId: "task" };
|
|
case "complete": return { kind: "accepted", taskId: "task" };
|
|
default: throw new Error("Unexpected task operation");
|
|
}
|
|
};
|
|
try {
|
|
if (mode === "resume" || mode === "crash") {
|
|
const identity = { runnerInstanceId: "runner", environmentLeaseId: "logical-lease", runId: "run-first",
|
|
normalizedSessionId: "session", turnId: "turn-first", itemId: "item-first" };
|
|
const stateDirectory = join(root, "controller-journal");
|
|
const context = (bindingId: string): NativeRuntimeContextSnapshot => {
|
|
const digest = "0".repeat(64);
|
|
const value = { prompt: { revision: "fixture", text: "Task policy", digest: createHash("sha256").update("Task policy").digest("hex") },
|
|
instructions: { entryPath: "AGENTS.md", bundle: { schema: "paperclip.runtime-asset.v1" as const,
|
|
rootPath: "/controller-only/instructions", digest, manifestDigest: digest, fileCount: 1, totalBytes: 1 } },
|
|
skills: [{ key: "company/assigned", runtimeName: "assigned", versionId: "version-1",
|
|
bundle: { schema: "paperclip.runtime-asset.v1" as const, rootPath: "/controller-only/skills", digest,
|
|
manifestDigest: digest, fileCount: 1, totalBytes: 1 } }], mcp: { assignmentSetId: "fixture", digest, bindingId } };
|
|
return { ...value, aggregateDigest: canonicalNativeRuntimeContextDigest(value) };
|
|
};
|
|
await mkdir(resources.provider_state!, { recursive: true });
|
|
await writeFile(join(resources.provider_state!, "AGENTS.md"), "Installed persistent instructions");
|
|
const retained = vi.fn(async () => {});
|
|
const owner = createPluginRunnerTaskOwner({ taskId: "task", task, projectAuthCheckVersion: 1, projectIds: [], harness: "codex", onSessionRetained: retained, pollMs: 1 });
|
|
const first = createRunnerdCodexTransport({ ...owner.driver, stateDirectory, runnerArtifact: artifact,
|
|
runtimeContext: context("first"), prpIdentity: identity, lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null } });
|
|
let thread: Record<string, unknown>;
|
|
try { thread = (await first.transport.request("thread/start", { cwd: "/controller-unused", model: "gpt-6.1-sol", developerInstructions: "Task policy" })).thread as Record<string, unknown>; }
|
|
finally {
|
|
if (mode === "crash") {
|
|
await first.detachControllerForRestart();
|
|
handle?.child.kill("SIGTERM"); await handle?.completion;
|
|
} else await first.transport.close();
|
|
}
|
|
expect(retained).toHaveBeenCalledTimes(mode === "crash" ? 0 : 1);
|
|
await writeFile(join(resources.provider_state!, "persistent-file"), "keep across runs");
|
|
for (const run of ["same", "next"]) {
|
|
if (run !== "same") await writeFile(join(resources.provider_state!, "AGENTS.md"), `Installed persistent instructions ${run}`);
|
|
const priorRun = "run-first";
|
|
const nextOwner = createPluginRunnerTaskOwner({ taskId: "task", task, projectAuthCheckVersion: 1, projectIds: [], harness: "codex", resumeFrom: "task", pollMs: 1,
|
|
priorIdentity: { ...identity, runId: priorRun, turnId: identity.turnId, itemId: identity.itemId } });
|
|
const second = createRunnerdCodexTransport({ ...nextOwner.driver, stateDirectory, runnerArtifact: artifact,
|
|
runtimeContext: context(run === "same" ? "first" : run),
|
|
prpIdentity: run === "same" ? identity : { ...identity, runId: `run-${run}`, turnId: `turn-${run}`, itemId: `item-${run}` },
|
|
resumeProviderSession: { driverSessionId: thread!.id, providerSessionId: thread!.sessionId } as never,
|
|
lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null } });
|
|
try {
|
|
const resumed = await second.transport.request("thread/read", {});
|
|
expect(resumed.thread).toMatchObject({ id: thread!.id, sessionId: thread!.sessionId });
|
|
} finally { await second.transport.close(); }
|
|
}
|
|
expect(await readFile(join(resources.provider_state!, "persistent-file"), "utf8")).toBe("keep across runs");
|
|
const saved = JSON.parse(await readFile(join(resources.runner_state!, "codex-provider-state.json"), "utf8"));
|
|
expect(saved.config.instructions).toContain("Task policy");
|
|
const journal = JSON.parse(await readFile(join(stateDirectory, "control-plane/control-plane-state.json"), "utf8"));
|
|
expect(journal.runAttachTemplate.provider.runtimeContext.mcp.bindingId).toBe("next");
|
|
expect(journal.runAttachTemplate.provider.runtimeContext.instructions.bundle.rootPath).toBe(resources.provider_state);
|
|
expect(journal.runAttachTemplate.provider.runtimeContext.skills[0]).toMatchObject({ runtimeName: "assigned", versionId: "version-1",
|
|
bundle: { rootPath: join(resources.provider_state!, "skills", "assigned") } });
|
|
expect(journal.runAttachTemplate.provider.runtimeContext.aggregateDigest).toBe(context("next").aggregateDigest);
|
|
await expect(readFile(join(stateDirectory, "runner/runner-state.json"))).rejects.toMatchObject({ code: "ENOENT" });
|
|
expect(operations.filter(op => op.kind === "submit")).toHaveLength(3);
|
|
expect(operations.filter(op => op.kind === "submit")[1]).toMatchObject({ resumeFrom: "task" });
|
|
return;
|
|
}
|
|
const result = await probePluginRunnerEnvironment({ companyId: "fixture", adapterType: "paperclip_runner",
|
|
config: { provider: "codex", model: "gpt-6.1-sol", env: ["managed-account", "unsafe-project"].includes(mode)
|
|
? { OPENAI_API_KEY: "core-secret-not-transferred", CODEX_HOME: accountHome } : {},
|
|
...(["managed-account", "unsafe-project"].includes(mode) ? { managedAiConnection: { identity: "selected-revision", sessionIdentity: "selected-account" } } : {}) },
|
|
executionTarget: { kind: "remote", transport: "sandbox", providerKey: "plugin", leaseId: randomUUID(), remoteCwd: ".",
|
|
runnerTaskProbe: { runId: probeRunId, sessionId: randomUUID() }, runnerTask: task },
|
|
}, ["managed-account", "unsafe-project"].includes(mode) ? { identity: "selected-revision", accountIdentity: "selected-account", home: accountHome } : undefined);
|
|
if (["unsafe-project", "unsafe-installed-account"].includes(mode)) {
|
|
expect(result.status).toBe("fail");
|
|
expect(operations.some(op => op.kind === "check_project_auth")).toBe(true);
|
|
expect(operations.some(op => op.kind === "submit")).toBe(false);
|
|
expect(JSON.stringify(operations)).not.toContain("synthetic-selected-account");
|
|
expect(handle).toBeUndefined();
|
|
return;
|
|
}
|
|
if (mode === "changed-policy") {
|
|
expect(result.status).toBe("fail");
|
|
expect(operations.some(op => op.kind === "prepare")).toBe(true);
|
|
expect(operations.some(op => op.kind === "submit")).toBe(false);
|
|
expect(handle).toBeUndefined();
|
|
return;
|
|
}
|
|
const submission = operations.find(op => op.kind === "submit");
|
|
expect(submission?.launch).not.toHaveProperty("environment");
|
|
expect(submission?.launch).not.toHaveProperty("assets");
|
|
expect(JSON.stringify(operations.filter(op => op.kind === "submit"))).not.toContain(root);
|
|
expect(JSON.stringify(operations)).not.toContain("core-secret-not-transferred");
|
|
expect(result.status).toBe(mode === "managed-account" ? "pass" : "fail");
|
|
if (mode === "managed-account") expect(submission).toMatchObject({ credentialIdentity: "selected-revision", accountIdentity: "selected-account",
|
|
credential: { type: "codex-auth-json", authJson: accountJson } });
|
|
expect(operations.some(op => op.kind === "submit")).toBe(true);
|
|
expect(operations.some(op => op.kind === "stop")).toBe(true);
|
|
expect(stopped).toBe(true);
|
|
expect(operations.some(op => op.kind === "complete")).toBe(true);
|
|
} finally { handle?.child.kill("SIGTERM"); await handle?.completion; await rm(root, { recursive: true, force: true }); }
|
|
}, 65_000);
|
|
});
|