From de619bd33450a9869395eaeb25fe5c4d6e034e36 Mon Sep 17 00:00:00 2001 From: Max <68940212+AS9929@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:39:33 +0800 Subject: [PATCH] fix(ida): canonicalize the headless workspace root before opening (#1481) On macOS the default workspace root is os.tmpdir() under /var/folders, and /var is a symlink to /private/var. idalib-mcp reports the canonical input path from idb_open/idb_list, while REA compared it against the uncanonicalized private copy with normalize(), so every headless IDA analysis failed its identity check (surfacing as unreadable_output) and the spawned idalib worker was never released. Resolve the workspace root with realpath() when allocating the private workspace so the input path REA sends already matches what upstream reports. Co-authored-by: Xingchuan Ma Co-authored-by: Claude Opus 5.5 --- src/ida/IdaWorkspace.ts | 6 ++++-- src/ida/IdaWorkspaceOwnership.test.ts | 24 +++++++++++++++++++++++- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/ida/IdaWorkspace.ts b/src/ida/IdaWorkspace.ts index 38e0c5d46..009754d35 100644 --- a/src/ida/IdaWorkspace.ts +++ b/src/ida/IdaWorkspace.ts @@ -1,6 +1,6 @@ import { createHash, randomUUID } from "node:crypto"; import { createReadStream, constants } from "node:fs"; -import { copyFile } from "node:fs/promises"; +import { copyFile, realpath } from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, join, normalize } from "node:path"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; @@ -39,8 +39,10 @@ export class IdaWorkspace { target: BinaryTarget, root = tmpdir(), ): Promise { + // Upstream reports canonical paths (macOS /var -> /private/var), so the + // private input path must already be canonical for identity checks. const runtime = await PrivateRuntimeRoot.create({ - parent: normalize(root), + parent: await realpath(normalize(root)), prefix: "rea-ida-", }); const inputPath = join(runtime.path, basename(target.path)); diff --git a/src/ida/IdaWorkspaceOwnership.test.ts b/src/ida/IdaWorkspaceOwnership.test.ts index b416b7ab0..b39c6e916 100644 --- a/src/ida/IdaWorkspaceOwnership.test.ts +++ b/src/ida/IdaWorkspaceOwnership.test.ts @@ -1,9 +1,11 @@ -import { chmod, readdir, rm } from "node:fs/promises"; +import { chmod, readdir, realpath, rm, symlink } from "node:fs/promises"; +import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { createIdaTarget, RecordingIdaMcp, } from "../../tests/fixtures/idaMcp.js"; +import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { IdaSessionClient } from "./IdaSessionClient.js"; @@ -33,6 +35,26 @@ const clientFor = (root: string, target: BinaryTarget) => { }; describe("IDA workspace ownership before input preparation", () => { + it.skipIf(process.platform === "win32")( + "opens the private input through a canonical path when the workspace root is a symlink", + async () => { + const { root, target } = await createIdaTarget(); + roots.push(root); + const linked = join( + await createTestTempDirectory("rea-ida-link-"), + "root", + ); + await symlink(root, linked); + const { client, producer } = clientFor(linked, target); + expect((await client.execute("health", {})).ok).toBe(true); + const open = producer.calls.find(({ name }) => name === "idb_open"); + expect( + String(open?.args.input_path).startsWith(`${await realpath(root)}/`), + ).toBe(true); + expect((await client.close()).ok).toBe(true); + }, + ); + it("retains cleanup ownership when copying the admitted input fails", async () => { const { root, target } = await createIdaTarget(); roots.push(root);