fix(runner): repair paid provider startup paths
This commit is contained in:
parent
0f94521017
commit
e9a9abf743
|
|
@ -58,7 +58,7 @@
|
|||
"sideEffects": false,
|
||||
"scripts": {
|
||||
"build": "pnpm run check:protocol-manifest && pnpm run build:typescript && pnpm run check:capability-contract && pnpm run check:capability-inventory && pnpm run check:protocol-coverage && pnpm run check:semantic-contracts && pnpm run check:runner-workflow-traceability && pnpm run build:binary && node scripts/generate-replay-goldens.mjs --check && node scripts/generate-semantic-action-catalog.mjs --check",
|
||||
"build:typescript": "pnpm run ensure:eval-build-deps && pnpm run check:protocol-types && node ./node_modules/typescript/bin/tsc --version && node ./node_modules/typescript/bin/tsc -p tsconfig.json && node ./node_modules/typescript/bin/tsc -p tsconfig.surfaces.json",
|
||||
"build:typescript": "pnpm run ensure:eval-build-deps && pnpm run check:protocol-types && node ./node_modules/typescript/bin/tsc --version && node ./node_modules/typescript/bin/tsc -p tsconfig.json && node ./node_modules/typescript/bin/tsc -p tsconfig.surfaces.json && node scripts/build-verified-provider-entrypoints.mjs",
|
||||
"build:rust": "cargo build --manifest-path runner/Cargo.toml --locked --workspace --bins",
|
||||
"build:binary": "cargo build --release --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --bin paperclip-runnerd && node scripts/stage-runner-binary.mjs",
|
||||
"build:provider-pack": "pnpm run build:typescript && node scripts/build-provider-pack.mjs",
|
||||
|
|
@ -73,7 +73,7 @@
|
|||
"typecheck:rust": "cargo fmt --manifest-path runner/Cargo.toml --all -- --check && cargo check --manifest-path runner/Cargo.toml --locked --workspace",
|
||||
"typecheck:browser": "tsc -p tsconfig.browser.json --noEmit",
|
||||
"test": "pnpm run test:typescript && pnpm run test:rust",
|
||||
"test:typescript": "pnpm run ensure:eval-build-deps && pnpm run build:rust && node --test test/protocol-contract.test.mjs test/acpx-sidecar-contract.test.mjs test/acpx-codex-package-contract.test.mjs scripts/aws-agentcore-provisioning.test.mjs && vitest run",
|
||||
"test:typescript": "pnpm run ensure:eval-build-deps && pnpm run build:rust && node --test test/protocol-contract.test.mjs test/acpx-sidecar-contract.test.mjs test/acpx-codex-package-contract.test.mjs scripts/aws-agentcore-provisioning.test.mjs scripts/build-verified-provider-entrypoints.test.mjs && vitest run",
|
||||
"test:rust": "cargo test --release --manifest-path runner/Cargo.toml --locked --workspace",
|
||||
"test:codex": "cargo test --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --test codex_provider",
|
||||
"test:durable": "cargo test --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core durable::",
|
||||
|
|
|
|||
|
|
@ -119,6 +119,7 @@ impl AcpxSidecarTransport {
|
|||
"RUST_BACKTRACE",
|
||||
"PAPERCLIP_NATIVE_MCP_NAME",
|
||||
"PAPERCLIP_NATIVE_MCP_URL",
|
||||
"PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT",
|
||||
];
|
||||
keys.extend_from_slice(credential_keys);
|
||||
Self::start_with_environment_keys(config, &keys)
|
||||
|
|
|
|||
|
|
@ -492,7 +492,30 @@ fn process_command<E: CommandExecutor>(
|
|||
// in the effect window, recovery returns an indeterminate result and never
|
||||
// executes the same logical command twice.
|
||||
store.save(state)?;
|
||||
let execution = executor.execute(command)?;
|
||||
let execution = match executor.execute(command) {
|
||||
Ok(execution) => execution,
|
||||
Err(error) => {
|
||||
// An executor-returned error is a terminal observation, not crash
|
||||
// ambiguity. Commit it before replying so recovery can replay the
|
||||
// original provider/bootstrap failure without executing the
|
||||
// command twice. A process death inside execute still leaves the
|
||||
// pre-effect marker pending and remains indeterminate on recovery.
|
||||
let message = error.to_string();
|
||||
state.record_diagnostic(format!(
|
||||
"{} command failed: {message}",
|
||||
command.command_type
|
||||
));
|
||||
let result = state.fail_command(
|
||||
command,
|
||||
json!({
|
||||
"code": "command_execution_failed",
|
||||
"message": message,
|
||||
}),
|
||||
)?;
|
||||
store.save(state)?;
|
||||
return Ok((result, CommandLifecycle::Continue));
|
||||
}
|
||||
};
|
||||
for (event_type, priority, payload) in execution.events {
|
||||
state.enqueue_event(config, event_type, priority, payload)?;
|
||||
}
|
||||
|
|
@ -566,6 +589,10 @@ mod tests {
|
|||
calls: usize,
|
||||
}
|
||||
|
||||
struct FailingExecutor {
|
||||
calls: usize,
|
||||
}
|
||||
|
||||
struct RetainingEventExecutor {
|
||||
events: VecDeque<PolledEvent>,
|
||||
fail_acknowledgement: bool,
|
||||
|
|
@ -578,6 +605,15 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
impl CommandExecutor for FailingExecutor {
|
||||
fn execute(&mut self, _command: &Command) -> Result<CommandExecution, DurableRunnerError> {
|
||||
self.calls += 1;
|
||||
Err(DurableRunnerError::invalid(
|
||||
"provider bootstrap rejected authorization=Bearer test-secret",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl CommandExecutor for RetainingEventExecutor {
|
||||
fn execute(&mut self, _command: &Command) -> Result<CommandExecution, DurableRunnerError> {
|
||||
Ok(CommandExecution::result(json!({"status": "completed"})))
|
||||
|
|
@ -758,6 +794,78 @@ mod tests {
|
|||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn executor_failure_is_durable_and_does_not_become_indeterminate() {
|
||||
let directory = std::env::temp_dir().join(format!(
|
||||
"paperclip-runner-command-failure-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&directory);
|
||||
let config = config(directory.clone());
|
||||
let store = DurableStateStore::new(&directory).unwrap();
|
||||
let (mut state, _) = store.load_or_create(&config).unwrap();
|
||||
let mut executor = FailingExecutor { calls: 0 };
|
||||
let command = command("session.open");
|
||||
|
||||
let failed = process_command(&mut state, &store, &config, &mut executor, &command)
|
||||
.unwrap()
|
||||
.0;
|
||||
let (mut recovered, existed) = store.load_or_create(&config).unwrap();
|
||||
let replay = process_command(&mut recovered, &store, &config, &mut executor, &command)
|
||||
.unwrap()
|
||||
.0;
|
||||
|
||||
assert!(existed);
|
||||
assert_eq!(executor.calls, 1);
|
||||
assert_eq!(failed, replay);
|
||||
assert_eq!(failed.status, "failed");
|
||||
assert_eq!(failed.result["code"], "command_execution_failed");
|
||||
assert_eq!(
|
||||
failed.result["message"],
|
||||
"provider bootstrap rejected authorization=Bearer [REDACTED]"
|
||||
);
|
||||
assert!(recovered.diagnostics.iter().any(|diagnostic| {
|
||||
diagnostic
|
||||
== "session.open command failed: provider bootstrap rejected authorization=Bearer [REDACTED]"
|
||||
}));
|
||||
assert!(recovered
|
||||
.diagnostics
|
||||
.iter()
|
||||
.all(|diagnostic| !diagnostic.contains("test-secret")));
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_death_after_journaling_remains_indeterminate_without_reexecution() {
|
||||
let directory = std::env::temp_dir().join(format!(
|
||||
"paperclip-runner-command-indeterminate-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&directory);
|
||||
let config = config(directory.clone());
|
||||
let store = DurableStateStore::new(&directory).unwrap();
|
||||
let (mut state, _) = store.load_or_create(&config).unwrap();
|
||||
let command = command("session.open");
|
||||
|
||||
assert_eq!(
|
||||
state.begin_command(&command).unwrap(),
|
||||
CommandDisposition::Execute
|
||||
);
|
||||
store.save(&state).unwrap();
|
||||
|
||||
let (mut recovered, existed) = store.load_or_create(&config).unwrap();
|
||||
let mut executor = CountingExecutor { calls: 0 };
|
||||
let replay = process_command(&mut recovered, &store, &config, &mut executor, &command)
|
||||
.unwrap()
|
||||
.0;
|
||||
|
||||
assert!(existed);
|
||||
assert_eq!(executor.calls, 0);
|
||||
assert_eq!(replay.status, "indeterminate");
|
||||
assert_eq!(replay.result["code"], "execution_indeterminate");
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_shutdown_replay_still_stops_after_delivery() {
|
||||
let directory = std::env::temp_dir().join(format!(
|
||||
|
|
|
|||
|
|
@ -537,6 +537,28 @@ impl DurableState {
|
|||
command: &Command,
|
||||
result: Value,
|
||||
) -> Result<StoredCommandResult, DurableRunnerError> {
|
||||
self.finish_command(command, "completed", result)
|
||||
}
|
||||
|
||||
pub fn fail_command(
|
||||
&mut self,
|
||||
command: &Command,
|
||||
result: Value,
|
||||
) -> Result<StoredCommandResult, DurableRunnerError> {
|
||||
self.finish_command(command, "failed", result)
|
||||
}
|
||||
|
||||
fn finish_command(
|
||||
&mut self,
|
||||
command: &Command,
|
||||
status: &str,
|
||||
result: Value,
|
||||
) -> Result<StoredCommandResult, DurableRunnerError> {
|
||||
if status != "completed" && status != "failed" {
|
||||
return Err(DurableRunnerError::invalid(
|
||||
"durable command terminal status is unsupported",
|
||||
));
|
||||
}
|
||||
{
|
||||
let stored = self
|
||||
.processed_commands
|
||||
|
|
@ -568,7 +590,7 @@ impl DurableState {
|
|||
.processed_commands
|
||||
.get_mut(&command.command_id)
|
||||
.expect("pending command was checked above");
|
||||
stored.status = "completed".to_owned();
|
||||
stored.status = status.to_owned();
|
||||
stored.result = sanitized_result;
|
||||
Ok(stored.clone())
|
||||
}
|
||||
|
|
@ -903,7 +925,7 @@ fn validate_binding(
|
|||
|| command.controller_seq > state.last_controller_command_seq
|
||||
|| !matches!(
|
||||
command.status.as_str(),
|
||||
"pending" | "completed" | "indeterminate"
|
||||
"pending" | "completed" | "failed" | "indeterminate"
|
||||
)
|
||||
{
|
||||
return Err(DurableRunnerError::invalid(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
import { builtinModules } from "node:module";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
import { build } from "esbuild";
|
||||
|
||||
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
export const verifiedProviderEntrypoints = Object.freeze([
|
||||
Object.freeze({
|
||||
name: "acpx-runtime-sidecar",
|
||||
source: resolve(packageRoot, "src/cli/acpx-runtime-sidecar.ts"),
|
||||
output: resolve(packageRoot, "dist/cli/acpx-runtime-sidecar.js"),
|
||||
}),
|
||||
Object.freeze({
|
||||
name: "opencode-app-server-proxy",
|
||||
source: resolve(packageRoot, "src/cli/opencode-app-server-proxy.ts"),
|
||||
output: resolve(packageRoot, "dist/cli/opencode-app-server-proxy.js"),
|
||||
}),
|
||||
]);
|
||||
|
||||
const nodeBuiltins = new Set([
|
||||
...builtinModules,
|
||||
...builtinModules.map((name) => `node:${name}`),
|
||||
]);
|
||||
|
||||
function assertSelfContainedBundle(entrypoint, result) {
|
||||
const outputs = Object.entries(result.metafile.outputs).filter(
|
||||
([, output]) => output.entryPoint !== undefined,
|
||||
);
|
||||
if (outputs.length !== 1) {
|
||||
throw new Error(
|
||||
`${entrypoint.name} bundle emitted ${outputs.length} entrypoint outputs instead of one`,
|
||||
);
|
||||
}
|
||||
const imports = outputs[0][1].imports;
|
||||
for (const dependency of imports) {
|
||||
if (!dependency.external || !nodeBuiltins.has(dependency.path)) {
|
||||
throw new Error(
|
||||
`${entrypoint.name} bundle retained a non-builtin import: ${dependency.path}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function bundleVerifiedProviderEntrypoints({ write = true } = {}) {
|
||||
const results = [];
|
||||
for (const entrypoint of verifiedProviderEntrypoints) {
|
||||
const result = await build({
|
||||
entryPoints: [entrypoint.source],
|
||||
outfile: entrypoint.output,
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
format: "esm",
|
||||
target: "node24",
|
||||
packages: "bundle",
|
||||
splitting: false,
|
||||
sourcemap: false,
|
||||
legalComments: "none",
|
||||
metafile: true,
|
||||
treeShaking: true,
|
||||
write,
|
||||
logLevel: "silent",
|
||||
});
|
||||
assertSelfContainedBundle(entrypoint, result);
|
||||
results.push({ entrypoint, result });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
const invokedPath = process.argv[1]
|
||||
? pathToFileURL(resolve(process.argv[1])).href
|
||||
: null;
|
||||
if (invokedPath === import.meta.url) {
|
||||
await bundleVerifiedProviderEntrypoints();
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
bundleVerifiedProviderEntrypoints,
|
||||
verifiedProviderEntrypoints,
|
||||
} from "./build-verified-provider-entrypoints.mjs";
|
||||
|
||||
test("verified JS provider entrypoints bundle into one descriptor-safe file", async () => {
|
||||
const bundles = await bundleVerifiedProviderEntrypoints({ write: false });
|
||||
assert.equal(bundles.length, verifiedProviderEntrypoints.length);
|
||||
for (const { entrypoint, result } of bundles) {
|
||||
assert.equal(result.outputFiles?.length, 1, entrypoint.name);
|
||||
const source = result.outputFiles[0].text;
|
||||
assert.match(source, /^#!\/usr\/bin\/env node\n/);
|
||||
}
|
||||
});
|
||||
|
|
@ -192,6 +192,48 @@ it("preserves an explicit OpenCode permission mode at the runner spawn boundary"
|
|||
expect(launches[0]!.environment.PAPERCLIP_OPENCODE_COMMAND).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves the controller-selected ACPX provider package root", () => {
|
||||
const launches: RunnerProcessLaunchSpec[] = [];
|
||||
spawnRunner({
|
||||
connection: { mode: "connect", connectUrl: "ws://127.0.0.1:43127" },
|
||||
stateDirectory: "/tmp/paperclip-runner-test",
|
||||
identity,
|
||||
ticket: "bootstrap-ticket",
|
||||
maxOutboxBytes: 256 * 1024,
|
||||
p0ReserveBytes: 64 * 1024,
|
||||
runnerVersion: expectedRunnerVersion,
|
||||
runnerDigest: expectedRunnerDigest,
|
||||
environment: {
|
||||
PATH: "/bin",
|
||||
PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT: "/verified/provider-pack",
|
||||
NODE_PATH: "/untrusted/modules",
|
||||
},
|
||||
processLauncher: (spec) => {
|
||||
launches.push(spec);
|
||||
return {
|
||||
child: {
|
||||
pid: 42,
|
||||
exitCode: null,
|
||||
signalCode: null,
|
||||
kill: () => true,
|
||||
},
|
||||
completion: Promise.resolve({
|
||||
code: 0,
|
||||
signal: null,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
expect(launches).toHaveLength(1);
|
||||
expect(
|
||||
launches[0]!.environment.PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT,
|
||||
).toBe("/verified/provider-pack");
|
||||
expect(launches[0]!.environment.NODE_PATH).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves file-backed AWS workload identity at the runner spawn boundary", () => {
|
||||
const launches: RunnerProcessLaunchSpec[] = [];
|
||||
spawnRunner({
|
||||
|
|
|
|||
|
|
@ -1892,6 +1892,7 @@ const runnerExplicitProviderEnvironmentKeys = [
|
|||
"PAPERCLIP_NATIVE_MCP_URL",
|
||||
"PAPERCLIP_NATIVE_MCP_TOKEN",
|
||||
"PAPERCLIP_NATIVE_RUNTIME_CONTEXT_PATH",
|
||||
"PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT",
|
||||
"PAPERCLIP_ACPX_PROVIDER_RECOVERY_POLICY",
|
||||
"PAPERCLIP_PROVIDER_TRACE_PATH",
|
||||
"PAPERCLIP_PROVIDER_TRACE_MAX_BYTES",
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { resolveQualifiedAcpxProfile } from "./qualified-profiles.js";
|
|||
import {
|
||||
awaitVerifiedAcpxProviderExit,
|
||||
awaitVerifiedAcpxProviderOwnership,
|
||||
createAcpxPackageJsonResolver,
|
||||
guardSnapshotModuleLookup,
|
||||
guardSnapshotModuleResolution,
|
||||
reapCurrentProviderProcessGroup,
|
||||
|
|
@ -48,6 +49,31 @@ afterEach(async () => {
|
|||
});
|
||||
|
||||
describe("ACPX installation integrity", () => {
|
||||
it("anchors dynamic provider package resolution at an explicit root", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-acpx-package-root-"));
|
||||
temporaryDirectories.push(root);
|
||||
const providerDirectory = join(root, "node_modules", "qualified-provider");
|
||||
const providerPackageJson = join(providerDirectory, "package.json");
|
||||
await mkdir(providerDirectory, { recursive: true });
|
||||
await Promise.all([
|
||||
writeFile(join(root, "package.json"), JSON.stringify({ private: true })),
|
||||
writeFile(
|
||||
providerPackageJson,
|
||||
JSON.stringify({ name: "qualified-provider", version: "1.0.0" }),
|
||||
),
|
||||
]);
|
||||
|
||||
expect(createAcpxPackageJsonResolver(root)("qualified-provider")).toBe(
|
||||
providerPackageJson,
|
||||
);
|
||||
expect(() =>
|
||||
createAcpxPackageJsonResolver("relative/provider-pack"),
|
||||
).toThrow("explicit normalized absolute path");
|
||||
expect(() => createAcpxPackageJsonResolver(undefined)).toThrow(
|
||||
"explicit normalized absolute path",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an unregistered provider exit proof", async () => {
|
||||
await expect(
|
||||
awaitVerifiedAcpxProviderExit({} as ChildProcess),
|
||||
|
|
|
|||
|
|
@ -198,6 +198,25 @@ const providerExitProof = new WeakMap<ChildProcess, Promise<void>>();
|
|||
|
||||
export type AcpxPackageJsonResolver = (packageName: string) => string;
|
||||
|
||||
export function createAcpxPackageJsonResolver(
|
||||
providerPackageRoot: string | undefined,
|
||||
): AcpxPackageJsonResolver {
|
||||
const root = providerPackageRoot?.trim();
|
||||
if (
|
||||
!root ||
|
||||
!isAbsolute(root) ||
|
||||
root.includes("\0") ||
|
||||
resolve(root) !== root
|
||||
) {
|
||||
throw new Error(
|
||||
"ACPX provider package root must be an explicit normalized absolute path",
|
||||
);
|
||||
}
|
||||
const providerRequire = createRequire(resolve(root, "package.json"));
|
||||
return (packageName) =>
|
||||
providerRequire.resolve(`${packageName}/package.json`);
|
||||
}
|
||||
|
||||
export interface VerifiedAcpxInstallation {
|
||||
readonly commandDigest: string;
|
||||
readonly agentServerPackageJsonPath: string;
|
||||
|
|
@ -469,6 +488,12 @@ export async function verifyQualifiedAcpxInstallation(
|
|||
}
|
||||
|
||||
function defaultPackageJsonResolver(packageName: string): string {
|
||||
const providerPackageRoot = process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT;
|
||||
if (providerPackageRoot !== undefined) {
|
||||
return createAcpxPackageJsonResolver(providerPackageRoot)(packageName);
|
||||
}
|
||||
// Source-mode and direct runtimes still have a stable module URL. The
|
||||
// descriptor-backed runner sidecar always receives the explicit root above.
|
||||
return createRequire(import.meta.url).resolve(`${packageName}/package.json`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -117,6 +117,19 @@ it.each(["acpx-runtime-sidecar.js", "opencode-app-server-proxy.js"] as const)(
|
|||
},
|
||||
);
|
||||
|
||||
it("derives the ACPX package root only from the verified dist/cli layout", () => {
|
||||
expect(
|
||||
runnerdLaunchProfileInternals.acpxProviderPackageRoot(
|
||||
"/provider-pack/dist/cli/acpx-runtime-sidecar.js",
|
||||
),
|
||||
).toBe("/provider-pack");
|
||||
expect(() =>
|
||||
runnerdLaunchProfileInternals.acpxProviderPackageRoot(
|
||||
"/unverified/acpx-runtime-sidecar.js",
|
||||
),
|
||||
).toThrow("ACPX sidecar must use the provider package dist/cli layout");
|
||||
});
|
||||
|
||||
it("requires a provider-pack authority for remote ACPX artifact hashes", () => {
|
||||
expect(() =>
|
||||
runnerdLaunchProfileInternals.acpxRunnerLaunchProfile(
|
||||
|
|
@ -409,6 +422,7 @@ it.each([
|
|||
environment: {
|
||||
PATH: "/bin",
|
||||
...credentialEnvironment,
|
||||
PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT: "/attacker/package-root",
|
||||
PAPERCLIP_API_KEY: "must-not-reach-provider",
|
||||
DATABASE_URL: "must-not-reach-provider",
|
||||
},
|
||||
|
|
@ -424,6 +438,8 @@ it.each([
|
|||
codexHome: "/isolated/codex-home",
|
||||
runtimeContextPath: "/isolated/runtime-context.json",
|
||||
hasRuntimeContext: true,
|
||||
acpxSidecarPath:
|
||||
"/verified/provider-pack/dist/cli/acpx-runtime-sidecar.js",
|
||||
});
|
||||
|
||||
expect(environment).toMatchObject({
|
||||
|
|
@ -432,6 +448,7 @@ it.each([
|
|||
PAPERCLIP_RUN_ID: "run-1",
|
||||
PAPERCLIP_NORMALIZED_SESSION_ID: "session-1",
|
||||
PAPERCLIP_NATIVE_RUNTIME_CONTEXT_PATH: "/isolated/runtime-context.json",
|
||||
PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT: "/verified/provider-pack",
|
||||
});
|
||||
for (const key of allowed)
|
||||
expect(environment[key]).toBe(credentialEnvironment[key]);
|
||||
|
|
|
|||
|
|
@ -1111,6 +1111,20 @@ function resolveBuildOwnedCliArtifact(
|
|||
);
|
||||
}
|
||||
|
||||
function acpxProviderPackageRoot(sidecarScript: string): string {
|
||||
const cliDirectory = dirname(sidecarScript);
|
||||
if (
|
||||
basename(sidecarScript) !== "acpx-runtime-sidecar.js" ||
|
||||
basename(cliDirectory) !== "cli" ||
|
||||
basename(dirname(cliDirectory)) !== "dist"
|
||||
) {
|
||||
throw new Error(
|
||||
"runner_provider_package_root_incompatible: ACPX sidecar must use the provider package dist/cli layout",
|
||||
);
|
||||
}
|
||||
return resolve(cliDirectory, "../..");
|
||||
}
|
||||
|
||||
function acpxRunnerLaunchProfile(
|
||||
options: CapabilityRunnerdCodexTransportOptions,
|
||||
command: string,
|
||||
|
|
@ -1279,6 +1293,7 @@ export function createCapabilityRunnerdProviderEnvironment(input: {
|
|||
codexHome: string;
|
||||
runtimeContextPath: string;
|
||||
hasRuntimeContext: boolean;
|
||||
acpxSidecarPath?: string;
|
||||
}): NodeJS.ProcessEnv {
|
||||
const commonIdentity = {
|
||||
PAPERCLIP_RUNNER_INSTANCE_ID: input.identity.runnerInstanceId,
|
||||
|
|
@ -1300,12 +1315,21 @@ export function createCapabilityRunnerdProviderEnvironment(input: {
|
|||
};
|
||||
}
|
||||
if (input.provider === "acpx") {
|
||||
const sidecarPath =
|
||||
input.acpxSidecarPath ??
|
||||
input.options.acpxSidecarPath ??
|
||||
resolve(packageRoot, "dist", "cli", "acpx-runtime-sidecar.js");
|
||||
return {
|
||||
...createSanitizedAcpxSpawnInput(
|
||||
input.options.environment,
|
||||
input.options.acpxAgent ?? "codex",
|
||||
).env,
|
||||
...commonIdentity,
|
||||
// The verified sidecar bundle cannot use import.meta.url while Node
|
||||
// executes it through /proc/self/fd. Anchor its closed provider package
|
||||
// lookups at the package that owns the already-authenticated bundle.
|
||||
PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT:
|
||||
acpxProviderPackageRoot(sidecarPath),
|
||||
...(input.options.providerRecoveryPolicy ===
|
||||
"allow_replacement_after_governed_wait"
|
||||
? {
|
||||
|
|
@ -2354,6 +2378,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
|||
codexHome,
|
||||
runtimeContextPath,
|
||||
hasRuntimeContext: runtimeContext !== null,
|
||||
acpxSidecarPath,
|
||||
}),
|
||||
this.options.environment,
|
||||
),
|
||||
|
|
@ -2657,6 +2682,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
|||
codexHome,
|
||||
runtimeContextPath,
|
||||
hasRuntimeContext: runtimeContext !== null,
|
||||
acpxSidecarPath,
|
||||
}),
|
||||
this.options.environment,
|
||||
),
|
||||
|
|
@ -3361,6 +3387,7 @@ export const createRunnerdCodexTransport =
|
|||
createCapabilityRunnerdCodexTransport;
|
||||
|
||||
export const runnerdLaunchProfileInternals = Object.freeze({
|
||||
acpxProviderPackageRoot,
|
||||
acpxRunnerLaunchProfile,
|
||||
resolveBuildOwnedCliArtifact,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -93,6 +93,12 @@ describe("runner E2E catalog", () => {
|
|||
expectedRunCount: 2,
|
||||
});
|
||||
expect(localQuestion?.buildPrompt("nonce")).toContain("ask_user_questions");
|
||||
expect(localQuestion?.buildPrompt("nonce")).toContain(
|
||||
"do not spell, quote, repeat, announce, or include PAPERCLIP_E2E_QUESTION_DONE_nonce",
|
||||
);
|
||||
expect(localQuestion?.buildPrompt("nonce")).toContain(
|
||||
"refer to it only as “the terminal marker.”",
|
||||
);
|
||||
expect(restartQuestion).toMatchObject({
|
||||
flow: "question_resume_completion",
|
||||
expectedRunCount: 2,
|
||||
|
|
|
|||
|
|
@ -572,6 +572,7 @@ const structuredQuestionResumeTask = {
|
|||
[
|
||||
"Ask the user one structured question before completing this task.",
|
||||
"The question must be required, single-select, and offer Cobalt and Amber. Do not publish a final answer or mark the task Done while it is pending.",
|
||||
`Before the answer arrives, do not spell, quote, repeat, announce, or include PAPERCLIP_E2E_QUESTION_DONE_${nonce} in any visible response, comment, or tool payload; refer to it only as “the terminal marker.”`,
|
||||
`In a native runner, call request_human_input exactly once with idempotencyKey \`question-${nonce}\`, interactionKind \`questions\`, title \`Verification word\`, prompt \`Choose the verification word\`, continuationPolicy \`wake_assignee\`, and payload {version:1,questions:[{id:\`verification-word\`,prompt:\`Choose the verification word.\`,selectionMode:\`single\`,required:true,options:[{id:\`cobalt\`,label:\`Cobalt\`},{id:\`amber\`,label:\`Amber\`}]}]}.`,
|
||||
"In a legacy runner, POST /api/issues/$PAPERCLIP_TASK_ID/interactions with {kind:`ask_user_questions`,continuationPolicy:`wake_assignee`,payload:{version:1,questions:[{id:`verification-word`,prompt:`Choose the verification word.`,selectionMode:`single`,required:true,options:[{id:`cobalt`,label:`Cobalt`},{id:`amber`,label:`Amber`}]}]}} using Authorization and X-Paperclip-Run-Id, then move the issue to `in_review`.",
|
||||
`After the answer arrives, if it is Cobalt, publish exactly PAPERCLIP_E2E_QUESTION_DONE_${nonce} once as the complete visible response and mark the task Done.`,
|
||||
|
|
|
|||
|
|
@ -178,6 +178,85 @@ describe("nativeRunEventsToTranscript", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("coalesces sparse Codex tool lifecycle events at the named write boundary", () => {
|
||||
const transcript = nativeRunEventsToTranscript([
|
||||
event(1, "tool.execution.started", {
|
||||
schema: "paperclip.tool.execution.v1",
|
||||
executionId: "exec-write-plan",
|
||||
transport: "dynamic",
|
||||
operation: "unknown",
|
||||
name: null,
|
||||
status: "running",
|
||||
output: null,
|
||||
}),
|
||||
itemEvent(2, "item.started", "exec-write-plan", {
|
||||
kind: "dynamicToolCall",
|
||||
item: { id: "exec-write-plan" },
|
||||
}),
|
||||
itemEvent(3, "item.started", "exec-write-plan", {
|
||||
kind: "dynamicToolCall",
|
||||
item: {
|
||||
type: "tool_use",
|
||||
id: "exec-write-plan",
|
||||
name: "write_document",
|
||||
input: {
|
||||
key: "plan",
|
||||
title: "Plan",
|
||||
body: "Ship the durable runner.",
|
||||
},
|
||||
},
|
||||
}),
|
||||
itemEvent(4, "item.completed", "exec-write-plan", {
|
||||
kind: "dynamicToolCall",
|
||||
item: {
|
||||
type: "tool_result",
|
||||
id: "exec-write-plan",
|
||||
tool_use_id: "exec-write-plan",
|
||||
result: {
|
||||
commandKind: "write_document",
|
||||
revisionId: "revision-1",
|
||||
},
|
||||
},
|
||||
}),
|
||||
event(5, "tool.execution.completed", {
|
||||
schema: "paperclip.tool.execution.v1",
|
||||
executionId: "exec-write-plan",
|
||||
transport: "dynamic",
|
||||
operation: "unknown",
|
||||
name: null,
|
||||
status: "completed",
|
||||
output: null,
|
||||
}),
|
||||
itemEvent(6, "item.completed", "exec-write-plan", {
|
||||
kind: "dynamicToolCall",
|
||||
item: { id: "exec-write-plan", status: "completed" },
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(transcript).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "tool_call",
|
||||
name: "write_document",
|
||||
toolUseId: "exec-write-plan",
|
||||
input: {
|
||||
key: "plan",
|
||||
title: "Plan",
|
||||
body: "Ship the durable runner.",
|
||||
},
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: "tool_result",
|
||||
toolUseId: "exec-write-plan",
|
||||
toolName: "write_document",
|
||||
content: JSON.stringify({
|
||||
commandKind: "write_document",
|
||||
revisionId: "revision-1",
|
||||
}),
|
||||
isError: false,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("streams deltas until a loss-resistant completed item is available", () => {
|
||||
expect(nativeRunEventsToTranscript([
|
||||
event(1, "item.delta", { itemId: "message-1", kind: "agentMessage", text: "Still " }),
|
||||
|
|
|
|||
|
|
@ -503,10 +503,44 @@ function timestamp(event: HeartbeatRunEvent, envelope: Record<string, unknown>):
|
|||
return Number.isNaN(Date.parse(createdAt)) ? new Date(0).toISOString() : createdAt;
|
||||
}
|
||||
|
||||
function toolPresentation(payload: Record<string, unknown>): { name: string; input: unknown } {
|
||||
interface NativeToolItemDetails {
|
||||
name: string | null;
|
||||
input?: unknown;
|
||||
result?: unknown;
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
function nativeToolItemDetails(
|
||||
payload: Record<string, unknown>,
|
||||
): { id: string | null; details: NativeToolItemDetails } | null {
|
||||
const item = normalizedItem(payload);
|
||||
const kind = (text(item.type) ?? "").replaceAll("_", "").toLowerCase();
|
||||
if (kind !== "tooluse" && kind !== "toolresult") return null;
|
||||
const id = kind === "toolresult"
|
||||
? text(item.tool_use_id) ?? text(item.id)
|
||||
: text(item.id);
|
||||
return {
|
||||
id,
|
||||
details: {
|
||||
name: text(item.name),
|
||||
...(Object.prototype.hasOwnProperty.call(item, "input")
|
||||
? { input: item.input }
|
||||
: {}),
|
||||
...(Object.prototype.hasOwnProperty.call(item, "result")
|
||||
? { result: item.result }
|
||||
: {}),
|
||||
isError: item.isError === true || item.is_error === true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toolPresentation(
|
||||
payload: Record<string, unknown>,
|
||||
item?: NativeToolItemDetails,
|
||||
): { name: string; input: unknown } {
|
||||
const transport = text(payload.transport);
|
||||
const operation = text(payload.operation);
|
||||
const reportedName = text(payload.name);
|
||||
const reportedName = text(payload.name) ?? item?.name ?? null;
|
||||
if (transport === "process") {
|
||||
return {
|
||||
name: "Bash",
|
||||
|
|
@ -515,7 +549,7 @@ function toolPresentation(payload: Record<string, unknown>): { name: string; inp
|
|||
}
|
||||
return {
|
||||
name: reportedName ?? operation ?? "Tool",
|
||||
input: {
|
||||
input: item?.input ?? {
|
||||
...(operation ? { operation } : {}),
|
||||
...(text(payload.namespace) ? { namespace: text(payload.namespace) } : {}),
|
||||
...(text(payload.target) ? { target: text(payload.target) } : {}),
|
||||
|
|
@ -531,6 +565,7 @@ function toolPresentation(payload: Record<string, unknown>): { name: string; inp
|
|||
export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[]): TranscriptEntry[] {
|
||||
const entries: TranscriptEntry[] = [];
|
||||
const startedToolIds = new Set<string>();
|
||||
const completedToolIds = new Set<string>();
|
||||
let hasFinalAssistantMessage = false;
|
||||
let usageSummary: {
|
||||
ts: string;
|
||||
|
|
@ -569,6 +604,7 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[]
|
|||
const completedAgentMessageIds = new Set<string>();
|
||||
const completedReasoningIds = new Set<string>();
|
||||
const completionItemIdentityById = new Map<string, ItemIdentity>();
|
||||
const nativeToolItemsById = new Map<string, NativeToolItemDetails>();
|
||||
for (const event of orderedEvents) {
|
||||
if (!isItemIdentityEvent(event.eventType)) continue;
|
||||
const envelope = record(event.payload?.prpEvent);
|
||||
|
|
@ -583,6 +619,25 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[]
|
|||
if (!payload) continue;
|
||||
const itemId = normalizedItemId(envelope, payload);
|
||||
if (!itemId) continue;
|
||||
const toolItem = nativeToolItemDetails(payload);
|
||||
if (toolItem) {
|
||||
const toolId = toolItem.id ?? itemId;
|
||||
const previous = nativeToolItemsById.get(toolId);
|
||||
nativeToolItemsById.set(toolId, {
|
||||
name: toolItem.details.name ?? previous?.name ?? null,
|
||||
...(toolItem.details.input !== undefined
|
||||
? { input: toolItem.details.input }
|
||||
: previous?.input !== undefined
|
||||
? { input: previous.input }
|
||||
: {}),
|
||||
...(toolItem.details.result !== undefined
|
||||
? { result: toolItem.details.result }
|
||||
: previous?.result !== undefined
|
||||
? { result: previous.result }
|
||||
: {}),
|
||||
isError: toolItem.details.isError || previous?.isError === true,
|
||||
});
|
||||
}
|
||||
const identity = resolveItemIdentity(
|
||||
payload,
|
||||
completionItemIdentityById.get(itemId),
|
||||
|
|
@ -700,7 +755,8 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[]
|
|||
if (payload.schema !== TOOL_EXECUTION_SCHEMA) continue;
|
||||
const executionId = text(payload.executionId);
|
||||
if (!executionId) continue;
|
||||
const presentation = toolPresentation(payload);
|
||||
const nativeToolItem = nativeToolItemsById.get(executionId);
|
||||
const presentation = toolPresentation(payload, nativeToolItem);
|
||||
if (!startedToolIds.has(executionId)) {
|
||||
startedToolIds.add(executionId);
|
||||
entries.push({
|
||||
|
|
@ -711,14 +767,24 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[]
|
|||
toolUseId: executionId,
|
||||
});
|
||||
}
|
||||
if (event.eventType === "tool.execution.completed") {
|
||||
if (event.eventType === "tool.execution.completed" && !completedToolIds.has(executionId)) {
|
||||
completedToolIds.add(executionId);
|
||||
const output = text(payload.output);
|
||||
let content = output ?? "";
|
||||
if (!output && nativeToolItem?.result !== undefined) {
|
||||
try {
|
||||
content = JSON.stringify(nativeToolItem.result) ?? "";
|
||||
} catch {
|
||||
content = "Tool result could not be serialized";
|
||||
}
|
||||
}
|
||||
entries.push({
|
||||
kind: "tool_result",
|
||||
ts,
|
||||
toolUseId: executionId,
|
||||
toolName: presentation.name,
|
||||
content: text(payload.output) ?? "",
|
||||
isError: payload.status === "failed",
|
||||
content,
|
||||
isError: payload.status === "failed" || nativeToolItem?.isError === true,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
|
|
|
|||
Loading…
Reference in New Issue