mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
feat(runner): support preconfigured user homes for Codex and ACPX (#16486)
## Thinking Path > - Paperclip manages AI agents and their work. > - Runner launches the selected provider and keeps durable session state. > - Runner currently prepares private provider homes for each session. > - Operators can already have working login, skills, and MCP configuration on the execution machine. > - This PR adds an explicit mode that uses that existing setup. > - Runner keeps its task bridge and session ownership while leaving the user's configuration in place. ## Linked Issues or Issue Description **Problem or motivation:** Rebuilding a provider home is unnecessary when the execution account is already configured. **Proposed solution:** Add `homeMode: "preconfigured"` to Runner adapter config and runnerd transport options. Keep `"isolated"` as the default. Initially support native Codex and ACPX Claude. **Alternatives considered:** Continue copying user configuration into a private home, or add an approval workflow. Both add work outside this feature's scope. **Roadmap alignment:** This extends the existing cross-provider Runner. It does not add another runtime. Related work: #14056 covers managed Codex credentials. #14474 covers command environment delivery. This mode uses user-owned credentials and retains the existing launch boundaries. ## What Changed - Add a closed v7 launch contract for the selected home mode. Existing contracts retain their behavior. - Keep homeMode under operator control. Reject task overrides and agent-authenticated changes, including removal through replacements, adapter changes, or rollback. Ignore stored task values and exclude it from automatic hire inheritance. - Skip managed AI login, assigned-skill materialization, and external MCP setup in preconfigured mode. Preserve the Runner task/completion bridge. - Keep protocol state and provider lifetime fences scoped to the Runner session. Different sessions can share a home. - Fail preconfigured ACPX planning/read-only launches before provider admission until a read-only user-home launch is qualified. Use isolated mode or native Codex for planning. - Reject ACPX approval requests. Keep native Codex on `never`. Local permission rules and hooks remain effective. Initially require the default Runner ACPX `approve-all` setting; other Runner policies are unqualified in this mode and fail before launch. - Keep the internal ACPX bridge in a stable session namespace, preserving an existing user MCP server named `paperclip`. - Reject preconfigured ACPX Codex before launch until a session permission profile can enforce Runner-state denial. Remove the unprotected full-access mode switch; use native Codex or isolated mode for that model family. - Bump Pi to profile 23 because its attestation covers shared Runner source changed here. Preserve profile 22 for historical recovery, and update Docker/package verification inputs. - Fence recovery on mode and home bindings. Fail missing provider-history recovery without starting a replacement session. Exclude the external Codex home from backups and credential cleanup. - Discover remote HOME on the execution machine. Do not project controller HOME or XDG paths onto it. - Separate task instructions from Codex ACP isolation and update the generated provider digest. - Keep remote configured-environment manifests consistent when dropping controller paths. Preserve exact Claude model IDs through per-launch environment settings, without editing the user home or patching the provider. ## Verification - Targeted local checks cover native contracts and launch allowlists, user-config preservation, provider admission, permissions, session recovery, remote environment projection, heartbeat preparation, and operator-only configuration. Runner, shared, and server TypeScript checks and the Rust binary build pass; generated ACPX profiles are current. - Following the latest master integration, all 757 server configuration/executor tests and 115 Runner admission/recovery tests passed. Additional review-fix checks passed: 103 planning contract/host tests, 78 provider-backend tests, 95 route/preparation/inheritance tests, 43 task-validator tests, and 43 workspace-policy tests. Counts describe separate runs and can overlap. - The real pinned Claude ACP/SDK regression reproduces exact-model aliasing without the fix (`claude-sonnet-5-5` becomes `sonnet`) and passes with the fix. All 89 adapter tests pass. User settings remain byte-for-byte unchanged. - Localhost-only Claude SDK probes with synthetic credentials verify the private bridge runs, local `ask` reaches the existing reject callback, local `deny` blocks without a callback, and a user MCP server named `paperclip` remains available. No user model allowlist is required. - Review regressions cover fail-closed provider admission, saved-mode recovery before routing, controller-home path removal without configured-environment manifest drift, pre-launch rejection of unqualified read-only ACPX launches, and denial of task/agent changes to the operator-owned home mode. - Before rebase, `d4d4ee2aebe4cb88c4f4d9ef8e22fed4b36b87be` received standard Greptile 5/5 with no unresolved threads. Prior Apex findings are fixed and covered by regressions. No manual Greptile retrigger is requested for this rebase, per reviewer direction. - The prior PR head and master hit the same GitHub runner encoding crash in workspace-b on three PR attempts, including diagnostic logging ([prior PR CI](https://github.com/paperclipai/paperclip/actions/runs/38818474563/job/116714538405), [prior master CI](https://github.com/paperclipai/paperclip/actions/runs/38819528748/job/116712423193)). Rebased onto `origin/master` at `c3ffa3f0dd` to pull in its bridge-test index correction. The rebased head `edd153db410bea063b645e0000aa68649f287b00` passes all CI checks ([CI run](https://github.com/paperclipai/paperclip/actions/runs/38824676914)), including workspace-b. Greptile's automatic standard review is 5/5 on this exact head, with no unresolved threads; no manual review was requested. - Local workspace-b investigation passed 911 shared tests and 3,868 tests across the remaining 15 workspace projects. The catalog run separately hit an existing ignored `.agents/skills/design-guide/SKILL.md` description-budget violation in this checkout; that file is untracked, outside the PR, and left untouched. Repeating shared tests with CI reporting enabled also passed. - Rebase verification: the final Git tree exactly matches the previously reviewed branch combined with current master. All 192 Runner admission/contract tests, 61 server preparation/provider tests, and 68 bridge tests pass, as do Runner/server TypeScript and generated-profile checks. The real Claude adapter fixture hit an `ENOTEMPTY` cleanup race on its initial run; all 89 adapter tests passed on retry. - Apex on rebased head `edd153db` returned 2/5: ACPX Codex selected full access without enforcing Runner-state denial, and agent configuration replacement could remove the operator mode. Both are fixed in `26e14dbbf`. Preconfigured ACPX Codex is temporarily unqualified; re-enabling it requires session permission enforcement on thread/turn operations and a real filesystem-denial probe. The config guard covers effective replacements, adapter changes, and rollback while allowing ordinary agent merges and operator removal. All 190 focused Runner tests, 103 server tests, both typechecks, and generated-profile checks pass. Standard review and CI passed before the next Apex request. - CI on `26e14dbbf` passed 1,967 Runner-shard tests but hit the previously observed `ENOTEMPTY` fixture cleanup race after successful Claude model admission. `eb83284fb` adds bounded filesystem retries to that fixture's teardown; all 87 adapter tests pass. Assertions and production cleanup behavior are unchanged. Head `eb83284fb42ee19a15d9a93b106c1bbb07e1695e` passes all 59 CI checks ([CI run](https://github.com/paperclipai/paperclip/actions/runs/38829500857)) and standard Greptile at 5/5 with no unresolved threads. Apex also completed at **5/5** on that exact head ([Apex result](https://github.com/paperclipai/paperclip/pull/16486#issuecomment-6201559527)). All review threads are resolved, the branch has no merge conflicts, and the PR remains unmerged. - No live paid-provider or remote-machine smoke was performed. The lockfile remains unchanged under the repository's refresh-bot policy; local dependency installation resolved the changed patch hash. ## Risks This explicit opt-in grants Codex tools access to the execution home (read-only in planning), including credentials. Native Codex separately denies Runner-owned protocol state. ACPX Claude retains its existing provider trust boundary, and the isolated default is unchanged. Providers may refresh credentials and update their own metadata. User configuration can block tools or the completion reporter. Existing blocked results and failure diagnostics remain the recovery path. Edit the local settings and retry; no new approval workflow is added. External provider history remains on the execution machine and is not backed up by Runner. Remote HOME discovery requires `getent` and fails explicitly when unavailable. Other harnesses need separate qualification. The Codex ACP bundle digest changes. Existing sessions tied to the old bundle may need a fresh session. No database migration is required. ## Model Used OpenAI Codex, GPT-6, with reasoning and code execution. The exact runtime model ID was not exposed. ## 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 following the feature issue template - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal ticket id - [x] I have run targeted tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented risks above --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
c7ea5c0216
commit
fa9f3f8a08
61 files changed
+1057
-120
No files matched your search
@@ -808,12 +808,61 @@ describe("agent routes adapter validation", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["dotAttachmentAccess", "dotWorkspaceAccess", "dotBindingId"])("refuses an agent granting itself the operator-owned Dot setting %s", async key => {
|
||||
it.each(["dotAttachmentAccess", "dotWorkspaceAccess", "dotBindingId", "homeMode"])("refuses an agent granting itself the operator-owned setting %s", async key => {
|
||||
const app = await createApp({ type: "agent", agentId: "11111111-1111-4111-8111-111111111111", companyId: "company-1", source: "agent_jwt" });
|
||||
const response = await requestApp(app, baseUrl => request(baseUrl).patch("/api/agents/11111111-1111-4111-8111-111111111111").send({ adapterConfig: { [key]: key === "dotBindingId" ? "another-binding" : true } }));
|
||||
const response = await requestApp(app, baseUrl => request(baseUrl).patch("/api/agents/11111111-1111-4111-8111-111111111111").send({ adapterConfig: { [key]: key === "homeMode" ? "preconfigured" : key === "dotBindingId" ? "another-binding" : true } }));
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(403);
|
||||
expect(mockAgentService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(["create", "hire"])("refuses agent-authenticated homeMode on %s even with agent-management permission", async mode => {
|
||||
mockAgentService.getById.mockResolvedValue({ ...(await mockAgentService.getById()), permissions: { canCreateAgents: true } });
|
||||
const app = await createApp({ type: "agent", agentId: "11111111-1111-4111-8111-111111111111", companyId: "company-1", source: "agent_jwt" });
|
||||
const response = await requestApp(app, baseUrl => request(baseUrl).post(mode === "create" ? "/api/companies/company-1/agents" : "/api/companies/company-1/agent-hires")
|
||||
.send({ name: "Runner", role: "engineer", adapterType: "paperclip_runner", adapterConfig: { homeMode: "preconfigured" } }));
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(403);
|
||||
expect(response.body.error).toContain("Only an operator can configure Runner homeMode");
|
||||
expect(mockAgentService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(["replace", "adapter change", "rollback"])("refuses agent removal of operator homeMode through %s", async mode => {
|
||||
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: true });
|
||||
mockAgentService.getById.mockResolvedValue({ ...(await mockAgentService.getById()), adapterType: "paperclip_runner",
|
||||
adapterConfig: { provider: "codex", model: "gpt-6.1-sol", homeMode: "preconfigured" }, permissions: { canCreateAgents: true } });
|
||||
mockAgentService.getConfigRevision.mockResolvedValue({ afterConfig: {
|
||||
adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-6.1-sol" },
|
||||
} });
|
||||
const app = await createApp({ type: "agent", agentId: "11111111-1111-4111-8111-111111111111", companyId: "company-1", source: "agent_jwt" });
|
||||
const response = await requestApp(app, baseUrl => mode === "rollback"
|
||||
? request(baseUrl).post("/api/agents/11111111-1111-4111-8111-111111111111/config-revisions/33333333-3333-4333-8333-333333333333/rollback").send({})
|
||||
: request(baseUrl).patch("/api/agents/11111111-1111-4111-8111-111111111111").send(mode === "replace"
|
||||
? { replaceAdapterConfig: true, adapterConfig: { provider: "codex", model: "gpt-6.1-sol" } }
|
||||
: { adapterType: "claude_local" }));
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(403);
|
||||
expect(response.body.error).toContain("Only an operator can configure Runner homeMode");
|
||||
expect(mockAgentService.update).not.toHaveBeenCalled();
|
||||
expect(mockAgentService.rollbackConfigRevision).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(["agent", "board"] as const)("allows %s ordinary config updates while keeping homeMode operator-owned", async actor => {
|
||||
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: true });
|
||||
mockAgentService.getById.mockResolvedValue({ ...(await mockAgentService.getById()), adapterType: "paperclip_runner",
|
||||
adapterConfig: { provider: "codex", model: "gpt-6.1-sol", homeMode: "preconfigured" }, permissions: { canCreateAgents: true } });
|
||||
const app = actor === "agent"
|
||||
? await createApp({ type: "agent", agentId: "11111111-1111-4111-8111-111111111111", companyId: "company-1", source: "agent_jwt" })
|
||||
: await createApp();
|
||||
const response = await requestApp(app, baseUrl => request(baseUrl).patch("/api/agents/11111111-1111-4111-8111-111111111111")
|
||||
.send({ adapterConfig: { provider: "codex", model: "gpt-6.1-sol" }, replaceAdapterConfig: actor === "board" }));
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(200);
|
||||
const saved = mockAgentService.update.mock.calls.at(-1)?.[1].adapterConfig;
|
||||
expect(saved.homeMode).toBe(actor === "agent" ? "preconfigured" : undefined);
|
||||
});
|
||||
it("allows an operator to configure Runner homeMode", async () => {
|
||||
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: true });
|
||||
mockAgentService.getById.mockResolvedValue({ ...(await mockAgentService.getById()), adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-6.1-sol" } });
|
||||
const app = await createApp();
|
||||
const response = await requestApp(app, baseUrl => request(baseUrl).patch("/api/agents/11111111-1111-4111-8111-111111111111")
|
||||
.send({ adapterConfig: { homeMode: "preconfigured" } }));
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(200);
|
||||
expect(mockAgentService.update).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ adapterConfig: expect.objectContaining({ homeMode: "preconfigured" }) }), expect.anything());
|
||||
});
|
||||
it("allows an operator to configure Dot attachment access separately", async () => {
|
||||
const app = await createApp();
|
||||
const response = await requestApp(app, baseUrl => request(baseUrl).patch("/api/agents/11111111-1111-4111-8111-111111111111").send({ adapterConfig: { dotAttachmentAccess: true, dotWorkspaceAccess: false } }));
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
isUnrunnableWorktreeCombo,
|
||||
issueExecutionWorkspaceModeForPersistedWorkspace,
|
||||
parseIssueExecutionWorkspaceSettings,
|
||||
parseIssueAssigneeAdapterOverrides,
|
||||
parseProjectExecutionWorkspacePolicy,
|
||||
ManagedSandboxUnavailableError,
|
||||
resolveEffectiveWorkspaceStrategyType,
|
||||
@@ -22,6 +23,10 @@ import {
|
||||
} from "../services/execution-workspace-policy.ts";
|
||||
|
||||
describe("execution workspace policy helpers", () => {
|
||||
it("ignores operator-owned homeMode in stored task overrides while retaining task settings", () => {
|
||||
expect(parseIssueAssigneeAdapterOverrides({ adapterConfig: { homeMode: "preconfigured", model: "task-model" } }))
|
||||
.toEqual({ adapterConfig: { model: "task-model" }, useProjectWorkspace: null });
|
||||
});
|
||||
it("defaults new issue settings from enabled project policy", () => {
|
||||
expect(
|
||||
defaultIssueExecutionWorkspaceSettingsForProject({
|
||||
|
||||
@@ -2,20 +2,21 @@ import { enableRunFailureReporting } from "./helpers/run-failure-reporting.js";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { trace } from "@opentelemetry/api";
|
||||
import { InMemorySpanExporter, SimpleSpanProcessor, TracerProvider } from "@opentelemetry/sdk-trace";
|
||||
import { access, readFile, mkdtemp, rm } from "node:fs/promises";
|
||||
import { access, chmod, mkdir, readdir, readFile, mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { agents, companies, companyMemberships, companySkills, createDb, heartbeatRunEvents, heartbeatRuns, issues, projects } from "@paperclipai/db";
|
||||
import { companySkillService } from "../services/company-skills.js";
|
||||
import { secretService } from "../services/secrets.js";
|
||||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
import { withSkillVersionCacheLock } from "../services/skill-version-cache.js";
|
||||
import { prepareHeartbeatExecutionConfiguration } from "../services/heartbeat/execution-configuration.js";
|
||||
import { registerServerAdapter, unregisterServerAdapter, type AdapterExecutionContext } from "../adapters/index.js";
|
||||
import { HttpError } from "../errors.js";
|
||||
import { heartbeatService } from "../services/heartbeat.js";
|
||||
import { bindHeartbeatRunnerHomeMode, prepareHeartbeatExecutionConfiguration, type HeartbeatExecutionConfigurationInput } from "../services/heartbeat/execution-configuration.js";
|
||||
import { aiRuntimeConnectionBindingSchema } from "@paperclipai/shared";
|
||||
import { secretService } from "../services/secrets.js";
|
||||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
import { aiConnectionService } from "../services/ai-connections.js";
|
||||
import * as aiRuntime from "../services/ai-connection-runtime.js";
|
||||
import * as sentry from "../sentry.js";
|
||||
@@ -23,6 +24,27 @@ import { waitForPendingRunFailureReports } from "../services/run-failure-report.
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
|
||||
const skillFailures = vi.hoisted(() => new Map<string, string>());
|
||||
|
||||
describe("Runner home mode during recovery preparation", () => {
|
||||
it.each([
|
||||
["preconfigured", { schema: "paperclip.native-execution-input.v5" }, "isolated"],
|
||||
["preconfigured", { schema: "paperclip.native-execution-input.v7", homeMode: "isolated" }, "isolated"],
|
||||
["isolated", { schema: "paperclip.native-execution-input.v7", homeMode: "preconfigured" }, "preconfigured"],
|
||||
])("prepares the saved mode after current configuration changes to %s", (current, saved, expected) => {
|
||||
const config = { homeMode: current, model: "current-model", env: { TASK_VALUE: "kept" } };
|
||||
// Called before router selection and again after mutable task overrides.
|
||||
const routed = bindHeartbeatRunnerHomeMode(config, saved);
|
||||
const prepared = bindHeartbeatRunnerHomeMode({ ...routed, homeMode: current }, saved);
|
||||
expect(prepared).toEqual({ ...config, homeMode: expected });
|
||||
expect(config.homeMode).toBe(current);
|
||||
});
|
||||
|
||||
it.each(["isolated", "preconfigured", undefined])("uses current mode %s for a new run", homeMode => {
|
||||
const config = { homeMode };
|
||||
expect(bindHeartbeatRunnerHomeMode(config, null)).toBe(config);
|
||||
expect(bindHeartbeatRunnerHomeMode(config, {})).toBe(config);
|
||||
});
|
||||
});
|
||||
vi.mock("../services/company-skills.js", async importOriginal => {
|
||||
const actual = await importOriginal<typeof import("../services/company-skills.js")>();
|
||||
return { ...actual, companySkillService: (...args: Parameters<typeof actual.companySkillService>) => {
|
||||
@@ -92,7 +114,7 @@ describePostgres("heartbeat execution configuration through adapter dispatch", (
|
||||
runtimeConfig: account ? { aiConnection: { provider: "openai", method: "api_key", mode: "delegated", connectionId: account.connectionId, grantId: account.grantId } } : {},
|
||||
});
|
||||
await db.insert(issues).values({ id: issueId, companyId, projectId, title: "Configuration fixture", status: "todo", assigneeAgentId: agentId, responsibleUserId: userId, issueNumber: 1, assigneeAdapterOverrides: { adapterConfig: { model: "issue-model" } } });
|
||||
return { companyId, agentId, issueId, credential };
|
||||
return { companyId, agentId, issueId, userId, credential };
|
||||
}
|
||||
|
||||
async function execute(f: Awaited<ReturnType<typeof seed>>) {
|
||||
@@ -107,6 +129,89 @@ describePostgres("heartbeat execution configuration through adapter dispatch", (
|
||||
return { finished, input: captured.get(run!.id) };
|
||||
}
|
||||
|
||||
it.each([
|
||||
{ saved: "isolated", managed: true, failure: "saved managed login required" },
|
||||
{ saved: "isolated", managed: false, failure: "saved isolated skills required" },
|
||||
{ saved: "preconfigured", managed: true, failure: null },
|
||||
{ saved: null, managed: true, failure: "saved managed login required" },
|
||||
] as const)("retains $saved preparation after configuration changes (managed: $managed)", async ({ saved, managed, failure }) => {
|
||||
const f = await seed(managed);
|
||||
const [agent] = await db.update(agents).set({ adapterType: "paperclip_runner", adapterConfig: {
|
||||
provider: "codex", model: "gpt-6.1-sol", homeMode: saved === "isolated" ? "preconfigured" : "isolated",
|
||||
} }).where(eq(agents.id, f.agentId)).returning();
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: f.agentId,
|
||||
status: "running", runtimeMode: "native", contextSnapshot: { issueId: f.issueId },
|
||||
}).returning();
|
||||
const prepare = vi.spyOn(aiRuntime, "prepareManagedAiRuntime").mockRejectedValue(new Error("saved managed login required"));
|
||||
skillFailures.set(f.companyId, "saved isolated skills required");
|
||||
// Preparation only reads the persisted mode and whether recovery is active.
|
||||
const persisted = saved === null ? null : { schema: saved === "isolated" ? "paperclip.native-execution-input.v5" : "paperclip.native-execution-input.v7",
|
||||
...(saved === "preconfigured" ? { homeMode: saved } : {}),
|
||||
} as unknown as HeartbeatExecutionConfigurationInput["environment"]["persistedNativeExecutionInput"];
|
||||
const opening = prepareHeartbeatExecutionConfiguration(db, {
|
||||
run, agent, task: { issueId: f.issueId, context: {}, responsibleUserId: f.userId,
|
||||
acceptedPlanContinuationWake: false, acceptedPlanWakeRoutingDecision: null, taskProjectContext: null,
|
||||
routineEnvContext: { routineId: null, env: null, responsibleUserId: null } },
|
||||
config: { sourceWorkspaceConfig: { ...agent.adapterConfig, homeMode: "preconfigured" }, aiBinding: managed
|
||||
? aiRuntimeConnectionBindingSchema.parse(agent.runtimeConfig!.aiConnection) : undefined },
|
||||
policy: { trustPreset: { kind: "standard", preset: "standard", boundary: null, sourcePresets: {} },
|
||||
useIsolatedTaskDirectory: false, explicitTaskDirectory: false },
|
||||
environment: { selectedEnvironmentId: null, selectedEnvironmentForConfig: null,
|
||||
resolvedInstanceSettings: await instanceSettingsService(db).get(), persistedNativeExecutionInput: persisted },
|
||||
services: { secretsSvc: secretService(db), companySkills: companySkillService(db), appendRunEvent: vi.fn(), finalizeAiConnectionBusyDeferral: vi.fn() },
|
||||
onFailureSecretsResolved: vi.fn(), onManagedAiRuntimeAcquired: vi.fn(), onIdentityRedactorPrepared: vi.fn(),
|
||||
});
|
||||
if (failure) await expect(opening).rejects.toThrow(failure);
|
||||
else {
|
||||
expect(await opening).toMatchObject({ prepared: true, runtimeConfig: { homeMode: "preconfigured" }, runtimeSkillEntries: [] });
|
||||
expect(prepare).not.toHaveBeenCalled();
|
||||
}
|
||||
if (managed && saved === "isolated") expect(prepare).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each(["agent", "stored task override"] as const)("uses operator preconfigured mode despite %s configuration", async source => {
|
||||
const f = await seed(true);
|
||||
const nativeHome = await mkdtemp(path.join(tmpdir(), "paperclip-preconfigured-preparation-"));
|
||||
const workspace = path.join(nativeHome, "workspace");
|
||||
await mkdir(workspace);
|
||||
process.env.PAPERCLIP_HOME = nativeHome;
|
||||
await db.update(agents).set({ adapterType: "paperclip_runner", adapterConfig: {
|
||||
provider: "codex", model: "gpt-6.1-sol", homeMode: "preconfigured", cwd: workspace,
|
||||
} }).where(eq(agents.id, f.agentId));
|
||||
if (source === "stored task override") {
|
||||
await db.update(issues).set({ assigneeAdapterOverrides: { adapterConfig: { homeMode: "isolated" } } }).where(eq(issues.id, f.issueId));
|
||||
// Old task data cannot change the operator's mode before AI routing.
|
||||
await db.update(agents).set({ runtimeConfig: { aiConnection: { mode: "router", connectionId: "unconfigured-pool" } } }).where(eq(agents.id, f.agentId));
|
||||
}
|
||||
skillFailures.set(f.companyId, "Preconfigured mode must not resolve assigned skills");
|
||||
const prepare = vi.spyOn(aiRuntime, "prepareManagedAiRuntime");
|
||||
const beforeNative = vi.fn(async () => { throw new Error("fixture stopped before provider start"); });
|
||||
const nativeHeartbeat = heartbeatService(db, { beforeNativeRuntimeSelection: beforeNative });
|
||||
try {
|
||||
const run = await nativeHeartbeat.wakeup(f.agentId, { source: "on_demand", triggerDetail: "manual", reason: "manual",
|
||||
payload: { issueId: f.issueId }, contextSnapshot: { issueId: f.issueId } });
|
||||
expect(run).not.toBeNull();
|
||||
await nativeHeartbeat.drainActiveRunExecutions();
|
||||
const saved = await nativeHeartbeat.getRun(run!.id);
|
||||
expect(saved?.error).toContain("fixture stopped before provider start");
|
||||
expect(beforeNative).toHaveBeenCalledOnce();
|
||||
expect(prepare).not.toHaveBeenCalled();
|
||||
expect(saved?.contextSnapshot?.aiConnection).toBeUndefined();
|
||||
} finally {
|
||||
await nativeHeartbeat.drainActiveRunExecutions();
|
||||
process.env.PAPERCLIP_HOME = home;
|
||||
// Native preparation seals context bundles; release only this test's tree.
|
||||
const release = async (directory: string): Promise<void> => {
|
||||
await chmod(directory, 0o700);
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) await release(path.join(directory, entry.name));
|
||||
}
|
||||
};
|
||||
await release(nativeHome);
|
||||
await rm(nativeHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function configuration(f: Awaited<ReturnType<typeof seed>>, config: Record<string, unknown>, snapshot: Record<string, unknown> = {}, type = "paperclip_runner") {
|
||||
const [agent] = await db.update(agents).set({ adapterType: type, adapterConfig: config }).where(eq(agents.id, f.agentId)).returning();
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: f.agentId, issueId: f.issueId, scopeKind: "issue", status: "running", contextSnapshot: snapshot }).returning();
|
||||
|
||||
@@ -90,6 +90,7 @@ export const PAPERCLIP_PUBLIC_SCHEMA_IDS = new Set([
|
||||
"paperclip.native-execution-input.v3",
|
||||
"paperclip.native-execution-input.v4",
|
||||
"paperclip.native-execution-input.v5",
|
||||
"paperclip.native-execution-input.v7",
|
||||
"paperclip.native-finalization.v1",
|
||||
"paperclip.native-finalization.v2",
|
||||
"paperclip.native-harness-backup-stamp.v1",
|
||||
|
||||
@@ -2566,6 +2566,8 @@ export function agentRoutes(
|
||||
adapterConfig: Record<string, unknown>,
|
||||
path = "adapterConfig",
|
||||
) {
|
||||
if (req.actor.type === "agent" && hasOwn(adapterConfig, "homeMode"))
|
||||
throw forbidden("Only an operator can configure Runner homeMode.");
|
||||
if (req.actor.type === "agent" && ["dotAttachmentAccess", "dotWorkspaceAccess", "dotBindingId", "externalBindingId"].some(key => hasOwn(adapterConfig, key)))
|
||||
throw forbidden("Only an operator can configure external-agent pairing, attachment access, or workspace access.");
|
||||
assertNoAgentInstructionsConfigMutation(req, adapterConfig, path);
|
||||
@@ -4098,6 +4100,8 @@ export function agentRoutes(
|
||||
await assertSelectableAdapterType(rollbackAdapterType, rollbackAdapterConfig);
|
||||
}
|
||||
assertNoAgentAdapterConfigMutation(req, rollbackAdapterConfig);
|
||||
if (req.actor.type === "agent" && rollbackAdapterConfig.homeMode !== asRecord(existing.adapterConfig)?.homeMode)
|
||||
throw forbidden("Only an operator can configure Runner homeMode.");
|
||||
assertNoAgentLocalAdapterHostCommandMutation(req, rollbackAdapterType, rollbackAdapterConfig);
|
||||
assertNoAgentProcessAdapterMutation(
|
||||
req,
|
||||
@@ -5316,6 +5320,8 @@ export function agentRoutes(
|
||||
if (requestedAdapterType === "paperclip_runner") {
|
||||
rawEffectiveAdapterConfig = normalizePaperclipRunnerAdapterConfig(requestedAdapterType, rawEffectiveAdapterConfig);
|
||||
}
|
||||
if (req.actor.type === "agent" && rawEffectiveAdapterConfig.homeMode !== existingAdapterConfig.homeMode)
|
||||
throw forbidden("Only an operator can configure Runner homeMode.");
|
||||
if (changingAdapterType || requestedAdapterConfig) {
|
||||
assertNoAgentLocalAdapterHostCommandMutation(
|
||||
req,
|
||||
|
||||
@@ -402,7 +402,9 @@ export function parseIssueAssigneeAdapterOverrides(
|
||||
raw: unknown,
|
||||
): ParsedIssueAssigneeAdapterOverrides | null {
|
||||
const parsed = parseObject(raw);
|
||||
const parsedAdapterConfig = parseObject(parsed.adapterConfig);
|
||||
const parsedAdapterConfig = Object.fromEntries(
|
||||
Object.entries(parseObject(parsed.adapterConfig)).filter(([key]) => key !== "homeMode"),
|
||||
);
|
||||
const adapterConfig =
|
||||
Object.keys(parsedAdapterConfig).length > 0 ? parsedAdapterConfig : null;
|
||||
const useProjectWorkspace =
|
||||
|
||||
@@ -30,6 +30,32 @@ describe("Paperclip Runner native provider configuration", () => {
|
||||
expect(resolvePaperclipRunnerNativeProviderInput({ backend: "codex_app_server",
|
||||
adapterConfig: { provider: "codex", model }, codexCliVersion })).toMatchObject({ model });
|
||||
});
|
||||
it.each(["claude"])("admits preconfigured ACPX %s without changing the permission setting", acpxAgent => {
|
||||
expect(resolvePaperclipRunnerNativeProviderInput({ backend: "acpx_runtime",
|
||||
adapterConfig: { provider: "acpx", acpxAgent, model: acpxAgent === "codex" ? "gpt-5.6-sol" : "claude-sonnet-5", homeMode: "preconfigured" },
|
||||
})).toMatchObject({ homeMode: "preconfigured", acpxPermissionMode: "approve-all" });
|
||||
});
|
||||
it("rejects preconfigured ACPX Codex until Runner-state protection is qualified", () => {
|
||||
expect(() => resolvePaperclipRunnerNativeProviderInput({ backend: "acpx_runtime",
|
||||
adapterConfig: { provider: "acpx", acpxAgent: "codex", model: "gpt-6.1-sol", homeMode: "preconfigured" },
|
||||
})).toThrow("cannot protect Runner state");
|
||||
});
|
||||
it.each(["approve-reads", "approve-paperclip", "deny-all"])("rejects unqualified preconfigured Runner policy %s before launch", acpxPermissionMode => {
|
||||
expect(() => resolvePaperclipRunnerNativeProviderInput({ backend: "acpx_runtime",
|
||||
adapterConfig: { provider: "acpx", acpxAgent: "codex", model: "gpt-6.1-sol", homeMode: "preconfigured", acpxPermissionMode },
|
||||
})).toThrow("currently requires ACPX approve-all");
|
||||
});
|
||||
it("limits the initial mode to qualified native Codex and ACPX Claude providers", () => {
|
||||
expect(resolvePaperclipRunnerNativeProviderInput({ backend: "codex_app_server",
|
||||
adapterConfig: { provider: "codex", homeMode: "preconfigured" },
|
||||
})).toMatchObject({ homeMode: "preconfigured", codexApprovalPolicy: "never" });
|
||||
for (const config of [{ provider: "acpx", acpxAgent: "grok" }, { provider: "opencode", model: "provider/model" }]) {
|
||||
expect(() => resolvePaperclipRunnerNativeProviderInput({ backend: "codex_app_server",
|
||||
adapterConfig: { ...config, homeMode: "preconfigured" },
|
||||
})).toThrow("Preconfigured home supports");
|
||||
}
|
||||
});
|
||||
|
||||
it.each([undefined, "approve-all", "approve-paperclip", "approve-reads", "deny-all"])(
|
||||
"passes Grok's full-auto default or explicit %s policy to the native runner",
|
||||
(acpxPermissionMode) => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { withRunExecutionTiming, measureRunPhase, measureWorkspaceSyncPhase, isW
|
||||
import { createHeartbeatPreparationTiming } from "./heartbeat/preparation-timing.js";
|
||||
import { createHeartbeatInstructionCopyRelease, createHeartbeatInstructionWorkingCopy } from "./heartbeat/instruction-working-copy.js";
|
||||
import { prepareHeartbeatRuntimeServiceIntents, startHeartbeatRuntimeServices, recordHeartbeatAdapterRuntimeServices } from "./heartbeat/runtime-services.js";
|
||||
import { prepareHeartbeatExecutionConfiguration } from "./heartbeat/execution-configuration.js";
|
||||
import { bindHeartbeatRunnerHomeMode, prepareHeartbeatExecutionConfiguration } from "./heartbeat/execution-configuration.js";
|
||||
import { createHeartbeatRunOutput } from "./heartbeat/run-output.js";
|
||||
import { prepareHeartbeatSession, resolveHeartbeatAdapterSession } from "./heartbeat/session-preparation.js";
|
||||
import { prepareHeartbeatTaskContext } from "./heartbeat/task-context.js";
|
||||
@@ -2486,9 +2486,15 @@ export function heartbeatService(
|
||||
taskKey,
|
||||
)
|
||||
: null;
|
||||
const routerHasPersistedInput = Object.keys(parseObject(parseObject(run.runnerProfileJson).nativeExecutionInput)).length > 0;
|
||||
const savedRouterInput = parseObject(parseObject(run.runnerProfileJson).nativeExecutionInput);
|
||||
const routerHasPersistedInput = Object.keys(savedRouterInput).length > 0;
|
||||
if (agent.adapterType === "paperclip_runner") {
|
||||
const homeConfig = bindHeartbeatRunnerHomeMode(config, savedRouterInput);
|
||||
if ("homeMode" in homeConfig) config = { ...config, homeMode: homeConfig.homeMode };
|
||||
}
|
||||
const persistedRouterPoolId = routerHasPersistedInput ? readNonEmptyString(parseObject(context.aiRouterSelection).poolId) : null;
|
||||
const requestedAiBinding = persistedRouterPoolId ? { mode: "router" as const, connectionId: persistedRouterPoolId } : agent.runtimeConfig?.aiConnection ? aiRuntimeConnectionBindingSchema.parse(agent.runtimeConfig.aiConnection) : undefined;
|
||||
const preconfiguredHome = agent.adapterType === "paperclip_runner" && config.homeMode === "preconfigured";
|
||||
const requestedAiBinding = preconfiguredHome ? undefined : persistedRouterPoolId ? { mode: "router" as const, connectionId: persistedRouterPoolId } : agent.runtimeConfig?.aiConnection ? aiRuntimeConnectionBindingSchema.parse(agent.runtimeConfig.aiConnection) : undefined;
|
||||
let aiBinding = requestedAiBinding?.mode === "router" ? undefined : requestedAiBinding;
|
||||
const originalAiIssueOverrides = issueAssigneeOverrides;
|
||||
if (requestedAiBinding?.mode === "router") {
|
||||
|
||||
@@ -40,6 +40,14 @@ const ACCEPTED_PLAN_CONVERSION_SKILL_KEY =
|
||||
type ManagedAiRuntime = Awaited<ReturnType<typeof prepareManagedAiRuntime>>;
|
||||
type EnvironmentSelection = Awaited<ReturnType<typeof selectHeartbeatEnvironment>>;
|
||||
|
||||
/** Preparation and routing must use the same immutable mode as native recovery. */
|
||||
export function bindHeartbeatRunnerHomeMode(config: Record<string, unknown>, persistedExecution: unknown): Record<string, unknown> {
|
||||
const saved = parseObject(persistedExecution);
|
||||
if (Object.keys(saved).length === 0) return config;
|
||||
// Execution schemas before v7 always used isolated homes.
|
||||
return { ...config, homeMode: saved.homeMode ?? "isolated" };
|
||||
}
|
||||
|
||||
export interface HeartbeatExecutionConfigurationInput {
|
||||
run: typeof heartbeatRuns.$inferSelect;
|
||||
agent: typeof agents.$inferSelect;
|
||||
@@ -77,9 +85,14 @@ export async function prepareHeartbeatExecutionConfiguration(db: Db, input: Hear
|
||||
const { run, agent, onFailureSecretsResolved, onManagedAiRuntimeAcquired, onIdentityRedactorPrepared } = input;
|
||||
const time = createHeartbeatPreparationTiming(run.id);
|
||||
const { issueId, context, responsibleUserId, acceptedPlanContinuationWake, acceptedPlanWakeRoutingDecision, taskProjectContext, routineEnvContext } = input.task;
|
||||
const { sourceWorkspaceConfig, aiBinding } = input.config;
|
||||
const { trustPreset, useIsolatedTaskDirectory, explicitTaskDirectory } = input.policy;
|
||||
const { selectedEnvironmentId, selectedEnvironmentForConfig, resolvedInstanceSettings, persistedNativeExecutionInput } = input.environment;
|
||||
const sourceWorkspaceConfig = agent.adapterType === "paperclip_runner"
|
||||
? { ...input.config.sourceWorkspaceConfig,
|
||||
homeMode: bindHeartbeatRunnerHomeMode(parseObject(agent.adapterConfig), persistedNativeExecutionInput).homeMode }
|
||||
: input.config.sourceWorkspaceConfig;
|
||||
const preconfiguredHome = agent.adapterType === "paperclip_runner" && sourceWorkspaceConfig.homeMode === "preconfigured";
|
||||
const aiBinding = preconfiguredHome ? undefined : input.config.aiBinding;
|
||||
const { trustPreset, useIsolatedTaskDirectory, explicitTaskDirectory } = input.policy;
|
||||
const { secretsSvc, companySkills, appendRunEvent, finalizeAiConnectionBusyDeferral } = input.services;
|
||||
let managedAiRuntime: ManagedAiRuntime | undefined;
|
||||
const mergedConfig = {
|
||||
@@ -97,7 +110,7 @@ export async function prepareHeartbeatExecutionConfiguration(db: Db, input: Hear
|
||||
const skillActor: AuthorizationActor = { type: "agent", agentId: agent.id, companyId: agent.companyId,
|
||||
runId: run.id, source: "agent_jwt", onBehalfOfUserId: responsibleUserId === "local-board" ? null : responsibleUserId };
|
||||
const runScopedMentionedSkillKeys =
|
||||
await time("configuration_mentions", () => resolveRunScopedMentionedSkillKeys({
|
||||
preconfiguredHome ? [] : await time("configuration_mentions", () => resolveRunScopedMentionedSkillKeys({
|
||||
db,
|
||||
companyId: agent.companyId,
|
||||
issueId,
|
||||
@@ -215,6 +228,7 @@ export async function prepareHeartbeatExecutionConfiguration(db: Db, input: Hear
|
||||
? run.contextSnapshot?.skillSelectionSnapshot === true && Object.keys(storedVersions).length === 0
|
||||
: agent.adapterType === "paperclip_runner" && runtimeSkillPreference.desiredSkills.length === 0;
|
||||
const prepareSkills = async () => {
|
||||
|
||||
try {
|
||||
const versionSelections = hasStoredVersions
|
||||
? new Map(Object.entries(storedVersions).filter((entry): entry is [string, string | null] => entry[1] === null || typeof entry[1] === "string"))
|
||||
@@ -268,7 +282,7 @@ export async function prepareHeartbeatExecutionConfiguration(db: Db, input: Hear
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
const runtimeSkillEntries = await time("configuration_skills", () => emptySkillSelection
|
||||
const runtimeSkillEntries = preconfiguredHome ? [] : await time("configuration_skills", () => emptySkillSelection
|
||||
? prepareSkills()
|
||||
: withSkillVersionCacheLock(db, agent.companyId, prepareSkills));
|
||||
nativeRunnerPreparationSpans.push({
|
||||
@@ -277,7 +291,7 @@ export async function prepareHeartbeatExecutionConfiguration(db: Db, input: Hear
|
||||
startedAtMs: skillsPrepareStartedAtMs,
|
||||
endedAtMs: Date.now(),
|
||||
});
|
||||
const connectorAssignments = await time("configuration_connector_assignments", () => resolveConnectorAssignments(db, { companyId: agent.companyId, agentId: agent.id, runId: run.id, issueId: typeof context.issueId === "string" ? context.issueId : undefined }));
|
||||
const connectorAssignments = preconfiguredHome ? [] : await time("configuration_connector_assignments", () => resolveConnectorAssignments(db, { companyId: agent.companyId, agentId: agent.id, runId: run.id, issueId: typeof context.issueId === "string" ? context.issueId : undefined }));
|
||||
const connectorSkillConfig = await time("configuration_connector_skills", () => applyConnectorSkills(skillDeliveryConfig, runtimeSkillEntries, connectorAssignments));
|
||||
// Both CLI adapters and native context materialization use the same resolved set.
|
||||
runtimeSkillEntries.splice(0, runtimeSkillEntries.length, ...connectorSkillConfig.paperclipRuntimeSkills);
|
||||
|
||||
@@ -496,7 +496,7 @@ export async function executeHeartbeatRuntime(db: Db, input: HeartbeatRuntimeExe
|
||||
nativeExecution.runtimeContext.mcp.bindingId
|
||||
? nativeExecution.runtimeContext.mcp.digest
|
||||
: null;
|
||||
const nativeMcpServers = await buildPaperclipRuntimeMcpServers({
|
||||
const nativeMcpServers = "homeMode" in nativeExecution && nativeExecution.homeMode === "preconfigured" ? [] : await buildPaperclipRuntimeMcpServers({
|
||||
db,
|
||||
agent,
|
||||
runId: run.id,
|
||||
|
||||
@@ -3,6 +3,9 @@ import { inheritNativeRunnerAdapterConfig } from "./native-agent-runtime-inherit
|
||||
import { resolvePaperclipRunnerProviderProfile } from "./provider-profile.js";
|
||||
|
||||
describe("native hire runtime inheritance", () => {
|
||||
it("does not inherit operator-owned home mode into a hired agent", () => {
|
||||
expect(inheritNativeRunnerAdapterConfig({ provider: "codex", homeMode: "preconfigured" })).toEqual({ provider: "codex" });
|
||||
});
|
||||
it("preserves Cursor's selected mode without its live session or credentials", () => {
|
||||
expect(inheritNativeRunnerAdapterConfig({
|
||||
provider: "acpx", acpxAgent: "cursor", acpxSessionMode: "plan", model: "exact-cursor-model",
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
NativeExecutionInputV5,
|
||||
NativeExecutionInputV6,
|
||||
DotBindingSnapshot,
|
||||
NativeExecutionInputV7,
|
||||
NativeCompletionSource,
|
||||
NativeInteractionResponseEnvelope,
|
||||
NativeOpenCodePermissionMode,
|
||||
@@ -74,6 +75,7 @@ export interface BuildNativeExecutionInput {
|
||||
provider?: "codex" | "opencode" | "claude_managed" | "aws_agentcore" | "acpx" | "openai_dot" | "grok_bot";
|
||||
dotBinding?: DotBindingSnapshot;
|
||||
acpxAgent?: NativeAcpxAgent;
|
||||
homeMode?: "isolated" | "preconfigured";
|
||||
codexApprovalPolicy?: NativeCodexApprovalPolicy;
|
||||
codexReasoningEffort?: string;
|
||||
opencodePermissionMode?: NativeOpenCodePermissionMode;
|
||||
@@ -109,9 +111,9 @@ export interface BuildNativeExecutionInput {
|
||||
runtimeContext: NativeRuntimeContextSnapshot;
|
||||
}
|
||||
export function buildNativeExecutionInput(input: BuildNativeExecutionInput & { provider: "openai_dot" | "grok_bot"; dotBinding: DotBindingSnapshot }): NativeExecutionInputV6;
|
||||
export function buildNativeExecutionInput(input: BuildNativeExecutionInput & { provider?: Exclude<BuildNativeExecutionInput["provider"], "openai_dot" | "grok_bot"> }): NativeExecutionInputV5;
|
||||
export function buildNativeExecutionInput(input: BuildNativeExecutionInput): NativeExecutionInputV5 | NativeExecutionInputV6;
|
||||
export function buildNativeExecutionInput(input: BuildNativeExecutionInput): NativeExecutionInputV5 | NativeExecutionInputV6 {
|
||||
export function buildNativeExecutionInput(input: BuildNativeExecutionInput & { provider?: Exclude<BuildNativeExecutionInput["provider"], "openai_dot" | "grok_bot"> }): NativeExecutionInputV5 | NativeExecutionInputV7;
|
||||
export function buildNativeExecutionInput(input: BuildNativeExecutionInput): NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7;
|
||||
export function buildNativeExecutionInput(input: BuildNativeExecutionInput): NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7 {
|
||||
if (input.issue.workMode !== "standard" && input.issue.workMode !== "planning" && input.issue.workMode !== "ask") {
|
||||
throw new Error("native_execution_input_invalid: issue work mode must be standard, planning, or ask");
|
||||
}
|
||||
@@ -211,7 +213,8 @@ export function buildNativeExecutionInput(input: BuildNativeExecutionInput): Nat
|
||||
? verifiedCompletionSources(input.turnContext, input.completionContract.sources ?? [])
|
||||
: [];
|
||||
const prepared = {
|
||||
schema: "paperclip.native-execution-input.v5",
|
||||
schema: input.homeMode === "preconfigured" ? "paperclip.native-execution-input.v7" : "paperclip.native-execution-input.v5",
|
||||
...(input.homeMode === "preconfigured" ? { homeMode: input.homeMode } : {}),
|
||||
...((input.initialCommunicationGuidance || input.freshSessionHandoff) ? {
|
||||
initialCommunicationGuidance: [input.initialCommunicationGuidance, input.freshSessionHandoff].filter(Boolean).join("\n\n"),
|
||||
} : {}),
|
||||
@@ -334,7 +337,7 @@ export function buildNativeExecutionInput(input: BuildNativeExecutionInput): Nat
|
||||
schema: "paperclip.native-execution-input.v6", provider: { kind: input.provider, model: null, binding: input.dotBinding },
|
||||
workspace: { access: "none", cwd: null, repoUrl: null, repoRef: null, branchName: null },
|
||||
session: { ...prepared.session, driverKind: input.provider === "grok_bot" ? "grok_bot_mcp" : "openai_dot_mcp", lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null } }
|
||||
} : prepared) as NativeExecutionInputV5 | NativeExecutionInputV6;
|
||||
} : prepared) as NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { trace } from "@opentelemetry/api";
|
||||
import { InMemorySpanExporter, SimpleSpanProcessor, TracerProvider } from "@opentelemetry/sdk-trace";
|
||||
import { configuredEnvironmentProjection } from "../../vendor/paperclip-runner/index.js";
|
||||
import { configuredEnvironment, configuredEnvironmentProjection } from "../../vendor/paperclip-runner/index.js";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { PgDialect } from "drizzle-orm/pg-core";
|
||||
@@ -1344,7 +1344,7 @@ describe("remote provider pack manifest", () => {
|
||||
claude:
|
||||
"sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a",
|
||||
codex:
|
||||
"sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3",
|
||||
"sha256:443491f4e814501a5dc47347e4510e84faeec9c9382e2d4e53bfe69386d5aabc",
|
||||
},
|
||||
artifacts: {
|
||||
grokLauncher: { path: "dist/providers/grok/launcher.cjs", sha256: digest(grokLauncher) },
|
||||
@@ -1554,6 +1554,14 @@ describe("provider pack read diagnostics", () => {
|
||||
});
|
||||
|
||||
describe("native harness persistence profiles", () => {
|
||||
it("does not back up the external Codex home in preconfigured mode", () => {
|
||||
const execution = { homeMode: "preconfigured", provider: { kind: "codex" }, session: {
|
||||
driverKind: "codex_app_server", normalizedSessionId: "session", protocolVersion: 1,
|
||||
lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null },
|
||||
} } as unknown as NativeExecutionInputV1;
|
||||
expect(resolveNativeHarnessPersistenceProfile(execution).directories.map(directory => directory.name)).toEqual(["runner"]);
|
||||
});
|
||||
|
||||
const profile = (provider: Record<string, unknown>, driverKind: string) =>
|
||||
resolveNativeHarnessPersistenceProfile({
|
||||
provider,
|
||||
@@ -12438,6 +12446,32 @@ describe("runnerd provider runtime wiring", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("removes controller path bindings from the remote preconfigured environment manifest", async () => {
|
||||
const paths = { CLAUDE_CONFIG_DIR: "/controller/claude", XDG_CONFIG_HOME: "/controller/config",
|
||||
XDG_DATA_HOME: "/controller/data", XDG_CACHE_HOME: "/controller/cache" };
|
||||
const runnerEnvironment = { ...configuredEnvironmentProjection({ ...paths, TASK_FLAG: "kept" }),
|
||||
HOME: "/controller/home", CODEX_HOME: "/controller/codex", AMBIENT_FLAG: "not-task-bound" };
|
||||
const remoteExecution = { ...execution, schema: "paperclip.native-execution-input.v7" as const,
|
||||
homeMode: "preconfigured" as const, executionMode: "default" as const, planningContext: null,
|
||||
provider: { kind: "codex" as const, model: null, approvalPolicy: "never" as const }, runtimeContext: nativeRuntimeContextFixture() };
|
||||
const remoteExecute = vi.fn(async (command: { args?: string[] }) => ({
|
||||
exitCode: 0, timedOut: false, stderr: "", stdout: command.args?.[1]?.includes("getent passwd") ? "/home/executor\n" : "",
|
||||
}));
|
||||
await createRunnerdBackend({ db: leaseDb(remoteExecution), execution: remoteExecution, runnerInstanceId: "remote-home-manifest",
|
||||
runnerEnvironment, runnerIngressAuthorized: true, runnerExecutionTarget: {
|
||||
kind: "remote", transport: "sandbox", remoteCwd: "/workspace", environmentId: "environment",
|
||||
leaseId: "remote-home-manifest", providerKey: "daytona", effectiveCapabilities: { runnerWebSocketIngress: true },
|
||||
runner: { execute: remoteExecute, syncIn: vi.fn(async () => undefined) },
|
||||
} as never });
|
||||
state.createBackend.mock.calls.at(-1)![1].codexTransportFactory!();
|
||||
const environment = (state.createTransport.mock.calls.at(-1)![0] as RunnerTransportOptions & { environment: NodeJS.ProcessEnv }).environment;
|
||||
expect(environment.HOME).toBe("/home/executor");
|
||||
expect(environment.CODEX_HOME).toBeUndefined();
|
||||
for (const key of Object.keys(paths)) expect(environment[key]).toBeUndefined();
|
||||
expect(configuredEnvironment(environment)).toEqual({ TASK_FLAG: "kept", PAPERCLIP_CONFIGURED_ENV_KEYS: '["TASK_FLAG"]' });
|
||||
expect(runnerEnvironment).toMatchObject(paths);
|
||||
});
|
||||
|
||||
it("archives failover evidence with an explicitly replaced provider session", async () => {
|
||||
const remoteCwd = join(isolatedStateDirectory, "remote");
|
||||
const remoteExecute = vi.fn(async (command: { command: string; args?: string[] }) => {
|
||||
|
||||
@@ -64,7 +64,7 @@ import {
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { tmpdir, userInfo } from "node:os";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { basename, dirname, join, posix, resolve } from "node:path";
|
||||
import type {
|
||||
@@ -713,6 +713,7 @@ const NATIVE_PROVIDER_HOST_ENV_KEYS = [
|
||||
"TEMP",
|
||||
"TMP",
|
||||
"CODEX_HOME",
|
||||
"CLAUDE_CONFIG_DIR",
|
||||
"XDG_CONFIG_HOME",
|
||||
"XDG_CACHE_HOME",
|
||||
"XDG_DATA_HOME",
|
||||
@@ -5392,6 +5393,7 @@ function nativeSessionConfigDigest(
|
||||
provider: execution.provider,
|
||||
driverKind: execution.session.driverKind,
|
||||
lifecyclePolicy: execution.session.lifecyclePolicy,
|
||||
...("homeMode" in execution ? { homeMode: execution.homeMode } : {}),
|
||||
executionMode:
|
||||
"executionMode" in execution ? execution.executionMode : "default",
|
||||
runtimeContextDigest:
|
||||
@@ -5485,7 +5487,7 @@ export function resolveNativeHarnessPersistenceProfile(
|
||||
): NativeHarnessPersistenceProfile {
|
||||
const providerDirectory: NativeHarnessPersistenceDirectory | null =
|
||||
execution.provider.kind === "codex"
|
||||
? {
|
||||
? ("homeMode" in execution && execution.homeMode === "preconfigured" ? null : {
|
||||
name: "codex-home",
|
||||
location: "filesystem",
|
||||
// Codex session history lives below this home, but these files are
|
||||
@@ -5494,7 +5496,7 @@ export function resolveNativeHarnessPersistenceProfile(
|
||||
// bearer token. Re-materialize both for a replacement sandbox
|
||||
// instead of putting credentials into the disaster-recovery copy.
|
||||
excludeEntries: CODEX_HOME_NON_PERSISTENT_ENTRIES,
|
||||
}
|
||||
})
|
||||
: execution.provider.kind === "opencode"
|
||||
? {
|
||||
name: "opencode",
|
||||
@@ -12792,6 +12794,7 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
},
|
||||
}
|
||||
: input.execution;
|
||||
const preconfiguredHome = "homeMode" in input.execution && input.execution.homeMode === "preconfigured";
|
||||
const isGrok = input.execution.provider.kind === "acpx" && input.execution.provider.agent === "grok";
|
||||
let effectiveRunnerEnvironmentBase: NodeJS.ProcessEnv = {
|
||||
...(isGrok
|
||||
@@ -12814,6 +12817,32 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
// Never let an agent, environment binding, or host variable disable the
|
||||
// Codex sandbox for a local runner by supplying the same key.
|
||||
delete effectiveRunnerEnvironmentBase.PAPERCLIP_RUNNER_EXTERNAL_SANDBOX;
|
||||
delete effectiveRunnerEnvironmentBase.PAPERCLIP_RUNNER_HOME_MODE;
|
||||
if (preconfiguredHome) {
|
||||
if (remoteCommandRunner) {
|
||||
const discovered = await remoteCommandRunner.execute({
|
||||
command: "sh", args: ["-c", 'getent passwd "$(id -u)" | cut -d: -f6'],
|
||||
bypassSession: true, timeoutMs: 10_000,
|
||||
});
|
||||
const home = discovered.stdout.trim();
|
||||
if (discovered.exitCode !== 0 || discovered.timedOut || !home.startsWith("/") || /[\0\r\n]/.test(home)) {
|
||||
throw new Error("runner_preconfigured_remote_home_unavailable");
|
||||
}
|
||||
// Paths from the controller's account cannot select a remote account's config.
|
||||
const hostPathKeys = new Set(["CODEX_HOME", "CLAUDE_CONFIG_DIR", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME"]);
|
||||
const configured = configuredEnvironment(effectiveRunnerEnvironmentBase);
|
||||
for (const key of hostPathKeys) delete effectiveRunnerEnvironmentBase[key];
|
||||
if (configured.PAPERCLIP_CONFIGURED_ENV_KEYS !== undefined) {
|
||||
effectiveRunnerEnvironmentBase.PAPERCLIP_CONFIGURED_ENV_KEYS = JSON.stringify(
|
||||
Object.keys(configured).filter(key => key !== "PAPERCLIP_CONFIGURED_ENV_KEYS" && !hostPathKeys.has(key)),
|
||||
);
|
||||
}
|
||||
effectiveRunnerEnvironmentBase.HOME = home;
|
||||
} else {
|
||||
effectiveRunnerEnvironmentBase.HOME ??= userInfo().homedir;
|
||||
}
|
||||
}
|
||||
if (preconfiguredHome) effectiveRunnerEnvironmentBase.PAPERCLIP_RUNNER_HOME_MODE = "preconfigured";
|
||||
const effectiveRunnerEnvironment: NodeJS.ProcessEnv = remoteRuntimeRoot
|
||||
? {
|
||||
...effectiveRunnerEnvironmentBase,
|
||||
@@ -12821,8 +12850,10 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
// Codex's permission profile explicitly denies HOME and CODEX_HOME. If
|
||||
// either points at remoteCwd, that deny rule shadows the workspace write
|
||||
// grant and the provider cannot initialize its shell sandbox or edit.
|
||||
HOME: posix.join(remoteRunnerFilesystemRoot!, "codex-home"),
|
||||
CODEX_HOME: posix.join(remoteRunnerFilesystemRoot!, "codex-home"),
|
||||
...(preconfiguredHome ? {} : {
|
||||
HOME: posix.join(remoteRunnerFilesystemRoot!, "codex-home"),
|
||||
CODEX_HOME: posix.join(remoteRunnerFilesystemRoot!, "codex-home"),
|
||||
}),
|
||||
PAPERCLIP_WORKSPACE_CWD: remoteTarget!.remoteCwd,
|
||||
...(remoteTarget!.transport === "sandbox"
|
||||
? { PAPERCLIP_RUNNER_EXTERNAL_SANDBOX: "1" }
|
||||
@@ -12946,6 +12977,7 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
codexTransportFactory: (recoveryContext) =>
|
||||
createRunnerdCodexTransport({
|
||||
onSpawn: input.onSpawn,
|
||||
homeMode: preconfiguredHome ? "preconfigured" : "isolated",
|
||||
onCredentialsUpdated: (event) => appendNativeCredentialUpdate(input.db, {
|
||||
companyId: input.execution.binding.companyId,
|
||||
agentId: input.execution.binding.agentId,
|
||||
@@ -12977,7 +13009,8 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
piThinkingLevel: input.execution.provider.piThinkingLevel,
|
||||
acpxPermissionModePinned:
|
||||
input.execution.schema === "paperclip.native-execution-input.v4" ||
|
||||
input.execution.schema === "paperclip.native-execution-input.v5",
|
||||
input.execution.schema === "paperclip.native-execution-input.v5" ||
|
||||
input.execution.schema === "paperclip.native-execution-input.v7",
|
||||
acpxRuntimeDirectory: remoteRunnerFilesystemRoot
|
||||
? posix.join(remoteRunnerFilesystemRoot, "acpx")
|
||||
: resolve(
|
||||
@@ -13232,7 +13265,7 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
},
|
||||
});
|
||||
}
|
||||
if (!input.managedAiCredentialHome || input.execution.provider.kind !== "codex" || boundManagedSessions.has(session)) return session;
|
||||
if (preconfiguredHome || !input.managedAiCredentialHome || input.execution.provider.kind !== "codex" || boundManagedSessions.has(session)) return session;
|
||||
boundManagedSessions.add(session);
|
||||
const remoteAuth = remoteRunnerFilesystemRoot ? posix.join(remoteRunnerFilesystemRoot, "codex-home", "auth.json") : null;
|
||||
const localAuth = join(root, "codex-home", "auth.json");
|
||||
|
||||
@@ -530,6 +530,7 @@ export function rebindNativeSessionCheckpoint(input: {
|
||||
("executionMode" in current ? current.executionMode : "default") ||
|
||||
!sameProvider(previousExecution.provider, current.provider) ||
|
||||
previousExecution.schema !== current.schema ||
|
||||
("homeMode" in previousExecution ? previousExecution.homeMode : "isolated") !== ("homeMode" in current ? current.homeMode : "isolated") ||
|
||||
("runtimeContext" in previousExecution &&
|
||||
"runtimeContext" in current &&
|
||||
previousExecution.runtimeContext.aggregateDigest !== current.runtimeContext.aggregateDigest &&
|
||||
|
||||
@@ -77,6 +77,7 @@ export type PaperclipRunnerNativeProviderInput =
|
||||
| {
|
||||
provider: "codex";
|
||||
model: string | null;
|
||||
homeMode?: "isolated" | "preconfigured";
|
||||
codexApprovalPolicy: "never" | "on-request" | "untrusted";
|
||||
codexReasoningEffort?: string;
|
||||
}
|
||||
@@ -129,6 +130,7 @@ export type PaperclipRunnerNativeProviderInput =
|
||||
provider: "acpx";
|
||||
model: string;
|
||||
acpxAgent: AdmittedPaperclipRunnerAcpxAgent;
|
||||
homeMode?: "isolated" | "preconfigured";
|
||||
acpxPermissionMode: "approve-all" | "approve-paperclip" | "approve-reads" | "deny-all";
|
||||
acpxSessionMode?: "agent" | "plan" | "ask";
|
||||
piThinkingLevel?: "off" | "low" | "high" | "max";
|
||||
@@ -564,6 +566,19 @@ export function resolvePaperclipRunnerNativeProviderInput(input: {
|
||||
}): PaperclipRunnerNativeProviderInput {
|
||||
const config = asRecord(input.adapterConfig);
|
||||
const profile = resolvePaperclipRunnerProviderProfile(config);
|
||||
if (config.homeMode !== undefined && config.homeMode !== "isolated" && config.homeMode !== "preconfigured") {
|
||||
throw new PaperclipRunnerProviderProfileError("paperclip_runner_home_mode_invalid", "homeMode must be isolated or preconfigured");
|
||||
}
|
||||
const preconfigured = config.homeMode === "preconfigured";
|
||||
if (preconfigured && profile.provider !== "codex" && !(profile.provider === "acpx" && ["claude", "codex"].includes(profile.acpxAgent))) {
|
||||
throw new PaperclipRunnerProviderProfileError("paperclip_runner_home_mode_unsupported", "Preconfigured home supports native Codex and ACPX Claude only");
|
||||
}
|
||||
if (preconfigured && profile.provider === "acpx" && config.acpxPermissionMode !== undefined && config.acpxPermissionMode !== "approve-all") {
|
||||
throw new PaperclipRunnerProviderProfileError("paperclip_runner_home_permission_mode_unsupported", "Preconfigured home currently requires ACPX approve-all; local permission rules still apply");
|
||||
}
|
||||
if (preconfigured && profile.provider === "acpx" && profile.acpxAgent === "codex") {
|
||||
throw new PaperclipRunnerProviderProfileError("paperclip_runner_home_state_boundary_unsupported", "preconfigured ACPX Codex cannot protect Runner state; use native Codex or isolated mode");
|
||||
}
|
||||
if (profile.backend !== input.backend) {
|
||||
throw new PaperclipRunnerProviderProfileError(
|
||||
"paperclip_runner_provider_changed",
|
||||
@@ -597,6 +612,7 @@ export function resolvePaperclipRunnerNativeProviderInput(input: {
|
||||
...(profile.acpxAgent === "cursor" ? {
|
||||
acpxSessionMode: resolvePaperclipRunnerCursorMode("acpx", "cursor", config.acpxSessionMode),
|
||||
} : {}),
|
||||
...(preconfigured ? { homeMode: "preconfigured" as const } : {}),
|
||||
acpxPermissionMode: resolvePaperclipRunnerPermissionMode(
|
||||
"acpx",
|
||||
config.acpxPermissionMode,
|
||||
@@ -769,6 +785,7 @@ export function resolvePaperclipRunnerNativeProviderInput(input: {
|
||||
return {
|
||||
provider: "codex",
|
||||
model: compatibleCodexModel(profile.model, input.codexCliVersion ?? null),
|
||||
...(preconfigured ? { homeMode: "preconfigured" as const } : {}),
|
||||
codexApprovalPolicy: resolvePaperclipRunnerPermissionMode(
|
||||
"codex",
|
||||
config.codexPermissionMode,
|
||||
|
||||
@@ -324,6 +324,18 @@ describe("buildNativeRuntimeContext", () => {
|
||||
expect(repeated.skills[0]!.bundle.rootPath).toBe(context.skills[0]!.bundle.rootPath);
|
||||
});
|
||||
|
||||
it("does not materialize assigned skills or external MCP in preconfigured mode", async () => {
|
||||
serviceMocks.exportFiles.mockResolvedValue({ entryFile: "AGENTS.md", files: { "AGENTS.md": "Test\n" } });
|
||||
const context = await buildNativeRuntimeContext({ db: {} as Db,
|
||||
agent: { id: "agent-1", companyId: "company-1", name: "Reviewer", adapterType: "paperclip_runner", adapterConfig: {} },
|
||||
runId: "run-1", runtimeConfig: { homeMode: "preconfigured", paperclipSkillSync: { desiredSkills: ["missing"] } },
|
||||
runtimeSkillEntries: [{ key: "missing", runtimeName: "missing", source: "/does/not/exist", sourceStatus: "missing" }],
|
||||
});
|
||||
expect(context.skills).toEqual([]);
|
||||
expect(context.mcp.bindingId).toBeNull();
|
||||
expect(serviceMocks.getEffectiveProfilesForAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails closed for a missing assigned skill and omits a stale legacy operational skill", async () => {
|
||||
serviceMocks.exportFiles.mockResolvedValue({ entryFile: "AGENTS.md", files: { "AGENTS.md": "Test\n" } });
|
||||
const base = {
|
||||
|
||||
@@ -251,10 +251,12 @@ export async function resolveNativeRuntimeMcpSnapshot(input: { db: Db; agent: Pi
|
||||
}
|
||||
|
||||
export async function buildNativeRuntimeContext(input: { db: Db; agent: RuntimeAgent; runId: string; runtimeConfig: Record<string, unknown>; runtimeSkillEntries: PaperclipSkillEntry[]; instructionWorkingCopy?: { rootPath: string; entryPath: string; kind?: "agent_files" } }): Promise<NativeRuntimeContextSnapshot> {
|
||||
const preconfigured = input.runtimeConfig.homeMode === "preconfigured";
|
||||
const emptyMcpDigest = sha256("preconfigured");
|
||||
const [instructions, skills, mcp] = await Promise.all([
|
||||
measureRunPhase("runtime_context.instructions", () => materializeInstructionBundle(input.db, input.agent, input.instructionWorkingCopy?.kind === "agent_files")),
|
||||
measureRunPhase("runtime_context.skills", () => materializeSelectedSkills(input.runtimeConfig, input.runtimeSkillEntries, input.agent.adapterType === "paperclip_runner")),
|
||||
measureRunPhase("runtime_context.mcp", () => resolveNativeRuntimeMcpSnapshot({ db: input.db, agent: input.agent, runId: input.runId })),
|
||||
measureRunPhase("runtime_context.skills", () => preconfigured ? [] : materializeSelectedSkills(input.runtimeConfig, input.runtimeSkillEntries, input.agent.adapterType === "paperclip_runner")),
|
||||
measureRunPhase("runtime_context.mcp", () => preconfigured ? { assignmentSetId: `sha256:${emptyMcpDigest}`, digest: emptyMcpDigest, bindingId: null } : resolveNativeRuntimeMcpSnapshot({ db: input.db, agent: input.agent, runId: input.runId })),
|
||||
]);
|
||||
const snapshot = {
|
||||
prompt: { revision: PAPERCLIP_EXECUTION_PROMPT_REVISION, text: PAPERCLIP_EXECUTION_PROMPT, digest: nativeRuntimePromptDigest() },
|
||||
|
||||
+1
@@ -39,6 +39,7 @@ export type {
|
||||
DotBindingSnapshot,
|
||||
ExternalProviderPort,
|
||||
ExternalProviderOperation,
|
||||
NativeExecutionInputV7,
|
||||
NativeCompletionSource,
|
||||
NativeCompletionSources,
|
||||
NativeInteractionResponseEnvelope,
|
||||
|
||||
Reference in new issue
Block a user