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
This commit is contained in:
parent
e13856eb37
commit
0834a0c1f7
|
|
@ -7,7 +7,11 @@
|
|||
// never runs a synthetic value.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildSessionFingerprint, finalizeLaunchEnvironment } from "./execute.js";
|
||||
import {
|
||||
buildSessionFingerprint,
|
||||
finalizeLaunchEnvironment,
|
||||
projectAcpxInheritedHostEnvironment,
|
||||
} from "./execute.js";
|
||||
import type {
|
||||
LaunchEnvironment,
|
||||
RunScopedContribution,
|
||||
|
|
@ -58,9 +62,15 @@ describe("acpx identity split and launch environment", () => {
|
|||
it("test_finalize_launch_environment_is_sole_constructor_of_branded_environment", () => {
|
||||
// The finalizer merges every contribution and freezes the launch env.
|
||||
const baseEnv: Record<string, string> = { LAUNCH_ENV_TEST_BASE: "base" };
|
||||
const launchEnvironment = finalizeLaunchEnvironment(baseEnv, [
|
||||
{ scope: "session", env: { LAUNCH_ENV_TEST_CONTRIB: "contrib" } },
|
||||
]);
|
||||
const launchEnvironment = finalizeLaunchEnvironment(
|
||||
baseEnv,
|
||||
[{ scope: "session", env: { LAUNCH_ENV_TEST_CONTRIB: "contrib" } }],
|
||||
{
|
||||
acpxAgent: "claude",
|
||||
inheritHostEnvironment: true,
|
||||
inheritedEnv: {},
|
||||
},
|
||||
);
|
||||
expect(launchEnvironment.env.LAUNCH_ENV_TEST_BASE).toBe("base");
|
||||
expect(launchEnvironment.env.LAUNCH_ENV_TEST_CONTRIB).toBe("contrib");
|
||||
expect(Object.isFrozen(launchEnvironment.env)).toBe(true);
|
||||
|
|
@ -92,4 +102,162 @@ describe("acpx identity split and launch environment", () => {
|
|||
void _assertNotReusable;
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it("inherits only safe host context and the selected provider's credentials", () => {
|
||||
const inherited = {
|
||||
PATH: "/usr/bin",
|
||||
LC_ALL: "C.UTF-8",
|
||||
HTTPS_PROXY: "https://proxy.example",
|
||||
OPENAI_API_KEY: "openai-host-secret",
|
||||
ANTHROPIC_API_KEY: "anthropic-host-secret",
|
||||
ANTHROPIC_AUTH_TOKEN: "anthropic-auth-host-secret",
|
||||
CLAUDE_CODE_OAUTH_TOKEN: "claude-oauth-host-secret",
|
||||
ANTHROPIC_BASE_URL: "https://anthropic.example",
|
||||
ANTHROPIC_MODEL: "claude-test",
|
||||
ANTHROPIC_SMALL_FAST_MODEL: "claude-fast-test",
|
||||
CLAUDE_CONFIG_DIR: "/host/claude",
|
||||
CLAUDE_CODE_USE_BEDROCK: "true",
|
||||
ANTHROPIC_BEDROCK_BASE_URL: "https://bedrock.example",
|
||||
AWS_BEARER_TOKEN_BEDROCK: "bedrock-host-secret",
|
||||
OPENROUTER_API_KEY: "openrouter-host-secret",
|
||||
GOOGLE_GENAI_USE_GCA: "true",
|
||||
KIMI_MODEL_NAME: "kimi-code/test",
|
||||
KIMI_MODEL_API_KEY: "kimi-host-secret",
|
||||
KIMI_MODEL_BASE_URL: "https://kimi.example",
|
||||
KIMI_MODEL_PROVIDER_TYPE: "openai_legacy",
|
||||
KIMI_CODE_HOME: "/host/kimi",
|
||||
PAPERCLIP_NATIVE_MCP_TOKEN: "native-mcp-host-secret",
|
||||
PAPERCLIP_ACPX_CODEX_AUTH_JSON_SECRET: "managed-auth-host-secret",
|
||||
UNRELATED_SECRET: "unrelated-host-secret",
|
||||
NODE_OPTIONS: "--require /tmp/host-hook.cjs",
|
||||
};
|
||||
|
||||
expect(projectAcpxInheritedHostEnvironment(inherited, "codex", true)).toEqual({
|
||||
PATH: "/usr/bin",
|
||||
LC_ALL: "C.UTF-8",
|
||||
HTTPS_PROXY: "https://proxy.example",
|
||||
OPENAI_API_KEY: "openai-host-secret",
|
||||
});
|
||||
expect(projectAcpxInheritedHostEnvironment(inherited, "claude", true)).toEqual({
|
||||
PATH: "/usr/bin",
|
||||
LC_ALL: "C.UTF-8",
|
||||
HTTPS_PROXY: "https://proxy.example",
|
||||
ANTHROPIC_API_KEY: "anthropic-host-secret",
|
||||
ANTHROPIC_AUTH_TOKEN: "anthropic-auth-host-secret",
|
||||
CLAUDE_CODE_OAUTH_TOKEN: "claude-oauth-host-secret",
|
||||
ANTHROPIC_BASE_URL: "https://anthropic.example",
|
||||
ANTHROPIC_MODEL: "claude-test",
|
||||
ANTHROPIC_SMALL_FAST_MODEL: "claude-fast-test",
|
||||
CLAUDE_CONFIG_DIR: "/host/claude",
|
||||
CLAUDE_CODE_USE_BEDROCK: "true",
|
||||
ANTHROPIC_BEDROCK_BASE_URL: "https://bedrock.example",
|
||||
AWS_BEARER_TOKEN_BEDROCK: "bedrock-host-secret",
|
||||
});
|
||||
expect(projectAcpxInheritedHostEnvironment(inherited, "pi", true)).toEqual({
|
||||
PATH: "/usr/bin",
|
||||
LC_ALL: "C.UTF-8",
|
||||
HTTPS_PROXY: "https://proxy.example",
|
||||
OPENROUTER_API_KEY: "openrouter-host-secret",
|
||||
});
|
||||
expect(projectAcpxInheritedHostEnvironment(inherited, "gemini", true)).toEqual({
|
||||
PATH: "/usr/bin",
|
||||
LC_ALL: "C.UTF-8",
|
||||
HTTPS_PROXY: "https://proxy.example",
|
||||
GOOGLE_GENAI_USE_GCA: "true",
|
||||
});
|
||||
expect(projectAcpxInheritedHostEnvironment(inherited, "kimi", true)).toEqual({
|
||||
PATH: "/usr/bin",
|
||||
LC_ALL: "C.UTF-8",
|
||||
HTTPS_PROXY: "https://proxy.example",
|
||||
KIMI_MODEL_NAME: "kimi-code/test",
|
||||
KIMI_MODEL_API_KEY: "kimi-host-secret",
|
||||
KIMI_MODEL_BASE_URL: "https://kimi.example",
|
||||
KIMI_MODEL_PROVIDER_TYPE: "openai_legacy",
|
||||
KIMI_CODE_HOME: "/host/kimi",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not project any ambient host environment across a remote boundary", () => {
|
||||
const inherited = {
|
||||
PATH: "/host/bin",
|
||||
OPENAI_API_KEY: "ambient-provider-secret",
|
||||
PAPERCLIP_NATIVE_MCP_TOKEN: "ambient-native-mcp-secret",
|
||||
PAPERCLIP_RUNNER_BOOTSTRAP_TICKET: "ambient-bootstrap-secret",
|
||||
};
|
||||
|
||||
expect(projectAcpxInheritedHostEnvironment(inherited, "codex", false)).toEqual({});
|
||||
});
|
||||
|
||||
it("keeps explicit remote adapter and run contributions while rejecting ambient authority", () => {
|
||||
const launchEnvironment = finalizeLaunchEnvironment(
|
||||
{
|
||||
OPENAI_API_KEY: "explicit-provider-secret",
|
||||
EXPLICIT_ADAPTER_SECRET: "adapter-secret",
|
||||
PAPERCLIP_RUNTIME_API_URL: "http://paperclip.internal/api",
|
||||
},
|
||||
[
|
||||
{
|
||||
scope: "session",
|
||||
env: { PAPERCLIP_NATIVE_MCP_TOKEN: "explicit-run-contribution" },
|
||||
},
|
||||
],
|
||||
{
|
||||
acpxAgent: "codex",
|
||||
inheritHostEnvironment: false,
|
||||
inheritedEnv: {
|
||||
PATH: "/host/bin",
|
||||
OPENAI_API_KEY: "ambient-provider-secret",
|
||||
PAPERCLIP_NATIVE_MCP_TOKEN: "ambient-native-mcp-secret",
|
||||
PAPERCLIP_RUNNER_BOOTSTRAP_TICKET: "ambient-bootstrap-secret",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(launchEnvironment.env).toMatchObject({
|
||||
OPENAI_API_KEY: "explicit-provider-secret",
|
||||
EXPLICIT_ADAPTER_SECRET: "adapter-secret",
|
||||
PAPERCLIP_RUNTIME_API_URL: "http://paperclip.internal/api",
|
||||
PAPERCLIP_NATIVE_MCP_TOKEN: "explicit-run-contribution",
|
||||
});
|
||||
expect(launchEnvironment.env).not.toHaveProperty("PATH");
|
||||
expect(launchEnvironment.env).not.toHaveProperty("PAPERCLIP_RUNNER_BOOTSTRAP_TICKET");
|
||||
});
|
||||
|
||||
it("preserves an explicit remote PATH instead of synthesizing a host fallback", () => {
|
||||
const launchEnvironment = finalizeLaunchEnvironment(
|
||||
{ PATH: "/sandbox/bin" },
|
||||
[],
|
||||
{
|
||||
acpxAgent: "codex",
|
||||
inheritHostEnvironment: false,
|
||||
inheritedEnv: { PATH: "/host/bin" },
|
||||
},
|
||||
);
|
||||
|
||||
expect(launchEnvironment.env).toEqual({ PATH: "/sandbox/bin" });
|
||||
});
|
||||
|
||||
it("lets explicit Windows env values suppress differently-cased ambient values", () => {
|
||||
const launchEnvironment = finalizeLaunchEnvironment(
|
||||
{
|
||||
openai_api_key: "",
|
||||
Path: "C:\\explicit\\bin",
|
||||
},
|
||||
[],
|
||||
{
|
||||
acpxAgent: "codex",
|
||||
inheritHostEnvironment: true,
|
||||
inheritedEnv: {
|
||||
OPENAI_API_KEY: "ambient-provider-secret",
|
||||
PATH: "C:\\ambient\\bin",
|
||||
},
|
||||
platform: "win32",
|
||||
},
|
||||
);
|
||||
|
||||
expect(launchEnvironment.env).toEqual({
|
||||
openai_api_key: "",
|
||||
Path: "C:\\explicit\\bin",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -505,6 +505,118 @@ export function buildSessionKey(identity: SessionKeyIdentity, fingerprint: strin
|
|||
return `paperclip:${identity.companyId}:${identity.agentId}:${identity.taskKey}:${fingerprint}`;
|
||||
}
|
||||
|
||||
// ACPX runs inside the long-lived Paperclip server process. A local child needs
|
||||
// a small amount of host context (PATH, locale, certificate/proxy settings, and
|
||||
// provider authentication), but it must not inherit the server's complete
|
||||
// environment. A runner-backed remote sandbox inherits no ambient host context
|
||||
// at all. In particular, native-runner bootstrap and MCP credentials are host
|
||||
// authority, not provider credentials.
|
||||
const ACPX_INHERITED_HOST_ENV_KEYS = new Set([
|
||||
"PATH",
|
||||
"PATHEXT",
|
||||
"SYSTEMROOT",
|
||||
"WINDIR",
|
||||
"COMSPEC",
|
||||
"HOME",
|
||||
"USERPROFILE",
|
||||
"HOMEDRIVE",
|
||||
"HOMEPATH",
|
||||
"USER",
|
||||
"USERNAME",
|
||||
"LOGNAME",
|
||||
"SHELL",
|
||||
"LANG",
|
||||
"LANGUAGE",
|
||||
"TZ",
|
||||
"TMPDIR",
|
||||
"TEMP",
|
||||
"TMP",
|
||||
"XDG_CONFIG_HOME",
|
||||
"XDG_CACHE_HOME",
|
||||
"XDG_DATA_HOME",
|
||||
"SSL_CERT_FILE",
|
||||
"SSL_CERT_DIR",
|
||||
"NODE_EXTRA_CA_CERTS",
|
||||
"HTTP_PROXY",
|
||||
"HTTPS_PROXY",
|
||||
"NO_PROXY",
|
||||
"ALL_PROXY",
|
||||
]);
|
||||
|
||||
const ACPX_INHERITED_PROVIDER_ENV_KEYS: Readonly<Record<string, ReadonlySet<string>>> = {
|
||||
codex: new Set([
|
||||
"OPENAI_API_KEY",
|
||||
"CODEX_API_KEY",
|
||||
]),
|
||||
claude: new Set([
|
||||
"ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN",
|
||||
"ANTHROPIC_BASE_URL",
|
||||
"ANTHROPIC_MODEL",
|
||||
"ANTHROPIC_SMALL_FAST_MODEL",
|
||||
"CLAUDE_CONFIG_DIR",
|
||||
"CLAUDE_CODE_USE_BEDROCK",
|
||||
"ANTHROPIC_BEDROCK_BASE_URL",
|
||||
"AWS_BEARER_TOKEN_BEDROCK",
|
||||
"AWS_ACCESS_KEY_ID",
|
||||
"AWS_SECRET_ACCESS_KEY",
|
||||
"AWS_SESSION_TOKEN",
|
||||
"AWS_REGION",
|
||||
"AWS_DEFAULT_REGION",
|
||||
"AWS_PROFILE",
|
||||
"AWS_CONFIG_FILE",
|
||||
"AWS_SHARED_CREDENTIALS_FILE",
|
||||
]),
|
||||
pi: new Set(["OPENROUTER_API_KEY"]),
|
||||
gemini: new Set([
|
||||
"GEMINI_API_KEY",
|
||||
"GOOGLE_API_KEY",
|
||||
"GOOGLE_APPLICATION_CREDENTIALS",
|
||||
"GOOGLE_GENAI_USE_GCA",
|
||||
]),
|
||||
kimi: new Set([
|
||||
"KIMI_API_KEY",
|
||||
"MOONSHOT_API_KEY",
|
||||
"KIMI_MODEL_NAME",
|
||||
"KIMI_MODEL_API_KEY",
|
||||
"KIMI_MODEL_BASE_URL",
|
||||
"KIMI_MODEL_PROVIDER_TYPE",
|
||||
"KIMI_CODE_HOME",
|
||||
]),
|
||||
grok: new Set(["XAI_API_KEY"]),
|
||||
};
|
||||
|
||||
/**
|
||||
* Project the server environment onto the closed set a host ACPX provider may
|
||||
* inherit. A runner-backed remote sandbox passes `false` and projects nothing.
|
||||
* Explicit adapter/runtime env is merged later and is intentionally not
|
||||
* restricted by this host projection.
|
||||
*/
|
||||
export function projectAcpxInheritedHostEnvironment(
|
||||
inheritedEnv: NodeJS.ProcessEnv,
|
||||
acpxAgent: string,
|
||||
inheritHostEnvironment: boolean,
|
||||
): Record<string, string> {
|
||||
// A runner-backed remote sandbox crosses a serialization boundary. Ambient
|
||||
// server state is never part of that contract: provider auth/config must be
|
||||
// supplied through adapter config, resolved runtime env, or a contribution.
|
||||
if (!inheritHostEnvironment) return {};
|
||||
|
||||
const providerKeys = ACPX_INHERITED_PROVIDER_ENV_KEYS[acpxAgent];
|
||||
const projected: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(inheritedEnv)) {
|
||||
if (typeof value !== "string") continue;
|
||||
const normalizedKey = key.toUpperCase();
|
||||
const allowed =
|
||||
ACPX_INHERITED_HOST_ENV_KEYS.has(normalizedKey) ||
|
||||
/^LC_[A-Z0-9_]{1,32}$/.test(normalizedKey) ||
|
||||
providerKeys?.has(normalizedKey) === true;
|
||||
if (allowed) projected[key] = value;
|
||||
}
|
||||
return projected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the single branded launch environment for a run. This is the sole
|
||||
* constructor of `LaunchEnvironment`. It applies each contribution into the base
|
||||
|
|
@ -519,11 +631,19 @@ export function buildSessionKey(identity: SessionKeyIdentity, fingerprint: strin
|
|||
export function finalizeLaunchEnvironment(
|
||||
baseEnv: Record<string, string>,
|
||||
contributions: readonly LaunchEnvironmentContribution[],
|
||||
options: {
|
||||
acpxAgent: string;
|
||||
inheritHostEnvironment: boolean;
|
||||
inheritedEnv?: NodeJS.ProcessEnv;
|
||||
platform?: typeof process.platform;
|
||||
},
|
||||
): LaunchEnvironment {
|
||||
for (const contribution of contributions) {
|
||||
Object.assign(baseEnv, contribution.env);
|
||||
}
|
||||
const env = Object.freeze(resolveRuntimeEnv(baseEnv));
|
||||
const env = Object.freeze(
|
||||
resolveRuntimeEnv(baseEnv, options.acpxAgent, options),
|
||||
);
|
||||
return { env } as unknown as LaunchEnvironment;
|
||||
}
|
||||
|
||||
|
|
@ -1890,10 +2010,20 @@ async function buildRuntime(input: {
|
|||
});
|
||||
let agentCommand = configuredCommand || builtInCommand?.command || null;
|
||||
let agentCommandShell = configuredCommand || builtInCommand?.shellCommand || "";
|
||||
// A runner-backed remote sandbox is the only lane that crosses the staging
|
||||
// and serialized-launch-env seam. Runner-less ACP→CLI fallback, SSH, and
|
||||
// local runs keep their historical host-provider compatibility behavior.
|
||||
const useRemoteProcessSession =
|
||||
executionTarget?.kind === "remote" &&
|
||||
executionTarget.transport === "sandbox" &&
|
||||
Boolean(executionTarget.runner) &&
|
||||
Boolean(agentCommandShell);
|
||||
if (acpxAgent === "gemini" && agentCommandShell) {
|
||||
const normalized = await normalizeGeminiAcpCommandShell(
|
||||
agentCommandShell,
|
||||
ensurePathInEnv({ ...process.env, ...env }),
|
||||
resolveRuntimeEnv(env, acpxAgent, {
|
||||
inheritHostEnvironment: !useRemoteProcessSession,
|
||||
}),
|
||||
);
|
||||
if (normalized !== agentCommandShell) {
|
||||
agentCommandShell = normalized;
|
||||
|
|
@ -1902,15 +2032,6 @@ async function buildRuntime(input: {
|
|||
}
|
||||
const childStderrDir = path.join(stateDir, "run-stderr");
|
||||
const childStderrLogPath = agentCommand ? path.join(childStderrDir, `${runId}.log`) : null;
|
||||
// A runner-backed remote sandbox is the only lane that crosses the staging
|
||||
// seam: the runner-less ACP→CLI fallback (no `runner`) and local runs keep
|
||||
// their historical behavior untouched. This is the single gate shared by the
|
||||
// workspace stage and both sandbox bridges.
|
||||
const useRemoteProcessSession =
|
||||
executionTarget?.kind === "remote" &&
|
||||
executionTarget.transport === "sandbox" &&
|
||||
Boolean(executionTarget.runner) &&
|
||||
Boolean(agentCommandShell);
|
||||
// Stream the agent output through the persistent session log stream instead of
|
||||
// the host output-file poll. The decision comes from the effective capability
|
||||
// snapshot alone: the provider must declare and verify incremental session
|
||||
|
|
@ -2158,7 +2279,11 @@ async function buildRuntime(input: {
|
|||
}),
|
||||
measureBridgeStep: (step, run) =>
|
||||
measureStartupStep(input.ctx, nowMs, step, run, concurrentBridgeStepMetrics),
|
||||
finalizeLaunchEnv: (contributions) => finalizeLaunchEnvironment(env, contributions).env,
|
||||
finalizeLaunchEnv: (contributions) =>
|
||||
finalizeLaunchEnvironment(env, contributions, {
|
||||
acpxAgent,
|
||||
inheritHostEnvironment: !useRemoteProcessSession,
|
||||
}).env,
|
||||
onPaperclipBridgeLog: () =>
|
||||
input.ctx.onLog("stdout", "[paperclip] Sandbox ACP API callback bridge enabled for this run.\n"),
|
||||
stopBridges: async ({ controlBridge, agentBridge }) => {
|
||||
|
|
@ -2207,7 +2332,10 @@ async function buildRuntime(input: {
|
|||
// Local / runner-less lanes never start a bridge, so they add no
|
||||
// contribution. `finalizeLaunchEnvironment` still produces the one branded
|
||||
// launch env the prepared runtime and the log builder read.
|
||||
runtimeEnv = finalizeLaunchEnvironment(env, []).env;
|
||||
runtimeEnv = finalizeLaunchEnvironment(env, [], {
|
||||
acpxAgent,
|
||||
inheritHostEnvironment: !useRemoteProcessSession,
|
||||
}).env;
|
||||
}
|
||||
} catch (err) {
|
||||
// On a partial concurrent bring-up failure, ONE bridge may have started while
|
||||
|
|
@ -2270,7 +2398,7 @@ async function buildRuntime(input: {
|
|||
workspaceId,
|
||||
workspaceRepoUrl,
|
||||
workspaceRepoRef,
|
||||
env,
|
||||
env: runtimeEnv,
|
||||
loggedEnv,
|
||||
stateDir,
|
||||
permissionMode,
|
||||
|
|
@ -2360,19 +2488,72 @@ async function applySessionConfigOptions(input: {
|
|||
}
|
||||
|
||||
/**
|
||||
* Build the process-session launch env: the host env overlaid with the run's
|
||||
* `env` (so the merged paperclip bridge vars win) and a guaranteed `PATH`,
|
||||
* narrowed to string values. Shared by the remote concurrent bring-up and the
|
||||
* local / runner-less lane so both resolve the runtime env identically.
|
||||
* Build the process-session launch env: the target-specific host projection
|
||||
* overlaid with the run's explicit `env` (so adapter config, runtime variables,
|
||||
* and bridge contributions win), narrowed to string values. Host-side launches
|
||||
* get the closed projection plus a default `PATH` when the host did not provide
|
||||
* one. Runner-backed remote sandbox launches get no ambient host state or
|
||||
* synthesized host `PATH`; omitting `PATH` preserves the sandbox-native value.
|
||||
*/
|
||||
function resolveRuntimeEnv(env: Record<string, string>): Record<string, string> {
|
||||
function resolveRuntimeEnv(
|
||||
env: Record<string, string>,
|
||||
acpxAgent: string,
|
||||
options: {
|
||||
inheritHostEnvironment: boolean;
|
||||
inheritedEnv?: NodeJS.ProcessEnv;
|
||||
platform?: typeof process.platform;
|
||||
},
|
||||
): Record<string, string> {
|
||||
const inheritedEnv = options.inheritedEnv ?? process.env;
|
||||
const projectedHostEnv = projectAcpxInheritedHostEnvironment(
|
||||
inheritedEnv,
|
||||
acpxAgent,
|
||||
options.inheritHostEnvironment,
|
||||
);
|
||||
const inheritedLaunchEnv = options.inheritHostEnvironment
|
||||
? ensurePathInEnv(projectedHostEnv)
|
||||
: projectedHostEnv;
|
||||
const mergedEnv = mergeRuntimeEnvironment(
|
||||
inheritedLaunchEnv,
|
||||
env,
|
||||
(options.platform ?? process.platform) === "win32",
|
||||
);
|
||||
return Object.fromEntries(
|
||||
Object.entries(ensurePathInEnv({ ...process.env, ...env })).filter(
|
||||
Object.entries(mergedEnv).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function mergeRuntimeEnvironment(
|
||||
inheritedEnv: NodeJS.ProcessEnv,
|
||||
explicitEnv: Record<string, string>,
|
||||
caseInsensitiveKeys: boolean,
|
||||
): NodeJS.ProcessEnv {
|
||||
if (!caseInsensitiveKeys) {
|
||||
return { ...inheritedEnv, ...explicitEnv };
|
||||
}
|
||||
|
||||
const merged: NodeJS.ProcessEnv = {};
|
||||
const keyByCanonicalName = new Map<string, string>();
|
||||
const apply = (source: NodeJS.ProcessEnv): void => {
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (typeof value !== "string") continue;
|
||||
const canonicalName = key.toUpperCase();
|
||||
const previousKey = keyByCanonicalName.get(canonicalName);
|
||||
if (previousKey !== undefined && previousKey !== key) {
|
||||
delete merged[previousKey];
|
||||
}
|
||||
merged[key] = value;
|
||||
keyByCanonicalName.set(canonicalName, key);
|
||||
}
|
||||
};
|
||||
|
||||
apply(inheritedEnv);
|
||||
apply(explicitEnv);
|
||||
return merged;
|
||||
}
|
||||
|
||||
// Stop both host-side bridges in one `allSettled`. This is the settlement
|
||||
// `stopTransport` effect. The bridge tokens are run-scoped, so they die with the
|
||||
// bridges here (Amendment B).
|
||||
|
|
@ -3857,6 +4038,10 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
|
|||
// and custom agents already emit their own per-tool output and don't
|
||||
// benefit from doubling the log volume.
|
||||
verbose: prepared.acpxAgent === "claude",
|
||||
// The engine passes a complete, sanitized launch environment. ACPX
|
||||
// must not merge the Paperclip server's ambient environment back in
|
||||
// when it spawns the provider child.
|
||||
inheritProcessEnv: false,
|
||||
onAgentStderr: prepared.childStderrLogPath
|
||||
? (chunk) => routeChildStderr(childStderrState, chunk)
|
||||
: undefined,
|
||||
|
|
|
|||
|
|
@ -448,6 +448,75 @@ describe("sandbox adapter execution targets", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ outputMode: "polled", streamOutputViaSession: false },
|
||||
{ outputMode: "streamed", streamOutputViaSession: true },
|
||||
])(
|
||||
"preserves an explicit remote PATH equal to host PATH in $outputMode mode",
|
||||
async ({ outputMode, streamOutputViaSession }) => {
|
||||
const rootDir = await mkdtemp(
|
||||
path.join(os.tmpdir(), `paperclip-process-session-${outputMode}-path-`),
|
||||
);
|
||||
cleanupDirs.push(rootDir);
|
||||
const childPath = path.join(rootDir, "print-path-child.mjs");
|
||||
await writeFile(
|
||||
childPath,
|
||||
'process.stdout.write(process.env.PATH ?? "<missing>");\n',
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const nodeBinDir = path.dirname(process.execPath);
|
||||
const explicitHostPath = `${nodeBinDir}:/explicit-host-bin`;
|
||||
const sandboxNativePath = `/usr/bin:/bin:${nodeBinDir}`;
|
||||
vi.stubEnv("PATH", explicitHostPath);
|
||||
|
||||
const delegate = createLocalSandboxRunner();
|
||||
const runner = {
|
||||
execute: vi.fn(
|
||||
async (input: Parameters<typeof delegate.execute>[0]) =>
|
||||
delegate.execute({
|
||||
...input,
|
||||
// The local fake otherwise inherits the test host PATH. Give the
|
||||
// wrapper a distinct sandbox-native PATH so the child proves the
|
||||
// explicit equal-to-host value survived payload serialization.
|
||||
env: { ...input.env, PATH: sandboxNativePath },
|
||||
}),
|
||||
),
|
||||
};
|
||||
const target: AdapterSandboxExecutionTarget = {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
providerKey: "local-test",
|
||||
remoteCwd: rootDir,
|
||||
timeoutMs: 30_000,
|
||||
runner,
|
||||
};
|
||||
|
||||
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
|
||||
runId: `run-process-session-${outputMode}-path`,
|
||||
target,
|
||||
runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"),
|
||||
adapterKey: "acpx",
|
||||
command: process.execPath,
|
||||
args: [childPath],
|
||||
cwd: rootDir,
|
||||
env: { PATH: explicitHostPath },
|
||||
timeoutSec: 5,
|
||||
onLog: async () => {},
|
||||
streamOutputViaSession,
|
||||
});
|
||||
expect(bridge).not.toBeNull();
|
||||
|
||||
try {
|
||||
const result = await runProxyWithInput(bridge!.agentCommand, "");
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toBe(explicitHostPath);
|
||||
} finally {
|
||||
await bridge?.stop();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("test_process_session_poll_exec_parents_to_run_context", async () => {
|
||||
// The poll timer runs run-time execs for the whole run. Its `sandbox.exec`
|
||||
// span must parent to the live run span, not to the ended startup step. The
|
||||
|
|
|
|||
|
|
@ -1712,7 +1712,11 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
|
|||
command: input.command,
|
||||
args: input.args,
|
||||
cwd: input.cwd || target.remoteCwd,
|
||||
env: sanitizeRemoteExecutionEnv(launchEnv),
|
||||
// The ACP engine has already projected this launch env from explicit
|
||||
// adapter/runtime inputs and registered contributions. Compare against an
|
||||
// empty inherited baseline so an explicit identity value (notably PATH)
|
||||
// is not reclassified as ambient merely because it equals the host value.
|
||||
env: sanitizeRemoteExecutionEnv(launchEnv, {}),
|
||||
}), "utf8").toString("base64");
|
||||
|
||||
// Legacy poll path: background the wrapper with `nohup` and read its output
|
||||
|
|
@ -2013,7 +2017,9 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
|
|||
command: input.command,
|
||||
args: input.args,
|
||||
cwd: input.cwd || target.remoteCwd,
|
||||
env: sanitizeRemoteExecutionEnv(launchEnvForStream),
|
||||
// Same provenance-clean contract as the polled payload above. Preserve
|
||||
// every explicit identity override even when it equals the host value.
|
||||
env: sanitizeRemoteExecutionEnv(launchEnvForStream, {}),
|
||||
}), "utf8").toString("base64");
|
||||
await onLog(
|
||||
"stdout",
|
||||
|
|
|
|||
|
|
@ -288,9 +288,14 @@ fn send_question(state: &FakeState) -> io::Result<()> {
|
|||
}))
|
||||
}
|
||||
|
||||
fn send_runtime_request_flood(state: &FakeState, interrupt_count: u64) -> io::Result<()> {
|
||||
fn send_runtime_request_flood(
|
||||
state: &FakeState,
|
||||
interrupt_count: u64,
|
||||
count: u64,
|
||||
question: &str,
|
||||
) -> io::Result<()> {
|
||||
let turn_id = state.active_turn_id.as_deref().unwrap_or("provider-turn-1");
|
||||
for index in 0..160_u64 {
|
||||
for index in 0..count {
|
||||
send(json!({
|
||||
"id": format!("runtime-flood-{interrupt_count}-{index}"),
|
||||
"method": "item/tool/requestUserInput",
|
||||
|
|
@ -303,7 +308,7 @@ fn send_runtime_request_flood(state: &FakeState, interrupt_count: u64) -> io::Re
|
|||
"questions": [{
|
||||
"id": "environment",
|
||||
"header": "Environment",
|
||||
"question": "Where should we deploy?",
|
||||
"question": question,
|
||||
"options": [{"label": "Staging", "description": "Deploy safely."}],
|
||||
}],
|
||||
},
|
||||
|
|
@ -412,6 +417,9 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||
let flood_runtime_requests_on_interrupt = args
|
||||
.iter()
|
||||
.any(|value| value == "--flood-runtime-requests-on-interrupt");
|
||||
let flood_large_runtime_requests_on_interrupt = args
|
||||
.iter()
|
||||
.any(|value| value == "--flood-large-runtime-requests-on-interrupt");
|
||||
let interrupt_terminal_delay_ms = argument(&args, "--interrupt-terminal-delay-ms")
|
||||
.map(|value| value.parse::<u64>())
|
||||
.transpose()?;
|
||||
|
|
@ -884,7 +892,16 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||
} else {
|
||||
send(json!({"id": id, "result": {"accepted": true}}))?;
|
||||
if flood_runtime_requests_on_interrupt {
|
||||
send_runtime_request_flood(&state, interrupt_count)?;
|
||||
send_runtime_request_flood(
|
||||
&state,
|
||||
interrupt_count,
|
||||
160,
|
||||
"Where should we deploy?",
|
||||
)?;
|
||||
}
|
||||
if flood_large_runtime_requests_on_interrupt {
|
||||
let question = "x".repeat(2 * 1024 * 1024);
|
||||
send_runtime_request_flood(&state, interrupt_count, 3, &question)?;
|
||||
}
|
||||
if !accept_interrupt_without_terminal
|
||||
&& !(accept_interrupt_without_terminal_once
|
||||
|
|
|
|||
|
|
@ -1963,19 +1963,27 @@ fn accepted_replacement_turn_revokes_prior_authority_before_idle_crash() {
|
|||
provider
|
||||
.start_turn("Start genuinely new provider work.", &config.cwd)
|
||||
.expect("start second provider turn");
|
||||
let second_exit = (0..64).find_map(|_| match provider.poll().expect("poll second turn") {
|
||||
Some(CodexProviderEvent::Exited {
|
||||
success,
|
||||
completed_turn_authoritative,
|
||||
completion_reconciles_exit,
|
||||
..
|
||||
}) => Some((
|
||||
success,
|
||||
completed_turn_authoritative,
|
||||
completion_reconciles_exit,
|
||||
)),
|
||||
_ => None,
|
||||
});
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
let second_exit = loop {
|
||||
if std::time::Instant::now() >= deadline {
|
||||
break None;
|
||||
}
|
||||
match provider.poll().expect("poll second turn") {
|
||||
Some(CodexProviderEvent::Exited {
|
||||
success,
|
||||
completed_turn_authoritative,
|
||||
completion_reconciles_exit,
|
||||
..
|
||||
}) => {
|
||||
break Some((
|
||||
success,
|
||||
completed_turn_authoritative,
|
||||
completion_reconciles_exit,
|
||||
));
|
||||
}
|
||||
_ => std::thread::sleep(std::time::Duration::from_millis(1)),
|
||||
}
|
||||
};
|
||||
assert_eq!(second_exit, Some((false, false, false)));
|
||||
|
||||
fs::remove_dir_all(directory).expect("remove Codex integration-test directory");
|
||||
|
|
@ -3680,6 +3688,55 @@ fn receipt_limit_polls_an_authoritative_terminal_with_unacknowledged_events() {
|
|||
fs::remove_dir_all(directory).expect("remove Codex integration-test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_runtime_request_count_limit_rejects_the_overflowing_request() {
|
||||
let directory = temporary_directory("runtime-request-count-limit");
|
||||
let config = provider_config(
|
||||
&directory,
|
||||
&[
|
||||
"--hold-turn",
|
||||
"--accept-interrupt-without-terminal",
|
||||
"--flood-runtime-requests-on-interrupt",
|
||||
],
|
||||
);
|
||||
let mut provider = CodexProvider::start(&config, None).expect("start Codex provider");
|
||||
provider
|
||||
.start_turn("Bound pending runtime requests by count.", &config.cwd)
|
||||
.expect("start held provider turn");
|
||||
provider
|
||||
.interrupt_turn()
|
||||
.expect("request the runtime-request flood");
|
||||
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
let mut accepted = 0;
|
||||
let mut rejected_at_capacity = false;
|
||||
while !rejected_at_capacity && std::time::Instant::now() < deadline {
|
||||
match provider
|
||||
.poll()
|
||||
.expect("the pending runtime-request count remains pollable")
|
||||
{
|
||||
Some(CodexProviderEvent::RuntimeRequest { .. }) => accepted += 1,
|
||||
Some(CodexProviderEvent::Notification { method, params })
|
||||
if method == "warning"
|
||||
&& params["message"]
|
||||
== "rejected a Codex runtime request at the bounded pending-input limit" =>
|
||||
{
|
||||
rejected_at_capacity = true;
|
||||
}
|
||||
None => std::thread::sleep(std::time::Duration::from_millis(1)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert_eq!(accepted, 128);
|
||||
assert!(
|
||||
rejected_at_capacity,
|
||||
"production rejects the 129th pending runtime request"
|
||||
);
|
||||
|
||||
provider.shutdown().expect("stop Codex provider");
|
||||
fs::remove_dir_all(directory).expect("remove Codex integration-test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receipt_limit_polling_bounds_and_rejects_runtime_request_floods() {
|
||||
let directory = temporary_directory("receipt-limit-runtime-request-flood");
|
||||
|
|
@ -3690,7 +3747,7 @@ fn receipt_limit_polling_bounds_and_rejects_runtime_request_floods() {
|
|||
"--hold-turn",
|
||||
"--emit-tool-call-on-resume",
|
||||
"--accept-interrupt-without-terminal",
|
||||
"--flood-runtime-requests-on-interrupt",
|
||||
"--flood-large-runtime-requests-on-interrupt",
|
||||
],
|
||||
);
|
||||
let runner_config = durable_config(&directory);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createSanitizedAcpxSpawnInput } from "./environment.js";
|
||||
|
||||
describe("ACPX launch environment", () => {
|
||||
it("projects only the selected agent's credentials and runtime allowlist", () => {
|
||||
const source = {
|
||||
PATH: "/bin",
|
||||
LC_ALL: "C.UTF-8",
|
||||
HTTPS_PROXY: "https://proxy.example",
|
||||
OPENAI_API_KEY: "openai-secret",
|
||||
ANTHROPIC_API_KEY: "anthropic-secret",
|
||||
OPENROUTER_API_KEY: "openrouter-secret",
|
||||
PAPERCLIP_ACPX_CODEX_AUTH_JSON_SECRET:
|
||||
'{"tokens":{"access_token":"managed-secret"}}',
|
||||
PAPERCLIP_RUNNER_BOOTSTRAP_TICKET: "transport-secret",
|
||||
PAPERCLIP_NATIVE_MCP_TOKEN: "bridge-secret",
|
||||
UNRELATED_SECRET: "not-visible",
|
||||
};
|
||||
|
||||
const codex = createSanitizedAcpxSpawnInput(source, "codex");
|
||||
expect(codex.env).toEqual({
|
||||
PATH: "/bin",
|
||||
LC_ALL: "C.UTF-8",
|
||||
HTTPS_PROXY: "https://proxy.example",
|
||||
OPENAI_API_KEY: "openai-secret",
|
||||
});
|
||||
expect(createSanitizedAcpxSpawnInput(source, "claude").env).toEqual({
|
||||
PATH: "/bin",
|
||||
LC_ALL: "C.UTF-8",
|
||||
HTTPS_PROXY: "https://proxy.example",
|
||||
ANTHROPIC_API_KEY: "anthropic-secret",
|
||||
});
|
||||
expect(createSanitizedAcpxSpawnInput(source, "pi").env).toEqual({
|
||||
PATH: "/bin",
|
||||
LC_ALL: "C.UTF-8",
|
||||
HTTPS_PROXY: "https://proxy.example",
|
||||
OPENROUTER_API_KEY: "openrouter-secret",
|
||||
});
|
||||
expect(codex.env).not.toHaveProperty("PAPERCLIP_NATIVE_MCP_TOKEN");
|
||||
expect(codex.env).not.toHaveProperty(
|
||||
"PAPERCLIP_ACPX_CODEX_AUTH_JSON_SECRET",
|
||||
);
|
||||
expect(Object.isFrozen(codex)).toBe(true);
|
||||
expect(Object.isFrozen(codex.env)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unsafe or unbounded retained values", () => {
|
||||
expect(() =>
|
||||
createSanitizedAcpxSpawnInput({ PATH: "bad\0path" }, "codex"),
|
||||
).toThrow("null byte");
|
||||
expect(() =>
|
||||
createSanitizedAcpxSpawnInput(
|
||||
{
|
||||
OPENAI_API_KEY: "x".repeat(64 * 1024),
|
||||
},
|
||||
"codex",
|
||||
),
|
||||
).toThrow("bounded launch size");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import type { QualifiedAcpxAgent } from "./qualified-profiles.js";
|
||||
|
||||
declare const sanitizedAcpxSpawnInputBrand: unique symbol;
|
||||
|
||||
/**
|
||||
* Opaque child-process input produced only after the host environment crosses
|
||||
* the ACPX credential allowlist. Future ACPX launchers accept this boundary
|
||||
* object rather than an arbitrary `process.env`-shaped value.
|
||||
*/
|
||||
export interface SanitizedAcpxSpawnInput {
|
||||
readonly env: Readonly<NodeJS.ProcessEnv>;
|
||||
readonly [sanitizedAcpxSpawnInputBrand]: true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the only host-environment input that may cross an ACPX child-process
|
||||
* launch boundary. Agent-specific homes are added by the later runtime sandbox;
|
||||
* Paperclip transport and native MCP credentials are never inherited from the
|
||||
* host process.
|
||||
*/
|
||||
export function createSanitizedAcpxSpawnInput(
|
||||
environment: NodeJS.ProcessEnv | undefined,
|
||||
agent: QualifiedAcpxAgent,
|
||||
): SanitizedAcpxSpawnInput {
|
||||
const source = environment ?? process.env;
|
||||
const result: NodeJS.ProcessEnv = {};
|
||||
const credentialNames =
|
||||
agent === "pi"
|
||||
? ["OPENROUTER_API_KEY"]
|
||||
: agent === "claude"
|
||||
? ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"]
|
||||
: [
|
||||
"OPENAI_API_KEY",
|
||||
"CODEX_API_KEY",
|
||||
];
|
||||
const allowed = new Set([
|
||||
"PATH",
|
||||
"LANG",
|
||||
"LANGUAGE",
|
||||
"TZ",
|
||||
"TMPDIR",
|
||||
"TEMP",
|
||||
"TMP",
|
||||
"SSL_CERT_FILE",
|
||||
"SSL_CERT_DIR",
|
||||
"NODE_EXTRA_CA_CERTS",
|
||||
"HTTP_PROXY",
|
||||
"HTTPS_PROXY",
|
||||
"NO_PROXY",
|
||||
"ALL_PROXY",
|
||||
"http_proxy",
|
||||
"https_proxy",
|
||||
"no_proxy",
|
||||
"all_proxy",
|
||||
"RUST_BACKTRACE",
|
||||
"PAPERCLIP_NATIVE_MCP_NAME",
|
||||
"PAPERCLIP_NATIVE_MCP_URL",
|
||||
...credentialNames,
|
||||
]);
|
||||
let retainedBytes = 0;
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (typeof value !== "string") continue;
|
||||
if (!allowed.has(key) && !/^LC_[A-Z0-9_]{1,32}$/.test(key)) continue;
|
||||
if (key.includes("\0") || value.includes("\0")) {
|
||||
throw new Error("ACPX environment contains a null byte");
|
||||
}
|
||||
const entryBytes = Buffer.byteLength(key) + Buffer.byteLength(value);
|
||||
if (entryBytes > 64 * 1024 || retainedBytes + entryBytes > 256 * 1024) {
|
||||
throw new Error("ACPX environment exceeds its bounded launch size");
|
||||
}
|
||||
retainedBytes += entryBytes;
|
||||
result[key] = value;
|
||||
}
|
||||
return Object.freeze({
|
||||
env: Object.freeze(result),
|
||||
}) as SanitizedAcpxSpawnInput;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
QUALIFIED_ACPX_PROFILES,
|
||||
resolveQualifiedAcpxProfile,
|
||||
} from "./qualified-profiles.js";
|
||||
|
||||
describe("qualified ACPX profiles", () => {
|
||||
it("binds each agent to one immutable package and model declaration", () => {
|
||||
for (const agent of ["pi", "claude", "codex"] as const) {
|
||||
const profile = QUALIFIED_ACPX_PROFILES[agent];
|
||||
expect(profile.agent).toBe(agent);
|
||||
expect(profile.commandDigest).toMatch(/^sha256:[a-f0-9]{64}$/);
|
||||
expect(Object.isFrozen(profile)).toBe(true);
|
||||
expect(
|
||||
resolveQualifiedAcpxProfile(agent, profile.qualificationModel),
|
||||
).toEqual(profile);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unqualified model substitutions", () => {
|
||||
expect(() =>
|
||||
resolveQualifiedAcpxProfile("codex", "some-other-model"),
|
||||
).toThrow("requires exact model");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
export const QUALIFIED_ACPX_VERSION = "0.13.1" as const;
|
||||
export const ACPX_DRIVER_KIND = "acpx_runtime" as const;
|
||||
export const ACPX_DRIVER_PROTOCOL_VERSION = 1 as const;
|
||||
|
||||
import type { NativeAcpxAgent } from "../../contracts/native-execution.js";
|
||||
|
||||
export type QualifiedAcpxAgent = NativeAcpxAgent;
|
||||
|
||||
export interface QualifiedAcpxProfile {
|
||||
readonly driverKind: typeof ACPX_DRIVER_KIND;
|
||||
readonly protocolVersion: typeof ACPX_DRIVER_PROTOCOL_VERSION;
|
||||
readonly acpxVersion: typeof QUALIFIED_ACPX_VERSION;
|
||||
readonly agent: QualifiedAcpxAgent;
|
||||
readonly agentProfileVersion: 1;
|
||||
readonly agentServerPackage: string;
|
||||
readonly agentServerVersion: string;
|
||||
readonly agentRuntimePackage: string | null;
|
||||
readonly agentRuntimeVersion: string | null;
|
||||
readonly commandDigest: string;
|
||||
readonly qualificationModel: string;
|
||||
/**
|
||||
* Model identifier the pinned ACP server reports after accepting the exact
|
||||
* qualification model. Most agents echo the requested model. Claude's ACP
|
||||
* server deliberately exposes its stable SDK selector (`sonnet`) while the
|
||||
* SDK resolves that selector to the canonical wire model
|
||||
* (`claude-sonnet-5`). Paperclip verifies the exact request was accepted
|
||||
* before treating this identifier as the qualified effective model.
|
||||
*/
|
||||
readonly reportedModelId: string;
|
||||
readonly permissionPolicy: "interactive";
|
||||
}
|
||||
|
||||
/**
|
||||
* Digests bind the closed profile declaration (package, version, runtime and
|
||||
* model), not a caller-controlled executable. The environment probe separately
|
||||
* verifies the resolved package files before a billable prompt is admitted.
|
||||
*/
|
||||
export const QUALIFIED_ACPX_PROFILES: Readonly<
|
||||
Record<QualifiedAcpxAgent, QualifiedAcpxProfile>
|
||||
> = deepFreeze({
|
||||
pi: {
|
||||
driverKind: ACPX_DRIVER_KIND,
|
||||
protocolVersion: ACPX_DRIVER_PROTOCOL_VERSION,
|
||||
acpxVersion: QUALIFIED_ACPX_VERSION,
|
||||
agent: "pi",
|
||||
agentProfileVersion: 1,
|
||||
agentServerPackage: "pi-acp",
|
||||
agentServerVersion: "0.0.33",
|
||||
agentRuntimePackage: "@earendil-works/pi-coding-agent",
|
||||
agentRuntimeVersion: "0.84.2",
|
||||
commandDigest:
|
||||
"sha256:8c696f38296d53d0061fa11534570c5ddd951b63532aed30e0f1fcc676dc169f",
|
||||
qualificationModel: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
reportedModelId: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
permissionPolicy: "interactive",
|
||||
},
|
||||
claude: {
|
||||
driverKind: ACPX_DRIVER_KIND,
|
||||
protocolVersion: ACPX_DRIVER_PROTOCOL_VERSION,
|
||||
acpxVersion: QUALIFIED_ACPX_VERSION,
|
||||
agent: "claude",
|
||||
agentProfileVersion: 1,
|
||||
agentServerPackage: "@agentclientprotocol/claude-agent-acp",
|
||||
agentServerVersion: "0.70.0",
|
||||
agentRuntimePackage: null,
|
||||
agentRuntimeVersion: null,
|
||||
commandDigest:
|
||||
"sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a",
|
||||
qualificationModel: "claude-sonnet-5",
|
||||
reportedModelId: "sonnet",
|
||||
permissionPolicy: "interactive",
|
||||
},
|
||||
codex: {
|
||||
driverKind: ACPX_DRIVER_KIND,
|
||||
protocolVersion: ACPX_DRIVER_PROTOCOL_VERSION,
|
||||
acpxVersion: QUALIFIED_ACPX_VERSION,
|
||||
agent: "codex",
|
||||
agentProfileVersion: 1,
|
||||
agentServerPackage: "@agentclientprotocol/codex-acp",
|
||||
agentServerVersion: "1.6.2",
|
||||
agentRuntimePackage: null,
|
||||
agentRuntimeVersion: null,
|
||||
commandDigest:
|
||||
"sha256:94049b3e3c3aee87de62703786e4fa81d031d7bd979f99bdf516d84f28791a79",
|
||||
qualificationModel: "gpt-5.6-sol",
|
||||
reportedModelId: "gpt-5.6-sol",
|
||||
permissionPolicy: "interactive",
|
||||
},
|
||||
});
|
||||
|
||||
export function resolveQualifiedAcpxProfile(
|
||||
agent: QualifiedAcpxAgent,
|
||||
requestedModel: string,
|
||||
): QualifiedAcpxProfile {
|
||||
const profile = QUALIFIED_ACPX_PROFILES[agent];
|
||||
if (requestedModel !== profile.qualificationModel) {
|
||||
throw new Error(
|
||||
`ACPX ${agent} profile requires exact model ${profile.qualificationModel}; received ${requestedModel}`,
|
||||
);
|
||||
}
|
||||
return structuredClone(profile);
|
||||
}
|
||||
|
||||
function deepFreeze<T>(value: T): T {
|
||||
if (typeof value !== "object" || value === null || Object.isFrozen(value))
|
||||
return value;
|
||||
Object.freeze(value);
|
||||
for (const child of Object.values(value)) deepFreeze(child);
|
||||
return value;
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
ACPX_SIDECAR_MAX_FRAME_BYTES,
|
||||
boundedSidecarValue,
|
||||
parseAcpxSidecarRequest,
|
||||
sanitizeAcpxPlanEntries,
|
||||
} from "./sidecar-protocol.js";
|
||||
|
||||
describe("ACPX sidecar request parsing", () => {
|
||||
it("accepts only bounded, versioned, generated commands", () => {
|
||||
expect(
|
||||
parseAcpxSidecarRequest({
|
||||
protocolVersion: 2,
|
||||
id: 1,
|
||||
command: "session.open",
|
||||
params: { cwd: "/workspace" },
|
||||
}),
|
||||
).toEqual({
|
||||
protocolVersion: 2,
|
||||
id: 1,
|
||||
command: "session.open",
|
||||
params: { cwd: "/workspace" },
|
||||
});
|
||||
expect(() =>
|
||||
parseAcpxSidecarRequest({
|
||||
protocolVersion: 3,
|
||||
id: 1,
|
||||
command: "session.open",
|
||||
params: {},
|
||||
}),
|
||||
).toThrow("unsupported ACPX sidecar protocol version");
|
||||
expect(() =>
|
||||
parseAcpxSidecarRequest({
|
||||
protocolVersion: 2,
|
||||
id: 1,
|
||||
command: "session.destroy",
|
||||
params: {},
|
||||
}),
|
||||
).toThrow("unsupported ACPX sidecar command");
|
||||
expect(() =>
|
||||
parseAcpxSidecarRequest({
|
||||
protocolVersion: 2,
|
||||
id: 1,
|
||||
command: "session.open",
|
||||
params: [],
|
||||
}),
|
||||
).toThrow("params must be an object");
|
||||
expect(() =>
|
||||
parseAcpxSidecarRequest({
|
||||
protocolVersion: 2,
|
||||
id: 1,
|
||||
command: "session.open",
|
||||
params: {},
|
||||
extra: true,
|
||||
}),
|
||||
).toThrow("unknown field");
|
||||
});
|
||||
|
||||
it("bounds and safely sanitizes arbitrary values", () => {
|
||||
expect(() =>
|
||||
parseAcpxSidecarRequest({
|
||||
protocolVersion: 2,
|
||||
id: 1,
|
||||
command: "initialize",
|
||||
params: { value: "x".repeat(ACPX_SIDECAR_MAX_FRAME_BYTES) },
|
||||
}),
|
||||
).toThrow("frame limit");
|
||||
const cyclic: Record<string, unknown> = {};
|
||||
cyclic.self = cyclic;
|
||||
expect(boundedSidecarValue(cyclic)).toEqual({
|
||||
omitted: true,
|
||||
reason: "serialization_failed",
|
||||
});
|
||||
expect(boundedSidecarValue(["not", "an", "object"])).toEqual({
|
||||
omitted: true,
|
||||
reason: "object_required",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ACPX sidecar structured plans", () => {
|
||||
it("preserves every valid ordered entry while bounding and sanitizing the snapshot", () => {
|
||||
const entries = sanitizeAcpxPlanEntries([
|
||||
{ content: " Inspect ", status: "completed", priority: "high" },
|
||||
{ content: "Implement", status: "in_progress", priority: "medium" },
|
||||
{ content: "Verify", status: "pending", priority: "low" },
|
||||
{ content: "Invalid status", status: "failed" },
|
||||
{ content: " ", status: "pending" },
|
||||
{
|
||||
content: "x".repeat(5_000),
|
||||
status: "pending",
|
||||
priority: "p".repeat(100),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(entries.slice(0, 3)).toEqual([
|
||||
{ content: "Inspect", status: "completed", priority: "high" },
|
||||
{ content: "Implement", status: "in_progress", priority: "medium" },
|
||||
{ content: "Verify", status: "pending", priority: "low" },
|
||||
]);
|
||||
expect(entries).toHaveLength(4);
|
||||
expect(entries[3]?.content).toHaveLength(4_000);
|
||||
expect(entries[3]?.priority).toHaveLength(80);
|
||||
expect(
|
||||
sanitizeAcpxPlanEntries(
|
||||
Array.from({ length: 300 }, (_, index) => ({
|
||||
content: `Step ${index}`,
|
||||
status: "pending",
|
||||
})),
|
||||
),
|
||||
).toHaveLength(256);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
import type { QualifiedAcpxAgent } from "./qualified-profiles.js";
|
||||
import type { NativeRuntimeContextSnapshot } from "../../contracts/runtime-context.js";
|
||||
import type { NativeAcpxPermissionMode } from "../../contracts/native-execution.js";
|
||||
import {
|
||||
GENERATED_ACPX_SIDECAR_COMMANDS,
|
||||
GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION,
|
||||
type GeneratedAcpxSidecarCommand,
|
||||
type GeneratedAcpxSidecarEventType,
|
||||
} from "./generated-sidecar-contract.js";
|
||||
|
||||
export const ACPX_SIDECAR_PROTOCOL_VERSION =
|
||||
GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION;
|
||||
export const ACPX_SIDECAR_MAX_FRAME_BYTES = 1024 * 1024;
|
||||
|
||||
export interface AcpxSidecarRequest {
|
||||
protocolVersion: typeof ACPX_SIDECAR_PROTOCOL_VERSION;
|
||||
id: number;
|
||||
command: GeneratedAcpxSidecarCommand;
|
||||
params: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AcpxSidecarResponse {
|
||||
protocolVersion: typeof ACPX_SIDECAR_PROTOCOL_VERSION;
|
||||
id: number;
|
||||
ok: boolean;
|
||||
result?: Record<string, unknown>;
|
||||
error?: { code: string; message: string; retryable: boolean };
|
||||
}
|
||||
|
||||
export interface AcpxSidecarEvent {
|
||||
protocolVersion: typeof ACPX_SIDECAR_PROTOCOL_VERSION;
|
||||
sequence: number;
|
||||
eventType: GeneratedAcpxSidecarEventType;
|
||||
runId: string | null;
|
||||
turnId: string | null;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AcpxSidecarOpenParams {
|
||||
runtimeDirectory: string;
|
||||
normalizedSessionId: string;
|
||||
workingDirectory: string;
|
||||
agent: QualifiedAcpxAgent;
|
||||
model: string;
|
||||
permissionMode: NativeAcpxPermissionMode;
|
||||
permissionModePinned: boolean;
|
||||
systemInstructions: string;
|
||||
runtimeContext: NativeRuntimeContextSnapshot | null;
|
||||
tools: readonly Readonly<Record<string, unknown>>[];
|
||||
providerSessionKey?: string;
|
||||
expectedIdentity?: AcpxExpectedSessionIdentity;
|
||||
}
|
||||
|
||||
export interface AcpxExpectedSessionIdentity {
|
||||
kind: "acpx";
|
||||
normalizedSessionId: string;
|
||||
acpxRecordId: string;
|
||||
backendSessionId: string;
|
||||
agentSessionId: string;
|
||||
profileDigest: string;
|
||||
workspaceDigest: string;
|
||||
requestedModel: string;
|
||||
effectiveModel: string;
|
||||
permissionMode?: NativeAcpxPermissionMode;
|
||||
}
|
||||
|
||||
export function parseAcpxSidecarRequest(value: unknown): AcpxSidecarRequest {
|
||||
let serialized: string;
|
||||
try {
|
||||
serialized = JSON.stringify(value);
|
||||
} catch {
|
||||
throw new Error("ACPX sidecar request is not JSON serializable");
|
||||
}
|
||||
if (
|
||||
!serialized ||
|
||||
Buffer.byteLength(serialized) > ACPX_SIDECAR_MAX_FRAME_BYTES
|
||||
) {
|
||||
throw new Error("ACPX sidecar request exceeds the frame limit");
|
||||
}
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new Error("ACPX sidecar request must be an object");
|
||||
}
|
||||
const request = value as Record<string, unknown>;
|
||||
if (
|
||||
Object.keys(request).some(
|
||||
(key) => !["protocolVersion", "id", "command", "params"].includes(key),
|
||||
)
|
||||
) {
|
||||
throw new Error("ACPX sidecar request contains an unknown field");
|
||||
}
|
||||
if (request.protocolVersion !== ACPX_SIDECAR_PROTOCOL_VERSION) {
|
||||
throw new Error("unsupported ACPX sidecar protocol version");
|
||||
}
|
||||
if (!Number.isSafeInteger(request.id) || Number(request.id) < 1)
|
||||
throw new Error("sidecar request id must be a positive integer");
|
||||
const command = text(request.command);
|
||||
if (!(GENERATED_ACPX_SIDECAR_COMMANDS as readonly string[]).includes(command))
|
||||
throw new Error("unsupported ACPX sidecar command");
|
||||
if (
|
||||
typeof request.params !== "object" ||
|
||||
request.params === null ||
|
||||
Array.isArray(request.params)
|
||||
) {
|
||||
throw new Error("ACPX sidecar request params must be an object");
|
||||
}
|
||||
return {
|
||||
protocolVersion: ACPX_SIDECAR_PROTOCOL_VERSION,
|
||||
id: Number(request.id),
|
||||
command: command as AcpxSidecarRequest["command"],
|
||||
params: structuredClone(request.params as Record<string, unknown>),
|
||||
};
|
||||
}
|
||||
|
||||
export function boundedSidecarValue(
|
||||
value: unknown,
|
||||
maxBytes = 64 * 1024,
|
||||
): Record<string, unknown> {
|
||||
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) {
|
||||
throw new Error("ACPX sidecar value limit must be a positive integer");
|
||||
}
|
||||
try {
|
||||
const serialized = JSON.stringify(value);
|
||||
if (!serialized || Buffer.byteLength(serialized) > maxBytes) {
|
||||
return { omitted: true, reason: "payload_limit" };
|
||||
}
|
||||
const parsed = JSON.parse(serialized);
|
||||
return typeof parsed === "object" &&
|
||||
parsed !== null &&
|
||||
!Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: { omitted: true, reason: "object_required" };
|
||||
} catch {
|
||||
return { omitted: true, reason: "serialization_failed" };
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizeAcpxPlanEntries(value: unknown): Array<{
|
||||
content: string;
|
||||
status: "pending" | "in_progress" | "completed";
|
||||
priority: string | null;
|
||||
}> {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.slice(0, 256).flatMap((candidate) => {
|
||||
const entry = record(candidate);
|
||||
const content = text(entry.content).trim().slice(0, 4_000);
|
||||
const status = text(entry.status);
|
||||
if (
|
||||
!content ||
|
||||
(status !== "pending" &&
|
||||
status !== "in_progress" &&
|
||||
status !== "completed")
|
||||
)
|
||||
return [];
|
||||
return [
|
||||
{
|
||||
content,
|
||||
status,
|
||||
priority: text(entry.priority).slice(0, 80) || null,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
export function record(value: unknown): Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
export function text(value: unknown, fallback = ""): string {
|
||||
return typeof value === "string" ? value : fallback;
|
||||
}
|
||||
|
|
@ -20,6 +20,62 @@ index 243c9d13bcba520923b63adfddad75cf2d94362d..336a1699b80b9e99416f9da4346ca73e
|
|||
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);
|
||||
|
|
@ -51,7 +107,7 @@ index 243c9d13bcba520923b63adfddad75cf2d94362d..336a1699b80b9e99416f9da4346ca73e
|
|||
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)
|
||||
+ spawnOptions: buildAgentSpawnOptions(this.options.spawnCwd ?? this.options.cwd, this.options.authCredentials, this.options.sessionOptions?.env, this.options.inheritProcessEnv)
|
||||
};
|
||||
}
|
||||
logAgentLaunch(plan) {
|
||||
|
|
@ -59,13 +115,14 @@ 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,9 @@ type AcpRuntimeOptions = {
|
||||
@@ -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>;
|
||||
|
|
@ -78,7 +135,7 @@ index 6c9cc999e50a11c399c68b3a0f1b7af4bc2317c0..33b5054b2906502d1d4b512bfa259bd2
|
|||
}
|
||||
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 };
|
||||
+ 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) {
|
||||
|
|
@ -87,13 +144,14 @@ diff --git a/dist/session-options-jkYbBxGE.d.ts b/dist/session-options-jkYbBxGE.
|
|||
index 9d37f377fb6a0828e0d2bc5a48754f3aa71509a4..680bc080fc5d6ffd266ed1b27d3d5056add9d980 100644
|
||||
--- a/dist/session-options-jkYbBxGE.d.ts
|
||||
+++ b/dist/session-options-jkYbBxGE.d.ts
|
||||
@@ -84,6 +84,9 @@ type AcpClientOptions = {
|
||||
@@ -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[];
|
||||
|
|
|
|||
Loading…
Reference in New Issue