fix(acpx): decouple host proxy spawn cwd from in-sandbox remoteCwd (#10122)
This commit is contained in:
parent
e41ba306c5
commit
b517b887ad
|
|
@ -776,12 +776,17 @@ describe("shared ACPX engine runtime behavior", () => {
|
|||
const root = await makeTempRoot();
|
||||
const localCwd = path.join(root, "local");
|
||||
const remoteCwd = "/workspace/remote";
|
||||
const { sessionInputs } = await runExecutor(
|
||||
const { sessionInputs, runtimeOptions } = await runExecutor(
|
||||
{ agent: "custom", agentCommand: "node ./fake-acp.js", cwd: localCwd, stateDir: path.join(root, "state") },
|
||||
{ context: { paperclipWorkspace: { cwd: localCwd, workspaceWorktreePath: localCwd } }, executionTarget: { kind: "remote", transport: "ssh", remoteCwd } },
|
||||
);
|
||||
const env = (sessionInputs[0]!.sessionOptions as { env: Record<string, string> }).env;
|
||||
expect(env.PAPERCLIP_WORKSPACE_CWD).toBe(localCwd);
|
||||
// The ssh remote transport is NOT the runner-backed process-session lane, so
|
||||
// it stays byte-identical: no host-spawn redirect. `cwd` is the host cwd and
|
||||
// `spawnCwd` is unset.
|
||||
expect(runtimeOptions[0]!.cwd).toBe(localCwd);
|
||||
expect(runtimeOptions[0]!.spawnCwd).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not materialize credential wrapper scripts", async () => {
|
||||
|
|
@ -888,6 +893,9 @@ describe("shared ACPX engine runtime behavior", () => {
|
|||
const { runtimeOptions } = await runExecutor({ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state") });
|
||||
expect(runtimeOptions[0]!.verbose).toBe(false);
|
||||
expect(runtimeOptions[0]!.onAgentStderr).toBeTypeOf("function");
|
||||
// Local lane is byte-identical: no host-spawn redirect, so `spawnCwd` is
|
||||
// unset and acpx falls back to `cwd`.
|
||||
expect(runtimeOptions[0]!.spawnCwd).toBeUndefined();
|
||||
});
|
||||
|
||||
it("starts sandbox ACP process sessions in the remote execution cwd", async () => {
|
||||
|
|
@ -911,7 +919,7 @@ describe("shared ACPX engine runtime behavior", () => {
|
|||
},
|
||||
);
|
||||
|
||||
await runExecutor(
|
||||
const { runtimeOptions, sessionInputs } = await runExecutor(
|
||||
{ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
|
||||
{
|
||||
authToken: "real-run-jwt",
|
||||
|
|
@ -930,6 +938,18 @@ describe("shared ACPX engine runtime behavior", () => {
|
|||
args: ["-lc", "exec node ./fake-acp.js"],
|
||||
cwd: remoteCwd,
|
||||
});
|
||||
|
||||
// Host-spawn cwd decoupling: on the remote process-session lane the acpx
|
||||
// runtime host-spawns the relay proxy, whose `chdir` must land in a
|
||||
// HOST-valid dir — the engine's host `cwd` (`localCwd`) — while the advertised
|
||||
// ACP `session/new` cwd and the in-sandbox `commandPayload.cwd` stay
|
||||
// `remoteCwd`. `spawnCwd` carries the host-only redirect; it must differ from
|
||||
// the advertised session cwd. (Threading proof; the acpx runtime honoring
|
||||
// `spawnCwd ?? cwd` at the real host spawn is proven in remote-spawn-smoke.)
|
||||
expect(runtimeOptions[0]!.cwd).toBe(remoteCwd);
|
||||
expect(sessionInputs[0]!.cwd).toBe(remoteCwd);
|
||||
expect(runtimeOptions[0]!.spawnCwd).toBe(localCwd);
|
||||
expect(runtimeOptions[0]!.spawnCwd).not.toBe(sessionInputs[0]!.cwd);
|
||||
const payloadEnv = ((sessionPayload as Record<string, unknown> | null)?.env ?? {}) as Record<string, unknown>;
|
||||
expect(payloadEnv).toMatchObject({
|
||||
PAPERCLIP_API_BRIDGE_MODE: "queue_v1",
|
||||
|
|
@ -941,6 +961,48 @@ describe("shared ACPX engine runtime behavior", () => {
|
|||
expect(payloadEnv.PAPERCLIP_API_KEY).not.toBe("real-run-jwt");
|
||||
});
|
||||
|
||||
it("keeps the session fingerprint stable when only the host spawn cwd changes", async () => {
|
||||
// `spawnCwd` (the host-only spawn redirect = the host `cwd`) must NOT enter
|
||||
// the session fingerprint or compat key: two runs of the same session that
|
||||
// stage into the same in-sandbox `remoteCwd` from DIFFERENT host worktrees
|
||||
// must reuse — not invalidate — the staged runtime. So the fingerprint has to
|
||||
// ignore the host cwd and key only on the advertised session cwd (`remoteCwd`).
|
||||
const root = await makeTempRoot();
|
||||
const remoteCwd = path.join(root, "remote-workspace");
|
||||
await fs.mkdir(remoteCwd, { recursive: true });
|
||||
|
||||
const runOnce = async (hostWorktree: string) => {
|
||||
const localCwd = path.join(root, hostWorktree);
|
||||
await fs.mkdir(localCwd, { recursive: true });
|
||||
return runExecutor(
|
||||
{ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, hostWorktree, "state"), cwd: localCwd },
|
||||
{
|
||||
authToken: "real-run-jwt",
|
||||
executionTarget: {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
providerKey: "fake-plugin",
|
||||
remoteCwd,
|
||||
runner: createLocalSandboxRunner(),
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const first = await runOnce("worktree-a");
|
||||
const second = await runOnce("worktree-b");
|
||||
|
||||
// Host cwd (and therefore `spawnCwd`) differs between the two runs...
|
||||
expect(first.runtimeOptions[0]!.spawnCwd).not.toBe(second.runtimeOptions[0]!.spawnCwd);
|
||||
// ...but the advertised session cwd — and thus the fingerprint — is identical.
|
||||
expect(first.sessionInputs[0]!.cwd).toBe(remoteCwd);
|
||||
expect(second.sessionInputs[0]!.cwd).toBe(remoteCwd);
|
||||
const fp = (r: { result: { sessionParams?: unknown } }) =>
|
||||
(r.result.sessionParams as { configFingerprint?: string } | undefined)?.configFingerprint;
|
||||
expect(fp(first)).toBeDefined();
|
||||
expect(fp(second)).toBe(fp(first));
|
||||
});
|
||||
|
||||
it("routes child stderr in-process while keeping the unfiltered run log", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const stateDir = path.join(root, "state");
|
||||
|
|
|
|||
|
|
@ -318,6 +318,13 @@ interface AcpxPreparedRuntime {
|
|||
acpxAgent: string;
|
||||
mode: "persistent" | "oneshot";
|
||||
cwd: string;
|
||||
// Host-only spawn cwd for the acpx runtime's host `spawn()` of the relay
|
||||
// proxy on the remote process-session lane. On that lane `cwd` is the
|
||||
// IN-SANDBOX `remoteCwd` (host-nonexistent), so the host proxy must `chdir`
|
||||
// into a HOST-valid dir instead — the engine's host `cwd`. `undefined` on
|
||||
// every other lane, where acpx falls back to `cwd` (byte-identical). It is
|
||||
// deliberately NOT part of the session fingerprint / compat key.
|
||||
hostSpawnCwd: string | undefined;
|
||||
workspaceId: string;
|
||||
workspaceRepoUrl: string;
|
||||
workspaceRepoRef: string;
|
||||
|
|
@ -1720,6 +1727,11 @@ async function buildRuntime(input: {
|
|||
// → the HOST cwd (`sessionCwd` resolves both). Every cwd-keyed session site
|
||||
// reads `prepared.cwd`, so binding it once here keeps them consistent.
|
||||
cwd: sessionCwd,
|
||||
// Only the remote process-session lane needs the host proxy's `spawn()`
|
||||
// `chdir` redirected off the in-sandbox `sessionCwd` and onto the host
|
||||
// `cwd` (which is where the workspace was staged FROM, so it is host-valid).
|
||||
// Every other lane leaves it `undefined` → acpx falls back to `cwd`.
|
||||
hostSpawnCwd: useRemoteProcessSession ? cwd : undefined,
|
||||
workspaceId,
|
||||
workspaceRepoUrl,
|
||||
workspaceRepoRef,
|
||||
|
|
@ -2510,6 +2522,12 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
|
|||
childStderrState.logPath = prepared.childStderrLogPath;
|
||||
const runtimeOptions: AcpRuntimeOptions = {
|
||||
cwd: prepared.cwd,
|
||||
// Host-only spawn cwd for the relay proxy on the remote process-session
|
||||
// lane; `undefined` elsewhere so acpx falls back to `cwd` (byte-identical).
|
||||
// The advertised `session/new` cwd (`prepared.cwd` = `remoteCwd`) and the
|
||||
// fingerprint / compat key are unaffected — this redirects ONLY the host
|
||||
// `spawn()` `chdir`, not the in-sandbox data path.
|
||||
spawnCwd: prepared.hostSpawnCwd,
|
||||
sessionStore: createRuntimeStore({ stateDir: prepared.stateDir }),
|
||||
agentRegistry: prepared.agentRegistry,
|
||||
permissionMode: prepared.permissionMode,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,126 @@
|
|||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import type { AcpRuntimeOptions } from "acpx/runtime";
|
||||
import { createAcpRuntime, createAgentRegistry, createRuntimeStore } from "acpx/runtime";
|
||||
|
||||
// Load-bearing repro for the remote-ACP "process session" lane host-spawn bug.
|
||||
//
|
||||
// The engine threads ONE cwd (`sessionCwd`) into every cwd-keyed site: the ACP
|
||||
// `session/new` cwd, the session fingerprint/compat key, AND the acpx HOST
|
||||
// `spawn()` of the host-local relay proxy. On the remote lane that value is the
|
||||
// IN-SANDBOX `remoteCwd`, which does not exist on the host, so libuv's pre-`exec`
|
||||
// `chdir` fails and acpx raises `AgentSpawnError` at `ensure_session`.
|
||||
//
|
||||
// A faithful full-engine remote-lane repro is NOT possible without a live
|
||||
// sandbox: the only local stand-in for a sandbox runs its commands as host child
|
||||
// processes, so every `remoteCwd`-derived operation (workspace staging, the
|
||||
// callback bridge, the process-session bridge) executes on the HOST filesystem —
|
||||
// either materializing `remoteCwd` on the host (masking the host-spawn ENOENT)
|
||||
// or failing earlier at a different phase. So we split the proof at its two real
|
||||
// seams: (1) here — the acpx runtime's REAL host `spawn()` honoring
|
||||
// `spawnCwd ?? cwd` (real `createAcpRuntime`, real libuv `chdir`); and (2) the
|
||||
// engine → `runtimeOptions` threading (`execute.test.ts`, which asserts the
|
||||
// engine sets `spawnCwd` host-valid on the remote lane and `undefined`
|
||||
// elsewhere, with the advertised `session/new` cwd staying `remoteCwd`).
|
||||
// End-to-end validation against a real sandbox is board-run.
|
||||
|
||||
const repoRoot = fileURLToPath(new URL("../../../..", import.meta.url));
|
||||
// A dedicated ACP agent fixture that reports, on stderr, the working directory
|
||||
// the host actually spawned it in (`SPAWN_CWD`) and the `cwd` advertised on
|
||||
// `session/new` (`SESSION_NEW_CWD`).
|
||||
const fixturePath = path.join(repoRoot, "scripts", "mcp-fixtures", "servers", "acp-cwd-report-agent.mjs");
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
type PatchedAcpRuntimeOptions = AcpRuntimeOptions & {
|
||||
spawnCwd?: string;
|
||||
};
|
||||
|
||||
type PatchedEnsureSessionOptions = Parameters<ReturnType<typeof createAcpRuntime>["ensureSession"]>[0];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
async function makeTempDir(prefix: string): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
tempRoots.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive a REAL acpx runtime through `ensureSession`, which performs the real
|
||||
* host `spawn()` (and its libuv `chdir`). `cwd` is the advertised session cwd;
|
||||
* `spawnCwd`, when set, is the host-only spawn cwd the acpx patch consumes as
|
||||
* `spawnCwd ?? cwd`.
|
||||
*/
|
||||
async function ensureRealAcpSession(input: { cwd: string; spawnCwd?: string }) {
|
||||
const stateRoot = await makeTempDir("paperclip-acpx-remote-spawn-state-");
|
||||
const stderrChunks: string[] = [];
|
||||
const agentCommand = `${JSON.stringify(process.execPath.replaceAll("\\", "/"))} ${JSON.stringify(fixturePath.replaceAll("\\", "/"))}`;
|
||||
const runtimeOptions: PatchedAcpRuntimeOptions = {
|
||||
cwd: input.cwd,
|
||||
// `spawnCwd` is the host-only knob added by patches/acpx@0.12.0.patch; when
|
||||
// unset acpx falls back to `cwd`, so every non-proxy lane is byte-identical.
|
||||
...(input.spawnCwd ? { spawnCwd: input.spawnCwd } : {}),
|
||||
sessionStore: createRuntimeStore({ stateDir: path.join(stateRoot, "state") }),
|
||||
agentRegistry: createAgentRegistry({ overrides: { custom: agentCommand } }),
|
||||
permissionMode: "approve-all",
|
||||
nonInteractivePermissions: "deny",
|
||||
onAgentStderr: (chunk: string) => stderrChunks.push(chunk),
|
||||
};
|
||||
const runtime = createAcpRuntime(runtimeOptions);
|
||||
|
||||
try {
|
||||
const sessionInput: PatchedEnsureSessionOptions = {
|
||||
sessionKey: "remote-spawn-smoke",
|
||||
agent: "custom",
|
||||
mode: "oneshot",
|
||||
cwd: input.cwd,
|
||||
sessionOptions: { env: {} },
|
||||
};
|
||||
const handle = await runtime.ensureSession(sessionInput);
|
||||
await (runtime as { close: (i: unknown) => Promise<void> }).close({ handle, reason: "done" }).catch(() => {});
|
||||
return { resolved: true as const, stderr: stderrChunks.join("") };
|
||||
} catch (err) {
|
||||
return { resolved: false as const, error: err as NodeJS.ErrnoException & { cause?: NodeJS.ErrnoException }, stderr: stderrChunks.join("") };
|
||||
}
|
||||
}
|
||||
|
||||
it("reproduces host-spawn ENOENT when the advertised session cwd is host-nonexistent", async () => {
|
||||
// The in-sandbox `remoteCwd` that does not exist on the host. Intentionally
|
||||
// NOT created: this is what trips the acpx host `spawn()` `chdir`.
|
||||
const sandboxParent = await makeTempDir("paperclip-acpx-remote-spawn-sandbox-");
|
||||
const remoteCwd = path.join(sandboxParent, "does-not-exist-on-host", "workspace");
|
||||
|
||||
const outcome = await ensureRealAcpSession({ cwd: remoteCwd });
|
||||
|
||||
// Before the fix the engine feeds `remoteCwd` as the host spawn cwd, so acpx's
|
||||
// real `spawn()` `chdir`s into a host-nonexistent dir and fails ENOENT. This is
|
||||
// the exact `ensure_session` failure the remote lane hits in production.
|
||||
expect(outcome.resolved, JSON.stringify(outcome)).toBe(false);
|
||||
if (outcome.resolved) return;
|
||||
expect(outcome.error.name).toBe("AgentSpawnError");
|
||||
expect(outcome.error.cause?.code).toBe("ENOENT");
|
||||
});
|
||||
|
||||
it("spawnCwd redirects the host spawn to a host-valid dir while the advertised session cwd stays remoteCwd", async () => {
|
||||
const sandboxParent = await makeTempDir("paperclip-acpx-remote-spawn-sandbox-");
|
||||
// Host-nonexistent in-sandbox cwd — the advertised `session/new` cwd.
|
||||
const remoteCwd = path.join(sandboxParent, "does-not-exist-on-host", "workspace");
|
||||
// Host-valid dir the proxy actually spawns in (the engine's host `cwd`).
|
||||
const hostSpawnCwd = await makeTempDir("paperclip-acpx-remote-spawn-host-");
|
||||
|
||||
const outcome = await ensureRealAcpSession({ cwd: remoteCwd, spawnCwd: hostSpawnCwd });
|
||||
|
||||
// With `spawnCwd` set the host `spawn()` `chdir`s into the host-valid dir, so
|
||||
// the session comes up instead of failing at `ensure_session`.
|
||||
expect(outcome.resolved, JSON.stringify(outcome)).toBe(true);
|
||||
// The host process really ran in `spawnCwd`...
|
||||
expect(outcome.stderr).toContain(`SPAWN_CWD=${await fs.realpath(hostSpawnCwd)}`);
|
||||
// ...while the in-sandbox data path (the advertised `session/new` cwd) is
|
||||
// unchanged — still `remoteCwd`.
|
||||
expect(outcome.stderr).toContain(`SESSION_NEW_CWD=${remoteCwd}`);
|
||||
});
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
|
@ -44,3 +45,31 @@ it("spawns a real Node ACP agent with per-session env on this platform", async (
|
|||
expect(stderr).toContain("nes/close");
|
||||
expect(stderr).toContain("paperclip-acp-echo-agent started");
|
||||
});
|
||||
|
||||
it("captures the Node error shape for a host-invalid spawn cwd", async () => {
|
||||
// Regression anchor for the primitive behind the remote-lane bug: a host
|
||||
// `spawn()` whose `cwd` does not exist fails BEFORE `exec`, when libuv
|
||||
// `chdir`s into it. The command itself (`process.execPath`) is valid, so the
|
||||
// failure is unambiguously the missing cwd — the exact condition acpx hits
|
||||
// when it host-spawns the relay proxy with the in-sandbox `remoteCwd`.
|
||||
const missingCwd = path.join(os.tmpdir(), "paperclip-acpx-missing-spawn-cwd", "nested", "does-not-exist");
|
||||
|
||||
const err = await new Promise<NodeJS.ErrnoException>((resolve, reject) => {
|
||||
const child = spawn(process.execPath, ["-e", "0"], {
|
||||
cwd: missingCwd,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
child.once("error", resolve);
|
||||
child.once("spawn", () => {
|
||||
child.kill("SIGKILL");
|
||||
reject(new Error("expected spawn to fail with a host-invalid cwd, but it started"));
|
||||
});
|
||||
});
|
||||
|
||||
expect(err.code).toBe("ENOENT");
|
||||
// libuv attributes the failed pre-`exec` `chdir` to the command spawn, not to
|
||||
// the missing cwd — `syscall`/`path` point at the executable. This misdirection
|
||||
// is precisely why the remote-lane failure was hard to diagnose.
|
||||
expect(err.syscall).toBe(`spawn ${process.execPath}`);
|
||||
expect(err.path).toBe(process.execPath);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,38 +1,8 @@
|
|||
--- a/dist/runtime.d.ts
|
||||
+++ b/dist/runtime.d.ts
|
||||
@@ -266,6 +266,7 @@
|
||||
timeoutMs?: number;
|
||||
probeAgent?: string;
|
||||
verbose?: boolean;
|
||||
+ onAgentStderr?: (chunk: string) => void;
|
||||
onPermissionRequest?: (req: AcpPermissionRequest, ctx: {
|
||||
signal: AbortSignal;
|
||||
}) => Promise<AcpPermissionDecision | undefined>;
|
||||
--- a/dist/session-options-jkYbBxGE.d.ts
|
||||
+++ b/dist/session-options-jkYbBxGE.d.ts
|
||||
@@ -84,6 +84,7 @@
|
||||
terminal?: boolean;
|
||||
suppressSdkConsoleErrors?: boolean;
|
||||
verbose?: boolean;
|
||||
+ onAgentStderr?: (chunk: string) => void;
|
||||
sessionOptions?: {
|
||||
model?: string;
|
||||
allowedTools?: string[];
|
||||
--- a/dist/runtime.js
|
||||
+++ b/dist/runtime.js
|
||||
@@ -744,7 +744,8 @@
|
||||
this.deps = deps;
|
||||
}
|
||||
createClient(options) {
|
||||
- return this.deps.clientFactory?.(options) ?? new AcpClient(options);
|
||||
+ const clientOptions = { ...options, onAgentStderr: this.options.onAgentStderr };
|
||||
+ return this.deps.clientFactory?.(clientOptions) ?? new AcpClient(clientOptions);
|
||||
}
|
||||
async readPendingPersistentClient(record, options) {
|
||||
const pendingClient = this.pendingPersistentClients.get(record.acpxRecordId);
|
||||
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 @@
|
||||
@@ -1532,7 +1532,7 @@ const ZED_TAG_KEYS = /* @__PURE__ */ new Set([
|
||||
"RedactedThinking",
|
||||
"ToolUse"
|
||||
]);
|
||||
|
|
@ -41,10 +11,16 @@
|
|||
const OPAQUE_VALUE_PATHS = /* @__PURE__ */ new Set([
|
||||
"agent_capabilities",
|
||||
"messages.Agent.content.ToolUse.input",
|
||||
@@ -2562,1 +2562,1 @@
|
||||
@@ -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 {
|
||||
@@ -3960,6 +3960,10 @@
|
||||
current: state.current,
|
||||
quote: state.quote,
|
||||
escaping: true,
|
||||
@@ -3960,6 +3960,10 @@ var AcpClient = class {
|
||||
const startupStderr = [];
|
||||
child.stderr.on("data", (chunk) => {
|
||||
this.captureStartupStderr(startupStderr, chunk);
|
||||
|
|
@ -55,3 +31,52 @@
|
|||
if (!this.options.verbose) return;
|
||||
process.stderr.write(chunk);
|
||||
});
|
||||
@@ -3994,7 +3998,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)
|
||||
};
|
||||
}
|
||||
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,8 @@ type AcpRuntimeOptions = {
|
||||
timeoutMs?: number;
|
||||
probeAgent?: string;
|
||||
verbose?: boolean;
|
||||
+ onAgentStderr?: (chunk: string) => void;
|
||||
+ spawnCwd?: string;
|
||||
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, spawnCwd: this.options.spawnCwd };
|
||||
+ 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,8 @@ type AcpClientOptions = {
|
||||
terminal?: boolean;
|
||||
suppressSdkConsoleErrors?: boolean;
|
||||
verbose?: boolean;
|
||||
+ onAgentStderr?: (chunk: string) => void;
|
||||
+ spawnCwd?: string;
|
||||
sessionOptions?: {
|
||||
model?: string;
|
||||
allowedTools?: string[];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
#!/usr/bin/env node
|
||||
// A minimal ACP agent fixture that reports, on its stderr, the working
|
||||
// directory the host actually spawned it in (`process.cwd()`) and the `cwd`
|
||||
// advertised on the `session/new` request. Used by the remote-lane host-spawn
|
||||
// smoke test to prove the `spawnCwd` decoupling: the host `spawn()` chdir is
|
||||
// redirected to a host-valid dir while the advertised session cwd is unchanged.
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createInterface } from "node:readline";
|
||||
|
||||
function writeMessage(message) {
|
||||
process.stdout.write(`${JSON.stringify(message)}\n`);
|
||||
}
|
||||
|
||||
// Emit the real spawn cwd as early as possible so a consumer capturing stderr
|
||||
// sees it even if the session never advances past initialize.
|
||||
process.stderr.write(`SPAWN_CWD=${process.cwd()}\n`);
|
||||
|
||||
async function handleRequest(request) {
|
||||
if (request.method === "initialize") {
|
||||
process.stderr.write("paperclip-acp-cwd-report-agent started\n");
|
||||
return {
|
||||
protocolVersion: 1,
|
||||
agentCapabilities: { loadSession: false, sessionCapabilities: { close: {} } },
|
||||
agentInfo: { name: "paperclip-acp-cwd-report-agent", version: "1.0.0" },
|
||||
};
|
||||
}
|
||||
if (request.method === "session/new") {
|
||||
process.stderr.write(`SESSION_NEW_CWD=${request.params?.cwd ?? ""}\n`);
|
||||
return { sessionId: randomUUID() };
|
||||
}
|
||||
if (request.method === "session/prompt") {
|
||||
writeMessage({
|
||||
jsonrpc: "2.0",
|
||||
method: "session/update",
|
||||
params: {
|
||||
sessionId: request.params.sessionId,
|
||||
update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "ok" } },
|
||||
},
|
||||
});
|
||||
return { stopReason: "end_turn" };
|
||||
}
|
||||
if (request.method === "session/close" || request.method === "session/set_mode" || request.method === "session/set_config_option") return {};
|
||||
if (request.method === "session/cancel") return null;
|
||||
throw new Error(`Unsupported ACP method: ${request.method}`);
|
||||
}
|
||||
|
||||
const lines = createInterface({ input: process.stdin });
|
||||
lines.on("line", async (line) => {
|
||||
let request;
|
||||
try {
|
||||
request = JSON.parse(line);
|
||||
const result = await handleRequest(request);
|
||||
if (request.id !== undefined && result !== null) writeMessage({ jsonrpc: "2.0", id: request.id, result });
|
||||
} catch (error) {
|
||||
if (request?.id !== undefined) {
|
||||
writeMessage({ jsonrpc: "2.0", id: request.id, error: { code: -32603, message: String(error?.message ?? error) } });
|
||||
}
|
||||
}
|
||||
});
|
||||
Loading…
Reference in New Issue