diff --git a/packages/coding-agent/src/eval/__tests__/js-context-manager.test.ts b/packages/coding-agent/src/eval/__tests__/js-context-manager.test.ts index e572dca6c..08a2d9aa8 100644 --- a/packages/coding-agent/src/eval/__tests__/js-context-manager.test.ts +++ b/packages/coding-agent/src/eval/__tests__/js-context-manager.test.ts @@ -1,4 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as path from "node:path"; import { TempDir } from "@oh-my-pi/pi-utils"; import { Settings } from "../../config/settings"; import type { ToolSession } from "../../tools"; @@ -330,6 +332,33 @@ describe.skipIf(process.platform === "win32")("JavaScript eval process isolation expect(reused.output.trim()).toBe("42"); }); + it("mirrors the session cwd onto the subprocess's real cwd", async () => { + using tempDir = TempDir.createSync("@omp-js-process-cwd-"); + const session = makeSession(tempDir.path()); + const evalSessionId = `js-cwd:${crypto.randomUUID()}`; + const result = await executeJs("return process.cwd();", { + cwd: tempDir.path(), + sessionId: evalSessionId, + session, + }); + // process.chdir resolves symlinks (macOS tempdirs live under /var -> + // /private/var), so compare physical paths. + expect(result.output.trim()).toBe(fs.realpathSync(tempDir.path())); + }); + + it("still runs cells when the session cwd does not exist", async () => { + using tempDir = TempDir.createSync("@omp-js-process-cwd-missing-"); + const missingCwd = path.join(tempDir.path(), "deleted"); + const session = makeSession(missingCwd); + const result = await executeJs("return String(6 * 7);", { + cwd: missingCwd, + sessionId: `js-cwd-missing:${crypto.randomUUID()}`, + session, + }); + expect(result.exitCode).toBe(0); + expect(result.output.trim()).toBe("42"); + }); + it("keeps the isolated process alive after a stackless floated rejection", async () => { using tempDir = TempDir.createSync("@omp-js-process-rejection-"); const session = makeSession(tempDir.path()); diff --git a/packages/coding-agent/src/eval/js/process-entry.ts b/packages/coding-agent/src/eval/js/process-entry.ts index 2308118db..7a30392be 100644 --- a/packages/coding-agent/src/eval/js/process-entry.ts +++ b/packages/coding-agent/src/eval/js/process-entry.ts @@ -14,6 +14,14 @@ export function startJsEvalProcess(transport: { // WorkerCore `closed` acknowledgement has crossed IPC. close: () => {}, }, - { mode: "isolated" }, + { + mode: "isolated", + // The subprocess starts with its real cwd at the worker-host entry dir + // (a `resolveWorkerSpawnCmd` requirement); mirror the session cwd so + // cell code using relative paths or spawning children resolves against + // the project instead of the install dir. Worker threads cannot pass + // this — `process.chdir` is unavailable there. + chdir: cwd => process.chdir(cwd), + }, ); } diff --git a/packages/coding-agent/src/eval/js/worker-core.ts b/packages/coding-agent/src/eval/js/worker-core.ts index 00bdf5302..354f0c981 100644 --- a/packages/coding-agent/src/eval/js/worker-core.ts +++ b/packages/coding-agent/src/eval/js/worker-core.ts @@ -26,7 +26,17 @@ interface ActiveRun { type RunResult = Extract; export type WorkerCoreOptions = - | { mode: "isolated" } + | { + mode: "isolated"; + /** + * Mirror the session cwd onto the real process cwd so cell code using + * `process.cwd()`, relative paths, or child processes without an explicit + * `cwd` resolves against the project. Only the dedicated subprocess may + * pass this: `process.chdir` is unavailable in Worker threads and would + * mutate the host's own cwd on the inline fallback. + */ + chdir?: (cwd: string) => void; + } | { mode: "inline"; interceptUnhandledRejections(handler: (reason: unknown) => boolean): () => void; @@ -214,6 +224,7 @@ export class WorkerCore { } #ensureRuntime(snapshot: SessionSnapshot): JsRuntime { + this.#syncProcessCwd(snapshot.cwd); if (this.#runtime) { this.#runtime.setCwd(snapshot.cwd); return this.#runtime; @@ -226,6 +237,22 @@ export class WorkerCore { return this.#runtime; } + #syncProcessCwd(cwd: string): void { + if (this.#options.mode !== "isolated" || !this.#options.chdir) return; + try { + this.#options.chdir(cwd); + } catch (error) { + // `process.chdir` throws when the session cwd no longer exists; keep + // the cell on the runtime's virtual cwd instead of failing the run. + this.#transport.send({ + type: "log", + level: "warn", + msg: "JS eval subprocess could not enter the session cwd", + meta: { cwd, error: errorPayload(error) }, + }); + } + } + async #runOne(runId: string, code: string, filename: string, snapshot: SessionSnapshot): Promise { const active: ActiveRun = { runId, filename, pendingTools: new Map(), floatingRejections: [] }; this.#runs.set(runId, active);