feat(runner): add flagged Codex execution adapter (#12188)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The Paperclip Runner now has protocol, provider, tool, package, persistence, and hidden server boundaries. > - The server still cannot select that path for a real agent heartbeat. > - A new runtime must not change any existing direct adapter. > - An experimental runtime must fail closed when its rollout flag is off. > - This pull request adds one guarded Codex vertical slice through runnerd. > - The benefit is a production-built runner path that users cannot start by default. ## Linked Issues or Issue Description Refs #11962 Refs #12111 Refs #12169 Refs #12176 **Subsystem affected** Cross-cutting. The change affects the runner package, server orchestration, shared settings, and adapter configuration UI. **Problem or motivation** The hidden PRP coordinator cannot execute a real heartbeat. The application also needs an explicit rollout boundary before it can expose the experimental runner. Existing direct adapters must keep their current execution and finalization behavior. **Proposed solution** Add `paperclip_runner` as a Codex-only adapter behind the default-off `enableNativeRunner` instance flag. Select the native runtime only for that adapter. Persist the run binding before runnerd starts. Wait for the durable PRP result and terminal event. Resume the real Codex provider thread on later heartbeats. Keep persisted native runs readable and recoverable after the flag changes. **Alternatives considered** The server could route `codex_local` through runnerd. That option would change an existing adapter and weaken rollback safety. The server could expose all providers now. That option would add unreviewed provider behavior. The build could depend on a prebuilt runner binary. That option would make source builds architecture-dependent and difficult to verify. **Roadmap alignment** This work supports the shipped enforced-outcomes, governed-tool, and self-healing-run milestones. It does not add a new roadmap surface. It is the guarded execution step after the merged hidden runner boundaries. **Additional context** This is the next replacement for the closed large runner pull request. Task-thread presentation remains a separate follow-up so this change can preserve the current direct-adapter UI. ## What Changed - Add `paperclip_runner` as an explicit Codex-only adapter. - Add the default-off `enableNativeRunner` instance flag. - Reject fresh create, hire, import, switch, and execution requests while the flag is off. - Allow edits to persisted runner agents while the flag is off. - Recover an already persisted native run even after the flag is disabled. - Keep every built-in direct adapter on its existing runtime path. - Persist an immutable native run binding and revisioned completion contract before runnerd starts. - Execute server to PRP to runnerd to Codex to server through the hidden coordinator. - Validate the durable result against the terminal event and exact completion criteria before finalization. - Preserve the Codex provider thread ID and use `thread/resume` on the next heartbeat. - Strip unsupported Codex configuration fields from the experimental adapter. - Build a target-native release runner binary from source and vendor it into the server distribution. - Install Rust only in the Docker build stage. Do not add a workflow or lockfile change. - Stop the runner process group on completion, cancellation, and forced shutdown. ## Verification - Run `pnpm --filter @paperclipai/paperclip-runner check:all`. All 69 TypeScript tests and 58 Rust tests pass. Protocol, conformance, replay, formatting, and generated-file checks pass. - Run the 12 focused adapter, settings, runtime-selection, coordinator, direct-isolation, and real Codex integration test files. All 186 tests pass. - The real integration test uses PostgreSQL, HTTP, WebSocket, runnerd, and a fake Codex app server. It proves one `thread/start` followed by one `thread/resume`. - Run `pnpm -r typecheck`. - Run `pnpm build`. - Run `pnpm check:token-gates`. - Build the Docker `build` target from a clean context. Confirm that the server distribution contains an executable `paperclip-runnerd` built with Debian Rust 1.85. - Start the server through the source-mode tsx entry point with the package `dist` directory absent. Confirm the vendor shim resolves source exports and the server boots. - Run `pnpm test:run` twice. On this macOS host, 405 files pass and 1 file skips. Eight untouched workspace and loopback tests fail because macOS resolves `/tmp` and `/var` through `/private` and because PID-derived test ports exceed 65535. Linux CI must pass the full suite. - Confirm that the diff contains 52 files. Confirm that it contains no `.github` or `pnpm-lock.yaml` change. ## Risks - The feature flag is off by default. A fresh native start fails with a stable error while the flag is off. - A persisted native run remains recoverable after the flag changes. This prevents rollout changes from corrupting recorded work. - Only local Codex execution is accepted. Other providers and remote work modes fail closed. - Existing direct adapters do not start runnerd, create native rows, use native status arbitration, or enter native finalization. - The runner receives its one-use bootstrap ticket through the child environment. The server does not put the ticket in command arguments or logs. - The server validates the company, task, agent, run, runner, session, completion contract, result, and terminal binding before it accepts completion. - The build compiles a target-native Rust binary. Cross-platform release packaging remains a later concern. Source builds and Docker builds compile for their current target. - Docker needs enough build memory for the existing server TypeScript compile. The Docker build stage sets a 4 GB V8 heap limit. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex with GPT-5. The exact deployment ID and context-window size are not exposed. The model used agentic reasoning, repository tools, code execution, and test execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and applicable tests pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
This commit is contained in:
parent
243430f76e
commit
397de98193
|
|
@ -8,3 +8,5 @@ coverage
|
|||
data
|
||||
tmp
|
||||
*.log
|
||||
packages/paperclip-runner/dist
|
||||
packages/paperclip-runner/runner/target
|
||||
|
|
|
|||
|
|
@ -52,6 +52,9 @@ RUN pnpm install --frozen-lockfile
|
|||
|
||||
FROM base AS build
|
||||
WORKDIR /app
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends cargo rustc \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=deps /app /app
|
||||
COPY . .
|
||||
RUN pnpm --filter @paperclipai/ui build
|
||||
|
|
@ -64,8 +67,10 @@ RUN pnpm --filter @paperclipai/plugin-sdk build
|
|||
# same ARG again for the runtime fallback; an ARG goes out of scope at the
|
||||
# end of its stage. Empty for local `docker build`, which then writes no stamp.
|
||||
ARG PAPERCLIP_BUILD_COMMIT=""
|
||||
ENV NODE_OPTIONS=--max-old-space-size=4096
|
||||
RUN pnpm --filter @paperclipai/server build
|
||||
RUN test -f server/dist/index.js || (echo "ERROR: server build output missing" && exit 1)
|
||||
RUN rm -rf packages/paperclip-runner/runner/target
|
||||
|
||||
FROM base AS production
|
||||
ARG USER_UID=1000
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { buildCodexLocalConfig } from "./build-config.js";
|
||||
import { buildCodexLocalConfig, buildPaperclipRunnerConfig } from "./build-config.js";
|
||||
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
|
||||
|
||||
function makeValues(overrides: Partial<CreateConfigValues> = {}): CreateConfigValues {
|
||||
|
|
@ -69,3 +69,41 @@ describe("buildCodexLocalConfig", () => {
|
|||
expect(config).not.toHaveProperty("model");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPaperclipRunnerConfig", () => {
|
||||
it("keeps only settings implemented by the Codex runner profile", () => {
|
||||
const config = buildPaperclipRunnerConfig(makeValues({
|
||||
codexEngine: "acp",
|
||||
codexAcpAgentCommand: "custom-acp",
|
||||
codexAcpStateDir: "/tmp/acp",
|
||||
search: true,
|
||||
fastMode: true,
|
||||
dangerouslyBypassSandbox: true,
|
||||
instructionsFilePath: "/tmp/AGENTS.md",
|
||||
thinkingEffort: "high",
|
||||
command: "custom-codex",
|
||||
extraArgs: "--unsafe",
|
||||
}));
|
||||
|
||||
expect(config).toMatchObject({
|
||||
provider: "codex",
|
||||
model: "gpt-5.4",
|
||||
timeoutSec: 0,
|
||||
graceSec: 15,
|
||||
});
|
||||
for (const unsupportedKey of [
|
||||
"engine",
|
||||
"agentCommand",
|
||||
"stateDir",
|
||||
"instructionsFilePath",
|
||||
"modelReasoningEffort",
|
||||
"search",
|
||||
"fastMode",
|
||||
"dangerouslyBypassApprovalsAndSandbox",
|
||||
"command",
|
||||
"extraArgs",
|
||||
]) {
|
||||
expect(config).not.toHaveProperty(unsupportedKey);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -60,3 +60,27 @@ export function buildCodexLocalConfig(v: CreateConfigValues): Record<string, unk
|
|||
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
|
||||
return ac;
|
||||
}
|
||||
|
||||
/** Build the Codex-only profile accepted by the experimental Rust runner. */
|
||||
export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||
const config = buildCodexLocalConfig(v);
|
||||
for (const unsupportedKey of [
|
||||
"engine",
|
||||
"agentCommand",
|
||||
"mode",
|
||||
"nonInteractivePermissions",
|
||||
"stateDir",
|
||||
"warmHandleIdleMs",
|
||||
"dangerouslyBypassApprovalsAndSandbox",
|
||||
"dangerouslyBypassSandbox",
|
||||
"instructionsFilePath",
|
||||
"modelReasoningEffort",
|
||||
"search",
|
||||
"fastMode",
|
||||
"command",
|
||||
"extraArgs",
|
||||
]) {
|
||||
delete config[unsupportedKey];
|
||||
}
|
||||
return { ...config, provider: "codex" };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
export { parseCodexStdoutLine } from "./parse-stdout.js";
|
||||
export { buildCodexLocalConfig } from "./build-config.js";
|
||||
export { buildCodexLocalConfig, buildPaperclipRunnerConfig } from "./build-config.js";
|
||||
// The canonical check code the Test result carries when a sandbox target has no
|
||||
// ready authentication. The user interface reads this stable code to decide when
|
||||
// to show the login affordance. The source file has no runtime dependencies, so
|
||||
|
|
|
|||
|
|
@ -18,14 +18,14 @@ every connection and event, and persists commands and cumulative event ACK
|
|||
state across server restarts.
|
||||
The package also publishes the canonical semantic action declarations and
|
||||
their input and output schemas. Its package-local dispatcher projects only
|
||||
bound, run-authorized actions and emits redacted semantic receipts. It does not
|
||||
add application bindings, a server adapter, or production Paperclip behavior.
|
||||
bound, run-authorized actions and emits redacted semantic receipts.
|
||||
|
||||
The first and only installed provider is Codex. Dynamic semantic tools remain
|
||||
undiscoverable unless the hidden server coordinator projects one of the five
|
||||
same-task read bindings for an already persisted native Codex run. Catalog
|
||||
membership alone does not grant authority, and no production adapter can create
|
||||
or start such a run yet. See
|
||||
membership alone does not grant authority. The server can now create and start
|
||||
a Codex-backed native run only through the default-off `paperclip_runner`
|
||||
adapter. See
|
||||
[`SEMANTIC_ACTIONS.md`](SEMANTIC_ACTIONS.md) for the catalog boundary.
|
||||
|
||||
The package has two initial public surfaces:
|
||||
|
|
@ -39,7 +39,15 @@ The package has two initial public surfaces:
|
|||
No SDK, browser, React, eval, live-console, lab, or provider-experiment entry
|
||||
point is exported. The package remains private in this wave. The server route
|
||||
at `/api/runner/v1/connect/:runId` has no authority until the hidden coordinator
|
||||
registers an exact existing run binding, and no production adapter starts it.
|
||||
registers an exact existing run binding. Fresh native starts are rejected
|
||||
unless the instance `enableNativeRunner` flag is enabled. Existing direct
|
||||
adapters keep their original execution path.
|
||||
|
||||
The package build compiles the release `paperclip-runnerd` executable and
|
||||
stages it under `dist/bin`. The normal server build vendors that directory, so
|
||||
an installed server does not depend on a separate system Rust installation or
|
||||
a manually copied binary. `pnpm-lock.yaml` remains under the repository's
|
||||
existing lockfile process.
|
||||
|
||||
Run the complete contract gate with:
|
||||
|
||||
|
|
|
|||
|
|
@ -24,9 +24,10 @@
|
|||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "pnpm run check:protocol-manifest && pnpm run build:typescript && node scripts/generate-replay-goldens.mjs --check && node scripts/generate-semantic-action-catalog.mjs --check",
|
||||
"build": "pnpm run check:protocol-manifest && pnpm run build:typescript && pnpm run build:binary && node scripts/generate-replay-goldens.mjs --check && node scripts/generate-semantic-action-catalog.mjs --check",
|
||||
"build:typescript": "pnpm run check:protocol-types && tsc -p tsconfig.json",
|
||||
"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",
|
||||
"typecheck": "pnpm run typecheck:typescript && pnpm run typecheck:rust",
|
||||
"typecheck:typescript": "node --check scripts/protocol-contract.mjs && node --check scripts/generate-protocol-manifest.mjs && node --check scripts/generate-protocol-schema-module.mjs && node --check scripts/generate-replay-goldens.mjs && node --check scripts/generate-semantic-action-catalog.mjs && pnpm run check:protocol-types && tsc -p tsconfig.json --noEmit",
|
||||
"typecheck:rust": "cargo fmt --manifest-path runner/Cargo.toml --all -- --check && cargo check --manifest-path runner/Cargo.toml --locked --workspace",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
# Codex provider boundary
|
||||
|
||||
`paperclip-runnerd` supports one provider in this layer: Codex app-server as a
|
||||
local supervised process. The Paperclip server does not select or launch this
|
||||
path yet.
|
||||
local supervised process. The Paperclip server selects this path only for the
|
||||
default-off `paperclip_runner` adapter. Every direct adapter keeps its existing
|
||||
execution and finalization path.
|
||||
|
||||
## Command lifecycle
|
||||
|
||||
|
|
@ -10,6 +11,9 @@ path yet.
|
|||
`driver: "codex_app_server"`, `providerVersion`, `command`, bounded `args`, an
|
||||
existing absolute `cwd`, optional `model`, `instructions`, and
|
||||
`approvalPolicy: "never"`.
|
||||
- The server also binds the immutable completion-contract revision and criterion
|
||||
identifiers. A completed Codex turn emits one `run.result.proposed` followed
|
||||
by one `run.terminal`; server finalization accepts only that bound pair.
|
||||
- `session.open` initializes Codex and starts a thread. A recovered runner
|
||||
resumes the recorded thread and reads it before accepting another turn.
|
||||
- `turn.start` requires bounded non-empty `payload.text`. `turn.steer`,
|
||||
|
|
@ -37,7 +41,9 @@ window remains indeterminate and is not retried. Codex JSON-RPC notifications
|
|||
received before a synchronous response are buffered rather than lost. Reusing
|
||||
a pending structured-input request ID with different content fails closed.
|
||||
Normalized events remain in the provider sidecar until the durable PRP outbox
|
||||
has committed and acknowledged them.
|
||||
has committed and acknowledged them. The server persists the native binding
|
||||
before process launch and reuses it after restart, including when the rollout
|
||||
flag has since been disabled.
|
||||
|
||||
## Normalization and authorization
|
||||
|
||||
|
|
@ -46,5 +52,5 @@ redacted session, turn, item, plan, usage, tool-execution, notice, and structure
|
|||
input events. Unknown notifications are ignored.
|
||||
|
||||
Codex starts with an empty dynamic-tool inventory. Catalog presence is not
|
||||
authorization, and semantic operations remain unavailable until the separate
|
||||
catalog and run-scoped authorization layers land.
|
||||
authorization. The coordinator projects only the already landed, same-task
|
||||
read bindings for the exact company and native run.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
# Durable PRP transport
|
||||
|
||||
This layer gives `paperclip-runnerd` a provider-neutral, package-local PRP v1
|
||||
transport. Nothing in the Paperclip server invokes the durable mode yet. Codex
|
||||
is the only installed provider; other providers remain unavailable.
|
||||
This layer gives `paperclip-runnerd` a provider-neutral PRP v1 transport. The
|
||||
Paperclip server invokes durable mode only for a selected, flag-enabled
|
||||
`paperclip_runner` agent or recovery of its persisted native run. Codex is the
|
||||
only installed provider; other providers remain unavailable.
|
||||
|
||||
## Trust boundary
|
||||
|
||||
|
|
@ -18,6 +19,11 @@ is the only installed provider; other providers remain unavailable.
|
|||
monotonically increasing counters, and session-bound associated data.
|
||||
Plaintext, replayed, out-of-order, oversized, or incorrectly bound frames
|
||||
fail closed.
|
||||
- Cross-language authentication primitives use the UTF-8 domain bytes followed
|
||||
by a NUL byte, then each input as an unsigned 64-bit big-endian byte length
|
||||
and its raw bytes. Challenge proofs cover the lexicographically key-sorted,
|
||||
compact JSON challenge payload. The server-to-runner integration test is the
|
||||
parity gate for these TypeScript and Rust encodings.
|
||||
- The durable state directory is private, symlinks are rejected, and updates
|
||||
use a private temporary file, file sync, atomic rename, and directory sync.
|
||||
Credentials and lease tokens are never written to this state.
|
||||
|
|
@ -60,8 +66,10 @@ P0 reserve is an explicit unrecoverable condition.
|
|||
## Current boundary
|
||||
|
||||
Durable mode is selected only when `paperclip-runnerd` receives
|
||||
`--connect-url`. Its executor accepts a Codex app-server descriptor through
|
||||
`run.prepare`, owns the provider process group, resumes the persisted Codex
|
||||
thread after runner restart, and translates provider notifications to PRP
|
||||
events. The existing local fake-runner mode remains unchanged. Semantic tools,
|
||||
server coordination, and the user-facing adapter belong to later layers.
|
||||
`--connect-url`. Its executor accepts a Codex app-server descriptor and bound
|
||||
completion contract through `run.prepare`, owns the provider process group,
|
||||
resumes the persisted Codex thread after runner restart, and translates
|
||||
provider notifications to PRP events. The server supplies the bootstrap ticket
|
||||
only through the child environment, stores the process identity for bounded
|
||||
cancellation, and waits for the durable result and terminal pair. The existing
|
||||
local fake-runner mode remains unchanged.
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ pub struct CodexProviderConfig {
|
|||
#[serde(default)]
|
||||
pub model: Option<String>,
|
||||
#[serde(default)]
|
||||
pub provider_session_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub instructions: String,
|
||||
#[serde(default = "default_approval_policy")]
|
||||
pub approval_policy: String,
|
||||
|
|
@ -74,6 +76,15 @@ impl CodexProviderConfig {
|
|||
{
|
||||
return Err(LocalRunnerError::invalid("Codex model is invalid"));
|
||||
}
|
||||
if self.provider_session_id.as_ref().is_some_and(|session_id| {
|
||||
session_id.is_empty()
|
||||
|| session_id.len() > 240
|
||||
|| session_id.chars().any(char::is_control)
|
||||
}) {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"Codex providerSessionId is invalid",
|
||||
));
|
||||
}
|
||||
if self.instructions.len() > MAX_INSTRUCTIONS_BYTES {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"Codex instructions exceed the 1 MiB limit",
|
||||
|
|
|
|||
|
|
@ -594,6 +594,7 @@ struct AuthChallenge {
|
|||
runner_version: String,
|
||||
runner_digest: String,
|
||||
selected_version: u64,
|
||||
credential_expires_at: String,
|
||||
credential_expires_at_unix_ms: u64,
|
||||
credential_lease_id: Option<String>,
|
||||
revocation_epoch: u64,
|
||||
|
|
@ -680,33 +681,58 @@ fn validate_challenge(
|
|||
}
|
||||
|
||||
fn challenge_signing_bytes(challenge: &AuthChallenge) -> Vec<u8> {
|
||||
let lease_id = challenge.credential_lease_id.as_deref().unwrap_or("");
|
||||
[
|
||||
challenge.credential_id.as_str(),
|
||||
challenge.credential_kind.as_str(),
|
||||
challenge.client_nonce.as_str(),
|
||||
challenge.server_nonce.as_str(),
|
||||
challenge.runner_instance_id.as_str(),
|
||||
challenge.environment_lease_id.as_str(),
|
||||
challenge.run_id.as_str(),
|
||||
challenge.normalized_session_id.as_str(),
|
||||
challenge.turn_id.as_str(),
|
||||
challenge.item_id.as_str(),
|
||||
challenge.runner_version.as_str(),
|
||||
challenge.runner_digest.as_str(),
|
||||
lease_id,
|
||||
]
|
||||
.iter()
|
||||
.fold(Vec::new(), |mut output, part| {
|
||||
output.extend_from_slice(&(part.len() as u64).to_be_bytes());
|
||||
output.extend_from_slice(part.as_bytes());
|
||||
output
|
||||
})
|
||||
.into_iter()
|
||||
.chain(challenge.selected_version.to_be_bytes())
|
||||
.chain(challenge.credential_expires_at_unix_ms.to_be_bytes())
|
||||
.chain(challenge.revocation_epoch.to_be_bytes())
|
||||
.collect()
|
||||
canonical_json(&json!({
|
||||
"credentialId": challenge.credential_id,
|
||||
"credentialKind": challenge.credential_kind,
|
||||
"clientNonce": challenge.client_nonce,
|
||||
"serverNonce": challenge.server_nonce,
|
||||
"runnerInstanceId": challenge.runner_instance_id,
|
||||
"environmentLeaseId": challenge.environment_lease_id,
|
||||
"runId": challenge.run_id,
|
||||
"normalizedSessionId": challenge.normalized_session_id,
|
||||
"turnId": challenge.turn_id,
|
||||
"itemId": challenge.item_id,
|
||||
"runnerVersion": challenge.runner_version,
|
||||
"runnerDigest": challenge.runner_digest,
|
||||
"selectedVersion": challenge.selected_version,
|
||||
"credentialLeaseId": challenge.credential_lease_id,
|
||||
"credentialExpiresAt": challenge.credential_expires_at,
|
||||
"credentialExpiresAtUnixMs": challenge.credential_expires_at_unix_ms,
|
||||
"revocationEpoch": challenge.revocation_epoch,
|
||||
}))
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
fn canonical_json(value: &Value) -> String {
|
||||
match value {
|
||||
Value::Null => "null".to_owned(),
|
||||
Value::Bool(value) => value.to_string(),
|
||||
Value::Number(value) => value.to_string(),
|
||||
Value::String(value) => serde_json::to_string(value).expect("serialize JSON string"),
|
||||
Value::Array(values) => format!(
|
||||
"[{}]",
|
||||
values
|
||||
.iter()
|
||||
.map(canonical_json)
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
),
|
||||
Value::Object(values) => {
|
||||
let mut keys = values.keys().collect::<Vec<_>>();
|
||||
keys.sort_unstable();
|
||||
format!(
|
||||
"{{{}}}",
|
||||
keys.iter()
|
||||
.map(|key| format!(
|
||||
"{}:{}",
|
||||
serde_json::to_string(key).expect("serialize JSON key"),
|
||||
canonical_json(&values[*key])
|
||||
))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_welcome(
|
||||
|
|
@ -952,8 +978,8 @@ pub(crate) fn current_unix_ms() -> Result<u64, DurableRunnerError> {
|
|||
|
||||
fn digest_domain(domain: &str, parts: &[&[u8]]) -> [u8; 32] {
|
||||
let mut digest = Sha256::new();
|
||||
digest.update((domain.len() as u64).to_be_bytes());
|
||||
digest.update(domain.as_bytes());
|
||||
digest.update([0]);
|
||||
for part in parts {
|
||||
digest.update((part.len() as u64).to_be_bytes());
|
||||
digest.update(part);
|
||||
|
|
@ -964,8 +990,8 @@ fn digest_domain(domain: &str, parts: &[&[u8]]) -> [u8; 32] {
|
|||
fn hmac_domain(key: &[u8], domain: &str, parts: &[&[u8]]) -> [u8; 32] {
|
||||
let mut mac =
|
||||
<HmacSha256 as Mac>::new_from_slice(key).expect("HMAC accepts keys of every length");
|
||||
mac.update(&(domain.len() as u64).to_be_bytes());
|
||||
mac.update(domain.as_bytes());
|
||||
mac.update(&[0]);
|
||||
for part in parts {
|
||||
mac.update(&(part.len() as u64).to_be_bytes());
|
||||
mac.update(part);
|
||||
|
|
@ -982,8 +1008,8 @@ fn verify_hmac_hex(
|
|||
let expected = hex_decode(expected)?;
|
||||
let mut mac =
|
||||
<HmacSha256 as Mac>::new_from_slice(key).expect("HMAC accepts keys of every length");
|
||||
mac.update(&(domain.len() as u64).to_be_bytes());
|
||||
mac.update(domain.as_bytes());
|
||||
mac.update(&[0]);
|
||||
for part in parts {
|
||||
mac.update(&(part.len() as u64).to_be_bytes());
|
||||
mac.update(part);
|
||||
|
|
@ -997,7 +1023,7 @@ fn hex_encode(input: &[u8]) -> String {
|
|||
}
|
||||
|
||||
fn hex_decode(input: &str) -> Result<Vec<u8>, DurableRunnerError> {
|
||||
if !input.len().is_multiple_of(2) {
|
||||
if input.len() % 2 != 0 {
|
||||
return Err(DurableRunnerError::invalid("hex value has an odd length"));
|
||||
}
|
||||
input
|
||||
|
|
@ -1091,6 +1117,7 @@ mod tests {
|
|||
runner_version: config.runner_version.clone(),
|
||||
runner_digest: config.runner_digest.clone(),
|
||||
selected_version: PROTOCOL_VERSION,
|
||||
credential_expires_at: "test-expiry".to_owned(),
|
||||
credential_expires_at_unix_ms: server_credential.expires_at_unix_ms,
|
||||
credential_lease_id: server_credential.lease_id.map(str::to_owned),
|
||||
revocation_epoch: server_credential.revocation_epoch,
|
||||
|
|
@ -1122,6 +1149,7 @@ mod tests {
|
|||
"runnerVersion": &challenge.runner_version,
|
||||
"runnerDigest": &challenge.runner_digest,
|
||||
"selectedVersion": challenge.selected_version,
|
||||
"credentialExpiresAt": &challenge.credential_expires_at,
|
||||
"credentialExpiresAtUnixMs": challenge.credential_expires_at_unix_ms,
|
||||
"credentialLeaseId": &challenge.credential_lease_id,
|
||||
"revocationEpoch": challenge.revocation_epoch,
|
||||
|
|
@ -1305,6 +1333,7 @@ mod tests {
|
|||
runner_version: server_config.runner_version.clone(),
|
||||
runner_digest: server_config.runner_digest.clone(),
|
||||
selected_version: PROTOCOL_VERSION,
|
||||
credential_expires_at: "test-expiry".to_owned(),
|
||||
credential_expires_at_unix_ms: expires,
|
||||
credential_lease_id: None,
|
||||
revocation_epoch: 0,
|
||||
|
|
@ -1336,6 +1365,7 @@ mod tests {
|
|||
"runnerVersion": challenge.runner_version,
|
||||
"runnerDigest": challenge.runner_digest,
|
||||
"selectedVersion": challenge.selected_version,
|
||||
"credentialExpiresAt": challenge.credential_expires_at,
|
||||
"credentialExpiresAtUnixMs": challenge.credential_expires_at_unix_ms,
|
||||
"credentialLeaseId": challenge.credential_lease_id,
|
||||
"revocationEpoch": challenge.revocation_epoch,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,13 @@ const PROVIDER_STATE_FILE: &str = "codex-provider-state.json";
|
|||
const MAX_PROVIDER_STATE_BYTES: u64 = 2 * 1024 * 1024;
|
||||
const MAX_EVENTS_PER_POLL: usize = 128;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CompletionContractBinding {
|
||||
revision: String,
|
||||
criterion_ids: Vec<String>,
|
||||
}
|
||||
|
||||
fn initial_provider_event_seq() -> u64 {
|
||||
1
|
||||
}
|
||||
|
|
@ -36,6 +43,112 @@ fn provider_event_sequence(event_id: &str) -> Option<u64> {
|
|||
(provider_event_id(sequence) == event_id).then_some(sequence)
|
||||
}
|
||||
|
||||
fn completion_contract(
|
||||
payload: &Value,
|
||||
) -> Result<Option<CompletionContractBinding>, DurableRunnerError> {
|
||||
let Some(value) = payload.get("completionContract") else {
|
||||
return Ok(None);
|
||||
};
|
||||
let binding: CompletionContractBinding =
|
||||
serde_json::from_value(value.clone()).map_err(|error| {
|
||||
DurableRunnerError::invalid(format!(
|
||||
"run.prepare completionContract is invalid: {error}"
|
||||
))
|
||||
})?;
|
||||
if binding.revision.is_empty()
|
||||
|| binding.revision.len() > 120
|
||||
|| binding.criterion_ids.is_empty()
|
||||
|| binding.criterion_ids.len() > 256
|
||||
|| binding.criterion_ids.iter().any(|criterion| {
|
||||
criterion.is_empty() || criterion.len() > 240 || criterion.chars().any(char::is_control)
|
||||
})
|
||||
{
|
||||
return Err(DurableRunnerError::invalid(
|
||||
"run.prepare completionContract is malformed or oversized",
|
||||
));
|
||||
}
|
||||
Ok(Some(binding))
|
||||
}
|
||||
|
||||
fn terminal_events(state: &CodexProviderState, event_type: &str) -> Vec<NormalizedProviderEvent> {
|
||||
let Some(contract) = state.completion_contract.as_ref() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let succeeded = event_type == "turn.completed";
|
||||
let cancelled = matches!(event_type, "turn.cancelled" | "turn.interrupted");
|
||||
let disposition = if succeeded { "done" } else { "needs_review" };
|
||||
let summary = state.last_agent_message.clone().unwrap_or_else(|| {
|
||||
if succeeded {
|
||||
"Codex completed the requested work.".to_owned()
|
||||
} else if cancelled {
|
||||
"The Codex run stopped before it completed.".to_owned()
|
||||
} else {
|
||||
"The Codex run failed before it completed.".to_owned()
|
||||
}
|
||||
});
|
||||
let evidence_ref = "provider:codex:agent-message";
|
||||
let criteria = contract
|
||||
.criterion_ids
|
||||
.iter()
|
||||
.map(|criterion_id| {
|
||||
json!({
|
||||
"criterionId": criterion_id,
|
||||
"status": if succeeded { "satisfied" } else { "unknown" },
|
||||
"evidenceRefs": if succeeded { vec![evidence_ref] } else { Vec::<&str>::new() },
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let result = json!({
|
||||
"schema": "paperclip.run_result.v1",
|
||||
"reportedWorkDisposition": disposition,
|
||||
"summary": summary,
|
||||
"completionClaim": {
|
||||
"contractRevision": contract.revision,
|
||||
"objectiveSatisfied": succeeded,
|
||||
"criteria": criteria,
|
||||
"remainingWork": if succeeded { Vec::<Value>::new() } else { vec![json!({
|
||||
"description": "Review the stopped Codex run and continue the task.",
|
||||
"blocksCompletion": true,
|
||||
})] },
|
||||
},
|
||||
"evidence": if succeeded { vec![json!({ "ref": evidence_ref })] } else { Vec::<Value>::new() },
|
||||
"verification": [],
|
||||
"attentionRequests": if succeeded { Vec::<Value>::new() } else { vec![json!({
|
||||
"kind": "review",
|
||||
"summary": "Review the stopped Codex run before continuing.",
|
||||
"ownerClass": "human",
|
||||
})] },
|
||||
"artifacts": [],
|
||||
});
|
||||
let turn_terminal_state = if succeeded {
|
||||
"completed"
|
||||
} else if event_type == "turn.interrupted" {
|
||||
"interrupted"
|
||||
} else if cancelled {
|
||||
"cancelled"
|
||||
} else {
|
||||
"failed"
|
||||
};
|
||||
let terminal = json!({
|
||||
"schema": "paperclip.prp.terminal.v1",
|
||||
"turnTerminalState": turn_terminal_state,
|
||||
"runTerminalState": if succeeded { "succeeded" } else if cancelled { "cancelled" } else { "failed" },
|
||||
"reportedWorkDisposition": disposition,
|
||||
});
|
||||
vec![
|
||||
NormalizedProviderEvent {
|
||||
event_type: "run.result.proposed".to_owned(),
|
||||
priority: EventPriority::P0,
|
||||
payload: result,
|
||||
},
|
||||
NormalizedProviderEvent {
|
||||
event_type: "run.terminal".to_owned(),
|
||||
priority: EventPriority::P0,
|
||||
payload: terminal,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CodexProviderState {
|
||||
|
|
@ -43,26 +156,36 @@ struct CodexProviderState {
|
|||
lifecycle: String,
|
||||
config: CodexProviderConfig,
|
||||
#[serde(default)]
|
||||
completion_contract: Option<CompletionContractBinding>,
|
||||
#[serde(default)]
|
||||
thread_id: Option<String>,
|
||||
#[serde(default)]
|
||||
provider_session_id: Option<String>,
|
||||
#[serde(default)]
|
||||
active_provider_turn_id: Option<String>,
|
||||
#[serde(default)]
|
||||
last_agent_message: Option<String>,
|
||||
#[serde(default)]
|
||||
pending_events: VecDeque<PolledEvent>,
|
||||
#[serde(default = "initial_provider_event_seq")]
|
||||
next_provider_event_seq: u64,
|
||||
}
|
||||
|
||||
impl CodexProviderState {
|
||||
fn new(config: CodexProviderConfig) -> Self {
|
||||
fn new(
|
||||
config: CodexProviderConfig,
|
||||
completion_contract: Option<CompletionContractBinding>,
|
||||
) -> Self {
|
||||
let thread_id = config.provider_session_id.clone();
|
||||
Self {
|
||||
schema: PROVIDER_STATE_SCHEMA.to_owned(),
|
||||
lifecycle: "prepared".to_owned(),
|
||||
config,
|
||||
thread_id: None,
|
||||
completion_contract,
|
||||
thread_id,
|
||||
provider_session_id: None,
|
||||
active_provider_turn_id: None,
|
||||
last_agent_message: None,
|
||||
pending_events: VecDeque::new(),
|
||||
next_provider_event_seq: initial_provider_event_seq(),
|
||||
}
|
||||
|
|
@ -90,6 +213,21 @@ impl CodexProviderState {
|
|||
.active_provider_turn_id
|
||||
.as_ref()
|
||||
.is_some_and(|value| value.is_empty() || value.len() > 240)
|
||||
|| self.completion_contract.as_ref().is_some_and(|contract| {
|
||||
contract.revision.is_empty()
|
||||
|| contract.revision.len() > 120
|
||||
|| contract.criterion_ids.is_empty()
|
||||
|| contract.criterion_ids.len() > 256
|
||||
|| contract.criterion_ids.iter().any(|criterion| {
|
||||
criterion.is_empty()
|
||||
|| criterion.len() > 240
|
||||
|| criterion.chars().any(char::is_control)
|
||||
})
|
||||
})
|
||||
|| self
|
||||
.last_agent_message
|
||||
.as_ref()
|
||||
.is_some_and(|value| value.is_empty() || value.len() > 1_000_000)
|
||||
|| (self.thread_id.is_none()
|
||||
&& (self.provider_session_id.is_some()
|
||||
|| self.active_provider_turn_id.is_some()
|
||||
|
|
@ -100,7 +238,7 @@ impl CodexProviderState {
|
|||
"prepared" | "session_open" | "closed"
|
||||
) && self.active_provider_turn_id.is_some())
|
||||
|| self.next_provider_event_seq == 0
|
||||
|| self.pending_events.len() > MAX_EVENTS_PER_POLL + 1
|
||||
|| self.pending_events.len() > MAX_EVENTS_PER_POLL + 3
|
||||
|| self.pending_events.iter().any(|event| {
|
||||
provider_event_sequence(&event.executor_event_id)
|
||||
.is_none_or(|sequence| sequence >= self.next_provider_event_seq)
|
||||
|
|
@ -318,10 +456,11 @@ impl CodexCommandExecutor {
|
|||
config
|
||||
.validate()
|
||||
.map_err(|error| DurableRunnerError::invalid(error.to_string()))?;
|
||||
let completion_contract = completion_contract(payload)?;
|
||||
if let Some(state) = &self.state {
|
||||
if state.config != config {
|
||||
if state.config != config || state.completion_contract != completion_contract {
|
||||
return Err(DurableRunnerError::invalid(
|
||||
"Codex provider configuration changed across the durable run",
|
||||
"Codex provider or completion contract changed across the durable run",
|
||||
));
|
||||
}
|
||||
if state.lifecycle == "closed" {
|
||||
|
|
@ -330,7 +469,7 @@ impl CodexCommandExecutor {
|
|||
));
|
||||
}
|
||||
} else {
|
||||
self.state = Some(CodexProviderState::new(config));
|
||||
self.state = Some(CodexProviderState::new(config, completion_contract));
|
||||
self.save_state()?;
|
||||
}
|
||||
Ok(CommandExecution::result(json!({
|
||||
|
|
@ -467,6 +606,7 @@ impl CodexCommandExecutor {
|
|||
.as_mut()
|
||||
.expect("Codex state exists after turn start");
|
||||
state.active_provider_turn_id = Some(provider_turn_id.clone());
|
||||
state.last_agent_message = None;
|
||||
state.lifecycle = "turn_active".to_owned();
|
||||
self.save_state()?;
|
||||
Ok(CommandExecution {
|
||||
|
|
@ -600,15 +740,41 @@ impl CodexCommandExecutor {
|
|||
match event {
|
||||
CodexProviderEvent::Notification { method, params } => {
|
||||
let normalized = normalize_codex_notification(&method, ¶ms);
|
||||
let terminal_event_type = normalized
|
||||
.iter()
|
||||
.find(|event| event.event_type.starts_with("turn."))
|
||||
.map(|event| event.event_type.clone())
|
||||
.filter(|event_type| {
|
||||
matches!(
|
||||
event_type.as_str(),
|
||||
"turn.completed"
|
||||
| "turn.failed"
|
||||
| "turn.cancelled"
|
||||
| "turn.interrupted"
|
||||
)
|
||||
});
|
||||
let state = self
|
||||
.state
|
||||
.as_mut()
|
||||
.expect("Codex state remains available while polling");
|
||||
if method == "item/completed" {
|
||||
let item = params.get("item").unwrap_or(¶ms);
|
||||
if item.get("type").and_then(Value::as_str) == Some("agentMessage") {
|
||||
state.last_agent_message = item
|
||||
.get("text")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|text| !text.is_empty())
|
||||
.map(|text| text.chars().take(1_000_000).collect());
|
||||
}
|
||||
}
|
||||
if method == "turn/completed" {
|
||||
state.active_provider_turn_id = None;
|
||||
state.lifecycle = "session_open".to_owned();
|
||||
}
|
||||
state.extend_events(normalized)?;
|
||||
if let Some(event_type) = terminal_event_type {
|
||||
state.extend_events(terminal_events(state, &event_type))?;
|
||||
}
|
||||
self.save_state()?;
|
||||
}
|
||||
CodexProviderEvent::RuntimeRequest {
|
||||
|
|
@ -767,15 +933,49 @@ mod tests {
|
|||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
model: None,
|
||||
provider_session_id: None,
|
||||
instructions: String::new(),
|
||||
approval_policy: "never".to_owned(),
|
||||
},
|
||||
completion_contract: None,
|
||||
thread_id: Some("thread-1".to_owned()),
|
||||
provider_session_id: None,
|
||||
active_provider_turn_id: None,
|
||||
last_agent_message: None,
|
||||
pending_events: VecDeque::new(),
|
||||
next_provider_event_seq: initial_provider_event_seq(),
|
||||
};
|
||||
assert!(state.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emits_a_structured_result_before_the_terminal_event() {
|
||||
let mut state = CodexProviderState::new(
|
||||
CodexProviderConfig {
|
||||
provider: "codex".to_owned(),
|
||||
driver: "codex_app_server".to_owned(),
|
||||
provider_version: "test".to_owned(),
|
||||
command: PathBuf::from("codex"),
|
||||
args: vec!["app-server".to_owned()],
|
||||
cwd: std::env::current_dir()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
model: None,
|
||||
provider_session_id: None,
|
||||
instructions: String::new(),
|
||||
approval_policy: "never".to_owned(),
|
||||
},
|
||||
Some(CompletionContractBinding {
|
||||
revision: "1".to_owned(),
|
||||
criterion_ids: vec!["objective".to_owned()],
|
||||
}),
|
||||
);
|
||||
state.last_agent_message = Some("Finished the requested work.".to_owned());
|
||||
let events = terminal_events(&state, "turn.completed");
|
||||
assert_eq!(events[0].event_type, "run.result.proposed");
|
||||
assert_eq!(events[0].payload["summary"], "Finished the requested work.");
|
||||
assert_eq!(events[1].event_type, "run.terminal");
|
||||
assert_eq!(events[1].payload["runTerminalState"], "succeeded");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ fn provider_config(directory: &Path, switches: &[&str]) -> CodexProviderConfig {
|
|||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
model: Some("test-model".to_owned()),
|
||||
provider_session_id: None,
|
||||
instructions: "Stay inside the test workspace.".to_owned(),
|
||||
approval_policy: "never".to_owned(),
|
||||
}
|
||||
|
|
@ -383,3 +384,70 @@ fn structured_question_round_trips_through_the_normalized_backend() {
|
|||
executor.shutdown().expect("stop provider process");
|
||||
fs::remove_dir_all(directory).expect("remove Codex integration-test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_completion_emits_the_bound_result_before_the_terminal_event() {
|
||||
let directory = temporary_directory("completion-contract");
|
||||
let config = provider_config(&directory, &[]);
|
||||
let mut executor = CodexCommandExecutor::new(&directory);
|
||||
executor
|
||||
.execute(&command(
|
||||
"prepare",
|
||||
1,
|
||||
"run.prepare",
|
||||
json!({
|
||||
"provider": config,
|
||||
"completionContract": {
|
||||
"revision": "sha256:test-contract",
|
||||
"criterionIds": ["criterion_test_task"]
|
||||
}
|
||||
}),
|
||||
))
|
||||
.expect("prepare provider with completion contract");
|
||||
executor
|
||||
.execute(&command("open", 2, "session.open", json!({})))
|
||||
.expect("open provider session");
|
||||
executor
|
||||
.execute(&command(
|
||||
"turn",
|
||||
3,
|
||||
"turn.start",
|
||||
json!({"text": "Complete the fake native run."}),
|
||||
))
|
||||
.expect("start provider turn");
|
||||
|
||||
let mut emitted = Vec::new();
|
||||
for _ in 0..32 {
|
||||
emitted.extend(poll_and_ack(&mut executor).expect("poll terminal events"));
|
||||
if emitted
|
||||
.iter()
|
||||
.any(|event| event.event_type == "run.terminal")
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
let result_index = emitted
|
||||
.iter()
|
||||
.position(|event| event.event_type == "run.result.proposed")
|
||||
.expect("result proposal is emitted");
|
||||
let terminal_index = emitted
|
||||
.iter()
|
||||
.position(|event| event.event_type == "run.terminal")
|
||||
.expect("terminal event is emitted");
|
||||
assert!(result_index < terminal_index);
|
||||
assert_eq!(
|
||||
emitted[result_index].payload["summary"],
|
||||
"Codex completed the fake turn."
|
||||
);
|
||||
assert_eq!(
|
||||
emitted[result_index].payload["completionClaim"]["contractRevision"],
|
||||
"sha256:test-contract"
|
||||
);
|
||||
assert_eq!(
|
||||
emitted[terminal_index].payload["runTerminalState"],
|
||||
"succeeded"
|
||||
);
|
||||
|
||||
executor.shutdown().expect("stop provider process");
|
||||
fs::remove_dir_all(directory).expect("remove Codex integration-test directory");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
import { chmod, copyFile, mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const executable = process.platform === "win32" ? "paperclip-runnerd.exe" : "paperclip-runnerd";
|
||||
const source = path.join(packageRoot, "runner", "target", "release", executable);
|
||||
const destinationDirectory = path.join(packageRoot, "dist", "bin");
|
||||
const destination = path.join(destinationDirectory, executable);
|
||||
|
||||
await mkdir(destinationDirectory, { recursive: true });
|
||||
await copyFile(source, destination);
|
||||
if (process.platform !== "win32") await chmod(destination, 0o755);
|
||||
|
|
@ -32,6 +32,7 @@ export const AGENT_ADAPTER_TYPES = [
|
|||
"http",
|
||||
"claude_local",
|
||||
"codex_local",
|
||||
"paperclip_runner",
|
||||
"cursor_cloud",
|
||||
"gemini_local",
|
||||
"grok_local",
|
||||
|
|
|
|||
|
|
@ -50,6 +50,14 @@ export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalog
|
|||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableNativeRunner: {
|
||||
title: "Paperclip Runner",
|
||||
description:
|
||||
"Allow new Codex agents to use the experimental Rust Paperclip Runner transport.",
|
||||
tier: "managed",
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableManagedSandboxOnly: {
|
||||
title: "Managed Environment Only",
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -46,6 +46,11 @@ export interface InstanceGeneralSettings {
|
|||
|
||||
export interface InstanceExperimentalSettings {
|
||||
enableEnvironments: boolean;
|
||||
/**
|
||||
* Exposes the experimental Paperclip Runner adapter for new selections.
|
||||
* Existing native runs ignore later flag changes so they remain recoverable.
|
||||
*/
|
||||
enableNativeRunner: boolean;
|
||||
/**
|
||||
* Hide the local environment and run all agents in the platform-managed
|
||||
* sandbox environment. Run selection refuses local while this is on.
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ export const patchInstanceGeneralSettingsSchema = z
|
|||
|
||||
export const instanceExperimentalSettingsSchema = z.object({
|
||||
enableEnvironments: z.boolean().default(false),
|
||||
enableNativeRunner: z.boolean().default(false),
|
||||
enableManagedSandboxOnly: z.boolean().default(false),
|
||||
enableIsolatedWorkspaces: z.boolean().default(false),
|
||||
enableStreamlinedLeftNavigation: z.boolean().default(true),
|
||||
|
|
|
|||
|
|
@ -60,7 +60,10 @@ function registerModuleMocks() {
|
|||
vi.doMock("../middleware/index.js", async () => vi.importActual("../middleware/index.js"));
|
||||
}
|
||||
|
||||
function createApp(actorOverrides: Partial<Express.Request["actor"]> = {}) {
|
||||
function createApp(
|
||||
actorOverrides: Partial<Express.Request["actor"]> = {},
|
||||
options: Parameters<typeof adapterRoutes>[0] = {},
|
||||
) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
|
|
@ -74,7 +77,7 @@ function createApp(actorOverrides: Partial<Express.Request["actor"]> = {}) {
|
|||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", adapterRoutes());
|
||||
app.use("/api", adapterRoutes(options));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
|
@ -146,6 +149,26 @@ describe("adapter routes", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("keeps paperclip_runner hidden from selection unless the rollout flag is enabled", async () => {
|
||||
const disabledResponse = await request(createApp()).get("/api/adapters");
|
||||
expect(disabledResponse.status).toBe(200);
|
||||
expect(disabledResponse.body.find((adapter: any) => adapter.type === "paperclip_runner"))
|
||||
.toMatchObject({ disabled: true });
|
||||
|
||||
const enabledResponse = await request(createApp({}, {
|
||||
getNativeRunnerEnabled: async () => true,
|
||||
})).get("/api/adapters");
|
||||
expect(enabledResponse.status).toBe(200);
|
||||
expect(enabledResponse.body.find((adapter: any) => adapter.type === "paperclip_runner"))
|
||||
.toMatchObject({
|
||||
disabled: false,
|
||||
capabilities: {
|
||||
supportsInstructionsBundle: false,
|
||||
supportsModelProfiles: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("GET /api/adapters returns correct capabilities for built-in adapters", async () => {
|
||||
const app = createApp();
|
||||
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ const mockApprovalService = vi.hoisted(() => ({
|
|||
|
||||
const mockInstanceSettingsService = vi.hoisted(() => ({
|
||||
getGeneral: vi.fn(async () => ({ censorUsernameInLogs: false })),
|
||||
getExperimental: vi.fn(async () => ({ enableNativeRunner: false })),
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
|
|
@ -234,6 +235,7 @@ describe("agent routes adapter validation", () => {
|
|||
mockAccessService.setPrincipalPermission.mockResolvedValue(undefined);
|
||||
mockLogActivity.mockResolvedValue(undefined);
|
||||
mockSecretService.syncEnvBindingsForTarget.mockResolvedValue(undefined);
|
||||
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: false });
|
||||
mockAgentInstructionsService.materializeManagedBundle.mockImplementation(async (agent: { adapterConfig: unknown }) => ({
|
||||
adapterConfig: agent.adapterConfig,
|
||||
}));
|
||||
|
|
@ -565,4 +567,52 @@ describe("agent routes adapter validation", () => {
|
|||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
});
|
||||
|
||||
it("rejects a new paperclip_runner selection while the rollout flag is off", async () => {
|
||||
const app = await createApp();
|
||||
const res = await requestApp(app, (baseUrl) =>
|
||||
request(baseUrl)
|
||||
.post("/api/companies/company-1/agents")
|
||||
.send({ name: "Native Codex", adapterType: "paperclip_runner" }),
|
||||
);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(422);
|
||||
expect(res.body.details).toMatchObject({ code: "paperclip_runner_rollout_disabled" });
|
||||
expect(mockAgentService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows a new paperclip_runner selection while the rollout flag is on", async () => {
|
||||
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: true });
|
||||
const app = await createApp();
|
||||
const res = await requestApp(app, (baseUrl) =>
|
||||
request(baseUrl)
|
||||
.post("/api/companies/company-1/agents")
|
||||
.send({
|
||||
name: "Native Codex",
|
||||
adapterType: "paperclip_runner",
|
||||
adapterConfig: { provider: "codex" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect(mockAgentService.create).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps an existing paperclip_runner agent editable after the flag is disabled", async () => {
|
||||
const existing = await mockAgentService.getById();
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
...existing,
|
||||
adapterType: "paperclip_runner",
|
||||
adapterConfig: { provider: "codex" },
|
||||
});
|
||||
const app = await createApp();
|
||||
const res = await requestApp(app, (baseUrl) =>
|
||||
request(baseUrl)
|
||||
.patch("/api/agents/11111111-1111-4111-8111-111111111111")
|
||||
.send({ name: "Native Codex (recorded)" }),
|
||||
);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockAgentService.update).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -102,6 +102,10 @@ const agentInstructionsSvc = {
|
|||
materializeManagedBundle: vi.fn(),
|
||||
};
|
||||
|
||||
const instanceSettingsSvc = {
|
||||
getExperimental: vi.fn(async () => ({ enableNativeRunner: false })),
|
||||
};
|
||||
|
||||
vi.mock("../services/companies.js", () => ({
|
||||
companyService: () => companySvc,
|
||||
}));
|
||||
|
|
@ -154,6 +158,10 @@ vi.mock("../services/agent-instructions.js", () => ({
|
|||
agentInstructionsService: () => agentInstructionsSvc,
|
||||
}));
|
||||
|
||||
vi.mock("../services/instance-settings.js", () => ({
|
||||
instanceSettingsService: () => instanceSettingsSvc,
|
||||
}));
|
||||
|
||||
vi.mock("../routes/org-chart-svg.js", () => ({
|
||||
renderOrgChartPng: vi.fn(async () => Buffer.from("png")),
|
||||
}));
|
||||
|
|
@ -171,6 +179,7 @@ describe("company portability", () => {
|
|||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
instanceSettingsSvc.getExperimental.mockResolvedValue({ enableNativeRunner: false });
|
||||
secretSvc.create.mockResolvedValue({ id: "secret-created" });
|
||||
secretSvc.remove.mockResolvedValue(true);
|
||||
secretSvc.normalizeAdapterConfigForPersistence.mockImplementation(async (_companyId, config) => config);
|
||||
|
|
@ -5808,6 +5817,48 @@ describe("company portability", () => {
|
|||
expect(preview.plan.projectPlans).toHaveLength(0);
|
||||
expect(preview.plan.issuePlans).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects runner imports while disabled and accepts the same selection when enabled", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
include: { company: false, agents: true, projects: false, issues: false },
|
||||
});
|
||||
agentSvc.list.mockResolvedValue([]);
|
||||
agentSvc.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({
|
||||
id: "agent-created",
|
||||
...input,
|
||||
}));
|
||||
const request = {
|
||||
source: {
|
||||
type: "inline" as const,
|
||||
rootPath: exported.rootPath,
|
||||
files: exported.files,
|
||||
},
|
||||
include: { company: false, agents: true, projects: false, issues: false },
|
||||
target: { mode: "existing_company" as const, companyId: "company-1" },
|
||||
agents: "all" as const,
|
||||
collisionStrategy: "rename" as const,
|
||||
adapterOverrides: {
|
||||
claudecoder: {
|
||||
adapterType: "paperclip_runner",
|
||||
adapterConfig: { provider: "codex" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await expect(portability.importBundle(request, "user-1")).rejects.toMatchObject({
|
||||
status: 422,
|
||||
details: { code: "paperclip_runner_rollout_disabled" },
|
||||
});
|
||||
expect(agentSvc.create).not.toHaveBeenCalled();
|
||||
|
||||
instanceSettingsSvc.getExperimental.mockResolvedValue({ enableNativeRunner: true });
|
||||
await portability.importBundle(request, "user-1");
|
||||
expect(agentSvc.create).toHaveBeenCalledWith("company-1", expect.objectContaining({
|
||||
adapterType: "paperclip_runner",
|
||||
adapterConfig: expect.objectContaining({ provider: "codex" }),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe("dedupeImportedCompanyName", () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,156 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { sql } from "drizzle-orm";
|
||||
import {
|
||||
agents,
|
||||
companies,
|
||||
completionContracts,
|
||||
createDb,
|
||||
nativeRunFinalizations,
|
||||
nativeRunResults,
|
||||
statusDecisions,
|
||||
workAssessments,
|
||||
} from "@paperclipai/db";
|
||||
import type { ServerAdapterModule } from "../adapters/index.js";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import {
|
||||
registerServerAdapter,
|
||||
unregisterServerAdapter,
|
||||
} from "../adapters/index.js";
|
||||
import { heartbeatService } from "../services/heartbeat.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping direct-adapter native-isolation tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForRunToFinish(
|
||||
heartbeat: ReturnType<typeof heartbeatService>,
|
||||
runId: string,
|
||||
timeoutMs = 10_000,
|
||||
) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const run = await heartbeat.getRun(runId);
|
||||
if (run && !["queued", "running"].includes(run.status)) return run;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
return heartbeat.getRun(runId);
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("direct adapter native-runner isolation", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
const execute = vi.fn<ServerAdapterModule["execute"]>();
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("heartbeat-direct-adapter-isolation-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
const directCodexAdapter: ServerAdapterModule = {
|
||||
type: "codex_local",
|
||||
supportsLocalAgentJwt: false,
|
||||
execute,
|
||||
testEnvironment: async () => ({
|
||||
adapterType: "codex_local",
|
||||
status: "pass",
|
||||
checks: [],
|
||||
testedAt: new Date(0).toISOString(),
|
||||
}),
|
||||
};
|
||||
registerServerAdapter(directCodexAdapter);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
await db.execute(sql.raw(`
|
||||
TRUNCATE TABLE
|
||||
"native_run_finalizations",
|
||||
"status_decisions",
|
||||
"work_assessments",
|
||||
"native_run_results",
|
||||
"completion_contracts",
|
||||
"environment_leases",
|
||||
"environments",
|
||||
"activity_log",
|
||||
"heartbeat_run_events",
|
||||
"heartbeat_runs",
|
||||
"agent_wakeup_requests",
|
||||
"agent_runtime_state",
|
||||
"company_skills",
|
||||
"agents",
|
||||
"companies"
|
||||
RESTART IDENTITY CASCADE
|
||||
`));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
unregisterServerAdapter("codex_local");
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
it("executes flag-off codex_local once without creating native records", async () => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const directProofJson = '{"schema":"direct-proof.v1","value":"byte-stable"}';
|
||||
execute.mockResolvedValue({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
provider: "codex",
|
||||
model: "test-codex",
|
||||
summary: "Direct adapter summary.",
|
||||
resultJson: { directProofJson },
|
||||
});
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Direct compatibility",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
defaultResponsibleUserId: "responsible-user",
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Direct Codex",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
const heartbeat = heartbeatService(db);
|
||||
const queued = await heartbeat.invoke(agentId, "on_demand", {}, "manual");
|
||||
expect(queued).not.toBeNull();
|
||||
const finished = await waitForRunToFinish(heartbeat, queued!.id);
|
||||
|
||||
expect(execute).toHaveBeenCalledOnce();
|
||||
expect(finished).toMatchObject({
|
||||
status: "succeeded",
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
runtimeMode: "legacy",
|
||||
nativePhase: null,
|
||||
});
|
||||
const persistedResult = finished?.resultJson as Record<string, unknown> | null;
|
||||
expect(persistedResult?.directProofJson).toBe(directProofJson);
|
||||
|
||||
const nativeRows = await Promise.all([
|
||||
db.select().from(completionContracts),
|
||||
db.select().from(nativeRunResults),
|
||||
db.select().from(workAssessments),
|
||||
db.select().from(statusDecisions),
|
||||
db.select().from(nativeRunFinalizations),
|
||||
]);
|
||||
expect(nativeRows.every((rows) => rows.length === 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -10,6 +10,7 @@ describe("instance settings service", () => {
|
|||
it("ignores retired experimental flags without resetting current settings", () => {
|
||||
expect(normalizeExperimentalSettings({
|
||||
enableEnvironments: true,
|
||||
enableNativeRunner: false,
|
||||
enableManagedSandboxOnly: false,
|
||||
enableIsolatedWorkspaces: true,
|
||||
enableIssuePlanDecompositions: true,
|
||||
|
|
@ -26,6 +27,7 @@ describe("instance settings service", () => {
|
|||
enableNewestFirstIssueThread: true,
|
||||
})).toEqual({
|
||||
enableEnvironments: true,
|
||||
enableNativeRunner: false,
|
||||
enableManagedSandboxOnly: false,
|
||||
enableIsolatedWorkspaces: true,
|
||||
enableStreamlinedLeftNavigation: true,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ import { describe, expect, it } from "vitest";
|
|||
const packageJsonPath = fileURLToPath(
|
||||
new URL("../../package.json", import.meta.url),
|
||||
);
|
||||
const runnerShimPath = fileURLToPath(
|
||||
new URL("../vendor/paperclip-runner/index.ts", import.meta.url),
|
||||
);
|
||||
|
||||
describe("server package build script", () => {
|
||||
it("builds the compiled package entry during prepack", () => {
|
||||
|
|
@ -52,4 +55,15 @@ describe("server package build script", () => {
|
|||
"cp -R ../packages/paperclip-runner/dist/. dist/vendor/paperclip-runner/",
|
||||
);
|
||||
});
|
||||
|
||||
it("loads runner source when the source server starts before workspace builds", () => {
|
||||
const shim = readFileSync(runnerShimPath, "utf8");
|
||||
|
||||
expect(shim).toContain(
|
||||
'"../../../../packages/paperclip-runner/src/index.ts"',
|
||||
);
|
||||
expect(shim).not.toContain(
|
||||
'export * from "@paperclipai/paperclip-runner"',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export const BUILTIN_ADAPTER_TYPES = new Set([
|
|||
"acpx_local",
|
||||
"claude_local",
|
||||
"codex_local",
|
||||
"paperclip_runner",
|
||||
"cursor_cloud",
|
||||
"cursor",
|
||||
"gemini_local",
|
||||
|
|
|
|||
|
|
@ -339,6 +339,52 @@ const codexLocalAdapter: ServerAdapterModule = {
|
|||
loginCapability: codexLoginCapability,
|
||||
};
|
||||
|
||||
const paperclipRunnerAdapter: ServerAdapterModule = {
|
||||
type: "paperclip_runner",
|
||||
async execute(ctx) {
|
||||
const message = "paperclip_runner requires the native runner coordinator";
|
||||
await ctx.onLog("stderr", `${message}\n`);
|
||||
return {
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage: message,
|
||||
errorCode: "paperclip_runner_coordinator_required",
|
||||
provider: "codex",
|
||||
summary: message,
|
||||
};
|
||||
},
|
||||
async testEnvironment(context) {
|
||||
const result = await codexTestEnvironment(context);
|
||||
return { ...result, adapterType: "paperclip_runner" };
|
||||
},
|
||||
listSkills: listCodexSkills,
|
||||
syncSkills: syncCodexSkills,
|
||||
sessionCodec: codexSessionCodec,
|
||||
models: codexModels,
|
||||
listModels: listCodexModels,
|
||||
refreshModels: refreshCodexModels,
|
||||
supportsLocalAgentJwt: false,
|
||||
supportsInstructionsBundle: false,
|
||||
requiresMaterializedRuntimeSkills: false,
|
||||
getRuntimeCommandSpec: (config) => buildNpmRuntimeCommandSpec(config, "codex", "@openai/codex"),
|
||||
agentConfigurationDoc:
|
||||
"# Paperclip Runner\n\nAdapter: paperclip_runner\n\nRuns Codex through the Rust Paperclip runner and authenticated PRP transport.\n",
|
||||
getConfigSchema: () => ({
|
||||
fields: [
|
||||
{
|
||||
key: "provider",
|
||||
label: "Provider",
|
||||
type: "select",
|
||||
default: "codex",
|
||||
options: [{ value: "codex", label: "Codex" }],
|
||||
hint: "Paperclip Runner currently supports only Codex app-server.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
loginCapability: codexLoginCapability,
|
||||
};
|
||||
|
||||
const cursorLocalAdapter: ServerAdapterModule = {
|
||||
type: "cursor",
|
||||
execute: cursorExecute,
|
||||
|
|
@ -518,6 +564,7 @@ function registerBuiltInAdapters() {
|
|||
acpxLocalAdapter,
|
||||
claudeLocalAdapter,
|
||||
codexLocalAdapter,
|
||||
paperclipRunnerAdapter,
|
||||
openCodeLocalAdapter,
|
||||
piLocalAdapter,
|
||||
cursorCloudAdapter,
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ import { sidebarPreferenceRoutes } from "./routes/sidebar-preferences.js";
|
|||
import { resourceMembershipRoutes } from "./routes/resource-memberships.js";
|
||||
import { inboxDismissalRoutes } from "./routes/inbox-dismissals.js";
|
||||
import { instanceSettingsRoutes } from "./routes/instance-settings.js";
|
||||
import { instanceSettingsService } from "./services/instance-settings.js";
|
||||
import { openApiRoutes } from "./routes/openapi.js";
|
||||
import {
|
||||
instanceDatabaseBackupRoutes,
|
||||
|
|
@ -644,7 +645,10 @@ export async function createApp(
|
|||
{ toolGateway },
|
||||
),
|
||||
);
|
||||
api.use(adapterRoutes());
|
||||
api.use(adapterRoutes({
|
||||
getNativeRunnerEnabled: async () =>
|
||||
(await instanceSettingsService(db).getExperimental()).enableNativeRunner === true,
|
||||
}));
|
||||
api.use(
|
||||
accessRoutes(db, {
|
||||
deploymentMode: opts.deploymentMode,
|
||||
|
|
|
|||
|
|
@ -259,7 +259,9 @@ function registerWithSessionManagement(adapter: ServerAdapterModule): void {
|
|||
// Router
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function adapterRoutes() {
|
||||
export function adapterRoutes(options: {
|
||||
getNativeRunnerEnabled?: () => Promise<boolean>;
|
||||
} = {}) {
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
|
|
@ -280,6 +282,8 @@ export function adapterRoutes() {
|
|||
listAdapterPlugins().map((r) => [r.type, r]),
|
||||
);
|
||||
const disabledSet = new Set(getDisabledAdapterTypes());
|
||||
const nativeRunnerEnabled = await options.getNativeRunnerEnabled?.().catch(() => false) ?? false;
|
||||
if (!nativeRunnerEnabled) disabledSet.add("paperclip_runner");
|
||||
|
||||
const result: AdapterInfo[] = registeredAdapters.map((adapter) =>
|
||||
buildAdapterInfo(adapter, externalRecords.get(adapter.type), disabledSet),
|
||||
|
|
|
|||
|
|
@ -1568,8 +1568,17 @@ export function agentRoutes(
|
|||
* (listEnabledServerAdapters documents the same rule: hidden from selection,
|
||||
* still functional for agents that already use them).
|
||||
*/
|
||||
function assertSelectableAdapterType(type: string | null | undefined): string {
|
||||
async function assertSelectableAdapterType(type: string | null | undefined): Promise<string> {
|
||||
const adapterType = assertKnownAdapterType(type);
|
||||
if (adapterType === "paperclip_runner") {
|
||||
const experimental = await instanceSettings.getExperimental();
|
||||
if (experimental.enableNativeRunner !== true) {
|
||||
throw unprocessable(
|
||||
"Paperclip Runner is experimental and disabled on this instance.",
|
||||
{ code: "paperclip_runner_rollout_disabled" },
|
||||
);
|
||||
}
|
||||
}
|
||||
const disabled = new Set(getDisabledAdapterTypes());
|
||||
if (!disabled.has(adapterType)) return adapterType;
|
||||
const available = listServerAdapters()
|
||||
|
|
@ -3341,7 +3350,7 @@ export function agentRoutes(
|
|||
applyStoredClaudeLogin: hireApplyStoredClaudeLogin,
|
||||
...hireInput
|
||||
} = req.body;
|
||||
hireInput.adapterType = assertSelectableAdapterType(hireInput.adapterType);
|
||||
hireInput.adapterType = await assertSelectableAdapterType(hireInput.adapterType);
|
||||
const rawHireAdapterConfig = (hireInput.adapterConfig ?? {}) as Record<string, unknown>;
|
||||
assertNoNewAgentLegacyPromptTemplate(
|
||||
hireInput.adapterType,
|
||||
|
|
@ -3560,7 +3569,7 @@ export function agentRoutes(
|
|||
applyStoredClaudeLogin: createApplyStoredClaudeLogin,
|
||||
...createInput
|
||||
} = req.body;
|
||||
createInput.adapterType = assertSelectableAdapterType(createInput.adapterType);
|
||||
createInput.adapterType = await assertSelectableAdapterType(createInput.adapterType);
|
||||
const rawCreateAdapterConfig = (createInput.adapterConfig ?? {}) as Record<string, unknown>;
|
||||
assertNoNewAgentLegacyPromptTemplate(
|
||||
createInput.adapterType,
|
||||
|
|
@ -3997,12 +4006,12 @@ export function agentRoutes(
|
|||
// it gets the selectable check; keeping the agent's current adapter (even
|
||||
// one since disabled) stays allowed, so a disabled harness does not make an
|
||||
// existing agent uneditable.
|
||||
const requestedAdapterType = hasOwn(patchData, "adapterType")
|
||||
? (() => {
|
||||
const next = assertKnownAdapterType(patchData.adapterType as string | null | undefined);
|
||||
return next === existing.adapterType ? next : assertSelectableAdapterType(next);
|
||||
})()
|
||||
const nextAdapterType = hasOwn(patchData, "adapterType")
|
||||
? assertKnownAdapterType(patchData.adapterType as string | null | undefined)
|
||||
: existing.adapterType;
|
||||
const requestedAdapterType = nextAdapterType === existing.adapterType
|
||||
? nextAdapterType
|
||||
: await assertSelectableAdapterType(nextAdapterType);
|
||||
let requestedRuntimeConfig: Record<string, unknown> | null = null;
|
||||
if (hasOwn(patchData, "runtimeConfig")) {
|
||||
const runtimeConfig = asRecord(patchData.runtimeConfig);
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ import { companyService } from "./companies.js";
|
|||
import { validateCron } from "./cron.js";
|
||||
import { documentService } from "./documents.js";
|
||||
import { issueService } from "./issues.js";
|
||||
import { instanceSettingsService } from "./instance-settings.js";
|
||||
import { projectService } from "./projects.js";
|
||||
import { workProductService } from "./work-products.js";
|
||||
import { routineService } from "./routines.js";
|
||||
|
|
@ -5230,6 +5231,28 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||
const warnings = [...plan.preview.warnings];
|
||||
const include = plan.include;
|
||||
|
||||
if (include.agents) {
|
||||
const importedAgentSlugs = new Set(
|
||||
plan.preview.plan.agentPlans
|
||||
.filter((entry) => entry.action !== "skip")
|
||||
.map((entry) => entry.slug),
|
||||
);
|
||||
const selectsNativeRunner = sourceManifest.agents.some((agent) =>
|
||||
importedAgentSlugs.has(agent.slug)
|
||||
&& (input.adapterOverrides?.[agent.slug]?.adapterType ?? agent.adapterType)
|
||||
=== "paperclip_runner",
|
||||
);
|
||||
if (
|
||||
selectsNativeRunner
|
||||
&& (await instanceSettingsService(db).getExperimental()).enableNativeRunner !== true
|
||||
) {
|
||||
throw unprocessable(
|
||||
"Paperclip Runner is experimental and disabled on this instance.",
|
||||
{ code: "paperclip_runner_rollout_disabled" },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Content-addressed blobs double as the bundle's tamper seal. Verify every
|
||||
// blob before any row is written so a corrupted package cannot leave a
|
||||
// partially imported company behind.
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ import {
|
|||
issueThreadInteractions,
|
||||
issues,
|
||||
issueWorkProducts,
|
||||
nativeRunFinalizations,
|
||||
projects,
|
||||
projectWorkspaces,
|
||||
routineRevisions,
|
||||
|
|
@ -333,12 +334,24 @@ import {
|
|||
} from "./effective-run-config-fingerprints.js";
|
||||
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
|
||||
import { serverVersion } from "../version.js";
|
||||
import { executeNativeCodexRunner } from "./native-runtime/native-codex-runner.js";
|
||||
import { prepareNativeHeartbeatRun } from "./native-runtime/prepare-native-run.js";
|
||||
import {
|
||||
NativeRunnerSelectionError,
|
||||
resolveHeartbeatRuntimeMode,
|
||||
} from "./native-runtime/runtime-mode.js";
|
||||
|
||||
const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024;
|
||||
const MAX_PERSISTED_LOG_CHUNK_CHARS = 64 * 1024;
|
||||
const MAX_RUN_EVENT_PAYLOAD_STRING_CHARS = 16 * 1024;
|
||||
const MAX_RUN_EVENT_PAYLOAD_ARRAY_ITEMS = 50;
|
||||
|
||||
function nativeRunnerErrorCode(error: unknown): string | null {
|
||||
if (error instanceof NativeRunnerSelectionError) return error.code;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return message.match(/^(paperclip_runner_[a-z0-9_]+)/)?.[1] ?? null;
|
||||
}
|
||||
|
||||
export function redactDetectedSuccessfulRunProgressSummaryForBoard(
|
||||
summary: string,
|
||||
currentUserRedactionOptions?: CurrentUserRedactionOptions,
|
||||
|
|
@ -7271,6 +7284,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
description: issues.description,
|
||||
status: issues.status,
|
||||
workMode: issues.workMode,
|
||||
reviewPolicy: issues.reviewPolicy,
|
||||
priority: issues.priority,
|
||||
projectId: issues.projectId,
|
||||
projectWorkspaceId: issues.projectWorkspaceId,
|
||||
|
|
@ -14373,6 +14387,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
priority: issueContext.priority,
|
||||
workMode: issueContext.workMode,
|
||||
description: issueContext.description,
|
||||
reviewPolicy: issueContext.reviewPolicy,
|
||||
projectId: issueContext.projectId,
|
||||
projectWorkspaceId: issueContext.projectWorkspaceId,
|
||||
executionWorkspaceId: issueContext.executionWorkspaceId,
|
||||
|
|
@ -15970,6 +15985,18 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
});
|
||||
};
|
||||
|
||||
const runtimeResolution = resolveHeartbeatRuntimeMode({
|
||||
persisted: {
|
||||
runtimeMode: run.runtimeMode,
|
||||
runtimeModeResolvedAt: run.runtimeModeResolvedAt,
|
||||
},
|
||||
enabled: resolvedInstanceSettings.experimental.enableNativeRunner === true,
|
||||
adapterType: agent.adapterType,
|
||||
adapterConfig: agent.adapterConfig,
|
||||
agentStatus: runningAgent.status,
|
||||
issue: issueRef ? { workMode: issueRef.workMode } : null,
|
||||
executionTarget,
|
||||
});
|
||||
const adapter = getServerAdapter(agent.adapterType);
|
||||
const localAgentJwtScope =
|
||||
issueRef?.workMode === "skill_test"
|
||||
|
|
@ -16176,59 +16203,110 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
|
||||
let adapterResult: Awaited<ReturnType<typeof adapter.execute>>;
|
||||
try {
|
||||
const adapterContext = { ...context };
|
||||
const runtimeMcpServers = await buildPaperclipRuntimeMcpServers({
|
||||
db,
|
||||
agent,
|
||||
runId: run.id,
|
||||
});
|
||||
const runtimeMcp = createAdapterRuntimeMcpAccess(runtimeMcpServers);
|
||||
const managedMcpConfig = await createManagedMcpRunConfig({
|
||||
db,
|
||||
agent,
|
||||
runId: run.id,
|
||||
config: runtimeConfig,
|
||||
projectId: issueRef?.projectId ?? null,
|
||||
issueId: issueRef?.id ?? null,
|
||||
});
|
||||
if (managedMcpConfig) {
|
||||
adapterContext.paperclipManagedMcp = managedMcpConfig;
|
||||
const onSpawn = async (meta: {
|
||||
pid: number;
|
||||
processGroupId: number | null;
|
||||
startedAt: string;
|
||||
}) => {
|
||||
await persistRunProcessMetadata(run.id, {
|
||||
pid: meta.pid,
|
||||
processGroupId: meta.processGroupId,
|
||||
startedAt: meta.startedAt,
|
||||
});
|
||||
};
|
||||
if (runtimeResolution.kind === "native") {
|
||||
if (!issueRef) throw new Error("paperclip_runner_issue_required");
|
||||
const native = await prepareNativeHeartbeatRun({
|
||||
db,
|
||||
run,
|
||||
issue: issueRef,
|
||||
environmentLeaseId: activeEnvironmentLease.lease.id,
|
||||
});
|
||||
const prompt = readNonEmptyString(context.paperclipTaskMarkdown)
|
||||
?? `# ${issueRef.identifier ?? issueRef.id}: ${issueRef.title}`;
|
||||
const configuredTimeoutSec = Number(runtimeConfig.timeoutSec);
|
||||
const timeoutMs = Number.isFinite(configuredTimeoutSec) && configuredTimeoutSec > 0
|
||||
? Math.min(configuredTimeoutSec * 1_000, 24 * 60 * 60 * 1_000)
|
||||
: 60 * 60 * 1_000;
|
||||
const environment = Object.fromEntries(
|
||||
Object.entries(parseObject(runtimeConfig.env)).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
),
|
||||
);
|
||||
await onAdapterMeta({
|
||||
adapterType: "paperclip_runner",
|
||||
command: "paperclip-runnerd",
|
||||
cwd: executionWorkspace.cwd,
|
||||
promptMetrics: { promptChars: prompt.length },
|
||||
context: { provider: "codex", protocolVersion: 1 },
|
||||
});
|
||||
adapterResult = await executeNativeCodexRunner({
|
||||
db,
|
||||
companyId: agent.companyId,
|
||||
issueId: issueRef.id,
|
||||
runId: run.id,
|
||||
agentId: agent.id,
|
||||
runnerInstanceId: native.runnerInstanceId,
|
||||
environmentLeaseId: native.environmentLeaseId,
|
||||
normalizedSessionId: native.normalizedSessionId,
|
||||
turnId: native.turnId,
|
||||
itemId: native.itemId,
|
||||
cwd: executionWorkspace.cwd,
|
||||
prompt,
|
||||
model: readNonEmptyString(runtimeConfig.model),
|
||||
resumeProviderSessionId: runtimeSessionIdForAdapter,
|
||||
completionContract: native.completionContract,
|
||||
timeoutMs,
|
||||
environment,
|
||||
onLog,
|
||||
onSpawn,
|
||||
});
|
||||
} else {
|
||||
const adapterContext = { ...context };
|
||||
const runtimeMcpServers = await buildPaperclipRuntimeMcpServers({
|
||||
db,
|
||||
agent,
|
||||
runId: run.id,
|
||||
});
|
||||
const runtimeMcp = createAdapterRuntimeMcpAccess(runtimeMcpServers);
|
||||
const managedMcpConfig = await createManagedMcpRunConfig({
|
||||
db,
|
||||
agent,
|
||||
runId: run.id,
|
||||
config: runtimeConfig,
|
||||
projectId: issueRef?.projectId ?? null,
|
||||
issueId: issueRef?.id ?? null,
|
||||
});
|
||||
if (managedMcpConfig) {
|
||||
adapterContext.paperclipManagedMcp = managedMcpConfig;
|
||||
}
|
||||
adapterResult = await adapter.execute({
|
||||
runId: run.id,
|
||||
agent,
|
||||
runtime: runtimeForAdapter,
|
||||
config: runtimeConfig,
|
||||
context: adapterContext,
|
||||
runtimeCommandSpec: adapter.getRuntimeCommandSpec?.(runtimeConfig) ?? null,
|
||||
executionTarget,
|
||||
executionTransport: remoteExecution
|
||||
? { remoteExecution: remoteExecution as unknown as Record<string, unknown> }
|
||||
: undefined,
|
||||
runtimeMcp,
|
||||
onLog,
|
||||
onMeta: onAdapterMeta,
|
||||
onEvent: onAdapterEvent,
|
||||
// The endpoint-gated OpenTelemetry startup trace context. It is a
|
||||
// no-op unless `OTEL_EXPORTER_OTLP_ENDPOINT` is set and the OTel
|
||||
// packages are installed, so the sandbox-start span path stays inert
|
||||
// by default.
|
||||
startupTraceContext: getStartupTraceContext(),
|
||||
onRuntimeProgress: async (progress) => {
|
||||
await recordCurrentHeartbeatRunRuntimeProgress(run, progress, issueId);
|
||||
},
|
||||
onSpawn,
|
||||
authToken: authToken ?? undefined,
|
||||
});
|
||||
}
|
||||
adapterResult = await adapter.execute({
|
||||
runId: run.id,
|
||||
agent,
|
||||
runtime: runtimeForAdapter,
|
||||
config: runtimeConfig,
|
||||
context: adapterContext,
|
||||
runtimeCommandSpec: adapter.getRuntimeCommandSpec?.(runtimeConfig) ?? null,
|
||||
executionTarget,
|
||||
executionTransport: remoteExecution
|
||||
? { remoteExecution: remoteExecution as unknown as Record<string, unknown> }
|
||||
: undefined,
|
||||
runtimeMcp,
|
||||
onLog,
|
||||
onMeta: onAdapterMeta,
|
||||
onEvent: onAdapterEvent,
|
||||
// The endpoint-gated OpenTelemetry startup trace context. It is a
|
||||
// no-op unless `OTEL_EXPORTER_OTLP_ENDPOINT` is set and the OTel
|
||||
// packages are installed, so the sandbox-start span path stays inert
|
||||
// by default.
|
||||
startupTraceContext: getStartupTraceContext(),
|
||||
onRuntimeProgress: async (progress) => {
|
||||
await recordCurrentHeartbeatRunRuntimeProgress(run, progress, issueId);
|
||||
},
|
||||
onSpawn: async (meta) => {
|
||||
await persistRunProcessMetadata(run.id, {
|
||||
pid: meta.pid,
|
||||
processGroupId:
|
||||
"processGroupId" in meta && typeof meta.processGroupId === "number"
|
||||
? meta.processGroupId
|
||||
: null,
|
||||
startedAt: meta.startedAt,
|
||||
});
|
||||
},
|
||||
authToken: authToken ?? undefined,
|
||||
});
|
||||
// Adapter returned cleanly, which means its workspace-restore finally
|
||||
// block also ran without throwing. Record the workspace_finalize
|
||||
// barrier so dependents that share this executionWorkspace can wake.
|
||||
|
|
@ -16501,6 +16579,28 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
return;
|
||||
}
|
||||
|
||||
if (runtimeResolution.kind === "native") {
|
||||
const nativePhase = status === "succeeded" ? "completed" : "failed";
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(nativeRunFinalizations)
|
||||
.set({
|
||||
phase: nativePhase,
|
||||
failureCode: status === "succeeded" ? null : (runErrorCode ?? "provider_failed"),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(nativeRunFinalizations.runId, run.id));
|
||||
await tx
|
||||
.update(heartbeatRuns)
|
||||
.set({
|
||||
nativePhase,
|
||||
nativePhaseUpdatedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(heartbeatRuns.id, run.id));
|
||||
});
|
||||
}
|
||||
|
||||
let persistedRun = persistedRunWrite.run;
|
||||
if (persistedRun) {
|
||||
persistedRun = await classifyAndPersistRunLiveness(persistedRun, persistedResultJson) ?? persistedRun;
|
||||
|
|
@ -16679,6 +16779,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
const failureErrorCode =
|
||||
workspaceValidationFailure?.code
|
||||
?? configurationIncompleteFailure?.code
|
||||
?? nativeRunnerErrorCode(err)
|
||||
?? recordedResponsibleUserDenialCode
|
||||
?? "adapter_failed";
|
||||
logger.error({ err, runId }, "heartbeat execution failed");
|
||||
|
|
@ -16727,6 +16828,16 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
}
|
||||
|
||||
const failedRun = failedRunWrite.run;
|
||||
if (failedRun?.runtimeMode === "native") {
|
||||
await db
|
||||
.update(heartbeatRuns)
|
||||
.set({ nativePhase: "failed", nativePhaseUpdatedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(heartbeatRuns.id, failedRun.id));
|
||||
await db
|
||||
.update(nativeRunFinalizations)
|
||||
.set({ phase: "failed", failureCode: failureErrorCode, updatedAt: new Date() })
|
||||
.where(eq(nativeRunFinalizations.runId, failedRun.id));
|
||||
}
|
||||
await setWakeupStatus(run.wakeupRequestId, "failed", {
|
||||
finishedAt: new Date(),
|
||||
error: message,
|
||||
|
|
@ -16829,6 +16940,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
workspaceValidationSetupFailure?.code ??
|
||||
configurationIncompleteSetupFailure?.code ??
|
||||
(unresolvedBaseRefSetupFailure ? CONFIGURATION_INCOMPLETE_FAILURE_CODE : null) ??
|
||||
nativeRunnerErrorCode(outerErr) ??
|
||||
recordedResponsibleUserDenialCode ??
|
||||
"setup_failed";
|
||||
logger.error({ err: outerErr, runId }, "heartbeat execution setup failed");
|
||||
|
|
|
|||
|
|
@ -219,6 +219,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
|
|||
if (parsed.success) {
|
||||
return {
|
||||
enableEnvironments: parsed.data.enableEnvironments ?? false,
|
||||
enableNativeRunner: parsed.data.enableNativeRunner ?? false,
|
||||
enableManagedSandboxOnly: parsed.data.enableManagedSandboxOnly ?? false,
|
||||
enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false,
|
||||
enableStreamlinedLeftNavigation: parsed.data.enableStreamlinedLeftNavigation ?? true,
|
||||
|
|
@ -257,6 +258,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
|
|||
}
|
||||
return {
|
||||
enableEnvironments: false,
|
||||
enableNativeRunner: false,
|
||||
enableManagedSandboxOnly: false,
|
||||
enableIsolatedWorkspaces: false,
|
||||
enableStreamlinedLeftNavigation: true,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
import { createHash } from "node:crypto";
|
||||
|
||||
export function canonicalNativeJson(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(canonicalNativeJson).join(",")}]`;
|
||||
if (typeof value === "object" && value !== null) {
|
||||
const record = value as Record<string, unknown>;
|
||||
return `{${Object.keys(record)
|
||||
.filter((key) => record[key] !== undefined)
|
||||
.sort()
|
||||
.map((key) => `${JSON.stringify(key)}:${canonicalNativeJson(record[key])}`)
|
||||
.join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value) ?? "null";
|
||||
}
|
||||
|
||||
export function nativeSha256(value: unknown): string {
|
||||
return createHash("sha256").update(canonicalNativeJson(value), "utf8").digest("hex");
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildNativeCompletionContract } from "./completion-contracts.js";
|
||||
|
||||
describe("buildNativeCompletionContract", () => {
|
||||
it("uses the task description as the single initial criterion", () => {
|
||||
expect(buildNativeCompletionContract({
|
||||
title: "Ship the runner",
|
||||
description: "Prove the Codex vertical slice.",
|
||||
})).toEqual({
|
||||
revision: "1",
|
||||
objective: "Ship the runner",
|
||||
criteria: [{ id: "objective", requirement: "Prove the Codex vertical slice." }],
|
||||
});
|
||||
});
|
||||
|
||||
it("binds the persisted numeric revision into the protocol contract", () => {
|
||||
expect(buildNativeCompletionContract({
|
||||
title: "Continue the runner",
|
||||
description: null,
|
||||
}, 3).revision).toBe("3");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import { and, desc, eq, sql } from "drizzle-orm";
|
||||
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { completionContracts } from "@paperclipai/db";
|
||||
|
||||
import { nativeSha256 } from "./canonical.js";
|
||||
|
||||
export const NATIVE_COMPLETION_CONTRACT_SCHEMA = "paperclip.completion-contract.v1";
|
||||
export const NATIVE_COMPLETION_POLICY_VERSION = "paperclip-runner-v1";
|
||||
|
||||
interface NativeCompletionContractInput {
|
||||
revision: string;
|
||||
objective: string;
|
||||
criteria: Array<{ id: string; requirement: string }>;
|
||||
}
|
||||
|
||||
export function buildNativeCompletionContract(issue: {
|
||||
title: string;
|
||||
description: string | null;
|
||||
}, revision = 1): NativeCompletionContractInput {
|
||||
return {
|
||||
revision: String(revision),
|
||||
objective: issue.title,
|
||||
criteria: [{
|
||||
id: "objective",
|
||||
requirement: issue.description?.trim() || `Complete: ${issue.title}`,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensureNativeCompletionContract(input: {
|
||||
db: Db;
|
||||
companyId: string;
|
||||
issue: {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
reviewPolicy?: string | null;
|
||||
};
|
||||
actorId: string;
|
||||
}) {
|
||||
return input.db.transaction(async (tx) => {
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${[
|
||||
"paperclip:native-completion-contract",
|
||||
input.companyId,
|
||||
input.issue.id,
|
||||
].join(":")}, 0))`);
|
||||
const externalReviewRequired = ["human_only", "not_creator"].includes(
|
||||
input.issue.reviewPolicy ?? "",
|
||||
);
|
||||
const policy = externalReviewRequired
|
||||
? { risk: "standard", completionAuthority: "server_arbiter" }
|
||||
: { risk: "low", completionAuthority: "agent_claim_policy" };
|
||||
const latest = await tx
|
||||
.select()
|
||||
.from(completionContracts)
|
||||
.where(and(
|
||||
eq(completionContracts.companyId, input.companyId),
|
||||
eq(completionContracts.issueId, input.issue.id),
|
||||
))
|
||||
.orderBy(desc(completionContracts.revision))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
const latestRevision = latest?.revision ?? 1;
|
||||
const latestCandidate = buildNativeCompletionContract(input.issue, latestRevision);
|
||||
const latestCandidateSha256 = nativeSha256({
|
||||
schemaVersion: NATIVE_COMPLETION_CONTRACT_SCHEMA,
|
||||
policyVersion: NATIVE_COMPLETION_POLICY_VERSION,
|
||||
...policy,
|
||||
contract: latestCandidate,
|
||||
});
|
||||
if (latest?.canonicalSha256 === latestCandidateSha256) {
|
||||
return { row: latest, contract: latestCandidate };
|
||||
}
|
||||
|
||||
const nextRevision = latest ? latest.revision + 1 : 1;
|
||||
const contract = buildNativeCompletionContract(input.issue, nextRevision);
|
||||
const canonicalSha256 = nativeSha256({
|
||||
schemaVersion: NATIVE_COMPLETION_CONTRACT_SCHEMA,
|
||||
policyVersion: NATIVE_COMPLETION_POLICY_VERSION,
|
||||
...policy,
|
||||
contract,
|
||||
});
|
||||
const [row] = await tx.insert(completionContracts).values({
|
||||
companyId: input.companyId,
|
||||
issueId: input.issue.id,
|
||||
revision: nextRevision,
|
||||
schemaVersion: NATIVE_COMPLETION_CONTRACT_SCHEMA,
|
||||
policyVersion: NATIVE_COMPLETION_POLICY_VERSION,
|
||||
...policy,
|
||||
incompleteCriteriaPolicy: "preserve_non_terminal",
|
||||
contractJson: contract as unknown as Record<string, unknown>,
|
||||
canonicalSha256,
|
||||
createdByActorType: "system",
|
||||
createdByActorId: input.actorId,
|
||||
supersedesContractId: latest?.id ?? null,
|
||||
}).returning();
|
||||
if (!row) throw new Error("native_completion_contract_not_persisted");
|
||||
return { row, contract };
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,336 @@
|
|||
import { execFileSync } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { tmpdir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
agents,
|
||||
companies,
|
||||
createDb,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
issues,
|
||||
nativeRunFinalizations,
|
||||
nativeRunResults,
|
||||
} from "@paperclipai/db";
|
||||
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "../../__tests__/helpers/embedded-postgres.js";
|
||||
import {
|
||||
runnerPrpWebSocketInternals,
|
||||
setupRunnerPrpWebSocketServer,
|
||||
} from "../../realtime/runner-prp-ws.js";
|
||||
import { executeNativeCodexRunner } from "./native-codex-runner.js";
|
||||
import { prepareNativeHeartbeatRun } from "./prepare-native-run.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping native Codex vertical-slice test on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
const runnerWorkspace = resolve(
|
||||
import.meta.dirname,
|
||||
"../../../../packages/paperclip-runner/runner",
|
||||
);
|
||||
const executableSuffix = process.platform === "win32" ? ".exe" : "";
|
||||
const runnerBinary = resolve(
|
||||
runnerWorkspace,
|
||||
"target",
|
||||
"release",
|
||||
`paperclip-runnerd${executableSuffix}`,
|
||||
);
|
||||
const fakeCodexBinary = resolve(
|
||||
runnerWorkspace,
|
||||
"target",
|
||||
"release",
|
||||
`fake-codex-app-server${executableSuffix}`,
|
||||
);
|
||||
|
||||
function ensureRunnerTestBinaries(): void {
|
||||
if (existsSync(runnerBinary) && existsSync(fakeCodexBinary)) return;
|
||||
execFileSync("cargo", [
|
||||
"build",
|
||||
"--release",
|
||||
"--locked",
|
||||
"-p",
|
||||
"paperclip-runner-core",
|
||||
"--bin",
|
||||
"paperclip-runnerd",
|
||||
"--bin",
|
||||
"fake-codex-app-server",
|
||||
], {
|
||||
cwd: runnerWorkspace,
|
||||
stdio: "inherit",
|
||||
timeout: 180_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function closeServer(server: Server | null): Promise<void> {
|
||||
if (!server) return;
|
||||
server.closeAllConnections();
|
||||
if (!server.listening) return;
|
||||
await new Promise<void>((resolveClose, rejectClose) => {
|
||||
server.close((error) => error ? rejectClose(error) : resolveClose());
|
||||
});
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("native Codex server vertical slice", () => {
|
||||
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
let runtimeRoot: string | null = null;
|
||||
let server: Server | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
ensureRunnerTestBinaries();
|
||||
temporary = await startEmbeddedPostgresTestDatabase("native-codex-vertical-slice-");
|
||||
runtimeRoot = await mkdtemp(resolve(tmpdir(), "native-codex-runtime-"));
|
||||
server = createServer();
|
||||
await new Promise<void>((resolveListen) => server!.listen(0, "127.0.0.1", resolveListen));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Expected a TCP listener");
|
||||
setupRunnerPrpWebSocketServer(server, {
|
||||
apiUrl: `http://127.0.0.1:${address.port}`,
|
||||
});
|
||||
}, 240_000);
|
||||
|
||||
afterAll(async () => {
|
||||
runnerPrpWebSocketInternals.resetForTests();
|
||||
await closeServer(server);
|
||||
await temporary?.cleanup();
|
||||
if (runtimeRoot) await rm(runtimeRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns a durable result and resumes the provider session on the next run", async () => {
|
||||
if (!temporary || !runtimeRoot) throw new Error("Vertical-slice fixture was not initialized");
|
||||
const db = createDb(temporary.connectionString);
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const runId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Native Codex vertical slice",
|
||||
issuePrefix: "NCV",
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Native Codex",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "paperclip_runner",
|
||||
adapterConfig: { provider: "codex" },
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
identifier: "NCV-1",
|
||||
title: "Complete the native Codex vertical slice",
|
||||
description: "Return a bound structured completion result.",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
workMode: "standard",
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
const [run] = await db.insert(heartbeatRuns).values({
|
||||
id: runId,
|
||||
companyId,
|
||||
agentId,
|
||||
status: "running",
|
||||
invocationSource: "assignment",
|
||||
triggerDetail: "system",
|
||||
contextSnapshot: { issueId },
|
||||
}).returning();
|
||||
if (!run) throw new Error("Failed to seed native run");
|
||||
await db
|
||||
.update(issues)
|
||||
.set({ executionRunId: runId })
|
||||
.where(eq(issues.id, issueId));
|
||||
|
||||
const native = await prepareNativeHeartbeatRun({
|
||||
db,
|
||||
run,
|
||||
issue: {
|
||||
id: issueId,
|
||||
title: "Complete the native Codex vertical slice",
|
||||
description: "Return a bound structured completion result.",
|
||||
reviewPolicy: null,
|
||||
},
|
||||
environmentLeaseId: "lease-native-codex-e2e",
|
||||
});
|
||||
const logs: string[] = [];
|
||||
const execute = executeNativeCodexRunner({
|
||||
db,
|
||||
companyId,
|
||||
issueId,
|
||||
runId,
|
||||
agentId,
|
||||
runnerInstanceId: native.runnerInstanceId,
|
||||
environmentLeaseId: native.environmentLeaseId,
|
||||
normalizedSessionId: native.normalizedSessionId,
|
||||
turnId: native.turnId,
|
||||
itemId: native.itemId,
|
||||
cwd: tmpdir(),
|
||||
prompt: "Complete the fake native Codex turn.",
|
||||
model: "test-model",
|
||||
resumeProviderSessionId: null,
|
||||
completionContract: native.completionContract,
|
||||
timeoutMs: 30_000,
|
||||
environment: {},
|
||||
runnerBinary,
|
||||
runtimeRoot,
|
||||
providerLaunch: {
|
||||
command: fakeCodexBinary,
|
||||
args: [
|
||||
"--state-file",
|
||||
resolve(runtimeRoot, "fake-codex-state.json"),
|
||||
"--call-log",
|
||||
resolve(runtimeRoot, "fake-codex-calls.log"),
|
||||
],
|
||||
providerVersion: "fake-codex-v1",
|
||||
},
|
||||
onLog: async (_stream, chunk) => {
|
||||
logs.push(chunk);
|
||||
},
|
||||
onSpawn: async () => undefined,
|
||||
});
|
||||
const result = await execute.catch((error) => {
|
||||
throw new Error(
|
||||
`${error instanceof Error ? error.message : String(error)}\n${logs.join("")}`,
|
||||
);
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
provider: "codex",
|
||||
sessionParams: { sessionId: "codex-thread-1" },
|
||||
summary: "Codex completed the fake turn.",
|
||||
resultJson: {
|
||||
nativeRunner: {
|
||||
result: {
|
||||
schema: "paperclip.run_result.v1",
|
||||
completionClaim: {
|
||||
contractRevision: "1",
|
||||
objectiveSatisfied: true,
|
||||
criteria: [{ criterionId: "objective", status: "satisfied" }],
|
||||
},
|
||||
},
|
||||
terminal: {
|
||||
schema: "paperclip.prp.terminal.v1",
|
||||
runTerminalState: "succeeded",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(logs.join("\n")).not.toContain("PAPERCLIP_RUNNER_BOOTSTRAP_TICKET");
|
||||
|
||||
const [persistedResult] = await db
|
||||
.select()
|
||||
.from(nativeRunResults)
|
||||
.where(eq(nativeRunResults.runId, runId));
|
||||
expect(persistedResult).toMatchObject({ schemaStatus: "accepted" });
|
||||
const [finalization] = await db
|
||||
.select()
|
||||
.from(nativeRunFinalizations)
|
||||
.where(eq(nativeRunFinalizations.runId, runId));
|
||||
expect(finalization).toMatchObject({ phase: "workspace_finalizing" });
|
||||
const eventTypes = await db
|
||||
.select({ eventType: heartbeatRunEvents.eventType })
|
||||
.from(heartbeatRunEvents)
|
||||
.where(eq(heartbeatRunEvents.runId, runId));
|
||||
expect(eventTypes.map((event) => event.eventType)).toEqual(expect.arrayContaining([
|
||||
"turn.completed",
|
||||
"run.result.proposed",
|
||||
"run.terminal",
|
||||
]));
|
||||
|
||||
const resumedRunId = randomUUID();
|
||||
const [resumedRun] = await db.insert(heartbeatRuns).values({
|
||||
id: resumedRunId,
|
||||
companyId,
|
||||
agentId,
|
||||
status: "running",
|
||||
invocationSource: "assignment",
|
||||
triggerDetail: "system",
|
||||
contextSnapshot: { issueId },
|
||||
}).returning();
|
||||
if (!resumedRun) throw new Error("Failed to seed resumed native run");
|
||||
await db
|
||||
.update(issues)
|
||||
.set({ executionRunId: resumedRunId })
|
||||
.where(eq(issues.id, issueId));
|
||||
const resumedNative = await prepareNativeHeartbeatRun({
|
||||
db,
|
||||
run: resumedRun,
|
||||
issue: {
|
||||
id: issueId,
|
||||
title: "Complete the native Codex vertical slice",
|
||||
description: "Return a bound structured completion result.",
|
||||
reviewPolicy: null,
|
||||
},
|
||||
environmentLeaseId: "lease-native-codex-resume",
|
||||
});
|
||||
const resumed = await executeNativeCodexRunner({
|
||||
db,
|
||||
companyId,
|
||||
issueId,
|
||||
runId: resumedRunId,
|
||||
agentId,
|
||||
runnerInstanceId: resumedNative.runnerInstanceId,
|
||||
environmentLeaseId: resumedNative.environmentLeaseId,
|
||||
normalizedSessionId: resumedNative.normalizedSessionId,
|
||||
turnId: resumedNative.turnId,
|
||||
itemId: resumedNative.itemId,
|
||||
cwd: tmpdir(),
|
||||
prompt: "Continue the fake native Codex session.",
|
||||
model: "test-model",
|
||||
resumeProviderSessionId: "codex-thread-1",
|
||||
completionContract: resumedNative.completionContract,
|
||||
timeoutMs: 30_000,
|
||||
environment: {},
|
||||
runnerBinary,
|
||||
runtimeRoot,
|
||||
providerLaunch: {
|
||||
command: fakeCodexBinary,
|
||||
args: [
|
||||
"--state-file",
|
||||
resolve(runtimeRoot, "fake-codex-state.json"),
|
||||
"--call-log",
|
||||
resolve(runtimeRoot, "fake-codex-calls.log"),
|
||||
],
|
||||
providerVersion: "fake-codex-v1",
|
||||
},
|
||||
onLog: async (_stream, chunk) => {
|
||||
logs.push(chunk);
|
||||
},
|
||||
onSpawn: async () => undefined,
|
||||
});
|
||||
expect(resumed).toMatchObject({
|
||||
exitCode: 0,
|
||||
sessionParams: { sessionId: "codex-thread-1" },
|
||||
});
|
||||
const providerCalls = await readFile(
|
||||
resolve(runtimeRoot, "fake-codex-calls.log"),
|
||||
"utf8",
|
||||
);
|
||||
expect(providerCalls.match(/^thread\/start$/gm)).toHaveLength(1);
|
||||
expect(providerCalls.match(/^thread\/resume$/gm)).toHaveLength(1);
|
||||
}, 60_000);
|
||||
});
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildNativeRunnerArguments } from "./native-codex-runner.js";
|
||||
|
||||
describe("buildNativeRunnerArguments", () => {
|
||||
it("binds every durable identity without exposing the bootstrap ticket", () => {
|
||||
const args = buildNativeRunnerArguments({
|
||||
connectUrl: "ws://127.0.0.1:3000/api/runner/v1/connect/run-1",
|
||||
stateDirectory: "/tmp/runner-state",
|
||||
runnerInstanceId: "runner-1",
|
||||
environmentLeaseId: "lease-1",
|
||||
runId: "run-1",
|
||||
normalizedSessionId: "session-1",
|
||||
turnId: "turn-1",
|
||||
itemId: "item-1",
|
||||
runnerDigest: `sha256:${"a".repeat(64)}`,
|
||||
maxRuntimeMs: 60_000,
|
||||
});
|
||||
expect(args).toContain("--connect-url");
|
||||
expect(args).toContain("--runner-digest");
|
||||
expect(args.join(" ")).not.toContain("bootstrap");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,291 @@
|
|||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { accessSync, chmodSync, constants, mkdirSync, readFileSync } from "node:fs";
|
||||
import { dirname, isAbsolute, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import type { AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
|
||||
import { resolvePaperclipInstanceRoot } from "../../home-paths.js";
|
||||
import { runnerPrpCoordinator } from "./runner-prp-coordinator.js";
|
||||
|
||||
const moduleDirectory = dirname(fileURLToPath(import.meta.url));
|
||||
const RUNNER_VERSION = "paperclip-runner-v1";
|
||||
|
||||
function executableName(): string {
|
||||
return process.platform === "win32" ? "paperclip-runnerd.exe" : "paperclip-runnerd";
|
||||
}
|
||||
|
||||
export function resolvePaperclipRunnerBinary(
|
||||
configuredPath = process.env.PAPERCLIP_RUNNER_BINARY,
|
||||
): string {
|
||||
const candidates = [
|
||||
configuredPath,
|
||||
resolve(moduleDirectory, "../../vendor/paperclip-runner/bin", executableName()),
|
||||
resolve(moduleDirectory, "../../../../packages/paperclip-runner/dist/bin", executableName()),
|
||||
resolve(
|
||||
moduleDirectory,
|
||||
"../../../../packages/paperclip-runner/runner/target/release",
|
||||
executableName(),
|
||||
),
|
||||
].filter((candidate): candidate is string => Boolean(candidate));
|
||||
if (configuredPath && !isAbsolute(configuredPath)) {
|
||||
throw new Error("PAPERCLIP_RUNNER_BINARY must be an absolute path");
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
accessSync(candidate, constants.R_OK | (process.platform === "win32" ? 0 : constants.X_OK));
|
||||
return candidate;
|
||||
} catch {
|
||||
// Continue through the fixed production and workspace locations.
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
"paperclip_runner_binary_missing: build @paperclipai/paperclip-runner or set PAPERCLIP_RUNNER_BINARY",
|
||||
);
|
||||
}
|
||||
|
||||
export function buildNativeRunnerArguments(input: {
|
||||
connectUrl: string;
|
||||
stateDirectory: string;
|
||||
runnerInstanceId: string;
|
||||
environmentLeaseId: string;
|
||||
runId: string;
|
||||
normalizedSessionId: string;
|
||||
turnId: string;
|
||||
itemId: string;
|
||||
runnerDigest: string;
|
||||
maxRuntimeMs: number;
|
||||
}): string[] {
|
||||
return [
|
||||
"--connect-url", input.connectUrl,
|
||||
"--state-dir", input.stateDirectory,
|
||||
"--runner-id", input.runnerInstanceId,
|
||||
"--environment-lease-id", input.environmentLeaseId,
|
||||
"--run-id", input.runId,
|
||||
"--session-id", input.normalizedSessionId,
|
||||
"--turn-id", input.turnId,
|
||||
"--item-id", input.itemId,
|
||||
"--runner-version", RUNNER_VERSION,
|
||||
"--runner-digest", input.runnerDigest,
|
||||
"--max-runtime-ms", String(input.maxRuntimeMs),
|
||||
];
|
||||
}
|
||||
|
||||
function privateDirectory(path: string): void {
|
||||
mkdirSync(path, { recursive: true, mode: 0o700 });
|
||||
if (process.platform !== "win32") chmodSync(path, 0o700);
|
||||
}
|
||||
|
||||
function waitForExit(child: ChildProcess): Promise<{
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
}> {
|
||||
return new Promise((resolveExit, rejectExit) => {
|
||||
child.once("error", rejectExit);
|
||||
child.once("exit", (code, signal) => resolveExit({ code, signal }));
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForChildExit(exit: Promise<unknown>, timeoutMs: number): Promise<boolean> {
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
try {
|
||||
return await Promise.race([
|
||||
exit.then(() => true),
|
||||
new Promise<false>((resolveTimeout) => {
|
||||
timer = setTimeout(() => resolveTimeout(false), timeoutMs);
|
||||
timer.unref();
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function signalRunnerProcessGroup(child: ChildProcess, signal: NodeJS.Signals): void {
|
||||
if (process.platform !== "win32" && child.pid) {
|
||||
try {
|
||||
process.kill(-child.pid, signal);
|
||||
return;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ESRCH") return;
|
||||
}
|
||||
}
|
||||
child.kill(signal);
|
||||
}
|
||||
|
||||
async function stopChild(
|
||||
child: ChildProcess,
|
||||
exit: Promise<unknown>,
|
||||
allowGracefulExit = false,
|
||||
): Promise<void> {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
if (allowGracefulExit && await waitForChildExit(exit, 5_000)) return;
|
||||
signalRunnerProcessGroup(child, "SIGTERM");
|
||||
if (await waitForChildExit(exit, 5_000)) return;
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
signalRunnerProcessGroup(child, "SIGKILL");
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeNativeCodexRunner(input: {
|
||||
db: Db;
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
runId: string;
|
||||
agentId: string;
|
||||
runnerInstanceId: string;
|
||||
environmentLeaseId: string;
|
||||
normalizedSessionId: string;
|
||||
turnId: string;
|
||||
itemId: string;
|
||||
cwd: string;
|
||||
prompt: string;
|
||||
model: string | null;
|
||||
resumeProviderSessionId: string | null;
|
||||
completionContract: { revision: string; criterionIds: string[] };
|
||||
timeoutMs: number;
|
||||
environment: Record<string, string>;
|
||||
/** Internal test seam; production always resolves the packaged binary. */
|
||||
runnerBinary?: string;
|
||||
/** Internal test seam; production always uses the instance runtime root. */
|
||||
runtimeRoot?: string;
|
||||
/** Internal conformance seam; production always launches `codex app-server`. */
|
||||
providerLaunch?: {
|
||||
command: string;
|
||||
args: string[];
|
||||
providerVersion?: string;
|
||||
};
|
||||
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||
onSpawn: (meta: {
|
||||
pid: number;
|
||||
processGroupId: number | null;
|
||||
startedAt: string;
|
||||
}) => Promise<void>;
|
||||
}): Promise<AdapterExecutionResult> {
|
||||
const binary = input.runnerBinary ?? resolvePaperclipRunnerBinary();
|
||||
const runnerDigest = `sha256:${createHash("sha256").update(readFileSync(binary)).digest("hex")}`;
|
||||
const runtimeRoot = input.runtimeRoot
|
||||
? resolve(input.runtimeRoot)
|
||||
: resolve(resolvePaperclipInstanceRoot(), "runtime", "paperclip-runner");
|
||||
const runnerStateDirectory = resolve(runtimeRoot, "runner", input.runId);
|
||||
privateDirectory(runtimeRoot);
|
||||
privateDirectory(resolve(runtimeRoot, "control-plane"));
|
||||
privateDirectory(resolve(runtimeRoot, "runner"));
|
||||
privateDirectory(runnerStateDirectory);
|
||||
|
||||
const prepared = await runnerPrpCoordinator(input.db, {
|
||||
stateRoot: resolve(runtimeRoot, "control-plane"),
|
||||
}).prepare({
|
||||
companyId: input.companyId,
|
||||
issueId: input.issueId,
|
||||
runId: input.runId,
|
||||
agentId: input.agentId,
|
||||
runnerInstanceId: input.runnerInstanceId,
|
||||
environmentLeaseId: input.environmentLeaseId,
|
||||
normalizedSessionId: input.normalizedSessionId,
|
||||
turnId: input.turnId,
|
||||
itemId: input.itemId,
|
||||
runnerVersion: RUNNER_VERSION,
|
||||
runnerDigest,
|
||||
});
|
||||
|
||||
prepared.queueCommand("run.prepare", {
|
||||
provider: {
|
||||
provider: "codex",
|
||||
driver: "codex_app_server",
|
||||
providerVersion: input.providerLaunch?.providerVersion ?? "codex-app-server-v1",
|
||||
command: input.providerLaunch?.command ?? "codex",
|
||||
args: input.providerLaunch?.args ?? ["app-server"],
|
||||
cwd: input.cwd,
|
||||
...(input.model ? { model: input.model } : {}),
|
||||
...(input.resumeProviderSessionId
|
||||
? { providerSessionId: input.resumeProviderSessionId }
|
||||
: {}),
|
||||
instructions: "",
|
||||
approvalPolicy: "never",
|
||||
},
|
||||
completionContract: input.completionContract,
|
||||
}, `prepare_${input.runId}`);
|
||||
prepared.queueCommand("session.open", {}, `open_${input.runId}`);
|
||||
prepared.queueCommand("turn.start", { text: input.prompt }, `turn_${input.runId}`);
|
||||
|
||||
const child = spawn(binary, buildNativeRunnerArguments({
|
||||
connectUrl: prepared.connectUrl,
|
||||
stateDirectory: runnerStateDirectory,
|
||||
runnerInstanceId: input.runnerInstanceId,
|
||||
environmentLeaseId: input.environmentLeaseId,
|
||||
runId: input.runId,
|
||||
normalizedSessionId: input.normalizedSessionId,
|
||||
turnId: input.turnId,
|
||||
itemId: input.itemId,
|
||||
runnerDigest,
|
||||
maxRuntimeMs: input.timeoutMs,
|
||||
}), {
|
||||
cwd: input.cwd,
|
||||
detached: process.platform !== "win32",
|
||||
env: {
|
||||
...process.env,
|
||||
...input.environment,
|
||||
PAPERCLIP_RUNNER_BOOTSTRAP_TICKET: prepared.bootstrapTicket,
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const exit = waitForExit(child);
|
||||
child.stdout?.on("data", (chunk: Buffer) => {
|
||||
void input.onLog("stdout", chunk.toString("utf8"));
|
||||
});
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
void input.onLog("stderr", chunk.toString("utf8"));
|
||||
});
|
||||
|
||||
try {
|
||||
if (!child.pid) throw new Error("paperclip_runner_process_not_started");
|
||||
await input.onSpawn({
|
||||
pid: child.pid,
|
||||
processGroupId: process.platform === "win32" ? null : child.pid,
|
||||
startedAt: new Date().toISOString(),
|
||||
});
|
||||
const completed = await Promise.race([
|
||||
prepared.waitForTerminal(input.timeoutMs),
|
||||
exit.then(async ({ code, signal }) => {
|
||||
const recovered = await prepared.waitForTerminal(2_000).catch(() => null);
|
||||
if (recovered) return recovered;
|
||||
throw new Error(
|
||||
`paperclip_runner_process_exited: code=${code ?? "null"} signal=${signal ?? "null"}`,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
prepared.queueCommand("session.close", {}, `close_${input.runId}`);
|
||||
prepared.queueCommand("runner.shutdown", {}, `shutdown_${input.runId}`);
|
||||
await stopChild(child, exit, true);
|
||||
|
||||
const succeeded = completed.terminal.runTerminalState === "succeeded";
|
||||
return {
|
||||
exitCode: succeeded ? 0 : 1,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
...(succeeded ? {} : {
|
||||
errorCode: "paperclip_runner_provider_failed",
|
||||
errorMessage: completed.result.summary,
|
||||
}),
|
||||
provider: "codex",
|
||||
model: input.model,
|
||||
sessionParams: {
|
||||
sessionId: completed.providerSessionId ?? input.normalizedSessionId,
|
||||
},
|
||||
sessionDisplayId: completed.providerSessionId ?? input.normalizedSessionId,
|
||||
resultJson: {
|
||||
nativeRunner: {
|
||||
result: completed.result,
|
||||
terminal: completed.terminal,
|
||||
},
|
||||
},
|
||||
summary: completed.result.summary,
|
||||
};
|
||||
} finally {
|
||||
await stopChild(child, exit).catch(() => undefined);
|
||||
await prepared.release();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { createHash } from "node:crypto";
|
||||
|
||||
import { and, desc, eq, lt } from "drizzle-orm";
|
||||
import { and, desc, eq, inArray, lt } from "drizzle-orm";
|
||||
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
|
|
@ -26,6 +26,8 @@ export interface NativeRunStoreBinding {
|
|||
readonly runnerSourceInstanceId: string;
|
||||
readonly completionContractId: string;
|
||||
readonly completionContractSha256: string;
|
||||
readonly completionContractRevision: string;
|
||||
readonly completionContractCriterionIds: readonly string[];
|
||||
}
|
||||
|
||||
export interface CompleteNativeRunInput {
|
||||
|
|
@ -52,21 +54,63 @@ function sha256(value: unknown): string {
|
|||
return `sha256:${createHash("sha256").update(canonicalJson(value)).digest("hex")}`;
|
||||
}
|
||||
|
||||
function assertTerminal(value: PrpTerminalState): void {
|
||||
function assertTerminal(value: unknown): asserts value is PrpTerminalState {
|
||||
const terminal = value as Partial<PrpTerminalState> | null;
|
||||
if (
|
||||
value.schema !== "paperclip.prp.terminal.v1" ||
|
||||
typeof value !== "object" ||
|
||||
terminal === null ||
|
||||
Array.isArray(value) ||
|
||||
terminal.schema !== "paperclip.prp.terminal.v1" ||
|
||||
typeof terminal.turnTerminalState !== "string" ||
|
||||
!["completed", "failed", "interrupted", "cancelled"].includes(
|
||||
value.turnTerminalState,
|
||||
terminal.turnTerminalState,
|
||||
) ||
|
||||
!["succeeded", "failed", "cancelled"].includes(value.runTerminalState) ||
|
||||
typeof terminal.runTerminalState !== "string" ||
|
||||
!["succeeded", "failed", "cancelled"].includes(terminal.runTerminalState) ||
|
||||
typeof terminal.reportedWorkDisposition !== "string" ||
|
||||
!["done", "blocked", "needs_review", "yielded"].includes(
|
||||
value.reportedWorkDisposition,
|
||||
terminal.reportedWorkDisposition,
|
||||
)
|
||||
) {
|
||||
throw new Error("native_terminal_schema_invalid");
|
||||
}
|
||||
}
|
||||
|
||||
function validateBoundCompletion(
|
||||
binding: NativeRunStoreBinding,
|
||||
result: unknown,
|
||||
terminal: unknown,
|
||||
): { result: PrpStructuredRunResult; terminal: PrpTerminalState } {
|
||||
const validated = validatePrpStructuredRunResult(result);
|
||||
if (!validated.ok) throw new Error("native_result_schema_invalid");
|
||||
assertTerminal(terminal);
|
||||
if (
|
||||
validated.result.completionClaim.contractRevision !==
|
||||
binding.completionContractRevision
|
||||
) {
|
||||
throw new Error("native_result_completion_contract_mismatch");
|
||||
}
|
||||
const reportedCriterionIds = validated.result.completionClaim.criteria.map(
|
||||
(criterion) => criterion.criterionId,
|
||||
);
|
||||
if (
|
||||
reportedCriterionIds.length !== binding.completionContractCriterionIds.length
|
||||
|| reportedCriterionIds.some(
|
||||
(criterionId, index) =>
|
||||
criterionId !== binding.completionContractCriterionIds[index],
|
||||
)
|
||||
) {
|
||||
throw new Error("native_result_completion_contract_mismatch");
|
||||
}
|
||||
if (
|
||||
validated.result.reportedWorkDisposition !==
|
||||
terminal.reportedWorkDisposition
|
||||
) {
|
||||
throw new Error("native_result_terminal_disposition_mismatch");
|
||||
}
|
||||
return { result: validated.result, terminal };
|
||||
}
|
||||
|
||||
/** Durable DB boundary used by the hidden PRP coordinator. */
|
||||
export class NativeRunCoordinatorStore {
|
||||
readonly #db: Db;
|
||||
|
|
@ -77,6 +121,79 @@ export class NativeRunCoordinatorStore {
|
|||
this.#binding = structuredClone(binding);
|
||||
}
|
||||
|
||||
async readCompletedRun(): Promise<{
|
||||
readonly result: PrpStructuredRunResult;
|
||||
readonly terminal: PrpTerminalState;
|
||||
readonly turnId?: string;
|
||||
} | null> {
|
||||
const [row] = await this.#db
|
||||
.select({ resultJson: nativeRunResults.resultJson })
|
||||
.from(nativeRunResults)
|
||||
.where(
|
||||
and(
|
||||
eq(nativeRunResults.companyId, this.#binding.companyId),
|
||||
eq(nativeRunResults.issueId, this.#binding.issueId),
|
||||
eq(nativeRunResults.runId, this.#binding.runId),
|
||||
eq(nativeRunResults.schemaStatus, "accepted"),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!row) return null;
|
||||
const envelope = row.resultJson as Record<string, unknown>;
|
||||
if (!("result" in envelope) || !("terminal" in envelope)) {
|
||||
throw new Error("native_result_envelope_invalid");
|
||||
}
|
||||
const validated = validateBoundCompletion(
|
||||
this.#binding,
|
||||
envelope.result,
|
||||
envelope.terminal,
|
||||
);
|
||||
if (envelope.turnId !== null && envelope.turnId !== undefined && typeof envelope.turnId !== "string") {
|
||||
throw new Error("native_result_envelope_invalid");
|
||||
}
|
||||
return {
|
||||
...validated,
|
||||
...(envelope.turnId ? { turnId: envelope.turnId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async readProviderSessionId(): Promise<string | null> {
|
||||
const rows = await this.#db
|
||||
.select({ payload: heartbeatRunEvents.payload })
|
||||
.from(heartbeatRunEvents)
|
||||
.where(and(
|
||||
eq(heartbeatRunEvents.runId, this.#binding.runId),
|
||||
eq(heartbeatRunEvents.sourceInstanceId, this.#binding.runnerSourceInstanceId),
|
||||
inArray(heartbeatRunEvents.eventType, [
|
||||
"session.started",
|
||||
"session.resumed",
|
||||
"session.reconciled",
|
||||
]),
|
||||
))
|
||||
.orderBy(desc(heartbeatRunEvents.sourceSeq))
|
||||
.limit(3);
|
||||
for (const row of rows) {
|
||||
const event = (row.payload as Record<string, unknown> | null)?.prpEvent;
|
||||
const payload = typeof event === "object" && event !== null && !Array.isArray(event)
|
||||
? (event as Record<string, unknown>).payload
|
||||
: null;
|
||||
const providerSessionId = typeof payload === "object"
|
||||
&& payload !== null
|
||||
&& !Array.isArray(payload)
|
||||
? (payload as Record<string, unknown>).providerSessionId
|
||||
: null;
|
||||
if (
|
||||
typeof providerSessionId === "string"
|
||||
&& providerSessionId.length > 0
|
||||
&& providerSessionId.length <= 240
|
||||
&& ![...providerSessionId].some((character) => /[\u0000-\u001f\u007f]/.test(character))
|
||||
) {
|
||||
return providerSessionId;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async appendEvent(value: PrpEvent): Promise<{
|
||||
readonly disposition: "committed" | "duplicate";
|
||||
readonly cursor: number;
|
||||
|
|
@ -205,18 +322,10 @@ export class NativeRunCoordinatorStore {
|
|||
readonly disposition: "committed" | "duplicate";
|
||||
readonly resultId: string;
|
||||
}> {
|
||||
const validated = validatePrpStructuredRunResult(input.result);
|
||||
if (!validated.ok) throw new Error("native_result_schema_invalid");
|
||||
assertTerminal(input.terminal);
|
||||
if (
|
||||
validated.result.reportedWorkDisposition !==
|
||||
input.terminal.reportedWorkDisposition
|
||||
) {
|
||||
throw new Error("native_result_terminal_disposition_mismatch");
|
||||
}
|
||||
const validated = validateBoundCompletion(this.#binding, input.result, input.terminal);
|
||||
const canonical = {
|
||||
result: validated.result,
|
||||
terminal: input.terminal,
|
||||
terminal: validated.terminal,
|
||||
turnId: input.turnId ?? null,
|
||||
};
|
||||
const canonicalSha256 = sha256(canonical);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,157 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { completionContracts, heartbeatRuns } from "@paperclipai/db";
|
||||
|
||||
import { ensureNativeCompletionContract } from "./completion-contracts.js";
|
||||
import { NATIVE_RUNTIME_RESOLVER_VERSION } from "./runtime-mode.js";
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
function contractBinding(
|
||||
contractJson: unknown,
|
||||
persistedRevision: number,
|
||||
): { revision: string; criterionIds: string[] } {
|
||||
const contract = record(contractJson);
|
||||
const revision = typeof contract.revision === "string" ? contract.revision : "";
|
||||
const criteria = Array.isArray(contract.criteria) ? contract.criteria : [];
|
||||
const criterionIds = criteria
|
||||
.map((criterion) => record(criterion).id)
|
||||
.filter((id): id is string => typeof id === "string" && id.length > 0);
|
||||
if (
|
||||
(revision && revision !== String(persistedRevision))
|
||||
|| criterionIds.length === 0
|
||||
|| criterionIds.length !== criteria.length
|
||||
) {
|
||||
throw new Error("native_completion_contract_binding_invalid");
|
||||
}
|
||||
return { revision: String(persistedRevision), criterionIds };
|
||||
}
|
||||
|
||||
export async function prepareNativeHeartbeatRun(input: {
|
||||
db: Db;
|
||||
run: {
|
||||
id: string;
|
||||
companyId: string;
|
||||
agentId: string;
|
||||
runtimeMode: string | null;
|
||||
runtimeModeResolvedAt: Date | null;
|
||||
runnerProfileJson: Record<string, unknown> | null;
|
||||
runnerInstanceId: string | null;
|
||||
nativeSessionId: string | null;
|
||||
nativeIssueId: string | null;
|
||||
completionContractId: string | null;
|
||||
completionContractSha256: string | null;
|
||||
};
|
||||
issue: {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
reviewPolicy?: string | null;
|
||||
};
|
||||
environmentLeaseId: string;
|
||||
}) {
|
||||
if (input.run.runtimeModeResolvedAt && input.run.runtimeMode !== "native") {
|
||||
throw new Error("native_runtime_mode_conflict");
|
||||
}
|
||||
const persistedProfile = record(input.run.runnerProfileJson);
|
||||
const runnerInstanceId = input.run.runnerInstanceId ?? randomUUID();
|
||||
const normalizedSessionId = input.run.nativeSessionId ?? randomUUID();
|
||||
const turnId = typeof persistedProfile.turnId === "string"
|
||||
? persistedProfile.turnId
|
||||
: randomUUID();
|
||||
const itemId = typeof persistedProfile.itemId === "string"
|
||||
? persistedProfile.itemId
|
||||
: randomUUID();
|
||||
const environmentLeaseId = typeof persistedProfile.environmentLeaseId === "string"
|
||||
? persistedProfile.environmentLeaseId
|
||||
: input.environmentLeaseId;
|
||||
|
||||
const persistedContract = input.run.completionContractId
|
||||
? await input.db
|
||||
.select()
|
||||
.from(completionContracts)
|
||||
.where(and(
|
||||
eq(completionContracts.id, input.run.completionContractId),
|
||||
eq(completionContracts.companyId, input.run.companyId),
|
||||
eq(completionContracts.issueId, input.issue.id),
|
||||
))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: null;
|
||||
const completion = persistedContract
|
||||
? { row: persistedContract, contract: persistedContract.contractJson }
|
||||
: await ensureNativeCompletionContract({
|
||||
db: input.db,
|
||||
companyId: input.run.companyId,
|
||||
issue: input.issue,
|
||||
actorId: input.run.agentId,
|
||||
});
|
||||
const binding = contractBinding(completion.contract, completion.row.revision);
|
||||
|
||||
await input.db.transaction(async (tx) => {
|
||||
const [locked] = await tx
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, input.run.id))
|
||||
.for("update")
|
||||
.limit(1);
|
||||
if (!locked) throw new Error("native_runtime_run_missing");
|
||||
if (locked.runtimeModeResolvedAt && locked.runtimeMode !== "native") {
|
||||
throw new Error("native_runtime_mode_conflict");
|
||||
}
|
||||
if (
|
||||
locked.runtimeModeResolvedAt
|
||||
&& (
|
||||
locked.runnerInstanceId !== runnerInstanceId
|
||||
|| locked.nativeSessionId !== normalizedSessionId
|
||||
|| locked.nativeIssueId !== input.issue.id
|
||||
|| locked.completionContractId !== completion.row.id
|
||||
)
|
||||
) {
|
||||
throw new Error("native_runtime_binding_conflict");
|
||||
}
|
||||
await tx
|
||||
.update(heartbeatRuns)
|
||||
.set({
|
||||
runtimeMode: "native",
|
||||
runtimeModeResolverVersion:
|
||||
locked.runtimeModeResolverVersion ?? NATIVE_RUNTIME_RESOLVER_VERSION,
|
||||
runtimeModeReason: locked.runtimeModeReason ?? "explicit_paperclip_runner",
|
||||
runtimeModeResolvedAt: locked.runtimeModeResolvedAt ?? new Date(),
|
||||
runnerProfileJson: {
|
||||
schema: "paperclip.runner.profile.v1",
|
||||
provider: "codex",
|
||||
turnId,
|
||||
itemId,
|
||||
environmentLeaseId,
|
||||
},
|
||||
runnerInstanceId,
|
||||
nativeSessionId: normalizedSessionId,
|
||||
nativeIssueId: input.issue.id,
|
||||
driverKind: "codex",
|
||||
driverVersion: "codex-app-server-v1",
|
||||
completionContractId: completion.row.id,
|
||||
completionContractSha256: completion.row.canonicalSha256,
|
||||
nativePhase: "provider_running",
|
||||
nativePhaseUpdatedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(heartbeatRuns.id, input.run.id));
|
||||
});
|
||||
|
||||
return {
|
||||
runnerInstanceId,
|
||||
normalizedSessionId,
|
||||
turnId,
|
||||
itemId,
|
||||
environmentLeaseId,
|
||||
completionContract: binding,
|
||||
};
|
||||
}
|
||||
|
|
@ -63,7 +63,7 @@ const result: PrpStructuredRunResult = {
|
|||
reportedWorkDisposition: "done",
|
||||
summary: "The hidden runner completed the bounded task.",
|
||||
completionClaim: {
|
||||
contractRevision: "contract-v1",
|
||||
contractRevision: "1",
|
||||
objectiveSatisfied: true,
|
||||
criteria: [],
|
||||
remainingWork: [],
|
||||
|
|
@ -226,6 +226,8 @@ describeEmbeddedPostgres("hidden runner PRP coordinator", () => {
|
|||
runnerSourceInstanceId: seed.runnerInstanceId,
|
||||
completionContractId: seed.completionContractId,
|
||||
completionContractSha256: seed.completionContractSha256,
|
||||
completionContractRevision: "1",
|
||||
completionContractCriterionIds: [],
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -339,6 +341,27 @@ describeEmbeddedPostgres("hidden runner PRP coordinator", () => {
|
|||
it("persists events and results idempotently and leases finalization", async () => {
|
||||
const seed = await seedNativeRun();
|
||||
const nativeStore = store(seed);
|
||||
await expect(nativeStore.completeRun({
|
||||
result: {
|
||||
...result,
|
||||
completionClaim: { ...result.completionClaim, contractRevision: "2" },
|
||||
},
|
||||
terminal,
|
||||
})).rejects.toThrow("native_result_completion_contract_mismatch");
|
||||
await expect(nativeStore.completeRun({
|
||||
result: {
|
||||
...result,
|
||||
completionClaim: {
|
||||
...result.completionClaim,
|
||||
criteria: [{
|
||||
criterionId: "not-bound",
|
||||
status: "satisfied",
|
||||
evidenceRefs: [],
|
||||
}],
|
||||
},
|
||||
},
|
||||
terminal,
|
||||
})).rejects.toThrow("native_result_completion_contract_mismatch");
|
||||
const event = runnerEvent(seed);
|
||||
await expect(nativeStore.appendEvent(event)).resolves.toMatchObject({
|
||||
disposition: "committed",
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { resolve } from "node:path";
|
|||
import { and, eq } from "drizzle-orm";
|
||||
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents, heartbeatRuns, issues } from "@paperclipai/db";
|
||||
import { agents, completionContracts, heartbeatRuns, issues } from "@paperclipai/db";
|
||||
import {
|
||||
DurablePrpControlPlane,
|
||||
type PaperclipSemanticToolDefinition,
|
||||
|
|
@ -56,6 +56,12 @@ export interface PreparedRunnerPrpSession {
|
|||
readonly disposition: "committed" | "duplicate";
|
||||
readonly resultId: string;
|
||||
}>;
|
||||
waitForTerminal(timeoutMs?: number): Promise<{
|
||||
readonly result: PrpStructuredRunResult;
|
||||
readonly terminal: PrpTerminalState;
|
||||
readonly turnId?: string;
|
||||
readonly providerSessionId?: string;
|
||||
}>;
|
||||
release(): Promise<void>;
|
||||
}
|
||||
|
||||
|
|
@ -95,6 +101,20 @@ function validateInput(input: PrepareRunnerPrpSessionInput): void {
|
|||
}
|
||||
}
|
||||
|
||||
function completionCriterionIds(value: unknown): string[] | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
const criteria = (value as Record<string, unknown>).criteria;
|
||||
if (!Array.isArray(criteria)) return null;
|
||||
const ids = criteria.map((criterion) => {
|
||||
if (typeof criterion !== "object" || criterion === null || Array.isArray(criterion)) return null;
|
||||
const id = (criterion as Record<string, unknown>).id;
|
||||
return typeof id === "string" && id.length > 0 ? id : null;
|
||||
});
|
||||
if (ids.some((id) => id === null)) return null;
|
||||
const typedIds = ids as string[];
|
||||
return new Set(typedIds).size === typedIds.length ? typedIds : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the hidden, run-bound PRP authority. This module does not select a
|
||||
* runtime or start runnerd. The flagged adapter owns those actions later.
|
||||
|
|
@ -126,7 +146,12 @@ export function runnerPrpCoordinator(
|
|||
);
|
||||
|
||||
const [binding] = await db
|
||||
.select({ run: heartbeatRuns, issue: issues, agent: agents })
|
||||
.select({
|
||||
run: heartbeatRuns,
|
||||
issue: issues,
|
||||
agent: agents,
|
||||
completionContract: completionContracts,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.innerJoin(
|
||||
issues,
|
||||
|
|
@ -142,6 +167,14 @@ export function runnerPrpCoordinator(
|
|||
eq(agents.companyId, heartbeatRuns.companyId),
|
||||
),
|
||||
)
|
||||
.innerJoin(
|
||||
completionContracts,
|
||||
and(
|
||||
eq(completionContracts.id, heartbeatRuns.completionContractId),
|
||||
eq(completionContracts.companyId, heartbeatRuns.companyId),
|
||||
eq(completionContracts.issueId, heartbeatRuns.nativeIssueId),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(heartbeatRuns.id, input.runId),
|
||||
|
|
@ -160,6 +193,7 @@ export function runnerPrpCoordinator(
|
|||
binding.run.driverKind !== "codex" ||
|
||||
!binding.run.completionContractId ||
|
||||
!binding.run.completionContractSha256 ||
|
||||
binding.completionContract.canonicalSha256 !== binding.run.completionContractSha256 ||
|
||||
binding.issue.assigneeAgentId !== input.agentId ||
|
||||
binding.issue.executionRunId !== input.runId ||
|
||||
["paused", "terminated", "pending_approval", "error"].includes(
|
||||
|
|
@ -168,6 +202,10 @@ export function runnerPrpCoordinator(
|
|||
) {
|
||||
throw new Error("runner_prp_run_not_authorized");
|
||||
}
|
||||
const criterionIds = completionCriterionIds(
|
||||
binding.completionContract.contractJson,
|
||||
);
|
||||
if (!criterionIds) throw new Error("runner_prp_run_not_authorized");
|
||||
|
||||
const semanticAuthority = new PaperclipRunnerSemanticAuthority(db, {
|
||||
companyId: input.companyId,
|
||||
|
|
@ -185,6 +223,22 @@ export function runnerPrpCoordinator(
|
|||
runnerSourceInstanceId: input.runnerInstanceId,
|
||||
completionContractId: binding.run.completionContractId,
|
||||
completionContractSha256: binding.run.completionContractSha256,
|
||||
completionContractRevision: String(binding.completionContract.revision),
|
||||
completionContractCriterionIds: criterionIds,
|
||||
});
|
||||
type StoredCompletedRun = NonNullable<Awaited<ReturnType<typeof nativeStore.readCompletedRun>>>;
|
||||
type CompletedRun = StoredCompletedRun & { readonly providerSessionId?: string };
|
||||
const withProviderSession = async (stored: StoredCompletedRun): Promise<CompletedRun> => {
|
||||
const providerSessionId = await nativeStore.readProviderSessionId();
|
||||
return {
|
||||
...stored,
|
||||
...(providerSessionId ? { providerSessionId } : {}),
|
||||
};
|
||||
};
|
||||
let completedRun: CompletedRun | null = null;
|
||||
let resolveTerminal!: (value: CompletedRun) => void;
|
||||
const terminalEvent = new Promise<CompletedRun>((resolveTerminalPromise) => {
|
||||
resolveTerminal = resolveTerminalPromise;
|
||||
});
|
||||
const authority = new DurablePrpControlPlane({
|
||||
stateDirectory: resolve(stateRoot, input.runId),
|
||||
|
|
@ -202,6 +256,12 @@ export function runnerPrpCoordinator(
|
|||
onCommittedEvent: async (event) => {
|
||||
await nativeStore.appendEvent(event);
|
||||
await nativeStore.reconcileTerminalEvent(event);
|
||||
if (event.eventType === "run.terminal") {
|
||||
const stored = await nativeStore.readCompletedRun();
|
||||
if (!stored) throw new Error("native_terminal_result_missing");
|
||||
completedRun = await withProviderSession(stored);
|
||||
resolveTerminal(completedRun);
|
||||
}
|
||||
},
|
||||
onSemanticToolInput: async (call) => {
|
||||
const result = await semanticAuthority.dispatch({
|
||||
|
|
@ -249,6 +309,33 @@ export function runnerPrpCoordinator(
|
|||
if (released) throw new Error("runner_prp_session_released");
|
||||
return nativeStore.completeRun(completeInput);
|
||||
},
|
||||
waitForTerminal: async (timeoutMs = 60 * 60 * 1_000) => {
|
||||
if (released) throw new Error("runner_prp_session_released");
|
||||
if (!Number.isInteger(timeoutMs) || timeoutMs < 1_000 || timeoutMs > 24 * 60 * 60 * 1_000) {
|
||||
throw new Error("runner_prp_terminal_timeout_invalid");
|
||||
}
|
||||
if (completedRun) return completedRun;
|
||||
const stored = await nativeStore.readCompletedRun();
|
||||
if (stored) {
|
||||
completedRun = await withProviderSession(stored);
|
||||
return completedRun;
|
||||
}
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
try {
|
||||
return await Promise.race([
|
||||
terminalEvent,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new Error("runner_prp_terminal_timeout")),
|
||||
timeoutMs,
|
||||
);
|
||||
timer.unref();
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
},
|
||||
release: async () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { BUILTIN_ADAPTER_TYPES } from "../../adapters/builtin-adapter-types.js";
|
||||
import {
|
||||
NativeRunnerSelectionError,
|
||||
resolveHeartbeatRuntimeMode,
|
||||
} from "./runtime-mode.js";
|
||||
|
||||
const base = {
|
||||
persisted: { runtimeMode: "legacy", runtimeModeResolvedAt: null },
|
||||
enabled: true,
|
||||
adapterConfig: { provider: "codex" },
|
||||
agentStatus: "running",
|
||||
issue: { workMode: "standard" },
|
||||
executionTarget: { kind: "local" },
|
||||
} as const;
|
||||
|
||||
describe("resolveHeartbeatRuntimeMode", () => {
|
||||
it("keeps every direct built-in adapter on the legacy path", () => {
|
||||
for (const adapterType of BUILTIN_ADAPTER_TYPES) {
|
||||
if (adapterType === "paperclip_runner") continue;
|
||||
expect(resolveHeartbeatRuntimeMode({ ...base, adapterType })).toEqual({
|
||||
kind: "legacy",
|
||||
resolverVersion: "paperclip-runner-v1",
|
||||
reason: "direct_adapter",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("fails closed for fresh runner starts while the flag is off", () => {
|
||||
expect(() => resolveHeartbeatRuntimeMode({
|
||||
...base,
|
||||
enabled: false,
|
||||
adapterType: "paperclip_runner",
|
||||
})).toThrowError(expect.objectContaining({
|
||||
code: "paperclip_runner_rollout_disabled",
|
||||
}) as NativeRunnerSelectionError);
|
||||
});
|
||||
|
||||
it("selects only Codex on a local target", () => {
|
||||
expect(resolveHeartbeatRuntimeMode({
|
||||
...base,
|
||||
adapterType: "paperclip_runner",
|
||||
})).toMatchObject({ kind: "native", provider: "codex" });
|
||||
expect(() => resolveHeartbeatRuntimeMode({
|
||||
...base,
|
||||
adapterType: "paperclip_runner",
|
||||
adapterConfig: { provider: "opencode" },
|
||||
})).toThrow(/only the Codex provider/);
|
||||
expect(() => resolveHeartbeatRuntimeMode({
|
||||
...base,
|
||||
adapterType: "paperclip_runner",
|
||||
executionTarget: { kind: "remote" },
|
||||
})).toThrow(/local execution environment/);
|
||||
});
|
||||
|
||||
it("recovers a persisted native run after the flag changes", () => {
|
||||
expect(resolveHeartbeatRuntimeMode({
|
||||
...base,
|
||||
enabled: false,
|
||||
adapterType: "paperclip_runner",
|
||||
persisted: { runtimeMode: "native", runtimeModeResolvedAt: new Date() },
|
||||
})).toEqual({
|
||||
kind: "native",
|
||||
resolverVersion: "paperclip-runner-v1",
|
||||
reason: "persisted_native_selection",
|
||||
provider: "codex",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
export const NATIVE_RUNTIME_RESOLVER_VERSION = "paperclip-runner-v1" as const;
|
||||
|
||||
export type HeartbeatRuntimeResolution =
|
||||
| {
|
||||
kind: "legacy";
|
||||
resolverVersion: typeof NATIVE_RUNTIME_RESOLVER_VERSION;
|
||||
reason: "direct_adapter" | "persisted_legacy_selection";
|
||||
}
|
||||
| {
|
||||
kind: "native";
|
||||
resolverVersion: typeof NATIVE_RUNTIME_RESOLVER_VERSION;
|
||||
reason: "explicit_paperclip_runner" | "persisted_native_selection";
|
||||
provider: "codex";
|
||||
};
|
||||
|
||||
export class NativeRunnerSelectionError extends Error {
|
||||
constructor(readonly code: string, message: string) {
|
||||
super(message);
|
||||
this.name = "NativeRunnerSelectionError";
|
||||
}
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
/** Resolve a run once. Persisted selections do not consult a later flag change. */
|
||||
export function resolveHeartbeatRuntimeMode(input: {
|
||||
persisted: {
|
||||
runtimeMode: string | null;
|
||||
runtimeModeResolvedAt: Date | null;
|
||||
};
|
||||
enabled: boolean;
|
||||
adapterType: string | null;
|
||||
adapterConfig: unknown;
|
||||
agentStatus: string;
|
||||
issue: { workMode: string } | null;
|
||||
executionTarget: { kind?: string } | null | undefined;
|
||||
}): HeartbeatRuntimeResolution {
|
||||
if (input.persisted.runtimeModeResolvedAt) {
|
||||
if (input.persisted.runtimeMode === "native") {
|
||||
return {
|
||||
kind: "native",
|
||||
resolverVersion: NATIVE_RUNTIME_RESOLVER_VERSION,
|
||||
reason: "persisted_native_selection",
|
||||
provider: "codex",
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "legacy",
|
||||
resolverVersion: NATIVE_RUNTIME_RESOLVER_VERSION,
|
||||
reason: "persisted_legacy_selection",
|
||||
};
|
||||
}
|
||||
|
||||
if (input.adapterType !== "paperclip_runner") {
|
||||
return {
|
||||
kind: "legacy",
|
||||
resolverVersion: NATIVE_RUNTIME_RESOLVER_VERSION,
|
||||
reason: "direct_adapter",
|
||||
};
|
||||
}
|
||||
if (!input.enabled) {
|
||||
throw new NativeRunnerSelectionError(
|
||||
"paperclip_runner_rollout_disabled",
|
||||
"Paperclip Runner is experimental and disabled on this instance.",
|
||||
);
|
||||
}
|
||||
const provider = record(input.adapterConfig).provider ?? "codex";
|
||||
if (provider !== "codex") {
|
||||
throw new NativeRunnerSelectionError(
|
||||
"paperclip_runner_provider_unsupported",
|
||||
"Paperclip Runner currently supports only the Codex provider.",
|
||||
);
|
||||
}
|
||||
if (!input.issue || !["standard", "planning", "ask"].includes(input.issue.workMode)) {
|
||||
throw new NativeRunnerSelectionError(
|
||||
"paperclip_runner_issue_ineligible",
|
||||
"Paperclip Runner requires a standard, planning, or ask task.",
|
||||
);
|
||||
}
|
||||
if (!input.executionTarget || input.executionTarget.kind !== "local") {
|
||||
throw new NativeRunnerSelectionError(
|
||||
"paperclip_runner_environment_unsupported",
|
||||
"Paperclip Runner currently requires a local execution environment.",
|
||||
);
|
||||
}
|
||||
if (!["active", "running"].includes(input.agentStatus)) {
|
||||
throw new NativeRunnerSelectionError(
|
||||
"paperclip_runner_agent_ineligible",
|
||||
"Paperclip Runner requires an active agent.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
kind: "native",
|
||||
resolverVersion: NATIVE_RUNTIME_RESOLVER_VERSION,
|
||||
reason: "explicit_paperclip_runner",
|
||||
provider: "codex",
|
||||
};
|
||||
}
|
||||
|
|
@ -1,8 +1,40 @@
|
|||
/**
|
||||
* Development shim for the package-local runner runtime.
|
||||
*
|
||||
* The server build replaces this emitted module with the runner package's
|
||||
* compiled `dist` tree so published server packages have no workspace runtime
|
||||
* dependency. Keep server imports pointed at this relative boundary.
|
||||
* Source-mode server entry points do not build workspace dependencies first,
|
||||
* so this shim loads the package source through the TypeScript runtime. The
|
||||
* server build replaces the emitted shim with the package's compiled `dist`
|
||||
* tree so published server packages have no workspace runtime dependency.
|
||||
* Keep server imports pointed at this relative boundary.
|
||||
*/
|
||||
export * from "@paperclipai/paperclip-runner";
|
||||
type RunnerModule = typeof import("@paperclipai/paperclip-runner");
|
||||
|
||||
export type {
|
||||
PaperclipJsonValue,
|
||||
PaperclipSemanticActionBinding,
|
||||
PaperclipSemanticActionId,
|
||||
PaperclipSemanticAuthorizationRecord,
|
||||
PaperclipSemanticRunContext,
|
||||
PaperclipSemanticToolCall,
|
||||
PaperclipSemanticToolDefinition,
|
||||
PaperclipSemanticToolResult,
|
||||
PrpEvent,
|
||||
PrpStructuredRunResult,
|
||||
PrpTerminalState,
|
||||
} from "@paperclipai/paperclip-runner";
|
||||
export type DurablePrpControlPlane =
|
||||
import("@paperclipai/paperclip-runner").DurablePrpControlPlane;
|
||||
export type PaperclipSemanticDispatcher =
|
||||
import("@paperclipai/paperclip-runner").PaperclipSemanticDispatcher;
|
||||
|
||||
const sourceUrl = new URL(
|
||||
"../../../../packages/paperclip-runner/src/index.ts",
|
||||
import.meta.url,
|
||||
);
|
||||
const runner = await import(sourceUrl.href) as RunnerModule;
|
||||
|
||||
export const DurablePrpControlPlane = runner.DurablePrpControlPlane;
|
||||
export const PaperclipSemanticDispatcher = runner.PaperclipSemanticDispatcher;
|
||||
export const validatePrpEvent = runner.validatePrpEvent;
|
||||
export const validatePrpStructuredRunResult =
|
||||
runner.validatePrpStructuredRunResult;
|
||||
|
|
|
|||
|
|
@ -80,6 +80,12 @@ const adapterDisplayMap: Record<string, AdapterDisplayInfo> = {
|
|||
icon: Code,
|
||||
recommended: true,
|
||||
},
|
||||
paperclip_runner: {
|
||||
label: "Paperclip Runner",
|
||||
description: "Experimental Rust runner with a Codex provider",
|
||||
icon: Cpu,
|
||||
experimental: true,
|
||||
},
|
||||
gemini_local: {
|
||||
label: "Gemini CLI",
|
||||
description: "Gemini CLI harness",
|
||||
|
|
|
|||
|
|
@ -31,7 +31,8 @@ export function CodexLocalConfigFields({
|
|||
models,
|
||||
hideInstructionsFile,
|
||||
}: AdapterConfigFieldsProps) {
|
||||
const rawEngine = isCreate
|
||||
const runnerManaged = adapterType === "paperclip_runner";
|
||||
const rawEngine = runnerManaged ? "cli" : isCreate
|
||||
? values!.codexEngine ?? "auto"
|
||||
: eff("adapterConfig", "engine", String(config.engine ?? "auto"));
|
||||
const engine = rawEngine === "acp" || rawEngine === "cli" ? rawEngine : "auto";
|
||||
|
|
@ -55,7 +56,7 @@ export function CodexLocalConfigFields({
|
|||
|
||||
return (
|
||||
<>
|
||||
<Field label="Execution engine" hint="Auto uses ACP when prerequisites pass and falls back to Codex CLI with diagnostics.">
|
||||
{!runnerManaged && <Field label="Execution engine" hint="Auto uses ACP when prerequisites pass and falls back to Codex CLI with diagnostics.">
|
||||
<select
|
||||
className={inputClass}
|
||||
value={engine}
|
||||
|
|
@ -70,7 +71,14 @@ export function CodexLocalConfigFields({
|
|||
<option value="cli">Codex CLI</option>
|
||||
<option value="acp">ACP</option>
|
||||
</select>
|
||||
</Field>
|
||||
</Field>}
|
||||
{runnerManaged && (
|
||||
<Field label="Provider" hint="Paperclip Runner currently supports Codex through app-server.">
|
||||
<select className={inputClass} value="codex" disabled>
|
||||
<option value="codex">Codex</option>
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
{acpSelected && (
|
||||
<>
|
||||
<Field
|
||||
|
|
@ -183,7 +191,7 @@ export function CodexLocalConfigFields({
|
|||
</Field>
|
||||
</>
|
||||
)}
|
||||
{!hideInstructionsFile && (
|
||||
{!runnerManaged && !hideInstructionsFile && (
|
||||
<Field label="Agent instructions file" hint={instructionsFileHint}>
|
||||
<div className="flex items-center gap-2">
|
||||
<DraftInput
|
||||
|
|
@ -209,52 +217,56 @@ export function CodexLocalConfigFields({
|
|||
</div>
|
||||
</Field>
|
||||
)}
|
||||
<ToggleField
|
||||
label="Bypass sandbox"
|
||||
hint={help.dangerouslyBypassSandbox}
|
||||
checked={
|
||||
isCreate
|
||||
? values!.dangerouslyBypassSandbox
|
||||
: eff(
|
||||
"adapterConfig",
|
||||
"dangerouslyBypassApprovalsAndSandbox",
|
||||
bypassEnabled,
|
||||
)
|
||||
}
|
||||
onChange={(v) =>
|
||||
isCreate
|
||||
? set!({ dangerouslyBypassSandbox: v })
|
||||
: mark("adapterConfig", "dangerouslyBypassApprovalsAndSandbox", v)
|
||||
}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Enable search"
|
||||
hint={help.search}
|
||||
checked={
|
||||
isCreate
|
||||
? values!.search
|
||||
: eff("adapterConfig", "search", !!config.search)
|
||||
}
|
||||
onChange={(v) =>
|
||||
isCreate
|
||||
? set!({ search: v })
|
||||
: mark("adapterConfig", "search", v)
|
||||
}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Fast mode"
|
||||
hint={help.fastMode}
|
||||
checked={fastModeEnabled}
|
||||
onChange={(v) =>
|
||||
isCreate
|
||||
? set!({ fastMode: v })
|
||||
: mark("adapterConfig", "fastMode", v)
|
||||
}
|
||||
/>
|
||||
{fastModeEnabled && (
|
||||
<div className="rounded-md border border-amber-300/70 bg-amber-50/80 px-3 py-2 text-sm text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100">
|
||||
{fastModeMessage}
|
||||
</div>
|
||||
{!runnerManaged && (
|
||||
<>
|
||||
<ToggleField
|
||||
label="Bypass sandbox"
|
||||
hint={help.dangerouslyBypassSandbox}
|
||||
checked={
|
||||
isCreate
|
||||
? values!.dangerouslyBypassSandbox
|
||||
: eff(
|
||||
"adapterConfig",
|
||||
"dangerouslyBypassApprovalsAndSandbox",
|
||||
bypassEnabled,
|
||||
)
|
||||
}
|
||||
onChange={(v) =>
|
||||
isCreate
|
||||
? set!({ dangerouslyBypassSandbox: v })
|
||||
: mark("adapterConfig", "dangerouslyBypassApprovalsAndSandbox", v)
|
||||
}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Enable search"
|
||||
hint={help.search}
|
||||
checked={
|
||||
isCreate
|
||||
? values!.search
|
||||
: eff("adapterConfig", "search", !!config.search)
|
||||
}
|
||||
onChange={(v) =>
|
||||
isCreate
|
||||
? set!({ search: v })
|
||||
: mark("adapterConfig", "search", v)
|
||||
}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Fast mode"
|
||||
hint={help.fastMode}
|
||||
checked={fastModeEnabled}
|
||||
onChange={(v) =>
|
||||
isCreate
|
||||
? set!({ fastMode: v })
|
||||
: mark("adapterConfig", "fastMode", v)
|
||||
}
|
||||
/>
|
||||
{fastModeEnabled && (
|
||||
<div className="rounded-md border border-amber-300/70 bg-amber-50/80 px-3 py-2 text-sm text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100">
|
||||
{fastModeMessage}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<LocalWorkspaceRuntimeFields
|
||||
isCreate={isCreate}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
import { buildPaperclipRunnerConfig, parseCodexStdoutLine } from "@paperclipai/adapter-codex-local/ui";
|
||||
import { CodexLocalConfigFields } from "../codex-local/config-fields";
|
||||
import type { UIAdapterModule } from "../types";
|
||||
|
||||
export const paperclipRunnerUIAdapter: UIAdapterModule = {
|
||||
type: "paperclip_runner",
|
||||
label: "Paperclip Runner",
|
||||
parseStdoutLine: parseCodexStdoutLine,
|
||||
ConfigFields: CodexLocalConfigFields,
|
||||
buildAdapterConfig: buildPaperclipRunnerConfig,
|
||||
};
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import type { UIAdapterModule } from "./types";
|
||||
import { claudeLocalUIAdapter } from "./claude-local";
|
||||
import { codexLocalUIAdapter } from "./codex-local";
|
||||
import { paperclipRunnerUIAdapter } from "./paperclip-runner";
|
||||
import { cursorCloudUIAdapter } from "./cursor-cloud";
|
||||
import { cursorLocalUIAdapter } from "./cursor";
|
||||
import { geminiLocalUIAdapter } from "./gemini-local";
|
||||
|
|
@ -55,6 +56,7 @@ function registerBuiltInUIAdapters() {
|
|||
for (const adapter of [
|
||||
claudeLocalUIAdapter,
|
||||
codexLocalUIAdapter,
|
||||
paperclipRunnerUIAdapter,
|
||||
cursorCloudUIAdapter,
|
||||
geminiLocalUIAdapter,
|
||||
grokLocalUIAdapter,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ const ALL_FALSE: AdapterCapabilities = {
|
|||
const KNOWN_DEFAULTS: Record<string, AdapterCapabilities> = {
|
||||
claude_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false, supportsModelProfiles: true, supportsAcp: true },
|
||||
codex_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false, supportsModelProfiles: true, supportsAcp: true },
|
||||
paperclip_runner: { supportsInstructionsBundle: false, supportsSkills: true, supportsLocalAgentJwt: false, requiresMaterializedRuntimeSkills: false, supportsModelProfiles: false, supportsAcp: false },
|
||||
cursor: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: true, supportsAcp: false },
|
||||
gemini_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: true, supportsAcp: true },
|
||||
grok_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: false, supportsAcp: false },
|
||||
|
|
|
|||
|
|
@ -1188,7 +1188,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
: adapterType === "opencode_local"
|
||||
? eff("adapterConfig", "variant", String(config.variant ?? ""))
|
||||
: eff("adapterConfig", "effort", String(config.effort ?? ""));
|
||||
const showThinkingEffort = adapterType !== "gemini_local" && adapterType !== "cursor_cloud";
|
||||
const showThinkingEffort = adapterType !== "gemini_local"
|
||||
&& adapterType !== "cursor_cloud"
|
||||
&& adapterType !== "paperclip_runner";
|
||||
const codexSearchEnabled = adapterType === "codex_local"
|
||||
? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search)))
|
||||
: false;
|
||||
|
|
|
|||
|
|
@ -72,7 +72,8 @@ const AUTO_RECOVERY_TOGGLE_SELECTOR =
|
|||
function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload {
|
||||
return {
|
||||
enableEnvironments: false,
|
||||
enableManagedSandboxOnly: false,
|
||||
enableNativeRunner: false,
|
||||
enableManagedSandboxOnly: false,
|
||||
enableIsolatedWorkspaces: false,
|
||||
enableStreamlinedLeftNavigation: true,
|
||||
enableApps: false,
|
||||
|
|
|
|||
Loading…
Reference in New Issue