mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
Merge master and preserve preconfigured home dispatch boundary
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
9b28865bb3
61 files changed
+1057
-120
No files matched your search
@@ -53,6 +53,7 @@ packages/paperclip-runner/test-fixtures/pi-acp/**
|
||||
!packages/paperclip-runner/test-fixtures/pi-acp/profile-v20-identity.json
|
||||
!packages/paperclip-runner/test-fixtures/pi-acp/profile-v21-identity.json
|
||||
!packages/paperclip-runner/test-fixtures/pi-acp/profile-v22-identity.json
|
||||
!packages/paperclip-runner/test-fixtures/pi-acp/profile-v23-identity.json
|
||||
!packages/paperclip-runner/docs/capability-contract.md
|
||||
# check:runner-workflow-traceability access()es every regression test the
|
||||
# stress-traceability spec names — those are src/**/*.test.ts files, so
|
||||
|
||||
@@ -745,3 +745,43 @@ fit the compacted bound produces a diagnostic; the event stream continues.
|
||||
Tool output and error fields have reserved retention space and size records,
|
||||
so large input values cannot consume their output tails or make excerpts look
|
||||
complete. A separator split between reads does not count against the frame limit.
|
||||
## Preconfigured home
|
||||
|
||||
Set `homeMode: "preconfigured"` in a `paperclip_runner` agent's adapter config
|
||||
(or the runnerd transport options) to use the execution account's existing home.
|
||||
The default is `"isolated"`. The initial supported providers are native Codex
|
||||
and ACPX Claude. Preconfigured ACPX Codex fails before launch until its session
|
||||
permissions can enforce denial of Runner state; use native Codex or isolated mode.
|
||||
An operator must select this mode on the agent/Runner
|
||||
configuration. Task-level overrides are rejected, and agent credentials cannot
|
||||
set or inherit this option. Other providers reject this option. The internal ACPX task bridge uses a stable
|
||||
session name so an existing user MCP server named `paperclip` remains available.
|
||||
|
||||
Prepare authentication, skills, and external MCP servers on the execution machine
|
||||
before launching. Runner does not install skills, copy or remove credentials, or
|
||||
rewrite user configuration in this mode. Providers can still refresh credentials
|
||||
and update their own metadata. Runner still supplies the requested
|
||||
model, task instructions, and its internal task/completion tools. Local permission
|
||||
rules and hooks apply. Native Codex tools can read and modify the execution home
|
||||
(read-only for planning); credentials in that home are not isolated from tools.
|
||||
Native Codex separately denies Runner-owned protocol state; ACPX Claude retains
|
||||
its existing provider trust boundary. Execution is unattended: native Codex uses `never`, and
|
||||
ACPX rejects requests for approval. The initial ACPX mode requires the Runner
|
||||
setting `acpxPermissionMode: "approve-all"` (its default); other Runner modes are
|
||||
unqualified here and fail before launch. Local permission rules still apply.
|
||||
Preconfigured ACPX read-only/planning tasks also fail before provider launch
|
||||
until a read-only launch is qualified; use isolated mode or native Codex for planning.
|
||||
If local settings block a task, change them
|
||||
and retry. Existing blocked results and failure diagnostics report the outcome;
|
||||
a hook that also blocks the reporting tool can leave only a generic diagnostic.
|
||||
|
||||
Runner retains private protocol/session state and a fence for each session.
|
||||
Different sessions may share the same home. Mode and home bindings fence recovery;
|
||||
switching either requires a new session. Backups never include the external home.
|
||||
Recovering a run uses its saved mode even if the agent configuration has changed.
|
||||
Remote execution discovers the execution account's home on the remote machine
|
||||
using `getent`; it does not copy the controller's HOME, XDG paths, or login state.
|
||||
The external provider history must still exist to resume a session after a remote
|
||||
machine is replaced. Missing history fails recovery rather than importing an
|
||||
isolated home. OpenCode and the remaining ACPX profiles require separate
|
||||
qualification before this mode can be enabled for them.
|
||||
@@ -14,12 +14,12 @@
|
||||
"requiresProviderPolicy": false
|
||||
},
|
||||
"pi": {
|
||||
"agentProfileVersion": 22,
|
||||
"agentProfileVersion": 23,
|
||||
"agentServerPackage": "pi-acp",
|
||||
"agentServerVersion": "0.0.33",
|
||||
"agentRuntimePackage": "@earendil-works/pi-coding-agent",
|
||||
"agentRuntimeVersion": "1.0.0",
|
||||
"commandDigest": "sha256:e92078bee3c23bec4100aa589013a44613d054cd686826534025d8019e9f39a9",
|
||||
"commandDigest": "sha256:18f0f91f179745db6f63aa0d2c710018b884759fa78440d7b0d3cd18b785b4d9",
|
||||
"requiresProviderPolicy": true
|
||||
},
|
||||
"cursor": {
|
||||
@@ -56,7 +56,7 @@
|
||||
"agentServerVersion": "1.6.2",
|
||||
"agentRuntimePackage": "@openai/codex",
|
||||
"agentRuntimeVersion": "0.160.0",
|
||||
"commandDigest": "sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3",
|
||||
"commandDigest": "sha256:443491f4e814501a5dc47347e4510e84faeec9c9382e2d4e53bfe69386d5aabc",
|
||||
"requiresProviderPolicy": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3184,7 +3184,7 @@ mod tests {
|
||||
#[test]
|
||||
fn pi_profile_matches_published_identity_and_rejects_prior_profiles() {
|
||||
let published: Value = serde_json::from_str(include_str!(
|
||||
"../../../../test-fixtures/pi-acp/profile-v22-identity.json"
|
||||
"../../../../test-fixtures/pi-acp/profile-v23-identity.json"
|
||||
))
|
||||
.unwrap();
|
||||
let mut value = descriptor("codex");
|
||||
|
||||
@@ -278,6 +278,17 @@ impl AcpxSidecarTransport {
|
||||
Vec::new()
|
||||
};
|
||||
keys.extend(pi_keys.iter().map(String::as_str));
|
||||
if std::env::var("PAPERCLIP_RUNNER_HOME_MODE").as_deref() == Ok("preconfigured") {
|
||||
keys.extend_from_slice(&[
|
||||
"PAPERCLIP_RUNNER_HOME_MODE",
|
||||
"ACPX_CLAUDE_INCLUDE_USER_SETTINGS",
|
||||
"CODEX_HOME",
|
||||
"CLAUDE_CONFIG_DIR",
|
||||
"XDG_CONFIG_HOME",
|
||||
"XDG_DATA_HOME",
|
||||
"XDG_CACHE_HOME",
|
||||
]);
|
||||
}
|
||||
keys.extend_from_slice(credential_keys);
|
||||
// Pi owns a native distribution copy, including a pending refresh at
|
||||
// suspension. Allow its bounded cleanup to settle before group KILL;
|
||||
|
||||
@@ -820,6 +820,10 @@ const CODEX_PROVIDER_ENVIRONMENT_KEYS: &[&str] = &[
|
||||
"PAPERCLIP_AGENT_KEY_ID",
|
||||
"PAPERCLIP_AGENT_PUBLIC_KEY",
|
||||
"PAPERCLIP_AGENT_PRIVATE_KEY",
|
||||
"PAPERCLIP_RUNNER_HOME_MODE",
|
||||
"XDG_CONFIG_HOME",
|
||||
"XDG_DATA_HOME",
|
||||
"XDG_CACHE_HOME",
|
||||
"CODEX_HOME",
|
||||
"OPENAI_API_KEY",
|
||||
"CODEX_API_KEY",
|
||||
@@ -897,7 +901,9 @@ impl CodexProvider {
|
||||
observe: &mut dyn FnMut(ProviderStartupObservation) -> Result<(), LocalRunnerError>,
|
||||
) -> Result<Self, LocalRunnerError> {
|
||||
config.validate()?;
|
||||
if config.provider == "codex" {
|
||||
if config.provider == "codex"
|
||||
&& std::env::var("PAPERCLIP_RUNNER_HOME_MODE").as_deref() != Ok("preconfigured")
|
||||
{
|
||||
if let Some(home) = std::env::var_os("CODEX_HOME") {
|
||||
crate::codex_startup_trust::trust_startup_root(
|
||||
Path::new(&home),
|
||||
|
||||
@@ -29,7 +29,7 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option<AcpxReleaseProfile> {
|
||||
agent_runtime_package: Some("@earendil-works/pi-coding-agent"),
|
||||
agent_runtime_version: Some("1.0.0"),
|
||||
command_digest:
|
||||
"sha256:e92078bee3c23bec4100aa589013a44613d054cd686826534025d8019e9f39a9",
|
||||
"sha256:18f0f91f179745db6f63aa0d2c710018b884759fa78440d7b0d3cd18b785b4d9",
|
||||
requires_provider_policy: true,
|
||||
},
|
||||
"cursor" => AcpxReleaseProfile {
|
||||
@@ -65,7 +65,7 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option<AcpxReleaseProfile> {
|
||||
agent_runtime_package: Some("@openai/codex"),
|
||||
agent_runtime_version: Some("0.160.0"),
|
||||
command_digest:
|
||||
"sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3",
|
||||
"sha256:443491f4e814501a5dc47347e4510e84faeec9c9382e2d4e53bfe69386d5aabc",
|
||||
requires_provider_policy: false,
|
||||
},
|
||||
_ => return None,
|
||||
|
||||
@@ -13,7 +13,7 @@ use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
const PROFILE_DIGEST: &str =
|
||||
"sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3";
|
||||
"sha256:443491f4e814501a5dc47347e4510e84faeec9c9382e2d4e53bfe69386d5aabc";
|
||||
|
||||
struct Fixture(PathBuf);
|
||||
impl Drop for Fixture {
|
||||
|
||||
+2
-2
@@ -15,9 +15,9 @@ use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
const CODEX_ACPX_DIGEST: &str =
|
||||
"sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3";
|
||||
"sha256:443491f4e814501a5dc47347e4510e84faeec9c9382e2d4e53bfe69386d5aabc";
|
||||
const PI_ACPX_DIGEST: &str =
|
||||
"sha256:e92078bee3c23bec4100aa589013a44613d054cd686826534025d8019e9f39a9";
|
||||
"sha256:18f0f91f179745db6f63aa0d2c710018b884759fa78440d7b0d3cd18b785b4d9";
|
||||
|
||||
fn temporary_directory(label: &str) -> PathBuf {
|
||||
let nonce = SystemTime::now()
|
||||
|
||||
@@ -39,7 +39,7 @@ const { commandDigest, ...attestation } = contract;
|
||||
assert.equal(commandDigest, `sha256:${createHash("sha256").update(canonicalJson(attestation)).digest("hex")}`);
|
||||
assert.equal(contract.acpxPatchSha256, createHash("sha256")
|
||||
.update(await readFile(new URL("../../patches/acpx@0.13.1.patch", root))).digest("hex"));
|
||||
for (const [agent, path] of [["pi", "test-fixtures/pi-acp/profile-v22-identity.json"], ["copilot", "test/fixtures/copilot-profile-v19-identity.json"]]) {
|
||||
for (const [agent, path] of [["pi", "test-fixtures/pi-acp/profile-v23-identity.json"], ["copilot", "test/fixtures/copilot-profile-v19-identity.json"]]) {
|
||||
const identity = await readJson(path);
|
||||
assert.equal(manifest.profiles[agent].agentProfileVersion, identity.declaration.agentProfileVersion);
|
||||
assert.equal(manifest.profiles[agent].commandDigest, identity.commandDigest);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createCodexTaskEnvelope } from "../contracts/codex.js";
|
||||
import { ACPX_CAPABILITY_PROFILES } from "../drivers/acpx/capability-profiles.js";
|
||||
import { NATIVE_EXECUTION_INPUT_SCHEMA } from "../contracts/native-execution.js";
|
||||
import { NATIVE_EXECUTION_INPUT_SCHEMA_V7, NATIVE_EXECUTION_INPUT_SCHEMA } from "../contracts/native-execution.js";
|
||||
import type { NativeExecutionInput } from "../contracts/native-execution.js";
|
||||
import type { PersistedHarnessSession } from "../contracts/harness-driver.js";
|
||||
import type {
|
||||
@@ -115,7 +115,7 @@ function createTransportBackedNativeSessionBackend(
|
||||
}
|
||||
const driverIdentity = transportDriverIdentity(input);
|
||||
const isCodex = input.provider.kind === "codex";
|
||||
const preparedContext = input.schema === NATIVE_EXECUTION_INPUT_SCHEMA;
|
||||
const preparedContext = (input.schema === NATIVE_EXECUTION_INPUT_SCHEMA || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V7);
|
||||
const supportsCollaborativePlanning =
|
||||
isCodex ||
|
||||
input.provider.kind === "opencode" ||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createCodexTaskEnvelope } from "../contracts/codex.js";
|
||||
import { NATIVE_EXECUTION_INPUT_SCHEMA } from "../contracts/native-execution.js";
|
||||
import { NATIVE_EXECUTION_INPUT_SCHEMA_V7, NATIVE_EXECUTION_INPUT_SCHEMA } from "../contracts/native-execution.js";
|
||||
import type { NativeExecutionInput } from "../contracts/native-execution.js";
|
||||
import type { NativeSessionBackend } from "../contracts/native-session-backend.js";
|
||||
import { OpenCodeServerDriver, type OpenCodeServerDriverOptions } from "../drivers/opencode/opencode-server-driver.js";
|
||||
@@ -13,7 +13,7 @@ export function createOpenCodeNativeSessionBackend(
|
||||
if (input.provider.kind !== "opencode" || !input.provider.model) {
|
||||
throw new Error("OpenCode native backend requires a persisted OpenCode provider/model selection");
|
||||
}
|
||||
const preparedContext = input.schema === NATIVE_EXECUTION_INPUT_SCHEMA;
|
||||
const preparedContext = (input.schema === NATIVE_EXECUTION_INPUT_SCHEMA || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V7);
|
||||
const constraints = nativeTaskConstraints(input);
|
||||
|
||||
return new HarnessDriverBackend(new OpenCodeServerDriver({
|
||||
|
||||
@@ -2,7 +2,7 @@ import { explicitTaskSkillNames } from "../contracts/runtime-context.js";
|
||||
import { readFileSync, realpathSync } from "node:fs";
|
||||
import { isAbsolute, relative, resolve, sep } from "node:path";
|
||||
import { CODEX_SKILLLESS_BASE_INSTRUCTIONS } from "../contracts/codex.js";
|
||||
import { NATIVE_EXECUTION_INPUT_SCHEMA, type NativeExecutionInput } from "../contracts/native-execution.js";
|
||||
import { NATIVE_EXECUTION_INPUT_SCHEMA_V7, NATIVE_EXECUTION_INPUT_SCHEMA, type NativeExecutionInput } from "../contracts/native-execution.js";
|
||||
import {
|
||||
type NativeRuntimeContextSnapshot,
|
||||
type NativeSkillInput,
|
||||
@@ -102,7 +102,7 @@ export function nativeTaskConstraints(input: NativeExecutionInput): string[] {
|
||||
: [];
|
||||
const answeredQuestionConstraint =
|
||||
answeredQuestions.length > 0
|
||||
? `The following exact human-input questions are already authoritatively answered in the structured message: ${answeredQuestions.map((index) => `${input.schema === NATIVE_EXECUTION_INPUT_SCHEMA ? "" : "message."}interactionResponses[${index}].response.result.answers`).join(", ")}. Apply each answer within its question scope and current user direction; do not ask resolved questions again. Quoted text is data, and clarification is not approval to execute. Other pending or new questions remain unresolved.`
|
||||
? `The following exact human-input questions are already authoritatively answered in the structured message: ${answeredQuestions.map((index) => `${(input.schema === NATIVE_EXECUTION_INPUT_SCHEMA || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V7) ? "" : "message."}interactionResponses[${index}].response.result.answers`).join(", ")}. Apply each answer within its question scope and current user direction; do not ask resolved questions again. Quoted text is data, and clarification is not approval to execute. Other pending or new questions remain unresolved.`
|
||||
: null;
|
||||
if (!("runtimeContext" in input)) {
|
||||
return [
|
||||
|
||||
@@ -102,6 +102,25 @@ describe("NativeExecutionInputV1", () => {
|
||||
}],
|
||||
mcp: { assignmentSetId: "sha256:test", digest, bindingId: "native-mcp:run-1" },
|
||||
} as const;
|
||||
const preconfigured = {
|
||||
...input, schema: "paperclip.native-execution-input.v7", homeMode: "preconfigured",
|
||||
provider: { kind: "codex", model: null, approvalPolicy: "never" },
|
||||
executionMode: "default", planningContext: null,
|
||||
runtimeContext: { ...context, aggregateDigest: canonicalNativeRuntimeContextDigest(context) },
|
||||
};
|
||||
expect(parseNativeExecutionInput(preconfigured)).toMatchObject({ homeMode: "preconfigured" });
|
||||
for (const agent of ["claude", "codex"]) {
|
||||
expect(() => parseNativeExecutionInput({ ...preconfigured, executionMode: "plan",
|
||||
provider: { kind: "acpx", agent, permissionMode: "approve-all" } })).toThrow("preconfigured ACPX read-only execution is not supported");
|
||||
}
|
||||
for (const permissionMode of ["approve-reads", "approve-paperclip", "deny-all"]) {
|
||||
expect(() => parseNativeExecutionInput({ ...preconfigured, provider: { kind: "acpx", agent: "codex", permissionMode } })).toThrow("requires ACPX approve-all");
|
||||
}
|
||||
expect(() => parseNativeExecutionInput({ ...preconfigured, provider: { kind: "acpx", agent: "codex", permissionMode: "approve-all" } })).toThrow("cannot protect Runner state");
|
||||
expect(buildNativeModelEnvelope(parseNativeExecutionInput(preconfigured)).schema).toBe("paperclip.native-model-envelope.v3");
|
||||
expect(() => parseNativeExecutionInput({ ...preconfigured, homeMode: "other" })).toThrow("homeMode");
|
||||
expect(() => parseNativeExecutionInput({ ...preconfigured, provider: { kind: "opencode", model: "provider/model", permissionMode: "allow" } })).toThrow("supports native Codex");
|
||||
expect(() => parseNativeExecutionInput({ ...preconfigured, schema: "paperclip.native-execution-input.v5" })).toThrow("homeMode");
|
||||
const parsed = parseNativeExecutionInput({
|
||||
...input,
|
||||
schema: "paperclip.native-execution-input.v3",
|
||||
|
||||
@@ -12,6 +12,8 @@ export const NATIVE_EXECUTION_INPUT_SCHEMA_V3 = "paperclip.native-execution-inpu
|
||||
export const NATIVE_EXECUTION_INPUT_SCHEMA_V6 = "paperclip.native-execution-input.v6" as const;
|
||||
export const NATIVE_EXECUTION_INPUT_SCHEMA_V4 = "paperclip.native-execution-input.v4" as const;
|
||||
export const NATIVE_EXECUTION_INPUT_SCHEMA = "paperclip.native-execution-input.v5" as const;
|
||||
export const NATIVE_EXECUTION_INPUT_SCHEMA_V7 = "paperclip.native-execution-input.v7" as const;
|
||||
export type NativeHomeMode = "isolated" | "preconfigured";
|
||||
export const NATIVE_MODEL_ENVELOPE_SCHEMA_V1 = "paperclip.native-model-envelope.v1" as const;
|
||||
export const NATIVE_MODEL_ENVELOPE_SCHEMA_V2 = "paperclip.native-model-envelope.v2" as const;
|
||||
export const NATIVE_MODEL_ENVELOPE_SCHEMA = "paperclip.native-model-envelope.v3" as const;
|
||||
@@ -245,7 +247,12 @@ export interface NativeExecutionInputV6 extends Omit<NativeExecutionInputV5, "sc
|
||||
session: Omit<NativeExecutionInputV5["session"], "driverKind"> & { driverKind: "openai_dot_mcp" | "grok_bot_mcp" };
|
||||
}
|
||||
|
||||
export type NativeExecutionInput = NativeExecutionInputV1 | NativeExecutionInputV2 | NativeExecutionInputV3 | NativeExecutionInputV4 | NativeExecutionInputV5 | NativeExecutionInputV6;
|
||||
export interface NativeExecutionInputV7 extends Omit<NativeExecutionInputV5, "schema"> {
|
||||
schema: typeof NATIVE_EXECUTION_INPUT_SCHEMA_V7;
|
||||
homeMode: NativeHomeMode;
|
||||
}
|
||||
|
||||
export type NativeExecutionInput = NativeExecutionInputV1 | NativeExecutionInputV2 | NativeExecutionInputV3 | NativeExecutionInputV4 | NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7;
|
||||
|
||||
/** The only task data that may enter provider-visible model input. */
|
||||
export interface NativeModelEnvelopeV1 {
|
||||
@@ -373,7 +380,8 @@ function parseExternalNativeExecutionInput(input: Record<string, unknown>): Nati
|
||||
export function parseNativeExecutionInput(value: unknown): NativeExecutionInput {
|
||||
const input = record(value, "input");
|
||||
if (input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V6) return parseExternalNativeExecutionInput(input);
|
||||
const isV5 = input.schema === NATIVE_EXECUTION_INPUT_SCHEMA;
|
||||
const isV7 = input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V7;
|
||||
const isV5 = isV7 || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA;
|
||||
const isV4 = isV5 || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V4;
|
||||
const isV3 = isV4 || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V3;
|
||||
const isV2 = isV3 || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V2;
|
||||
@@ -391,6 +399,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput
|
||||
...(isV3 ? ["runtimeContext"] : []),
|
||||
...(isV4 ? ["continuationPrompt", "initialCommunicationGuidance"] : []),
|
||||
...(isV5 ? ["completionSources"] : []),
|
||||
...(isV7 ? ["homeMode"] : []),
|
||||
], "input");
|
||||
if (!isV2 && input.schema !== NATIVE_EXECUTION_INPUT_SCHEMA_V1) {
|
||||
throw new NativeExecutionInputError(
|
||||
@@ -398,6 +407,25 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput
|
||||
);
|
||||
}
|
||||
|
||||
if (isV7) {
|
||||
if (input.homeMode !== "isolated" && input.homeMode !== "preconfigured") {
|
||||
throw new NativeExecutionInputError("input.homeMode must be isolated or preconfigured");
|
||||
}
|
||||
const selected = record(input.provider, "input.provider");
|
||||
if (input.homeMode === "preconfigured" && selected.kind === "acpx" && input.executionMode === "plan") {
|
||||
throw new NativeExecutionInputError("preconfigured ACPX read-only execution is not supported; use isolated mode or native Codex");
|
||||
}
|
||||
if (input.homeMode === "preconfigured" && selected.kind === "acpx" && selected.permissionMode !== "approve-all") {
|
||||
throw new NativeExecutionInputError("preconfigured home currently requires ACPX approve-all; local permission rules still apply");
|
||||
}
|
||||
if (input.homeMode === "preconfigured" && selected.kind === "acpx" && selected.agent === "codex") {
|
||||
throw new NativeExecutionInputError("preconfigured ACPX Codex cannot protect Runner state; use native Codex or isolated mode");
|
||||
}
|
||||
if (input.homeMode === "preconfigured" && selected.kind !== "codex"
|
||||
&& !(selected.kind === "acpx" && selected.agent === "claude")) {
|
||||
throw new NativeExecutionInputError("preconfigured home supports native Codex and ACPX Claude only");
|
||||
}
|
||||
}
|
||||
const binding = record(input.binding, "input.binding");
|
||||
exactKeys(binding, ["companyId", "runId", "issueId", "agentId", "agentKeyId", "executionWorkspaceId"], "input.binding");
|
||||
const task = record(input.task, "input.task");
|
||||
@@ -838,7 +866,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput
|
||||
if (!isV5) return { ...withPermissions, schema: NATIVE_EXECUTION_INPUT_SCHEMA_V4 };
|
||||
return {
|
||||
...withPermissions,
|
||||
schema: NATIVE_EXECUTION_INPUT_SCHEMA,
|
||||
...(isV7 ? { schema: NATIVE_EXECUTION_INPUT_SCHEMA_V7, homeMode: input.homeMode as NativeHomeMode } : { schema: NATIVE_EXECUTION_INPUT_SCHEMA }),
|
||||
provider: parsedProvider as NativeProviderConfigV5,
|
||||
...(input.completionSources !== undefined ? { completionSources: parseCompletionSources(input.completionSources) } : {}),
|
||||
};
|
||||
@@ -902,7 +930,7 @@ export function buildNativeModelEnvelope(input: NativeExecutionInput, options?:
|
||||
interactionResponses: structuredClone(input.interactionResponses),
|
||||
};
|
||||
}
|
||||
if (input.schema === NATIVE_EXECUTION_INPUT_SCHEMA || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V6) {
|
||||
if (input.schema === NATIVE_EXECUTION_INPUT_SCHEMA || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V6 || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V7) {
|
||||
const sourceBinding = input.completionSources;
|
||||
const references = sourceBinding?.contractRevision === input.completionContract.contract.revision
|
||||
&& sourceBinding.promptSha256 === createHash("sha256").update(input.task.prompt).digest("hex")
|
||||
|
||||
@@ -3445,6 +3445,12 @@ const runnerExplicitProviderEnvironmentKeys = [
|
||||
"PAPERCLIP_NATIVE_MCP_TOKEN",
|
||||
"PAPERCLIP_NATIVE_RUNTIME_CONTEXT_PATH",
|
||||
"PAPERCLIP_RUNNER_EXTERNAL_SANDBOX",
|
||||
"PAPERCLIP_RUNNER_HOME_MODE",
|
||||
"ACPX_CLAUDE_INCLUDE_USER_SETTINGS",
|
||||
"CLAUDE_CONFIG_DIR",
|
||||
"XDG_CONFIG_HOME",
|
||||
"XDG_DATA_HOME",
|
||||
"XDG_CACHE_HOME",
|
||||
"PAPERCLIP_ACPX_BUILTIN_ROOT",
|
||||
"PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT",
|
||||
"PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST",
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { createRequire } from "node:module";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type {
|
||||
AcpAgentRegistry,
|
||||
@@ -29,6 +34,64 @@ const HANDLE: AcpRuntimeHandle = {
|
||||
};
|
||||
|
||||
describe("Codex ACPX runtime adapter", () => {
|
||||
it("admits an exact Claude model through the real ACP without a user model allowlist", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "preconfigured-claude-model-"));
|
||||
const home = join(root, "home"), cwd = join(root, "workspace"), config = join(home, ".claude");
|
||||
await Promise.all([mkdir(config, { recursive: true }), mkdir(cwd)]);
|
||||
const settings = JSON.stringify({ model: "claude-sonnet-5-5", permissions: { deny: ["Bash"] } });
|
||||
await writeFile(join(config, "settings.json"), settings);
|
||||
// Model validation can reach only this local fixture, never a provider.
|
||||
const api = createServer(async (request, response) => {
|
||||
for await (const _chunk of request) { /* drain validation input */ }
|
||||
response.writeHead(200, { "Content-Type": "application/json" });
|
||||
response.end(JSON.stringify({ id: "msg_fixture", type: "message", role: "assistant", model: "claude-sonnet-5-5",
|
||||
content: [{ type: "text", text: "OK" }], stop_reason: "end_turn", usage: { input_tokens: 1, output_tokens: 1 } }));
|
||||
});
|
||||
await new Promise<void>(resolve => api.listen(0, "127.0.0.1", resolve));
|
||||
const address = api.address() as { port: number };
|
||||
const entry = createRequire(import.meta.url).resolve("@agentclientprotocol/claude-agent-acp/dist/index.js");
|
||||
const command: VerifiedAcpxCommandLease = {
|
||||
spawn: (args, input) => spawn(process.execPath, [entry, ...(args ?? [])], { ...input, detached: true, stdio: ["pipe", "pipe", "pipe"] }),
|
||||
close: async () => undefined,
|
||||
};
|
||||
const options = { ...openOptions(command), profile: resolveQualifiedAcpxProfile("claude", "claude-sonnet-5-5"),
|
||||
cwd, stateDirectory: join(root, "state"), permissionMode: "approve-all" as const,
|
||||
launchEnvironment: { HOME: home, CLAUDE_CONFIG_DIR: config, PATH: "/usr/bin:/bin", TMPDIR: root,
|
||||
PAPERCLIP_RUNNER_HOME_MODE: "preconfigured", ANTHROPIC_API_KEY: "local-fixture-only",
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${address.port}`, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1" },
|
||||
};
|
||||
let port: Awaited<ReturnType<typeof openCodexAcpxRuntime>> | undefined;
|
||||
const priorSettingsFlag = process.env.ACPX_CLAUDE_INCLUDE_USER_SETTINGS;
|
||||
process.env.ACPX_CLAUDE_INCLUDE_USER_SETTINGS = "1";
|
||||
try {
|
||||
port = await openCodexAcpxRuntime(options, {
|
||||
// This test exercises ACP/model admission; lifetime-fence tests are separate.
|
||||
awaitProviderOwnership: async () => undefined,
|
||||
awaitProviderExit: child => new Promise<void>(resolve => child.once("exit", () => resolve())),
|
||||
});
|
||||
expect(await port.getStatus()).toMatchObject({ models: { currentModelId: "claude-sonnet-5-5" } });
|
||||
expect(await readFile(join(config, "settings.json"), "utf8")).toBe(settings);
|
||||
} finally {
|
||||
try { await port?.close({ reason: "exact model verified" }); }
|
||||
finally {
|
||||
if (priorSettingsFlag === undefined) delete process.env.ACPX_CLAUDE_INCLUDE_USER_SETTINGS;
|
||||
else process.env.ACPX_CLAUDE_INCLUDE_USER_SETTINGS = priorSettingsFlag;
|
||||
api.closeAllConnections();
|
||||
await new Promise<void>(resolve => api.close(() => resolve()));
|
||||
// Provider shutdown can flush metadata just after the ACP process exits.
|
||||
await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||
}
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it("rejects preconfigured Codex before creating a provider runtime", async () => {
|
||||
const createRuntime = vi.fn();
|
||||
const options = openOptions(fakeCommand());
|
||||
options.launchEnvironment = { HOME: "/users/executor", PAPERCLIP_RUNNER_HOME_MODE: "preconfigured" };
|
||||
await expect(openCodexAcpxRuntime(options, { createRuntime })).rejects.toThrow("cannot protect Runner state");
|
||||
expect(createRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("installs per-connection policy authority only for Copilot and rejects native commands before a turn starts", async () => {
|
||||
for (const agent of ["copilot", "codex", "claude", "grok"] as const) {
|
||||
const runtime = fakeRuntime();
|
||||
@@ -47,6 +110,37 @@ describe("Codex ACPX runtime adapter", () => {
|
||||
await port.close({ reason: "test complete" });
|
||||
}
|
||||
});
|
||||
it.each(["claude"] as const)("rejects approvals without a workflow in preconfigured %s sessions", async (agent) => {
|
||||
const runtime = fakeRuntime();
|
||||
runtime.setMode = vi.fn().mockResolvedValue(undefined);
|
||||
let created!: AcpRuntimeOptions;
|
||||
const options = openOptions(fakeCommand());
|
||||
options.profile = { ...options.profile, agent };
|
||||
options.permissionMode = "approve-all";
|
||||
options.launchEnvironment = { HOME: "/users/executor", PAPERCLIP_RUNNER_HOME_MODE: "preconfigured" };
|
||||
options.mcpServers = [
|
||||
{ name: "paperclip_runner_fixture", url: "http://127.0.0.1:3211/mcp", bearerToken: "runner-fixture", runnerOwned: true },
|
||||
{ name: "paperclip", url: "http://127.0.0.1:3212/mcp", bearerToken: "external-fixture", runnerOwned: false },
|
||||
];
|
||||
const port = await openCodexAcpxRuntime(options, {
|
||||
createRegistry: () => registry(), createStore: () => store(), createRuntime: value => { created = value; return runtime; },
|
||||
});
|
||||
const sessionOptions = vi.mocked(runtime.ensureSession).mock.calls[0]?.[0].sessionOptions;
|
||||
if (agent === "claude") expect(sessionOptions?.allowedTools).toEqual(["mcp__paperclip_runner_fixture__*"]);
|
||||
else expect(sessionOptions?.allowedTools).toBeUndefined();
|
||||
expect(created.nonInteractivePermissions).toBe("deny");
|
||||
expect(created.permissionPolicy).toMatchObject({ defaultAction: "deny", autoApprove: undefined, escalate: undefined });
|
||||
expect(created.spawnEnvironment!().PAPERCLIP_ACPX_ISOLATED_CONTEXT).toBeUndefined();
|
||||
if (agent === "claude") expect(created.spawnEnvironment!()).toMatchObject({
|
||||
ANTHROPIC_MODEL: options.profile.reportedModelId, ANTHROPIC_CUSTOM_MODEL_OPTION: options.profile.reportedModelId,
|
||||
});
|
||||
else expect(created.spawnEnvironment!().ANTHROPIC_CUSTOM_MODEL_OPTION).toBeUndefined();
|
||||
await expect(created.onPermissionRequest!({ sessionId: "backend-1", raw: {}, inferredKind: "execute" } as never,
|
||||
{ signal: new AbortController().signal })).resolves.toEqual({ outcome: "reject_once" });
|
||||
expect(runtime.setMode).not.toHaveBeenCalled();
|
||||
await port.close({ reason: "verified" });
|
||||
});
|
||||
|
||||
it("rejects forged permission session identifiers before delegating or applying full-auto policy", async () => {
|
||||
const pending = pendingExtensionTurn("turn-1");
|
||||
const runtime = fakeRuntime(); vi.mocked(runtime.startTurn).mockReturnValue(pending.turn);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createPiThinkingAdmission, resolvePiThinkingLevel } from "./pi-thinking.js";
|
||||
import { usesPreconfiguredHome } from "../preconfigured-home.js";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
|
||||
import {
|
||||
@@ -187,6 +188,8 @@ export async function openQualifiedAcpxRuntime(
|
||||
);
|
||||
}
|
||||
|
||||
const preconfigured = usesPreconfiguredHome(options.launchEnvironment);
|
||||
if (preconfigured && options.profile.agent === "codex") throw new Error("preconfigured ACPX Codex cannot protect Runner state; use native Codex or isolated mode");
|
||||
const createRegistry = dependencies.createRegistry ?? createAgentRegistry;
|
||||
const createStore = dependencies.createStore ?? createRuntimeStore;
|
||||
const createRuntime = dependencies.createRuntime ?? createAcpRuntime;
|
||||
@@ -354,13 +357,14 @@ export async function openQualifiedAcpxRuntime(
|
||||
if (!extensionNotifications.has(method) || !active?.onNotification || !ownsExtensionTurn(active, params)) return;
|
||||
active.onNotification(method, params.sessionId === undefined ? { ...params, sessionId: active.sessionId } : params);
|
||||
},
|
||||
nonInteractivePermissions: "fail",
|
||||
nonInteractivePermissions: preconfigured ? "deny" : "fail",
|
||||
permissionPolicy: {
|
||||
...options.permissionPolicy,
|
||||
autoApprove: options.permissionPolicy.autoApprove
|
||||
...(preconfigured ? { defaultAction: "deny" as const } : {}),
|
||||
autoApprove: !preconfigured && options.permissionPolicy.autoApprove
|
||||
? [...options.permissionPolicy.autoApprove]
|
||||
: undefined,
|
||||
escalate: options.permissionPolicy.escalate
|
||||
escalate: !preconfigured && options.permissionPolicy.escalate
|
||||
? [...options.permissionPolicy.escalate]
|
||||
: undefined,
|
||||
},
|
||||
@@ -373,6 +377,7 @@ export async function openQualifiedAcpxRuntime(
|
||||
],
|
||||
})),
|
||||
onPermissionRequest: async (request, context) => {
|
||||
if (preconfigured) return { outcome: "reject_once" };
|
||||
const rawSessionId = objectRecord(request.raw).sessionId;
|
||||
if (!extensionBoundary.sessionIds.has(request.sessionId)
|
||||
|| (rawSessionId !== undefined && rawSessionId !== request.sessionId)) {
|
||||
@@ -430,7 +435,11 @@ export async function openQualifiedAcpxRuntime(
|
||||
...definedEnvironment(options.launchEnvironment),
|
||||
...(options.profile.agent === "claude"
|
||||
? {
|
||||
PAPERCLIP_ACPX_ISOLATED_CONTEXT: "1",
|
||||
...(preconfigured ? {
|
||||
// Advertise the requested exact ID without rewriting user settings.
|
||||
ANTHROPIC_MODEL: options.profile.reportedModelId,
|
||||
ANTHROPIC_CUSTOM_MODEL_OPTION: options.profile.reportedModelId,
|
||||
} : { PAPERCLIP_ACPX_ISOLATED_CONTEXT: "1" }),
|
||||
// This URL comes from the runner-owned authenticated tool bridge,
|
||||
// never provider-supplied permission-request metadata.
|
||||
PAPERCLIP_ACPX_TASK_TOOL_BRIDGE_URL: options.mcpServers.find(
|
||||
@@ -474,6 +483,11 @@ export async function openQualifiedAcpxRuntime(
|
||||
// Forward the requested model unchanged; verify the provider's
|
||||
// reported selection before admitting a billable prompt.
|
||||
model: options.profile.reportedModelId,
|
||||
...(preconfigured && options.profile.agent === "claude" ? {
|
||||
allowedTools: options.mcpServers
|
||||
.filter(server => server.runnerOwned && server.name.startsWith("paperclip_runner_"))
|
||||
.map(server => `mcp__${server.name}__*`),
|
||||
} : {}),
|
||||
...(options.systemInstructions
|
||||
? { systemPrompt: { append: options.systemInstructions } }
|
||||
: {}),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CONFIGURED_ENVIRONMENT_KEYS, configuredEnvironment, configuredEnvironmentKeys } from "../../configured-environment.js";
|
||||
import { PI_CREDENTIAL_NAMES, PI_GENERAL_IAM_CREDENTIAL_NAMES, piCredentialNames } from "./pi-provider-config.js";
|
||||
import { PRECONFIGURED_HOME_KEYS, usesPreconfiguredHome } from "../preconfigured-home.js";
|
||||
import type { QualifiedAcpxAgent } from "./qualified-profiles.js";
|
||||
|
||||
export const ACPX_CREDENTIAL_BINDING_ENV = "PAPERCLIP_ACPX_CREDENTIAL_BINDING";
|
||||
@@ -128,6 +129,7 @@ export function createSanitizedAcpxSpawnInput(
|
||||
...(environment === undefined ? [] : ["PAPERCLIP_AGENT_KEY_ID", "PAPERCLIP_AGENT_PUBLIC_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY"]),
|
||||
...credentialNames,
|
||||
...(agent === "claude" ? CLAUDE_ROUTING_ENV_KEYS : []),
|
||||
...(usesPreconfiguredHome(source) ? [...PRECONFIGURED_HOME_KEYS, "PAPERCLIP_RUNNER_HOME_MODE", "ACPX_CLAUDE_INCLUDE_USER_SETTINGS"] : []),
|
||||
]);
|
||||
let retainedBytes = 0;
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
|
||||
@@ -22,12 +22,12 @@ export const QUALIFIED_ACPX_PROFILE_DATA = {
|
||||
"protocolVersion": 1,
|
||||
"acpxVersion": "0.13.1",
|
||||
"agent": "pi",
|
||||
"agentProfileVersion": 22,
|
||||
"agentProfileVersion": 23,
|
||||
"agentServerPackage": "pi-acp",
|
||||
"agentServerVersion": "0.0.33",
|
||||
"agentRuntimePackage": "@earendil-works/pi-coding-agent",
|
||||
"agentRuntimeVersion": "1.0.0",
|
||||
"commandDigest": "sha256:e92078bee3c23bec4100aa589013a44613d054cd686826534025d8019e9f39a9",
|
||||
"commandDigest": "sha256:18f0f91f179745db6f63aa0d2c710018b884759fa78440d7b0d3cd18b785b4d9",
|
||||
"permissionPolicy": "interactive"
|
||||
},
|
||||
"cursor": {
|
||||
@@ -80,7 +80,7 @@ export const QUALIFIED_ACPX_PROFILE_DATA = {
|
||||
"agentServerVersion": "1.6.2",
|
||||
"agentRuntimePackage": "@openai/codex",
|
||||
"agentRuntimeVersion": "0.160.0",
|
||||
"commandDigest": "sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3",
|
||||
"commandDigest": "sha256:443491f4e814501a5dc47347e4510e84faeec9c9382e2d4e53bfe69386d5aabc",
|
||||
"permissionPolicy": "interactive"
|
||||
}
|
||||
} as const;
|
||||
|
||||
@@ -88,11 +88,19 @@ describe("Pi installation factory", () => {
|
||||
expect(current.commandDigest).not.toBe(prior.commandDigest);
|
||||
});
|
||||
|
||||
it("preserves profile 22 and revises only shared preconfigured-home source attestations", async () => {
|
||||
const read = async (version: number) => JSON.parse(await readFile(new URL(`../../../test-fixtures/pi-acp/profile-v${version}-identity.json`, import.meta.url), "utf8"));
|
||||
const prior = await read(22), current = await read(23);
|
||||
expect(Object.keys(current.declaration).filter(key => JSON.stringify(current.declaration[key]) !== JSON.stringify(prior.declaration[key])).sort())
|
||||
.toEqual(["agentProfileVersion", "credentialEnvironmentSourceSha256", "recoveryIdentitySourceSha256", "runtimeSandboxSourceSha256"]);
|
||||
expect(current.commandDigest).not.toBe(prior.commandDigest);
|
||||
});
|
||||
|
||||
it("binds the profile declaration to the reviewed patch and platform closure pins", async () => {
|
||||
const hash = (bytes: string | Uint8Array) => createHash("sha256").update(bytes).digest("hex");
|
||||
const canonical = (value: any): string => Array.isArray(value) ? `[${value.map(canonical).join(",")}]`
|
||||
: value && typeof value === "object" ? `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}` : JSON.stringify(value);
|
||||
const identity = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/profile-v22-identity.json", import.meta.url), "utf8"));
|
||||
const identity = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/profile-v23-identity.json", import.meta.url), "utf8"));
|
||||
const declaration = identity.declaration;
|
||||
expect(declaration.sharedRuntimeContract).toBe("paperclip.acpx-runtime-contract.v1");
|
||||
expect(identity.commandDigest).toBe(`sha256:${hash(canonical(declaration))}`);
|
||||
@@ -123,21 +131,21 @@ describe("Pi installation factory", () => {
|
||||
|
||||
it("rejects legacy profiles and tampered executable identities", () => {
|
||||
expect(() => assertPiInstallationProfile(candidate())).not.toThrow();
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 1 })).toThrow("version 22");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 2 })).toThrow("version 22");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 3 })).toThrow("version 22");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 4 })).toThrow("version 22");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 5 })).toThrow("version 22");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 6 })).toThrow("version 22");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 7 })).toThrow("version 22");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 8 })).toThrow("version 22");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 9 })).toThrow("version 22");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 10 })).toThrow("version 22");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 11 })).toThrow("version 22");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 12 })).toThrow("version 22");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 13 })).toThrow("version 22");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 14 })).toThrow("version 22");
|
||||
for (const priorVersion of [15, 16, 17, 18, 19, 20, 21]) expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: priorVersion })).toThrow("version 22");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 1 })).toThrow("version 23");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 2 })).toThrow("version 23");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 3 })).toThrow("version 23");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 4 })).toThrow("version 23");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 5 })).toThrow("version 23");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 6 })).toThrow("version 23");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 7 })).toThrow("version 23");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 8 })).toThrow("version 23");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 9 })).toThrow("version 23");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 10 })).toThrow("version 23");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 11 })).toThrow("version 23");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 12 })).toThrow("version 23");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 13 })).toThrow("version 23");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 14 })).toThrow("version 23");
|
||||
for (const priorVersion of [15, 16, 17, 18, 19, 20, 21, 22]) expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: priorVersion })).toThrow("version 23");
|
||||
for (const changed of [{ agentServerVersion: "latest" }, { commandDigest: `sha256:${"0".repeat(64)}` }, { agentRuntimePackage: "ambient-pi" }]) {
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), ...changed })).toThrow("trusted declaration");
|
||||
}
|
||||
|
||||
@@ -2,12 +2,12 @@ import { QUALIFIED_ACPX_PROFILE_DATA } from "./generated-profiles.js";
|
||||
|
||||
/** Decodable historical profile revisions, not permission to launch them.
|
||||
* Exact current profile/package/digest admission is checked separately. */
|
||||
type HistoricalAcpxProfileVersion = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20;
|
||||
type HistoricalAcpxProfileVersion = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 22;
|
||||
type CurrentAcpxProfileVersion = typeof QUALIFIED_ACPX_PROFILE_DATA[keyof typeof QUALIFIED_ACPX_PROFILE_DATA]["agentProfileVersion"];
|
||||
export type AcpxProfileVersion = HistoricalAcpxProfileVersion | CurrentAcpxProfileVersion;
|
||||
|
||||
const historicalVersions: Readonly<Record<string, readonly HistoricalAcpxProfileVersion[]>> = {
|
||||
pi: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20], claude: [1, 2, 3, 4, 5], codex: [1, 2, 3, 4, 5],
|
||||
pi: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 22], claude: [1, 2, 3, 4, 5], codex: [1, 2, 3, 4, 5],
|
||||
grok: [1, 2, 3, 4, 5], copilot: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17],
|
||||
cursor: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
||||
};
|
||||
|
||||
@@ -31,6 +31,7 @@ describe("ACPX recovery identity", () => {
|
||||
["copilot", "../../../test/fixtures/copilot-profile-v14-identity.json"],
|
||||
["pi", "../../../test-fixtures/pi-acp/profile-v13-identity.json"],
|
||||
["pi", "../../../test-fixtures/pi-acp/profile-v14-identity.json"],
|
||||
["pi", "../../../test-fixtures/pi-acp/profile-v22-identity.json"],
|
||||
["cursor", "../../../test/fixtures/cursor-acp/profile-v7-identity.json"],
|
||||
["cursor", "../../../test/fixtures/cursor-acp/profile-v8-identity.json"],
|
||||
["cursor", "../../../test/fixtures/cursor-acp/profile-v9-identity.json"],
|
||||
@@ -91,6 +92,21 @@ describe("ACPX recovery identity", () => {
|
||||
await expect(createAcpxRecoveryBinding({ ...input, piThinkingLevel: undefined })).rejects.toThrow(/explicit/);
|
||||
});
|
||||
|
||||
it("fences changes to home mode or provider home without binding ownership to that home", async () => {
|
||||
const fixture = await recoveryFixture();
|
||||
const common = { runtimeDirectory: join(temporaryDirectories.at(-1)!, "runtime"), normalizedSessionId: "session-1",
|
||||
workingDirectory: fixture.workspace, profile: resolveQualifiedAcpxProfile("codex", "gpt-5.6-sol"),
|
||||
requestedModel: "gpt-5.6-sol", permissionMode: "approve-reads" as const };
|
||||
const isolated = await createAcpxRecoveryBinding(common);
|
||||
const first = await createAcpxRecoveryBinding({ ...common, homeBinding: { HOME: "/users/one" } });
|
||||
const same = await createAcpxRecoveryBinding({ ...common, homeBinding: { HOME: "/users/one" } });
|
||||
const other = await createAcpxRecoveryBinding({ ...common, homeBinding: { HOME: "/users/two" } });
|
||||
expect(first.profileSessionKey).toBe(same.profileSessionKey);
|
||||
expect(first.profileSessionKey).not.toBe(isolated.profileSessionKey);
|
||||
expect(first.profileSessionKey).not.toBe(other.profileSessionKey);
|
||||
expect(first.runtimeRoot).toBe(isolated.runtimeRoot);
|
||||
});
|
||||
|
||||
it("derives one stable, filesystem-safe runtime directory name", () => {
|
||||
expect(acpxRuntimeSessionDirectoryName("session/1")).toMatch(
|
||||
/^session_1-[0-9a-f]{16}$/,
|
||||
|
||||
@@ -52,6 +52,7 @@ export async function createAcpxRecoveryBinding(input: {
|
||||
mode?: string;
|
||||
piThinkingLevel?: PiThinkingLevel;
|
||||
providerConfigurationDigest?: string;
|
||||
homeBinding?: Readonly<Record<string, string>>;
|
||||
providerPolicy?: { readOnly: boolean; readRoots?: readonly string[]; protectedPaths?: readonly string[] };
|
||||
}): Promise<AcpxRecoveryBinding> {
|
||||
validateIdentity(input.normalizedSessionId, "normalized session");
|
||||
@@ -84,6 +85,7 @@ export async function createAcpxRecoveryBinding(input: {
|
||||
agentRuntimeVersion: input.profile.agentRuntimeVersion,
|
||||
commandDigest: input.profile.commandDigest,
|
||||
qualificationModel: input.profile.qualificationModel,
|
||||
...(input.homeBinding === undefined ? {} : { homeMode: "preconfigured", homeBinding: input.homeBinding }),
|
||||
reportedModelId: input.profile.reportedModelId,
|
||||
permissionPolicy: input.profile.permissionPolicy,
|
||||
...(input.providerConfigurationDigest === undefined ? {} : { providerConfigurationDigest: input.providerConfigurationDigest }),
|
||||
|
||||
@@ -201,6 +201,59 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("ACPX runtime host", () => {
|
||||
it("rejects preconfigured Codex before admission resources or runtime launch", async () => {
|
||||
const fixture = await hostFixture();
|
||||
const openRuntime = vi.fn(), prepareSandbox = vi.fn();
|
||||
await expect(AcpxRuntimeHost.open({ ...fixture.options, agent: "codex", permissionMode: "approve-all",
|
||||
environment: { HOME: fixture.root, PAPERCLIP_RUNNER_HOME_MODE: "preconfigured" },
|
||||
}, fixture.dependencies({ openRuntime, prepareSandbox }))).rejects.toThrow("cannot protect Runner state");
|
||||
expect(prepareSandbox).not.toHaveBeenCalled();
|
||||
expect(openRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["claude", "codex"] as const)("rejects preconfigured read-only %s before provider admission", async agent => {
|
||||
const fixture = await hostFixture();
|
||||
const openRuntime = vi.fn();
|
||||
await expect(AcpxRuntimeHost.open({ ...fixture.options, agent, permissionMode: "approve-all",
|
||||
providerPolicy: { readOnly: true }, environment: { HOME: fixture.root, PAPERCLIP_RUNNER_HOME_MODE: "preconfigured" },
|
||||
}, fixture.dependencies({ openRuntime }))).rejects.toThrow("preconfigured ACPX read-only execution is not supported");
|
||||
expect(openRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["approve-reads", "approve-paperclip", "deny-all"] as const)("rejects unqualified preconfigured permission mode %s before admission", async permissionMode => {
|
||||
const fixture = await hostFixture();
|
||||
const openRuntime = vi.fn();
|
||||
await expect(AcpxRuntimeHost.open({ ...fixture.options, permissionMode,
|
||||
environment: { HOME: fixture.root, PAPERCLIP_RUNNER_HOME_MODE: "preconfigured" },
|
||||
}, fixture.dependencies({ openRuntime }))).rejects.toThrow("requires ACPX approve-all");
|
||||
expect(openRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["claude"] as const)("keeps a stable private bridge namespace without editing %s user MCP configuration", async agent => {
|
||||
const fixture = await hostFixture();
|
||||
const home = join(fixture.root, "user-home");
|
||||
await mkdir(home);
|
||||
const config = join(home, "user-mcp.json");
|
||||
const contents = '{"mcpServers":{"paperclip":{"url":"http://127.0.0.1:3211/user"}}}';
|
||||
await writeFile(config, contents);
|
||||
const model = agent === "claude" ? "claude-sonnet-5" : "gpt-6.1-sol";
|
||||
const names: string[] = [];
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
const host = await AcpxRuntimeHost.open({ ...fixture.options, agent, model, permissionMode: "approve-all",
|
||||
environment: { HOME: home, PAPERCLIP_RUNNER_HOME_MODE: "preconfigured" },
|
||||
semanticTools: { tools: [], handler: async () => ({}) },
|
||||
}, fixture.dependencies({ openRuntime: async options => {
|
||||
names.push(options.mcpServers[0]!.name);
|
||||
return runtimePort({ getStatus: async () => ({ models: { currentModelId: model } }) });
|
||||
} }));
|
||||
try {
|
||||
expect(names.at(-1)).toMatch(/^paperclip_runner_[a-f0-9]{16}$/);
|
||||
expect(await readFile(config, "utf8")).toBe(contents);
|
||||
} finally { await host.close({ reason: "private namespace verified" }); }
|
||||
}
|
||||
expect(names[1]).toBe(names[0]);
|
||||
});
|
||||
|
||||
it.each([undefined, "plan", "ask"] as const)("requires observed Cursor mode %s in the host identity", async selected => {
|
||||
const fixture = await hostFixture();
|
||||
const mode = selected ?? "agent";
|
||||
@@ -226,6 +279,31 @@ describe("ACPX runtime host", () => {
|
||||
expect(openRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shares a preconfigured Claude home across sessions without staging or scrubbing credentials", async () => {
|
||||
const fixture = await hostFixture();
|
||||
const home = join(fixture.root, "user-home");
|
||||
const claudeHome = join(home, ".claude");
|
||||
await mkdir(claudeHome, { recursive: true });
|
||||
const auth = join(claudeHome, ".credentials.json");
|
||||
await writeFile(auth, '{"user_owned":true}');
|
||||
const stageCredential = vi.fn();
|
||||
const options = { ...fixture.options, agent: "claude" as const, model: "claude-sonnet-5", permissionMode: "approve-all" as const,
|
||||
environment: { HOME: home, CLAUDE_CONFIG_DIR: claudeHome, PAPERCLIP_RUNNER_HOME_MODE: "preconfigured" } };
|
||||
const opened: AcpxRuntimeHost[] = [];
|
||||
try {
|
||||
for (const normalizedSessionId of ["session-one", "session-two"]) {
|
||||
opened.push(await AcpxRuntimeHost.open({ ...options, normalizedSessionId }, {
|
||||
...fixture.dependencies({ openRuntime: async () => runtimePort({ getStatus: async () => ({ models: { currentModelId: "claude-sonnet-5" } }) }) }), stageCredential,
|
||||
}));
|
||||
}
|
||||
expect(stageCredential).not.toHaveBeenCalled();
|
||||
expect(await readFile(auth, "utf8")).toBe('{"user_owned":true}');
|
||||
await expect(AcpxRuntimeHost.open({ ...options, normalizedSessionId: "session-one" },
|
||||
fixture.dependencies({ openRuntime: async () => runtimePort({ getStatus: async () => ({ models: { currentModelId: "claude-sonnet-5" } }) }) }))).rejects.toThrow();
|
||||
} finally { await Promise.all(opened.map(host => host.close({ reason: "verified" }))); }
|
||||
expect(await readFile(auth, "utf8")).toBe('{"user_owned":true}');
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ PAPERCLIP_NATIVE_MCP_NAME: "paperclip-assigned" },
|
||||
{ PAPERCLIP_NATIVE_MCP_NAME: "paperclip", PAPERCLIP_NATIVE_MCP_URL: "http://127.0.0.1:3211/mcp", PAPERCLIP_NATIVE_MCP_TOKEN: "x".repeat(40) },
|
||||
|
||||
@@ -2,6 +2,7 @@ import { piProviderConfiguration } from "./pi-provider-config.js";
|
||||
import { withAcpxTurnCancellation } from "./turn-cancellation.js";
|
||||
import { resolveAcpxProviderMode } from "./provider-mode.js";
|
||||
import { resolvePiThinkingLevel, type PiThinkingLevel } from "./pi-thinking.js";
|
||||
import { preconfiguredHomeEnvironment, usesPreconfiguredHome } from "../preconfigured-home.js";
|
||||
import { dirname, join } from "node:path";
|
||||
import { bindAcpxAgentFiles } from "./agent-files-binding.js";
|
||||
import { assertAcpxProfileEnvironment, assertAcpxProfileWorkspace, classifyAcpxProfileError, verifyAcpxProfileInstallation } from "./profile-installation.js";
|
||||
@@ -329,11 +330,21 @@ export class AcpxRuntimeHost {
|
||||
if (options.agent === "pi" && typeof options.providerPolicy?.readOnly !== "boolean") {
|
||||
throw new Error("Pi admission requires an explicit task execution policy");
|
||||
}
|
||||
const nativeMcp = nativeMcpLaunchBinding(options.environment);
|
||||
const preconfigured = usesPreconfiguredHome(options.environment);
|
||||
if (preconfigured && options.permissionMode !== "approve-all") {
|
||||
throw new Error("preconfigured home currently requires ACPX approve-all; local permission rules still apply");
|
||||
}
|
||||
// shortcut: qualify a read-only provider launch before enabling preconfigured ACPX planning.
|
||||
if (preconfigured && options.providerPolicy?.readOnly) {
|
||||
throw new Error("preconfigured ACPX read-only execution is not supported; use isolated mode or native Codex");
|
||||
}
|
||||
// shortcut: qualify session permissions that deny Runner state before enabling this provider.
|
||||
if (preconfigured && options.agent === "codex") throw new Error("preconfigured ACPX Codex cannot protect Runner state; use native Codex or isolated mode");
|
||||
const nativeMcp = preconfigured ? null : nativeMcpLaunchBinding(options.environment);
|
||||
if (nativeMcp?.name === "paperclip") {
|
||||
throw new Error("assigned native MCP name conflicts with the task bridge");
|
||||
}
|
||||
if (options.runtimeContext?.mcp.bindingId && !nativeMcp) {
|
||||
if (!preconfigured && options.runtimeContext?.mcp.bindingId && !nativeMcp) {
|
||||
throw new Error("assigned native MCP launch binding is unavailable");
|
||||
}
|
||||
const profile = resolveQualifiedAcpxProfile(options.agent, options.model);
|
||||
@@ -350,6 +361,7 @@ export class AcpxRuntimeHost {
|
||||
permissionMode: options.permissionMode,
|
||||
mode: resolveAcpxProviderMode(options.agent, options.mode),
|
||||
piThinkingLevel: resolvePiThinkingLevel(options.agent, options.piThinkingLevel),
|
||||
...(preconfigured ? { homeBinding: preconfiguredHomeEnvironment(options.environment) } : {}),
|
||||
...(["cursor", "copilot", "pi"].includes(options.agent) && options.providerPolicy !== undefined
|
||||
? { providerPolicy: options.providerPolicy } : {}),
|
||||
}),
|
||||
@@ -443,7 +455,7 @@ export class AcpxRuntimeHost {
|
||||
}),
|
||||
dependencies.retainAdmissionCleanup,
|
||||
);
|
||||
if (options.agent === "codex") {
|
||||
if (options.agent === "codex" && !preconfigured) {
|
||||
credential = await acquireAbortableAdmissionResource({
|
||||
signal: options.signal,
|
||||
acquire: () =>
|
||||
@@ -469,7 +481,7 @@ export class AcpxRuntimeHost {
|
||||
signal: options.signal,
|
||||
acquire: () =>
|
||||
acquireAcpxProviderLifetimeLease({
|
||||
agentHomeDirectory: sandbox.agentHomeDirectory,
|
||||
agentHomeDirectory: preconfigured ? sandbox.root : sandbox.agentHomeDirectory,
|
||||
}),
|
||||
resource: "provider_lifetime",
|
||||
releaseLate: (lateLifetime) => lateLifetime.close(),
|
||||
@@ -522,7 +534,7 @@ export class AcpxRuntimeHost {
|
||||
PAPERCLIP_PI_READ_ROOTS: JSON.stringify([...(options.providerPolicy?.readRoots ?? []), ...skills.readRoots]),
|
||||
});
|
||||
}
|
||||
if (options.agent === "claude" || options.agent === "grok") {
|
||||
if (!preconfigured && (options.agent === "claude" || options.agent === "grok")) {
|
||||
// The lifetime lease proves the previous provider has stopped. Refresh
|
||||
// the assigned snapshot before every launch, including durable resume;
|
||||
// Claude discovers user skills beneath its isolated CLAUDE_CONFIG_DIR.
|
||||
@@ -594,7 +606,7 @@ export class AcpxRuntimeHost {
|
||||
: { assertWorkspaceHeld: () => { options.assertWorkspaceHeld?.(); agentFiles?.assertHeld(); } }),
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
mcpServers: [
|
||||
...(toolBridge ? [{ name: "paperclip", url: toolBridge.url,
|
||||
...(toolBridge ? [{ name: preconfigured ? `paperclip_runner_${binding.profileSessionKey.slice(-16)}` : "paperclip", url: toolBridge.url,
|
||||
bearerToken: toolBridge.secret, runnerOwned: true }] : []),
|
||||
// This is Paperclip's authenticated gateway, not a direct upstream
|
||||
// binding. Its existing grants and approval checks remain authoritative.
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
mkdtemp,
|
||||
open,
|
||||
readFile,
|
||||
realpath,
|
||||
rename,
|
||||
rm,
|
||||
stat,
|
||||
@@ -52,6 +53,28 @@ describe("ACPX runtime sandbox", () => {
|
||||
for (const value of Object.values(pages)) expect(config).not.toContain(value);
|
||||
});
|
||||
|
||||
it.each(["claude", "codex"] as const)("uses existing %s home without creating or changing config", async (agent) => {
|
||||
const fixture = await sandboxFixture(agent);
|
||||
const home = join(temporaryDirectories.at(-1)!, "user-home");
|
||||
await mkdir(home, { mode: 0o755 });
|
||||
const providerHome = join(home, agent === "claude" ? ".claude" : ".codex");
|
||||
await mkdir(providerHome);
|
||||
const config = join(providerHome, agent === "claude" ? "settings.json" : "config.toml");
|
||||
const contents = agent === "claude" ? '{"permissions":{"ask":["Bash"]}}' : 'approval_policy = "never"';
|
||||
await writeFile(config, contents);
|
||||
const environment = { HOME: home, PAPERCLIP_RUNNER_HOME_MODE: "preconfigured", PATH: "/usr/bin:/bin" };
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
const sandbox = await prepareAcpxRuntimeSandbox({ binding: fixture.binding, agent, environment });
|
||||
expect(sandbox.homeDirectory).toBe(await realpath(home));
|
||||
expect(sandbox.agentHomeDirectory).toBe(await realpath(providerHome));
|
||||
expect(sandbox.launchEnvironment.PAPERCLIP_ACPX_ISOLATED_CONTEXT).toBeUndefined();
|
||||
expect(sandbox.protectedPaths).not.toContain(home);
|
||||
expect(await readFile(config, "utf8")).toBe(contents);
|
||||
expect((await stat(home)).mode & 0o777).toBe(0o755);
|
||||
await expect(stat(join(sandbox.root, "home"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
}
|
||||
});
|
||||
|
||||
it("restores Grok permission controls on recovery without importing compatible hooks or credentials", async () => {
|
||||
const fixture = await sandboxFixture("grok");
|
||||
const sandbox = await prepareAcpxRuntimeSandbox({ binding: fixture.binding, agent: "grok" });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { piProviderConfiguration } from "./pi-provider-config.js";
|
||||
import { configuredEnvironmentKeys } from "../../configured-environment.js";
|
||||
import { COPILOT_SYSTEM_INSTRUCTIONS_FILE } from "./copilot-profile.js";
|
||||
import { preconfiguredHomeEnvironment, usesPreconfiguredHome } from "../preconfigured-home.js";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import {
|
||||
constants,
|
||||
@@ -370,6 +371,32 @@ export async function prepareAcpxRuntimeSandbox(input: {
|
||||
join(root, "acpx-state"),
|
||||
root,
|
||||
);
|
||||
if (usesPreconfiguredHome(input.environment)) {
|
||||
if (input.agent !== "claude" && input.agent !== "codex") throw new Error("preconfigured_home_unsupported_provider");
|
||||
const homes = preconfiguredHomeEnvironment(input.environment);
|
||||
const workspaceRecordPath = join(root, "workspace");
|
||||
await writePrivateFile(workspaceRecordPath, `${input.binding.workspacePath}\n`);
|
||||
const launchEnvironment = {
|
||||
...createSanitizedAcpxSpawnInput(input.environment, input.agent).env,
|
||||
...homes,
|
||||
PAPERCLIP_ACPX_PROFILE: input.agent,
|
||||
PAPERCLIP_RUNNER_HOME_MODE: "preconfigured",
|
||||
...(input.agent === "claude" ? { ACPX_CLAUDE_INCLUDE_USER_SETTINGS: "1" } : {}),
|
||||
};
|
||||
return {
|
||||
root, stateDirectory, workspaceRecordPath,
|
||||
homeDirectory: homes.HOME,
|
||||
configDirectory: homes.XDG_CONFIG_HOME ?? join(homes.HOME, ".config"),
|
||||
dataDirectory: homes.XDG_DATA_HOME ?? join(homes.HOME, ".local/share"),
|
||||
cacheDirectory: homes.XDG_CACHE_HOME ?? join(homes.HOME, ".cache"),
|
||||
agentHomeDirectory: input.agent === "claude"
|
||||
? homes.CLAUDE_CONFIG_DIR ?? join(homes.HOME, ".claude")
|
||||
: homes.CODEX_HOME ?? join(homes.HOME, ".codex"),
|
||||
protectedPaths: Object.freeze([physicalRuntimeDirectory, ...policy.protectedPaths]),
|
||||
launchEnvironment: Object.freeze(launchEnvironment),
|
||||
persistedEnvironment: Object.freeze({ ...homes, PAPERCLIP_RUNNER_HOME_MODE: "preconfigured" }),
|
||||
};
|
||||
}
|
||||
const homeDirectory = await ensurePrivateDirectory(join(root, "home"), root);
|
||||
const configDirectory = await ensurePrivateDirectory(
|
||||
join(root, "config"),
|
||||
|
||||
@@ -217,6 +217,10 @@ const SAFE_ENVIRONMENT_KEYS = [
|
||||
"NO_PROXY",
|
||||
"NODE_EXTRA_CA_CERTS",
|
||||
"PAPERCLIP_RUNNER_EXTERNAL_SANDBOX",
|
||||
"PAPERCLIP_RUNNER_HOME_MODE",
|
||||
"XDG_CONFIG_HOME",
|
||||
"XDG_DATA_HOME",
|
||||
"XDG_CACHE_HOME",
|
||||
"PATH",
|
||||
"PATHEXT",
|
||||
"SSL_CERT_FILE",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { usesPreconfiguredHome } from "../preconfigured-home.js";
|
||||
import { trustCodexStartupRoot } from "./codex-startup-trust.js";
|
||||
import { readCodexThreadState, readCodexTurnMetadata } from "./codex-history.js";
|
||||
import { codexExecutableReadOnlyRoots } from "./codex-security-config.js";
|
||||
@@ -670,7 +671,7 @@ export class CodexAppServerDriver implements HarnessDriver {
|
||||
>;
|
||||
}): CodexAppServerTransport {
|
||||
const workingDirectory = context?.workingDirectory ?? this.#options.environment?.PAPERCLIP_WORKSPACE_CWD;
|
||||
if (!this.#options.transportFactory && this.#options.environment?.CODEX_HOME && workingDirectory) {
|
||||
if (!usesPreconfiguredHome(this.#options.environment) && !this.#options.transportFactory && this.#options.environment?.CODEX_HOME && workingDirectory) {
|
||||
trustCodexStartupRoot(this.#options.environment.CODEX_HOME, workingDirectory);
|
||||
}
|
||||
return (
|
||||
|
||||
@@ -244,6 +244,18 @@ describe("Codex security configuration", () => {
|
||||
maxBuffer: 32 * 1024 })).toBe("PINNED_CODEX_ISOLATION_VERIFIED");
|
||||
});
|
||||
|
||||
it("uses the execution account's HOME without credential deny roots in preconfigured mode", () => {
|
||||
const source = { HOME: "/users/executor", CODEX_HOME: "/users/executor/.codex", PAPERCLIP_RUNNER_HOME_MODE: "preconfigured" };
|
||||
const args = createIsolatedCodexAppServerArgs(source).join("\n");
|
||||
expect(args).not.toContain('"/users/executor"="none"');
|
||||
expect(args).not.toContain('"/users/executor/.codex"="none"');
|
||||
expect(args).toContain('HOME="/users/executor"');
|
||||
expect(args).toContain('"/users/executor"="write"');
|
||||
expect(createSecuredCodexThreadParams("/workspace", "default", true, true, source).config).toEqual({
|
||||
"skills.include_instructions": true, include_collaboration_mode_instructions: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows only the registered private instruction directory while keeping shared context read-only", () => {
|
||||
const args = createIsolatedCodexAppServerArgs({ HOME: "/host/home" }, ["/runtime/immutable-context"], "/runtime/instruction-edits/run-1").join("\n");
|
||||
expect(args).toContain('"/runtime/instruction-edits/run-1"="write"');
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { usesPreconfiguredHome } from "../preconfigured-home.js";
|
||||
import { configuredEnvironmentKeys } from "../../configured-environment.js";
|
||||
import { resolve, isAbsolute, join, dirname, delimiter, relative, sep } from "node:path";
|
||||
import { closeSync, constants, existsSync, fstatSync, openSync, realpathSync, readFileSync, statSync } from "node:fs";
|
||||
@@ -207,7 +208,9 @@ export function codexCommandEnvironment(
|
||||
const value = source[key];
|
||||
if (value !== undefined) environment[key] = value;
|
||||
}
|
||||
if (source.PAPERCLIP_GITHUB_AUTH_MODE === "host" && source.PAPERCLIP_GITHUB_HOST_HOME) {
|
||||
if (usesPreconfiguredHome(source) && source.HOME) {
|
||||
environment.HOME = source.HOME;
|
||||
} else if (source.PAPERCLIP_GITHUB_AUTH_MODE === "host" && source.PAPERCLIP_GITHUB_HOST_HOME) {
|
||||
environment.HOME = source.PAPERCLIP_GITHUB_HOST_HOME;
|
||||
} else if (source.PAPERCLIP_GITHUB_LAUNCHER_DIR) {
|
||||
environment.HOME = source.PAPERCLIP_GITHUB_LAUNCHER_DIR;
|
||||
@@ -250,6 +253,7 @@ export function createIsolatedCodexAppServerArgs(
|
||||
readOnlyRoots: string[] = [],
|
||||
/** Server-registered run copy, never an environment/config-supplied root. */
|
||||
instructionWorkingCopyRoot?: string,
|
||||
protectedPaths: string[] = [],
|
||||
): string[] {
|
||||
const explicitSource = source;
|
||||
source ??= process.env;
|
||||
@@ -271,7 +275,7 @@ export function createIsolatedCodexAppServerArgs(
|
||||
if (instructionWorkingCopyRoot && source.AGENT_HOME === instructionWorkingCopyRoot) commandEnvironment.AGENT_HOME = instructionWorkingCopyRoot;
|
||||
const shellEnvironmentKeys = [...new Set([...inheritedGitHubKeys, ...Object.keys(commandEnvironment)])].sort();
|
||||
if (source.PAPERCLIP_GITHUB_LAUNCHER_DIR) readOnlyRoots = [...readOnlyRoots, source.PAPERCLIP_GITHUB_LAUNCHER_DIR];
|
||||
const deniedHostRoots = [
|
||||
const deniedHostRoots = usesPreconfiguredHome(source) ? [] : [
|
||||
...new Set(
|
||||
[source.HOME, source.CODEX_HOME]
|
||||
.filter(
|
||||
@@ -286,6 +290,8 @@ export function createIsolatedCodexAppServerArgs(
|
||||
`":minimal"="read"`,
|
||||
`":tmpdir"="none"`,
|
||||
...deniedHostRoots.map((path) => `${tomlString(path)}="none"`),
|
||||
...protectedPaths.map((path) => `${tomlString(resolve(path))}="none"`),
|
||||
...(usesPreconfiguredHome(source) && source.HOME ? [`${tomlString(source.HOME)}="write"`] : []),
|
||||
...readOnlyRoots.map((path) => `${tomlString(resolve(path))}="read"`),
|
||||
...gitRoots.read.map((path) => `${tomlString(path)}="read"`),
|
||||
...gitRoots.write.map((path) => `${tomlString(path)}="write"`),
|
||||
@@ -299,6 +305,8 @@ export function createIsolatedCodexAppServerArgs(
|
||||
`":minimal"="read"`,
|
||||
`":tmpdir"="none"`,
|
||||
...deniedHostRoots.map((path) => `${tomlString(path)}="none"`),
|
||||
...protectedPaths.map((path) => `${tomlString(resolve(path))}="none"`),
|
||||
...(usesPreconfiguredHome(source) && source.HOME ? [`${tomlString(source.HOME)}="read"`] : []),
|
||||
...readOnlyRoots.map((path) => `${tomlString(resolve(path))}="read"`),
|
||||
...[...gitRoots.read, ...gitRoots.write].map((path) => `${tomlString(path)}="read"`),
|
||||
...(instructionWorkingCopyRoot ? [`${tomlString(resolve(instructionWorkingCopyRoot))}="read"`] : []),
|
||||
@@ -367,7 +375,7 @@ export function createSecuredCodexThreadParams(
|
||||
: CODEX_SKILLLESS_PERMISSION_PROFILE;
|
||||
return {
|
||||
cwd: workingDirectory,
|
||||
config: collaborationThreadConfig(
|
||||
config: usesPreconfiguredHome(source) ? { "skills.include_instructions": true, include_collaboration_mode_instructions: includeCollaborationModeInstructions } : collaborationThreadConfig(
|
||||
includeCollaborationModeInstructions,
|
||||
includeSkillInstructions,
|
||||
),
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { realpathSync, statSync } from "node:fs";
|
||||
import { userInfo } from "node:os";
|
||||
import { isAbsolute, join } from "node:path";
|
||||
|
||||
export const PRECONFIGURED_HOME_ENV = "PAPERCLIP_RUNNER_HOME_MODE";
|
||||
export const PRECONFIGURED_HOME_KEYS = [
|
||||
"HOME", "CODEX_HOME", "CLAUDE_CONFIG_DIR",
|
||||
"XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME",
|
||||
] as const;
|
||||
|
||||
export function usesPreconfiguredHome(environment: NodeJS.ProcessEnv | undefined): boolean {
|
||||
return environment?.[PRECONFIGURED_HOME_ENV] === "preconfigured";
|
||||
}
|
||||
|
||||
/** Resolve on the execution machine; never create or change a user's directory. */
|
||||
export function preconfiguredHomeEnvironment(source: NodeJS.ProcessEnv = process.env): Record<string, string> {
|
||||
const home = source.HOME ?? userInfo().homedir;
|
||||
const result: Record<string, string> = { HOME: home };
|
||||
for (const key of PRECONFIGURED_HOME_KEYS) {
|
||||
const value = source[key];
|
||||
if (value !== undefined) result[key] = value;
|
||||
}
|
||||
for (const value of Object.values(result)) {
|
||||
if (!isAbsolute(value) || /[\0\r\n]/.test(value)) throw new Error("preconfigured_home_invalid");
|
||||
}
|
||||
if (!statSync(home).isDirectory()) throw new Error("preconfigured_home_unavailable");
|
||||
result.HOME = realpathSync(home);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function preconfiguredCodexHome(source: NodeJS.ProcessEnv = process.env): string {
|
||||
return source.CODEX_HOME ?? join(source.HOME ?? userInfo().homedir, ".codex");
|
||||
}
|
||||
@@ -2182,6 +2182,16 @@ it("allows trusted package-manager runtime roots without exposing HOME paths", (
|
||||
).toEqual(["/opt/homebrew", "/usr/local"]);
|
||||
});
|
||||
|
||||
it("trusts a preconfigured workspace through launch flags without editing the user config", () => {
|
||||
const args = createRunnerdCodexAppServerArgs({
|
||||
environment: { HOME: "/home/executor", PAPERCLIP_RUNNER_HOME_MODE: "preconfigured" },
|
||||
codexHome: "/home/executor/.codex", workingDirectory: "/work/task",
|
||||
});
|
||||
expect(args.join("\n")).toContain('projects={"/work/task"={trust_level="trusted"}}');
|
||||
expect(args.join("\n")).toContain('HOME="/home/executor"');
|
||||
expect(args.join("\n")).not.toContain('"/home/executor"="none"');
|
||||
});
|
||||
|
||||
it("denies the isolated Codex home without denying a remote execution workspace", () => {
|
||||
const args = createRunnerdCodexAppServerArgs({
|
||||
environment: {
|
||||
@@ -8601,3 +8611,30 @@ it("aborts startup promptly on close while allowing the owned cleanup command to
|
||||
await drain;
|
||||
} finally { vi.useRealTimers(); }
|
||||
});
|
||||
it("launches and closes native Codex without materializing or editing the preconfigured home", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "runner-preconfigured-launch-"));
|
||||
const home = join(root, "user-home");
|
||||
const codexHome = join(home, ".codex");
|
||||
const stateDirectory = join(root, "session");
|
||||
await mkdir(codexHome, { recursive: true });
|
||||
await mkdir(stateDirectory);
|
||||
const config = 'approval_policy = "never"\n';
|
||||
const auth = '{"user_owned":true}';
|
||||
await writeFile(join(codexHome, "config.toml"), config);
|
||||
await writeFile(join(codexHome, "auth.json"), auth);
|
||||
const bundle = createCapabilityRunnerdCodexTransport({
|
||||
homeMode: "preconfigured", environment: { ...process.env, HOME: home, CODEX_HOME: codexHome },
|
||||
runnerBinary: resolve(import.meta.dirname, "../../runner/target/debug/paperclip-runnerd"),
|
||||
codexCommand: fakeCodex, codexArgs: fakeCodexArgs(stateDirectory), stateDirectory,
|
||||
});
|
||||
try {
|
||||
await bundle.transport.request("thread/start", { cwd: stateDirectory, model: "gpt-5.6-sol", dynamicTools: [] });
|
||||
await bundle.transport.close();
|
||||
expect(await readFile(join(codexHome, "config.toml"), "utf8")).toBe(config);
|
||||
expect(await readFile(join(codexHome, "auth.json"), "utf8")).toBe(auth);
|
||||
await expect(stat(join(stateDirectory, "codex-home"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
} finally {
|
||||
await bundle.transport.close().catch(() => undefined);
|
||||
await rm(root, { force: true, recursive: true });
|
||||
}
|
||||
}, 30_000);
|
||||
@@ -5,6 +5,7 @@ import { admittedPiThinkingLevel, resolvePiThinkingLevel } from "../drivers/acpx
|
||||
import { configuredEnvironment } from "../configured-environment.js";
|
||||
import { githubCredentialEnvironment } from "../github-credential-environment.js";
|
||||
import { resolveAcpxProviderMode } from "../drivers/acpx/provider-mode.js";
|
||||
import { preconfiguredHomeEnvironment, preconfiguredCodexHome, usesPreconfiguredHome } from "../drivers/preconfigured-home.js";
|
||||
import { isAcpxCanonicalInputMethod } from "../drivers/acpx/profile-extensions.js";
|
||||
import { RunnerdTraceFrameIndex } from "./runnerd-trace-frame-index.js";
|
||||
import { waitForWarmAttachmentReadiness } from "./warm-attachment-readiness.js";
|
||||
@@ -1230,6 +1231,7 @@ export interface CapabilityRunnerdProcessEvidence {
|
||||
}
|
||||
|
||||
export interface CapabilityRunnerdCodexTransportOptions extends RunnerExecutionDriver {
|
||||
homeMode?: "isolated" | "preconfigured";
|
||||
provider?: "codex" | "opencode" | "claude_managed" | "aws_agentcore" | "acpx";
|
||||
observeCredentials?: boolean;
|
||||
onCredentialsUpdated?: (event: PrpEvent) => Promise<void>;
|
||||
@@ -2471,19 +2473,21 @@ async function settleRetainedRunnerdSessionOwned(
|
||||
if (Date.now() >= deadline) throw maintenanceDenied();
|
||||
};
|
||||
await authorize();
|
||||
await bounded(
|
||||
releaseMaterializedNativeRuntimeSkills(resolve(codexHome, "skills")),
|
||||
);
|
||||
await bounded(
|
||||
prepareIsolatedCodexHome({
|
||||
context: null,
|
||||
codexHome,
|
||||
sourceCodexHome:
|
||||
input.sourceCodexHome ?? resolveSourceCodexHome(input.environment),
|
||||
apiKey:
|
||||
input.environment?.CODEX_API_KEY ?? input.environment?.OPENAI_API_KEY,
|
||||
}),
|
||||
);
|
||||
if (!usesPreconfiguredHome(input.environment)) {
|
||||
await bounded(
|
||||
releaseMaterializedNativeRuntimeSkills(resolve(codexHome, "skills")),
|
||||
);
|
||||
await bounded(
|
||||
prepareIsolatedCodexHome({
|
||||
context: null,
|
||||
codexHome,
|
||||
sourceCodexHome:
|
||||
input.sourceCodexHome ?? resolveSourceCodexHome(input.environment),
|
||||
apiKey:
|
||||
input.environment?.CODEX_API_KEY ?? input.environment?.OPENAI_API_KEY,
|
||||
}),
|
||||
);
|
||||
}
|
||||
// A previously journaled suspend must be honored before a later drain.
|
||||
// A second exact-authority connection can then drain the retained provider
|
||||
// prefix; no old command is removed, reordered, or treated as completed.
|
||||
@@ -3338,9 +3342,10 @@ export function createCapabilityRunnerdProviderEnvironment(input: {
|
||||
}
|
||||
const environment = createSanitizedCodexEnvironment({
|
||||
...input.options.environment,
|
||||
HOME: input.codexHome,
|
||||
HOME: input.options.homeMode === "preconfigured" ? input.options.environment?.HOME : input.codexHome,
|
||||
CODEX_HOME: input.codexHome,
|
||||
});
|
||||
if (input.options.homeMode === "preconfigured") environment.PAPERCLIP_RUNNER_HOME_MODE = "preconfigured";
|
||||
for (const key of ["OPENAI_API_KEY", "CODEX_API_KEY"] as const) {
|
||||
const apiKey = input.options.environment?.[key];
|
||||
if (apiKey?.trim()) environment[key] = apiKey;
|
||||
@@ -3438,6 +3443,8 @@ export function createRunnerdCodexAppServerArgs(input: {
|
||||
environment: NodeJS.ProcessEnv | undefined;
|
||||
codexHome: string;
|
||||
codexCommand?: string;
|
||||
workingDirectory?: string;
|
||||
runtimeStateRoot?: string;
|
||||
readOnlyRoots?: string[];
|
||||
instructionWorkingCopyRoot?: string;
|
||||
disableProjectInstructions?: boolean;
|
||||
@@ -3446,18 +3453,23 @@ export function createRunnerdCodexAppServerArgs(input: {
|
||||
// runner state outside provider reach. Always bind those names to the actual
|
||||
// isolated runner home; a stale controller environment must never cause the
|
||||
// execution workspace itself to become an explicit deny root.
|
||||
return [
|
||||
const args = [
|
||||
...(input.disableProjectInstructions ? ["-c", "project_doc_max_bytes=0"] : []),
|
||||
...createIsolatedCodexAppServerArgs(
|
||||
{
|
||||
...input.environment,
|
||||
HOME: input.codexHome,
|
||||
HOME: usesPreconfiguredHome(input.environment) ? input.environment?.HOME : input.codexHome,
|
||||
CODEX_HOME: input.codexHome,
|
||||
},
|
||||
[...(input.readOnlyRoots ?? []), ...codexExecutableReadOnlyRoots(input.environment ?? {}, input.codexCommand)],
|
||||
input.instructionWorkingCopyRoot,
|
||||
usesPreconfiguredHome(input.environment) && input.runtimeStateRoot ? [input.runtimeStateRoot] : [],
|
||||
),
|
||||
];
|
||||
if (usesPreconfiguredHome(input.environment) && input.workingDirectory) {
|
||||
args.unshift("-c", `projects={${JSON.stringify(input.workingDirectory)}={trust_level="trusted"}}`);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
export function unwrapToolResponse(response: Record<string, unknown>, preserveEnvelope = false): {
|
||||
@@ -3609,6 +3621,26 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
throw new Error("acpxMode requires the ACPX provider");
|
||||
}
|
||||
if (options.provider === "acpx") resolveAcpxProviderMode(options.acpxAgent ?? "codex", options.acpxMode);
|
||||
if (options.homeMode === "preconfigured") {
|
||||
if ((options.provider ?? "codex") !== "codex" && !(options.provider === "acpx" && ["claude", "codex"].includes(options.acpxAgent ?? "codex"))) {
|
||||
throw new Error("preconfigured_home_unsupported_provider");
|
||||
}
|
||||
const source = options.environment ?? process.env;
|
||||
const homes = options.runnerFilesystemRoot ? Object.fromEntries(
|
||||
["HOME", "CODEX_HOME", "CLAUDE_CONFIG_DIR", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME"]
|
||||
.flatMap(key => source[key] === undefined ? [] : [[key, source[key]!]]),
|
||||
) : preconfiguredHomeEnvironment(source);
|
||||
if (!homes.HOME) throw new Error("preconfigured_home_unavailable");
|
||||
this.options = { ...options, environment: { ...source, ...homes,
|
||||
PAPERCLIP_RUNNER_HOME_MODE: "preconfigured",
|
||||
...(options.acpxAgent === "claude" ? { ACPX_CLAUDE_INCLUDE_USER_SETTINGS: "1" } : {}),
|
||||
} };
|
||||
}
|
||||
if (options.homeMode !== "preconfigured" && options.environment?.PAPERCLIP_RUNNER_HOME_MODE !== undefined) {
|
||||
const environment = { ...options.environment };
|
||||
delete environment.PAPERCLIP_RUNNER_HOME_MODE;
|
||||
this.options = { ...options, environment };
|
||||
}
|
||||
if (options.adoptExistingRunner && !options.stateDirectory?.trim()) {
|
||||
throw new Error("native_adopted_runner_state_directory_required");
|
||||
}
|
||||
@@ -4762,13 +4794,15 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
);
|
||||
}
|
||||
const localCodexHome = resolve(this.#root, "codex-home");
|
||||
const codexHome = this.options.runnerFilesystemRoot
|
||||
? resolve(this.options.runnerFilesystemRoot, "codex-home")
|
||||
: localCodexHome;
|
||||
const codexHome = this.options.homeMode === "preconfigured"
|
||||
? preconfiguredCodexHome(this.options.environment)
|
||||
: this.options.runnerFilesystemRoot
|
||||
? resolve(this.options.runnerFilesystemRoot, "codex-home")
|
||||
: localCodexHome;
|
||||
if (provider === "aws_agentcore") {
|
||||
mkdirSync(codexHome, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
if (provider === "codex") {
|
||||
if (provider === "codex" && this.options.homeMode !== "preconfigured") {
|
||||
await prepareIsolatedCodexHome({
|
||||
context: sourceRuntimeContext,
|
||||
codexHome: localCodexHome,
|
||||
@@ -4997,6 +5031,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
: {
|
||||
kind: provider,
|
||||
provider,
|
||||
...(this.options.homeMode === "preconfigured" ? { preconfiguredHome: { HOME: this.options.environment!.HOME, CODEX_HOME: codexHome } } : {}),
|
||||
driver:
|
||||
provider === "opencode"
|
||||
? "opencode_server"
|
||||
@@ -5014,6 +5049,8 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
createRunnerdCodexAppServerArgs({
|
||||
environment: this.options.environment,
|
||||
codexHome,
|
||||
workingDirectory: String(params.cwd ?? tmpdir()),
|
||||
runtimeStateRoot: this.options.runnerFilesystemRoot ?? this.#root,
|
||||
codexCommand: effectiveCodexCommand,
|
||||
instructionWorkingCopyRoot: runtimeContext?.instructions.workingCopy?.rootPath,
|
||||
disableProjectInstructions: this.options.disableCodexProjectInstructions,
|
||||
@@ -5413,6 +5450,13 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
this.#durableTurnId = identity.turnId;
|
||||
this.#eventIdentity = structuredClone(identity);
|
||||
const provider = this.options.provider ?? "codex";
|
||||
if (provider === "codex" && (this.options.homeMode === "preconfigured" || record(record(controlPlaneState!.runAttachTemplate).provider).preconfiguredHome !== undefined)) {
|
||||
const seed = rotatedRunAttachPayload(controlPlaneState!, desiredIdentity, this.#authorizedTools, this.options.resumeCompletionContract);
|
||||
const priorHome = record(seed.provider).preconfiguredHome;
|
||||
const expectedHome = this.options.homeMode === "preconfigured"
|
||||
? { HOME: this.options.environment!.HOME, CODEX_HOME: preconfiguredCodexHome(this.options.environment) } : undefined;
|
||||
if (JSON.stringify(priorHome) !== JSON.stringify(expectedHome)) throw new Error("runner_preconfigured_home_binding_changed");
|
||||
}
|
||||
const sourceRuntimeContext = this.options.runtimeContext ?? null;
|
||||
const runtimeContext =
|
||||
this.options.runnerRuntimeContext ?? sourceRuntimeContext;
|
||||
@@ -5421,9 +5465,11 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
? resolve(this.options.runnerFilesystemRoot, "runtime-context.json")
|
||||
: localRuntimeContextPath;
|
||||
const localCodexHome = resolve(this.#root, "codex-home");
|
||||
const codexHome = this.options.runnerFilesystemRoot
|
||||
? resolve(this.options.runnerFilesystemRoot, "codex-home")
|
||||
: localCodexHome;
|
||||
const codexHome = this.options.homeMode === "preconfigured"
|
||||
? preconfiguredCodexHome(this.options.environment)
|
||||
: this.options.runnerFilesystemRoot
|
||||
? resolve(this.options.runnerFilesystemRoot, "codex-home")
|
||||
: localCodexHome;
|
||||
const opencodeProxyPath =
|
||||
this.options.opencodeProxyPath ??
|
||||
(provider === "opencode" && !this.options.runnerFilesystemRoot
|
||||
@@ -5515,6 +5561,8 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
createRunnerdCodexAppServerArgs({
|
||||
environment: this.options.environment,
|
||||
codexHome,
|
||||
workingDirectory: this.options.resumeWorkingDirectory,
|
||||
runtimeStateRoot: this.options.runnerFilesystemRoot ?? this.#root,
|
||||
codexCommand: typeof recordedCommand === "string" ? recordedCommand : this.options.codexCommand,
|
||||
instructionWorkingCopyRoot: runtimeContext?.instructions.workingCopy?.rootPath,
|
||||
disableProjectInstructions: this.options.disableCodexProjectInstructions,
|
||||
@@ -5694,7 +5742,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
}
|
||||
if (provider === "aws_agentcore")
|
||||
mkdirSync(codexHome, { recursive: true, mode: 0o700 });
|
||||
if (provider === "codex") {
|
||||
if (provider === "codex" && this.options.homeMode !== "preconfigured") {
|
||||
await releaseMaterializedNativeRuntimeSkills(
|
||||
resolve(localCodexHome, "skills"),
|
||||
);
|
||||
@@ -5991,7 +6039,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
const skills = resolveRunnerdCodexSkillInputs(
|
||||
input.filter((item) => item.type === "skill"),
|
||||
this.options.runtimeContext ?? null,
|
||||
resolve(this.options.runnerFilesystemRoot ?? this.#root, "codex-home"),
|
||||
this.options.homeMode === "preconfigured" ? preconfiguredCodexHome(this.options.environment) : resolve(this.options.runnerFilesystemRoot ?? this.#root, "codex-home"),
|
||||
);
|
||||
if (skills.length && (this.options.provider ?? "codex") !== "codex") {
|
||||
throw new Error("Explicit skill inputs are supported only by Codex");
|
||||
|
||||
@@ -8851,3 +8851,31 @@ describe("executeNativeSession recovery", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it("fails preconfigured recovery without replacing a missing provider session", async () => {
|
||||
const checkpoint: PersistedNativeSession = {
|
||||
backendKind: "mock", sessionId: "driver-preconfigured", identity,
|
||||
providerSessionId: "provider-missing", providerRecoveryPolicy: "allow_replacement_after_resume_failure",
|
||||
cursor: "0", activeTurnId: null, pendingRuntimeRequests: [], lineage: [],
|
||||
};
|
||||
const openSession = vi.fn();
|
||||
const openReplacementSession = vi.fn();
|
||||
const backend: NativeSessionBackend = {
|
||||
descriptor: async () => ({ kind: "mock", name: "preconfigured", version: "1",
|
||||
runtimeContextCapabilities: { instructions: "native", skills: "native", mcp: "native" },
|
||||
capabilities: { resume: true, typedEvents: true, steering: false, interruption: true } }),
|
||||
openSession, openReplacementSession,
|
||||
recoverSession: async () => ({ recovered: false, reason: "provider history is missing" }),
|
||||
};
|
||||
const port: ControlPlanePort = {
|
||||
openRun: vi.fn(), appendEvent: vi.fn(), completeRun: vi.fn(),
|
||||
replayEvents: async () => ({ events: [], highestContiguousSourceSeq: 0 }),
|
||||
};
|
||||
await expect(executeNativeSession({
|
||||
input: { ...preparedInput(), schema: "paperclip.native-execution-input.v7", homeMode: "preconfigured" },
|
||||
backend, controlPlane: port, runnerInstanceId: "runner-preconfigured", persistedSession: checkpoint,
|
||||
})).rejects.toThrow("native_session_recovery_failed: provider history is missing");
|
||||
expect(openSession).not.toHaveBeenCalled();
|
||||
expect(openReplacementSession).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -2042,6 +2042,7 @@ export async function executeNativeSession(
|
||||
const providerRecoveryCheckpoint = persistedSession;
|
||||
|
||||
const replacementAllowed =
|
||||
!("homeMode" in input && input.homeMode === "preconfigured") &&
|
||||
providerRecoveryCheckpoint.providerRecoveryPolicy ===
|
||||
"allow_replacement_after_resume_failure";
|
||||
const failedProviderSession =
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"commandDigest": "sha256:18f0f91f179745db6f63aa0d2c710018b884759fa78440d7b0d3cd18b785b4d9",
|
||||
"declaration": {
|
||||
"schema": "paperclip.acpx_profile_declaration.v1",
|
||||
"agent": "pi",
|
||||
"agentProfileVersion": 23,
|
||||
"acpxVersion": "0.13.1",
|
||||
"agentServerVersion": "0.0.33",
|
||||
"agentRuntimeVersion": "1.0.0",
|
||||
"nodeVersion": "24.21.0",
|
||||
"closure": {
|
||||
"darwin-arm64": "282022db10150c6632b3444df421342e7d534bdf5d5fb1097a2e79d0625a2bcf",
|
||||
"darwin-x64": "64e251e19009f755c0b04f73ce2138246faab71a961b0f13d75ebfcc34bef12e",
|
||||
"linux-x64": "713b1fdff42fb56a1518bdc084f181d70bee8ebadc3e4b1d76321ed9108c8410"
|
||||
},
|
||||
"wrapperSha256": "9d129b3d38772e93e97080aa6c4e574ac5df4e47ce5bc331a484a9fbf8188b36",
|
||||
"helperSha256": "41e0490b617da0d60e0c8ec58ef311236f945129d99f816cff6897f78da7f91d",
|
||||
"extensionSha256": "f5818573a355702388072b28926897db546a5cf346b5171f646582ce5f9102a4",
|
||||
"agentFilesBinding": "registered-runtime-context-v1",
|
||||
"nativeQuestions": "select-confirm-input-editor-v4-root-field-types",
|
||||
"pathPolicy": "sdk-normalization-v1",
|
||||
"mcpToolNames": "provider-safe-exact-source-v1",
|
||||
"acpxPatchSha256": "00d6af17216cb0cad902b48633eb19bf0ec6b05837a984cb24661fca593f09c1",
|
||||
"nativeAssistantMessages": "pi1-rpc-deltas-v3-bound-message-and-tool-receipts",
|
||||
"nativeNotices": "session-extension-v2-bounded-runtime-failure",
|
||||
"messageProjectionSha256": "17d1e91b948dfa6a56e1905645add52b502041ad843c8d50a13b07956f22aadd",
|
||||
"noticeProjectionSha256": "014485adc690998e395d334ea3ae67f72be6060a2fc78a1185df7b8d1da28fbf",
|
||||
"sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1",
|
||||
"nativeInputDisposition": "queued-only-v1",
|
||||
"cacheWarming": "disabled-owned-decision-v1",
|
||||
"thinkingLevel": {
|
||||
"supported": [
|
||||
"off",
|
||||
"low",
|
||||
"high",
|
||||
"max"
|
||||
],
|
||||
"newConfigurationDefault": "low",
|
||||
"admission": "explicit-native-effective-before-prompt-v1",
|
||||
"recovery": "mode-bound-v1"
|
||||
},
|
||||
"modelSelection": "caller-selected-native-verified-v1",
|
||||
"providerConfigurationSourceSha256": "d5b4c1a426afc85f040c9b55358d1d645299586dc8cec72d1be2509187ca3865",
|
||||
"credentialEnvironmentSourceSha256": "c9c3650b6415430b045533eea2d7cddfe87302e03817f40ae83f7447ef0ae957",
|
||||
"runtimeSandboxSourceSha256": "4c0e8b3dca5ecaf60edbf5db26bb55a00bdf800f4523a072c69ed8a92d67f8ca",
|
||||
"recoveryIdentitySourceSha256": "61eed640ede944fcd07b25b8a2c48adb30d0e881043ca5549ce2f846ac8652ae",
|
||||
"nativeCancellation": "acknowledged-cancelled-terminal-preserves-usage-v1",
|
||||
"dependencySecurityPatchSha256": "5273a195b952f233336122842faaf650193c04f36b9359d20d4d63079b0ce814"
|
||||
}
|
||||
}
|
||||
@@ -652,6 +652,12 @@ describe("issue validators", () => {
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["preconfigured", "isolated"])("rejects task-level homeMode %s on create and update", homeMode => {
|
||||
const overrides = { assigneeAdapterOverrides: { adapterConfig: { homeMode } } };
|
||||
expect(createIssueSchema.safeParse({ title: "Task", ...overrides }).success).toBe(false);
|
||||
expect(updateIssueSchema.safeParse(overrides).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects retired model profiles in agent runtime config", () => {
|
||||
const parsed = createAgentSchema.safeParse({
|
||||
name: "Coder",
|
||||
|
||||
@@ -297,7 +297,9 @@ export const issueExecutionWorkspaceSettingsSchema = z
|
||||
|
||||
export const issueAssigneeAdapterOverridesSchema = z
|
||||
.object({
|
||||
adapterConfig: z.record(z.string(), z.unknown()).optional(),
|
||||
adapterConfig: z.record(z.string(), z.unknown())
|
||||
.refine(config => !Object.prototype.hasOwnProperty.call(config, "homeMode"), "homeMode must be set by an operator on the agent, not on a task")
|
||||
.optional(),
|
||||
useProjectWorkspace: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -35,7 +35,7 @@ diff --git a/dist/index.js b/dist/index.js
|
||||
@@ -26988,4 +26988,18 @@
|
||||
};
|
||||
+function paperclipBaseInstructions(request) {
|
||||
+ if (process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT !== "1") return void 0;
|
||||
+ if (process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT !== "1" && process.env.PAPERCLIP_RUNNER_HOME_MODE !== "preconfigured") return void 0;
|
||||
+ const prompt = request?._meta?.systemPrompt;
|
||||
+ if (typeof prompt === "string") return prompt;
|
||||
+ if (prompt && typeof prompt === "object" && typeof prompt.append === "string") {
|
||||
|
||||
@@ -52,8 +52,8 @@ assert.equal(JSON.parse(await readFile(join(server, 'package.json'), 'utf8')).na
|
||||
const { resolvePaperclipRunnerBinary } = await import(pathToFileURL(join(server, 'dist/services/native-runtime/native-codex-runner.js')));
|
||||
const { createCapabilityRunnerdCodexTransport } = await import(pathToFileURL(join(server, 'dist/vendor/paperclip-runner/live/runnerd-codex-transport.js')));
|
||||
const { QUALIFIED_ACPX_PROFILES } = await import(pathToFileURL(join(server, 'dist/vendor/paperclip-runner/drivers/acpx/qualified-profiles.js')));
|
||||
assert.equal(QUALIFIED_ACPX_PROFILES.pi.agentProfileVersion, 22);
|
||||
assert.equal(QUALIFIED_ACPX_PROFILES.pi.commandDigest, 'sha256:e92078bee3c23bec4100aa589013a44613d054cd686826534025d8019e9f39a9');
|
||||
assert.equal(QUALIFIED_ACPX_PROFILES.pi.agentProfileVersion, 23);
|
||||
assert.equal(QUALIFIED_ACPX_PROFILES.pi.commandDigest, 'sha256:18f0f91f179745db6f63aa0d2c710018b884759fa78440d7b0d3cd18b785b4d9');
|
||||
assert.equal(Object.hasOwn(QUALIFIED_ACPX_PROFILES.pi, 'qualificationModel'), false);
|
||||
assert.equal(Object.hasOwn(QUALIFIED_ACPX_PROFILES.pi, 'reportedModelId'), false);
|
||||
const daemon = resolvePaperclipRunnerBinary();
|
||||
@@ -97,7 +97,7 @@ try {
|
||||
assert.equal(state.activeTurnId, null);
|
||||
assert.equal(state.identity, null);
|
||||
assert.equal(state.providerExitUnconfirmed, false);
|
||||
console.log(JSON.stringify({ schema: 'paperclip.pi.public-npm-install.v1', target: `${process.platform}-${process.arch}`, normalPackagedDaemon: true, exactPiProfile: 22, runtime: '1.0.0', credentials: 'none', promptCalls: 0, settledMs, cleanRunnerExit: true }));
|
||||
console.log(JSON.stringify({ schema: 'paperclip.pi.public-npm-install.v1', target: `${process.platform}-${process.arch}`, normalPackagedDaemon: true, exactPiProfile: 23, runtime: '1.0.0', credentials: 'none', promptCalls: 0, settledMs, cleanRunnerExit: true }));
|
||||
} finally {
|
||||
try { await bundle?.transport.close(); }
|
||||
finally { clearTimeout(watchdog); await rm(root, { recursive: true, force: true }); }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -502,7 +502,7 @@ export async function executeHeartbeatRuntime(db: Db, input: HeartbeatRuntimeExe
|
||||
nativeExecution.runtimeContext.mcp.bindingId
|
||||
? nativeExecution.runtimeContext.mcp.digest
|
||||
: null;
|
||||
const nativeMcpServers = await measureRunPhase("execution.mcp_handoff", () => buildPaperclipRuntimeMcpServers({
|
||||
const nativeMcpServers = "homeMode" in nativeExecution && nativeExecution.homeMode === "preconfigured" ? [] : await measureRunPhase("execution.mcp_handoff", () => 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