Files
PaperClipAI/patches/acpx@0.12.0.patch
T
Dotta 0834a0c1f7 feat(runner): bind ACPX profile boundary (#12387)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The Paperclip runner needs a safe boundary before it can launch
ACP-compatible agents.
> - A caller-controlled command, model, environment, or frame could
bypass that boundary.
> - The ACPX transport contract in #12386 defines the allowed messages
but does not bind a runtime profile.
> - This pull request defines closed, versioned profiles and validates
the launch inputs around that contract.
> - The benefit is a small and reviewable trust boundary before any ACPX
process can become available.

## Linked Issues or Issue Description

**Agent or provider**

ACPX sidecar support for the qualified Pi, Claude, and Codex ACP
servers.

**Why this adapter is useful**

The runner needs one bounded process boundary for ACP-compatible
providers. A closed profile prevents an untrusted run from selecting an
arbitrary executable, package version, or model.

**How the agent is invoked**

A later pull request will launch an internal sidecar from an exact
profile. This pull request only validates profiles, environment values,
and protocol frames. It does not add an executable dependency or enable
an adapter.

**Additional context**

This pull request is stacked on #12386. It keeps the existing direct
adapters and the Codex runner path unchanged.

## What Changed

- Add a closed profile table for the qualified Pi, Claude, and Codex ACP
servers.
- Require the exact qualified model and return an isolated profile value
to callers.
- Add an agent-specific environment allowlist with entry and aggregate
size limits.
- Add strict parsing for bounded sidecar requests and structured plan
values.
- Reject unknown fields, unsupported protocol versions, invalid
identifiers, null bytes, cyclic values, and oversized input.

## Verification

- Runner TypeScript typecheck — passed.
- Runner TypeScript tests — 40 files and 362 Vitest tests passed; 11
Node contract tests passed.
- `pnpm -r typecheck` — passed for all applicable workspaces.
- `pnpm build` — passed, including runner binary, server, UI, and
workspace packages.
- Prettier and `git diff --check` — passed.
- The diff contains 6 files and does not change `pnpm-lock.yaml`, a
workflow, a package dependency, or a public export.

## Risks

The main risk is accepting more launch state than the sidecar needs. The
implementation uses an agent-specific allowlist, rejects null bytes, and
enforces per-entry and aggregate bounds. This pull request does not
launch a process or expose a new adapter, so production and
direct-adapter behavior remain unchanged.

## Model Used

OpenAI Codex with GPT-5 and repository tool use.

## 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 linked an existing public item or described the
issue in this PR
- [x] I have not referenced internal or instance-local Paperclip issues
or links
- [x] My branch name describes the change and contains no internal task
identifier
- [x] I have run the affected tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have documented the compatibility and security boundary
- [ ] All applicable GitHub Actions are green
- [ ] Greptile is 5/5 with every actionable comment resolved
- [x] I will address all review findings before requesting merge
2026-08-30 12:37:13 -05:00

158 lines
6.2 KiB
Diff

diff --git a/dist/live-checkpoint-ClPCSdrW.js b/dist/live-checkpoint-ClPCSdrW.js
index 243c9d13bcba520923b63adfddad75cf2d94362d..336a1699b80b9e99416f9da4346ca73ee2ad1626 100644
--- a/dist/live-checkpoint-ClPCSdrW.js
+++ b/dist/live-checkpoint-ClPCSdrW.js
@@ -1532,7 +1532,7 @@ const ZED_TAG_KEYS = /* @__PURE__ */ new Set([
"RedactedThinking",
"ToolUse"
]);
-const MAP_OBJECT_PATHS = /* @__PURE__ */ new Set(["request_token_usage", "messages.Agent.tool_results"]);
+const MAP_OBJECT_PATHS = /* @__PURE__ */ new Set(["request_token_usage", "messages.Agent.tool_results", "acpx.session_options.env"]);
const OPAQUE_VALUE_PATHS = /* @__PURE__ */ new Set([
"agent_capabilities",
"messages.Agent.content.ToolUse.input",
@@ -2557,7 +2557,7 @@ function readCommandLineChar(state) {
escaping: false,
hasPart: true
};
- if (state.ch === "\\" && state.quote !== "'") return {
+ if (process.platform !== "win32" && state.ch === "\\" && state.quote !== "'") return {
current: state.current,
quote: state.quote,
escaping: true,
@@ -2914,15 +2914,15 @@
}
-function buildAgentEnvironment(authCredentials, sessionEnv) {
- const env = { ...process.env };
- const protectedAuthEnvKeys = promotePrefixedAuthEnvironment(env);
- if (authCredentials) for (const [methodId, credential] of Object.entries(authCredentials)) {
- addAuthCredentialEnvKeys(protectedAuthEnvKeys, methodId, credential);
- assignAuthCredentialEnv(env, methodId, credential);
- }
- if (sessionEnv) for (const [key, value] of Object.entries(sessionEnv)) {
- if (typeof value !== "string" || protectedAuthEnvKeys.has(protectedEnvKey(key))) continue;
- assignSessionEnv(env, key, value);
- }
- return env;
-}
+function buildAgentEnvironment(authCredentials, sessionEnv, inheritProcessEnv = true) {
+ const env = inheritProcessEnv ? { ...process.env } : {};
+ const protectedAuthEnvKeys = promotePrefixedAuthEnvironment(env);
+ if (authCredentials) for (const [methodId, credential] of Object.entries(authCredentials)) {
+ addAuthCredentialEnvKeys(protectedAuthEnvKeys, methodId, credential);
+ assignAuthCredentialEnv(env, methodId, credential);
+ }
+ if (sessionEnv) for (const [key, value] of Object.entries(sessionEnv)) {
+ if (typeof value !== "string" || protectedAuthEnvKeys.has(protectedEnvKey(key))) continue;
+ assignSessionEnv(env, key, value);
+ }
+ return env;
+}
function assignSessionEnv(env, key, value) {
@@ -2957,14 +2957,14 @@
}
-function buildAgentSpawnOptions(cwd, authCredentials, sessionEnv) {
- return {
- cwd,
- env: buildAgentEnvironment(authCredentials, sessionEnv),
- stdio: [
- "pipe",
- "pipe",
- "pipe"
- ],
- windowsHide: true
- };
-}
+function buildAgentSpawnOptions(cwd, authCredentials, sessionEnv, inheritProcessEnv) {
+ return {
+ cwd,
+ env: buildAgentEnvironment(authCredentials, sessionEnv, inheritProcessEnv),
+ stdio: [
+ "pipe",
+ "pipe",
+ "pipe"
+ ],
+ windowsHide: true
+ };
+}
//#endregion
@@ -3959,7 +3959,24 @@ var AcpClient = class {
- this.attachAgentLifecycleObservers(child);
+ this.attachAgentLifecycleObservers(child);
+ if (this.options.onAgentSpawn) {
+ try {
+ if (typeof child.pid !== "number" || child.pid <= 0) {
+ throw new Error("ACPX agent spawn did not expose a valid process id.");
+ }
+ await this.options.onAgentSpawn({ pid: child.pid, startedAt: this.agentStartedAt });
+ } catch (error) {
+ try {
+ child.kill("SIGKILL");
+ } catch {}
+ throw error;
+ }
+ }
const startupStderr = [];
child.stderr.on("data", (chunk) => {
this.captureStartupStderr(startupStderr, chunk);
+ if (this.options.onAgentStderr) {
+ this.options.onAgentStderr(chunk.toString());
+ return;
+ }
if (!this.options.verbose) return;
process.stderr.write(chunk);
});
@@ -3994,7 +4011,7 @@ var AcpClient = class {
geminiAcp: isGeminiAcpCommand(spawnCommand, args),
copilotAcp: isCopilotAcpCommand(spawnCommand, args),
claudeAcp: isClaudeAcpCommand(spawnCommand, args),
- spawnOptions: buildAgentSpawnOptions(this.options.cwd, this.options.authCredentials, this.options.sessionOptions?.env)
+ spawnOptions: buildAgentSpawnOptions(this.options.spawnCwd ?? this.options.cwd, this.options.authCredentials, this.options.sessionOptions?.env, this.options.inheritProcessEnv)
};
}
logAgentLaunch(plan) {
diff --git a/dist/runtime.d.ts b/dist/runtime.d.ts
index ccdbe5b032521518022223733049b8b38793473b..3d4e04231e78efeecd8540735b08e1e43547ff2d 100644
--- a/dist/runtime.d.ts
+++ b/dist/runtime.d.ts
@@ -266,6 +266,10 @@ type AcpRuntimeOptions = {
timeoutMs?: number;
probeAgent?: string;
verbose?: boolean;
+ onAgentStderr?: (chunk: string) => void;
+ onAgentSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
+ spawnCwd?: string;
+ inheritProcessEnv?: boolean;
onPermissionRequest?: (req: AcpPermissionRequest, ctx: {
signal: AbortSignal;
}) => Promise<AcpPermissionDecision | undefined>;
diff --git a/dist/runtime.js b/dist/runtime.js
index 6c9cc999e50a11c399c68b3a0f1b7af4bc2317c0..33b5054b2906502d1d4b512bfa259bd2ba5a9f05 100644
--- a/dist/runtime.js
+++ b/dist/runtime.js
@@ -744,7 +744,8 @@ var AcpRuntimeManager = class {
this.deps = deps;
}
createClient(options) {
- return this.deps.clientFactory?.(options) ?? new AcpClient(options);
+ const clientOptions = { ...options, onAgentStderr: this.options.onAgentStderr, onAgentSpawn: this.options.onAgentSpawn, spawnCwd: this.options.spawnCwd, inheritProcessEnv: this.options.inheritProcessEnv };
+ return this.deps.clientFactory?.(clientOptions) ?? new AcpClient(clientOptions);
}
async readPendingPersistentClient(record, options) {
const pendingClient = this.pendingPersistentClients.get(record.acpxRecordId);
diff --git a/dist/session-options-jkYbBxGE.d.ts b/dist/session-options-jkYbBxGE.d.ts
index 9d37f377fb6a0828e0d2bc5a48754f3aa71509a4..680bc080fc5d6ffd266ed1b27d3d5056add9d980 100644
--- a/dist/session-options-jkYbBxGE.d.ts
+++ b/dist/session-options-jkYbBxGE.d.ts
@@ -84,6 +84,10 @@ type AcpClientOptions = {
terminal?: boolean;
suppressSdkConsoleErrors?: boolean;
verbose?: boolean;
+ onAgentStderr?: (chunk: string) => void;
+ onAgentSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
+ spawnCwd?: string;
+ inheritProcessEnv?: boolean;
sessionOptions?: {
model?: string;
allowedTools?: string[];