From bb72c205be3699a6046153dd8f99a3fb6b0924d3 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:12:25 -0700 Subject: [PATCH 01/49] fix(mcp): reject unsafe Deep Agents projection types Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- docs/manage-sandboxes/manage-mcp-servers.mdx | 9 +++ ...idge-adapter-deepagents-projection.test.ts | 22 ++++++- .../sandbox/mcp-bridge-adapter-status.ts | 19 ++++++ .../actions/sandbox/mcp-bridge-adapters.ts | 1 + .../mcp-bridge-status-resolution.test.ts | 58 +++++++++++++++++++ src/lib/actions/sandbox/mcp-bridge-status.ts | 19 ++++-- .../mcp-bridge-adapter-deepagents-fixture.ts | 13 +++-- 7 files changed, 129 insertions(+), 12 deletions(-) diff --git a/docs/manage-sandboxes/manage-mcp-servers.mdx b/docs/manage-sandboxes/manage-mcp-servers.mdx index 4cc214d9d3c..ab0d6e96b4b 100644 --- a/docs/manage-sandboxes/manage-mcp-servers.mdx +++ b/docs/manage-sandboxes/manage-mcp-servers.mdx @@ -37,6 +37,15 @@ An existing valid provider can remain ready when a host variable is unset becaus For a trusted private server, status resolves the endpoint without changing managed state. Text output reports `private address pins: match`, `drift`, or `unresolved`. JSON output reports the same value in `trustedPrivateTarget.state` and includes the recorded pins. Status never adds a new address to the policy. + + +Before it reads `/sandbox/.deepagents/.nemoclaw-mcp.json`, Deep Agents Code `mcp status` checks the path type without following a symbolic link or opening a FIFO. +If the path is a symbolic link or FIFO, the command prints `Unsafe managed Deep Agents MCP projection path: symbolic link` or `Unsafe managed Deep Agents MCP projection path: FIFO`, writes no status output, and exits with status `2`. +This exit status applies only to the unsafe path-type diagnostic. +Other adapter inspection failures retain their ordinary status behavior. + + + ## Discover Advertised Tools Pass `--tools` with one server name to request the server's advertised tool names: diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts index da848e4ff0f..b59687ea82b 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts @@ -74,7 +74,7 @@ describe("Deep Agents managed MCP projection safety", () => { ); }); - it("fails status inspection closed without following hostile projection paths", () => { + it("names unsafe projection types without following hostile paths (#10754)", () => { const statusCommand = buildDeepAgentsMcpStatusCommand(baseEntry); expect(statusCommand).toContain("os.O_NONBLOCK | os.O_NOFOLLOW"); expect(statusCommand).not.toContain("config_path.read_text"); @@ -88,7 +88,9 @@ describe("Deep Agents managed MCP projection safety", () => { ); expect(symlink.status).toBe(2); expect(symlink.stdout.trim()).toBe(""); - expect(symlink.stderr).toContain("Could not inspect managed Deep Agents MCP state"); + expect(symlink.stderr).toContain( + "Unsafe managed Deep Agents MCP projection path: symbolic link", + ); expect(symlink.managedSymlinkTargetText).toBe(`${JSON.stringify(emptyProjection, null, 2)}\n`); const fifo = runDeepAgentsConfigCommand(statusCommand, undefined, "v2", undefined, 0o600, { @@ -96,7 +98,21 @@ describe("Deep Agents managed MCP projection safety", () => { }); expect(fifo.status).toBe(2); expect(fifo.stdout.trim()).toBe(""); - expect(fifo.stderr).toContain("Could not inspect managed Deep Agents MCP state"); + expect(fifo.stderr).toContain("Unsafe managed Deep Agents MCP projection path: FIFO"); + + const danglingSymlink = runDeepAgentsConfigCommand( + statusCommand, + undefined, + "v2", + undefined, + 0o600, + { danglingSymlink: true }, + ); + expect(danglingSymlink.status).toBe(2); + expect(danglingSymlink.stdout.trim()).toBe(""); + expect(danglingSymlink.stderr).toContain( + "Unsafe managed Deep Agents MCP projection path: symbolic link", + ); }); it.each([ diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-status.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-status.ts index 03518cab344..42eba383d8a 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-status.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-status.ts @@ -16,6 +16,8 @@ import { // `.mcp.json` discovery is disabled in the managed image so user-authored MCP // state can never be layered over the validated registry projection. export const DEEPAGENTS_MCP_CONFIG_PATH = "/sandbox/.deepagents/.nemoclaw-mcp.json"; +export const DEEPAGENTS_UNSAFE_MCP_PROJECTION_PREFIX = + "Unsafe managed Deep Agents MCP projection path"; export const DEFAULT_OPENCLAW_CONFIG_DIR = "/sandbox/.openclaw"; export const HERMES_MCP_TRANSACTION_HELPER = "/usr/local/lib/nemoclaw/hermes-mcp-config-transaction.py"; @@ -258,6 +260,23 @@ export function buildDeepAgentsMcpStatusCommand( " raise SystemExit(2)", "is_v2 = runtime_kind == 'v2'", "config_path = managed_path if is_v2 else legacy_path", + "if is_v2:", + " try:", + " projection_mode = os.stat(config_path, follow_symlinks=False).st_mode", + " except FileNotFoundError:", + " projection_mode = None", + " if projection_mode is not None:", + " if stat.S_ISLNK(projection_mode):", + " projection_kind = 'symbolic link'", + " elif stat.S_ISFIFO(projection_mode):", + " projection_kind = 'FIFO'", + " elif not stat.S_ISREG(projection_mode):", + " projection_kind = 'non-regular file'", + " else:", + " projection_kind = None", + " if projection_kind:", + ` print(f'${DEEPAGENTS_UNSAFE_MCP_PROJECTION_PREFIX}: {projection_kind}', file=sys.stderr)`, + " raise SystemExit(2)", "try:", " data = read_managed_projection(config_path)[0] if is_v2 else read_legacy_config(config_path)[0]", "except FileNotFoundError:", diff --git a/src/lib/actions/sandbox/mcp-bridge-adapters.ts b/src/lib/actions/sandbox/mcp-bridge-adapters.ts index a4fec84fc61..afc9657ce6e 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapters.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapters.ts @@ -62,6 +62,7 @@ export { buildOpenClawMcporterInspectCommand, DEFAULT_OPENCLAW_CONFIG_DIR, DEEPAGENTS_MCP_CONFIG_PATH, + DEEPAGENTS_UNSAFE_MCP_PROJECTION_PREFIX, mcporterHeadersMatchExpected, openClawMcporterRoot, } from "./mcp-bridge-adapter-status"; diff --git a/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts b/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts index 09c024b10b2..18e6dc28cb6 100644 --- a/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts @@ -190,6 +190,64 @@ ${body} } describe("MCP status wire-level credential-resolution probe", { timeout: 15_000 }, () => { + it("returns a type-specific failure for unsafe Deep Agents projections (#10754)", () => { + const home = createTempHome("nemoclaw-mcp-status-projection-"); + const { stdout } = runHarness( + home, + String.raw` + const current = registry.getSandbox("alpha"); + registry.updateSandbox("alpha", { + agent: "langchain-deepagents-code", + mcp: { + bridges: { + github: { + ...current.mcp.bridges.github, + agent: "langchain-deepagents-code", + adapter: "deepagents-config", + }, + }, + managedServerNames: ["github"], + }, + }); + const outcomes = []; + for (const detail of [ + "Unsafe managed Deep Agents MCP projection path: symbolic link", + "Unsafe managed Deep Agents MCP projection path: FIFO", + ]) { + processRecovery.executeSandboxCommand = () => ({ + status: 2, + stdout: "", + stderr: detail + "\n", + }); + logLines.length = 0; + errorLines.length = 0; + await bridge.dispatchMcpBridgeCommand("alpha", ["status", "github", "--no-probe", "--json"]); + outcomes.push({ + detail, + exitCode: process.exitCode ?? 0, + stdout: logLines.join("\n"), + stderr: errorLines.join("\n"), + }); + process.exitCode = 0; + } + process.stdout.write(JSON.stringify(outcomes)); +`, + ); + const outcomes = JSON.parse(stdout) as Array<{ + detail: string; + exitCode: number; + stdout: string; + stderr: string; + }>; + + expect(outcomes).toHaveLength(2); + outcomes.forEach((outcome) => { + expect(outcome.exitCode).toBe(2); + expect(outcome.stdout).toBe(""); + expect(outcome.stderr).toContain(outcome.detail); + }); + }); + it("probes by default for a single named server and surfaces the wire failure (#6379)", () => { const home = createTempHome("nemoclaw-mcp-resolution-single-"); const { stdout } = runHarness( diff --git a/src/lib/actions/sandbox/mcp-bridge-status.ts b/src/lib/actions/sandbox/mcp-bridge-status.ts index a4580968b40..a030014be30 100644 --- a/src/lib/actions/sandbox/mcp-bridge-status.ts +++ b/src/lib/actions/sandbox/mcp-bridge-status.ts @@ -8,6 +8,7 @@ import { buildHermesMcpStatusCommand, buildOpenClawMcporterInspectCommand, DEFAULT_OPENCLAW_CONFIG_DIR, + DEEPAGENTS_UNSAFE_MCP_PROJECTION_PREFIX, openClawMcporterRoot, } from "./mcp-bridge-adapters"; import { isAgentMcpAdapter, McpBridgeError, type McpBridgeStatus } from "./mcp-bridge-contracts"; @@ -126,13 +127,21 @@ function getAdapterRegistration( return { registered: false, detail: output || "not found" }; } const envValues = resolvePersistedCredentialEnvForRedaction(entry.env); + const detail = redactBridgeSecretsForDisplay( + result.stderr || result.stdout || "not found", + entry, + envValues, + ); + const normalizedDetail = detail.trim(); + if ( + adapter === "deepagents-config" && + normalizedDetail.startsWith(DEEPAGENTS_UNSAFE_MCP_PROJECTION_PREFIX) + ) { + throw new McpBridgeError(normalizedDetail, 2); + } return { registered: false, - detail: redactBridgeSecretsForDisplay( - result.stderr || result.stdout || "not found", - entry, - envValues, - ), + detail, }; } diff --git a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts index 6d869d0a57b..9a1d799c57c 100644 --- a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts +++ b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts @@ -35,6 +35,7 @@ export interface DeepAgentsConfigCommandResult { } export interface DeepAgentsManagedFixtureOptions { + danglingSymlink?: boolean; fifo?: boolean; mode?: number; symlink?: boolean; @@ -65,16 +66,20 @@ export function runDeepAgentsConfigCommand( { mode }, ); }; - const managedInitialPath = managedOptions.symlink ? managedSymlinkTarget : configPath; + const managedSymlink = managedOptions.symlink === true || managedOptions.danglingSymlink === true; + const managedInitialPath = managedSymlink ? managedSymlinkTarget : configPath; if (managedOptions.fifo) { fs.mkdirSync(path.dirname(configPath), { recursive: true }); const fifo = spawnSync("mkfifo", [configPath], { encoding: "utf-8", timeout: 5000 }); if (fifo.status !== 0) throw new Error(fifo.stderr || "could not create managed fixture FIFO"); fs.chmodSync(configPath, managedOptions.mode ?? 0o600); } else { - initializeConfig(managedInitialPath, initialConfig, managedOptions.mode); - if (initialConfig !== undefined) fs.chmodSync(managedInitialPath, managedOptions.mode ?? 0o600); - if (managedOptions.symlink && initialConfig !== undefined) { + if (!managedOptions.danglingSymlink) { + initializeConfig(managedInitialPath, initialConfig, managedOptions.mode); + if (initialConfig !== undefined) + fs.chmodSync(managedInitialPath, managedOptions.mode ?? 0o600); + } + if (managedSymlink) { fs.mkdirSync(path.dirname(configPath), { recursive: true }); fs.symlinkSync(managedSymlinkTarget, configPath); } From bd8b2a178f5a7229a5a2f4fd55e445e89fdae188 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:28:32 -0700 Subject: [PATCH 02/49] fix(mcp): preserve Deep Agents status failures Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- docs/manage-sandboxes/manage-mcp-servers.mdx | 3 +- ...idge-adapter-deepagents-projection.test.ts | 28 +++++++++++++++ .../sandbox/mcp-bridge-adapter-status.ts | 34 +++++++++---------- .../mcp-bridge-adapter-deepagents-fixture.ts | 11 ++++-- 4 files changed, 56 insertions(+), 20 deletions(-) diff --git a/docs/manage-sandboxes/manage-mcp-servers.mdx b/docs/manage-sandboxes/manage-mcp-servers.mdx index ab0d6e96b4b..fa5c3f7dd1c 100644 --- a/docs/manage-sandboxes/manage-mcp-servers.mdx +++ b/docs/manage-sandboxes/manage-mcp-servers.mdx @@ -40,7 +40,8 @@ For a trusted private server, status resolves the endpoint without changing mana Before it reads `/sandbox/.deepagents/.nemoclaw-mcp.json`, Deep Agents Code `mcp status` checks the path type without following a symbolic link or opening a FIFO. -If the path is a symbolic link or FIFO, the command prints `Unsafe managed Deep Agents MCP projection path: symbolic link` or `Unsafe managed Deep Agents MCP projection path: FIFO`, writes no status output, and exits with status `2`. +If the path is a symbolic link, FIFO, or another non-regular file, the command names the path type, writes no status output, and exits with status `2`. +The diagnostics are `Unsafe managed Deep Agents MCP projection path: symbolic link`, `Unsafe managed Deep Agents MCP projection path: FIFO`, and `Unsafe managed Deep Agents MCP projection path: non-regular file`. This exit status applies only to the unsafe path-type diagnostic. Other adapter inspection failures retain their ordinary status behavior. diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts index b59687ea82b..cd293e76d44 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts @@ -100,6 +100,20 @@ describe("Deep Agents managed MCP projection safety", () => { expect(fifo.stdout.trim()).toBe(""); expect(fifo.stderr).toContain("Unsafe managed Deep Agents MCP projection path: FIFO"); + const directory = runDeepAgentsConfigCommand( + statusCommand, + undefined, + "v2", + undefined, + 0o600, + { directory: true }, + ); + expect(directory.status).toBe(2); + expect(directory.stdout.trim()).toBe(""); + expect(directory.stderr).toContain( + "Unsafe managed Deep Agents MCP projection path: non-regular file", + ); + const danglingSymlink = runDeepAgentsConfigCommand( statusCommand, undefined, @@ -115,6 +129,20 @@ describe("Deep Agents managed MCP projection safety", () => { ); }); + it("keeps the generic diagnostic for ordinary projection inspection failures (#10754)", () => { + const statusCommand = buildDeepAgentsMcpStatusCommand(baseEntry).replace( + " projection_mode = os.stat(config_path, follow_symlinks=False).st_mode", + " raise PermissionError('fixture denied')", + ); + const result = runDeepAgentsConfigCommand(statusCommand, emptyProjection); + + expect(result.status).toBe(2); + expect(result.stdout.trim()).toBe(""); + expect(result.stderr).toContain("Could not inspect managed Deep Agents MCP state"); + expect(result.stderr).toContain("fixture denied"); + expect(result.stderr).not.toContain("Traceback"); + }); + it.each([ ["registration", registrationCommand], ["v2 rollback", rollbackCommand], diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-status.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-status.ts index 42eba383d8a..510272737ed 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-status.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-status.ts @@ -260,24 +260,24 @@ export function buildDeepAgentsMcpStatusCommand( " raise SystemExit(2)", "is_v2 = runtime_kind == 'v2'", "config_path = managed_path if is_v2 else legacy_path", - "if is_v2:", - " try:", - " projection_mode = os.stat(config_path, follow_symlinks=False).st_mode", - " except FileNotFoundError:", - " projection_mode = None", - " if projection_mode is not None:", - " if stat.S_ISLNK(projection_mode):", - " projection_kind = 'symbolic link'", - " elif stat.S_ISFIFO(projection_mode):", - " projection_kind = 'FIFO'", - " elif not stat.S_ISREG(projection_mode):", - " projection_kind = 'non-regular file'", - " else:", - " projection_kind = None", - " if projection_kind:", - ` print(f'${DEEPAGENTS_UNSAFE_MCP_PROJECTION_PREFIX}: {projection_kind}', file=sys.stderr)`, - " raise SystemExit(2)", "try:", + " if is_v2:", + " try:", + " projection_mode = os.stat(config_path, follow_symlinks=False).st_mode", + " except FileNotFoundError:", + " projection_mode = None", + " if projection_mode is not None:", + " if stat.S_ISLNK(projection_mode):", + " projection_kind = 'symbolic link'", + " elif stat.S_ISFIFO(projection_mode):", + " projection_kind = 'FIFO'", + " elif not stat.S_ISREG(projection_mode):", + " projection_kind = 'non-regular file'", + " else:", + " projection_kind = None", + " if projection_kind:", + ` print(f'${DEEPAGENTS_UNSAFE_MCP_PROJECTION_PREFIX}: {projection_kind}', file=sys.stderr)`, + " raise SystemExit(2)", " data = read_managed_projection(config_path)[0] if is_v2 else read_legacy_config(config_path)[0]", "except FileNotFoundError:", " data = {}", diff --git a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts index 9a1d799c57c..d2a7836f720 100644 --- a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts +++ b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts @@ -36,6 +36,7 @@ export interface DeepAgentsConfigCommandResult { export interface DeepAgentsManagedFixtureOptions { danglingSymlink?: boolean; + directory?: boolean; fifo?: boolean; mode?: number; symlink?: boolean; @@ -68,7 +69,9 @@ export function runDeepAgentsConfigCommand( }; const managedSymlink = managedOptions.symlink === true || managedOptions.danglingSymlink === true; const managedInitialPath = managedSymlink ? managedSymlinkTarget : configPath; - if (managedOptions.fifo) { + if (managedOptions.directory) { + fs.mkdirSync(configPath, { recursive: true, mode: managedOptions.mode ?? 0o700 }); + } else if (managedOptions.fifo) { fs.mkdirSync(path.dirname(configPath), { recursive: true }); const fifo = spawnSync("mkfifo", [configPath], { encoding: "utf-8", timeout: 5000 }); if (fifo.status !== 0) throw new Error(fifo.stderr || "could not create managed fixture FIFO"); @@ -99,7 +102,11 @@ export function runDeepAgentsConfigCommand( const configExists = fs.existsSync(configPath); const legacyConfigExists = fs.existsSync(legacyConfigPath); const configIsFifo = configExists && fs.lstatSync(configPath).isFIFO(); - const configText = configExists && !configIsFifo ? fs.readFileSync(configPath, "utf-8") : null; + const configIsDirectory = configExists && fs.lstatSync(configPath).isDirectory(); + const configText = + configExists && !configIsFifo && !configIsDirectory + ? fs.readFileSync(configPath, "utf-8") + : null; const managedSymlinkTargetExists = fs.existsSync(managedSymlinkTarget); const managedSymlinkTargetText = managedSymlinkTargetExists ? fs.readFileSync(managedSymlinkTarget, "utf-8") From c18198013540b1e3e97fb13286b35b9dffb3176f Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:12:40 -0700 Subject: [PATCH 03/49] fix(mcp): classify projection open races Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- ...idge-adapter-deepagents-projection.test.ts | 45 ++++++++++++++++--- ...cp-bridge-adapter-deepagents-projection.ts | 23 ++++++++-- .../sandbox/mcp-bridge-adapter-status.ts | 19 ++++---- .../mcp-bridge-adapter-deepagents-fixture.ts | 45 +++++++++++++++++-- 4 files changed, 108 insertions(+), 24 deletions(-) diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts index cd293e76d44..da5e341fb35 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts @@ -76,8 +76,6 @@ describe("Deep Agents managed MCP projection safety", () => { it("names unsafe projection types without following hostile paths (#10754)", () => { const statusCommand = buildDeepAgentsMcpStatusCommand(baseEntry); - expect(statusCommand).toContain("os.O_NONBLOCK | os.O_NOFOLLOW"); - expect(statusCommand).not.toContain("config_path.read_text"); const symlink = runDeepAgentsConfigCommand( statusCommand, emptyProjection, @@ -127,20 +125,53 @@ describe("Deep Agents managed MCP projection safety", () => { expect(danglingSymlink.stderr).toContain( "Unsafe managed Deep Agents MCP projection path: symbolic link", ); + + const racedSymlink = runDeepAgentsConfigCommand( + statusCommand, + emptyProjection, + "v2", + undefined, + 0o600, + { swapOnManagedOpen: "symlink" }, + ); + expect(racedSymlink.status).toBe(2); + expect(racedSymlink.stdout.trim()).toBe(""); + expect(racedSymlink.stderr).toContain( + "Unsafe managed Deep Agents MCP projection path: symbolic link", + ); + expect(racedSymlink.configIsSymlink).toBe(true); + expect(racedSymlink.managedSymlinkTargetText).toBe( + `${JSON.stringify(emptyProjection, null, 2)}\n`, + ); + + const racedFifo = runDeepAgentsConfigCommand( + statusCommand, + emptyProjection, + "v2", + undefined, + 0o600, + { swapOnManagedOpen: "fifo" }, + ); + expect(racedFifo.status).toBe(2); + expect(racedFifo.stdout.trim()).toBe(""); + expect(racedFifo.stderr).toContain( + "Unsafe managed Deep Agents MCP projection path: FIFO", + ); + expect(racedFifo.configIsFifo).toBe(true); }); it("keeps the generic diagnostic for ordinary projection inspection failures (#10754)", () => { - const statusCommand = buildDeepAgentsMcpStatusCommand(baseEntry).replace( - " projection_mode = os.stat(config_path, follow_symlinks=False).st_mode", - " raise PermissionError('fixture denied')", + const result = runDeepAgentsConfigCommand( + buildDeepAgentsMcpStatusCommand(baseEntry), + '{"mcpServers":', ); - const result = runDeepAgentsConfigCommand(statusCommand, emptyProjection); expect(result.status).toBe(2); expect(result.stdout.trim()).toBe(""); expect(result.stderr).toContain("Could not inspect managed Deep Agents MCP state"); - expect(result.stderr).toContain("fixture denied"); + expect(result.stderr).toContain("Expecting value"); expect(result.stderr).not.toContain("Traceback"); + expect(result.configText).toBe('{"mcpServers":'); }); it.each([ diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts index 357bfcfbbc3..e22c242b9e9 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts @@ -18,7 +18,16 @@ export const DEEPAGENTS_STRICT_JSON_HELPERS = [ ]; export const DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS = [ + "import errno", "MANAGED_MCP_MAX_BYTES = 262144", + "def managed_projection_path_kind(mode):", + " if stat.S_ISLNK(mode):", + " return 'symbolic link'", + " if stat.S_ISFIFO(mode):", + " return 'FIFO'", + " if not stat.S_ISREG(mode):", + " return 'non-regular file'", + " return None", "def managed_fingerprint(metadata):", " return (metadata.st_dev, metadata.st_ino, metadata.st_size, metadata.st_mtime_ns, metadata.st_ctime_ns, metadata.st_mode, metadata.st_nlink, metadata.st_uid)", "def managed_path_identity(path):", @@ -41,7 +50,7 @@ export const DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS = [ " if not safe:", " raise ValueError('managed MCP projection has unsafe ownership, mode, type, links, or path identity')", " return managed_fingerprint(opened)", - "def open_managed_projection(path, writable=False):", + "def open_managed_projection(path, writable=False, unsafe_path_prefix=None):", " access = os.O_RDWR if writable else os.O_RDONLY", " flags = access | os.O_CLOEXEC | os.O_NONBLOCK | os.O_NOFOLLOW", " try:", @@ -49,8 +58,16 @@ export const DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS = [ " except FileNotFoundError:", " assert_managed_source_stable(path, None)", " return b'', None, None", + " except OSError as exc:", + " if unsafe_path_prefix and exc.errno == errno.ELOOP:", + " raise ValueError(f'{unsafe_path_prefix}: symbolic link') from exc", + " raise", " try:", " before = os.fstat(descriptor)", + " if unsafe_path_prefix:", + " unsafe_path_kind = managed_projection_path_kind(before.st_mode)", + " if unsafe_path_kind:", + " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}')", " validate_managed_descriptor_path(path, descriptor)", " if before.st_size < 0 or before.st_size > MANAGED_MCP_MAX_BYTES:", " raise ValueError('managed MCP projection has invalid size')", @@ -87,8 +104,8 @@ export const DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS = [ " except Exception:", " close_managed_projection_descriptor(descriptor)", " raise", - "def read_managed_projection(path):", - " raw, identity, descriptor = open_managed_projection(path)", + "def read_managed_projection(path, unsafe_path_prefix=None):", + " raw, identity, descriptor = open_managed_projection(path, unsafe_path_prefix=unsafe_path_prefix)", " try:", " return decode_managed_projection(raw), identity", " finally:", diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-status.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-status.ts index 510272737ed..d82f323f7aa 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-status.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-status.ts @@ -267,21 +267,20 @@ export function buildDeepAgentsMcpStatusCommand( " except FileNotFoundError:", " projection_mode = None", " if projection_mode is not None:", - " if stat.S_ISLNK(projection_mode):", - " projection_kind = 'symbolic link'", - " elif stat.S_ISFIFO(projection_mode):", - " projection_kind = 'FIFO'", - " elif not stat.S_ISREG(projection_mode):", - " projection_kind = 'non-regular file'", - " else:", - " projection_kind = None", + " projection_kind = managed_projection_path_kind(projection_mode)", " if projection_kind:", ` print(f'${DEEPAGENTS_UNSAFE_MCP_PROJECTION_PREFIX}: {projection_kind}', file=sys.stderr)`, " raise SystemExit(2)", - " data = read_managed_projection(config_path)[0] if is_v2 else read_legacy_config(config_path)[0]", + ` data = read_managed_projection(config_path, ${JSON.stringify(DEEPAGENTS_UNSAFE_MCP_PROJECTION_PREFIX)})[0] if is_v2 else read_legacy_config(config_path)[0]`, "except FileNotFoundError:", " data = {}", - "except (OSError, UnicodeDecodeError, ValueError) as exc:", + "except ValueError as exc:", + ` if str(exc).startswith('${DEEPAGENTS_UNSAFE_MCP_PROJECTION_PREFIX}: '):`, + " print(str(exc), file=sys.stderr)", + " raise SystemExit(2)", + " print(f'Could not inspect managed Deep Agents MCP state at {config_path}: {exc}', file=sys.stderr)", + " raise SystemExit(2)", + "except (OSError, UnicodeDecodeError) as exc:", " print(f'Could not inspect managed Deep Agents MCP state at {config_path}: {exc}', file=sys.stderr)", " raise SystemExit(2)", "servers = data.get('mcpServers') if isinstance(data, dict) else None", diff --git a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts index d2a7836f720..d0ecf4b6818 100644 --- a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts +++ b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts @@ -25,6 +25,8 @@ export interface DeepAgentsConfigCommandResult { stdout: string; stderr: string; configExists: boolean; + configIsFifo: boolean; + configIsSymlink: boolean; config: Record | null; configText: string | null; legacyConfigExists: boolean; @@ -39,6 +41,7 @@ export interface DeepAgentsManagedFixtureOptions { directory?: boolean; fifo?: boolean; mode?: number; + swapOnManagedOpen?: "fifo" | "symlink"; symlink?: boolean; } @@ -87,10 +90,35 @@ export function runDeepAgentsConfigCommand( fs.symlinkSync(managedSymlinkTarget, configPath); } } + if (managedOptions.swapOnManagedOpen === "symlink") { + initializeConfig(managedSymlinkTarget, initialConfig, managedOptions.mode); + } initializeConfig(legacyConfigPath, initialLegacyConfig); if (initialLegacyConfig !== undefined) fs.chmodSync(legacyConfigPath, initialLegacyMode); try { + const swapOnManagedOpenPrelude = managedOptions.swapOnManagedOpen + ? [ + "import os as _nemoclaw_test_os", + `_nemoclaw_test_path = ${JSON.stringify(configPath)}`, + `_nemoclaw_test_target = ${JSON.stringify(managedSymlinkTarget)}`, + `_nemoclaw_test_swap = ${JSON.stringify(managedOptions.swapOnManagedOpen)}`, + "_nemoclaw_test_real_open = _nemoclaw_test_os.open", + "_nemoclaw_test_swapped = False", + "def _nemoclaw_test_open(path, flags, *args, **kwargs):", + " global _nemoclaw_test_swapped", + " if not _nemoclaw_test_swapped and _nemoclaw_test_os.fspath(path) == _nemoclaw_test_path:", + " _nemoclaw_test_swapped = True", + " _nemoclaw_test_os.unlink(_nemoclaw_test_path)", + " if _nemoclaw_test_swap == 'symlink':", + " _nemoclaw_test_os.symlink(_nemoclaw_test_target, _nemoclaw_test_path)", + " else:", + " _nemoclaw_test_os.mkfifo(_nemoclaw_test_path, 0o600)", + " return _nemoclaw_test_real_open(path, flags, *args, **kwargs)", + "_nemoclaw_test_os.open = _nemoclaw_test_open", + ].join("\n") + : ""; const fixtureCommand = command + .replace("<<'PY'\n", `<<'PY'\n${swapOnManagedOpenPrelude}\n`) .replaceAll(DEEPAGENTS_MCP_CONFIG_PATH, configPath) .replaceAll("/sandbox/.deepagents/.mcp.json", legacyConfigPath) .replaceAll("/opt/venv/bin/python3", "python3") @@ -102,6 +130,7 @@ export function runDeepAgentsConfigCommand( const configExists = fs.existsSync(configPath); const legacyConfigExists = fs.existsSync(legacyConfigPath); const configIsFifo = configExists && fs.lstatSync(configPath).isFIFO(); + const configIsSymlink = configExists && fs.lstatSync(configPath).isSymbolicLink(); const configIsDirectory = configExists && fs.lstatSync(configPath).isDirectory(); const configText = configExists && !configIsFifo && !configIsDirectory @@ -112,17 +141,25 @@ export function runDeepAgentsConfigCommand( ? fs.readFileSync(managedSymlinkTarget, "utf-8") : null; const legacyConfigText = legacyConfigExists ? fs.readFileSync(legacyConfigPath, "utf-8") : null; + const parseConfigText = (text: string | null): Record | null => { + if (!text) return null; + try { + return JSON.parse(text) as Record; + } catch { + return null; + } + }; return { status: result.status, stdout: result.stdout, stderr: result.stderr, configExists, - config: configText ? (JSON.parse(configText) as Record) : null, + configIsFifo, + configIsSymlink, + config: parseConfigText(configText), configText, legacyConfigExists, - legacyConfig: legacyConfigText - ? (JSON.parse(legacyConfigText) as Record) - : null, + legacyConfig: parseConfigText(legacyConfigText), legacyConfigText, managedSymlinkTargetExists, managedSymlinkTargetText, From 00f5a639bf535304d74a44435b4e2f09f29021c8 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:20:52 -0700 Subject: [PATCH 04/49] fix(mcp): classify socket projection races Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- docs/manage-sandboxes/manage-mcp-servers.mdx | 3 ++- ...idge-adapter-deepagents-projection.test.ts | 15 +++++++++++++++ ...cp-bridge-adapter-deepagents-projection.ts | 11 +++++++++-- .../mcp-bridge-adapter-deepagents-fixture.ts | 19 ++++++++++++++----- 4 files changed, 40 insertions(+), 8 deletions(-) diff --git a/docs/manage-sandboxes/manage-mcp-servers.mdx b/docs/manage-sandboxes/manage-mcp-servers.mdx index fa5c3f7dd1c..4fdb898ea95 100644 --- a/docs/manage-sandboxes/manage-mcp-servers.mdx +++ b/docs/manage-sandboxes/manage-mcp-servers.mdx @@ -39,7 +39,8 @@ For a trusted private server, status resolves the endpoint without changing mana -Before it reads `/sandbox/.deepagents/.nemoclaw-mcp.json`, Deep Agents Code `mcp status` checks the path type without following a symbolic link or opening a FIFO. +Before it reads `/sandbox/.deepagents/.nemoclaw-mcp.json`, Deep Agents Code `mcp status` checks the path type. +It does not follow a final symbolic link or read from a FIFO. If the path is a symbolic link, FIFO, or another non-regular file, the command names the path type, writes no status output, and exits with status `2`. The diagnostics are `Unsafe managed Deep Agents MCP projection path: symbolic link`, `Unsafe managed Deep Agents MCP projection path: FIFO`, and `Unsafe managed Deep Agents MCP projection path: non-regular file`. This exit status applies only to the unsafe path-type diagnostic. diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts index da5e341fb35..2f6114e32b3 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts @@ -158,6 +158,21 @@ describe("Deep Agents managed MCP projection safety", () => { "Unsafe managed Deep Agents MCP projection path: FIFO", ); expect(racedFifo.configIsFifo).toBe(true); + + const racedSocket = runDeepAgentsConfigCommand( + statusCommand, + emptyProjection, + "v2", + undefined, + 0o600, + { swapOnManagedOpen: "socket" }, + ); + expect(racedSocket.status).toBe(2); + expect(racedSocket.stdout.trim()).toBe(""); + expect(racedSocket.stderr).toContain( + "Unsafe managed Deep Agents MCP projection path: non-regular file", + ); + expect(racedSocket.configIsSocket).toBe(true); }); it("keeps the generic diagnostic for ordinary projection inspection failures (#10754)", () => { diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts index e22c242b9e9..672d74c7c4a 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts @@ -59,8 +59,15 @@ export const DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS = [ " assert_managed_source_stable(path, None)", " return b'', None, None", " except OSError as exc:", - " if unsafe_path_prefix and exc.errno == errno.ELOOP:", - " raise ValueError(f'{unsafe_path_prefix}: symbolic link') from exc", + " unsafe_path_kind = None", + " if unsafe_path_prefix:", + " try:", + " unsafe_path_kind = managed_projection_path_kind(os.stat(path, follow_symlinks=False).st_mode)", + " except OSError:", + " if exc.errno == errno.ELOOP:", + " unsafe_path_kind = 'symbolic link'", + " if unsafe_path_kind:", + " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}') from exc", " raise", " try:", " before = os.fstat(descriptor)", diff --git a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts index d0ecf4b6818..b64c8f77556 100644 --- a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts +++ b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts @@ -26,6 +26,7 @@ export interface DeepAgentsConfigCommandResult { stderr: string; configExists: boolean; configIsFifo: boolean; + configIsSocket: boolean; configIsSymlink: boolean; config: Record | null; configText: string | null; @@ -41,7 +42,7 @@ export interface DeepAgentsManagedFixtureOptions { directory?: boolean; fifo?: boolean; mode?: number; - swapOnManagedOpen?: "fifo" | "symlink"; + swapOnManagedOpen?: "fifo" | "socket" | "symlink"; symlink?: boolean; } @@ -53,7 +54,8 @@ export function runDeepAgentsConfigCommand( initialLegacyMode = 0o600, managedOptions: DeepAgentsManagedFixtureOptions = {}, ): DeepAgentsConfigCommandResult { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-deepagents-mcp-")); + const fixtureRoot = managedOptions.swapOnManagedOpen === "socket" ? "/tmp" : os.tmpdir(); + const tmp = fs.mkdtempSync(path.join(fixtureRoot, "nemoclaw-deepagents-mcp-")); const configPath = path.join(tmp, ".deepagents", ".nemoclaw-mcp.json"); const managedSymlinkTarget = path.join(tmp, "managed-projection-target.json"); const legacyConfigPath = path.join(tmp, ".deepagents", ".mcp.json"); @@ -99,20 +101,25 @@ export function runDeepAgentsConfigCommand( const swapOnManagedOpenPrelude = managedOptions.swapOnManagedOpen ? [ "import os as _nemoclaw_test_os", + "import socket as _nemoclaw_test_socket_module", `_nemoclaw_test_path = ${JSON.stringify(configPath)}`, `_nemoclaw_test_target = ${JSON.stringify(managedSymlinkTarget)}`, `_nemoclaw_test_swap = ${JSON.stringify(managedOptions.swapOnManagedOpen)}`, "_nemoclaw_test_real_open = _nemoclaw_test_os.open", + "_nemoclaw_test_socket = None", "_nemoclaw_test_swapped = False", "def _nemoclaw_test_open(path, flags, *args, **kwargs):", - " global _nemoclaw_test_swapped", + " global _nemoclaw_test_socket, _nemoclaw_test_swapped", " if not _nemoclaw_test_swapped and _nemoclaw_test_os.fspath(path) == _nemoclaw_test_path:", " _nemoclaw_test_swapped = True", " _nemoclaw_test_os.unlink(_nemoclaw_test_path)", " if _nemoclaw_test_swap == 'symlink':", " _nemoclaw_test_os.symlink(_nemoclaw_test_target, _nemoclaw_test_path)", - " else:", + " elif _nemoclaw_test_swap == 'fifo':", " _nemoclaw_test_os.mkfifo(_nemoclaw_test_path, 0o600)", + " else:", + " _nemoclaw_test_socket = _nemoclaw_test_socket_module.socket(_nemoclaw_test_socket_module.AF_UNIX, _nemoclaw_test_socket_module.SOCK_STREAM)", + " _nemoclaw_test_socket.bind(_nemoclaw_test_path)", " return _nemoclaw_test_real_open(path, flags, *args, **kwargs)", "_nemoclaw_test_os.open = _nemoclaw_test_open", ].join("\n") @@ -130,10 +137,11 @@ export function runDeepAgentsConfigCommand( const configExists = fs.existsSync(configPath); const legacyConfigExists = fs.existsSync(legacyConfigPath); const configIsFifo = configExists && fs.lstatSync(configPath).isFIFO(); + const configIsSocket = configExists && fs.lstatSync(configPath).isSocket(); const configIsSymlink = configExists && fs.lstatSync(configPath).isSymbolicLink(); const configIsDirectory = configExists && fs.lstatSync(configPath).isDirectory(); const configText = - configExists && !configIsFifo && !configIsDirectory + configExists && !configIsFifo && !configIsSocket && !configIsDirectory ? fs.readFileSync(configPath, "utf-8") : null; const managedSymlinkTargetExists = fs.existsSync(managedSymlinkTarget); @@ -155,6 +163,7 @@ export function runDeepAgentsConfigCommand( stderr: result.stderr, configExists, configIsFifo, + configIsSocket, configIsSymlink, config: parseConfigText(configText), configText, From f1f854f0894b43c1acf50a14737ee61138cb01af Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:24:54 -0700 Subject: [PATCH 05/49] test(mcp): inspect dangling projection links Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- .../mcp-bridge-adapter-deepagents-fixture.ts | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts index b64c8f77556..cf9e9206d09 100644 --- a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts +++ b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts @@ -134,14 +134,24 @@ export function runDeepAgentsConfigCommand( `runtime_kind = "${runtimeKind}" # NEMOCLAW_DEEPAGENTS_RUNTIME_TEST_ANCHOR`, ); const result = spawnSync("bash", ["-c", fixtureCommand], { encoding: "utf-8", timeout: 5000 }); - const configExists = fs.existsSync(configPath); + let configStat: fs.Stats | null = null; + try { + configStat = fs.lstatSync(configPath); + } catch { + // A missing projection has no path type to report. + } + const configExists = configStat !== null; const legacyConfigExists = fs.existsSync(legacyConfigPath); - const configIsFifo = configExists && fs.lstatSync(configPath).isFIFO(); - const configIsSocket = configExists && fs.lstatSync(configPath).isSocket(); - const configIsSymlink = configExists && fs.lstatSync(configPath).isSymbolicLink(); - const configIsDirectory = configExists && fs.lstatSync(configPath).isDirectory(); + const configIsFifo = configStat?.isFIFO() === true; + const configIsSocket = configStat?.isSocket() === true; + const configIsSymlink = configStat?.isSymbolicLink() === true; + const configIsDirectory = configStat?.isDirectory() === true; const configText = - configExists && !configIsFifo && !configIsSocket && !configIsDirectory + configExists && + !configIsFifo && + !configIsSocket && + !configIsSymlink && + !configIsDirectory ? fs.readFileSync(configPath, "utf-8") : null; const managedSymlinkTargetExists = fs.existsSync(managedSymlinkTarget); From 7293012ec1db18508e513c9b8fc54a1c50239606 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:18:17 -0700 Subject: [PATCH 06/49] fix(mcp): classify post-open projection races Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- ...idge-adapter-deepagents-projection.test.ts | 51 ++++++++++++++ ...cp-bridge-adapter-deepagents-projection.ts | 12 +++- .../mcp-bridge-adapter-deepagents-fixture.ts | 67 ++++++++++++++----- 3 files changed, 110 insertions(+), 20 deletions(-) diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts index 2f6114e32b3..f338cfa76ef 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts @@ -173,6 +173,57 @@ describe("Deep Agents managed MCP projection safety", () => { "Unsafe managed Deep Agents MCP projection path: non-regular file", ); expect(racedSocket.configIsSocket).toBe(true); + + const replacedAfterOpenWithSymlink = runDeepAgentsConfigCommand( + statusCommand, + emptyProjection, + "v2", + undefined, + 0o600, + { swapAfterManagedOpen: "symlink" }, + ); + expect(replacedAfterOpenWithSymlink.status).toBe(2); + expect(replacedAfterOpenWithSymlink.stdout.trim()).toBe(""); + expect(replacedAfterOpenWithSymlink.stderr).toContain( + "Unsafe managed Deep Agents MCP projection path: symbolic link", + ); + expect(replacedAfterOpenWithSymlink.configIsSymlink).toBe(true); + expect(replacedAfterOpenWithSymlink.managedSymlinkTargetText).toBe( + `${JSON.stringify(emptyProjection, null, 2)}\n`, + ); + + const replacedAfterOpenWithFifo = runDeepAgentsConfigCommand( + statusCommand, + emptyProjection, + "v2", + undefined, + 0o600, + { swapAfterManagedOpen: "fifo" }, + ); + expect(replacedAfterOpenWithFifo.status).toBe(2); + expect(replacedAfterOpenWithFifo.stdout.trim()).toBe(""); + expect(replacedAfterOpenWithFifo.stderr).toContain( + "Unsafe managed Deep Agents MCP projection path: FIFO", + ); + expect(replacedAfterOpenWithFifo.configIsFifo).toBe(true); + + const replacedWhileReadingWithSymlink = runDeepAgentsConfigCommand( + statusCommand, + emptyProjection, + "v2", + undefined, + 0o600, + { swapOnManagedRead: "symlink" }, + ); + expect(replacedWhileReadingWithSymlink.status).toBe(2); + expect(replacedWhileReadingWithSymlink.stdout.trim()).toBe(""); + expect(replacedWhileReadingWithSymlink.stderr).toContain( + "Unsafe managed Deep Agents MCP projection path: symbolic link", + ); + expect(replacedWhileReadingWithSymlink.configIsSymlink).toBe(true); + expect(replacedWhileReadingWithSymlink.managedSymlinkTargetText).toBe( + `${JSON.stringify(emptyProjection, null, 2)}\n`, + ); }); it("keeps the generic diagnostic for ordinary projection inspection failures (#10754)", () => { diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts index 672d74c7c4a..52f0bde24ea 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts @@ -43,9 +43,13 @@ export const DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS = [ " return", " if current != identity:", " raise ValueError('managed MCP projection changed before mutation')", - "def validate_managed_descriptor_path(path, descriptor):", + "def validate_managed_descriptor_path(path, descriptor, unsafe_path_prefix=None):", " opened = os.fstat(descriptor)", " linked = os.stat(path, follow_symlinks=False)", + " if unsafe_path_prefix:", + " unsafe_path_kind = managed_projection_path_kind(linked.st_mode)", + " if unsafe_path_kind:", + " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}')", " safe = (stat.S_ISREG(opened.st_mode) and opened.st_uid == os.getuid() and stat.S_IMODE(opened.st_mode) == 0o600 and opened.st_nlink == 1 and (opened.st_dev, opened.st_ino) == (linked.st_dev, linked.st_ino))", " if not safe:", " raise ValueError('managed MCP projection has unsafe ownership, mode, type, links, or path identity')", @@ -75,7 +79,7 @@ export const DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS = [ " unsafe_path_kind = managed_projection_path_kind(before.st_mode)", " if unsafe_path_kind:", " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}')", - " validate_managed_descriptor_path(path, descriptor)", + " validate_managed_descriptor_path(path, descriptor, unsafe_path_prefix)", " if before.st_size < 0 or before.st_size > MANAGED_MCP_MAX_BYTES:", " raise ValueError('managed MCP projection has invalid size')", " chunks = []", @@ -88,6 +92,10 @@ export const DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS = [ " remaining -= len(chunk)", " after = os.fstat(descriptor)", " linked_after = os.stat(path, follow_symlinks=False)", + " if unsafe_path_prefix:", + " unsafe_path_kind = managed_projection_path_kind(linked_after.st_mode)", + " if unsafe_path_kind:", + " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}')", " stable = (managed_fingerprint(before) == managed_fingerprint(after) and managed_fingerprint(after) == managed_fingerprint(linked_after))", " if remaining or not stable:", " raise ValueError('managed MCP projection changed while reading')", diff --git a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts index cf9e9206d09..12068ce7732 100644 --- a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts +++ b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts @@ -42,7 +42,9 @@ export interface DeepAgentsManagedFixtureOptions { directory?: boolean; fifo?: boolean; mode?: number; + swapAfterManagedOpen?: "fifo" | "symlink"; swapOnManagedOpen?: "fifo" | "socket" | "symlink"; + swapOnManagedRead?: "fifo" | "symlink"; symlink?: boolean; } @@ -54,7 +56,14 @@ export function runDeepAgentsConfigCommand( initialLegacyMode = 0o600, managedOptions: DeepAgentsManagedFixtureOptions = {}, ): DeepAgentsConfigCommandResult { - const fixtureRoot = managedOptions.swapOnManagedOpen === "socket" ? "/tmp" : os.tmpdir(); + const managedSwap = managedOptions.swapOnManagedOpen + ? { kind: managedOptions.swapOnManagedOpen, phase: "before-open" } + : managedOptions.swapAfterManagedOpen + ? { kind: managedOptions.swapAfterManagedOpen, phase: "after-open" } + : managedOptions.swapOnManagedRead + ? { kind: managedOptions.swapOnManagedRead, phase: "read" } + : undefined; + const fixtureRoot = managedSwap?.kind === "socket" ? "/tmp" : os.tmpdir(); const tmp = fs.mkdtempSync(path.join(fixtureRoot, "nemoclaw-deepagents-mcp-")); const configPath = path.join(tmp, ".deepagents", ".nemoclaw-mcp.json"); const managedSymlinkTarget = path.join(tmp, "managed-projection-target.json"); @@ -92,40 +101,59 @@ export function runDeepAgentsConfigCommand( fs.symlinkSync(managedSymlinkTarget, configPath); } } - if (managedOptions.swapOnManagedOpen === "symlink") { + if (managedSwap?.kind === "symlink") { initializeConfig(managedSymlinkTarget, initialConfig, managedOptions.mode); } initializeConfig(legacyConfigPath, initialLegacyConfig); if (initialLegacyConfig !== undefined) fs.chmodSync(legacyConfigPath, initialLegacyMode); try { - const swapOnManagedOpenPrelude = managedOptions.swapOnManagedOpen + const managedSwapPrelude = managedSwap ? [ "import os as _nemoclaw_test_os", "import socket as _nemoclaw_test_socket_module", `_nemoclaw_test_path = ${JSON.stringify(configPath)}`, `_nemoclaw_test_target = ${JSON.stringify(managedSymlinkTarget)}`, - `_nemoclaw_test_swap = ${JSON.stringify(managedOptions.swapOnManagedOpen)}`, + `_nemoclaw_test_swap = ${JSON.stringify(managedSwap.kind)}`, + `_nemoclaw_test_phase = ${JSON.stringify(managedSwap.phase)}`, "_nemoclaw_test_real_open = _nemoclaw_test_os.open", + "_nemoclaw_test_real_read = _nemoclaw_test_os.read", + "_nemoclaw_test_descriptor = None", "_nemoclaw_test_socket = None", "_nemoclaw_test_swapped = False", - "def _nemoclaw_test_open(path, flags, *args, **kwargs):", + "def _nemoclaw_test_replace_path():", " global _nemoclaw_test_socket, _nemoclaw_test_swapped", - " if not _nemoclaw_test_swapped and _nemoclaw_test_os.fspath(path) == _nemoclaw_test_path:", - " _nemoclaw_test_swapped = True", - " _nemoclaw_test_os.unlink(_nemoclaw_test_path)", - " if _nemoclaw_test_swap == 'symlink':", - " _nemoclaw_test_os.symlink(_nemoclaw_test_target, _nemoclaw_test_path)", - " elif _nemoclaw_test_swap == 'fifo':", - " _nemoclaw_test_os.mkfifo(_nemoclaw_test_path, 0o600)", - " else:", - " _nemoclaw_test_socket = _nemoclaw_test_socket_module.socket(_nemoclaw_test_socket_module.AF_UNIX, _nemoclaw_test_socket_module.SOCK_STREAM)", - " _nemoclaw_test_socket.bind(_nemoclaw_test_path)", - " return _nemoclaw_test_real_open(path, flags, *args, **kwargs)", + " if _nemoclaw_test_swapped:", + " return", + " _nemoclaw_test_swapped = True", + " _nemoclaw_test_os.unlink(_nemoclaw_test_path)", + " if _nemoclaw_test_swap == 'symlink':", + " _nemoclaw_test_os.symlink(_nemoclaw_test_target, _nemoclaw_test_path)", + " elif _nemoclaw_test_swap == 'fifo':", + " _nemoclaw_test_os.mkfifo(_nemoclaw_test_path, 0o600)", + " else:", + " _nemoclaw_test_socket = _nemoclaw_test_socket_module.socket(_nemoclaw_test_socket_module.AF_UNIX, _nemoclaw_test_socket_module.SOCK_STREAM)", + " _nemoclaw_test_socket.bind(_nemoclaw_test_path)", + "def _nemoclaw_test_open(path, flags, *args, **kwargs):", + " global _nemoclaw_test_descriptor", + " managed = _nemoclaw_test_os.fspath(path) == _nemoclaw_test_path", + " if managed and _nemoclaw_test_phase == 'before-open':", + " _nemoclaw_test_replace_path()", + " descriptor = _nemoclaw_test_real_open(path, flags, *args, **kwargs)", + " if managed:", + " _nemoclaw_test_descriptor = descriptor", + " if _nemoclaw_test_phase == 'after-open':", + " _nemoclaw_test_replace_path()", + " return descriptor", + "def _nemoclaw_test_read(descriptor, size):", + " if descriptor == _nemoclaw_test_descriptor and _nemoclaw_test_phase == 'read':", + " _nemoclaw_test_replace_path()", + " return _nemoclaw_test_real_read(descriptor, size)", "_nemoclaw_test_os.open = _nemoclaw_test_open", + "_nemoclaw_test_os.read = _nemoclaw_test_read", ].join("\n") : ""; const fixtureCommand = command - .replace("<<'PY'\n", `<<'PY'\n${swapOnManagedOpenPrelude}\n`) + .replace("<<'PY'\n", `<<'PY'\n${managedSwapPrelude}\n`) .replaceAll(DEEPAGENTS_MCP_CONFIG_PATH, configPath) .replaceAll("/sandbox/.deepagents/.mcp.json", legacyConfigPath) .replaceAll("/opt/venv/bin/python3", "python3") @@ -162,7 +190,10 @@ export function runDeepAgentsConfigCommand( const parseConfigText = (text: string | null): Record | null => { if (!text) return null; try { - return JSON.parse(text) as Record; + const parsed: unknown = JSON.parse(text); + return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) + ? (parsed as Record) + : null; } catch { return null; } From 0bdb3d47baaefed86abb1ba2fb2c8e6edb8e1150 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 15:19:55 -0700 Subject: [PATCH 07/49] refactor(mcp): centralize projection type validation Signed-off-by: Prekshi Vyas --- src/lib/actions/sandbox/mcp-bridge-adapter-status.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-status.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-status.ts index d82f323f7aa..e46d13bbcde 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-status.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-status.ts @@ -261,16 +261,6 @@ export function buildDeepAgentsMcpStatusCommand( "is_v2 = runtime_kind == 'v2'", "config_path = managed_path if is_v2 else legacy_path", "try:", - " if is_v2:", - " try:", - " projection_mode = os.stat(config_path, follow_symlinks=False).st_mode", - " except FileNotFoundError:", - " projection_mode = None", - " if projection_mode is not None:", - " projection_kind = managed_projection_path_kind(projection_mode)", - " if projection_kind:", - ` print(f'${DEEPAGENTS_UNSAFE_MCP_PROJECTION_PREFIX}: {projection_kind}', file=sys.stderr)`, - " raise SystemExit(2)", ` data = read_managed_projection(config_path, ${JSON.stringify(DEEPAGENTS_UNSAFE_MCP_PROJECTION_PREFIX)})[0] if is_v2 else read_legacy_config(config_path)[0]`, "except FileNotFoundError:", " data = {}", From 391946b1214b21912eea32a4a6bd6f1ff4c16aff Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 18:10:55 -0700 Subject: [PATCH 08/49] test(mcp): exercise projection races at runtime Signed-off-by: Prekshi Vyas --- ...idge-adapter-deepagents-projection.test.ts | 103 +++++------ .../mcp-bridge-adapter-deepagents-fixture.ts | 163 ++++++++++++------ 2 files changed, 149 insertions(+), 117 deletions(-) diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts index f338cfa76ef..25d01223017 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts @@ -17,41 +17,13 @@ import { buildDeepAgentsMcpStatusCommand } from "./mcp-bridge-adapter-status"; const emptyProjection = { mcpServers: {} }; const duplicateProjection = '{"mcpServers":{},"mcpServers":{"shadow":{}}}\n'; const attackerProjection = '{"mcpServers":{"attacker":{"type":"stdio"}}}\n'; +const managedCommandTimeoutMs = 30_000; const registrationCommand = buildDeepAgentsMcpRegisterCommand(baseEntry); const rollbackCommand = buildDeepAgentsMcpRegisterCommand(baseEntry, true, [baseEntry], true); const removalCommand = buildDeepAgentsMcpRemoveCommand(baseEntry); describe("Deep Agents managed MCP projection safety", () => { - it.each([ - { mutation: "registration", command: registrationCommand }, - { mutation: "rollback", command: rollbackCommand }, - { mutation: "removal", command: removalCommand }, - ])("uses one stable-read contract for $mutation", ({ command }) => { - expect(command).toContain("os.O_NOFOLLOW"); - expect(command).toContain("os.fstat(descriptor)"); - expect(command).toContain("assert_managed_source_stable(path, identity)"); - expect(command).toContain("os.link(tmp_name, path, follow_symlinks=False)"); - expect(command).toContain("os.ftruncate(descriptor, 0)"); - expect(command).not.toContain("\n path.unlink()\n"); - expect(command).not.toContain("config_path.read_text"); - }); - - it("uses the isolated runtime and bounds registration before mutation", () => { - expect(registrationCommand).toMatch(/^\/opt\/venv\/bin\/python3 -I - <<'PY'/); - expect(buildDeepAgentsMcpStatusCommand(baseEntry)).toMatch( - /^\/opt\/venv\/bin\/python3 -I - <<'PY'/, - ); - expect(rollbackCommand).toContain( - `len(payload['expectedServers']) > ${String(DEEPAGENTS_MCP_MAX_SERVERS)}`, - ); - const sizeCheckIndex = registrationCommand.indexOf("len(payload) > MANAGED_MCP_MAX_BYTES"); - const truncateIndex = registrationCommand.indexOf("os.ftruncate(descriptor, 0)"); - expect(sizeCheckIndex).toBeGreaterThanOrEqual(0); - expect(truncateIndex).toBeGreaterThanOrEqual(0); - expect(sizeCheckIndex).toBeLessThan(truncateIndex); - }); - it("applies the shared server cap before normal and rollback v2 publication", () => { const entries = Array.from( { length: DEEPAGENTS_MCP_MAX_SERVERS + 1 }, @@ -82,7 +54,7 @@ describe("Deep Agents managed MCP projection safety", () => { "v2", undefined, 0o600, - { symlink: true }, + { symlink: true, timeoutMs: managedCommandTimeoutMs }, ); expect(symlink.status).toBe(2); expect(symlink.stdout.trim()).toBe(""); @@ -93,19 +65,16 @@ describe("Deep Agents managed MCP projection safety", () => { const fifo = runDeepAgentsConfigCommand(statusCommand, undefined, "v2", undefined, 0o600, { fifo: true, + timeoutMs: managedCommandTimeoutMs, }); expect(fifo.status).toBe(2); expect(fifo.stdout.trim()).toBe(""); expect(fifo.stderr).toContain("Unsafe managed Deep Agents MCP projection path: FIFO"); - const directory = runDeepAgentsConfigCommand( - statusCommand, - undefined, - "v2", - undefined, - 0o600, - { directory: true }, - ); + const directory = runDeepAgentsConfigCommand(statusCommand, undefined, "v2", undefined, 0o600, { + directory: true, + timeoutMs: managedCommandTimeoutMs, + }); expect(directory.status).toBe(2); expect(directory.stdout.trim()).toBe(""); expect(directory.stderr).toContain( @@ -118,7 +87,7 @@ describe("Deep Agents managed MCP projection safety", () => { "v2", undefined, 0o600, - { danglingSymlink: true }, + { danglingSymlink: true, timeoutMs: managedCommandTimeoutMs }, ); expect(danglingSymlink.status).toBe(2); expect(danglingSymlink.stdout.trim()).toBe(""); @@ -132,7 +101,7 @@ describe("Deep Agents managed MCP projection safety", () => { "v2", undefined, 0o600, - { swapOnManagedOpen: "symlink" }, + { swapOnManagedOpen: "symlink", timeoutMs: managedCommandTimeoutMs }, ); expect(racedSymlink.status).toBe(2); expect(racedSymlink.stdout.trim()).toBe(""); @@ -150,13 +119,11 @@ describe("Deep Agents managed MCP projection safety", () => { "v2", undefined, 0o600, - { swapOnManagedOpen: "fifo" }, + { swapOnManagedOpen: "fifo", timeoutMs: managedCommandTimeoutMs }, ); expect(racedFifo.status).toBe(2); expect(racedFifo.stdout.trim()).toBe(""); - expect(racedFifo.stderr).toContain( - "Unsafe managed Deep Agents MCP projection path: FIFO", - ); + expect(racedFifo.stderr).toContain("Unsafe managed Deep Agents MCP projection path: FIFO"); expect(racedFifo.configIsFifo).toBe(true); const racedSocket = runDeepAgentsConfigCommand( @@ -165,7 +132,7 @@ describe("Deep Agents managed MCP projection safety", () => { "v2", undefined, 0o600, - { swapOnManagedOpen: "socket" }, + { swapOnManagedOpen: "socket", timeoutMs: managedCommandTimeoutMs }, ); expect(racedSocket.status).toBe(2); expect(racedSocket.stdout.trim()).toBe(""); @@ -180,7 +147,7 @@ describe("Deep Agents managed MCP projection safety", () => { "v2", undefined, 0o600, - { swapAfterManagedOpen: "symlink" }, + { swapAfterManagedOpen: "symlink", timeoutMs: managedCommandTimeoutMs }, ); expect(replacedAfterOpenWithSymlink.status).toBe(2); expect(replacedAfterOpenWithSymlink.stdout.trim()).toBe(""); @@ -198,7 +165,7 @@ describe("Deep Agents managed MCP projection safety", () => { "v2", undefined, 0o600, - { swapAfterManagedOpen: "fifo" }, + { swapAfterManagedOpen: "fifo", timeoutMs: managedCommandTimeoutMs }, ); expect(replacedAfterOpenWithFifo.status).toBe(2); expect(replacedAfterOpenWithFifo.stdout.trim()).toBe(""); @@ -213,7 +180,7 @@ describe("Deep Agents managed MCP projection safety", () => { "v2", undefined, 0o600, - { swapOnManagedRead: "symlink" }, + { swapOnManagedRead: "symlink", timeoutMs: managedCommandTimeoutMs }, ); expect(replacedWhileReadingWithSymlink.status).toBe(2); expect(replacedWhileReadingWithSymlink.stdout.trim()).toBe(""); @@ -269,37 +236,49 @@ describe("Deep Agents managed MCP projection safety", () => { }); it("never clobbers a projection that appears during absent publication or fd rewrite", () => { - const absentRace = registrationCommand.replace( - " write_managed_projection(config_path, data, source_identity, source_descriptor)", - ` config_path.write_text(${JSON.stringify(attackerProjection)}, encoding='utf-8')\n os.chmod(config_path, 0o600)\n write_managed_projection(config_path, data, source_identity, source_descriptor)`, + const absentResult = runDeepAgentsConfigCommand( + registrationCommand, + undefined, + "v2", + undefined, + 0o600, + { swapBeforeManagedLink: attackerProjection, timeoutMs: managedCommandTimeoutMs }, ); - const absentResult = runDeepAgentsConfigCommand(absentRace); expect(absentResult.status).toBe(2); - expect(absentResult.stderr).toContain("appeared during mutation"); + expect(absentResult.stderr).toContain("appeared during publication"); expect(absentResult.configText).toBe(attackerProjection); - const existingRace = registrationCommand.replace( - " payload = managed_projection_bytes(value)\n os.lseek(descriptor, 0, os.SEEK_SET)", - ` payload = managed_projection_bytes(value)\n path.unlink()\n path.write_text(${JSON.stringify(attackerProjection)}, encoding='utf-8')\n os.chmod(path, 0o600)\n os.lseek(descriptor, 0, os.SEEK_SET)`, + const existingResult = runDeepAgentsConfigCommand( + registrationCommand, + emptyProjection, + "v2", + undefined, + 0o600, + { swapOnManagedSeek: attackerProjection, timeoutMs: managedCommandTimeoutMs }, ); - const existingResult = runDeepAgentsConfigCommand(existingRace, emptyProjection); expect(existingResult.status).toBe(2); expect(existingResult.stderr).toContain("links, or path identity"); expect(existingResult.configText).toBe(attackerProjection); }); - it("keeps forced removal identity-bound for malformed files and symlinks", () => { + it("keeps forced removal identity-bound during a descriptor rewrite (#10754)", () => { const forcedCommand = buildDeepAgentsMcpRemoveCommand(baseEntry, true); - const racedCommand = forcedCommand.replace( - " payload = managed_projection_bytes(value)\n os.lseek(descriptor, 0, os.SEEK_SET)", - ` payload = managed_projection_bytes(value)\n path.unlink()\n path.write_text(${JSON.stringify(attackerProjection)}, encoding='utf-8')\n os.chmod(path, 0o600)\n os.lseek(descriptor, 0, os.SEEK_SET)`, + const raced = runDeepAgentsConfigCommand( + forcedCommand, + { ui: { theme: "dark" } }, + "v2", + undefined, + 0o600, + { swapOnManagedSeek: attackerProjection, timeoutMs: managedCommandTimeoutMs }, ); - const raced = runDeepAgentsConfigCommand(racedCommand, { ui: { theme: "dark" } }); expect(raced.status).toBe(2); expect(raced.stderr).toContain("Refusing unsafe managed MCP v2 repair"); expect(raced.stderr).not.toContain("Traceback"); expect(raced.configText).toBe(attackerProjection); + }); + it("keeps forced removal identity-bound for malformed files and symlinks", () => { + const forcedCommand = buildDeepAgentsMcpRemoveCommand(baseEntry, true); const forcedSymlink = runDeepAgentsConfigCommand( forcedCommand, emptyProjection, diff --git a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts index 12068ce7732..1a0f8bcb61b 100644 --- a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts +++ b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts @@ -43,9 +43,88 @@ export interface DeepAgentsManagedFixtureOptions { fifo?: boolean; mode?: number; swapAfterManagedOpen?: "fifo" | "symlink"; + swapBeforeManagedLink?: string; swapOnManagedOpen?: "fifo" | "socket" | "symlink"; swapOnManagedRead?: "fifo" | "symlink"; + swapOnManagedSeek?: string; symlink?: boolean; + timeoutMs?: number; +} + +type ManagedSwap = { + content?: string; + kind: "fifo" | "regular" | "socket" | "symlink"; + phase: "after-open" | "before-link" | "before-open" | "read" | "seek"; +}; + +function createManagedSwapPythonExecutable( + fixtureRoot: string, + configPath: string, + managedSymlinkTarget: string, + managedSwap: ManagedSwap, +): { executablePath: string; scriptPath: string } { + const executablePath = path.join(fixtureRoot, "managed-swap-python"); + const scriptPath = path.join(fixtureRoot, "managed-swap-wrapper.py"); + const wrapper = [ + "import os", + "import socket", + "import sys", + `managed_path = ${JSON.stringify(configPath)}`, + `managed_target = ${JSON.stringify(managedSymlinkTarget)}`, + `managed_swap_kind = ${JSON.stringify(managedSwap.kind)}`, + `managed_swap_phase = ${JSON.stringify(managedSwap.phase)}`, + `managed_swap_content = ${JSON.stringify(managedSwap.content ?? "")}`, + "managed_socket = None", + "managed_swapped = False", + "def replace_managed_path():", + " global managed_socket, managed_swapped", + " if managed_swapped:", + " return", + " managed_swapped = True", + " try:", + " os.unlink(managed_path)", + " except FileNotFoundError:", + " pass", + " if managed_swap_kind == 'symlink':", + " os.symlink(managed_target, managed_path)", + " elif managed_swap_kind == 'fifo':", + " os.mkfifo(managed_path, 0o600)", + " elif managed_swap_kind == 'socket':", + " managed_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)", + " managed_socket.bind(managed_path)", + " else:", + " with open(managed_path, 'w', encoding='utf-8') as replacement:", + " replacement.write(managed_swap_content)", + " os.chmod(managed_path, 0o600)", + "def managed_profile(_frame, event, target):", + " if managed_swapped:", + " return", + " if managed_swap_phase == 'before-open' and event == 'c_call' and target is os.open:", + " replace_managed_path()", + " elif managed_swap_phase == 'after-open' and event == 'c_return' and target is os.open:", + " replace_managed_path()", + " elif managed_swap_phase == 'read' and event == 'c_call' and target is os.read:", + " replace_managed_path()", + " elif managed_swap_phase == 'before-link' and event == 'c_call' and target is os.link:", + " replace_managed_path()", + " elif managed_swap_phase == 'seek' and event == 'c_call' and target is os.lseek:", + " replace_managed_path()", + "source = sys.stdin.read()", + "namespace = {'__name__': '__main__'}", + "sys.setprofile(managed_profile)", + "try:", + " exec(compile(source, '', 'exec'), namespace)", + "finally:", + " sys.setprofile(None)", + "", + ].join("\n"); + fs.writeFileSync(scriptPath, wrapper, { mode: 0o600 }); + fs.writeFileSync( + executablePath, + '#!/bin/sh\nexec python3 -I "$DEEPAGENTS_FIXTURE_PYTHON_WRAPPER" "$@"\n', + { mode: 0o700 }, + ); + return { executablePath, scriptPath }; } export function runDeepAgentsConfigCommand( @@ -56,13 +135,25 @@ export function runDeepAgentsConfigCommand( initialLegacyMode = 0o600, managedOptions: DeepAgentsManagedFixtureOptions = {}, ): DeepAgentsConfigCommandResult { - const managedSwap = managedOptions.swapOnManagedOpen + const managedSwap: ManagedSwap | undefined = managedOptions.swapOnManagedOpen ? { kind: managedOptions.swapOnManagedOpen, phase: "before-open" } : managedOptions.swapAfterManagedOpen ? { kind: managedOptions.swapAfterManagedOpen, phase: "after-open" } : managedOptions.swapOnManagedRead ? { kind: managedOptions.swapOnManagedRead, phase: "read" } - : undefined; + : managedOptions.swapBeforeManagedLink !== undefined + ? { + content: managedOptions.swapBeforeManagedLink, + kind: "regular" as const, + phase: "before-link" as const, + } + : managedOptions.swapOnManagedSeek !== undefined + ? { + content: managedOptions.swapOnManagedSeek, + kind: "regular" as const, + phase: "seek" as const, + } + : undefined; const fixtureRoot = managedSwap?.kind === "socket" ? "/tmp" : os.tmpdir(); const tmp = fs.mkdtempSync(path.join(fixtureRoot, "nemoclaw-deepagents-mcp-")); const configPath = path.join(tmp, ".deepagents", ".nemoclaw-mcp.json"); @@ -107,61 +198,27 @@ export function runDeepAgentsConfigCommand( initializeConfig(legacyConfigPath, initialLegacyConfig); if (initialLegacyConfig !== undefined) fs.chmodSync(legacyConfigPath, initialLegacyMode); try { - const managedSwapPrelude = managedSwap - ? [ - "import os as _nemoclaw_test_os", - "import socket as _nemoclaw_test_socket_module", - `_nemoclaw_test_path = ${JSON.stringify(configPath)}`, - `_nemoclaw_test_target = ${JSON.stringify(managedSymlinkTarget)}`, - `_nemoclaw_test_swap = ${JSON.stringify(managedSwap.kind)}`, - `_nemoclaw_test_phase = ${JSON.stringify(managedSwap.phase)}`, - "_nemoclaw_test_real_open = _nemoclaw_test_os.open", - "_nemoclaw_test_real_read = _nemoclaw_test_os.read", - "_nemoclaw_test_descriptor = None", - "_nemoclaw_test_socket = None", - "_nemoclaw_test_swapped = False", - "def _nemoclaw_test_replace_path():", - " global _nemoclaw_test_socket, _nemoclaw_test_swapped", - " if _nemoclaw_test_swapped:", - " return", - " _nemoclaw_test_swapped = True", - " _nemoclaw_test_os.unlink(_nemoclaw_test_path)", - " if _nemoclaw_test_swap == 'symlink':", - " _nemoclaw_test_os.symlink(_nemoclaw_test_target, _nemoclaw_test_path)", - " elif _nemoclaw_test_swap == 'fifo':", - " _nemoclaw_test_os.mkfifo(_nemoclaw_test_path, 0o600)", - " else:", - " _nemoclaw_test_socket = _nemoclaw_test_socket_module.socket(_nemoclaw_test_socket_module.AF_UNIX, _nemoclaw_test_socket_module.SOCK_STREAM)", - " _nemoclaw_test_socket.bind(_nemoclaw_test_path)", - "def _nemoclaw_test_open(path, flags, *args, **kwargs):", - " global _nemoclaw_test_descriptor", - " managed = _nemoclaw_test_os.fspath(path) == _nemoclaw_test_path", - " if managed and _nemoclaw_test_phase == 'before-open':", - " _nemoclaw_test_replace_path()", - " descriptor = _nemoclaw_test_real_open(path, flags, *args, **kwargs)", - " if managed:", - " _nemoclaw_test_descriptor = descriptor", - " if _nemoclaw_test_phase == 'after-open':", - " _nemoclaw_test_replace_path()", - " return descriptor", - "def _nemoclaw_test_read(descriptor, size):", - " if descriptor == _nemoclaw_test_descriptor and _nemoclaw_test_phase == 'read':", - " _nemoclaw_test_replace_path()", - " return _nemoclaw_test_real_read(descriptor, size)", - "_nemoclaw_test_os.open = _nemoclaw_test_open", - "_nemoclaw_test_os.read = _nemoclaw_test_read", - ].join("\n") - : ""; + const managedSwapPython = managedSwap + ? createManagedSwapPythonExecutable(tmp, configPath, managedSymlinkTarget, managedSwap) + : null; const fixtureCommand = command - .replace("<<'PY'\n", `<<'PY'\n${managedSwapPrelude}\n`) .replaceAll(DEEPAGENTS_MCP_CONFIG_PATH, configPath) .replaceAll("/sandbox/.deepagents/.mcp.json", legacyConfigPath) - .replaceAll("/opt/venv/bin/python3", "python3") + .replaceAll("/opt/venv/bin/python3", managedSwapPython?.executablePath ?? "python3") .replace( 'runtime_kind = "auto" # NEMOCLAW_DEEPAGENTS_RUNTIME_TEST_ANCHOR', `runtime_kind = "${runtimeKind}" # NEMOCLAW_DEEPAGENTS_RUNTIME_TEST_ANCHOR`, ); - const result = spawnSync("bash", ["-c", fixtureCommand], { encoding: "utf-8", timeout: 5000 }); + const result = spawnSync("bash", ["-c", fixtureCommand], { + encoding: "utf-8", + env: { + ...process.env, + ...(managedSwapPython + ? { DEEPAGENTS_FIXTURE_PYTHON_WRAPPER: managedSwapPython.scriptPath } + : {}), + }, + timeout: managedOptions.timeoutMs ?? 5000, + }); let configStat: fs.Stats | null = null; try { configStat = fs.lstatSync(configPath); @@ -175,11 +232,7 @@ export function runDeepAgentsConfigCommand( const configIsSymlink = configStat?.isSymbolicLink() === true; const configIsDirectory = configStat?.isDirectory() === true; const configText = - configExists && - !configIsFifo && - !configIsSocket && - !configIsSymlink && - !configIsDirectory + configExists && !configIsFifo && !configIsSocket && !configIsSymlink && !configIsDirectory ? fs.readFileSync(configPath, "utf-8") : null; const managedSymlinkTargetExists = fs.existsSync(managedSymlinkTarget); From bc8973302cc0d3d1a4f7851b2cb544d9731db815 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:33:31 -0700 Subject: [PATCH 09/49] fix(mcp): preserve unsafe projection race types Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- ...idge-adapter-deepagents-projection.test.ts | 57 +++++++++++++++---- ...cp-bridge-adapter-deepagents-projection.ts | 26 +++++---- .../mcp-bridge-status-resolution.test.ts | 35 ++++++++++-- .../mcp-bridge-adapter-deepagents-fixture.ts | 55 +++++++++++++----- 4 files changed, 131 insertions(+), 42 deletions(-) diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts index f338cfa76ef..0a87c310218 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts @@ -98,14 +98,9 @@ describe("Deep Agents managed MCP projection safety", () => { expect(fifo.stdout.trim()).toBe(""); expect(fifo.stderr).toContain("Unsafe managed Deep Agents MCP projection path: FIFO"); - const directory = runDeepAgentsConfigCommand( - statusCommand, - undefined, - "v2", - undefined, - 0o600, - { directory: true }, - ); + const directory = runDeepAgentsConfigCommand(statusCommand, undefined, "v2", undefined, 0o600, { + directory: true, + }); expect(directory.status).toBe(2); expect(directory.stdout.trim()).toBe(""); expect(directory.stderr).toContain( @@ -154,9 +149,7 @@ describe("Deep Agents managed MCP projection safety", () => { ); expect(racedFifo.status).toBe(2); expect(racedFifo.stdout.trim()).toBe(""); - expect(racedFifo.stderr).toContain( - "Unsafe managed Deep Agents MCP projection path: FIFO", - ); + expect(racedFifo.stderr).toContain("Unsafe managed Deep Agents MCP projection path: FIFO"); expect(racedFifo.configIsFifo).toBe(true); const racedSocket = runDeepAgentsConfigCommand( @@ -224,8 +217,50 @@ describe("Deep Agents managed MCP projection safety", () => { expect(replacedWhileReadingWithSymlink.managedSymlinkTargetText).toBe( `${JSON.stringify(emptyProjection, null, 2)}\n`, ); + + const replacedAfterEloop = runDeepAgentsConfigCommand( + statusCommand, + emptyProjection, + "v2", + undefined, + 0o600, + { statAfterManagedEloopAsRegular: true, symlink: true }, + ); + expect(replacedAfterEloop.status).toBe(2); + expect(replacedAfterEloop.stdout.trim()).toBe(""); + expect(replacedAfterEloop.stderr).toContain( + "Unsafe managed Deep Agents MCP projection path: symbolic link", + ); + expect(replacedAfterEloop.configIsSymlink).toBe(true); + expect(replacedAfterEloop.managedSymlinkTargetText).toBe( + `${JSON.stringify(emptyProjection, null, 2)}\n`, + ); }); + it.each([ + { appearedType: "symlink" as const, diagnostic: "symbolic link" }, + { appearedType: "fifo" as const, diagnostic: "FIFO" }, + ])( + "classifies a $diagnostic that appears after a missing-path open (#10754)", + ({ appearedType, diagnostic }) => { + const result = runDeepAgentsConfigCommand( + buildDeepAgentsMcpStatusCommand(baseEntry), + emptyProjection, + "v2", + undefined, + 0o600, + { swapAfterMissingManagedOpen: appearedType }, + ); + + expect(result.status).toBe(2); + expect(result.stdout.trim()).toBe(""); + expect(result.stderr).toContain( + `Unsafe managed Deep Agents MCP projection path: ${diagnostic}`, + ); + expect(appearedType === "symlink" ? result.configIsSymlink : result.configIsFifo).toBe(true); + }, + ); + it("keeps the generic diagnostic for ordinary projection inspection failures (#10754)", () => { const result = runDeepAgentsConfigCommand( buildDeepAgentsMcpStatusCommand(baseEntry), diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts index 52f0bde24ea..d484ec70a7c 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts @@ -30,15 +30,17 @@ export const DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS = [ " return None", "def managed_fingerprint(metadata):", " return (metadata.st_dev, metadata.st_ino, metadata.st_size, metadata.st_mtime_ns, metadata.st_ctime_ns, metadata.st_mode, metadata.st_nlink, metadata.st_uid)", - "def managed_path_identity(path):", + "def assert_managed_source_stable(path, identity, unsafe_path_prefix=None):", " try:", - " return managed_fingerprint(os.stat(path, follow_symlinks=False))", + " current_metadata = os.stat(path, follow_symlinks=False)", " except FileNotFoundError:", - " return None", - "def assert_managed_source_stable(path, identity):", - " current = managed_path_identity(path)", + " current_metadata = None", + " current = managed_fingerprint(current_metadata) if current_metadata is not None else None", " if identity is None:", " if current is not None:", + " unsafe_path_kind = managed_projection_path_kind(current_metadata.st_mode) if unsafe_path_prefix else None", + " if unsafe_path_kind:", + " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}')", " raise ValueError('managed MCP projection appeared during mutation')", " return", " if current != identity:", @@ -60,16 +62,18 @@ export const DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS = [ " try:", " descriptor = os.open(path, flags)", " except FileNotFoundError:", - " assert_managed_source_stable(path, None)", + " assert_managed_source_stable(path, None, unsafe_path_prefix)", " return b'', None, None", " except OSError as exc:", " unsafe_path_kind = None", " if unsafe_path_prefix:", - " try:", - " unsafe_path_kind = managed_projection_path_kind(os.stat(path, follow_symlinks=False).st_mode)", - " except OSError:", - " if exc.errno == errno.ELOOP:", - " unsafe_path_kind = 'symbolic link'", + " if exc.errno == errno.ELOOP:", + " unsafe_path_kind = 'symbolic link'", + " else:", + " try:", + " unsafe_path_kind = managed_projection_path_kind(os.stat(path, follow_symlinks=False).st_mode)", + " except OSError:", + " pass", " if unsafe_path_kind:", " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}') from exc", " raise", diff --git a/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts b/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts index 18e6dc28cb6..c2d0565db17 100644 --- a/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts @@ -7,6 +7,12 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { + baseEntry, + runDeepAgentsConfigCommand, +} from "../../../../test/helpers/mcp-bridge-adapter-deepagents-fixture"; + +import { buildDeepAgentsMcpStatusCommand } from "./mcp-bridge-adapter-status"; const sourceRequireHook = path.resolve("test/helpers/onboard-script-mocks.cjs"); const sourceNodeOptions = [process.env.NODE_OPTIONS, `--require=${sourceRequireHook}`] @@ -190,7 +196,27 @@ ${body} } describe("MCP status wire-level credential-resolution probe", { timeout: 15_000 }, () => { - it("returns a type-specific failure for unsafe Deep Agents projections (#10754)", () => { + it("returns a type-specific failure for unsafe Deep Agents projection races (#10754)", () => { + const statusCommand = buildDeepAgentsMcpStatusCommand(baseEntry); + const projection = { mcpServers: {} }; + const unsafeResults = [ + ...(["symlink", "fifo"] as const).map((appearedType) => + runDeepAgentsConfigCommand(statusCommand, projection, "v2", undefined, 0o600, { + swapAfterMissingManagedOpen: appearedType, + }), + ), + runDeepAgentsConfigCommand(statusCommand, projection, "v2", undefined, 0o600, { + statAfterManagedEloopAsRegular: true, + symlink: true, + }), + ]; + unsafeResults.forEach((result) => { + expect(result.status).toBe(2); + expect(result.stdout.trim()).toBe(""); + expect(result.stderr).toContain("Unsafe managed Deep Agents MCP projection path:"); + expect(result.configIsSymlink || result.configIsFifo).toBe(true); + }); + const unsafeDetails = unsafeResults.map((result) => result.stderr.trim()); const home = createTempHome("nemoclaw-mcp-status-projection-"); const { stdout } = runHarness( home, @@ -210,10 +236,7 @@ describe("MCP status wire-level credential-resolution probe", { timeout: 15_000 }, }); const outcomes = []; - for (const detail of [ - "Unsafe managed Deep Agents MCP projection path: symbolic link", - "Unsafe managed Deep Agents MCP projection path: FIFO", - ]) { + for (const detail of ${JSON.stringify(unsafeDetails)}) { processRecovery.executeSandboxCommand = () => ({ status: 2, stdout: "", @@ -240,7 +263,7 @@ describe("MCP status wire-level credential-resolution probe", { timeout: 15_000 stderr: string; }>; - expect(outcomes).toHaveLength(2); + expect(outcomes).toHaveLength(3); outcomes.forEach((outcome) => { expect(outcome.exitCode).toBe(2); expect(outcome.stdout).toBe(""); diff --git a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts index 12068ce7732..6318bca93ee 100644 --- a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts +++ b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts @@ -42,7 +42,9 @@ export interface DeepAgentsManagedFixtureOptions { directory?: boolean; fifo?: boolean; mode?: number; + statAfterManagedEloopAsRegular?: boolean; swapAfterManagedOpen?: "fifo" | "symlink"; + swapAfterMissingManagedOpen?: "fifo" | "symlink"; swapOnManagedOpen?: "fifo" | "socket" | "symlink"; swapOnManagedRead?: "fifo" | "symlink"; symlink?: boolean; @@ -58,11 +60,15 @@ export function runDeepAgentsConfigCommand( ): DeepAgentsConfigCommandResult { const managedSwap = managedOptions.swapOnManagedOpen ? { kind: managedOptions.swapOnManagedOpen, phase: "before-open" } - : managedOptions.swapAfterManagedOpen - ? { kind: managedOptions.swapAfterManagedOpen, phase: "after-open" } - : managedOptions.swapOnManagedRead - ? { kind: managedOptions.swapOnManagedRead, phase: "read" } - : undefined; + : managedOptions.swapAfterMissingManagedOpen + ? { kind: managedOptions.swapAfterMissingManagedOpen, phase: "after-missing-open" } + : managedOptions.swapAfterManagedOpen + ? { kind: managedOptions.swapAfterManagedOpen, phase: "after-open" } + : managedOptions.swapOnManagedRead + ? { kind: managedOptions.swapOnManagedRead, phase: "read" } + : managedOptions.statAfterManagedEloopAsRegular + ? { kind: "symlink", phase: "eloop-regular-stat" } + : undefined; const fixtureRoot = managedSwap?.kind === "socket" ? "/tmp" : os.tmpdir(); const tmp = fs.mkdtempSync(path.join(fixtureRoot, "nemoclaw-deepagents-mcp-")); const configPath = path.join(tmp, ".deepagents", ".nemoclaw-mcp.json"); @@ -91,11 +97,14 @@ export function runDeepAgentsConfigCommand( if (fifo.status !== 0) throw new Error(fifo.stderr || "could not create managed fixture FIFO"); fs.chmodSync(configPath, managedOptions.mode ?? 0o600); } else { - if (!managedOptions.danglingSymlink) { + if (!managedOptions.danglingSymlink && managedSwap?.phase !== "after-missing-open") { initializeConfig(managedInitialPath, initialConfig, managedOptions.mode); if (initialConfig !== undefined) fs.chmodSync(managedInitialPath, managedOptions.mode ?? 0o600); } + if (managedSwap?.phase === "after-missing-open") { + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + } if (managedSymlink) { fs.mkdirSync(path.dirname(configPath), { recursive: true }); fs.symlinkSync(managedSymlinkTarget, configPath); @@ -110,6 +119,7 @@ export function runDeepAgentsConfigCommand( const managedSwapPrelude = managedSwap ? [ "import os as _nemoclaw_test_os", + "import errno as _nemoclaw_test_errno", "import socket as _nemoclaw_test_socket_module", `_nemoclaw_test_path = ${JSON.stringify(configPath)}`, `_nemoclaw_test_target = ${JSON.stringify(managedSymlinkTarget)}`, @@ -117,7 +127,10 @@ export function runDeepAgentsConfigCommand( `_nemoclaw_test_phase = ${JSON.stringify(managedSwap.phase)}`, "_nemoclaw_test_real_open = _nemoclaw_test_os.open", "_nemoclaw_test_real_read = _nemoclaw_test_os.read", + "_nemoclaw_test_real_stat = _nemoclaw_test_os.stat", "_nemoclaw_test_descriptor = None", + "_nemoclaw_test_saw_eloop = False", + "_nemoclaw_test_returned_regular_stat = False", "_nemoclaw_test_socket = None", "_nemoclaw_test_swapped = False", "def _nemoclaw_test_replace_path():", @@ -125,7 +138,10 @@ export function runDeepAgentsConfigCommand( " if _nemoclaw_test_swapped:", " return", " _nemoclaw_test_swapped = True", - " _nemoclaw_test_os.unlink(_nemoclaw_test_path)", + " try:", + " _nemoclaw_test_os.unlink(_nemoclaw_test_path)", + " except FileNotFoundError:", + " pass", " if _nemoclaw_test_swap == 'symlink':", " _nemoclaw_test_os.symlink(_nemoclaw_test_target, _nemoclaw_test_path)", " elif _nemoclaw_test_swap == 'fifo':", @@ -134,11 +150,18 @@ export function runDeepAgentsConfigCommand( " _nemoclaw_test_socket = _nemoclaw_test_socket_module.socket(_nemoclaw_test_socket_module.AF_UNIX, _nemoclaw_test_socket_module.SOCK_STREAM)", " _nemoclaw_test_socket.bind(_nemoclaw_test_path)", "def _nemoclaw_test_open(path, flags, *args, **kwargs):", - " global _nemoclaw_test_descriptor", + " global _nemoclaw_test_descriptor, _nemoclaw_test_saw_eloop", " managed = _nemoclaw_test_os.fspath(path) == _nemoclaw_test_path", " if managed and _nemoclaw_test_phase == 'before-open':", " _nemoclaw_test_replace_path()", - " descriptor = _nemoclaw_test_real_open(path, flags, *args, **kwargs)", + " try:", + " descriptor = _nemoclaw_test_real_open(path, flags, *args, **kwargs)", + " except OSError as exc:", + " if managed and _nemoclaw_test_phase == 'after-missing-open' and exc.errno == _nemoclaw_test_errno.ENOENT:", + " _nemoclaw_test_replace_path()", + " if managed and _nemoclaw_test_phase == 'eloop-regular-stat' and exc.errno == _nemoclaw_test_errno.ELOOP:", + " _nemoclaw_test_saw_eloop = True", + " raise", " if managed:", " _nemoclaw_test_descriptor = descriptor", " if _nemoclaw_test_phase == 'after-open':", @@ -148,8 +171,16 @@ export function runDeepAgentsConfigCommand( " if descriptor == _nemoclaw_test_descriptor and _nemoclaw_test_phase == 'read':", " _nemoclaw_test_replace_path()", " return _nemoclaw_test_real_read(descriptor, size)", + "def _nemoclaw_test_stat(path, *args, **kwargs):", + " global _nemoclaw_test_returned_regular_stat", + " managed = _nemoclaw_test_os.fspath(path) == _nemoclaw_test_path", + " if managed and _nemoclaw_test_phase == 'eloop-regular-stat' and _nemoclaw_test_saw_eloop and not _nemoclaw_test_returned_regular_stat:", + " _nemoclaw_test_returned_regular_stat = True", + " return _nemoclaw_test_real_stat(_nemoclaw_test_target, follow_symlinks=False)", + " return _nemoclaw_test_real_stat(path, *args, **kwargs)", "_nemoclaw_test_os.open = _nemoclaw_test_open", "_nemoclaw_test_os.read = _nemoclaw_test_read", + "_nemoclaw_test_os.stat = _nemoclaw_test_stat", ].join("\n") : ""; const fixtureCommand = command @@ -175,11 +206,7 @@ export function runDeepAgentsConfigCommand( const configIsSymlink = configStat?.isSymbolicLink() === true; const configIsDirectory = configStat?.isDirectory() === true; const configText = - configExists && - !configIsFifo && - !configIsSocket && - !configIsSymlink && - !configIsDirectory + configExists && !configIsFifo && !configIsSocket && !configIsSymlink && !configIsDirectory ? fs.readFileSync(configPath, "utf-8") : null; const managedSymlinkTargetExists = fs.existsSync(managedSymlinkTarget); From b77384e3a8dc95f382e301282fc5d475e250da4a Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 18:53:30 -0700 Subject: [PATCH 10/49] fix(mcp): preserve unsafe race classification --- ...idge-adapter-deepagents-projection.test.ts | 48 ++++++++++++ ...cp-bridge-adapter-deepagents-projection.ts | 21 +++--- .../mcp-bridge-status-resolution.test.ts | 13 +++- .../mcp-bridge-adapter-deepagents-fixture.ts | 75 +++++++++++++------ 4 files changed, 122 insertions(+), 35 deletions(-) diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts index 25d01223017..3f0ba6c8cf8 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts @@ -193,6 +193,54 @@ describe("Deep Agents managed MCP projection safety", () => { ); }); + it("preserves unsafe classification when a hostile path appears after an absent open (#10754)", () => { + const statusCommand = buildDeepAgentsMcpStatusCommand(baseEntry); + const racedSymlink = runDeepAgentsConfigCommand( + statusCommand, + undefined, + "v2", + undefined, + 0o600, + { swapAfterManagedOpenError: "symlink", timeoutMs: managedCommandTimeoutMs }, + ); + expect(racedSymlink.status).toBe(2); + expect(racedSymlink.stdout.trim()).toBe(""); + expect(racedSymlink.stderr).toContain( + "Unsafe managed Deep Agents MCP projection path: symbolic link", + ); + expect(racedSymlink.configIsSymlink).toBe(true); + expect(racedSymlink.managedSymlinkTargetExists).toBe(false); + + const racedFifo = runDeepAgentsConfigCommand(statusCommand, undefined, "v2", undefined, 0o600, { + swapAfterManagedOpenError: "fifo", + timeoutMs: managedCommandTimeoutMs, + }); + expect(racedFifo.status).toBe(2); + expect(racedFifo.stdout.trim()).toBe(""); + expect(racedFifo.stderr).toContain("Unsafe managed Deep Agents MCP projection path: FIFO"); + expect(racedFifo.configIsFifo).toBe(true); + }); + + it("preserves ELOOP classification when a rejected symlink is briefly masked (#10754)", () => { + const raced = runDeepAgentsConfigCommand( + buildDeepAgentsMcpStatusCommand(baseEntry), + emptyProjection, + "v2", + undefined, + 0o600, + { + swapRejectedManagedSymlinkToRegular: attackerProjection, + timeoutMs: managedCommandTimeoutMs, + }, + ); + + expect(raced.status).toBe(2); + expect(raced.stdout.trim()).toBe(""); + expect(raced.stderr).toContain("Unsafe managed Deep Agents MCP projection path: symbolic link"); + expect(raced.configIsSymlink).toBe(true); + expect(raced.managedSymlinkTargetText).toBe(`${JSON.stringify(emptyProjection, null, 2)}\n`); + }); + it("keeps the generic diagnostic for ordinary projection inspection failures (#10754)", () => { const result = runDeepAgentsConfigCommand( buildDeepAgentsMcpStatusCommand(baseEntry), diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts index 52f0bde24ea..9b4c36eff79 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts @@ -30,15 +30,17 @@ export const DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS = [ " return None", "def managed_fingerprint(metadata):", " return (metadata.st_dev, metadata.st_ino, metadata.st_size, metadata.st_mtime_ns, metadata.st_ctime_ns, metadata.st_mode, metadata.st_nlink, metadata.st_uid)", - "def managed_path_identity(path):", + "def assert_managed_source_stable(path, identity, unsafe_path_prefix=None):", " try:", - " return managed_fingerprint(os.stat(path, follow_symlinks=False))", + " current_metadata = os.stat(path, follow_symlinks=False)", " except FileNotFoundError:", - " return None", - "def assert_managed_source_stable(path, identity):", - " current = managed_path_identity(path)", + " current_metadata = None", + " current = managed_fingerprint(current_metadata) if current_metadata is not None else None", " if identity is None:", " if current is not None:", + " unsafe_path_kind = managed_projection_path_kind(current_metadata.st_mode) if unsafe_path_prefix else None", + " if unsafe_path_kind:", + " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}')", " raise ValueError('managed MCP projection appeared during mutation')", " return", " if current != identity:", @@ -60,16 +62,15 @@ export const DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS = [ " try:", " descriptor = os.open(path, flags)", " except FileNotFoundError:", - " assert_managed_source_stable(path, None)", + " assert_managed_source_stable(path, None, unsafe_path_prefix)", " return b'', None, None", " except OSError as exc:", - " unsafe_path_kind = None", - " if unsafe_path_prefix:", + " unsafe_path_kind = 'symbolic link' if unsafe_path_prefix and exc.errno == errno.ELOOP else None", + " if unsafe_path_prefix and unsafe_path_kind is None:", " try:", " unsafe_path_kind = managed_projection_path_kind(os.stat(path, follow_symlinks=False).st_mode)", " except OSError:", - " if exc.errno == errno.ELOOP:", - " unsafe_path_kind = 'symbolic link'", + " pass", " if unsafe_path_kind:", " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}') from exc", " raise", diff --git a/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts b/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts index 18e6dc28cb6..4dd8b07ea1b 100644 --- a/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts @@ -210,9 +210,11 @@ describe("MCP status wire-level credential-resolution probe", { timeout: 15_000 }, }); const outcomes = []; - for (const detail of [ - "Unsafe managed Deep Agents MCP projection path: symbolic link", - "Unsafe managed Deep Agents MCP projection path: FIFO", + for (const [scenario, detail] of [ + ["static symlink", "Unsafe managed Deep Agents MCP projection path: symbolic link"], + ["static FIFO", "Unsafe managed Deep Agents MCP projection path: FIFO"], + ["symlink after absent open", "Unsafe managed Deep Agents MCP projection path: symbolic link"], + ["masked ELOOP symlink", "Unsafe managed Deep Agents MCP projection path: symbolic link"], ]) { processRecovery.executeSandboxCommand = () => ({ status: 2, @@ -223,6 +225,7 @@ describe("MCP status wire-level credential-resolution probe", { timeout: 15_000 errorLines.length = 0; await bridge.dispatchMcpBridgeCommand("alpha", ["status", "github", "--no-probe", "--json"]); outcomes.push({ + scenario, detail, exitCode: process.exitCode ?? 0, stdout: logLines.join("\n"), @@ -234,14 +237,16 @@ describe("MCP status wire-level credential-resolution probe", { timeout: 15_000 `, ); const outcomes = JSON.parse(stdout) as Array<{ + scenario: string; detail: string; exitCode: number; stdout: string; stderr: string; }>; - expect(outcomes).toHaveLength(2); + expect(outcomes).toHaveLength(4); outcomes.forEach((outcome) => { + expect(outcome.scenario).toBeTruthy(); expect(outcome.exitCode).toBe(2); expect(outcome.stdout).toBe(""); expect(outcome.stderr).toContain(outcome.detail); diff --git a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts index 1a0f8bcb61b..82f144112f6 100644 --- a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts +++ b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts @@ -43,7 +43,9 @@ export interface DeepAgentsManagedFixtureOptions { fifo?: boolean; mode?: number; swapAfterManagedOpen?: "fifo" | "symlink"; + swapAfterManagedOpenError?: "fifo" | "symlink"; swapBeforeManagedLink?: string; + swapRejectedManagedSymlinkToRegular?: string; swapOnManagedOpen?: "fifo" | "socket" | "symlink"; swapOnManagedRead?: "fifo" | "symlink"; swapOnManagedSeek?: string; @@ -54,9 +56,40 @@ export interface DeepAgentsManagedFixtureOptions { type ManagedSwap = { content?: string; kind: "fifo" | "regular" | "socket" | "symlink"; - phase: "after-open" | "before-link" | "before-open" | "read" | "seek"; + phase: "after-open" | "before-link" | "before-open" | "open-error" | "read" | "seek"; + restoreSymlink?: boolean; }; +function resolveManagedSwap(options: DeepAgentsManagedFixtureOptions): ManagedSwap | undefined { + if (options.swapOnManagedOpen) { + return { kind: options.swapOnManagedOpen, phase: "before-open" }; + } + if (options.swapAfterManagedOpen) { + return { kind: options.swapAfterManagedOpen, phase: "after-open" }; + } + if (options.swapAfterManagedOpenError) { + return { kind: options.swapAfterManagedOpenError, phase: "open-error" }; + } + if (options.swapRejectedManagedSymlinkToRegular !== undefined) { + return { + content: options.swapRejectedManagedSymlinkToRegular, + kind: "regular", + phase: "open-error", + restoreSymlink: true, + }; + } + if (options.swapOnManagedRead) { + return { kind: options.swapOnManagedRead, phase: "read" }; + } + if (options.swapBeforeManagedLink !== undefined) { + return { content: options.swapBeforeManagedLink, kind: "regular", phase: "before-link" }; + } + if (options.swapOnManagedSeek !== undefined) { + return { content: options.swapOnManagedSeek, kind: "regular", phase: "seek" }; + } + return undefined; +} + function createManagedSwapPythonExecutable( fixtureRoot: string, configPath: string, @@ -74,6 +107,7 @@ function createManagedSwapPythonExecutable( `managed_swap_kind = ${JSON.stringify(managedSwap.kind)}`, `managed_swap_phase = ${JSON.stringify(managedSwap.phase)}`, `managed_swap_content = ${JSON.stringify(managedSwap.content ?? "")}`, + `managed_restore_symlink = ${managedSwap.restoreSymlink === true ? "True" : "False"}`, "managed_socket = None", "managed_swapped = False", "def replace_managed_path():", @@ -96,6 +130,14 @@ function createManagedSwapPythonExecutable( " with open(managed_path, 'w', encoding='utf-8') as replacement:", " replacement.write(managed_swap_content)", " os.chmod(managed_path, 0o600)", + "def restore_managed_symlink():", + " if not managed_restore_symlink or not managed_swapped:", + " return", + " try:", + " os.unlink(managed_path)", + " except FileNotFoundError:", + " pass", + " os.symlink(managed_target, managed_path)", "def managed_profile(_frame, event, target):", " if managed_swapped:", " return", @@ -103,6 +145,8 @@ function createManagedSwapPythonExecutable( " replace_managed_path()", " elif managed_swap_phase == 'after-open' and event == 'c_return' and target is os.open:", " replace_managed_path()", + " elif managed_swap_phase == 'open-error' and event == 'c_exception' and target is os.open:", + " replace_managed_path()", " elif managed_swap_phase == 'read' and event == 'c_call' and target is os.read:", " replace_managed_path()", " elif managed_swap_phase == 'before-link' and event == 'c_call' and target is os.link:", @@ -116,6 +160,7 @@ function createManagedSwapPythonExecutable( " exec(compile(source, '', 'exec'), namespace)", "finally:", " sys.setprofile(None)", + " restore_managed_symlink()", "", ].join("\n"); fs.writeFileSync(scriptPath, wrapper, { mode: 0o600 }); @@ -135,25 +180,7 @@ export function runDeepAgentsConfigCommand( initialLegacyMode = 0o600, managedOptions: DeepAgentsManagedFixtureOptions = {}, ): DeepAgentsConfigCommandResult { - const managedSwap: ManagedSwap | undefined = managedOptions.swapOnManagedOpen - ? { kind: managedOptions.swapOnManagedOpen, phase: "before-open" } - : managedOptions.swapAfterManagedOpen - ? { kind: managedOptions.swapAfterManagedOpen, phase: "after-open" } - : managedOptions.swapOnManagedRead - ? { kind: managedOptions.swapOnManagedRead, phase: "read" } - : managedOptions.swapBeforeManagedLink !== undefined - ? { - content: managedOptions.swapBeforeManagedLink, - kind: "regular" as const, - phase: "before-link" as const, - } - : managedOptions.swapOnManagedSeek !== undefined - ? { - content: managedOptions.swapOnManagedSeek, - kind: "regular" as const, - phase: "seek" as const, - } - : undefined; + const managedSwap = resolveManagedSwap(managedOptions); const fixtureRoot = managedSwap?.kind === "socket" ? "/tmp" : os.tmpdir(); const tmp = fs.mkdtempSync(path.join(fixtureRoot, "nemoclaw-deepagents-mcp-")); const configPath = path.join(tmp, ".deepagents", ".nemoclaw-mcp.json"); @@ -172,7 +199,10 @@ export function runDeepAgentsConfigCommand( { mode }, ); }; - const managedSymlink = managedOptions.symlink === true || managedOptions.danglingSymlink === true; + const managedSymlink = + managedOptions.symlink === true || + managedOptions.danglingSymlink === true || + managedOptions.swapRejectedManagedSymlinkToRegular !== undefined; const managedInitialPath = managedSymlink ? managedSymlinkTarget : configPath; if (managedOptions.directory) { fs.mkdirSync(configPath, { recursive: true, mode: managedOptions.mode ?? 0o700 }); @@ -195,6 +225,9 @@ export function runDeepAgentsConfigCommand( if (managedSwap?.kind === "symlink") { initializeConfig(managedSymlinkTarget, initialConfig, managedOptions.mode); } + if (managedSwap?.phase === "open-error") { + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + } initializeConfig(legacyConfigPath, initialLegacyConfig); if (initialLegacyConfig !== undefined) fs.chmodSync(legacyConfigPath, initialLegacyMode); try { From 89e98a7f589f97e8c1b90d0386d6174ce8f17014 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 19:58:31 -0700 Subject: [PATCH 11/49] fix(mcp): prioritize unsafe status paths --- docs/manage-sandboxes/manage-mcp-servers.mdx | 3 +- .../mcp-bridge-status-resolution.test.ts | 50 +++++++++++-------- src/lib/actions/sandbox/mcp-bridge-status.ts | 19 ++++--- 3 files changed, 43 insertions(+), 29 deletions(-) diff --git a/docs/manage-sandboxes/manage-mcp-servers.mdx b/docs/manage-sandboxes/manage-mcp-servers.mdx index 4fdb898ea95..cbe29b0ad18 100644 --- a/docs/manage-sandboxes/manage-mcp-servers.mdx +++ b/docs/manage-sandboxes/manage-mcp-servers.mdx @@ -39,12 +39,13 @@ For a trusted private server, status resolves the endpoint without changing mana -Before it reads `/sandbox/.deepagents/.nemoclaw-mcp.json`, Deep Agents Code `mcp status` checks the path type. +Before it reads `/sandbox/.deepagents/.nemoclaw-mcp.json`, Deep Agents Code v2 `mcp status` checks the path type. It does not follow a final symbolic link or read from a FIFO. If the path is a symbolic link, FIFO, or another non-regular file, the command names the path type, writes no status output, and exits with status `2`. The diagnostics are `Unsafe managed Deep Agents MCP projection path: symbolic link`, `Unsafe managed Deep Agents MCP projection path: FIFO`, and `Unsafe managed Deep Agents MCP projection path: non-regular file`. This exit status applies only to the unsafe path-type diagnostic. Other adapter inspection failures retain their ordinary status behavior. +Deep Agents Code v1 reads the legacy `.deepagents/.mcp.json` path and does not expose these type-specific diagnostics. diff --git a/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts b/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts index baa3e18d562..e022775ee82 100644 --- a/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts @@ -97,11 +97,13 @@ let providerCredentialObservation = "v11"; let credentialObservationCount = 0; processRecovery.executeSandboxExecCommand = () => { credentialObservationCount += 1; - return { - status: 0, - stdout: providerCredentialObservation, - stderr: "", - }; + return providerCredentialObservation === null + ? undefined + : { + status: 0, + stdout: providerCredentialObservation, + stderr: "", + }; }; processRecovery.executeSandboxCommand = (sandboxName, command) => { executedSandboxCommands.push(command); @@ -242,34 +244,40 @@ describe("MCP status wire-level credential-resolution probe", { timeout: 15_000 }); const outcomes = []; for (const detail of ${JSON.stringify(unsafeDetails)}) { - processRecovery.executeSandboxCommand = () => ({ - status: 2, - stdout: "", - stderr: detail + "\n", - }); - logLines.length = 0; - errorLines.length = 0; - await bridge.dispatchMcpBridgeCommand("alpha", ["status", "github", "--no-probe", "--json"]); - outcomes.push({ - detail, - exitCode: process.exitCode ?? 0, - stdout: logLines.join("\n"), - stderr: errorLines.join("\n"), - }); - process.exitCode = 0; + for (const observation of [null, "absent", "canonical"]) { + providerCredentialObservation = observation; + processRecovery.executeSandboxCommand = () => ({ + status: 2, + stdout: "", + stderr: detail + "\n", + }); + logLines.length = 0; + errorLines.length = 0; + await bridge.dispatchMcpBridgeCommand("alpha", ["status", "github", "--no-probe", "--json"]); + outcomes.push({ + detail, + observation: observation ?? "unavailable", + exitCode: process.exitCode ?? 0, + stdout: logLines.join("\n"), + stderr: errorLines.join("\n"), + }); + process.exitCode = 0; + } } process.stdout.write(JSON.stringify(outcomes)); `, ); const outcomes = JSON.parse(stdout) as Array<{ detail: string; + observation: string; exitCode: number; stdout: string; stderr: string; }>; - expect(outcomes).toHaveLength(3); + expect(outcomes).toHaveLength(9); outcomes.forEach((outcome) => { + expect(["unavailable", "absent", "canonical"]).toContain(outcome.observation); expect(outcome.exitCode).toBe(2); expect(outcome.stdout).toBe(""); expect(outcome.stderr).toContain(outcome.detail); diff --git a/src/lib/actions/sandbox/mcp-bridge-status.ts b/src/lib/actions/sandbox/mcp-bridge-status.ts index a030014be30..7d04efa0f55 100644 --- a/src/lib/actions/sandbox/mcp-bridge-status.ts +++ b/src/lib/actions/sandbox/mcp-bridge-status.ts @@ -97,12 +97,14 @@ function getAdapterRegistration( ): McpBridgeStatus["adapter"] { if (!entry) return { registered: null }; if (!adapter) return { registered: null, detail: "MCP adapter is not declared" }; - if (credentialObservationDetail) { - return { - registered: null, - detail: `Adapter inspection was skipped because ${credentialObservationDetail}.`, - }; - } + const credentialInspectionSkip: McpBridgeStatus["adapter"] | null = + credentialObservationDetail === undefined + ? null + : { + registered: null, + detail: `Adapter inspection was skipped because ${credentialObservationDetail}.`, + }; + if (credentialInspectionSkip && adapter !== "deepagents-config") return credentialInspectionSkip; if (adapter === "hermes-config" && hermesReconciliation) { return hermesReconciliation.ok ? { registered: true } @@ -120,8 +122,10 @@ function getAdapterRegistration( ? buildHermesMcpStatusCommand(entry, credentialRevision) : buildDeepAgentsMcpStatusCommand(entry, credentialRevision); const result = executeSandboxCommand(sandboxName, command); - if (!result) return { registered: null, detail: "sandbox unreachable" }; + if (!result) + return credentialInspectionSkip ?? { registered: null, detail: "sandbox unreachable" }; if (result.status === 0) { + if (credentialInspectionSkip) return credentialInspectionSkip; const output = result.stdout.trim(); if (output === "registered") return { registered: true }; return { registered: false, detail: output || "not found" }; @@ -139,6 +143,7 @@ function getAdapterRegistration( ) { throw new McpBridgeError(normalizedDetail, 2); } + if (credentialInspectionSkip) return credentialInspectionSkip; return { registered: false, detail, From 79cccd9099e5698af6eb9aa5d3ae5a09758b9bc7 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 22:15:43 -0700 Subject: [PATCH 12/49] fix(mcp): pin managed projection parents --- .../mcp-bridge-adapter-deepagents-legacy.ts | 10 +- ...idge-adapter-deepagents-projection.test.ts | 447 +++++++++++------- ...cp-bridge-adapter-deepagents-projection.ts | 286 +++++++---- ...-bridge-adapter-deepagents-registration.ts | 10 +- .../mcp-bridge-adapter-deepagents-teardown.ts | 22 +- .../mcp-bridge-status-resolution.test.ts | 36 +- .../mcp-bridge-adapter-deepagents-fixture.ts | 25 +- 7 files changed, 508 insertions(+), 328 deletions(-) diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy.ts index 50251a1de33..f7d3e18acd6 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy.ts @@ -71,14 +71,16 @@ export function buildDeepAgentsMcpRollbackRegisterCommand( "data = {}", "managed_identity = None", "managed_descriptor = None", + "managed_parent_identity = None", + "managed_parent_descriptor = None", "legacy_identity = None", "def fail_rollback(message):", - " close_managed_projection_descriptor(managed_descriptor)", + " close_managed_projection_descriptors(managed_descriptor, managed_parent_descriptor)", " print(message, file=sys.stderr)", " raise SystemExit(2)", "try:", " if is_v2:", - " data, managed_identity, managed_descriptor = load_managed_projection_for_update(config_path)", + " data, managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity = load_managed_projection_for_update(config_path)", " elif os.path.lexists(config_path):", " data, legacy_identity = read_legacy_config(config_path)", "except (OSError, UnicodeDecodeError, ValueError) as exc:", @@ -108,8 +110,8 @@ export function buildDeepAgentsMcpRollbackRegisterCommand( " if payload['server'] in servers and current != payload['expected']:", " fail_rollback(f'Refusing to overwrite user-owned legacy MCP server at {config_path}')", " servers[payload['server']] = payload['expected']", - "config_path.parent.mkdir(parents=True, exist_ok=True)", "if not is_v2:", + " config_path.parent.mkdir(parents=True, exist_ok=True)", " tmp_fd, tmp_name = tempfile.mkstemp(prefix='.nemoclaw-mcp.', dir=config_path.parent)", " try:", " os.fchmod(tmp_fd, 0o600)", @@ -131,7 +133,7 @@ export function buildDeepAgentsMcpRollbackRegisterCommand( " pass", "else:", " try:", - " write_managed_projection(config_path, data, managed_identity, managed_descriptor)", + " write_managed_projection(config_path, data, managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity)", " except (OSError, ValueError) as exc:", " fail_rollback(f'Could not publish managed MCP rollback state at {config_path}: {exc}')", "try:", diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts index 02d46fbda32..b020684c52b 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts @@ -24,203 +24,243 @@ const rollbackCommand = buildDeepAgentsMcpRegisterCommand(baseEntry, true, [base const removalCommand = buildDeepAgentsMcpRemoveCommand(baseEntry); describe("Deep Agents managed MCP projection safety", () => { - it("applies the shared server cap before normal and rollback v2 publication", () => { - const entries = Array.from( - { length: DEEPAGENTS_MCP_MAX_SERVERS + 1 }, - (_, index): McpBridgeEntry => ({ - ...baseEntry, - server: `server${String(index)}`, - env: [`SERVER_${String(index)}_TOKEN`], - }), - ); + it( + "applies the shared server cap before normal and rollback v2 publication", + { timeout: 60_000 }, + () => { + const entries = Array.from( + { length: DEEPAGENTS_MCP_MAX_SERVERS + 1 }, + (_, index): McpBridgeEntry => ({ + ...baseEntry, + server: `server${String(index)}`, + env: [`SERVER_${String(index)}_TOKEN`], + }), + ); - expect(() => buildDeepAgentsMcpRegisterCommand(entries[0], false, entries)).toThrow( - `at most ${String(DEEPAGENTS_MCP_MAX_SERVERS)} servers`, - ); - const rollback = runDeepAgentsConfigCommand( - buildDeepAgentsMcpRegisterCommand(entries[0], true, entries, true), - ); - expect(rollback.status).toBe(2); - expect(rollback.stderr).toContain( - `supports at most ${String(DEEPAGENTS_MCP_MAX_SERVERS)} servers`, - ); - }); + expect(() => buildDeepAgentsMcpRegisterCommand(entries[0], false, entries)).toThrow( + `at most ${String(DEEPAGENTS_MCP_MAX_SERVERS)} servers`, + ); + const rollback = runDeepAgentsConfigCommand( + buildDeepAgentsMcpRegisterCommand(entries[0], true, entries, true), + undefined, + "v2", + undefined, + 0o600, + { timeoutMs: managedCommandTimeoutMs }, + ); + expect(rollback.status).toBe(2); + expect(rollback.stderr).toContain( + `supports at most ${String(DEEPAGENTS_MCP_MAX_SERVERS)} servers`, + ); + }, + ); - it("names unsafe projection types without following hostile paths (#10754)", () => { - const statusCommand = buildDeepAgentsMcpStatusCommand(baseEntry); - const symlink = runDeepAgentsConfigCommand( - statusCommand, - emptyProjection, - "v2", - undefined, - 0o600, - { symlink: true, timeoutMs: managedCommandTimeoutMs }, - ); - expect(symlink.status).toBe(2); - expect(symlink.stdout.trim()).toBe(""); - expect(symlink.stderr).toContain( - "Unsafe managed Deep Agents MCP projection path: symbolic link", - ); - expect(symlink.managedSymlinkTargetText).toBe(`${JSON.stringify(emptyProjection, null, 2)}\n`); + it( + "names unsafe projection types without following hostile paths (#10754)", + { timeout: 60_000 }, + () => { + const statusCommand = buildDeepAgentsMcpStatusCommand(baseEntry); + const symlink = runDeepAgentsConfigCommand( + statusCommand, + emptyProjection, + "v2", + undefined, + 0o600, + { symlink: true, timeoutMs: managedCommandTimeoutMs }, + ); + expect(symlink.status).toBe(2); + expect(symlink.stdout.trim()).toBe(""); + expect(symlink.stderr).toContain( + "Unsafe managed Deep Agents MCP projection path: symbolic link", + ); + expect(symlink.managedSymlinkTargetText).toBe( + `${JSON.stringify(emptyProjection, null, 2)}\n`, + ); - const fifo = runDeepAgentsConfigCommand(statusCommand, undefined, "v2", undefined, 0o600, { - fifo: true, - timeoutMs: managedCommandTimeoutMs, - }); - expect(fifo.status).toBe(2); - expect(fifo.stdout.trim()).toBe(""); - expect(fifo.stderr).toContain("Unsafe managed Deep Agents MCP projection path: FIFO"); + const fifo = runDeepAgentsConfigCommand(statusCommand, undefined, "v2", undefined, 0o600, { + fifo: true, + timeoutMs: managedCommandTimeoutMs, + }); + expect(fifo.status).toBe(2); + expect(fifo.stdout.trim()).toBe(""); + expect(fifo.stderr).toContain("Unsafe managed Deep Agents MCP projection path: FIFO"); - const directory = runDeepAgentsConfigCommand(statusCommand, undefined, "v2", undefined, 0o600, { - directory: true, - timeoutMs: managedCommandTimeoutMs, - }); - expect(directory.status).toBe(2); - expect(directory.stdout.trim()).toBe(""); - expect(directory.stderr).toContain( - "Unsafe managed Deep Agents MCP projection path: non-regular file", - ); + const directory = runDeepAgentsConfigCommand( + statusCommand, + undefined, + "v2", + undefined, + 0o600, + { + directory: true, + timeoutMs: managedCommandTimeoutMs, + }, + ); + expect(directory.status).toBe(2); + expect(directory.stdout.trim()).toBe(""); + expect(directory.stderr).toContain( + "Unsafe managed Deep Agents MCP projection path: non-regular file", + ); - const danglingSymlink = runDeepAgentsConfigCommand( - statusCommand, - undefined, - "v2", - undefined, - 0o600, - { danglingSymlink: true, timeoutMs: managedCommandTimeoutMs }, - ); - expect(danglingSymlink.status).toBe(2); - expect(danglingSymlink.stdout.trim()).toBe(""); - expect(danglingSymlink.stderr).toContain( - "Unsafe managed Deep Agents MCP projection path: symbolic link", - ); + const danglingSymlink = runDeepAgentsConfigCommand( + statusCommand, + undefined, + "v2", + undefined, + 0o600, + { danglingSymlink: true, timeoutMs: managedCommandTimeoutMs }, + ); + expect(danglingSymlink.status).toBe(2); + expect(danglingSymlink.stdout.trim()).toBe(""); + expect(danglingSymlink.stderr).toContain( + "Unsafe managed Deep Agents MCP projection path: symbolic link", + ); - const racedSymlink = runDeepAgentsConfigCommand( - statusCommand, - emptyProjection, - "v2", - undefined, - 0o600, - { swapOnManagedOpen: "symlink", timeoutMs: managedCommandTimeoutMs }, - ); - expect(racedSymlink.status).toBe(2); - expect(racedSymlink.stdout.trim()).toBe(""); - expect(racedSymlink.stderr).toContain( - "Unsafe managed Deep Agents MCP projection path: symbolic link", - ); - expect(racedSymlink.configIsSymlink).toBe(true); - expect(racedSymlink.managedSymlinkTargetText).toBe( - `${JSON.stringify(emptyProjection, null, 2)}\n`, - ); + const racedSymlink = runDeepAgentsConfigCommand( + statusCommand, + emptyProjection, + "v2", + undefined, + 0o600, + { swapOnManagedOpen: "symlink", timeoutMs: managedCommandTimeoutMs }, + ); + expect(racedSymlink.status).toBe(2); + expect(racedSymlink.stdout.trim()).toBe(""); + expect(racedSymlink.stderr).toContain( + "Unsafe managed Deep Agents MCP projection path: symbolic link", + ); + expect(racedSymlink.configIsSymlink).toBe(true); + expect(racedSymlink.managedSymlinkTargetText).toBe( + `${JSON.stringify(emptyProjection, null, 2)}\n`, + ); - const racedFifo = runDeepAgentsConfigCommand( - statusCommand, - emptyProjection, - "v2", - undefined, - 0o600, - { swapOnManagedOpen: "fifo", timeoutMs: managedCommandTimeoutMs }, - ); - expect(racedFifo.status).toBe(2); - expect(racedFifo.stdout.trim()).toBe(""); - expect(racedFifo.stderr).toContain("Unsafe managed Deep Agents MCP projection path: FIFO"); - expect(racedFifo.configIsFifo).toBe(true); + const racedFifo = runDeepAgentsConfigCommand( + statusCommand, + emptyProjection, + "v2", + undefined, + 0o600, + { swapOnManagedOpen: "fifo", timeoutMs: managedCommandTimeoutMs }, + ); + expect(racedFifo.status).toBe(2); + expect(racedFifo.stdout.trim()).toBe(""); + expect(racedFifo.stderr).toContain("Unsafe managed Deep Agents MCP projection path: FIFO"); + expect(racedFifo.configIsFifo).toBe(true); - const racedSocket = runDeepAgentsConfigCommand( - statusCommand, - emptyProjection, - "v2", - undefined, - 0o600, - { swapOnManagedOpen: "socket", timeoutMs: managedCommandTimeoutMs }, - ); - expect(racedSocket.status).toBe(2); - expect(racedSocket.stdout.trim()).toBe(""); - expect(racedSocket.stderr).toContain( - "Unsafe managed Deep Agents MCP projection path: non-regular file", - ); - expect(racedSocket.configIsSocket).toBe(true); + const racedSocket = runDeepAgentsConfigCommand( + statusCommand, + emptyProjection, + "v2", + undefined, + 0o600, + { swapOnManagedOpen: "socket", timeoutMs: managedCommandTimeoutMs }, + ); + expect(racedSocket.status).toBe(2); + expect(racedSocket.stdout.trim()).toBe(""); + expect(racedSocket.stderr).toContain( + "Unsafe managed Deep Agents MCP projection path: non-regular file", + ); + expect(racedSocket.configIsSocket).toBe(true); - const replacedAfterOpenWithSymlink = runDeepAgentsConfigCommand( - statusCommand, - emptyProjection, - "v2", - undefined, - 0o600, - { swapAfterManagedOpen: "symlink", timeoutMs: managedCommandTimeoutMs }, - ); - expect(replacedAfterOpenWithSymlink.status).toBe(2); - expect(replacedAfterOpenWithSymlink.stdout.trim()).toBe(""); - expect(replacedAfterOpenWithSymlink.stderr).toContain( - "Unsafe managed Deep Agents MCP projection path: symbolic link", - ); - expect(replacedAfterOpenWithSymlink.configIsSymlink).toBe(true); - expect(replacedAfterOpenWithSymlink.managedSymlinkTargetText).toBe( - `${JSON.stringify(emptyProjection, null, 2)}\n`, - ); + const replacedAfterOpenWithSymlink = runDeepAgentsConfigCommand( + statusCommand, + emptyProjection, + "v2", + undefined, + 0o600, + { swapAfterManagedOpen: "symlink", timeoutMs: managedCommandTimeoutMs }, + ); + expect(replacedAfterOpenWithSymlink.status).toBe(2); + expect(replacedAfterOpenWithSymlink.stdout.trim()).toBe(""); + expect(replacedAfterOpenWithSymlink.stderr).toContain( + "Unsafe managed Deep Agents MCP projection path: symbolic link", + ); + expect(replacedAfterOpenWithSymlink.configIsSymlink).toBe(true); + expect(replacedAfterOpenWithSymlink.managedSymlinkTargetText).toBe( + `${JSON.stringify(emptyProjection, null, 2)}\n`, + ); - const replacedAfterOpenWithFifo = runDeepAgentsConfigCommand( - statusCommand, - emptyProjection, - "v2", - undefined, - 0o600, - { swapAfterManagedOpen: "fifo", timeoutMs: managedCommandTimeoutMs }, - ); - expect(replacedAfterOpenWithFifo.status).toBe(2); - expect(replacedAfterOpenWithFifo.stdout.trim()).toBe(""); - expect(replacedAfterOpenWithFifo.stderr).toContain( - "Unsafe managed Deep Agents MCP projection path: FIFO", - ); - expect(replacedAfterOpenWithFifo.configIsFifo).toBe(true); + const replacedAfterOpenWithFifo = runDeepAgentsConfigCommand( + statusCommand, + emptyProjection, + "v2", + undefined, + 0o600, + { swapAfterManagedOpen: "fifo", timeoutMs: managedCommandTimeoutMs }, + ); + expect(replacedAfterOpenWithFifo.status).toBe(2); + expect(replacedAfterOpenWithFifo.stdout.trim()).toBe(""); + expect(replacedAfterOpenWithFifo.stderr).toContain( + "Unsafe managed Deep Agents MCP projection path: FIFO", + ); + expect(replacedAfterOpenWithFifo.configIsFifo).toBe(true); - const replacedWhileReadingWithSymlink = runDeepAgentsConfigCommand( - statusCommand, - emptyProjection, - "v2", - undefined, - 0o600, - { swapOnManagedRead: "symlink", timeoutMs: managedCommandTimeoutMs }, - ); - expect(replacedWhileReadingWithSymlink.status).toBe(2); - expect(replacedWhileReadingWithSymlink.stdout.trim()).toBe(""); - expect(replacedWhileReadingWithSymlink.stderr).toContain( - "Unsafe managed Deep Agents MCP projection path: symbolic link", - ); - expect(replacedWhileReadingWithSymlink.configIsSymlink).toBe(true); - expect(replacedWhileReadingWithSymlink.managedSymlinkTargetText).toBe( - `${JSON.stringify(emptyProjection, null, 2)}\n`, - ); + const replacedWhileReadingWithSymlink = runDeepAgentsConfigCommand( + statusCommand, + emptyProjection, + "v2", + undefined, + 0o600, + { swapOnManagedRead: "symlink", timeoutMs: managedCommandTimeoutMs }, + ); + expect(replacedWhileReadingWithSymlink.status).toBe(2); + expect(replacedWhileReadingWithSymlink.stdout.trim()).toBe(""); + expect(replacedWhileReadingWithSymlink.stderr).toContain( + "Unsafe managed Deep Agents MCP projection path: symbolic link", + ); + expect(replacedWhileReadingWithSymlink.configIsSymlink).toBe(true); + expect(replacedWhileReadingWithSymlink.managedSymlinkTargetText).toBe( + `${JSON.stringify(emptyProjection, null, 2)}\n`, + ); - const replacedAfterEloop = runDeepAgentsConfigCommand( - statusCommand, - emptyProjection, - "v2", - undefined, - 0o600, - { - statAfterManagedEloopAsRegular: true, - symlink: true, - timeoutMs: managedCommandTimeoutMs, - }, - ); - expect(replacedAfterEloop.status).toBe(2); - expect(replacedAfterEloop.stdout.trim()).toBe(""); - expect(replacedAfterEloop.stderr).toContain( - "Unsafe managed Deep Agents MCP projection path: symbolic link", - ); - expect(replacedAfterEloop.configIsSymlink).toBe(true); - expect(replacedAfterEloop.managedSymlinkTargetText).toBe( - `${JSON.stringify(emptyProjection, null, 2)}\n`, - ); - }); + const replacedAfterEloop = runDeepAgentsConfigCommand( + statusCommand, + emptyProjection, + "v2", + undefined, + 0o600, + { + statAfterManagedEloopAsRegular: true, + symlink: true, + timeoutMs: managedCommandTimeoutMs, + }, + ); + expect(replacedAfterEloop.status).toBe(2); + expect(replacedAfterEloop.stdout.trim()).toBe(""); + expect(replacedAfterEloop.stderr).toContain( + "Unsafe managed Deep Agents MCP projection path: symbolic link", + ); + expect(replacedAfterEloop.configIsSymlink).toBe(true); + expect(replacedAfterEloop.managedSymlinkTargetText).toBe( + `${JSON.stringify(emptyProjection, null, 2)}\n`, + ); + }, + ); it.each([ - { appearedType: "symlink" as const, diagnostic: "symbolic link" }, - { appearedType: "fifo" as const, diagnostic: "FIFO" }, + { + appearedType: "symlink" as const, + diagnostic: "symbolic link", + expectedConfigIsFifo: false, + expectedConfigIsSymlink: true, + expectedTargetText: `${JSON.stringify(emptyProjection, null, 2)}\n`, + }, + { + appearedType: "fifo" as const, + diagnostic: "FIFO", + expectedConfigIsFifo: true, + expectedConfigIsSymlink: false, + expectedTargetText: null, + }, ])( "classifies a $diagnostic that appears after a missing-path open (#10754)", - ({ appearedType, diagnostic }) => { + ({ + appearedType, + diagnostic, + expectedConfigIsFifo, + expectedConfigIsSymlink, + expectedTargetText, + }) => { const result = runDeepAgentsConfigCommand( buildDeepAgentsMcpStatusCommand(baseEntry), emptyProjection, @@ -235,10 +275,49 @@ describe("Deep Agents managed MCP projection safety", () => { expect(result.stderr).toContain( `Unsafe managed Deep Agents MCP projection path: ${diagnostic}`, ); - expect(appearedType === "symlink" ? result.configIsSymlink : result.configIsFifo).toBe(true); - expect(result.managedSymlinkTargetText).toBe( - appearedType === "symlink" ? `${JSON.stringify(emptyProjection, null, 2)}\n` : null, + expect(result.configIsFifo).toBe(expectedConfigIsFifo); + expect(result.configIsSymlink).toBe(expectedConfigIsSymlink); + expect(result.managedSymlinkTargetText).toBe(expectedTargetText); + }, + ); + + it("rejects a symlinked projection parent without reading its target (#10754)", () => { + const initialText = `${JSON.stringify(emptyProjection, null, 2)}\n`; + const status = runDeepAgentsConfigCommand( + buildDeepAgentsMcpStatusCommand(baseEntry), + emptyProjection, + "v2", + undefined, + 0o600, + { parentSymlink: true }, + ); + expect(status.status).toBe(2); + expect(status.stdout.trim()).toBe(""); + expect(status.stderr).toContain( + "Unsafe managed Deep Agents MCP projection path: symbolic link", + ); + expect(status.managedParentIsSymlink).toBe(true); + expect(status.managedParentTargetText).toBe(initialText); + }); + + it.each([ + ["registration", registrationCommand], + ["forced removal", buildDeepAgentsMcpRemoveCommand(baseEntry, true)], + ])( + "rejects a symlinked projection parent during %s without mutating its target (#10754)", + (_operation, command) => { + const initialText = `${JSON.stringify(emptyProjection, null, 2)}\n`; + const mutation = runDeepAgentsConfigCommand( + command, + emptyProjection, + "v2", + undefined, + 0o600, + { parentSymlink: true }, ); + expect(mutation.status).toBe(2); + expect(mutation.managedParentIsSymlink).toBe(true); + expect(mutation.managedParentTargetText).toBe(initialText); }, ); diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts index d484ec70a7c..c21db5ed120 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts @@ -28,11 +28,76 @@ export const DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS = [ " if not stat.S_ISREG(mode):", " return 'non-regular file'", " return None", + "def managed_parent_path_kind(mode):", + " if stat.S_ISLNK(mode):", + " return 'symbolic link'", + " if not stat.S_ISDIR(mode):", + " return 'non-directory parent'", + " return None", "def managed_fingerprint(metadata):", " return (metadata.st_dev, metadata.st_ino, metadata.st_size, metadata.st_mtime_ns, metadata.st_ctime_ns, metadata.st_mode, metadata.st_nlink, metadata.st_uid)", - "def assert_managed_source_stable(path, identity, unsafe_path_prefix=None):", + "def managed_parent_fingerprint(metadata):", + " return (metadata.st_dev, metadata.st_ino, metadata.st_mode, metadata.st_uid)", + "def raise_managed_parent_error(parent_descriptor, component, exc, unsafe_path_prefix=None):", + " unsafe_path_kind = None", + " try:", + " unsafe_path_kind = managed_parent_path_kind(os.stat(component, dir_fd=parent_descriptor, follow_symlinks=False).st_mode)", + " except OSError:", + " pass", + " if unsafe_path_kind and unsafe_path_prefix:", + " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}') from exc", + " if unsafe_path_kind:", + " raise ValueError(f'managed MCP projection parent is a {unsafe_path_kind}') from exc", + " raise exc", + "def open_managed_parent(path, create=False, unsafe_path_prefix=None):", + " if not path.is_absolute() or not path.name or path.name in ('.', '..'):", + " raise ValueError('managed MCP projection path must be absolute')", + " components = path.parts[1:-1]", + " descriptor = os.open('/', os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC)", + " created = False", + " try:", + " for index, component in enumerate(components):", + " flags = os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC | os.O_NOFOLLOW", + " try:", + " next_descriptor = os.open(component, flags, dir_fd=descriptor)", + " except FileNotFoundError:", + " if not create or index != len(components) - 1:", + " raise", + " try:", + " os.mkdir(component, 0o700, dir_fd=descriptor)", + " created = True", + " except FileExistsError:", + " pass", + " try:", + " next_descriptor = os.open(component, flags, dir_fd=descriptor)", + " except OSError as exc:", + " raise_managed_parent_error(descriptor, component, exc, unsafe_path_prefix)", + " except OSError as exc:", + " raise_managed_parent_error(descriptor, component, exc, unsafe_path_prefix)", + " os.close(descriptor)", + " descriptor = next_descriptor", + " metadata = os.fstat(descriptor)", + " if not stat.S_ISDIR(metadata.st_mode):", + " raise ValueError('managed MCP projection parent is not a directory')", + " return descriptor, managed_parent_fingerprint(metadata), created", + " except Exception:", + " os.close(descriptor)", + " raise", + "def assert_managed_parent_stable(path, identity, unsafe_path_prefix=None):", " try:", - " current_metadata = os.stat(path, follow_symlinks=False)", + " descriptor, current, _ = open_managed_parent(path, unsafe_path_prefix=unsafe_path_prefix)", + " except FileNotFoundError:", + " current = None", + " descriptor = None", + " try:", + " if current != identity:", + " raise ValueError('managed MCP projection parent changed during operation')", + " finally:", + " if descriptor is not None:", + " os.close(descriptor)", + "def assert_managed_source_stable(entry_name, parent_descriptor, identity, unsafe_path_prefix=None):", + " try:", + " current_metadata = os.stat(entry_name, dir_fd=parent_descriptor, follow_symlinks=False)", " except FileNotFoundError:", " current_metadata = None", " current = managed_fingerprint(current_metadata) if current_metadata is not None else None", @@ -45,9 +110,9 @@ export const DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS = [ " return", " if current != identity:", " raise ValueError('managed MCP projection changed before mutation')", - "def validate_managed_descriptor_path(path, descriptor, unsafe_path_prefix=None):", + "def validate_managed_descriptor_path(entry_name, parent_descriptor, descriptor, unsafe_path_prefix=None):", " opened = os.fstat(descriptor)", - " linked = os.stat(path, follow_symlinks=False)", + " linked = os.stat(entry_name, dir_fd=parent_descriptor, follow_symlinks=False)", " if unsafe_path_prefix:", " unsafe_path_kind = managed_projection_path_kind(linked.st_mode)", " if unsafe_path_kind:", @@ -57,78 +122,90 @@ export const DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS = [ " raise ValueError('managed MCP projection has unsafe ownership, mode, type, links, or path identity')", " return managed_fingerprint(opened)", "def open_managed_projection(path, writable=False, unsafe_path_prefix=None):", - " access = os.O_RDWR if writable else os.O_RDONLY", - " flags = access | os.O_CLOEXEC | os.O_NONBLOCK | os.O_NOFOLLOW", " try:", - " descriptor = os.open(path, flags)", + " parent_descriptor, parent_identity, _ = open_managed_parent(path, unsafe_path_prefix=unsafe_path_prefix)", " except FileNotFoundError:", - " assert_managed_source_stable(path, None, unsafe_path_prefix)", - " return b'', None, None", - " except OSError as exc:", - " unsafe_path_kind = None", - " if unsafe_path_prefix:", - " if exc.errno == errno.ELOOP:", - " unsafe_path_kind = 'symbolic link'", - " else:", - " try:", - " unsafe_path_kind = managed_projection_path_kind(os.stat(path, follow_symlinks=False).st_mode)", - " except OSError:", - " pass", - " if unsafe_path_kind:", - " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}') from exc", - " raise", + " return b'', None, None, None, None", + " entry_name = path.name", + " access = os.O_RDWR if writable else os.O_RDONLY", + " flags = access | os.O_CLOEXEC | os.O_NONBLOCK | os.O_NOFOLLOW", " try:", - " before = os.fstat(descriptor)", - " if unsafe_path_prefix:", - " unsafe_path_kind = managed_projection_path_kind(before.st_mode)", - " if unsafe_path_kind:", - " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}')", - " validate_managed_descriptor_path(path, descriptor, unsafe_path_prefix)", - " if before.st_size < 0 or before.st_size > MANAGED_MCP_MAX_BYTES:", - " raise ValueError('managed MCP projection has invalid size')", - " chunks = []", - " remaining = before.st_size", - " while remaining:", - " chunk = os.read(descriptor, remaining)", - " if not chunk:", - " break", - " chunks.append(chunk)", - " remaining -= len(chunk)", - " after = os.fstat(descriptor)", - " linked_after = os.stat(path, follow_symlinks=False)", - " if unsafe_path_prefix:", - " unsafe_path_kind = managed_projection_path_kind(linked_after.st_mode)", + " try:", + " descriptor = os.open(entry_name, flags, dir_fd=parent_descriptor)", + " except FileNotFoundError:", + " assert_managed_source_stable(entry_name, parent_descriptor, None, unsafe_path_prefix)", + " assert_managed_parent_stable(path, parent_identity, unsafe_path_prefix)", + " return b'', None, None, parent_descriptor, parent_identity", + " except OSError as exc:", + " unsafe_path_kind = None", + " if unsafe_path_prefix:", + " if exc.errno == errno.ELOOP:", + " unsafe_path_kind = 'symbolic link'", + " else:", + " try:", + " unsafe_path_kind = managed_projection_path_kind(os.stat(entry_name, dir_fd=parent_descriptor, follow_symlinks=False).st_mode)", + " except OSError:", + " pass", " if unsafe_path_kind:", - " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}')", - " stable = (managed_fingerprint(before) == managed_fingerprint(after) and managed_fingerprint(after) == managed_fingerprint(linked_after))", - " if remaining or not stable:", - " raise ValueError('managed MCP projection changed while reading')", - " return b''.join(chunks), managed_fingerprint(after), descriptor", + " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}') from exc", + " raise", + " try:", + " before = os.fstat(descriptor)", + " if unsafe_path_prefix:", + " unsafe_path_kind = managed_projection_path_kind(before.st_mode)", + " if unsafe_path_kind:", + " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}')", + " validate_managed_descriptor_path(entry_name, parent_descriptor, descriptor, unsafe_path_prefix)", + " if before.st_size < 0 or before.st_size > MANAGED_MCP_MAX_BYTES:", + " raise ValueError('managed MCP projection has invalid size')", + " chunks = []", + " remaining = before.st_size", + " while remaining:", + " chunk = os.read(descriptor, remaining)", + " if not chunk:", + " break", + " chunks.append(chunk)", + " remaining -= len(chunk)", + " after = os.fstat(descriptor)", + " linked_after = os.stat(entry_name, dir_fd=parent_descriptor, follow_symlinks=False)", + " if unsafe_path_prefix:", + " unsafe_path_kind = managed_projection_path_kind(linked_after.st_mode)", + " if unsafe_path_kind:", + " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}')", + " stable = (managed_fingerprint(before) == managed_fingerprint(after) and managed_fingerprint(after) == managed_fingerprint(linked_after))", + " if remaining or not stable:", + " raise ValueError('managed MCP projection changed while reading')", + " assert_managed_parent_stable(path, parent_identity, unsafe_path_prefix)", + " return b''.join(chunks), managed_fingerprint(after), descriptor, parent_descriptor, parent_identity", + " except Exception:", + " os.close(descriptor)", + " raise", " except Exception:", - " os.close(descriptor)", + " os.close(parent_descriptor)", " raise", "def decode_managed_projection(raw):", " return strict_json_loads(raw.decode('utf-8')) if raw else {}", - "def close_managed_projection_descriptor(descriptor):", - " if descriptor is None:", - " return", - " try:", - " os.close(descriptor)", - " except OSError:", - " pass", + "def close_managed_projection_descriptors(descriptor, parent_descriptor):", + " for current in (descriptor, parent_descriptor):", + " if current is None:", + " continue", + " try:", + " os.close(current)", + " except OSError:", + " pass", "def load_managed_projection_for_update(path):", - " raw, identity, descriptor = open_managed_projection(path, True)", + " raw, identity, descriptor, parent_descriptor, parent_identity = open_managed_projection(path, True)", " try:", - " return decode_managed_projection(raw), identity, descriptor", + " return decode_managed_projection(raw), identity, descriptor, parent_descriptor, parent_identity", " except Exception:", - " close_managed_projection_descriptor(descriptor)", + " close_managed_projection_descriptors(descriptor, parent_descriptor)", " raise", "def read_managed_projection(path, unsafe_path_prefix=None):", - " raw, identity, descriptor = open_managed_projection(path, unsafe_path_prefix=unsafe_path_prefix)", + " raw, identity, descriptor, parent_descriptor, _ = open_managed_projection(path, unsafe_path_prefix=unsafe_path_prefix)", " try:", " return decode_managed_projection(raw), identity", " finally:", - " close_managed_projection_descriptor(descriptor)", + " close_managed_projection_descriptors(descriptor, parent_descriptor)", ]; export const DEEPAGENTS_MANAGED_PROJECTION_MUTATION_HELPERS = [ @@ -137,10 +214,11 @@ export const DEEPAGENTS_MANAGED_PROJECTION_MUTATION_HELPERS = [ " if not payload or len(payload) > MANAGED_MCP_MAX_BYTES:", " raise ValueError('managed MCP projection has invalid rendered size')", " return payload", - "def rewrite_managed_projection(path, value, identity, descriptor):", - " if identity is None or descriptor is None:", + "def rewrite_managed_projection(path, value, identity, descriptor, parent_descriptor, parent_identity):", + " if identity is None or descriptor is None or parent_descriptor is None or parent_identity is None:", " raise ValueError('managed MCP projection descriptor is unavailable')", - " assert_managed_source_stable(path, identity)", + " assert_managed_parent_stable(path, parent_identity)", + " assert_managed_source_stable(path.name, parent_descriptor, identity)", " payload = managed_projection_bytes(value)", " os.lseek(descriptor, 0, os.SEEK_SET)", " os.ftruncate(descriptor, 0)", @@ -155,40 +233,62 @@ export const DEEPAGENTS_MANAGED_PROJECTION_MUTATION_HELPERS = [ " persisted = os.read(descriptor, len(payload) + 1)", " if persisted != payload or os.fstat(descriptor).st_size != len(payload):", " raise ValueError('managed MCP projection verification failed')", - " validate_managed_descriptor_path(path, descriptor)", - "def publish_absent_managed_projection(path, value):", - " assert_managed_source_stable(path, None)", - " payload = managed_projection_bytes(value)", - " tmp_fd, tmp_name = tempfile.mkstemp(prefix='.nemoclaw-mcp.', dir=path.parent)", - " try:", - " os.fchmod(tmp_fd, 0o600)", - " with os.fdopen(tmp_fd, 'wb') as tmp_file:", - " tmp_file.write(payload)", - " tmp_file.flush()", - " os.fsync(tmp_file.fileno())", + " validate_managed_descriptor_path(path.name, parent_descriptor, descriptor)", + " assert_managed_parent_stable(path, parent_identity)", + "def create_managed_projection_temp(parent_descriptor):", + " flags = os.O_RDWR | os.O_CREAT | os.O_EXCL | os.O_CLOEXEC | os.O_NOFOLLOW", + " for _ in range(16):", + " name = '.nemoclaw-mcp.' + os.urandom(16).hex()", " try:", - " os.link(tmp_name, path, follow_symlinks=False)", - " except FileExistsError as exc:", - " raise ValueError('managed MCP projection appeared during publication') from exc", - " os.unlink(tmp_name)", - " finally:", - " try:", - " os.unlink(tmp_name)", - " except FileNotFoundError:", + " return os.open(name, flags, 0o600, dir_fd=parent_descriptor), name", + " except FileExistsError:", " pass", - " persisted, _ = read_managed_projection(path)", - " if persisted != value:", - " raise ValueError('managed MCP projection verification failed')", - "def write_managed_projection(path, value, identity, descriptor):", - " if identity is None:", - " if descriptor is not None:", - " raise ValueError('unexpected managed MCP projection descriptor')", - " publish_absent_managed_projection(path, value)", - " else:", + " raise FileExistsError('could not allocate managed MCP projection temporary file')", + "def publish_absent_managed_projection(path, value, parent_descriptor, parent_identity):", + " owns_parent_descriptor = parent_descriptor is None", + " if parent_descriptor is None:", + " parent_descriptor, parent_identity, created = open_managed_parent(path, True)", + " if not created:", + " os.close(parent_descriptor)", + " raise ValueError('managed MCP projection parent appeared during publication')", + " try:", + " if not owns_parent_descriptor:", + " assert_managed_parent_stable(path, parent_identity)", + " assert_managed_source_stable(path.name, parent_descriptor, None)", + " payload = managed_projection_bytes(value)", + " tmp_fd, tmp_name = create_managed_projection_temp(parent_descriptor)", " try:", - " rewrite_managed_projection(path, value, identity, descriptor)", + " with os.fdopen(tmp_fd, 'wb') as tmp_file:", + " tmp_file.write(payload)", + " tmp_file.flush()", + " os.fsync(tmp_file.fileno())", + " try:", + " os.link(tmp_name, path.name, src_dir_fd=parent_descriptor, dst_dir_fd=parent_descriptor, follow_symlinks=False)", + " except FileExistsError as exc:", + " raise ValueError('managed MCP projection appeared during publication') from exc", + " os.unlink(tmp_name, dir_fd=parent_descriptor)", " finally:", - " close_managed_projection_descriptor(descriptor)", + " try:", + " os.unlink(tmp_name, dir_fd=parent_descriptor)", + " except FileNotFoundError:", + " pass", + " assert_managed_parent_stable(path, parent_identity)", + " persisted, _ = read_managed_projection(path)", + " if persisted != value:", + " raise ValueError('managed MCP projection verification failed')", + " finally:", + " if owns_parent_descriptor:", + " os.close(parent_descriptor)", + "def write_managed_projection(path, value, identity, descriptor, parent_descriptor, parent_identity):", + " try:", + " if identity is None:", + " if descriptor is not None:", + " raise ValueError('unexpected managed MCP projection descriptor')", + " publish_absent_managed_projection(path, value, parent_descriptor, parent_identity)", + " else:", + " rewrite_managed_projection(path, value, identity, descriptor, parent_descriptor, parent_identity)", + " finally:", + " close_managed_projection_descriptors(descriptor, parent_descriptor)", ]; export const DEEPAGENTS_MANAGED_PROJECTION_HELPERS = [ diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-registration.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-registration.ts index 0b9365de1b0..5657a097489 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-registration.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-registration.ts @@ -52,19 +52,20 @@ export function buildDeepAgentsMcpRegisterCommand( }; return [ "/opt/venv/bin/python3 -I - <<'PY'", - "import json, os, pathlib, stat, sys, tempfile", + "import json, os, pathlib, stat, sys", `payload = json.loads(${pythonJsonLiteral(payload)})`, `config_path = pathlib.Path(${JSON.stringify(DEEPAGENTS_MCP_CONFIG_PATH)})`, ...DEEPAGENTS_STRICT_JSON_HELPERS, ...DEEPAGENTS_MANAGED_PROJECTION_HELPERS, ...MANAGED_HTTP_SERVER_MATCH_HELPERS, "source_descriptor = None", + "source_parent_descriptor = None", "def fail_registration(message):", - " close_managed_projection_descriptor(source_descriptor)", + " close_managed_projection_descriptors(source_descriptor, source_parent_descriptor)", " print(message, file=sys.stderr)", " raise SystemExit(2)", "try:", - " data, source_identity, source_descriptor = load_managed_projection_for_update(config_path)", + " data, source_identity, source_descriptor, source_parent_descriptor, source_parent_identity = load_managed_projection_for_update(config_path)", "except (OSError, UnicodeDecodeError, ValueError) as exc:", ` fail_registration(f'Invalid ${DEEPAGENTS_MCP_CONFIG_PATH}: {exc}')`, "if not isinstance(data, dict):", @@ -95,9 +96,8 @@ export function buildDeepAgentsMcpRegisterCommand( " else:", " next_servers[name] = expected", "data = {'mcpServers': next_servers}", - "config_path.parent.mkdir(parents=True, exist_ok=True)", "try:", - " write_managed_projection(config_path, data, source_identity, source_descriptor)", + " write_managed_projection(config_path, data, source_identity, source_descriptor, source_parent_descriptor, source_parent_identity)", "except (OSError, ValueError) as exc:", ` fail_registration(f'Could not publish ${DEEPAGENTS_MCP_CONFIG_PATH}: {exc}')`, "PY", diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-teardown.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-teardown.ts index c27d89dc2f3..4ffe28abe34 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-teardown.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-teardown.ts @@ -61,18 +61,20 @@ export function buildDeepAgentsMcpRemoveCommand( "config_path = managed_path if is_v2 else legacy_path", "managed_identity = None", "managed_descriptor = None", + "managed_parent_identity = None", + "managed_parent_descriptor = None", "legacy_identity = None", "def finish(outcome):", - " close_managed_projection_descriptor(managed_descriptor)", + " close_managed_projection_descriptors(managed_descriptor, managed_parent_descriptor)", " print('NEMOCLAW_DEEPAGENTS_MCP_REMOVAL=' + outcome)", " raise SystemExit(0)", "def fail_teardown(message):", - " close_managed_projection_descriptor(managed_descriptor)", + " close_managed_projection_descriptors(managed_descriptor, managed_parent_descriptor)", " print(message, file=sys.stderr)", " raise SystemExit(2)", - "def repair_v2_projection(identity, descriptor):", + "def repair_v2_projection(identity, descriptor, parent_descriptor, parent_identity):", " try:", - " write_managed_projection(config_path, {'mcpServers': {}}, identity, descriptor)", + " write_managed_projection(config_path, {'mcpServers': {}}, identity, descriptor, parent_descriptor, parent_identity)", " except (OSError, ValueError) as exc:", " fail_teardown(f'Refusing unsafe managed MCP v2 repair at {config_path}: {exc}')", "def write_legacy_data(value):", @@ -97,7 +99,7 @@ export function buildDeepAgentsMcpRemoveCommand( " pass", "if is_v2:", " try:", - " raw, managed_identity, managed_descriptor = open_managed_projection(config_path, True)", + " raw, managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity = open_managed_projection(config_path, True)", " except (OSError, ValueError) as exc:", " fail_teardown(f'Invalid managed MCP v2 projection at {config_path}: {exc}')", " if managed_descriptor is None:", @@ -106,7 +108,7 @@ export function buildDeepAgentsMcpRemoveCommand( " data = decode_managed_projection(raw)", " except (UnicodeDecodeError, ValueError) as exc:", " if payload['force']:", - " repair_v2_projection(managed_identity, managed_descriptor)", + " repair_v2_projection(managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity)", " finish('removed')", " fail_teardown(f'Invalid managed MCP v2 projection at {config_path}: {exc}')", "else:", @@ -118,7 +120,7 @@ export function buildDeepAgentsMcpRemoveCommand( " finish('unowned')", "if not isinstance(data, dict):", " if is_v2 and payload['force']:", - " repair_v2_projection(managed_identity, managed_descriptor)", + " repair_v2_projection(managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity)", " finish('removed')", " if is_v2:", " fail_teardown(f'Invalid managed MCP v2 projection at {config_path}: expected object')", @@ -128,7 +130,7 @@ export function buildDeepAgentsMcpRemoveCommand( " if not is_v2 and 'mcpServers' not in data:", " finish('absent')", " if is_v2 and payload['force']:", - " repair_v2_projection(managed_identity, managed_descriptor)", + " repair_v2_projection(managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity)", " finish('removed')", " if is_v2:", " fail_teardown(f'Invalid managed MCP v2 server map at {config_path}')", @@ -138,7 +140,7 @@ export function buildDeepAgentsMcpRemoveCommand( "if is_v2:", " if data and set(data) != {'mcpServers'}:", " if payload['force']:", - " repair_v2_projection(managed_identity, managed_descriptor)", + " repair_v2_projection(managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity)", " finish('removed')", " fail_teardown(f'Invalid managed MCP v2 projection at {config_path}: only mcpServers is allowed')", " if present and not payload['force'] and not managed_http_server_matches(current, payload['expected'], True):", @@ -158,7 +160,7 @@ export function buildDeepAgentsMcpRemoveCommand( "if data:", " try:", " if is_v2:", - " write_managed_projection(config_path, data, managed_identity, managed_descriptor)", + " write_managed_projection(config_path, data, managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity)", " persisted = read_managed_projection(config_path)[0]", " else:", " write_legacy_data(data)", diff --git a/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts b/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts index e022775ee82..b61e9e82284 100644 --- a/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts @@ -7,12 +7,6 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { - baseEntry, - runDeepAgentsConfigCommand, -} from "../../../../test/helpers/mcp-bridge-adapter-deepagents-fixture"; - -import { buildDeepAgentsMcpStatusCommand } from "./mcp-bridge-adapter-status"; const sourceRequireHook = path.resolve("test/helpers/onboard-script-mocks.cjs"); const sourceNodeOptions = [process.env.NODE_OPTIONS, `--require=${sourceRequireHook}`] @@ -199,31 +193,13 @@ ${body} describe("MCP status wire-level credential-resolution probe", { timeout: 15_000 }, () => { it( - "returns a type-specific failure for unsafe Deep Agents projection races (#10754)", - { timeout: 120_000 }, + "preserves fixed unsafe projection diagnostics across credential observations (#10754)", + { timeout: 30_000 }, () => { - const statusCommand = buildDeepAgentsMcpStatusCommand(baseEntry); - const projection = { mcpServers: {} }; - const unsafeResults = [ - ...(["symlink", "fifo"] as const).map((appearedType) => - runDeepAgentsConfigCommand(statusCommand, projection, "v2", undefined, 0o600, { - swapAfterMissingManagedOpen: appearedType, - timeoutMs: 30_000, - }), - ), - runDeepAgentsConfigCommand(statusCommand, projection, "v2", undefined, 0o600, { - statAfterManagedEloopAsRegular: true, - symlink: true, - timeoutMs: 30_000, - }), + const unsafeDetails = [ + "Unsafe managed Deep Agents MCP projection path: symbolic link", + "Unsafe managed Deep Agents MCP projection path: FIFO", ]; - unsafeResults.forEach((result) => { - expect(result.status).toBe(2); - expect(result.stdout.trim()).toBe(""); - expect(result.stderr).toContain("Unsafe managed Deep Agents MCP projection path:"); - expect(result.configIsSymlink || result.configIsFifo).toBe(true); - }); - const unsafeDetails = unsafeResults.map((result) => result.stderr.trim()); const home = createTempHome("nemoclaw-mcp-status-projection-"); const { stdout } = runHarness( home, @@ -275,7 +251,7 @@ describe("MCP status wire-level credential-resolution probe", { timeout: 15_000 stderr: string; }>; - expect(outcomes).toHaveLength(9); + expect(outcomes).toHaveLength(6); outcomes.forEach((outcome) => { expect(["unavailable", "absent", "canonical"]).toContain(outcome.observation); expect(outcome.exitCode).toBe(2); diff --git a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts index 3952fc954d3..0513903b952 100644 --- a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts +++ b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts @@ -33,6 +33,8 @@ export interface DeepAgentsConfigCommandResult { legacyConfigExists: boolean; legacyConfig: Record | null; legacyConfigText: string | null; + managedParentIsSymlink: boolean; + managedParentTargetText: string | null; managedSymlinkTargetExists: boolean; managedSymlinkTargetText: string | null; } @@ -42,6 +44,7 @@ export interface DeepAgentsManagedFixtureOptions { directory?: boolean; fifo?: boolean; mode?: number; + parentSymlink?: boolean; statAfterManagedEloopAsRegular?: boolean; swapAfterManagedOpen?: "fifo" | "symlink"; swapAfterMissingManagedOpen?: "fifo" | "symlink"; @@ -194,8 +197,11 @@ export function runDeepAgentsConfigCommand( ): DeepAgentsConfigCommandResult { const managedSwap = resolveManagedSwap(managedOptions); const fixtureRoot = managedSwap?.kind === "socket" ? "/tmp" : os.tmpdir(); - const tmp = fs.mkdtempSync(path.join(fixtureRoot, "nemoclaw-deepagents-mcp-")); + const tmp = fs.realpathSync(fs.mkdtempSync(path.join(fixtureRoot, "nemoclaw-deepagents-mcp-"))); const configPath = path.join(tmp, ".deepagents", ".nemoclaw-mcp.json"); + const managedParentPath = path.dirname(configPath); + const managedParentTarget = path.join(tmp, "managed-parent-target"); + const managedParentTargetConfig = path.join(managedParentTarget, path.basename(configPath)); const managedSymlinkTarget = path.join(tmp, "managed-projection-target.json"); const legacyConfigPath = path.join(tmp, ".deepagents", ".mcp.json"); const initializeConfig = ( @@ -213,7 +219,11 @@ export function runDeepAgentsConfigCommand( }; const managedSymlink = managedOptions.symlink === true || managedOptions.danglingSymlink === true; const managedInitialPath = managedSymlink ? managedSymlinkTarget : configPath; - if (managedOptions.directory) { + if (managedOptions.parentSymlink) { + fs.mkdirSync(managedParentTarget, { recursive: true }); + initializeConfig(managedParentTargetConfig, initialConfig, managedOptions.mode); + fs.symlinkSync(managedParentTarget, managedParentPath); + } else if (managedOptions.directory) { fs.mkdirSync(configPath, { recursive: true, mode: managedOptions.mode ?? 0o700 }); } else if (managedOptions.fifo) { fs.mkdirSync(path.dirname(configPath), { recursive: true }); @@ -268,6 +278,12 @@ export function runDeepAgentsConfigCommand( // A missing projection has no path type to report. } const configExists = configStat !== null; + let managedParentIsSymlink = false; + try { + managedParentIsSymlink = fs.lstatSync(managedParentPath).isSymbolicLink(); + } catch { + // A missing projection parent cannot redirect the managed path. + } const legacyConfigExists = fs.existsSync(legacyConfigPath); const configIsFifo = configStat?.isFIFO() === true; const configIsSocket = configStat?.isSocket() === true; @@ -281,6 +297,9 @@ export function runDeepAgentsConfigCommand( const managedSymlinkTargetText = managedSymlinkTargetExists ? fs.readFileSync(managedSymlinkTarget, "utf-8") : null; + const managedParentTargetText = fs.existsSync(managedParentTargetConfig) + ? fs.readFileSync(managedParentTargetConfig, "utf-8") + : null; const legacyConfigText = legacyConfigExists ? fs.readFileSync(legacyConfigPath, "utf-8") : null; const parseConfigText = (text: string | null): Record | null => { if (!text) return null; @@ -306,6 +325,8 @@ export function runDeepAgentsConfigCommand( legacyConfigExists, legacyConfig: parseConfigText(legacyConfigText), legacyConfigText, + managedParentIsSymlink, + managedParentTargetText, managedSymlinkTargetExists, managedSymlinkTargetText, }; From 854c257c075cc4949ad7903ddf2ae19b8aee72d0 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:50:01 -0700 Subject: [PATCH 13/49] fix(mcp): keep projection safety fix scoped Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- .../mcp-bridge-adapter-deepagents-legacy.ts | 10 +- ...idge-adapter-deepagents-projection.test.ts | 447 +++++++----------- ...cp-bridge-adapter-deepagents-projection.ts | 286 ++++------- ...-bridge-adapter-deepagents-registration.ts | 10 +- .../mcp-bridge-adapter-deepagents-teardown.ts | 22 +- .../mcp-bridge-status-resolution.test.ts | 36 +- .../mcp-bridge-adapter-deepagents-fixture.ts | 25 +- 7 files changed, 328 insertions(+), 508 deletions(-) diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy.ts index f7d3e18acd6..50251a1de33 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy.ts @@ -71,16 +71,14 @@ export function buildDeepAgentsMcpRollbackRegisterCommand( "data = {}", "managed_identity = None", "managed_descriptor = None", - "managed_parent_identity = None", - "managed_parent_descriptor = None", "legacy_identity = None", "def fail_rollback(message):", - " close_managed_projection_descriptors(managed_descriptor, managed_parent_descriptor)", + " close_managed_projection_descriptor(managed_descriptor)", " print(message, file=sys.stderr)", " raise SystemExit(2)", "try:", " if is_v2:", - " data, managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity = load_managed_projection_for_update(config_path)", + " data, managed_identity, managed_descriptor = load_managed_projection_for_update(config_path)", " elif os.path.lexists(config_path):", " data, legacy_identity = read_legacy_config(config_path)", "except (OSError, UnicodeDecodeError, ValueError) as exc:", @@ -110,8 +108,8 @@ export function buildDeepAgentsMcpRollbackRegisterCommand( " if payload['server'] in servers and current != payload['expected']:", " fail_rollback(f'Refusing to overwrite user-owned legacy MCP server at {config_path}')", " servers[payload['server']] = payload['expected']", + "config_path.parent.mkdir(parents=True, exist_ok=True)", "if not is_v2:", - " config_path.parent.mkdir(parents=True, exist_ok=True)", " tmp_fd, tmp_name = tempfile.mkstemp(prefix='.nemoclaw-mcp.', dir=config_path.parent)", " try:", " os.fchmod(tmp_fd, 0o600)", @@ -133,7 +131,7 @@ export function buildDeepAgentsMcpRollbackRegisterCommand( " pass", "else:", " try:", - " write_managed_projection(config_path, data, managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity)", + " write_managed_projection(config_path, data, managed_identity, managed_descriptor)", " except (OSError, ValueError) as exc:", " fail_rollback(f'Could not publish managed MCP rollback state at {config_path}: {exc}')", "try:", diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts index b020684c52b..02d46fbda32 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts @@ -24,243 +24,203 @@ const rollbackCommand = buildDeepAgentsMcpRegisterCommand(baseEntry, true, [base const removalCommand = buildDeepAgentsMcpRemoveCommand(baseEntry); describe("Deep Agents managed MCP projection safety", () => { - it( - "applies the shared server cap before normal and rollback v2 publication", - { timeout: 60_000 }, - () => { - const entries = Array.from( - { length: DEEPAGENTS_MCP_MAX_SERVERS + 1 }, - (_, index): McpBridgeEntry => ({ - ...baseEntry, - server: `server${String(index)}`, - env: [`SERVER_${String(index)}_TOKEN`], - }), - ); + it("applies the shared server cap before normal and rollback v2 publication", () => { + const entries = Array.from( + { length: DEEPAGENTS_MCP_MAX_SERVERS + 1 }, + (_, index): McpBridgeEntry => ({ + ...baseEntry, + server: `server${String(index)}`, + env: [`SERVER_${String(index)}_TOKEN`], + }), + ); - expect(() => buildDeepAgentsMcpRegisterCommand(entries[0], false, entries)).toThrow( - `at most ${String(DEEPAGENTS_MCP_MAX_SERVERS)} servers`, - ); - const rollback = runDeepAgentsConfigCommand( - buildDeepAgentsMcpRegisterCommand(entries[0], true, entries, true), - undefined, - "v2", - undefined, - 0o600, - { timeoutMs: managedCommandTimeoutMs }, - ); - expect(rollback.status).toBe(2); - expect(rollback.stderr).toContain( - `supports at most ${String(DEEPAGENTS_MCP_MAX_SERVERS)} servers`, - ); - }, - ); + expect(() => buildDeepAgentsMcpRegisterCommand(entries[0], false, entries)).toThrow( + `at most ${String(DEEPAGENTS_MCP_MAX_SERVERS)} servers`, + ); + const rollback = runDeepAgentsConfigCommand( + buildDeepAgentsMcpRegisterCommand(entries[0], true, entries, true), + ); + expect(rollback.status).toBe(2); + expect(rollback.stderr).toContain( + `supports at most ${String(DEEPAGENTS_MCP_MAX_SERVERS)} servers`, + ); + }); - it( - "names unsafe projection types without following hostile paths (#10754)", - { timeout: 60_000 }, - () => { - const statusCommand = buildDeepAgentsMcpStatusCommand(baseEntry); - const symlink = runDeepAgentsConfigCommand( - statusCommand, - emptyProjection, - "v2", - undefined, - 0o600, - { symlink: true, timeoutMs: managedCommandTimeoutMs }, - ); - expect(symlink.status).toBe(2); - expect(symlink.stdout.trim()).toBe(""); - expect(symlink.stderr).toContain( - "Unsafe managed Deep Agents MCP projection path: symbolic link", - ); - expect(symlink.managedSymlinkTargetText).toBe( - `${JSON.stringify(emptyProjection, null, 2)}\n`, - ); + it("names unsafe projection types without following hostile paths (#10754)", () => { + const statusCommand = buildDeepAgentsMcpStatusCommand(baseEntry); + const symlink = runDeepAgentsConfigCommand( + statusCommand, + emptyProjection, + "v2", + undefined, + 0o600, + { symlink: true, timeoutMs: managedCommandTimeoutMs }, + ); + expect(symlink.status).toBe(2); + expect(symlink.stdout.trim()).toBe(""); + expect(symlink.stderr).toContain( + "Unsafe managed Deep Agents MCP projection path: symbolic link", + ); + expect(symlink.managedSymlinkTargetText).toBe(`${JSON.stringify(emptyProjection, null, 2)}\n`); - const fifo = runDeepAgentsConfigCommand(statusCommand, undefined, "v2", undefined, 0o600, { - fifo: true, - timeoutMs: managedCommandTimeoutMs, - }); - expect(fifo.status).toBe(2); - expect(fifo.stdout.trim()).toBe(""); - expect(fifo.stderr).toContain("Unsafe managed Deep Agents MCP projection path: FIFO"); + const fifo = runDeepAgentsConfigCommand(statusCommand, undefined, "v2", undefined, 0o600, { + fifo: true, + timeoutMs: managedCommandTimeoutMs, + }); + expect(fifo.status).toBe(2); + expect(fifo.stdout.trim()).toBe(""); + expect(fifo.stderr).toContain("Unsafe managed Deep Agents MCP projection path: FIFO"); - const directory = runDeepAgentsConfigCommand( - statusCommand, - undefined, - "v2", - undefined, - 0o600, - { - directory: true, - timeoutMs: managedCommandTimeoutMs, - }, - ); - expect(directory.status).toBe(2); - expect(directory.stdout.trim()).toBe(""); - expect(directory.stderr).toContain( - "Unsafe managed Deep Agents MCP projection path: non-regular file", - ); + const directory = runDeepAgentsConfigCommand(statusCommand, undefined, "v2", undefined, 0o600, { + directory: true, + timeoutMs: managedCommandTimeoutMs, + }); + expect(directory.status).toBe(2); + expect(directory.stdout.trim()).toBe(""); + expect(directory.stderr).toContain( + "Unsafe managed Deep Agents MCP projection path: non-regular file", + ); - const danglingSymlink = runDeepAgentsConfigCommand( - statusCommand, - undefined, - "v2", - undefined, - 0o600, - { danglingSymlink: true, timeoutMs: managedCommandTimeoutMs }, - ); - expect(danglingSymlink.status).toBe(2); - expect(danglingSymlink.stdout.trim()).toBe(""); - expect(danglingSymlink.stderr).toContain( - "Unsafe managed Deep Agents MCP projection path: symbolic link", - ); + const danglingSymlink = runDeepAgentsConfigCommand( + statusCommand, + undefined, + "v2", + undefined, + 0o600, + { danglingSymlink: true, timeoutMs: managedCommandTimeoutMs }, + ); + expect(danglingSymlink.status).toBe(2); + expect(danglingSymlink.stdout.trim()).toBe(""); + expect(danglingSymlink.stderr).toContain( + "Unsafe managed Deep Agents MCP projection path: symbolic link", + ); - const racedSymlink = runDeepAgentsConfigCommand( - statusCommand, - emptyProjection, - "v2", - undefined, - 0o600, - { swapOnManagedOpen: "symlink", timeoutMs: managedCommandTimeoutMs }, - ); - expect(racedSymlink.status).toBe(2); - expect(racedSymlink.stdout.trim()).toBe(""); - expect(racedSymlink.stderr).toContain( - "Unsafe managed Deep Agents MCP projection path: symbolic link", - ); - expect(racedSymlink.configIsSymlink).toBe(true); - expect(racedSymlink.managedSymlinkTargetText).toBe( - `${JSON.stringify(emptyProjection, null, 2)}\n`, - ); + const racedSymlink = runDeepAgentsConfigCommand( + statusCommand, + emptyProjection, + "v2", + undefined, + 0o600, + { swapOnManagedOpen: "symlink", timeoutMs: managedCommandTimeoutMs }, + ); + expect(racedSymlink.status).toBe(2); + expect(racedSymlink.stdout.trim()).toBe(""); + expect(racedSymlink.stderr).toContain( + "Unsafe managed Deep Agents MCP projection path: symbolic link", + ); + expect(racedSymlink.configIsSymlink).toBe(true); + expect(racedSymlink.managedSymlinkTargetText).toBe( + `${JSON.stringify(emptyProjection, null, 2)}\n`, + ); - const racedFifo = runDeepAgentsConfigCommand( - statusCommand, - emptyProjection, - "v2", - undefined, - 0o600, - { swapOnManagedOpen: "fifo", timeoutMs: managedCommandTimeoutMs }, - ); - expect(racedFifo.status).toBe(2); - expect(racedFifo.stdout.trim()).toBe(""); - expect(racedFifo.stderr).toContain("Unsafe managed Deep Agents MCP projection path: FIFO"); - expect(racedFifo.configIsFifo).toBe(true); + const racedFifo = runDeepAgentsConfigCommand( + statusCommand, + emptyProjection, + "v2", + undefined, + 0o600, + { swapOnManagedOpen: "fifo", timeoutMs: managedCommandTimeoutMs }, + ); + expect(racedFifo.status).toBe(2); + expect(racedFifo.stdout.trim()).toBe(""); + expect(racedFifo.stderr).toContain("Unsafe managed Deep Agents MCP projection path: FIFO"); + expect(racedFifo.configIsFifo).toBe(true); - const racedSocket = runDeepAgentsConfigCommand( - statusCommand, - emptyProjection, - "v2", - undefined, - 0o600, - { swapOnManagedOpen: "socket", timeoutMs: managedCommandTimeoutMs }, - ); - expect(racedSocket.status).toBe(2); - expect(racedSocket.stdout.trim()).toBe(""); - expect(racedSocket.stderr).toContain( - "Unsafe managed Deep Agents MCP projection path: non-regular file", - ); - expect(racedSocket.configIsSocket).toBe(true); + const racedSocket = runDeepAgentsConfigCommand( + statusCommand, + emptyProjection, + "v2", + undefined, + 0o600, + { swapOnManagedOpen: "socket", timeoutMs: managedCommandTimeoutMs }, + ); + expect(racedSocket.status).toBe(2); + expect(racedSocket.stdout.trim()).toBe(""); + expect(racedSocket.stderr).toContain( + "Unsafe managed Deep Agents MCP projection path: non-regular file", + ); + expect(racedSocket.configIsSocket).toBe(true); - const replacedAfterOpenWithSymlink = runDeepAgentsConfigCommand( - statusCommand, - emptyProjection, - "v2", - undefined, - 0o600, - { swapAfterManagedOpen: "symlink", timeoutMs: managedCommandTimeoutMs }, - ); - expect(replacedAfterOpenWithSymlink.status).toBe(2); - expect(replacedAfterOpenWithSymlink.stdout.trim()).toBe(""); - expect(replacedAfterOpenWithSymlink.stderr).toContain( - "Unsafe managed Deep Agents MCP projection path: symbolic link", - ); - expect(replacedAfterOpenWithSymlink.configIsSymlink).toBe(true); - expect(replacedAfterOpenWithSymlink.managedSymlinkTargetText).toBe( - `${JSON.stringify(emptyProjection, null, 2)}\n`, - ); + const replacedAfterOpenWithSymlink = runDeepAgentsConfigCommand( + statusCommand, + emptyProjection, + "v2", + undefined, + 0o600, + { swapAfterManagedOpen: "symlink", timeoutMs: managedCommandTimeoutMs }, + ); + expect(replacedAfterOpenWithSymlink.status).toBe(2); + expect(replacedAfterOpenWithSymlink.stdout.trim()).toBe(""); + expect(replacedAfterOpenWithSymlink.stderr).toContain( + "Unsafe managed Deep Agents MCP projection path: symbolic link", + ); + expect(replacedAfterOpenWithSymlink.configIsSymlink).toBe(true); + expect(replacedAfterOpenWithSymlink.managedSymlinkTargetText).toBe( + `${JSON.stringify(emptyProjection, null, 2)}\n`, + ); - const replacedAfterOpenWithFifo = runDeepAgentsConfigCommand( - statusCommand, - emptyProjection, - "v2", - undefined, - 0o600, - { swapAfterManagedOpen: "fifo", timeoutMs: managedCommandTimeoutMs }, - ); - expect(replacedAfterOpenWithFifo.status).toBe(2); - expect(replacedAfterOpenWithFifo.stdout.trim()).toBe(""); - expect(replacedAfterOpenWithFifo.stderr).toContain( - "Unsafe managed Deep Agents MCP projection path: FIFO", - ); - expect(replacedAfterOpenWithFifo.configIsFifo).toBe(true); + const replacedAfterOpenWithFifo = runDeepAgentsConfigCommand( + statusCommand, + emptyProjection, + "v2", + undefined, + 0o600, + { swapAfterManagedOpen: "fifo", timeoutMs: managedCommandTimeoutMs }, + ); + expect(replacedAfterOpenWithFifo.status).toBe(2); + expect(replacedAfterOpenWithFifo.stdout.trim()).toBe(""); + expect(replacedAfterOpenWithFifo.stderr).toContain( + "Unsafe managed Deep Agents MCP projection path: FIFO", + ); + expect(replacedAfterOpenWithFifo.configIsFifo).toBe(true); - const replacedWhileReadingWithSymlink = runDeepAgentsConfigCommand( - statusCommand, - emptyProjection, - "v2", - undefined, - 0o600, - { swapOnManagedRead: "symlink", timeoutMs: managedCommandTimeoutMs }, - ); - expect(replacedWhileReadingWithSymlink.status).toBe(2); - expect(replacedWhileReadingWithSymlink.stdout.trim()).toBe(""); - expect(replacedWhileReadingWithSymlink.stderr).toContain( - "Unsafe managed Deep Agents MCP projection path: symbolic link", - ); - expect(replacedWhileReadingWithSymlink.configIsSymlink).toBe(true); - expect(replacedWhileReadingWithSymlink.managedSymlinkTargetText).toBe( - `${JSON.stringify(emptyProjection, null, 2)}\n`, - ); + const replacedWhileReadingWithSymlink = runDeepAgentsConfigCommand( + statusCommand, + emptyProjection, + "v2", + undefined, + 0o600, + { swapOnManagedRead: "symlink", timeoutMs: managedCommandTimeoutMs }, + ); + expect(replacedWhileReadingWithSymlink.status).toBe(2); + expect(replacedWhileReadingWithSymlink.stdout.trim()).toBe(""); + expect(replacedWhileReadingWithSymlink.stderr).toContain( + "Unsafe managed Deep Agents MCP projection path: symbolic link", + ); + expect(replacedWhileReadingWithSymlink.configIsSymlink).toBe(true); + expect(replacedWhileReadingWithSymlink.managedSymlinkTargetText).toBe( + `${JSON.stringify(emptyProjection, null, 2)}\n`, + ); - const replacedAfterEloop = runDeepAgentsConfigCommand( - statusCommand, - emptyProjection, - "v2", - undefined, - 0o600, - { - statAfterManagedEloopAsRegular: true, - symlink: true, - timeoutMs: managedCommandTimeoutMs, - }, - ); - expect(replacedAfterEloop.status).toBe(2); - expect(replacedAfterEloop.stdout.trim()).toBe(""); - expect(replacedAfterEloop.stderr).toContain( - "Unsafe managed Deep Agents MCP projection path: symbolic link", - ); - expect(replacedAfterEloop.configIsSymlink).toBe(true); - expect(replacedAfterEloop.managedSymlinkTargetText).toBe( - `${JSON.stringify(emptyProjection, null, 2)}\n`, - ); - }, - ); + const replacedAfterEloop = runDeepAgentsConfigCommand( + statusCommand, + emptyProjection, + "v2", + undefined, + 0o600, + { + statAfterManagedEloopAsRegular: true, + symlink: true, + timeoutMs: managedCommandTimeoutMs, + }, + ); + expect(replacedAfterEloop.status).toBe(2); + expect(replacedAfterEloop.stdout.trim()).toBe(""); + expect(replacedAfterEloop.stderr).toContain( + "Unsafe managed Deep Agents MCP projection path: symbolic link", + ); + expect(replacedAfterEloop.configIsSymlink).toBe(true); + expect(replacedAfterEloop.managedSymlinkTargetText).toBe( + `${JSON.stringify(emptyProjection, null, 2)}\n`, + ); + }); it.each([ - { - appearedType: "symlink" as const, - diagnostic: "symbolic link", - expectedConfigIsFifo: false, - expectedConfigIsSymlink: true, - expectedTargetText: `${JSON.stringify(emptyProjection, null, 2)}\n`, - }, - { - appearedType: "fifo" as const, - diagnostic: "FIFO", - expectedConfigIsFifo: true, - expectedConfigIsSymlink: false, - expectedTargetText: null, - }, + { appearedType: "symlink" as const, diagnostic: "symbolic link" }, + { appearedType: "fifo" as const, diagnostic: "FIFO" }, ])( "classifies a $diagnostic that appears after a missing-path open (#10754)", - ({ - appearedType, - diagnostic, - expectedConfigIsFifo, - expectedConfigIsSymlink, - expectedTargetText, - }) => { + ({ appearedType, diagnostic }) => { const result = runDeepAgentsConfigCommand( buildDeepAgentsMcpStatusCommand(baseEntry), emptyProjection, @@ -275,49 +235,10 @@ describe("Deep Agents managed MCP projection safety", () => { expect(result.stderr).toContain( `Unsafe managed Deep Agents MCP projection path: ${diagnostic}`, ); - expect(result.configIsFifo).toBe(expectedConfigIsFifo); - expect(result.configIsSymlink).toBe(expectedConfigIsSymlink); - expect(result.managedSymlinkTargetText).toBe(expectedTargetText); - }, - ); - - it("rejects a symlinked projection parent without reading its target (#10754)", () => { - const initialText = `${JSON.stringify(emptyProjection, null, 2)}\n`; - const status = runDeepAgentsConfigCommand( - buildDeepAgentsMcpStatusCommand(baseEntry), - emptyProjection, - "v2", - undefined, - 0o600, - { parentSymlink: true }, - ); - expect(status.status).toBe(2); - expect(status.stdout.trim()).toBe(""); - expect(status.stderr).toContain( - "Unsafe managed Deep Agents MCP projection path: symbolic link", - ); - expect(status.managedParentIsSymlink).toBe(true); - expect(status.managedParentTargetText).toBe(initialText); - }); - - it.each([ - ["registration", registrationCommand], - ["forced removal", buildDeepAgentsMcpRemoveCommand(baseEntry, true)], - ])( - "rejects a symlinked projection parent during %s without mutating its target (#10754)", - (_operation, command) => { - const initialText = `${JSON.stringify(emptyProjection, null, 2)}\n`; - const mutation = runDeepAgentsConfigCommand( - command, - emptyProjection, - "v2", - undefined, - 0o600, - { parentSymlink: true }, + expect(appearedType === "symlink" ? result.configIsSymlink : result.configIsFifo).toBe(true); + expect(result.managedSymlinkTargetText).toBe( + appearedType === "symlink" ? `${JSON.stringify(emptyProjection, null, 2)}\n` : null, ); - expect(mutation.status).toBe(2); - expect(mutation.managedParentIsSymlink).toBe(true); - expect(mutation.managedParentTargetText).toBe(initialText); }, ); diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts index c21db5ed120..d484ec70a7c 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts @@ -28,76 +28,11 @@ export const DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS = [ " if not stat.S_ISREG(mode):", " return 'non-regular file'", " return None", - "def managed_parent_path_kind(mode):", - " if stat.S_ISLNK(mode):", - " return 'symbolic link'", - " if not stat.S_ISDIR(mode):", - " return 'non-directory parent'", - " return None", "def managed_fingerprint(metadata):", " return (metadata.st_dev, metadata.st_ino, metadata.st_size, metadata.st_mtime_ns, metadata.st_ctime_ns, metadata.st_mode, metadata.st_nlink, metadata.st_uid)", - "def managed_parent_fingerprint(metadata):", - " return (metadata.st_dev, metadata.st_ino, metadata.st_mode, metadata.st_uid)", - "def raise_managed_parent_error(parent_descriptor, component, exc, unsafe_path_prefix=None):", - " unsafe_path_kind = None", - " try:", - " unsafe_path_kind = managed_parent_path_kind(os.stat(component, dir_fd=parent_descriptor, follow_symlinks=False).st_mode)", - " except OSError:", - " pass", - " if unsafe_path_kind and unsafe_path_prefix:", - " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}') from exc", - " if unsafe_path_kind:", - " raise ValueError(f'managed MCP projection parent is a {unsafe_path_kind}') from exc", - " raise exc", - "def open_managed_parent(path, create=False, unsafe_path_prefix=None):", - " if not path.is_absolute() or not path.name or path.name in ('.', '..'):", - " raise ValueError('managed MCP projection path must be absolute')", - " components = path.parts[1:-1]", - " descriptor = os.open('/', os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC)", - " created = False", - " try:", - " for index, component in enumerate(components):", - " flags = os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC | os.O_NOFOLLOW", - " try:", - " next_descriptor = os.open(component, flags, dir_fd=descriptor)", - " except FileNotFoundError:", - " if not create or index != len(components) - 1:", - " raise", - " try:", - " os.mkdir(component, 0o700, dir_fd=descriptor)", - " created = True", - " except FileExistsError:", - " pass", - " try:", - " next_descriptor = os.open(component, flags, dir_fd=descriptor)", - " except OSError as exc:", - " raise_managed_parent_error(descriptor, component, exc, unsafe_path_prefix)", - " except OSError as exc:", - " raise_managed_parent_error(descriptor, component, exc, unsafe_path_prefix)", - " os.close(descriptor)", - " descriptor = next_descriptor", - " metadata = os.fstat(descriptor)", - " if not stat.S_ISDIR(metadata.st_mode):", - " raise ValueError('managed MCP projection parent is not a directory')", - " return descriptor, managed_parent_fingerprint(metadata), created", - " except Exception:", - " os.close(descriptor)", - " raise", - "def assert_managed_parent_stable(path, identity, unsafe_path_prefix=None):", + "def assert_managed_source_stable(path, identity, unsafe_path_prefix=None):", " try:", - " descriptor, current, _ = open_managed_parent(path, unsafe_path_prefix=unsafe_path_prefix)", - " except FileNotFoundError:", - " current = None", - " descriptor = None", - " try:", - " if current != identity:", - " raise ValueError('managed MCP projection parent changed during operation')", - " finally:", - " if descriptor is not None:", - " os.close(descriptor)", - "def assert_managed_source_stable(entry_name, parent_descriptor, identity, unsafe_path_prefix=None):", - " try:", - " current_metadata = os.stat(entry_name, dir_fd=parent_descriptor, follow_symlinks=False)", + " current_metadata = os.stat(path, follow_symlinks=False)", " except FileNotFoundError:", " current_metadata = None", " current = managed_fingerprint(current_metadata) if current_metadata is not None else None", @@ -110,9 +45,9 @@ export const DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS = [ " return", " if current != identity:", " raise ValueError('managed MCP projection changed before mutation')", - "def validate_managed_descriptor_path(entry_name, parent_descriptor, descriptor, unsafe_path_prefix=None):", + "def validate_managed_descriptor_path(path, descriptor, unsafe_path_prefix=None):", " opened = os.fstat(descriptor)", - " linked = os.stat(entry_name, dir_fd=parent_descriptor, follow_symlinks=False)", + " linked = os.stat(path, follow_symlinks=False)", " if unsafe_path_prefix:", " unsafe_path_kind = managed_projection_path_kind(linked.st_mode)", " if unsafe_path_kind:", @@ -122,90 +57,78 @@ export const DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS = [ " raise ValueError('managed MCP projection has unsafe ownership, mode, type, links, or path identity')", " return managed_fingerprint(opened)", "def open_managed_projection(path, writable=False, unsafe_path_prefix=None):", - " try:", - " parent_descriptor, parent_identity, _ = open_managed_parent(path, unsafe_path_prefix=unsafe_path_prefix)", - " except FileNotFoundError:", - " return b'', None, None, None, None", - " entry_name = path.name", " access = os.O_RDWR if writable else os.O_RDONLY", " flags = access | os.O_CLOEXEC | os.O_NONBLOCK | os.O_NOFOLLOW", " try:", - " try:", - " descriptor = os.open(entry_name, flags, dir_fd=parent_descriptor)", - " except FileNotFoundError:", - " assert_managed_source_stable(entry_name, parent_descriptor, None, unsafe_path_prefix)", - " assert_managed_parent_stable(path, parent_identity, unsafe_path_prefix)", - " return b'', None, None, parent_descriptor, parent_identity", - " except OSError as exc:", - " unsafe_path_kind = None", - " if unsafe_path_prefix:", - " if exc.errno == errno.ELOOP:", - " unsafe_path_kind = 'symbolic link'", - " else:", - " try:", - " unsafe_path_kind = managed_projection_path_kind(os.stat(entry_name, dir_fd=parent_descriptor, follow_symlinks=False).st_mode)", - " except OSError:", - " pass", + " descriptor = os.open(path, flags)", + " except FileNotFoundError:", + " assert_managed_source_stable(path, None, unsafe_path_prefix)", + " return b'', None, None", + " except OSError as exc:", + " unsafe_path_kind = None", + " if unsafe_path_prefix:", + " if exc.errno == errno.ELOOP:", + " unsafe_path_kind = 'symbolic link'", + " else:", + " try:", + " unsafe_path_kind = managed_projection_path_kind(os.stat(path, follow_symlinks=False).st_mode)", + " except OSError:", + " pass", + " if unsafe_path_kind:", + " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}') from exc", + " raise", + " try:", + " before = os.fstat(descriptor)", + " if unsafe_path_prefix:", + " unsafe_path_kind = managed_projection_path_kind(before.st_mode)", " if unsafe_path_kind:", - " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}') from exc", - " raise", - " try:", - " before = os.fstat(descriptor)", - " if unsafe_path_prefix:", - " unsafe_path_kind = managed_projection_path_kind(before.st_mode)", - " if unsafe_path_kind:", - " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}')", - " validate_managed_descriptor_path(entry_name, parent_descriptor, descriptor, unsafe_path_prefix)", - " if before.st_size < 0 or before.st_size > MANAGED_MCP_MAX_BYTES:", - " raise ValueError('managed MCP projection has invalid size')", - " chunks = []", - " remaining = before.st_size", - " while remaining:", - " chunk = os.read(descriptor, remaining)", - " if not chunk:", - " break", - " chunks.append(chunk)", - " remaining -= len(chunk)", - " after = os.fstat(descriptor)", - " linked_after = os.stat(entry_name, dir_fd=parent_descriptor, follow_symlinks=False)", - " if unsafe_path_prefix:", - " unsafe_path_kind = managed_projection_path_kind(linked_after.st_mode)", - " if unsafe_path_kind:", - " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}')", - " stable = (managed_fingerprint(before) == managed_fingerprint(after) and managed_fingerprint(after) == managed_fingerprint(linked_after))", - " if remaining or not stable:", - " raise ValueError('managed MCP projection changed while reading')", - " assert_managed_parent_stable(path, parent_identity, unsafe_path_prefix)", - " return b''.join(chunks), managed_fingerprint(after), descriptor, parent_descriptor, parent_identity", - " except Exception:", - " os.close(descriptor)", - " raise", + " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}')", + " validate_managed_descriptor_path(path, descriptor, unsafe_path_prefix)", + " if before.st_size < 0 or before.st_size > MANAGED_MCP_MAX_BYTES:", + " raise ValueError('managed MCP projection has invalid size')", + " chunks = []", + " remaining = before.st_size", + " while remaining:", + " chunk = os.read(descriptor, remaining)", + " if not chunk:", + " break", + " chunks.append(chunk)", + " remaining -= len(chunk)", + " after = os.fstat(descriptor)", + " linked_after = os.stat(path, follow_symlinks=False)", + " if unsafe_path_prefix:", + " unsafe_path_kind = managed_projection_path_kind(linked_after.st_mode)", + " if unsafe_path_kind:", + " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}')", + " stable = (managed_fingerprint(before) == managed_fingerprint(after) and managed_fingerprint(after) == managed_fingerprint(linked_after))", + " if remaining or not stable:", + " raise ValueError('managed MCP projection changed while reading')", + " return b''.join(chunks), managed_fingerprint(after), descriptor", " except Exception:", - " os.close(parent_descriptor)", + " os.close(descriptor)", " raise", "def decode_managed_projection(raw):", " return strict_json_loads(raw.decode('utf-8')) if raw else {}", - "def close_managed_projection_descriptors(descriptor, parent_descriptor):", - " for current in (descriptor, parent_descriptor):", - " if current is None:", - " continue", - " try:", - " os.close(current)", - " except OSError:", - " pass", + "def close_managed_projection_descriptor(descriptor):", + " if descriptor is None:", + " return", + " try:", + " os.close(descriptor)", + " except OSError:", + " pass", "def load_managed_projection_for_update(path):", - " raw, identity, descriptor, parent_descriptor, parent_identity = open_managed_projection(path, True)", + " raw, identity, descriptor = open_managed_projection(path, True)", " try:", - " return decode_managed_projection(raw), identity, descriptor, parent_descriptor, parent_identity", + " return decode_managed_projection(raw), identity, descriptor", " except Exception:", - " close_managed_projection_descriptors(descriptor, parent_descriptor)", + " close_managed_projection_descriptor(descriptor)", " raise", "def read_managed_projection(path, unsafe_path_prefix=None):", - " raw, identity, descriptor, parent_descriptor, _ = open_managed_projection(path, unsafe_path_prefix=unsafe_path_prefix)", + " raw, identity, descriptor = open_managed_projection(path, unsafe_path_prefix=unsafe_path_prefix)", " try:", " return decode_managed_projection(raw), identity", " finally:", - " close_managed_projection_descriptors(descriptor, parent_descriptor)", + " close_managed_projection_descriptor(descriptor)", ]; export const DEEPAGENTS_MANAGED_PROJECTION_MUTATION_HELPERS = [ @@ -214,11 +137,10 @@ export const DEEPAGENTS_MANAGED_PROJECTION_MUTATION_HELPERS = [ " if not payload or len(payload) > MANAGED_MCP_MAX_BYTES:", " raise ValueError('managed MCP projection has invalid rendered size')", " return payload", - "def rewrite_managed_projection(path, value, identity, descriptor, parent_descriptor, parent_identity):", - " if identity is None or descriptor is None or parent_descriptor is None or parent_identity is None:", + "def rewrite_managed_projection(path, value, identity, descriptor):", + " if identity is None or descriptor is None:", " raise ValueError('managed MCP projection descriptor is unavailable')", - " assert_managed_parent_stable(path, parent_identity)", - " assert_managed_source_stable(path.name, parent_descriptor, identity)", + " assert_managed_source_stable(path, identity)", " payload = managed_projection_bytes(value)", " os.lseek(descriptor, 0, os.SEEK_SET)", " os.ftruncate(descriptor, 0)", @@ -233,62 +155,40 @@ export const DEEPAGENTS_MANAGED_PROJECTION_MUTATION_HELPERS = [ " persisted = os.read(descriptor, len(payload) + 1)", " if persisted != payload or os.fstat(descriptor).st_size != len(payload):", " raise ValueError('managed MCP projection verification failed')", - " validate_managed_descriptor_path(path.name, parent_descriptor, descriptor)", - " assert_managed_parent_stable(path, parent_identity)", - "def create_managed_projection_temp(parent_descriptor):", - " flags = os.O_RDWR | os.O_CREAT | os.O_EXCL | os.O_CLOEXEC | os.O_NOFOLLOW", - " for _ in range(16):", - " name = '.nemoclaw-mcp.' + os.urandom(16).hex()", + " validate_managed_descriptor_path(path, descriptor)", + "def publish_absent_managed_projection(path, value):", + " assert_managed_source_stable(path, None)", + " payload = managed_projection_bytes(value)", + " tmp_fd, tmp_name = tempfile.mkstemp(prefix='.nemoclaw-mcp.', dir=path.parent)", + " try:", + " os.fchmod(tmp_fd, 0o600)", + " with os.fdopen(tmp_fd, 'wb') as tmp_file:", + " tmp_file.write(payload)", + " tmp_file.flush()", + " os.fsync(tmp_file.fileno())", " try:", - " return os.open(name, flags, 0o600, dir_fd=parent_descriptor), name", - " except FileExistsError:", + " os.link(tmp_name, path, follow_symlinks=False)", + " except FileExistsError as exc:", + " raise ValueError('managed MCP projection appeared during publication') from exc", + " os.unlink(tmp_name)", + " finally:", + " try:", + " os.unlink(tmp_name)", + " except FileNotFoundError:", " pass", - " raise FileExistsError('could not allocate managed MCP projection temporary file')", - "def publish_absent_managed_projection(path, value, parent_descriptor, parent_identity):", - " owns_parent_descriptor = parent_descriptor is None", - " if parent_descriptor is None:", - " parent_descriptor, parent_identity, created = open_managed_parent(path, True)", - " if not created:", - " os.close(parent_descriptor)", - " raise ValueError('managed MCP projection parent appeared during publication')", - " try:", - " if not owns_parent_descriptor:", - " assert_managed_parent_stable(path, parent_identity)", - " assert_managed_source_stable(path.name, parent_descriptor, None)", - " payload = managed_projection_bytes(value)", - " tmp_fd, tmp_name = create_managed_projection_temp(parent_descriptor)", + " persisted, _ = read_managed_projection(path)", + " if persisted != value:", + " raise ValueError('managed MCP projection verification failed')", + "def write_managed_projection(path, value, identity, descriptor):", + " if identity is None:", + " if descriptor is not None:", + " raise ValueError('unexpected managed MCP projection descriptor')", + " publish_absent_managed_projection(path, value)", + " else:", " try:", - " with os.fdopen(tmp_fd, 'wb') as tmp_file:", - " tmp_file.write(payload)", - " tmp_file.flush()", - " os.fsync(tmp_file.fileno())", - " try:", - " os.link(tmp_name, path.name, src_dir_fd=parent_descriptor, dst_dir_fd=parent_descriptor, follow_symlinks=False)", - " except FileExistsError as exc:", - " raise ValueError('managed MCP projection appeared during publication') from exc", - " os.unlink(tmp_name, dir_fd=parent_descriptor)", + " rewrite_managed_projection(path, value, identity, descriptor)", " finally:", - " try:", - " os.unlink(tmp_name, dir_fd=parent_descriptor)", - " except FileNotFoundError:", - " pass", - " assert_managed_parent_stable(path, parent_identity)", - " persisted, _ = read_managed_projection(path)", - " if persisted != value:", - " raise ValueError('managed MCP projection verification failed')", - " finally:", - " if owns_parent_descriptor:", - " os.close(parent_descriptor)", - "def write_managed_projection(path, value, identity, descriptor, parent_descriptor, parent_identity):", - " try:", - " if identity is None:", - " if descriptor is not None:", - " raise ValueError('unexpected managed MCP projection descriptor')", - " publish_absent_managed_projection(path, value, parent_descriptor, parent_identity)", - " else:", - " rewrite_managed_projection(path, value, identity, descriptor, parent_descriptor, parent_identity)", - " finally:", - " close_managed_projection_descriptors(descriptor, parent_descriptor)", + " close_managed_projection_descriptor(descriptor)", ]; export const DEEPAGENTS_MANAGED_PROJECTION_HELPERS = [ diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-registration.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-registration.ts index 5657a097489..0b9365de1b0 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-registration.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-registration.ts @@ -52,20 +52,19 @@ export function buildDeepAgentsMcpRegisterCommand( }; return [ "/opt/venv/bin/python3 -I - <<'PY'", - "import json, os, pathlib, stat, sys", + "import json, os, pathlib, stat, sys, tempfile", `payload = json.loads(${pythonJsonLiteral(payload)})`, `config_path = pathlib.Path(${JSON.stringify(DEEPAGENTS_MCP_CONFIG_PATH)})`, ...DEEPAGENTS_STRICT_JSON_HELPERS, ...DEEPAGENTS_MANAGED_PROJECTION_HELPERS, ...MANAGED_HTTP_SERVER_MATCH_HELPERS, "source_descriptor = None", - "source_parent_descriptor = None", "def fail_registration(message):", - " close_managed_projection_descriptors(source_descriptor, source_parent_descriptor)", + " close_managed_projection_descriptor(source_descriptor)", " print(message, file=sys.stderr)", " raise SystemExit(2)", "try:", - " data, source_identity, source_descriptor, source_parent_descriptor, source_parent_identity = load_managed_projection_for_update(config_path)", + " data, source_identity, source_descriptor = load_managed_projection_for_update(config_path)", "except (OSError, UnicodeDecodeError, ValueError) as exc:", ` fail_registration(f'Invalid ${DEEPAGENTS_MCP_CONFIG_PATH}: {exc}')`, "if not isinstance(data, dict):", @@ -96,8 +95,9 @@ export function buildDeepAgentsMcpRegisterCommand( " else:", " next_servers[name] = expected", "data = {'mcpServers': next_servers}", + "config_path.parent.mkdir(parents=True, exist_ok=True)", "try:", - " write_managed_projection(config_path, data, source_identity, source_descriptor, source_parent_descriptor, source_parent_identity)", + " write_managed_projection(config_path, data, source_identity, source_descriptor)", "except (OSError, ValueError) as exc:", ` fail_registration(f'Could not publish ${DEEPAGENTS_MCP_CONFIG_PATH}: {exc}')`, "PY", diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-teardown.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-teardown.ts index 4ffe28abe34..c27d89dc2f3 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-teardown.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-teardown.ts @@ -61,20 +61,18 @@ export function buildDeepAgentsMcpRemoveCommand( "config_path = managed_path if is_v2 else legacy_path", "managed_identity = None", "managed_descriptor = None", - "managed_parent_identity = None", - "managed_parent_descriptor = None", "legacy_identity = None", "def finish(outcome):", - " close_managed_projection_descriptors(managed_descriptor, managed_parent_descriptor)", + " close_managed_projection_descriptor(managed_descriptor)", " print('NEMOCLAW_DEEPAGENTS_MCP_REMOVAL=' + outcome)", " raise SystemExit(0)", "def fail_teardown(message):", - " close_managed_projection_descriptors(managed_descriptor, managed_parent_descriptor)", + " close_managed_projection_descriptor(managed_descriptor)", " print(message, file=sys.stderr)", " raise SystemExit(2)", - "def repair_v2_projection(identity, descriptor, parent_descriptor, parent_identity):", + "def repair_v2_projection(identity, descriptor):", " try:", - " write_managed_projection(config_path, {'mcpServers': {}}, identity, descriptor, parent_descriptor, parent_identity)", + " write_managed_projection(config_path, {'mcpServers': {}}, identity, descriptor)", " except (OSError, ValueError) as exc:", " fail_teardown(f'Refusing unsafe managed MCP v2 repair at {config_path}: {exc}')", "def write_legacy_data(value):", @@ -99,7 +97,7 @@ export function buildDeepAgentsMcpRemoveCommand( " pass", "if is_v2:", " try:", - " raw, managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity = open_managed_projection(config_path, True)", + " raw, managed_identity, managed_descriptor = open_managed_projection(config_path, True)", " except (OSError, ValueError) as exc:", " fail_teardown(f'Invalid managed MCP v2 projection at {config_path}: {exc}')", " if managed_descriptor is None:", @@ -108,7 +106,7 @@ export function buildDeepAgentsMcpRemoveCommand( " data = decode_managed_projection(raw)", " except (UnicodeDecodeError, ValueError) as exc:", " if payload['force']:", - " repair_v2_projection(managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity)", + " repair_v2_projection(managed_identity, managed_descriptor)", " finish('removed')", " fail_teardown(f'Invalid managed MCP v2 projection at {config_path}: {exc}')", "else:", @@ -120,7 +118,7 @@ export function buildDeepAgentsMcpRemoveCommand( " finish('unowned')", "if not isinstance(data, dict):", " if is_v2 and payload['force']:", - " repair_v2_projection(managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity)", + " repair_v2_projection(managed_identity, managed_descriptor)", " finish('removed')", " if is_v2:", " fail_teardown(f'Invalid managed MCP v2 projection at {config_path}: expected object')", @@ -130,7 +128,7 @@ export function buildDeepAgentsMcpRemoveCommand( " if not is_v2 and 'mcpServers' not in data:", " finish('absent')", " if is_v2 and payload['force']:", - " repair_v2_projection(managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity)", + " repair_v2_projection(managed_identity, managed_descriptor)", " finish('removed')", " if is_v2:", " fail_teardown(f'Invalid managed MCP v2 server map at {config_path}')", @@ -140,7 +138,7 @@ export function buildDeepAgentsMcpRemoveCommand( "if is_v2:", " if data and set(data) != {'mcpServers'}:", " if payload['force']:", - " repair_v2_projection(managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity)", + " repair_v2_projection(managed_identity, managed_descriptor)", " finish('removed')", " fail_teardown(f'Invalid managed MCP v2 projection at {config_path}: only mcpServers is allowed')", " if present and not payload['force'] and not managed_http_server_matches(current, payload['expected'], True):", @@ -160,7 +158,7 @@ export function buildDeepAgentsMcpRemoveCommand( "if data:", " try:", " if is_v2:", - " write_managed_projection(config_path, data, managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity)", + " write_managed_projection(config_path, data, managed_identity, managed_descriptor)", " persisted = read_managed_projection(config_path)[0]", " else:", " write_legacy_data(data)", diff --git a/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts b/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts index b61e9e82284..e022775ee82 100644 --- a/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts @@ -7,6 +7,12 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { + baseEntry, + runDeepAgentsConfigCommand, +} from "../../../../test/helpers/mcp-bridge-adapter-deepagents-fixture"; + +import { buildDeepAgentsMcpStatusCommand } from "./mcp-bridge-adapter-status"; const sourceRequireHook = path.resolve("test/helpers/onboard-script-mocks.cjs"); const sourceNodeOptions = [process.env.NODE_OPTIONS, `--require=${sourceRequireHook}`] @@ -193,13 +199,31 @@ ${body} describe("MCP status wire-level credential-resolution probe", { timeout: 15_000 }, () => { it( - "preserves fixed unsafe projection diagnostics across credential observations (#10754)", - { timeout: 30_000 }, + "returns a type-specific failure for unsafe Deep Agents projection races (#10754)", + { timeout: 120_000 }, () => { - const unsafeDetails = [ - "Unsafe managed Deep Agents MCP projection path: symbolic link", - "Unsafe managed Deep Agents MCP projection path: FIFO", + const statusCommand = buildDeepAgentsMcpStatusCommand(baseEntry); + const projection = { mcpServers: {} }; + const unsafeResults = [ + ...(["symlink", "fifo"] as const).map((appearedType) => + runDeepAgentsConfigCommand(statusCommand, projection, "v2", undefined, 0o600, { + swapAfterMissingManagedOpen: appearedType, + timeoutMs: 30_000, + }), + ), + runDeepAgentsConfigCommand(statusCommand, projection, "v2", undefined, 0o600, { + statAfterManagedEloopAsRegular: true, + symlink: true, + timeoutMs: 30_000, + }), ]; + unsafeResults.forEach((result) => { + expect(result.status).toBe(2); + expect(result.stdout.trim()).toBe(""); + expect(result.stderr).toContain("Unsafe managed Deep Agents MCP projection path:"); + expect(result.configIsSymlink || result.configIsFifo).toBe(true); + }); + const unsafeDetails = unsafeResults.map((result) => result.stderr.trim()); const home = createTempHome("nemoclaw-mcp-status-projection-"); const { stdout } = runHarness( home, @@ -251,7 +275,7 @@ describe("MCP status wire-level credential-resolution probe", { timeout: 15_000 stderr: string; }>; - expect(outcomes).toHaveLength(6); + expect(outcomes).toHaveLength(9); outcomes.forEach((outcome) => { expect(["unavailable", "absent", "canonical"]).toContain(outcome.observation); expect(outcome.exitCode).toBe(2); diff --git a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts index 0513903b952..3952fc954d3 100644 --- a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts +++ b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts @@ -33,8 +33,6 @@ export interface DeepAgentsConfigCommandResult { legacyConfigExists: boolean; legacyConfig: Record | null; legacyConfigText: string | null; - managedParentIsSymlink: boolean; - managedParentTargetText: string | null; managedSymlinkTargetExists: boolean; managedSymlinkTargetText: string | null; } @@ -44,7 +42,6 @@ export interface DeepAgentsManagedFixtureOptions { directory?: boolean; fifo?: boolean; mode?: number; - parentSymlink?: boolean; statAfterManagedEloopAsRegular?: boolean; swapAfterManagedOpen?: "fifo" | "symlink"; swapAfterMissingManagedOpen?: "fifo" | "symlink"; @@ -197,11 +194,8 @@ export function runDeepAgentsConfigCommand( ): DeepAgentsConfigCommandResult { const managedSwap = resolveManagedSwap(managedOptions); const fixtureRoot = managedSwap?.kind === "socket" ? "/tmp" : os.tmpdir(); - const tmp = fs.realpathSync(fs.mkdtempSync(path.join(fixtureRoot, "nemoclaw-deepagents-mcp-"))); + const tmp = fs.mkdtempSync(path.join(fixtureRoot, "nemoclaw-deepagents-mcp-")); const configPath = path.join(tmp, ".deepagents", ".nemoclaw-mcp.json"); - const managedParentPath = path.dirname(configPath); - const managedParentTarget = path.join(tmp, "managed-parent-target"); - const managedParentTargetConfig = path.join(managedParentTarget, path.basename(configPath)); const managedSymlinkTarget = path.join(tmp, "managed-projection-target.json"); const legacyConfigPath = path.join(tmp, ".deepagents", ".mcp.json"); const initializeConfig = ( @@ -219,11 +213,7 @@ export function runDeepAgentsConfigCommand( }; const managedSymlink = managedOptions.symlink === true || managedOptions.danglingSymlink === true; const managedInitialPath = managedSymlink ? managedSymlinkTarget : configPath; - if (managedOptions.parentSymlink) { - fs.mkdirSync(managedParentTarget, { recursive: true }); - initializeConfig(managedParentTargetConfig, initialConfig, managedOptions.mode); - fs.symlinkSync(managedParentTarget, managedParentPath); - } else if (managedOptions.directory) { + if (managedOptions.directory) { fs.mkdirSync(configPath, { recursive: true, mode: managedOptions.mode ?? 0o700 }); } else if (managedOptions.fifo) { fs.mkdirSync(path.dirname(configPath), { recursive: true }); @@ -278,12 +268,6 @@ export function runDeepAgentsConfigCommand( // A missing projection has no path type to report. } const configExists = configStat !== null; - let managedParentIsSymlink = false; - try { - managedParentIsSymlink = fs.lstatSync(managedParentPath).isSymbolicLink(); - } catch { - // A missing projection parent cannot redirect the managed path. - } const legacyConfigExists = fs.existsSync(legacyConfigPath); const configIsFifo = configStat?.isFIFO() === true; const configIsSocket = configStat?.isSocket() === true; @@ -297,9 +281,6 @@ export function runDeepAgentsConfigCommand( const managedSymlinkTargetText = managedSymlinkTargetExists ? fs.readFileSync(managedSymlinkTarget, "utf-8") : null; - const managedParentTargetText = fs.existsSync(managedParentTargetConfig) - ? fs.readFileSync(managedParentTargetConfig, "utf-8") - : null; const legacyConfigText = legacyConfigExists ? fs.readFileSync(legacyConfigPath, "utf-8") : null; const parseConfigText = (text: string | null): Record | null => { if (!text) return null; @@ -325,8 +306,6 @@ export function runDeepAgentsConfigCommand( legacyConfigExists, legacyConfig: parseConfigText(legacyConfigText), legacyConfigText, - managedParentIsSymlink, - managedParentTargetText, managedSymlinkTargetExists, managedSymlinkTargetText, }; From 4d8f0aa1828c2ff4ab46beb51daf5adff41903a5 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 23:02:18 -0700 Subject: [PATCH 14/49] test(mcp): separate unsafe projection scenarios --- ...idge-adapter-deepagents-projection.test.ts | 351 ++++++++++-------- .../mcp-bridge-adapter-deepagents-fixture.ts | 2 + 2 files changed, 192 insertions(+), 161 deletions(-) diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts index b020684c52b..173152e49cc 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts @@ -55,188 +55,216 @@ describe("Deep Agents managed MCP projection safety", () => { }, ); - it( - "names unsafe projection types without following hostile paths (#10754)", - { timeout: 60_000 }, - () => { - const statusCommand = buildDeepAgentsMcpStatusCommand(baseEntry); - const symlink = runDeepAgentsConfigCommand( - statusCommand, - emptyProjection, - "v2", - undefined, - 0o600, - { symlink: true, timeoutMs: managedCommandTimeoutMs }, - ); - expect(symlink.status).toBe(2); - expect(symlink.stdout.trim()).toBe(""); - expect(symlink.stderr).toContain( - "Unsafe managed Deep Agents MCP projection path: symbolic link", - ); - expect(symlink.managedSymlinkTargetText).toBe( - `${JSON.stringify(emptyProjection, null, 2)}\n`, - ); - - const fifo = runDeepAgentsConfigCommand(statusCommand, undefined, "v2", undefined, 0o600, { - fifo: true, - timeoutMs: managedCommandTimeoutMs, - }); - expect(fifo.status).toBe(2); - expect(fifo.stdout.trim()).toBe(""); - expect(fifo.stderr).toContain("Unsafe managed Deep Agents MCP projection path: FIFO"); - - const directory = runDeepAgentsConfigCommand( - statusCommand, - undefined, - "v2", - undefined, - 0o600, - { - directory: true, - timeoutMs: managedCommandTimeoutMs, - }, - ); - expect(directory.status).toBe(2); - expect(directory.stdout.trim()).toBe(""); - expect(directory.stderr).toContain( - "Unsafe managed Deep Agents MCP projection path: non-regular file", - ); - - const danglingSymlink = runDeepAgentsConfigCommand( - statusCommand, - undefined, - "v2", - undefined, - 0o600, - { danglingSymlink: true, timeoutMs: managedCommandTimeoutMs }, - ); - expect(danglingSymlink.status).toBe(2); - expect(danglingSymlink.stdout.trim()).toBe(""); - expect(danglingSymlink.stderr).toContain( - "Unsafe managed Deep Agents MCP projection path: symbolic link", - ); - - const racedSymlink = runDeepAgentsConfigCommand( - statusCommand, - emptyProjection, - "v2", - undefined, - 0o600, - { swapOnManagedOpen: "symlink", timeoutMs: managedCommandTimeoutMs }, - ); - expect(racedSymlink.status).toBe(2); - expect(racedSymlink.stdout.trim()).toBe(""); - expect(racedSymlink.stderr).toContain( - "Unsafe managed Deep Agents MCP projection path: symbolic link", - ); - expect(racedSymlink.configIsSymlink).toBe(true); - expect(racedSymlink.managedSymlinkTargetText).toBe( - `${JSON.stringify(emptyProjection, null, 2)}\n`, - ); - - const racedFifo = runDeepAgentsConfigCommand( - statusCommand, - emptyProjection, - "v2", - undefined, - 0o600, - { swapOnManagedOpen: "fifo", timeoutMs: managedCommandTimeoutMs }, - ); - expect(racedFifo.status).toBe(2); - expect(racedFifo.stdout.trim()).toBe(""); - expect(racedFifo.stderr).toContain("Unsafe managed Deep Agents MCP projection path: FIFO"); - expect(racedFifo.configIsFifo).toBe(true); - - const racedSocket = runDeepAgentsConfigCommand( - statusCommand, - emptyProjection, - "v2", - undefined, - 0o600, - { swapOnManagedOpen: "socket", timeoutMs: managedCommandTimeoutMs }, - ); - expect(racedSocket.status).toBe(2); - expect(racedSocket.stdout.trim()).toBe(""); - expect(racedSocket.stderr).toContain( - "Unsafe managed Deep Agents MCP projection path: non-regular file", - ); - expect(racedSocket.configIsSocket).toBe(true); - - const replacedAfterOpenWithSymlink = runDeepAgentsConfigCommand( - statusCommand, - emptyProjection, - "v2", - undefined, - 0o600, - { swapAfterManagedOpen: "symlink", timeoutMs: managedCommandTimeoutMs }, - ); - expect(replacedAfterOpenWithSymlink.status).toBe(2); - expect(replacedAfterOpenWithSymlink.stdout.trim()).toBe(""); - expect(replacedAfterOpenWithSymlink.stderr).toContain( - "Unsafe managed Deep Agents MCP projection path: symbolic link", - ); - expect(replacedAfterOpenWithSymlink.configIsSymlink).toBe(true); - expect(replacedAfterOpenWithSymlink.managedSymlinkTargetText).toBe( - `${JSON.stringify(emptyProjection, null, 2)}\n`, - ); - - const replacedAfterOpenWithFifo = runDeepAgentsConfigCommand( - statusCommand, - emptyProjection, + it.each([ + { + caseName: "symbolic link", + diagnostic: "symbolic link", + expectedConfigIsDirectory: false, + expectedConfigIsFifo: false, + expectedConfigIsSymlink: true, + expectedTargetText: `${JSON.stringify(emptyProjection, null, 2)}\n`, + initialConfig: emptyProjection, + options: { symlink: true }, + }, + { + caseName: "FIFO", + diagnostic: "FIFO", + expectedConfigIsDirectory: false, + expectedConfigIsFifo: true, + expectedConfigIsSymlink: false, + expectedTargetText: null, + initialConfig: undefined, + options: { fifo: true }, + }, + { + caseName: "directory", + diagnostic: "non-regular file", + expectedConfigIsDirectory: true, + expectedConfigIsFifo: false, + expectedConfigIsSymlink: false, + expectedTargetText: null, + initialConfig: undefined, + options: { directory: true }, + }, + { + caseName: "dangling symbolic link", + diagnostic: "symbolic link", + expectedConfigIsDirectory: false, + expectedConfigIsFifo: false, + expectedConfigIsSymlink: true, + expectedTargetText: null, + initialConfig: undefined, + options: { danglingSymlink: true }, + }, + ])( + "rejects an existing $caseName projection path without following it (#10754)", + ({ + diagnostic, + expectedConfigIsDirectory, + expectedConfigIsFifo, + expectedConfigIsSymlink, + expectedTargetText, + initialConfig, + options, + }) => { + const result = runDeepAgentsConfigCommand( + buildDeepAgentsMcpStatusCommand(baseEntry), + initialConfig, "v2", undefined, 0o600, - { swapAfterManagedOpen: "fifo", timeoutMs: managedCommandTimeoutMs }, + { ...options, timeoutMs: managedCommandTimeoutMs }, ); - expect(replacedAfterOpenWithFifo.status).toBe(2); - expect(replacedAfterOpenWithFifo.stdout.trim()).toBe(""); - expect(replacedAfterOpenWithFifo.stderr).toContain( - "Unsafe managed Deep Agents MCP projection path: FIFO", + expect(result.status).toBe(2); + expect(result.stdout.trim()).toBe(""); + expect(result.stderr).toContain( + `Unsafe managed Deep Agents MCP projection path: ${diagnostic}`, ); - expect(replacedAfterOpenWithFifo.configIsFifo).toBe(true); + expect(result.configIsDirectory).toBe(expectedConfigIsDirectory); + expect(result.configIsFifo).toBe(expectedConfigIsFifo); + expect(result.configIsSymlink).toBe(expectedConfigIsSymlink); + expect(result.managedSymlinkTargetText).toBe(expectedTargetText); + }, + ); - const replacedWhileReadingWithSymlink = runDeepAgentsConfigCommand( - statusCommand, + it.each([ + { + diagnostic: "symbolic link", + expectedConfigIsFifo: false, + expectedConfigIsSocket: false, + expectedConfigIsSymlink: true, + expectedTargetText: `${JSON.stringify(emptyProjection, null, 2)}\n`, + replacementType: "symlink" as const, + }, + { + diagnostic: "FIFO", + expectedConfigIsFifo: true, + expectedConfigIsSocket: false, + expectedConfigIsSymlink: false, + expectedTargetText: null, + replacementType: "fifo" as const, + }, + { + diagnostic: "non-regular file", + expectedConfigIsFifo: false, + expectedConfigIsSocket: true, + expectedConfigIsSymlink: false, + expectedTargetText: null, + replacementType: "socket" as const, + }, + ])( + "rejects a $replacementType replacement before projection open (#10754)", + ({ + diagnostic, + expectedConfigIsFifo, + expectedConfigIsSocket, + expectedConfigIsSymlink, + expectedTargetText, + replacementType, + }) => { + const result = runDeepAgentsConfigCommand( + buildDeepAgentsMcpStatusCommand(baseEntry), emptyProjection, "v2", undefined, 0o600, - { swapOnManagedRead: "symlink", timeoutMs: managedCommandTimeoutMs }, - ); - expect(replacedWhileReadingWithSymlink.status).toBe(2); - expect(replacedWhileReadingWithSymlink.stdout.trim()).toBe(""); - expect(replacedWhileReadingWithSymlink.stderr).toContain( - "Unsafe managed Deep Agents MCP projection path: symbolic link", + { swapOnManagedOpen: replacementType, timeoutMs: managedCommandTimeoutMs }, ); - expect(replacedWhileReadingWithSymlink.configIsSymlink).toBe(true); - expect(replacedWhileReadingWithSymlink.managedSymlinkTargetText).toBe( - `${JSON.stringify(emptyProjection, null, 2)}\n`, + expect(result.status).toBe(2); + expect(result.stdout.trim()).toBe(""); + expect(result.stderr).toContain( + `Unsafe managed Deep Agents MCP projection path: ${diagnostic}`, ); + expect(result.configIsFifo).toBe(expectedConfigIsFifo); + expect(result.configIsSocket).toBe(expectedConfigIsSocket); + expect(result.configIsSymlink).toBe(expectedConfigIsSymlink); + expect(result.managedSymlinkTargetText).toBe(expectedTargetText); + }, + ); - const replacedAfterEloop = runDeepAgentsConfigCommand( - statusCommand, + it.each([ + { + diagnostic: "symbolic link", + expectedConfigIsFifo: false, + expectedConfigIsSymlink: true, + expectedTargetText: `${JSON.stringify(emptyProjection, null, 2)}\n`, + replacementType: "symlink" as const, + }, + { + diagnostic: "FIFO", + expectedConfigIsFifo: true, + expectedConfigIsSymlink: false, + expectedTargetText: null, + replacementType: "fifo" as const, + }, + ])( + "rejects a $replacementType replacement after projection open (#10754)", + ({ + diagnostic, + expectedConfigIsFifo, + expectedConfigIsSymlink, + expectedTargetText, + replacementType, + }) => { + const result = runDeepAgentsConfigCommand( + buildDeepAgentsMcpStatusCommand(baseEntry), emptyProjection, "v2", undefined, 0o600, - { - statAfterManagedEloopAsRegular: true, - symlink: true, - timeoutMs: managedCommandTimeoutMs, - }, - ); - expect(replacedAfterEloop.status).toBe(2); - expect(replacedAfterEloop.stdout.trim()).toBe(""); - expect(replacedAfterEloop.stderr).toContain( - "Unsafe managed Deep Agents MCP projection path: symbolic link", + { swapAfterManagedOpen: replacementType, timeoutMs: managedCommandTimeoutMs }, ); - expect(replacedAfterEloop.configIsSymlink).toBe(true); - expect(replacedAfterEloop.managedSymlinkTargetText).toBe( - `${JSON.stringify(emptyProjection, null, 2)}\n`, + expect(result.status).toBe(2); + expect(result.stdout.trim()).toBe(""); + expect(result.stderr).toContain( + `Unsafe managed Deep Agents MCP projection path: ${diagnostic}`, ); + expect(result.configIsFifo).toBe(expectedConfigIsFifo); + expect(result.configIsSymlink).toBe(expectedConfigIsSymlink); + expect(result.managedSymlinkTargetText).toBe(expectedTargetText); }, ); + it("rejects a symbolic-link replacement while reading the projection (#10754)", () => { + const result = runDeepAgentsConfigCommand( + buildDeepAgentsMcpStatusCommand(baseEntry), + emptyProjection, + "v2", + undefined, + 0o600, + { swapOnManagedRead: "symlink", timeoutMs: managedCommandTimeoutMs }, + ); + expect(result.status).toBe(2); + expect(result.stdout.trim()).toBe(""); + expect(result.stderr).toContain( + "Unsafe managed Deep Agents MCP projection path: symbolic link", + ); + expect(result.configIsSymlink).toBe(true); + expect(result.managedSymlinkTargetText).toBe(`${JSON.stringify(emptyProjection, null, 2)}\n`); + }); + + it("preserves conclusive ELOOP classification across a follow-up path swap (#10754)", () => { + const result = runDeepAgentsConfigCommand( + buildDeepAgentsMcpStatusCommand(baseEntry), + emptyProjection, + "v2", + undefined, + 0o600, + { + statAfterManagedEloopAsRegular: true, + symlink: true, + timeoutMs: managedCommandTimeoutMs, + }, + ); + expect(result.status).toBe(2); + expect(result.stdout.trim()).toBe(""); + expect(result.stderr).toContain( + "Unsafe managed Deep Agents MCP projection path: symbolic link", + ); + expect(result.configIsSymlink).toBe(true); + expect(result.managedSymlinkTargetText).toBe(`${JSON.stringify(emptyProjection, null, 2)}\n`); + }); + it.each([ { appearedType: "symlink" as const, @@ -405,7 +433,7 @@ describe("Deep Agents managed MCP projection safety", () => { expect(raced.configText).toBe(attackerProjection); }); - it("keeps forced removal identity-bound for malformed files and symlinks", () => { + it("keeps forced removal identity-bound for malformed and unsafe projections", () => { const forcedCommand = buildDeepAgentsMcpRemoveCommand(baseEntry, true); const forcedSymlink = runDeepAgentsConfigCommand( forcedCommand, @@ -443,6 +471,7 @@ describe("Deep Agents managed MCP projection safety", () => { ); expect(forcedFifo.status).toBe(2); expect(forcedFifo.configExists).toBe(true); + expect(forcedFifo.configIsFifo).toBe(true); const duplicate = runDeepAgentsConfigCommand(removalCommand, duplicateProjection); expect(duplicate.status).toBe(2); diff --git a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts index 0513903b952..ded631c8e90 100644 --- a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts +++ b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts @@ -25,6 +25,7 @@ export interface DeepAgentsConfigCommandResult { stdout: string; stderr: string; configExists: boolean; + configIsDirectory: boolean; configIsFifo: boolean; configIsSocket: boolean; configIsSymlink: boolean; @@ -317,6 +318,7 @@ export function runDeepAgentsConfigCommand( stdout: result.stdout, stderr: result.stderr, configExists, + configIsDirectory, configIsFifo, configIsSocket, configIsSymlink, From dac7b3754be1f89bec01342deef5a8801207b526 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 23:11:20 -0700 Subject: [PATCH 15/49] fix(mcp): restore projection parent trust boundary Reverts content from 854c257c075cc4949ad7903ddf2ae19b8aee72d0. That change removes the Advisor-required parent-descriptor boundary. --- .../mcp-bridge-adapter-deepagents-legacy.ts | 10 +- ...idge-adapter-deepagents-projection.test.ts | 102 +++++-- ...cp-bridge-adapter-deepagents-projection.ts | 286 ++++++++++++------ ...-bridge-adapter-deepagents-registration.ts | 10 +- .../mcp-bridge-adapter-deepagents-teardown.ts | 22 +- .../mcp-bridge-status-resolution.test.ts | 36 +-- .../mcp-bridge-adapter-deepagents-fixture.ts | 25 +- 7 files changed, 323 insertions(+), 168 deletions(-) diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy.ts index 50251a1de33..f7d3e18acd6 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy.ts @@ -71,14 +71,16 @@ export function buildDeepAgentsMcpRollbackRegisterCommand( "data = {}", "managed_identity = None", "managed_descriptor = None", + "managed_parent_identity = None", + "managed_parent_descriptor = None", "legacy_identity = None", "def fail_rollback(message):", - " close_managed_projection_descriptor(managed_descriptor)", + " close_managed_projection_descriptors(managed_descriptor, managed_parent_descriptor)", " print(message, file=sys.stderr)", " raise SystemExit(2)", "try:", " if is_v2:", - " data, managed_identity, managed_descriptor = load_managed_projection_for_update(config_path)", + " data, managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity = load_managed_projection_for_update(config_path)", " elif os.path.lexists(config_path):", " data, legacy_identity = read_legacy_config(config_path)", "except (OSError, UnicodeDecodeError, ValueError) as exc:", @@ -108,8 +110,8 @@ export function buildDeepAgentsMcpRollbackRegisterCommand( " if payload['server'] in servers and current != payload['expected']:", " fail_rollback(f'Refusing to overwrite user-owned legacy MCP server at {config_path}')", " servers[payload['server']] = payload['expected']", - "config_path.parent.mkdir(parents=True, exist_ok=True)", "if not is_v2:", + " config_path.parent.mkdir(parents=True, exist_ok=True)", " tmp_fd, tmp_name = tempfile.mkstemp(prefix='.nemoclaw-mcp.', dir=config_path.parent)", " try:", " os.fchmod(tmp_fd, 0o600)", @@ -131,7 +133,7 @@ export function buildDeepAgentsMcpRollbackRegisterCommand( " pass", "else:", " try:", - " write_managed_projection(config_path, data, managed_identity, managed_descriptor)", + " write_managed_projection(config_path, data, managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity)", " except (OSError, ValueError) as exc:", " fail_rollback(f'Could not publish managed MCP rollback state at {config_path}: {exc}')", "try:", diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts index d48631b5734..173152e49cc 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts @@ -24,27 +24,36 @@ const rollbackCommand = buildDeepAgentsMcpRegisterCommand(baseEntry, true, [base const removalCommand = buildDeepAgentsMcpRemoveCommand(baseEntry); describe("Deep Agents managed MCP projection safety", () => { - it("applies the shared server cap before normal and rollback v2 publication", () => { - const entries = Array.from( - { length: DEEPAGENTS_MCP_MAX_SERVERS + 1 }, - (_, index): McpBridgeEntry => ({ - ...baseEntry, - server: `server${String(index)}`, - env: [`SERVER_${String(index)}_TOKEN`], - }), - ); + it( + "applies the shared server cap before normal and rollback v2 publication", + { timeout: 60_000 }, + () => { + const entries = Array.from( + { length: DEEPAGENTS_MCP_MAX_SERVERS + 1 }, + (_, index): McpBridgeEntry => ({ + ...baseEntry, + server: `server${String(index)}`, + env: [`SERVER_${String(index)}_TOKEN`], + }), + ); - expect(() => buildDeepAgentsMcpRegisterCommand(entries[0], false, entries)).toThrow( - `at most ${String(DEEPAGENTS_MCP_MAX_SERVERS)} servers`, - ); - const rollback = runDeepAgentsConfigCommand( - buildDeepAgentsMcpRegisterCommand(entries[0], true, entries, true), - ); - expect(rollback.status).toBe(2); - expect(rollback.stderr).toContain( - `supports at most ${String(DEEPAGENTS_MCP_MAX_SERVERS)} servers`, - ); - }); + expect(() => buildDeepAgentsMcpRegisterCommand(entries[0], false, entries)).toThrow( + `at most ${String(DEEPAGENTS_MCP_MAX_SERVERS)} servers`, + ); + const rollback = runDeepAgentsConfigCommand( + buildDeepAgentsMcpRegisterCommand(entries[0], true, entries, true), + undefined, + "v2", + undefined, + 0o600, + { timeoutMs: managedCommandTimeoutMs }, + ); + expect(rollback.status).toBe(2); + expect(rollback.stderr).toContain( + `supports at most ${String(DEEPAGENTS_MCP_MAX_SERVERS)} servers`, + ); + }, + ); it.each([ { @@ -273,7 +282,13 @@ describe("Deep Agents managed MCP projection safety", () => { }, ])( "classifies a $diagnostic that appears after a missing-path open (#10754)", - ({ appearedType, diagnostic }) => { + ({ + appearedType, + diagnostic, + expectedConfigIsFifo, + expectedConfigIsSymlink, + expectedTargetText, + }) => { const result = runDeepAgentsConfigCommand( buildDeepAgentsMcpStatusCommand(baseEntry), emptyProjection, @@ -288,10 +303,49 @@ describe("Deep Agents managed MCP projection safety", () => { expect(result.stderr).toContain( `Unsafe managed Deep Agents MCP projection path: ${diagnostic}`, ); - expect(appearedType === "symlink" ? result.configIsSymlink : result.configIsFifo).toBe(true); - expect(result.managedSymlinkTargetText).toBe( - appearedType === "symlink" ? `${JSON.stringify(emptyProjection, null, 2)}\n` : null, + expect(result.configIsFifo).toBe(expectedConfigIsFifo); + expect(result.configIsSymlink).toBe(expectedConfigIsSymlink); + expect(result.managedSymlinkTargetText).toBe(expectedTargetText); + }, + ); + + it("rejects a symlinked projection parent without reading its target (#10754)", () => { + const initialText = `${JSON.stringify(emptyProjection, null, 2)}\n`; + const status = runDeepAgentsConfigCommand( + buildDeepAgentsMcpStatusCommand(baseEntry), + emptyProjection, + "v2", + undefined, + 0o600, + { parentSymlink: true }, + ); + expect(status.status).toBe(2); + expect(status.stdout.trim()).toBe(""); + expect(status.stderr).toContain( + "Unsafe managed Deep Agents MCP projection path: symbolic link", + ); + expect(status.managedParentIsSymlink).toBe(true); + expect(status.managedParentTargetText).toBe(initialText); + }); + + it.each([ + ["registration", registrationCommand], + ["forced removal", buildDeepAgentsMcpRemoveCommand(baseEntry, true)], + ])( + "rejects a symlinked projection parent during %s without mutating its target (#10754)", + (_operation, command) => { + const initialText = `${JSON.stringify(emptyProjection, null, 2)}\n`; + const mutation = runDeepAgentsConfigCommand( + command, + emptyProjection, + "v2", + undefined, + 0o600, + { parentSymlink: true }, ); + expect(mutation.status).toBe(2); + expect(mutation.managedParentIsSymlink).toBe(true); + expect(mutation.managedParentTargetText).toBe(initialText); }, ); diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts index d484ec70a7c..c21db5ed120 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts @@ -28,11 +28,76 @@ export const DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS = [ " if not stat.S_ISREG(mode):", " return 'non-regular file'", " return None", + "def managed_parent_path_kind(mode):", + " if stat.S_ISLNK(mode):", + " return 'symbolic link'", + " if not stat.S_ISDIR(mode):", + " return 'non-directory parent'", + " return None", "def managed_fingerprint(metadata):", " return (metadata.st_dev, metadata.st_ino, metadata.st_size, metadata.st_mtime_ns, metadata.st_ctime_ns, metadata.st_mode, metadata.st_nlink, metadata.st_uid)", - "def assert_managed_source_stable(path, identity, unsafe_path_prefix=None):", + "def managed_parent_fingerprint(metadata):", + " return (metadata.st_dev, metadata.st_ino, metadata.st_mode, metadata.st_uid)", + "def raise_managed_parent_error(parent_descriptor, component, exc, unsafe_path_prefix=None):", + " unsafe_path_kind = None", + " try:", + " unsafe_path_kind = managed_parent_path_kind(os.stat(component, dir_fd=parent_descriptor, follow_symlinks=False).st_mode)", + " except OSError:", + " pass", + " if unsafe_path_kind and unsafe_path_prefix:", + " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}') from exc", + " if unsafe_path_kind:", + " raise ValueError(f'managed MCP projection parent is a {unsafe_path_kind}') from exc", + " raise exc", + "def open_managed_parent(path, create=False, unsafe_path_prefix=None):", + " if not path.is_absolute() or not path.name or path.name in ('.', '..'):", + " raise ValueError('managed MCP projection path must be absolute')", + " components = path.parts[1:-1]", + " descriptor = os.open('/', os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC)", + " created = False", + " try:", + " for index, component in enumerate(components):", + " flags = os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC | os.O_NOFOLLOW", + " try:", + " next_descriptor = os.open(component, flags, dir_fd=descriptor)", + " except FileNotFoundError:", + " if not create or index != len(components) - 1:", + " raise", + " try:", + " os.mkdir(component, 0o700, dir_fd=descriptor)", + " created = True", + " except FileExistsError:", + " pass", + " try:", + " next_descriptor = os.open(component, flags, dir_fd=descriptor)", + " except OSError as exc:", + " raise_managed_parent_error(descriptor, component, exc, unsafe_path_prefix)", + " except OSError as exc:", + " raise_managed_parent_error(descriptor, component, exc, unsafe_path_prefix)", + " os.close(descriptor)", + " descriptor = next_descriptor", + " metadata = os.fstat(descriptor)", + " if not stat.S_ISDIR(metadata.st_mode):", + " raise ValueError('managed MCP projection parent is not a directory')", + " return descriptor, managed_parent_fingerprint(metadata), created", + " except Exception:", + " os.close(descriptor)", + " raise", + "def assert_managed_parent_stable(path, identity, unsafe_path_prefix=None):", " try:", - " current_metadata = os.stat(path, follow_symlinks=False)", + " descriptor, current, _ = open_managed_parent(path, unsafe_path_prefix=unsafe_path_prefix)", + " except FileNotFoundError:", + " current = None", + " descriptor = None", + " try:", + " if current != identity:", + " raise ValueError('managed MCP projection parent changed during operation')", + " finally:", + " if descriptor is not None:", + " os.close(descriptor)", + "def assert_managed_source_stable(entry_name, parent_descriptor, identity, unsafe_path_prefix=None):", + " try:", + " current_metadata = os.stat(entry_name, dir_fd=parent_descriptor, follow_symlinks=False)", " except FileNotFoundError:", " current_metadata = None", " current = managed_fingerprint(current_metadata) if current_metadata is not None else None", @@ -45,9 +110,9 @@ export const DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS = [ " return", " if current != identity:", " raise ValueError('managed MCP projection changed before mutation')", - "def validate_managed_descriptor_path(path, descriptor, unsafe_path_prefix=None):", + "def validate_managed_descriptor_path(entry_name, parent_descriptor, descriptor, unsafe_path_prefix=None):", " opened = os.fstat(descriptor)", - " linked = os.stat(path, follow_symlinks=False)", + " linked = os.stat(entry_name, dir_fd=parent_descriptor, follow_symlinks=False)", " if unsafe_path_prefix:", " unsafe_path_kind = managed_projection_path_kind(linked.st_mode)", " if unsafe_path_kind:", @@ -57,78 +122,90 @@ export const DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS = [ " raise ValueError('managed MCP projection has unsafe ownership, mode, type, links, or path identity')", " return managed_fingerprint(opened)", "def open_managed_projection(path, writable=False, unsafe_path_prefix=None):", - " access = os.O_RDWR if writable else os.O_RDONLY", - " flags = access | os.O_CLOEXEC | os.O_NONBLOCK | os.O_NOFOLLOW", " try:", - " descriptor = os.open(path, flags)", + " parent_descriptor, parent_identity, _ = open_managed_parent(path, unsafe_path_prefix=unsafe_path_prefix)", " except FileNotFoundError:", - " assert_managed_source_stable(path, None, unsafe_path_prefix)", - " return b'', None, None", - " except OSError as exc:", - " unsafe_path_kind = None", - " if unsafe_path_prefix:", - " if exc.errno == errno.ELOOP:", - " unsafe_path_kind = 'symbolic link'", - " else:", - " try:", - " unsafe_path_kind = managed_projection_path_kind(os.stat(path, follow_symlinks=False).st_mode)", - " except OSError:", - " pass", - " if unsafe_path_kind:", - " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}') from exc", - " raise", + " return b'', None, None, None, None", + " entry_name = path.name", + " access = os.O_RDWR if writable else os.O_RDONLY", + " flags = access | os.O_CLOEXEC | os.O_NONBLOCK | os.O_NOFOLLOW", " try:", - " before = os.fstat(descriptor)", - " if unsafe_path_prefix:", - " unsafe_path_kind = managed_projection_path_kind(before.st_mode)", - " if unsafe_path_kind:", - " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}')", - " validate_managed_descriptor_path(path, descriptor, unsafe_path_prefix)", - " if before.st_size < 0 or before.st_size > MANAGED_MCP_MAX_BYTES:", - " raise ValueError('managed MCP projection has invalid size')", - " chunks = []", - " remaining = before.st_size", - " while remaining:", - " chunk = os.read(descriptor, remaining)", - " if not chunk:", - " break", - " chunks.append(chunk)", - " remaining -= len(chunk)", - " after = os.fstat(descriptor)", - " linked_after = os.stat(path, follow_symlinks=False)", - " if unsafe_path_prefix:", - " unsafe_path_kind = managed_projection_path_kind(linked_after.st_mode)", + " try:", + " descriptor = os.open(entry_name, flags, dir_fd=parent_descriptor)", + " except FileNotFoundError:", + " assert_managed_source_stable(entry_name, parent_descriptor, None, unsafe_path_prefix)", + " assert_managed_parent_stable(path, parent_identity, unsafe_path_prefix)", + " return b'', None, None, parent_descriptor, parent_identity", + " except OSError as exc:", + " unsafe_path_kind = None", + " if unsafe_path_prefix:", + " if exc.errno == errno.ELOOP:", + " unsafe_path_kind = 'symbolic link'", + " else:", + " try:", + " unsafe_path_kind = managed_projection_path_kind(os.stat(entry_name, dir_fd=parent_descriptor, follow_symlinks=False).st_mode)", + " except OSError:", + " pass", " if unsafe_path_kind:", - " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}')", - " stable = (managed_fingerprint(before) == managed_fingerprint(after) and managed_fingerprint(after) == managed_fingerprint(linked_after))", - " if remaining or not stable:", - " raise ValueError('managed MCP projection changed while reading')", - " return b''.join(chunks), managed_fingerprint(after), descriptor", + " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}') from exc", + " raise", + " try:", + " before = os.fstat(descriptor)", + " if unsafe_path_prefix:", + " unsafe_path_kind = managed_projection_path_kind(before.st_mode)", + " if unsafe_path_kind:", + " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}')", + " validate_managed_descriptor_path(entry_name, parent_descriptor, descriptor, unsafe_path_prefix)", + " if before.st_size < 0 or before.st_size > MANAGED_MCP_MAX_BYTES:", + " raise ValueError('managed MCP projection has invalid size')", + " chunks = []", + " remaining = before.st_size", + " while remaining:", + " chunk = os.read(descriptor, remaining)", + " if not chunk:", + " break", + " chunks.append(chunk)", + " remaining -= len(chunk)", + " after = os.fstat(descriptor)", + " linked_after = os.stat(entry_name, dir_fd=parent_descriptor, follow_symlinks=False)", + " if unsafe_path_prefix:", + " unsafe_path_kind = managed_projection_path_kind(linked_after.st_mode)", + " if unsafe_path_kind:", + " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}')", + " stable = (managed_fingerprint(before) == managed_fingerprint(after) and managed_fingerprint(after) == managed_fingerprint(linked_after))", + " if remaining or not stable:", + " raise ValueError('managed MCP projection changed while reading')", + " assert_managed_parent_stable(path, parent_identity, unsafe_path_prefix)", + " return b''.join(chunks), managed_fingerprint(after), descriptor, parent_descriptor, parent_identity", + " except Exception:", + " os.close(descriptor)", + " raise", " except Exception:", - " os.close(descriptor)", + " os.close(parent_descriptor)", " raise", "def decode_managed_projection(raw):", " return strict_json_loads(raw.decode('utf-8')) if raw else {}", - "def close_managed_projection_descriptor(descriptor):", - " if descriptor is None:", - " return", - " try:", - " os.close(descriptor)", - " except OSError:", - " pass", + "def close_managed_projection_descriptors(descriptor, parent_descriptor):", + " for current in (descriptor, parent_descriptor):", + " if current is None:", + " continue", + " try:", + " os.close(current)", + " except OSError:", + " pass", "def load_managed_projection_for_update(path):", - " raw, identity, descriptor = open_managed_projection(path, True)", + " raw, identity, descriptor, parent_descriptor, parent_identity = open_managed_projection(path, True)", " try:", - " return decode_managed_projection(raw), identity, descriptor", + " return decode_managed_projection(raw), identity, descriptor, parent_descriptor, parent_identity", " except Exception:", - " close_managed_projection_descriptor(descriptor)", + " close_managed_projection_descriptors(descriptor, parent_descriptor)", " raise", "def read_managed_projection(path, unsafe_path_prefix=None):", - " raw, identity, descriptor = open_managed_projection(path, unsafe_path_prefix=unsafe_path_prefix)", + " raw, identity, descriptor, parent_descriptor, _ = open_managed_projection(path, unsafe_path_prefix=unsafe_path_prefix)", " try:", " return decode_managed_projection(raw), identity", " finally:", - " close_managed_projection_descriptor(descriptor)", + " close_managed_projection_descriptors(descriptor, parent_descriptor)", ]; export const DEEPAGENTS_MANAGED_PROJECTION_MUTATION_HELPERS = [ @@ -137,10 +214,11 @@ export const DEEPAGENTS_MANAGED_PROJECTION_MUTATION_HELPERS = [ " if not payload or len(payload) > MANAGED_MCP_MAX_BYTES:", " raise ValueError('managed MCP projection has invalid rendered size')", " return payload", - "def rewrite_managed_projection(path, value, identity, descriptor):", - " if identity is None or descriptor is None:", + "def rewrite_managed_projection(path, value, identity, descriptor, parent_descriptor, parent_identity):", + " if identity is None or descriptor is None or parent_descriptor is None or parent_identity is None:", " raise ValueError('managed MCP projection descriptor is unavailable')", - " assert_managed_source_stable(path, identity)", + " assert_managed_parent_stable(path, parent_identity)", + " assert_managed_source_stable(path.name, parent_descriptor, identity)", " payload = managed_projection_bytes(value)", " os.lseek(descriptor, 0, os.SEEK_SET)", " os.ftruncate(descriptor, 0)", @@ -155,40 +233,62 @@ export const DEEPAGENTS_MANAGED_PROJECTION_MUTATION_HELPERS = [ " persisted = os.read(descriptor, len(payload) + 1)", " if persisted != payload or os.fstat(descriptor).st_size != len(payload):", " raise ValueError('managed MCP projection verification failed')", - " validate_managed_descriptor_path(path, descriptor)", - "def publish_absent_managed_projection(path, value):", - " assert_managed_source_stable(path, None)", - " payload = managed_projection_bytes(value)", - " tmp_fd, tmp_name = tempfile.mkstemp(prefix='.nemoclaw-mcp.', dir=path.parent)", - " try:", - " os.fchmod(tmp_fd, 0o600)", - " with os.fdopen(tmp_fd, 'wb') as tmp_file:", - " tmp_file.write(payload)", - " tmp_file.flush()", - " os.fsync(tmp_file.fileno())", + " validate_managed_descriptor_path(path.name, parent_descriptor, descriptor)", + " assert_managed_parent_stable(path, parent_identity)", + "def create_managed_projection_temp(parent_descriptor):", + " flags = os.O_RDWR | os.O_CREAT | os.O_EXCL | os.O_CLOEXEC | os.O_NOFOLLOW", + " for _ in range(16):", + " name = '.nemoclaw-mcp.' + os.urandom(16).hex()", " try:", - " os.link(tmp_name, path, follow_symlinks=False)", - " except FileExistsError as exc:", - " raise ValueError('managed MCP projection appeared during publication') from exc", - " os.unlink(tmp_name)", - " finally:", - " try:", - " os.unlink(tmp_name)", - " except FileNotFoundError:", + " return os.open(name, flags, 0o600, dir_fd=parent_descriptor), name", + " except FileExistsError:", " pass", - " persisted, _ = read_managed_projection(path)", - " if persisted != value:", - " raise ValueError('managed MCP projection verification failed')", - "def write_managed_projection(path, value, identity, descriptor):", - " if identity is None:", - " if descriptor is not None:", - " raise ValueError('unexpected managed MCP projection descriptor')", - " publish_absent_managed_projection(path, value)", - " else:", + " raise FileExistsError('could not allocate managed MCP projection temporary file')", + "def publish_absent_managed_projection(path, value, parent_descriptor, parent_identity):", + " owns_parent_descriptor = parent_descriptor is None", + " if parent_descriptor is None:", + " parent_descriptor, parent_identity, created = open_managed_parent(path, True)", + " if not created:", + " os.close(parent_descriptor)", + " raise ValueError('managed MCP projection parent appeared during publication')", + " try:", + " if not owns_parent_descriptor:", + " assert_managed_parent_stable(path, parent_identity)", + " assert_managed_source_stable(path.name, parent_descriptor, None)", + " payload = managed_projection_bytes(value)", + " tmp_fd, tmp_name = create_managed_projection_temp(parent_descriptor)", " try:", - " rewrite_managed_projection(path, value, identity, descriptor)", + " with os.fdopen(tmp_fd, 'wb') as tmp_file:", + " tmp_file.write(payload)", + " tmp_file.flush()", + " os.fsync(tmp_file.fileno())", + " try:", + " os.link(tmp_name, path.name, src_dir_fd=parent_descriptor, dst_dir_fd=parent_descriptor, follow_symlinks=False)", + " except FileExistsError as exc:", + " raise ValueError('managed MCP projection appeared during publication') from exc", + " os.unlink(tmp_name, dir_fd=parent_descriptor)", " finally:", - " close_managed_projection_descriptor(descriptor)", + " try:", + " os.unlink(tmp_name, dir_fd=parent_descriptor)", + " except FileNotFoundError:", + " pass", + " assert_managed_parent_stable(path, parent_identity)", + " persisted, _ = read_managed_projection(path)", + " if persisted != value:", + " raise ValueError('managed MCP projection verification failed')", + " finally:", + " if owns_parent_descriptor:", + " os.close(parent_descriptor)", + "def write_managed_projection(path, value, identity, descriptor, parent_descriptor, parent_identity):", + " try:", + " if identity is None:", + " if descriptor is not None:", + " raise ValueError('unexpected managed MCP projection descriptor')", + " publish_absent_managed_projection(path, value, parent_descriptor, parent_identity)", + " else:", + " rewrite_managed_projection(path, value, identity, descriptor, parent_descriptor, parent_identity)", + " finally:", + " close_managed_projection_descriptors(descriptor, parent_descriptor)", ]; export const DEEPAGENTS_MANAGED_PROJECTION_HELPERS = [ diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-registration.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-registration.ts index 0b9365de1b0..5657a097489 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-registration.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-registration.ts @@ -52,19 +52,20 @@ export function buildDeepAgentsMcpRegisterCommand( }; return [ "/opt/venv/bin/python3 -I - <<'PY'", - "import json, os, pathlib, stat, sys, tempfile", + "import json, os, pathlib, stat, sys", `payload = json.loads(${pythonJsonLiteral(payload)})`, `config_path = pathlib.Path(${JSON.stringify(DEEPAGENTS_MCP_CONFIG_PATH)})`, ...DEEPAGENTS_STRICT_JSON_HELPERS, ...DEEPAGENTS_MANAGED_PROJECTION_HELPERS, ...MANAGED_HTTP_SERVER_MATCH_HELPERS, "source_descriptor = None", + "source_parent_descriptor = None", "def fail_registration(message):", - " close_managed_projection_descriptor(source_descriptor)", + " close_managed_projection_descriptors(source_descriptor, source_parent_descriptor)", " print(message, file=sys.stderr)", " raise SystemExit(2)", "try:", - " data, source_identity, source_descriptor = load_managed_projection_for_update(config_path)", + " data, source_identity, source_descriptor, source_parent_descriptor, source_parent_identity = load_managed_projection_for_update(config_path)", "except (OSError, UnicodeDecodeError, ValueError) as exc:", ` fail_registration(f'Invalid ${DEEPAGENTS_MCP_CONFIG_PATH}: {exc}')`, "if not isinstance(data, dict):", @@ -95,9 +96,8 @@ export function buildDeepAgentsMcpRegisterCommand( " else:", " next_servers[name] = expected", "data = {'mcpServers': next_servers}", - "config_path.parent.mkdir(parents=True, exist_ok=True)", "try:", - " write_managed_projection(config_path, data, source_identity, source_descriptor)", + " write_managed_projection(config_path, data, source_identity, source_descriptor, source_parent_descriptor, source_parent_identity)", "except (OSError, ValueError) as exc:", ` fail_registration(f'Could not publish ${DEEPAGENTS_MCP_CONFIG_PATH}: {exc}')`, "PY", diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-teardown.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-teardown.ts index c27d89dc2f3..4ffe28abe34 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-teardown.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-teardown.ts @@ -61,18 +61,20 @@ export function buildDeepAgentsMcpRemoveCommand( "config_path = managed_path if is_v2 else legacy_path", "managed_identity = None", "managed_descriptor = None", + "managed_parent_identity = None", + "managed_parent_descriptor = None", "legacy_identity = None", "def finish(outcome):", - " close_managed_projection_descriptor(managed_descriptor)", + " close_managed_projection_descriptors(managed_descriptor, managed_parent_descriptor)", " print('NEMOCLAW_DEEPAGENTS_MCP_REMOVAL=' + outcome)", " raise SystemExit(0)", "def fail_teardown(message):", - " close_managed_projection_descriptor(managed_descriptor)", + " close_managed_projection_descriptors(managed_descriptor, managed_parent_descriptor)", " print(message, file=sys.stderr)", " raise SystemExit(2)", - "def repair_v2_projection(identity, descriptor):", + "def repair_v2_projection(identity, descriptor, parent_descriptor, parent_identity):", " try:", - " write_managed_projection(config_path, {'mcpServers': {}}, identity, descriptor)", + " write_managed_projection(config_path, {'mcpServers': {}}, identity, descriptor, parent_descriptor, parent_identity)", " except (OSError, ValueError) as exc:", " fail_teardown(f'Refusing unsafe managed MCP v2 repair at {config_path}: {exc}')", "def write_legacy_data(value):", @@ -97,7 +99,7 @@ export function buildDeepAgentsMcpRemoveCommand( " pass", "if is_v2:", " try:", - " raw, managed_identity, managed_descriptor = open_managed_projection(config_path, True)", + " raw, managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity = open_managed_projection(config_path, True)", " except (OSError, ValueError) as exc:", " fail_teardown(f'Invalid managed MCP v2 projection at {config_path}: {exc}')", " if managed_descriptor is None:", @@ -106,7 +108,7 @@ export function buildDeepAgentsMcpRemoveCommand( " data = decode_managed_projection(raw)", " except (UnicodeDecodeError, ValueError) as exc:", " if payload['force']:", - " repair_v2_projection(managed_identity, managed_descriptor)", + " repair_v2_projection(managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity)", " finish('removed')", " fail_teardown(f'Invalid managed MCP v2 projection at {config_path}: {exc}')", "else:", @@ -118,7 +120,7 @@ export function buildDeepAgentsMcpRemoveCommand( " finish('unowned')", "if not isinstance(data, dict):", " if is_v2 and payload['force']:", - " repair_v2_projection(managed_identity, managed_descriptor)", + " repair_v2_projection(managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity)", " finish('removed')", " if is_v2:", " fail_teardown(f'Invalid managed MCP v2 projection at {config_path}: expected object')", @@ -128,7 +130,7 @@ export function buildDeepAgentsMcpRemoveCommand( " if not is_v2 and 'mcpServers' not in data:", " finish('absent')", " if is_v2 and payload['force']:", - " repair_v2_projection(managed_identity, managed_descriptor)", + " repair_v2_projection(managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity)", " finish('removed')", " if is_v2:", " fail_teardown(f'Invalid managed MCP v2 server map at {config_path}')", @@ -138,7 +140,7 @@ export function buildDeepAgentsMcpRemoveCommand( "if is_v2:", " if data and set(data) != {'mcpServers'}:", " if payload['force']:", - " repair_v2_projection(managed_identity, managed_descriptor)", + " repair_v2_projection(managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity)", " finish('removed')", " fail_teardown(f'Invalid managed MCP v2 projection at {config_path}: only mcpServers is allowed')", " if present and not payload['force'] and not managed_http_server_matches(current, payload['expected'], True):", @@ -158,7 +160,7 @@ export function buildDeepAgentsMcpRemoveCommand( "if data:", " try:", " if is_v2:", - " write_managed_projection(config_path, data, managed_identity, managed_descriptor)", + " write_managed_projection(config_path, data, managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity)", " persisted = read_managed_projection(config_path)[0]", " else:", " write_legacy_data(data)", diff --git a/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts b/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts index e022775ee82..b61e9e82284 100644 --- a/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts @@ -7,12 +7,6 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { - baseEntry, - runDeepAgentsConfigCommand, -} from "../../../../test/helpers/mcp-bridge-adapter-deepagents-fixture"; - -import { buildDeepAgentsMcpStatusCommand } from "./mcp-bridge-adapter-status"; const sourceRequireHook = path.resolve("test/helpers/onboard-script-mocks.cjs"); const sourceNodeOptions = [process.env.NODE_OPTIONS, `--require=${sourceRequireHook}`] @@ -199,31 +193,13 @@ ${body} describe("MCP status wire-level credential-resolution probe", { timeout: 15_000 }, () => { it( - "returns a type-specific failure for unsafe Deep Agents projection races (#10754)", - { timeout: 120_000 }, + "preserves fixed unsafe projection diagnostics across credential observations (#10754)", + { timeout: 30_000 }, () => { - const statusCommand = buildDeepAgentsMcpStatusCommand(baseEntry); - const projection = { mcpServers: {} }; - const unsafeResults = [ - ...(["symlink", "fifo"] as const).map((appearedType) => - runDeepAgentsConfigCommand(statusCommand, projection, "v2", undefined, 0o600, { - swapAfterMissingManagedOpen: appearedType, - timeoutMs: 30_000, - }), - ), - runDeepAgentsConfigCommand(statusCommand, projection, "v2", undefined, 0o600, { - statAfterManagedEloopAsRegular: true, - symlink: true, - timeoutMs: 30_000, - }), + const unsafeDetails = [ + "Unsafe managed Deep Agents MCP projection path: symbolic link", + "Unsafe managed Deep Agents MCP projection path: FIFO", ]; - unsafeResults.forEach((result) => { - expect(result.status).toBe(2); - expect(result.stdout.trim()).toBe(""); - expect(result.stderr).toContain("Unsafe managed Deep Agents MCP projection path:"); - expect(result.configIsSymlink || result.configIsFifo).toBe(true); - }); - const unsafeDetails = unsafeResults.map((result) => result.stderr.trim()); const home = createTempHome("nemoclaw-mcp-status-projection-"); const { stdout } = runHarness( home, @@ -275,7 +251,7 @@ describe("MCP status wire-level credential-resolution probe", { timeout: 15_000 stderr: string; }>; - expect(outcomes).toHaveLength(9); + expect(outcomes).toHaveLength(6); outcomes.forEach((outcome) => { expect(["unavailable", "absent", "canonical"]).toContain(outcome.observation); expect(outcome.exitCode).toBe(2); diff --git a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts index 107c0609b1d..ded631c8e90 100644 --- a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts +++ b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts @@ -34,6 +34,8 @@ export interface DeepAgentsConfigCommandResult { legacyConfigExists: boolean; legacyConfig: Record | null; legacyConfigText: string | null; + managedParentIsSymlink: boolean; + managedParentTargetText: string | null; managedSymlinkTargetExists: boolean; managedSymlinkTargetText: string | null; } @@ -43,6 +45,7 @@ export interface DeepAgentsManagedFixtureOptions { directory?: boolean; fifo?: boolean; mode?: number; + parentSymlink?: boolean; statAfterManagedEloopAsRegular?: boolean; swapAfterManagedOpen?: "fifo" | "symlink"; swapAfterMissingManagedOpen?: "fifo" | "symlink"; @@ -195,8 +198,11 @@ export function runDeepAgentsConfigCommand( ): DeepAgentsConfigCommandResult { const managedSwap = resolveManagedSwap(managedOptions); const fixtureRoot = managedSwap?.kind === "socket" ? "/tmp" : os.tmpdir(); - const tmp = fs.mkdtempSync(path.join(fixtureRoot, "nemoclaw-deepagents-mcp-")); + const tmp = fs.realpathSync(fs.mkdtempSync(path.join(fixtureRoot, "nemoclaw-deepagents-mcp-"))); const configPath = path.join(tmp, ".deepagents", ".nemoclaw-mcp.json"); + const managedParentPath = path.dirname(configPath); + const managedParentTarget = path.join(tmp, "managed-parent-target"); + const managedParentTargetConfig = path.join(managedParentTarget, path.basename(configPath)); const managedSymlinkTarget = path.join(tmp, "managed-projection-target.json"); const legacyConfigPath = path.join(tmp, ".deepagents", ".mcp.json"); const initializeConfig = ( @@ -214,7 +220,11 @@ export function runDeepAgentsConfigCommand( }; const managedSymlink = managedOptions.symlink === true || managedOptions.danglingSymlink === true; const managedInitialPath = managedSymlink ? managedSymlinkTarget : configPath; - if (managedOptions.directory) { + if (managedOptions.parentSymlink) { + fs.mkdirSync(managedParentTarget, { recursive: true }); + initializeConfig(managedParentTargetConfig, initialConfig, managedOptions.mode); + fs.symlinkSync(managedParentTarget, managedParentPath); + } else if (managedOptions.directory) { fs.mkdirSync(configPath, { recursive: true, mode: managedOptions.mode ?? 0o700 }); } else if (managedOptions.fifo) { fs.mkdirSync(path.dirname(configPath), { recursive: true }); @@ -269,6 +279,12 @@ export function runDeepAgentsConfigCommand( // A missing projection has no path type to report. } const configExists = configStat !== null; + let managedParentIsSymlink = false; + try { + managedParentIsSymlink = fs.lstatSync(managedParentPath).isSymbolicLink(); + } catch { + // A missing projection parent cannot redirect the managed path. + } const legacyConfigExists = fs.existsSync(legacyConfigPath); const configIsFifo = configStat?.isFIFO() === true; const configIsSocket = configStat?.isSocket() === true; @@ -282,6 +298,9 @@ export function runDeepAgentsConfigCommand( const managedSymlinkTargetText = managedSymlinkTargetExists ? fs.readFileSync(managedSymlinkTarget, "utf-8") : null; + const managedParentTargetText = fs.existsSync(managedParentTargetConfig) + ? fs.readFileSync(managedParentTargetConfig, "utf-8") + : null; const legacyConfigText = legacyConfigExists ? fs.readFileSync(legacyConfigPath, "utf-8") : null; const parseConfigText = (text: string | null): Record | null => { if (!text) return null; @@ -308,6 +327,8 @@ export function runDeepAgentsConfigCommand( legacyConfigExists, legacyConfig: parseConfigText(legacyConfigText), legacyConfigText, + managedParentIsSymlink, + managedParentTargetText, managedSymlinkTargetExists, managedSymlinkTargetText, }; From 35318a04a5e02dfa5a5b4f37cf7d857362ab9886 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 1 Sep 2026 23:39:55 -0700 Subject: [PATCH 16/49] test(mcp): exercise runtime detection in fixtures Signed-off-by: Prekshi Vyas --- .../mcp-bridge-adapter-deepagents-legacy.ts | 2 +- .../mcp-bridge-adapter-deepagents-teardown.ts | 2 +- .../sandbox/mcp-bridge-adapter-status.ts | 2 +- .../mcp-bridge-adapter-deepagents-fixture.ts | 52 +++++++++++++------ 4 files changed, 38 insertions(+), 20 deletions(-) diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy.ts index f7d3e18acd6..bcce0026c77 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy.ts @@ -48,7 +48,7 @@ export function buildDeepAgentsMcpRollbackRegisterCommand( ...DEEPAGENTS_MANAGED_PROJECTION_HELPERS, ...DEEPAGENTS_LEGACY_CONFIG_HELPERS, ...MANAGED_HTTP_SERVER_MATCH_HELPERS, - `runtime_kind = "auto" # NEMOCLAW_DEEPAGENTS_RUNTIME_TEST_ANCHOR`, + `runtime_kind = "auto"`, "if runtime_kind == 'auto':", " runtime_kind = 'unknown'", " try:", diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-teardown.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-teardown.ts index 4ffe28abe34..94135102f84 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-teardown.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-teardown.ts @@ -42,7 +42,7 @@ export function buildDeepAgentsMcpRemoveCommand( ...DEEPAGENTS_MANAGED_PROJECTION_HELPERS, ...DEEPAGENTS_LEGACY_CONFIG_HELPERS, ...MANAGED_HTTP_SERVER_MATCH_HELPERS, - `runtime_kind = "${adaptiveTeardown ? "auto" : "v2"}" # NEMOCLAW_DEEPAGENTS_RUNTIME_TEST_ANCHOR`, + `runtime_kind = "${adaptiveTeardown ? "auto" : "v2"}"`, "if runtime_kind == 'auto':", " runtime_kind = 'unknown'", " try:", diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-status.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-status.ts index e46d13bbcde..3bca5c8c498 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-status.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-status.ts @@ -243,7 +243,7 @@ export function buildDeepAgentsMcpStatusCommand( ...DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS, ...DEEPAGENTS_LEGACY_CONFIG_HELPERS, ...MANAGED_HTTP_SERVER_MATCH_HELPERS, - `runtime_kind = "auto" # NEMOCLAW_DEEPAGENTS_RUNTIME_TEST_ANCHOR`, + `runtime_kind = "auto"`, "if runtime_kind == 'auto':", " runtime_kind = 'unknown'", " try:", diff --git a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts index ded631c8e90..29464d39306 100644 --- a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts +++ b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts @@ -95,23 +95,40 @@ function resolveManagedSwap(options: DeepAgentsManagedFixtureOptions): ManagedSw return undefined; } -function createManagedSwapPythonExecutable( +function createDeepAgentsFixturePythonExecutable( fixtureRoot: string, configPath: string, + legacyConfigPath: string, managedSymlinkTarget: string, - managedSwap: ManagedSwap, + runtimeKind: "v2" | "legacy" | "unknown", + managedSwap?: ManagedSwap, ): { executablePath: string; scriptPath: string } { const executablePath = path.join(fixtureRoot, "managed-swap-python"); const scriptPath = path.join(fixtureRoot, "managed-swap-wrapper.py"); + const runtimeConfigPath = + runtimeKind === "v2" + ? configPath + : runtimeKind === "legacy" + ? legacyConfigPath + : path.join(fixtureRoot, "unknown-runtime-config.json"); + const runtimePackagePath = path.join(fixtureRoot, "deepagents_code"); + fs.mkdirSync(runtimePackagePath); + fs.writeFileSync(path.join(runtimePackagePath, "__init__.py"), "", { mode: 0o600 }); + fs.writeFileSync( + path.join(runtimePackagePath, "_nemoclaw_managed.py"), + `_MCP_CONFIG_FILE = ${JSON.stringify(runtimeConfigPath)}\n`, + { mode: 0o600 }, + ); const wrapper = [ "import os", "import socket", "import sys", + `fixture_root = ${JSON.stringify(fixtureRoot)}`, `managed_path = ${JSON.stringify(configPath)}`, `managed_target = ${JSON.stringify(managedSymlinkTarget)}`, - `managed_swap_kind = ${JSON.stringify(managedSwap.kind)}`, - `managed_swap_phase = ${JSON.stringify(managedSwap.phase)}`, - `managed_swap_content = ${JSON.stringify(managedSwap.content ?? "")}`, + `managed_swap_kind = ${JSON.stringify(managedSwap?.kind ?? "")}`, + `managed_swap_phase = ${JSON.stringify(managedSwap?.phase ?? "")}`, + `managed_swap_content = ${JSON.stringify(managedSwap?.content ?? "")}`, "managed_eloop_stage = 0", "managed_socket = None", "managed_swapped = False", @@ -170,7 +187,9 @@ function createManagedSwapPythonExecutable( " replace_managed_path()", "source = sys.stdin.read()", "namespace = {'__name__': '__main__'}", - "sys.setprofile(managed_profile)", + "sys.path.insert(0, fixture_root)", + "if managed_swap_phase:", + " sys.setprofile(managed_profile)", "try:", " exec(compile(source, '', 'exec'), namespace)", "finally:", @@ -251,24 +270,23 @@ export function runDeepAgentsConfigCommand( initializeConfig(legacyConfigPath, initialLegacyConfig); if (initialLegacyConfig !== undefined) fs.chmodSync(legacyConfigPath, initialLegacyMode); try { - const managedSwapPython = managedSwap - ? createManagedSwapPythonExecutable(tmp, configPath, managedSymlinkTarget, managedSwap) - : null; + const fixturePython = createDeepAgentsFixturePythonExecutable( + tmp, + configPath, + legacyConfigPath, + managedSymlinkTarget, + runtimeKind, + managedSwap, + ); const fixtureCommand = command .replaceAll(DEEPAGENTS_MCP_CONFIG_PATH, configPath) .replaceAll("/sandbox/.deepagents/.mcp.json", legacyConfigPath) - .replaceAll("/opt/venv/bin/python3", managedSwapPython?.executablePath ?? "python3") - .replace( - 'runtime_kind = "auto" # NEMOCLAW_DEEPAGENTS_RUNTIME_TEST_ANCHOR', - `runtime_kind = "${runtimeKind}" # NEMOCLAW_DEEPAGENTS_RUNTIME_TEST_ANCHOR`, - ); + .replaceAll("/opt/venv/bin/python3", fixturePython.executablePath); const result = spawnSync("bash", ["-c", fixtureCommand], { encoding: "utf-8", env: { ...process.env, - ...(managedSwapPython - ? { DEEPAGENTS_FIXTURE_PYTHON_WRAPPER: managedSwapPython.scriptPath } - : {}), + DEEPAGENTS_FIXTURE_PYTHON_WRAPPER: fixturePython.scriptPath, }, timeout: managedOptions.timeoutMs ?? 5000, }); From 825f6aae37d2c5363cc0faf2f8c1039aa7fd8535 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Wed, 2 Sep 2026 00:02:41 -0700 Subject: [PATCH 17/49] test(mcp): isolate projection safety scenarios Signed-off-by: Prekshi Vyas --- docs/manage-sandboxes/manage-mcp-servers.mdx | 4 +-- ...idge-adapter-deepagents-projection.test.ts | 29 +++++++++++++++++-- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/docs/manage-sandboxes/manage-mcp-servers.mdx b/docs/manage-sandboxes/manage-mcp-servers.mdx index ee2a0473450..16aab5bdf19 100644 --- a/docs/manage-sandboxes/manage-mcp-servers.mdx +++ b/docs/manage-sandboxes/manage-mcp-servers.mdx @@ -43,8 +43,8 @@ Before it reads `/sandbox/.deepagents/.nemoclaw-mcp.json`, Deep Agents Code v2 ` It does not follow a final symbolic link or read from a FIFO. If the path is a symbolic link, FIFO, or another non-regular file, the command names the path type, writes no status output, and exits with status `2`. The diagnostics are `Unsafe managed Deep Agents MCP projection path: symbolic link`, `Unsafe managed Deep Agents MCP projection path: FIFO`, and `Unsafe managed Deep Agents MCP projection path: non-regular file`. -This exit status applies only to the unsafe path-type diagnostic. -Other adapter inspection failures retain their ordinary status behavior. +Other Deep Agents inspection failures can also exit with status `2`. +Use the standard error diagnostic to distinguish an unsafe path from another inspection failure. Deep Agents Code v1 reads the legacy `.deepagents/.mcp.json` path and does not expose these type-specific diagnostics. diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts index 173152e49cc..ecde1cfaec1 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts @@ -366,12 +366,17 @@ describe("Deep Agents managed MCP projection safety", () => { it.each([ ["registration", registrationCommand], ["v2 rollback", rollbackCommand], - ])("rejects duplicate JSON and unsafe projection metadata during %s", (_name, command) => { + ])("rejects duplicate JSON during %s", (_name, command) => { const duplicate = runDeepAgentsConfigCommand(command, duplicateProjection); expect(duplicate.status).toBe(2); expect(duplicate.stderr).toContain("duplicate JSON key: mcpServers"); expect(duplicate.configText).toBe(duplicateProjection); + }); + it.each([ + ["registration", registrationCommand], + ["v2 rollback", rollbackCommand], + ])("rejects an unsafe projection mode during %s", (_name, command) => { const unsafeMode = runDeepAgentsConfigCommand( command, emptyProjection, @@ -383,7 +388,12 @@ describe("Deep Agents managed MCP projection safety", () => { expect(unsafeMode.status).toBe(2); expect(unsafeMode.stderr).toContain("unsafe ownership, mode, type, links, or path identity"); expect(unsafeMode.config).toEqual(emptyProjection); + }); + it.each([ + ["registration", registrationCommand], + ["v2 rollback", rollbackCommand], + ])("rejects a projection symlink during %s without mutating its target", (_name, command) => { const symlink = runDeepAgentsConfigCommand(command, emptyProjection, "v2", undefined, 0o600, { symlink: true, }); @@ -391,7 +401,7 @@ describe("Deep Agents managed MCP projection safety", () => { expect(symlink.managedSymlinkTargetText).toBe(`${JSON.stringify(emptyProjection, null, 2)}\n`); }); - it("never clobbers a projection that appears during absent publication or fd rewrite", () => { + it("preserves a projection that appears during absent publication", () => { const absentResult = runDeepAgentsConfigCommand( registrationCommand, undefined, @@ -403,7 +413,9 @@ describe("Deep Agents managed MCP projection safety", () => { expect(absentResult.status).toBe(2); expect(absentResult.stderr).toContain("appeared during publication"); expect(absentResult.configText).toBe(attackerProjection); + }); + it("preserves a replacement during a descriptor rewrite", () => { const existingResult = runDeepAgentsConfigCommand( registrationCommand, emptyProjection, @@ -433,7 +445,7 @@ describe("Deep Agents managed MCP projection safety", () => { expect(raced.configText).toBe(attackerProjection); }); - it("keeps forced removal identity-bound for malformed and unsafe projections", () => { + it("preserves a projection symlink during forced removal", () => { const forcedCommand = buildDeepAgentsMcpRemoveCommand(baseEntry, true); const forcedSymlink = runDeepAgentsConfigCommand( forcedCommand, @@ -449,7 +461,10 @@ describe("Deep Agents managed MCP projection safety", () => { expect(forcedSymlink.managedSymlinkTargetText).toBe( `${JSON.stringify(emptyProjection, null, 2)}\n`, ); + }); + it("preserves an unsafe projection mode during forced removal", () => { + const forcedCommand = buildDeepAgentsMcpRemoveCommand(baseEntry, true); const forcedUnsafeMode = runDeepAgentsConfigCommand( forcedCommand, emptyProjection, @@ -460,7 +475,10 @@ describe("Deep Agents managed MCP projection safety", () => { ); expect(forcedUnsafeMode.status).toBe(2); expect(forcedUnsafeMode.config).toEqual(emptyProjection); + }); + it("preserves a projection FIFO during forced removal", () => { + const forcedCommand = buildDeepAgentsMcpRemoveCommand(baseEntry, true); const forcedFifo = runDeepAgentsConfigCommand( forcedCommand, undefined, @@ -472,11 +490,16 @@ describe("Deep Agents managed MCP projection safety", () => { expect(forcedFifo.status).toBe(2); expect(forcedFifo.configExists).toBe(true); expect(forcedFifo.configIsFifo).toBe(true); + }); + it("rejects duplicate projection JSON during ordinary removal", () => { const duplicate = runDeepAgentsConfigCommand(removalCommand, duplicateProjection); expect(duplicate.status).toBe(2); expect(duplicate.configText).toBe(duplicateProjection); + }); + it("repairs duplicate projection JSON during forced removal", () => { + const forcedCommand = buildDeepAgentsMcpRemoveCommand(baseEntry, true); const forcedDuplicate = runDeepAgentsConfigCommand(forcedCommand, duplicateProjection); expect(forcedDuplicate.status, forcedDuplicate.stderr).toBe(0); expect(forcedDuplicate.config).toEqual(emptyProjection); From 6429a92852eebc5b82eaa8ab60207aaf7a23bc26 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Wed, 2 Sep 2026 00:36:14 -0700 Subject: [PATCH 18/49] test(mcp): prove symlink targets stay unread Signed-off-by: Prekshi Vyas --- ...idge-adapter-deepagents-projection.test.ts | 27 +++++- .../mcp-bridge-adapter-deepagents-fixture.ts | 97 ++++++++++++++++--- 2 files changed, 106 insertions(+), 18 deletions(-) diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts index ecde1cfaec1..9350e67ab9c 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts @@ -97,7 +97,7 @@ describe("Deep Agents managed MCP projection safety", () => { options: { danglingSymlink: true }, }, ])( - "rejects an existing $caseName projection path without following it (#10754)", + "rejects an existing $caseName projection path (#10754)", ({ diagnostic, expectedConfigIsDirectory, @@ -127,6 +127,24 @@ describe("Deep Agents managed MCP projection safety", () => { }, ); + it("rejects a projection symlink without reading its target (#10754)", () => { + const result = runDeepAgentsConfigCommand( + buildDeepAgentsMcpStatusCommand(baseEntry), + undefined, + "v2", + undefined, + 0o600, + { symlink: true, targetReadProbe: true, timeoutMs: managedCommandTimeoutMs }, + ); + expect(result.status).toBe(2); + expect(result.stdout.trim()).toBe(""); + expect(result.stderr).toContain( + "Unsafe managed Deep Agents MCP projection path: symbolic link", + ); + expect(result.managedSymlinkTargetExists).toBe(true); + expect(result.managedTargetReadAccessed).toBe(false); + }); + it.each([ { diagnostic: "symbolic link", @@ -310,14 +328,13 @@ describe("Deep Agents managed MCP projection safety", () => { ); it("rejects a symlinked projection parent without reading its target (#10754)", () => { - const initialText = `${JSON.stringify(emptyProjection, null, 2)}\n`; const status = runDeepAgentsConfigCommand( buildDeepAgentsMcpStatusCommand(baseEntry), - emptyProjection, + undefined, "v2", undefined, 0o600, - { parentSymlink: true }, + { parentSymlink: true, targetReadProbe: true, timeoutMs: managedCommandTimeoutMs }, ); expect(status.status).toBe(2); expect(status.stdout.trim()).toBe(""); @@ -325,7 +342,7 @@ describe("Deep Agents managed MCP projection safety", () => { "Unsafe managed Deep Agents MCP projection path: symbolic link", ); expect(status.managedParentIsSymlink).toBe(true); - expect(status.managedParentTargetText).toBe(initialText); + expect(status.managedTargetReadAccessed).toBe(false); }); it.each([ diff --git a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts index 29464d39306..2971de0ec39 100644 --- a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts +++ b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { spawnSync } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -38,6 +38,7 @@ export interface DeepAgentsConfigCommandResult { managedParentTargetText: string | null; managedSymlinkTargetExists: boolean; managedSymlinkTargetText: string | null; + managedTargetReadAccessed: boolean; } export interface DeepAgentsManagedFixtureOptions { @@ -54,6 +55,7 @@ export interface DeepAgentsManagedFixtureOptions { swapOnManagedRead?: "fifo" | "symlink"; swapOnManagedSeek?: string; symlink?: boolean; + targetReadProbe?: boolean; timeoutMs?: number; } @@ -95,6 +97,45 @@ function resolveManagedSwap(options: DeepAgentsManagedFixtureOptions): ManagedSw return undefined; } +function createFifo(target: string, mode = 0o600): void { + fs.mkdirSync(path.dirname(target), { recursive: true }); + const fifo = spawnSync("mkfifo", [target], { encoding: "utf-8", timeout: 5000 }); + if (fifo.status !== 0) throw new Error(fifo.stderr || "could not create managed fixture FIFO"); + fs.chmodSync(target, mode); +} + +function pause(milliseconds: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds); +} + +function waitForPath(target: string, timeoutMs: number): boolean { + const deadline = Date.now() + timeoutMs; + do { + if (fs.existsSync(target)) return true; + pause(10); + } while (Date.now() < deadline); + return fs.existsSync(target); +} + +function startTargetReadProbe(target: string, ready: string, accessed: string) { + const script = [ + "import os, sys", + "open(sys.argv[2], 'x').close()", + "descriptor = os.open(sys.argv[1], os.O_WRONLY)", + "os.close(descriptor)", + "open(sys.argv[3], 'x').close()", + ].join("\n"); + const probe = spawn("python3", ["-I", "-c", script, target, ready, accessed], { + stdio: "ignore", + }); + if (!waitForPath(ready, 1000)) { + probe.kill("SIGTERM"); + throw new Error("managed target-read probe did not become ready"); + } + pause(50); + return probe; +} + function createDeepAgentsFixturePythonExecutable( fixtureRoot: string, configPath: string, @@ -223,6 +264,8 @@ export function runDeepAgentsConfigCommand( const managedParentTarget = path.join(tmp, "managed-parent-target"); const managedParentTargetConfig = path.join(managedParentTarget, path.basename(configPath)); const managedSymlinkTarget = path.join(tmp, "managed-projection-target.json"); + const managedTargetReadReady = path.join(tmp, "managed-target-read-ready"); + const managedTargetReadAccess = path.join(tmp, "managed-target-read-accessed"); const legacyConfigPath = path.join(tmp, ".deepagents", ".mcp.json"); const initializeConfig = ( target: string, @@ -238,20 +281,33 @@ export function runDeepAgentsConfigCommand( ); }; const managedSymlink = managedOptions.symlink === true || managedOptions.danglingSymlink === true; + const managedTargetReadPath = managedOptions.targetReadProbe + ? managedOptions.parentSymlink + ? managedParentTargetConfig + : managedSymlink + ? managedSymlinkTarget + : null + : null; + if (managedOptions.targetReadProbe && managedTargetReadPath === null) { + throw new Error("targetReadProbe requires a projection or parent symlink"); + } const managedInitialPath = managedSymlink ? managedSymlinkTarget : configPath; if (managedOptions.parentSymlink) { fs.mkdirSync(managedParentTarget, { recursive: true }); - initializeConfig(managedParentTargetConfig, initialConfig, managedOptions.mode); + if (managedTargetReadPath === managedParentTargetConfig) { + createFifo(managedParentTargetConfig, managedOptions.mode); + } else { + initializeConfig(managedParentTargetConfig, initialConfig, managedOptions.mode); + } fs.symlinkSync(managedParentTarget, managedParentPath); } else if (managedOptions.directory) { fs.mkdirSync(configPath, { recursive: true, mode: managedOptions.mode ?? 0o700 }); } else if (managedOptions.fifo) { - fs.mkdirSync(path.dirname(configPath), { recursive: true }); - const fifo = spawnSync("mkfifo", [configPath], { encoding: "utf-8", timeout: 5000 }); - if (fifo.status !== 0) throw new Error(fifo.stderr || "could not create managed fixture FIFO"); - fs.chmodSync(configPath, managedOptions.mode ?? 0o600); + createFifo(configPath, managedOptions.mode); } else { - if (!managedOptions.danglingSymlink && managedSwap?.phase !== "after-missing-open") { + if (managedTargetReadPath === managedSymlinkTarget) { + createFifo(managedSymlinkTarget, managedOptions.mode); + } else if (!managedOptions.danglingSymlink && managedSwap?.phase !== "after-missing-open") { initializeConfig(managedInitialPath, initialConfig, managedOptions.mode); if (initialConfig !== undefined) fs.chmodSync(managedInitialPath, managedOptions.mode ?? 0o600); @@ -269,7 +325,15 @@ export function runDeepAgentsConfigCommand( } initializeConfig(legacyConfigPath, initialLegacyConfig); if (initialLegacyConfig !== undefined) fs.chmodSync(legacyConfigPath, initialLegacyMode); + let targetReadProbe: ReturnType | null = null; try { + if (managedTargetReadPath !== null) { + targetReadProbe = startTargetReadProbe( + managedTargetReadPath, + managedTargetReadReady, + managedTargetReadAccess, + ); + } const fixturePython = createDeepAgentsFixturePythonExecutable( tmp, configPath, @@ -313,12 +377,17 @@ export function runDeepAgentsConfigCommand( ? fs.readFileSync(configPath, "utf-8") : null; const managedSymlinkTargetExists = fs.existsSync(managedSymlinkTarget); - const managedSymlinkTargetText = managedSymlinkTargetExists - ? fs.readFileSync(managedSymlinkTarget, "utf-8") - : null; - const managedParentTargetText = fs.existsSync(managedParentTargetConfig) - ? fs.readFileSync(managedParentTargetConfig, "utf-8") - : null; + const readRegularFile = (target: string): string | null => { + try { + return fs.lstatSync(target).isFile() ? fs.readFileSync(target, "utf-8") : null; + } catch { + return null; + } + }; + const managedSymlinkTargetText = readRegularFile(managedSymlinkTarget); + const managedParentTargetText = readRegularFile(managedParentTargetConfig); + const managedTargetReadAccessed = + managedTargetReadPath !== null && waitForPath(managedTargetReadAccess, 200); const legacyConfigText = legacyConfigExists ? fs.readFileSync(legacyConfigPath, "utf-8") : null; const parseConfigText = (text: string | null): Record | null => { if (!text) return null; @@ -349,8 +418,10 @@ export function runDeepAgentsConfigCommand( managedParentTargetText, managedSymlinkTargetExists, managedSymlinkTargetText, + managedTargetReadAccessed, }; } finally { + targetReadProbe?.kill("SIGTERM"); fs.rmSync(tmp, { recursive: true, force: true }); } } From 66e0d23550ae1eac8492a43c5794587c6ce7a387 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Wed, 2 Sep 2026 01:01:19 -0700 Subject: [PATCH 19/49] docs(mcp): include parent path diagnostic Signed-off-by: Prekshi Vyas --- docs/manage-sandboxes/manage-mcp-servers.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/manage-sandboxes/manage-mcp-servers.mdx b/docs/manage-sandboxes/manage-mcp-servers.mdx index 16aab5bdf19..88b9211c356 100644 --- a/docs/manage-sandboxes/manage-mcp-servers.mdx +++ b/docs/manage-sandboxes/manage-mcp-servers.mdx @@ -42,7 +42,7 @@ For a trusted private server, status resolves the endpoint without changing mana Before it reads `/sandbox/.deepagents/.nemoclaw-mcp.json`, Deep Agents Code v2 `mcp status` checks the path type. It does not follow a final symbolic link or read from a FIFO. If the path is a symbolic link, FIFO, or another non-regular file, the command names the path type, writes no status output, and exits with status `2`. -The diagnostics are `Unsafe managed Deep Agents MCP projection path: symbolic link`, `Unsafe managed Deep Agents MCP projection path: FIFO`, and `Unsafe managed Deep Agents MCP projection path: non-regular file`. +The diagnostic starts with `Unsafe managed Deep Agents MCP projection path:` and names a type such as `symbolic link`, `FIFO`, `non-regular file`, or `non-directory parent`. Other Deep Agents inspection failures can also exit with status `2`. Use the standard error diagnostic to distinguish an unsafe path from another inspection failure. Deep Agents Code v1 reads the legacy `.deepagents/.mcp.json` path and does not expose these type-specific diagnostics. From f2fe68d2377649db2dabbca391533f9e4474ecfd Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Wed, 2 Sep 2026 01:10:23 -0700 Subject: [PATCH 20/49] test(mcp): target post-open projection swap Signed-off-by: Prekshi Vyas --- test/helpers/mcp-bridge-adapter-deepagents-fixture.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts index 2971de0ec39..9982848dc17 100644 --- a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts +++ b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts @@ -216,7 +216,7 @@ function createDeepAgentsFixturePythonExecutable( " return", " if managed_swap_phase == 'before-open' and event == 'c_call' and target is os.open:", " replace_managed_path()", - " elif managed_swap_phase == 'after-open' and event == 'c_return' and target is os.open:", + " elif managed_swap_phase == 'after-open' and event == 'c_return' and target is os.open and _frame.f_code.co_name == 'open_managed_projection':", " replace_managed_path()", " elif managed_swap_phase == 'after-missing-open' and event == 'c_exception' and target is os.open:", " replace_managed_path()", From 8373ae12be547ca16d50875f1b8b49f7b9678b88 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Wed, 2 Sep 2026 01:25:29 -0700 Subject: [PATCH 21/49] test(mcp): use behavior-named race boundaries Signed-off-by: Prekshi Vyas --- ...idge-adapter-deepagents-projection.test.ts | 2 +- .../mcp-bridge-adapter-deepagents-fixture.ts | 127 ++++++++++++------ 2 files changed, 89 insertions(+), 40 deletions(-) diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts index 9350e67ab9c..76b9435c0cf 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts @@ -269,7 +269,7 @@ describe("Deep Agents managed MCP projection safety", () => { undefined, 0o600, { - statAfterManagedEloopAsRegular: true, + swapAfterManagedEloop: true, symlink: true, timeoutMs: managedCommandTimeoutMs, }, diff --git a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts index 9982848dc17..926361b55e2 100644 --- a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts +++ b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts @@ -47,7 +47,7 @@ export interface DeepAgentsManagedFixtureOptions { fifo?: boolean; mode?: number; parentSymlink?: boolean; - statAfterManagedEloopAsRegular?: boolean; + swapAfterManagedEloop?: boolean; swapAfterManagedOpen?: "fifo" | "symlink"; swapAfterMissingManagedOpen?: "fifo" | "symlink"; swapBeforeManagedLink?: string; @@ -63,36 +63,47 @@ type ManagedSwap = { content?: string; kind: "fifo" | "regular" | "socket" | "symlink"; phase: - | "after-missing-open" - | "after-open" - | "before-link" - | "before-open" - | "eloop-regular-stat" - | "read" - | "seek"; + | "after-missing-projection-open" + | "after-projection-eloop" + | "after-projection-open" + | "before-projection-open" + | "projection-publication-begins" + | "projection-read-begins" + | "projection-rewrite-begins"; }; function resolveManagedSwap(options: DeepAgentsManagedFixtureOptions): ManagedSwap | undefined { if (options.swapOnManagedOpen) { - return { kind: options.swapOnManagedOpen, phase: "before-open" }; + return { kind: options.swapOnManagedOpen, phase: "before-projection-open" }; } if (options.swapAfterManagedOpen) { - return { kind: options.swapAfterManagedOpen, phase: "after-open" }; + return { kind: options.swapAfterManagedOpen, phase: "after-projection-open" }; } if (options.swapAfterMissingManagedOpen) { - return { kind: options.swapAfterMissingManagedOpen, phase: "after-missing-open" }; + return { + kind: options.swapAfterMissingManagedOpen, + phase: "after-missing-projection-open", + }; } if (options.swapOnManagedRead) { - return { kind: options.swapOnManagedRead, phase: "read" }; + return { kind: options.swapOnManagedRead, phase: "projection-read-begins" }; } - if (options.statAfterManagedEloopAsRegular) { - return { kind: "symlink", phase: "eloop-regular-stat" }; + if (options.swapAfterManagedEloop) { + return { kind: "symlink", phase: "after-projection-eloop" }; } if (options.swapBeforeManagedLink !== undefined) { - return { content: options.swapBeforeManagedLink, kind: "regular", phase: "before-link" }; + return { + content: options.swapBeforeManagedLink, + kind: "regular", + phase: "projection-publication-begins", + }; } if (options.swapOnManagedSeek !== undefined) { - return { content: options.swapOnManagedSeek, kind: "regular", phase: "seek" }; + return { + content: options.swapOnManagedSeek, + kind: "regular", + phase: "projection-rewrite-begins", + }; } return undefined; } @@ -161,6 +172,7 @@ function createDeepAgentsFixturePythonExecutable( { mode: 0o600 }, ); const wrapper = [ + "import errno", "import os", "import socket", "import sys", @@ -173,6 +185,12 @@ function createDeepAgentsFixturePythonExecutable( "managed_eloop_stage = 0", "managed_socket = None", "managed_swapped = False", + "managed_descriptor = None", + "managed_entry_name = os.path.basename(managed_path)", + "managed_real_link = os.link", + "managed_real_lseek = os.lseek", + "managed_real_open = os.open", + "managed_real_read = os.read", "def replace_managed_path():", " global managed_socket, managed_swapped", " if managed_swapped:", @@ -205,37 +223,65 @@ function createDeepAgentsFixturePythonExecutable( " os.unlink(managed_path)", " os.symlink(managed_target, managed_path)", " managed_eloop_stage = 2", - "def managed_profile(_frame, event, target):", - " if managed_swap_phase == 'eloop-regular-stat':", - " if managed_eloop_stage == 0 and event == 'c_exception' and target is os.open:", - " replace_managed_symlink_with_regular()", - " elif managed_eloop_stage == 1 and event == 'c_return' and target is os.stat:", - " restore_managed_symlink()", - " return", - " if managed_swapped:", - " return", - " if managed_swap_phase == 'before-open' and event == 'c_call' and target is os.open:", - " replace_managed_path()", - " elif managed_swap_phase == 'after-open' and event == 'c_return' and target is os.open and _frame.f_code.co_name == 'open_managed_projection':", - " replace_managed_path()", - " elif managed_swap_phase == 'after-missing-open' and event == 'c_exception' and target is os.open:", + "def is_managed_entry(value, directory_descriptor):", + " try:", + " return directory_descriptor is not None and os.fspath(value) == managed_entry_name", + " except TypeError:", + " return False", + "def managed_boundary_open(value, flags, mode=0o777, *, dir_fd=None):", + " global managed_descriptor", + " projection_entry = is_managed_entry(value, dir_fd)", + " if projection_entry and managed_swap_phase == 'before-projection-open':", " replace_managed_path()", - " elif managed_swap_phase == 'read' and event == 'c_call' and target is os.read:", + " try:", + " if dir_fd is None:", + " descriptor = managed_real_open(value, flags, mode)", + " else:", + " descriptor = managed_real_open(value, flags, mode, dir_fd=dir_fd)", + " except OSError as exc:", + " if projection_entry and managed_swap_phase == 'after-missing-projection-open' and isinstance(exc, FileNotFoundError):", + " replace_managed_path()", + " elif projection_entry and managed_swap_phase == 'after-projection-eloop' and exc.errno == errno.ELOOP:", + " replace_managed_symlink_with_regular()", + " raise", + " if projection_entry:", + " managed_descriptor = descriptor", + " if managed_swap_phase == 'after-projection-open':", + " replace_managed_path()", + " return descriptor", + "def managed_boundary_read(descriptor, length):", + " if managed_swap_phase == 'projection-read-begins' and descriptor == managed_descriptor:", " replace_managed_path()", - " elif managed_swap_phase == 'before-link' and event == 'c_call' and target is os.link:", + " return managed_real_read(descriptor, length)", + "def managed_boundary_link(source, destination, *args, **kwargs):", + " if managed_swap_phase == 'projection-publication-begins' and is_managed_entry(destination, kwargs.get('dst_dir_fd')):", " replace_managed_path()", - " elif managed_swap_phase == 'seek' and event == 'c_call' and target is os.lseek:", + " return managed_real_link(source, destination, *args, **kwargs)", + "def managed_boundary_lseek(descriptor, position, how):", + " if managed_swap_phase == 'projection-rewrite-begins' and descriptor == managed_descriptor:", " replace_managed_path()", + " return managed_real_lseek(descriptor, position, how)", + "def install_managed_boundaries():", + " if not managed_swap_phase:", + " return", + " os.link = managed_boundary_link", + " os.lseek = managed_boundary_lseek", + " os.open = managed_boundary_open", + " os.read = managed_boundary_read", + "def restore_managed_boundaries():", + " os.link = managed_real_link", + " os.lseek = managed_real_lseek", + " os.open = managed_real_open", + " os.read = managed_real_read", "source = sys.stdin.read()", "namespace = {'__name__': '__main__'}", "sys.path.insert(0, fixture_root)", - "if managed_swap_phase:", - " sys.setprofile(managed_profile)", + "install_managed_boundaries()", "try:", " exec(compile(source, '', 'exec'), namespace)", "finally:", - " sys.setprofile(None)", - " if managed_swap_phase == 'eloop-regular-stat' and managed_eloop_stage == 1:", + " restore_managed_boundaries()", + " if managed_swap_phase == 'after-projection-eloop' and managed_eloop_stage == 1:", " restore_managed_symlink()", "", ].join("\n"); @@ -307,12 +353,15 @@ export function runDeepAgentsConfigCommand( } else { if (managedTargetReadPath === managedSymlinkTarget) { createFifo(managedSymlinkTarget, managedOptions.mode); - } else if (!managedOptions.danglingSymlink && managedSwap?.phase !== "after-missing-open") { + } else if ( + !managedOptions.danglingSymlink && + managedSwap?.phase !== "after-missing-projection-open" + ) { initializeConfig(managedInitialPath, initialConfig, managedOptions.mode); if (initialConfig !== undefined) fs.chmodSync(managedInitialPath, managedOptions.mode ?? 0o600); } - if (managedSwap?.phase === "after-missing-open") { + if (managedSwap?.phase === "after-missing-projection-open") { fs.mkdirSync(path.dirname(configPath), { recursive: true }); } if (managedSymlink) { From fa29f6c080d41e5d740724a6348aba12701c6173 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Wed, 2 Sep 2026 01:33:59 -0700 Subject: [PATCH 22/49] test(mcp): harden projection fixture reads Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- .../mcp-bridge-adapter-deepagents-fixture.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts index 926361b55e2..f5937ef469b 100644 --- a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts +++ b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts @@ -395,7 +395,9 @@ export function runDeepAgentsConfigCommand( .replaceAll(DEEPAGENTS_MCP_CONFIG_PATH, configPath) .replaceAll("/sandbox/.deepagents/.mcp.json", legacyConfigPath) .replaceAll("/opt/venv/bin/python3", fixturePython.executablePath); - const result = spawnSync("bash", ["-c", fixtureCommand], { + const fixtureCommandPath = path.join(tmp, "managed-command.sh"); + fs.writeFileSync(fixtureCommandPath, fixtureCommand, { mode: 0o600 }); + const result = spawnSync("bash", [fixtureCommandPath], { encoding: "utf-8", env: { ...process.env, @@ -427,10 +429,18 @@ export function runDeepAgentsConfigCommand( : null; const managedSymlinkTargetExists = fs.existsSync(managedSymlinkTarget); const readRegularFile = (target: string): string | null => { + let descriptor: number | null = null; try { - return fs.lstatSync(target).isFile() ? fs.readFileSync(target, "utf-8") : null; + if (!fs.lstatSync(target).isFile()) return null; + descriptor = fs.openSync( + target, + fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK, + ); + return fs.fstatSync(descriptor).isFile() ? fs.readFileSync(descriptor, "utf-8") : null; } catch { return null; + } finally { + if (descriptor !== null) fs.closeSync(descriptor); } }; const managedSymlinkTargetText = readRegularFile(managedSymlinkTarget); From 524d337e77842a417b96c1907da85165cc4c447f Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Wed, 2 Sep 2026 01:55:58 -0700 Subject: [PATCH 23/49] fix(mcp): remove projection parent expansion Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- docs/manage-sandboxes/manage-mcp-servers.mdx | 5 +- .../mcp-bridge-adapter-deepagents-legacy.ts | 10 +- ...idge-adapter-deepagents-projection.test.ts | 101 ++----- ...cp-bridge-adapter-deepagents-projection.ts | 286 ++++++------------ ...-bridge-adapter-deepagents-registration.ts | 10 +- .../mcp-bridge-adapter-deepagents-teardown.ts | 22 +- .../mcp-bridge-status-resolution.test.ts | 36 ++- .../mcp-bridge-adapter-deepagents-fixture.ts | 40 +-- 8 files changed, 176 insertions(+), 334 deletions(-) diff --git a/docs/manage-sandboxes/manage-mcp-servers.mdx b/docs/manage-sandboxes/manage-mcp-servers.mdx index 88b9211c356..e7a43e373f3 100644 --- a/docs/manage-sandboxes/manage-mcp-servers.mdx +++ b/docs/manage-sandboxes/manage-mcp-servers.mdx @@ -42,9 +42,8 @@ For a trusted private server, status resolves the endpoint without changing mana Before it reads `/sandbox/.deepagents/.nemoclaw-mcp.json`, Deep Agents Code v2 `mcp status` checks the path type. It does not follow a final symbolic link or read from a FIFO. If the path is a symbolic link, FIFO, or another non-regular file, the command names the path type, writes no status output, and exits with status `2`. -The diagnostic starts with `Unsafe managed Deep Agents MCP projection path:` and names a type such as `symbolic link`, `FIFO`, `non-regular file`, or `non-directory parent`. -Other Deep Agents inspection failures can also exit with status `2`. -Use the standard error diagnostic to distinguish an unsafe path from another inspection failure. +The diagnostic starts with `Unsafe managed Deep Agents MCP projection path:` and names a type such as `symbolic link`, `FIFO`, or `non-regular file`. +Exit status `2` is specific to this unsafe path-type result. Deep Agents Code v1 reads the legacy `.deepagents/.mcp.json` path and does not expose these type-specific diagnostics. diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy.ts index bcce0026c77..4c2bd48ac55 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy.ts @@ -71,16 +71,14 @@ export function buildDeepAgentsMcpRollbackRegisterCommand( "data = {}", "managed_identity = None", "managed_descriptor = None", - "managed_parent_identity = None", - "managed_parent_descriptor = None", "legacy_identity = None", "def fail_rollback(message):", - " close_managed_projection_descriptors(managed_descriptor, managed_parent_descriptor)", + " close_managed_projection_descriptor(managed_descriptor)", " print(message, file=sys.stderr)", " raise SystemExit(2)", "try:", " if is_v2:", - " data, managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity = load_managed_projection_for_update(config_path)", + " data, managed_identity, managed_descriptor = load_managed_projection_for_update(config_path)", " elif os.path.lexists(config_path):", " data, legacy_identity = read_legacy_config(config_path)", "except (OSError, UnicodeDecodeError, ValueError) as exc:", @@ -110,8 +108,8 @@ export function buildDeepAgentsMcpRollbackRegisterCommand( " if payload['server'] in servers and current != payload['expected']:", " fail_rollback(f'Refusing to overwrite user-owned legacy MCP server at {config_path}')", " servers[payload['server']] = payload['expected']", + "config_path.parent.mkdir(parents=True, exist_ok=True)", "if not is_v2:", - " config_path.parent.mkdir(parents=True, exist_ok=True)", " tmp_fd, tmp_name = tempfile.mkstemp(prefix='.nemoclaw-mcp.', dir=config_path.parent)", " try:", " os.fchmod(tmp_fd, 0o600)", @@ -133,7 +131,7 @@ export function buildDeepAgentsMcpRollbackRegisterCommand( " pass", "else:", " try:", - " write_managed_projection(config_path, data, managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity)", + " write_managed_projection(config_path, data, managed_identity, managed_descriptor)", " except (OSError, ValueError) as exc:", " fail_rollback(f'Could not publish managed MCP rollback state at {config_path}: {exc}')", "try:", diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts index 76b9435c0cf..f0a4ad5d8c5 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts @@ -24,36 +24,27 @@ const rollbackCommand = buildDeepAgentsMcpRegisterCommand(baseEntry, true, [base const removalCommand = buildDeepAgentsMcpRemoveCommand(baseEntry); describe("Deep Agents managed MCP projection safety", () => { - it( - "applies the shared server cap before normal and rollback v2 publication", - { timeout: 60_000 }, - () => { - const entries = Array.from( - { length: DEEPAGENTS_MCP_MAX_SERVERS + 1 }, - (_, index): McpBridgeEntry => ({ - ...baseEntry, - server: `server${String(index)}`, - env: [`SERVER_${String(index)}_TOKEN`], - }), - ); + it("applies the shared server cap before normal and rollback v2 publication", () => { + const entries = Array.from( + { length: DEEPAGENTS_MCP_MAX_SERVERS + 1 }, + (_, index): McpBridgeEntry => ({ + ...baseEntry, + server: `server${String(index)}`, + env: [`SERVER_${String(index)}_TOKEN`], + }), + ); - expect(() => buildDeepAgentsMcpRegisterCommand(entries[0], false, entries)).toThrow( - `at most ${String(DEEPAGENTS_MCP_MAX_SERVERS)} servers`, - ); - const rollback = runDeepAgentsConfigCommand( - buildDeepAgentsMcpRegisterCommand(entries[0], true, entries, true), - undefined, - "v2", - undefined, - 0o600, - { timeoutMs: managedCommandTimeoutMs }, - ); - expect(rollback.status).toBe(2); - expect(rollback.stderr).toContain( - `supports at most ${String(DEEPAGENTS_MCP_MAX_SERVERS)} servers`, - ); - }, - ); + expect(() => buildDeepAgentsMcpRegisterCommand(entries[0], false, entries)).toThrow( + `at most ${String(DEEPAGENTS_MCP_MAX_SERVERS)} servers`, + ); + const rollback = runDeepAgentsConfigCommand( + buildDeepAgentsMcpRegisterCommand(entries[0], true, entries, true), + ); + expect(rollback.status).toBe(2); + expect(rollback.stderr).toContain( + `supports at most ${String(DEEPAGENTS_MCP_MAX_SERVERS)} servers`, + ); + }); it.each([ { @@ -300,13 +291,7 @@ describe("Deep Agents managed MCP projection safety", () => { }, ])( "classifies a $diagnostic that appears after a missing-path open (#10754)", - ({ - appearedType, - diagnostic, - expectedConfigIsFifo, - expectedConfigIsSymlink, - expectedTargetText, - }) => { + ({ appearedType, diagnostic }) => { const result = runDeepAgentsConfigCommand( buildDeepAgentsMcpStatusCommand(baseEntry), emptyProjection, @@ -321,48 +306,10 @@ describe("Deep Agents managed MCP projection safety", () => { expect(result.stderr).toContain( `Unsafe managed Deep Agents MCP projection path: ${diagnostic}`, ); - expect(result.configIsFifo).toBe(expectedConfigIsFifo); - expect(result.configIsSymlink).toBe(expectedConfigIsSymlink); - expect(result.managedSymlinkTargetText).toBe(expectedTargetText); - }, - ); - - it("rejects a symlinked projection parent without reading its target (#10754)", () => { - const status = runDeepAgentsConfigCommand( - buildDeepAgentsMcpStatusCommand(baseEntry), - undefined, - "v2", - undefined, - 0o600, - { parentSymlink: true, targetReadProbe: true, timeoutMs: managedCommandTimeoutMs }, - ); - expect(status.status).toBe(2); - expect(status.stdout.trim()).toBe(""); - expect(status.stderr).toContain( - "Unsafe managed Deep Agents MCP projection path: symbolic link", - ); - expect(status.managedParentIsSymlink).toBe(true); - expect(status.managedTargetReadAccessed).toBe(false); - }); - - it.each([ - ["registration", registrationCommand], - ["forced removal", buildDeepAgentsMcpRemoveCommand(baseEntry, true)], - ])( - "rejects a symlinked projection parent during %s without mutating its target (#10754)", - (_operation, command) => { - const initialText = `${JSON.stringify(emptyProjection, null, 2)}\n`; - const mutation = runDeepAgentsConfigCommand( - command, - emptyProjection, - "v2", - undefined, - 0o600, - { parentSymlink: true }, + expect(appearedType === "symlink" ? result.configIsSymlink : result.configIsFifo).toBe(true); + expect(result.managedSymlinkTargetText).toBe( + appearedType === "symlink" ? `${JSON.stringify(emptyProjection, null, 2)}\n` : null, ); - expect(mutation.status).toBe(2); - expect(mutation.managedParentIsSymlink).toBe(true); - expect(mutation.managedParentTargetText).toBe(initialText); }, ); diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts index c21db5ed120..d484ec70a7c 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts @@ -28,76 +28,11 @@ export const DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS = [ " if not stat.S_ISREG(mode):", " return 'non-regular file'", " return None", - "def managed_parent_path_kind(mode):", - " if stat.S_ISLNK(mode):", - " return 'symbolic link'", - " if not stat.S_ISDIR(mode):", - " return 'non-directory parent'", - " return None", "def managed_fingerprint(metadata):", " return (metadata.st_dev, metadata.st_ino, metadata.st_size, metadata.st_mtime_ns, metadata.st_ctime_ns, metadata.st_mode, metadata.st_nlink, metadata.st_uid)", - "def managed_parent_fingerprint(metadata):", - " return (metadata.st_dev, metadata.st_ino, metadata.st_mode, metadata.st_uid)", - "def raise_managed_parent_error(parent_descriptor, component, exc, unsafe_path_prefix=None):", - " unsafe_path_kind = None", - " try:", - " unsafe_path_kind = managed_parent_path_kind(os.stat(component, dir_fd=parent_descriptor, follow_symlinks=False).st_mode)", - " except OSError:", - " pass", - " if unsafe_path_kind and unsafe_path_prefix:", - " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}') from exc", - " if unsafe_path_kind:", - " raise ValueError(f'managed MCP projection parent is a {unsafe_path_kind}') from exc", - " raise exc", - "def open_managed_parent(path, create=False, unsafe_path_prefix=None):", - " if not path.is_absolute() or not path.name or path.name in ('.', '..'):", - " raise ValueError('managed MCP projection path must be absolute')", - " components = path.parts[1:-1]", - " descriptor = os.open('/', os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC)", - " created = False", - " try:", - " for index, component in enumerate(components):", - " flags = os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC | os.O_NOFOLLOW", - " try:", - " next_descriptor = os.open(component, flags, dir_fd=descriptor)", - " except FileNotFoundError:", - " if not create or index != len(components) - 1:", - " raise", - " try:", - " os.mkdir(component, 0o700, dir_fd=descriptor)", - " created = True", - " except FileExistsError:", - " pass", - " try:", - " next_descriptor = os.open(component, flags, dir_fd=descriptor)", - " except OSError as exc:", - " raise_managed_parent_error(descriptor, component, exc, unsafe_path_prefix)", - " except OSError as exc:", - " raise_managed_parent_error(descriptor, component, exc, unsafe_path_prefix)", - " os.close(descriptor)", - " descriptor = next_descriptor", - " metadata = os.fstat(descriptor)", - " if not stat.S_ISDIR(metadata.st_mode):", - " raise ValueError('managed MCP projection parent is not a directory')", - " return descriptor, managed_parent_fingerprint(metadata), created", - " except Exception:", - " os.close(descriptor)", - " raise", - "def assert_managed_parent_stable(path, identity, unsafe_path_prefix=None):", + "def assert_managed_source_stable(path, identity, unsafe_path_prefix=None):", " try:", - " descriptor, current, _ = open_managed_parent(path, unsafe_path_prefix=unsafe_path_prefix)", - " except FileNotFoundError:", - " current = None", - " descriptor = None", - " try:", - " if current != identity:", - " raise ValueError('managed MCP projection parent changed during operation')", - " finally:", - " if descriptor is not None:", - " os.close(descriptor)", - "def assert_managed_source_stable(entry_name, parent_descriptor, identity, unsafe_path_prefix=None):", - " try:", - " current_metadata = os.stat(entry_name, dir_fd=parent_descriptor, follow_symlinks=False)", + " current_metadata = os.stat(path, follow_symlinks=False)", " except FileNotFoundError:", " current_metadata = None", " current = managed_fingerprint(current_metadata) if current_metadata is not None else None", @@ -110,9 +45,9 @@ export const DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS = [ " return", " if current != identity:", " raise ValueError('managed MCP projection changed before mutation')", - "def validate_managed_descriptor_path(entry_name, parent_descriptor, descriptor, unsafe_path_prefix=None):", + "def validate_managed_descriptor_path(path, descriptor, unsafe_path_prefix=None):", " opened = os.fstat(descriptor)", - " linked = os.stat(entry_name, dir_fd=parent_descriptor, follow_symlinks=False)", + " linked = os.stat(path, follow_symlinks=False)", " if unsafe_path_prefix:", " unsafe_path_kind = managed_projection_path_kind(linked.st_mode)", " if unsafe_path_kind:", @@ -122,90 +57,78 @@ export const DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS = [ " raise ValueError('managed MCP projection has unsafe ownership, mode, type, links, or path identity')", " return managed_fingerprint(opened)", "def open_managed_projection(path, writable=False, unsafe_path_prefix=None):", - " try:", - " parent_descriptor, parent_identity, _ = open_managed_parent(path, unsafe_path_prefix=unsafe_path_prefix)", - " except FileNotFoundError:", - " return b'', None, None, None, None", - " entry_name = path.name", " access = os.O_RDWR if writable else os.O_RDONLY", " flags = access | os.O_CLOEXEC | os.O_NONBLOCK | os.O_NOFOLLOW", " try:", - " try:", - " descriptor = os.open(entry_name, flags, dir_fd=parent_descriptor)", - " except FileNotFoundError:", - " assert_managed_source_stable(entry_name, parent_descriptor, None, unsafe_path_prefix)", - " assert_managed_parent_stable(path, parent_identity, unsafe_path_prefix)", - " return b'', None, None, parent_descriptor, parent_identity", - " except OSError as exc:", - " unsafe_path_kind = None", - " if unsafe_path_prefix:", - " if exc.errno == errno.ELOOP:", - " unsafe_path_kind = 'symbolic link'", - " else:", - " try:", - " unsafe_path_kind = managed_projection_path_kind(os.stat(entry_name, dir_fd=parent_descriptor, follow_symlinks=False).st_mode)", - " except OSError:", - " pass", + " descriptor = os.open(path, flags)", + " except FileNotFoundError:", + " assert_managed_source_stable(path, None, unsafe_path_prefix)", + " return b'', None, None", + " except OSError as exc:", + " unsafe_path_kind = None", + " if unsafe_path_prefix:", + " if exc.errno == errno.ELOOP:", + " unsafe_path_kind = 'symbolic link'", + " else:", + " try:", + " unsafe_path_kind = managed_projection_path_kind(os.stat(path, follow_symlinks=False).st_mode)", + " except OSError:", + " pass", + " if unsafe_path_kind:", + " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}') from exc", + " raise", + " try:", + " before = os.fstat(descriptor)", + " if unsafe_path_prefix:", + " unsafe_path_kind = managed_projection_path_kind(before.st_mode)", " if unsafe_path_kind:", - " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}') from exc", - " raise", - " try:", - " before = os.fstat(descriptor)", - " if unsafe_path_prefix:", - " unsafe_path_kind = managed_projection_path_kind(before.st_mode)", - " if unsafe_path_kind:", - " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}')", - " validate_managed_descriptor_path(entry_name, parent_descriptor, descriptor, unsafe_path_prefix)", - " if before.st_size < 0 or before.st_size > MANAGED_MCP_MAX_BYTES:", - " raise ValueError('managed MCP projection has invalid size')", - " chunks = []", - " remaining = before.st_size", - " while remaining:", - " chunk = os.read(descriptor, remaining)", - " if not chunk:", - " break", - " chunks.append(chunk)", - " remaining -= len(chunk)", - " after = os.fstat(descriptor)", - " linked_after = os.stat(entry_name, dir_fd=parent_descriptor, follow_symlinks=False)", - " if unsafe_path_prefix:", - " unsafe_path_kind = managed_projection_path_kind(linked_after.st_mode)", - " if unsafe_path_kind:", - " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}')", - " stable = (managed_fingerprint(before) == managed_fingerprint(after) and managed_fingerprint(after) == managed_fingerprint(linked_after))", - " if remaining or not stable:", - " raise ValueError('managed MCP projection changed while reading')", - " assert_managed_parent_stable(path, parent_identity, unsafe_path_prefix)", - " return b''.join(chunks), managed_fingerprint(after), descriptor, parent_descriptor, parent_identity", - " except Exception:", - " os.close(descriptor)", - " raise", + " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}')", + " validate_managed_descriptor_path(path, descriptor, unsafe_path_prefix)", + " if before.st_size < 0 or before.st_size > MANAGED_MCP_MAX_BYTES:", + " raise ValueError('managed MCP projection has invalid size')", + " chunks = []", + " remaining = before.st_size", + " while remaining:", + " chunk = os.read(descriptor, remaining)", + " if not chunk:", + " break", + " chunks.append(chunk)", + " remaining -= len(chunk)", + " after = os.fstat(descriptor)", + " linked_after = os.stat(path, follow_symlinks=False)", + " if unsafe_path_prefix:", + " unsafe_path_kind = managed_projection_path_kind(linked_after.st_mode)", + " if unsafe_path_kind:", + " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}')", + " stable = (managed_fingerprint(before) == managed_fingerprint(after) and managed_fingerprint(after) == managed_fingerprint(linked_after))", + " if remaining or not stable:", + " raise ValueError('managed MCP projection changed while reading')", + " return b''.join(chunks), managed_fingerprint(after), descriptor", " except Exception:", - " os.close(parent_descriptor)", + " os.close(descriptor)", " raise", "def decode_managed_projection(raw):", " return strict_json_loads(raw.decode('utf-8')) if raw else {}", - "def close_managed_projection_descriptors(descriptor, parent_descriptor):", - " for current in (descriptor, parent_descriptor):", - " if current is None:", - " continue", - " try:", - " os.close(current)", - " except OSError:", - " pass", + "def close_managed_projection_descriptor(descriptor):", + " if descriptor is None:", + " return", + " try:", + " os.close(descriptor)", + " except OSError:", + " pass", "def load_managed_projection_for_update(path):", - " raw, identity, descriptor, parent_descriptor, parent_identity = open_managed_projection(path, True)", + " raw, identity, descriptor = open_managed_projection(path, True)", " try:", - " return decode_managed_projection(raw), identity, descriptor, parent_descriptor, parent_identity", + " return decode_managed_projection(raw), identity, descriptor", " except Exception:", - " close_managed_projection_descriptors(descriptor, parent_descriptor)", + " close_managed_projection_descriptor(descriptor)", " raise", "def read_managed_projection(path, unsafe_path_prefix=None):", - " raw, identity, descriptor, parent_descriptor, _ = open_managed_projection(path, unsafe_path_prefix=unsafe_path_prefix)", + " raw, identity, descriptor = open_managed_projection(path, unsafe_path_prefix=unsafe_path_prefix)", " try:", " return decode_managed_projection(raw), identity", " finally:", - " close_managed_projection_descriptors(descriptor, parent_descriptor)", + " close_managed_projection_descriptor(descriptor)", ]; export const DEEPAGENTS_MANAGED_PROJECTION_MUTATION_HELPERS = [ @@ -214,11 +137,10 @@ export const DEEPAGENTS_MANAGED_PROJECTION_MUTATION_HELPERS = [ " if not payload or len(payload) > MANAGED_MCP_MAX_BYTES:", " raise ValueError('managed MCP projection has invalid rendered size')", " return payload", - "def rewrite_managed_projection(path, value, identity, descriptor, parent_descriptor, parent_identity):", - " if identity is None or descriptor is None or parent_descriptor is None or parent_identity is None:", + "def rewrite_managed_projection(path, value, identity, descriptor):", + " if identity is None or descriptor is None:", " raise ValueError('managed MCP projection descriptor is unavailable')", - " assert_managed_parent_stable(path, parent_identity)", - " assert_managed_source_stable(path.name, parent_descriptor, identity)", + " assert_managed_source_stable(path, identity)", " payload = managed_projection_bytes(value)", " os.lseek(descriptor, 0, os.SEEK_SET)", " os.ftruncate(descriptor, 0)", @@ -233,62 +155,40 @@ export const DEEPAGENTS_MANAGED_PROJECTION_MUTATION_HELPERS = [ " persisted = os.read(descriptor, len(payload) + 1)", " if persisted != payload or os.fstat(descriptor).st_size != len(payload):", " raise ValueError('managed MCP projection verification failed')", - " validate_managed_descriptor_path(path.name, parent_descriptor, descriptor)", - " assert_managed_parent_stable(path, parent_identity)", - "def create_managed_projection_temp(parent_descriptor):", - " flags = os.O_RDWR | os.O_CREAT | os.O_EXCL | os.O_CLOEXEC | os.O_NOFOLLOW", - " for _ in range(16):", - " name = '.nemoclaw-mcp.' + os.urandom(16).hex()", + " validate_managed_descriptor_path(path, descriptor)", + "def publish_absent_managed_projection(path, value):", + " assert_managed_source_stable(path, None)", + " payload = managed_projection_bytes(value)", + " tmp_fd, tmp_name = tempfile.mkstemp(prefix='.nemoclaw-mcp.', dir=path.parent)", + " try:", + " os.fchmod(tmp_fd, 0o600)", + " with os.fdopen(tmp_fd, 'wb') as tmp_file:", + " tmp_file.write(payload)", + " tmp_file.flush()", + " os.fsync(tmp_file.fileno())", " try:", - " return os.open(name, flags, 0o600, dir_fd=parent_descriptor), name", - " except FileExistsError:", + " os.link(tmp_name, path, follow_symlinks=False)", + " except FileExistsError as exc:", + " raise ValueError('managed MCP projection appeared during publication') from exc", + " os.unlink(tmp_name)", + " finally:", + " try:", + " os.unlink(tmp_name)", + " except FileNotFoundError:", " pass", - " raise FileExistsError('could not allocate managed MCP projection temporary file')", - "def publish_absent_managed_projection(path, value, parent_descriptor, parent_identity):", - " owns_parent_descriptor = parent_descriptor is None", - " if parent_descriptor is None:", - " parent_descriptor, parent_identity, created = open_managed_parent(path, True)", - " if not created:", - " os.close(parent_descriptor)", - " raise ValueError('managed MCP projection parent appeared during publication')", - " try:", - " if not owns_parent_descriptor:", - " assert_managed_parent_stable(path, parent_identity)", - " assert_managed_source_stable(path.name, parent_descriptor, None)", - " payload = managed_projection_bytes(value)", - " tmp_fd, tmp_name = create_managed_projection_temp(parent_descriptor)", + " persisted, _ = read_managed_projection(path)", + " if persisted != value:", + " raise ValueError('managed MCP projection verification failed')", + "def write_managed_projection(path, value, identity, descriptor):", + " if identity is None:", + " if descriptor is not None:", + " raise ValueError('unexpected managed MCP projection descriptor')", + " publish_absent_managed_projection(path, value)", + " else:", " try:", - " with os.fdopen(tmp_fd, 'wb') as tmp_file:", - " tmp_file.write(payload)", - " tmp_file.flush()", - " os.fsync(tmp_file.fileno())", - " try:", - " os.link(tmp_name, path.name, src_dir_fd=parent_descriptor, dst_dir_fd=parent_descriptor, follow_symlinks=False)", - " except FileExistsError as exc:", - " raise ValueError('managed MCP projection appeared during publication') from exc", - " os.unlink(tmp_name, dir_fd=parent_descriptor)", + " rewrite_managed_projection(path, value, identity, descriptor)", " finally:", - " try:", - " os.unlink(tmp_name, dir_fd=parent_descriptor)", - " except FileNotFoundError:", - " pass", - " assert_managed_parent_stable(path, parent_identity)", - " persisted, _ = read_managed_projection(path)", - " if persisted != value:", - " raise ValueError('managed MCP projection verification failed')", - " finally:", - " if owns_parent_descriptor:", - " os.close(parent_descriptor)", - "def write_managed_projection(path, value, identity, descriptor, parent_descriptor, parent_identity):", - " try:", - " if identity is None:", - " if descriptor is not None:", - " raise ValueError('unexpected managed MCP projection descriptor')", - " publish_absent_managed_projection(path, value, parent_descriptor, parent_identity)", - " else:", - " rewrite_managed_projection(path, value, identity, descriptor, parent_descriptor, parent_identity)", - " finally:", - " close_managed_projection_descriptors(descriptor, parent_descriptor)", + " close_managed_projection_descriptor(descriptor)", ]; export const DEEPAGENTS_MANAGED_PROJECTION_HELPERS = [ diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-registration.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-registration.ts index 5657a097489..0b9365de1b0 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-registration.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-registration.ts @@ -52,20 +52,19 @@ export function buildDeepAgentsMcpRegisterCommand( }; return [ "/opt/venv/bin/python3 -I - <<'PY'", - "import json, os, pathlib, stat, sys", + "import json, os, pathlib, stat, sys, tempfile", `payload = json.loads(${pythonJsonLiteral(payload)})`, `config_path = pathlib.Path(${JSON.stringify(DEEPAGENTS_MCP_CONFIG_PATH)})`, ...DEEPAGENTS_STRICT_JSON_HELPERS, ...DEEPAGENTS_MANAGED_PROJECTION_HELPERS, ...MANAGED_HTTP_SERVER_MATCH_HELPERS, "source_descriptor = None", - "source_parent_descriptor = None", "def fail_registration(message):", - " close_managed_projection_descriptors(source_descriptor, source_parent_descriptor)", + " close_managed_projection_descriptor(source_descriptor)", " print(message, file=sys.stderr)", " raise SystemExit(2)", "try:", - " data, source_identity, source_descriptor, source_parent_descriptor, source_parent_identity = load_managed_projection_for_update(config_path)", + " data, source_identity, source_descriptor = load_managed_projection_for_update(config_path)", "except (OSError, UnicodeDecodeError, ValueError) as exc:", ` fail_registration(f'Invalid ${DEEPAGENTS_MCP_CONFIG_PATH}: {exc}')`, "if not isinstance(data, dict):", @@ -96,8 +95,9 @@ export function buildDeepAgentsMcpRegisterCommand( " else:", " next_servers[name] = expected", "data = {'mcpServers': next_servers}", + "config_path.parent.mkdir(parents=True, exist_ok=True)", "try:", - " write_managed_projection(config_path, data, source_identity, source_descriptor, source_parent_descriptor, source_parent_identity)", + " write_managed_projection(config_path, data, source_identity, source_descriptor)", "except (OSError, ValueError) as exc:", ` fail_registration(f'Could not publish ${DEEPAGENTS_MCP_CONFIG_PATH}: {exc}')`, "PY", diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-teardown.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-teardown.ts index 94135102f84..2df3550c3b1 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-teardown.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-teardown.ts @@ -61,20 +61,18 @@ export function buildDeepAgentsMcpRemoveCommand( "config_path = managed_path if is_v2 else legacy_path", "managed_identity = None", "managed_descriptor = None", - "managed_parent_identity = None", - "managed_parent_descriptor = None", "legacy_identity = None", "def finish(outcome):", - " close_managed_projection_descriptors(managed_descriptor, managed_parent_descriptor)", + " close_managed_projection_descriptor(managed_descriptor)", " print('NEMOCLAW_DEEPAGENTS_MCP_REMOVAL=' + outcome)", " raise SystemExit(0)", "def fail_teardown(message):", - " close_managed_projection_descriptors(managed_descriptor, managed_parent_descriptor)", + " close_managed_projection_descriptor(managed_descriptor)", " print(message, file=sys.stderr)", " raise SystemExit(2)", - "def repair_v2_projection(identity, descriptor, parent_descriptor, parent_identity):", + "def repair_v2_projection(identity, descriptor):", " try:", - " write_managed_projection(config_path, {'mcpServers': {}}, identity, descriptor, parent_descriptor, parent_identity)", + " write_managed_projection(config_path, {'mcpServers': {}}, identity, descriptor)", " except (OSError, ValueError) as exc:", " fail_teardown(f'Refusing unsafe managed MCP v2 repair at {config_path}: {exc}')", "def write_legacy_data(value):", @@ -99,7 +97,7 @@ export function buildDeepAgentsMcpRemoveCommand( " pass", "if is_v2:", " try:", - " raw, managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity = open_managed_projection(config_path, True)", + " raw, managed_identity, managed_descriptor = open_managed_projection(config_path, True)", " except (OSError, ValueError) as exc:", " fail_teardown(f'Invalid managed MCP v2 projection at {config_path}: {exc}')", " if managed_descriptor is None:", @@ -108,7 +106,7 @@ export function buildDeepAgentsMcpRemoveCommand( " data = decode_managed_projection(raw)", " except (UnicodeDecodeError, ValueError) as exc:", " if payload['force']:", - " repair_v2_projection(managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity)", + " repair_v2_projection(managed_identity, managed_descriptor)", " finish('removed')", " fail_teardown(f'Invalid managed MCP v2 projection at {config_path}: {exc}')", "else:", @@ -120,7 +118,7 @@ export function buildDeepAgentsMcpRemoveCommand( " finish('unowned')", "if not isinstance(data, dict):", " if is_v2 and payload['force']:", - " repair_v2_projection(managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity)", + " repair_v2_projection(managed_identity, managed_descriptor)", " finish('removed')", " if is_v2:", " fail_teardown(f'Invalid managed MCP v2 projection at {config_path}: expected object')", @@ -130,7 +128,7 @@ export function buildDeepAgentsMcpRemoveCommand( " if not is_v2 and 'mcpServers' not in data:", " finish('absent')", " if is_v2 and payload['force']:", - " repair_v2_projection(managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity)", + " repair_v2_projection(managed_identity, managed_descriptor)", " finish('removed')", " if is_v2:", " fail_teardown(f'Invalid managed MCP v2 server map at {config_path}')", @@ -140,7 +138,7 @@ export function buildDeepAgentsMcpRemoveCommand( "if is_v2:", " if data and set(data) != {'mcpServers'}:", " if payload['force']:", - " repair_v2_projection(managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity)", + " repair_v2_projection(managed_identity, managed_descriptor)", " finish('removed')", " fail_teardown(f'Invalid managed MCP v2 projection at {config_path}: only mcpServers is allowed')", " if present and not payload['force'] and not managed_http_server_matches(current, payload['expected'], True):", @@ -160,7 +158,7 @@ export function buildDeepAgentsMcpRemoveCommand( "if data:", " try:", " if is_v2:", - " write_managed_projection(config_path, data, managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity)", + " write_managed_projection(config_path, data, managed_identity, managed_descriptor)", " persisted = read_managed_projection(config_path)[0]", " else:", " write_legacy_data(data)", diff --git a/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts b/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts index e4b521a09fa..3582140ab2c 100644 --- a/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts @@ -7,6 +7,12 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { + baseEntry, + runDeepAgentsConfigCommand, +} from "../../../../test/helpers/mcp-bridge-adapter-deepagents-fixture"; + +import { buildDeepAgentsMcpStatusCommand } from "./mcp-bridge-adapter-status"; const sourceRequireHook = path.resolve("test/helpers/onboard-script-mocks.cjs"); const sourceNodeOptions = [process.env.NODE_OPTIONS, `--require=${sourceRequireHook}`] @@ -199,13 +205,31 @@ ${body} describe("MCP status wire-level credential-resolution probe", { timeout: 15_000 }, () => { it( - "preserves fixed unsafe projection diagnostics across credential observations (#10754)", - { timeout: 30_000 }, + "returns a type-specific failure for unsafe Deep Agents projection races (#10754)", + { timeout: 120_000 }, () => { - const unsafeDetails = [ - "Unsafe managed Deep Agents MCP projection path: symbolic link", - "Unsafe managed Deep Agents MCP projection path: FIFO", + const statusCommand = buildDeepAgentsMcpStatusCommand(baseEntry); + const projection = { mcpServers: {} }; + const unsafeResults = [ + ...(["symlink", "fifo"] as const).map((appearedType) => + runDeepAgentsConfigCommand(statusCommand, projection, "v2", undefined, 0o600, { + swapAfterMissingManagedOpen: appearedType, + timeoutMs: 30_000, + }), + ), + runDeepAgentsConfigCommand(statusCommand, projection, "v2", undefined, 0o600, { + swapAfterManagedEloop: true, + symlink: true, + timeoutMs: 30_000, + }), ]; + unsafeResults.forEach((result) => { + expect(result.status).toBe(2); + expect(result.stdout.trim()).toBe(""); + expect(result.stderr).toContain("Unsafe managed Deep Agents MCP projection path:"); + expect(result.configIsSymlink || result.configIsFifo).toBe(true); + }); + const unsafeDetails = unsafeResults.map((result) => result.stderr.trim()); const home = createTempHome("nemoclaw-mcp-status-projection-"); const { stdout } = runHarness( home, @@ -257,7 +281,7 @@ describe("MCP status wire-level credential-resolution probe", { timeout: 15_000 stderr: string; }>; - expect(outcomes).toHaveLength(6); + expect(outcomes).toHaveLength(9); outcomes.forEach((outcome) => { expect(["unavailable", "absent", "canonical"]).toContain(outcome.observation); expect(outcome.exitCode).toBe(2); diff --git a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts index 7fcdaa16fa7..3b1d9aa4076 100644 --- a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts +++ b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts @@ -34,8 +34,6 @@ export interface DeepAgentsConfigCommandResult { legacyConfigExists: boolean; legacyConfig: Record | null; legacyConfigText: string | null; - managedParentIsSymlink: boolean; - managedParentTargetText: string | null; managedSymlinkTargetExists: boolean; managedSymlinkTargetText: string | null; managedTargetReadAccessed: boolean; @@ -46,7 +44,6 @@ export interface DeepAgentsManagedFixtureOptions { directory?: boolean; fifo?: boolean; mode?: number; - parentSymlink?: boolean; swapAfterManagedEloop?: boolean; swapAfterManagedOpen?: "fifo" | "symlink"; swapAfterMissingManagedOpen?: "fifo" | "symlink"; @@ -225,7 +222,8 @@ function createDeepAgentsFixturePythonExecutable( " managed_eloop_stage = 2", "def is_managed_entry(value, directory_descriptor):", " try:", - " return directory_descriptor is not None and os.fspath(value) == managed_entry_name", + " candidate = os.fspath(value)", + " return candidate == managed_path if directory_descriptor is None else candidate == managed_entry_name", " except TypeError:", " return False", "def managed_boundary_open(value, flags, mode=0o777, *, dir_fd=None):", @@ -305,11 +303,8 @@ export function runDeepAgentsConfigCommand( ): DeepAgentsConfigCommandResult { const managedSwap = resolveManagedSwap(managedOptions); const fixtureRoot = managedSwap?.kind === "socket" ? "/tmp" : os.tmpdir(); - const tmp = fs.realpathSync(fs.mkdtempSync(path.join(fixtureRoot, "nemoclaw-deepagents-mcp-"))); + const tmp = fs.mkdtempSync(path.join(fixtureRoot, "nemoclaw-deepagents-mcp-")); const configPath = path.join(tmp, ".deepagents", ".nemoclaw-mcp.json"); - const managedParentPath = path.dirname(configPath); - const managedParentTarget = path.join(tmp, "managed-parent-target"); - const managedParentTargetConfig = path.join(managedParentTarget, path.basename(configPath)); const managedSymlinkTarget = path.join(tmp, "managed-projection-target.json"); const managedTargetReadReady = path.join(tmp, "managed-target-read-ready"); const managedTargetReadAccess = path.join(tmp, "managed-target-read-accessed"); @@ -329,25 +324,15 @@ export function runDeepAgentsConfigCommand( }; const managedSymlink = managedOptions.symlink === true || managedOptions.danglingSymlink === true; const managedTargetReadPath = managedOptions.targetReadProbe - ? managedOptions.parentSymlink - ? managedParentTargetConfig - : managedSymlink - ? managedSymlinkTarget - : null + ? managedSymlink + ? managedSymlinkTarget + : null : null; if (managedOptions.targetReadProbe && managedTargetReadPath === null) { - throw new Error("targetReadProbe requires a projection or parent symlink"); + throw new Error("targetReadProbe requires a projection symlink"); } const managedInitialPath = managedSymlink ? managedSymlinkTarget : configPath; - if (managedOptions.parentSymlink) { - fs.mkdirSync(managedParentTarget, { recursive: true }); - if (managedTargetReadPath === managedParentTargetConfig) { - createFifo(managedParentTargetConfig, managedOptions.mode); - } else { - initializeConfig(managedParentTargetConfig, initialConfig, managedOptions.mode); - } - fs.symlinkSync(managedParentTarget, managedParentPath); - } else if (managedOptions.directory) { + if (managedOptions.directory) { fs.mkdirSync(configPath, { recursive: true, mode: managedOptions.mode ?? 0o700 }); } else if (managedOptions.fifo) { createFifo(configPath, managedOptions.mode); @@ -420,12 +405,6 @@ export function runDeepAgentsConfigCommand( // A missing projection has no path type to report. } const configExists = configStat !== null; - let managedParentIsSymlink = false; - try { - managedParentIsSymlink = fs.lstatSync(managedParentPath).isSymbolicLink(); - } catch { - // A missing projection parent cannot redirect the managed path. - } const legacyConfigExists = fs.existsSync(legacyConfigPath); const configIsFifo = configStat?.isFIFO() === true; const configIsSocket = configStat?.isSocket() === true; @@ -452,7 +431,6 @@ export function runDeepAgentsConfigCommand( } }; const managedSymlinkTargetText = readRegularFile(managedSymlinkTarget); - const managedParentTargetText = readRegularFile(managedParentTargetConfig); const managedTargetReadAccessed = managedTargetReadPath !== null && waitForPath(managedTargetReadAccess, 200); const legacyConfigText = legacyConfigExists ? fs.readFileSync(legacyConfigPath, "utf-8") : null; @@ -481,8 +459,6 @@ export function runDeepAgentsConfigCommand( legacyConfigExists, legacyConfig: parseConfigText(legacyConfigText), legacyConfigText, - managedParentIsSymlink, - managedParentTargetText, managedSymlinkTargetExists, managedSymlinkTargetText, managedTargetReadAccessed, From bd6bd83f461a829140c2c13e65ecf05224d5aecb Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Wed, 2 Sep 2026 02:01:05 -0700 Subject: [PATCH 24/49] fix(mcp): retain descriptor-bound projection parents Signed-off-by: Prekshi Vyas --- docs/manage-sandboxes/manage-mcp-servers.mdx | 5 +- .../mcp-bridge-adapter-deepagents-legacy.ts | 10 +- ...idge-adapter-deepagents-projection.test.ts | 101 +++++-- ...cp-bridge-adapter-deepagents-projection.ts | 286 ++++++++++++------ ...-bridge-adapter-deepagents-registration.ts | 10 +- .../mcp-bridge-adapter-deepagents-teardown.ts | 22 +- .../mcp-bridge-status-resolution.test.ts | 36 +-- .../mcp-bridge-adapter-deepagents-fixture.ts | 40 ++- 8 files changed, 334 insertions(+), 176 deletions(-) diff --git a/docs/manage-sandboxes/manage-mcp-servers.mdx b/docs/manage-sandboxes/manage-mcp-servers.mdx index e7a43e373f3..88b9211c356 100644 --- a/docs/manage-sandboxes/manage-mcp-servers.mdx +++ b/docs/manage-sandboxes/manage-mcp-servers.mdx @@ -42,8 +42,9 @@ For a trusted private server, status resolves the endpoint without changing mana Before it reads `/sandbox/.deepagents/.nemoclaw-mcp.json`, Deep Agents Code v2 `mcp status` checks the path type. It does not follow a final symbolic link or read from a FIFO. If the path is a symbolic link, FIFO, or another non-regular file, the command names the path type, writes no status output, and exits with status `2`. -The diagnostic starts with `Unsafe managed Deep Agents MCP projection path:` and names a type such as `symbolic link`, `FIFO`, or `non-regular file`. -Exit status `2` is specific to this unsafe path-type result. +The diagnostic starts with `Unsafe managed Deep Agents MCP projection path:` and names a type such as `symbolic link`, `FIFO`, `non-regular file`, or `non-directory parent`. +Other Deep Agents inspection failures can also exit with status `2`. +Use the standard error diagnostic to distinguish an unsafe path from another inspection failure. Deep Agents Code v1 reads the legacy `.deepagents/.mcp.json` path and does not expose these type-specific diagnostics. diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy.ts index 4c2bd48ac55..bcce0026c77 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy.ts @@ -71,14 +71,16 @@ export function buildDeepAgentsMcpRollbackRegisterCommand( "data = {}", "managed_identity = None", "managed_descriptor = None", + "managed_parent_identity = None", + "managed_parent_descriptor = None", "legacy_identity = None", "def fail_rollback(message):", - " close_managed_projection_descriptor(managed_descriptor)", + " close_managed_projection_descriptors(managed_descriptor, managed_parent_descriptor)", " print(message, file=sys.stderr)", " raise SystemExit(2)", "try:", " if is_v2:", - " data, managed_identity, managed_descriptor = load_managed_projection_for_update(config_path)", + " data, managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity = load_managed_projection_for_update(config_path)", " elif os.path.lexists(config_path):", " data, legacy_identity = read_legacy_config(config_path)", "except (OSError, UnicodeDecodeError, ValueError) as exc:", @@ -108,8 +110,8 @@ export function buildDeepAgentsMcpRollbackRegisterCommand( " if payload['server'] in servers and current != payload['expected']:", " fail_rollback(f'Refusing to overwrite user-owned legacy MCP server at {config_path}')", " servers[payload['server']] = payload['expected']", - "config_path.parent.mkdir(parents=True, exist_ok=True)", "if not is_v2:", + " config_path.parent.mkdir(parents=True, exist_ok=True)", " tmp_fd, tmp_name = tempfile.mkstemp(prefix='.nemoclaw-mcp.', dir=config_path.parent)", " try:", " os.fchmod(tmp_fd, 0o600)", @@ -131,7 +133,7 @@ export function buildDeepAgentsMcpRollbackRegisterCommand( " pass", "else:", " try:", - " write_managed_projection(config_path, data, managed_identity, managed_descriptor)", + " write_managed_projection(config_path, data, managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity)", " except (OSError, ValueError) as exc:", " fail_rollback(f'Could not publish managed MCP rollback state at {config_path}: {exc}')", "try:", diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts index f0a4ad5d8c5..76b9435c0cf 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts @@ -24,27 +24,36 @@ const rollbackCommand = buildDeepAgentsMcpRegisterCommand(baseEntry, true, [base const removalCommand = buildDeepAgentsMcpRemoveCommand(baseEntry); describe("Deep Agents managed MCP projection safety", () => { - it("applies the shared server cap before normal and rollback v2 publication", () => { - const entries = Array.from( - { length: DEEPAGENTS_MCP_MAX_SERVERS + 1 }, - (_, index): McpBridgeEntry => ({ - ...baseEntry, - server: `server${String(index)}`, - env: [`SERVER_${String(index)}_TOKEN`], - }), - ); + it( + "applies the shared server cap before normal and rollback v2 publication", + { timeout: 60_000 }, + () => { + const entries = Array.from( + { length: DEEPAGENTS_MCP_MAX_SERVERS + 1 }, + (_, index): McpBridgeEntry => ({ + ...baseEntry, + server: `server${String(index)}`, + env: [`SERVER_${String(index)}_TOKEN`], + }), + ); - expect(() => buildDeepAgentsMcpRegisterCommand(entries[0], false, entries)).toThrow( - `at most ${String(DEEPAGENTS_MCP_MAX_SERVERS)} servers`, - ); - const rollback = runDeepAgentsConfigCommand( - buildDeepAgentsMcpRegisterCommand(entries[0], true, entries, true), - ); - expect(rollback.status).toBe(2); - expect(rollback.stderr).toContain( - `supports at most ${String(DEEPAGENTS_MCP_MAX_SERVERS)} servers`, - ); - }); + expect(() => buildDeepAgentsMcpRegisterCommand(entries[0], false, entries)).toThrow( + `at most ${String(DEEPAGENTS_MCP_MAX_SERVERS)} servers`, + ); + const rollback = runDeepAgentsConfigCommand( + buildDeepAgentsMcpRegisterCommand(entries[0], true, entries, true), + undefined, + "v2", + undefined, + 0o600, + { timeoutMs: managedCommandTimeoutMs }, + ); + expect(rollback.status).toBe(2); + expect(rollback.stderr).toContain( + `supports at most ${String(DEEPAGENTS_MCP_MAX_SERVERS)} servers`, + ); + }, + ); it.each([ { @@ -291,7 +300,13 @@ describe("Deep Agents managed MCP projection safety", () => { }, ])( "classifies a $diagnostic that appears after a missing-path open (#10754)", - ({ appearedType, diagnostic }) => { + ({ + appearedType, + diagnostic, + expectedConfigIsFifo, + expectedConfigIsSymlink, + expectedTargetText, + }) => { const result = runDeepAgentsConfigCommand( buildDeepAgentsMcpStatusCommand(baseEntry), emptyProjection, @@ -306,10 +321,48 @@ describe("Deep Agents managed MCP projection safety", () => { expect(result.stderr).toContain( `Unsafe managed Deep Agents MCP projection path: ${diagnostic}`, ); - expect(appearedType === "symlink" ? result.configIsSymlink : result.configIsFifo).toBe(true); - expect(result.managedSymlinkTargetText).toBe( - appearedType === "symlink" ? `${JSON.stringify(emptyProjection, null, 2)}\n` : null, + expect(result.configIsFifo).toBe(expectedConfigIsFifo); + expect(result.configIsSymlink).toBe(expectedConfigIsSymlink); + expect(result.managedSymlinkTargetText).toBe(expectedTargetText); + }, + ); + + it("rejects a symlinked projection parent without reading its target (#10754)", () => { + const status = runDeepAgentsConfigCommand( + buildDeepAgentsMcpStatusCommand(baseEntry), + undefined, + "v2", + undefined, + 0o600, + { parentSymlink: true, targetReadProbe: true, timeoutMs: managedCommandTimeoutMs }, + ); + expect(status.status).toBe(2); + expect(status.stdout.trim()).toBe(""); + expect(status.stderr).toContain( + "Unsafe managed Deep Agents MCP projection path: symbolic link", + ); + expect(status.managedParentIsSymlink).toBe(true); + expect(status.managedTargetReadAccessed).toBe(false); + }); + + it.each([ + ["registration", registrationCommand], + ["forced removal", buildDeepAgentsMcpRemoveCommand(baseEntry, true)], + ])( + "rejects a symlinked projection parent during %s without mutating its target (#10754)", + (_operation, command) => { + const initialText = `${JSON.stringify(emptyProjection, null, 2)}\n`; + const mutation = runDeepAgentsConfigCommand( + command, + emptyProjection, + "v2", + undefined, + 0o600, + { parentSymlink: true }, ); + expect(mutation.status).toBe(2); + expect(mutation.managedParentIsSymlink).toBe(true); + expect(mutation.managedParentTargetText).toBe(initialText); }, ); diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts index d484ec70a7c..c21db5ed120 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts @@ -28,11 +28,76 @@ export const DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS = [ " if not stat.S_ISREG(mode):", " return 'non-regular file'", " return None", + "def managed_parent_path_kind(mode):", + " if stat.S_ISLNK(mode):", + " return 'symbolic link'", + " if not stat.S_ISDIR(mode):", + " return 'non-directory parent'", + " return None", "def managed_fingerprint(metadata):", " return (metadata.st_dev, metadata.st_ino, metadata.st_size, metadata.st_mtime_ns, metadata.st_ctime_ns, metadata.st_mode, metadata.st_nlink, metadata.st_uid)", - "def assert_managed_source_stable(path, identity, unsafe_path_prefix=None):", + "def managed_parent_fingerprint(metadata):", + " return (metadata.st_dev, metadata.st_ino, metadata.st_mode, metadata.st_uid)", + "def raise_managed_parent_error(parent_descriptor, component, exc, unsafe_path_prefix=None):", + " unsafe_path_kind = None", + " try:", + " unsafe_path_kind = managed_parent_path_kind(os.stat(component, dir_fd=parent_descriptor, follow_symlinks=False).st_mode)", + " except OSError:", + " pass", + " if unsafe_path_kind and unsafe_path_prefix:", + " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}') from exc", + " if unsafe_path_kind:", + " raise ValueError(f'managed MCP projection parent is a {unsafe_path_kind}') from exc", + " raise exc", + "def open_managed_parent(path, create=False, unsafe_path_prefix=None):", + " if not path.is_absolute() or not path.name or path.name in ('.', '..'):", + " raise ValueError('managed MCP projection path must be absolute')", + " components = path.parts[1:-1]", + " descriptor = os.open('/', os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC)", + " created = False", + " try:", + " for index, component in enumerate(components):", + " flags = os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC | os.O_NOFOLLOW", + " try:", + " next_descriptor = os.open(component, flags, dir_fd=descriptor)", + " except FileNotFoundError:", + " if not create or index != len(components) - 1:", + " raise", + " try:", + " os.mkdir(component, 0o700, dir_fd=descriptor)", + " created = True", + " except FileExistsError:", + " pass", + " try:", + " next_descriptor = os.open(component, flags, dir_fd=descriptor)", + " except OSError as exc:", + " raise_managed_parent_error(descriptor, component, exc, unsafe_path_prefix)", + " except OSError as exc:", + " raise_managed_parent_error(descriptor, component, exc, unsafe_path_prefix)", + " os.close(descriptor)", + " descriptor = next_descriptor", + " metadata = os.fstat(descriptor)", + " if not stat.S_ISDIR(metadata.st_mode):", + " raise ValueError('managed MCP projection parent is not a directory')", + " return descriptor, managed_parent_fingerprint(metadata), created", + " except Exception:", + " os.close(descriptor)", + " raise", + "def assert_managed_parent_stable(path, identity, unsafe_path_prefix=None):", " try:", - " current_metadata = os.stat(path, follow_symlinks=False)", + " descriptor, current, _ = open_managed_parent(path, unsafe_path_prefix=unsafe_path_prefix)", + " except FileNotFoundError:", + " current = None", + " descriptor = None", + " try:", + " if current != identity:", + " raise ValueError('managed MCP projection parent changed during operation')", + " finally:", + " if descriptor is not None:", + " os.close(descriptor)", + "def assert_managed_source_stable(entry_name, parent_descriptor, identity, unsafe_path_prefix=None):", + " try:", + " current_metadata = os.stat(entry_name, dir_fd=parent_descriptor, follow_symlinks=False)", " except FileNotFoundError:", " current_metadata = None", " current = managed_fingerprint(current_metadata) if current_metadata is not None else None", @@ -45,9 +110,9 @@ export const DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS = [ " return", " if current != identity:", " raise ValueError('managed MCP projection changed before mutation')", - "def validate_managed_descriptor_path(path, descriptor, unsafe_path_prefix=None):", + "def validate_managed_descriptor_path(entry_name, parent_descriptor, descriptor, unsafe_path_prefix=None):", " opened = os.fstat(descriptor)", - " linked = os.stat(path, follow_symlinks=False)", + " linked = os.stat(entry_name, dir_fd=parent_descriptor, follow_symlinks=False)", " if unsafe_path_prefix:", " unsafe_path_kind = managed_projection_path_kind(linked.st_mode)", " if unsafe_path_kind:", @@ -57,78 +122,90 @@ export const DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS = [ " raise ValueError('managed MCP projection has unsafe ownership, mode, type, links, or path identity')", " return managed_fingerprint(opened)", "def open_managed_projection(path, writable=False, unsafe_path_prefix=None):", - " access = os.O_RDWR if writable else os.O_RDONLY", - " flags = access | os.O_CLOEXEC | os.O_NONBLOCK | os.O_NOFOLLOW", " try:", - " descriptor = os.open(path, flags)", + " parent_descriptor, parent_identity, _ = open_managed_parent(path, unsafe_path_prefix=unsafe_path_prefix)", " except FileNotFoundError:", - " assert_managed_source_stable(path, None, unsafe_path_prefix)", - " return b'', None, None", - " except OSError as exc:", - " unsafe_path_kind = None", - " if unsafe_path_prefix:", - " if exc.errno == errno.ELOOP:", - " unsafe_path_kind = 'symbolic link'", - " else:", - " try:", - " unsafe_path_kind = managed_projection_path_kind(os.stat(path, follow_symlinks=False).st_mode)", - " except OSError:", - " pass", - " if unsafe_path_kind:", - " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}') from exc", - " raise", + " return b'', None, None, None, None", + " entry_name = path.name", + " access = os.O_RDWR if writable else os.O_RDONLY", + " flags = access | os.O_CLOEXEC | os.O_NONBLOCK | os.O_NOFOLLOW", " try:", - " before = os.fstat(descriptor)", - " if unsafe_path_prefix:", - " unsafe_path_kind = managed_projection_path_kind(before.st_mode)", - " if unsafe_path_kind:", - " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}')", - " validate_managed_descriptor_path(path, descriptor, unsafe_path_prefix)", - " if before.st_size < 0 or before.st_size > MANAGED_MCP_MAX_BYTES:", - " raise ValueError('managed MCP projection has invalid size')", - " chunks = []", - " remaining = before.st_size", - " while remaining:", - " chunk = os.read(descriptor, remaining)", - " if not chunk:", - " break", - " chunks.append(chunk)", - " remaining -= len(chunk)", - " after = os.fstat(descriptor)", - " linked_after = os.stat(path, follow_symlinks=False)", - " if unsafe_path_prefix:", - " unsafe_path_kind = managed_projection_path_kind(linked_after.st_mode)", + " try:", + " descriptor = os.open(entry_name, flags, dir_fd=parent_descriptor)", + " except FileNotFoundError:", + " assert_managed_source_stable(entry_name, parent_descriptor, None, unsafe_path_prefix)", + " assert_managed_parent_stable(path, parent_identity, unsafe_path_prefix)", + " return b'', None, None, parent_descriptor, parent_identity", + " except OSError as exc:", + " unsafe_path_kind = None", + " if unsafe_path_prefix:", + " if exc.errno == errno.ELOOP:", + " unsafe_path_kind = 'symbolic link'", + " else:", + " try:", + " unsafe_path_kind = managed_projection_path_kind(os.stat(entry_name, dir_fd=parent_descriptor, follow_symlinks=False).st_mode)", + " except OSError:", + " pass", " if unsafe_path_kind:", - " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}')", - " stable = (managed_fingerprint(before) == managed_fingerprint(after) and managed_fingerprint(after) == managed_fingerprint(linked_after))", - " if remaining or not stable:", - " raise ValueError('managed MCP projection changed while reading')", - " return b''.join(chunks), managed_fingerprint(after), descriptor", + " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}') from exc", + " raise", + " try:", + " before = os.fstat(descriptor)", + " if unsafe_path_prefix:", + " unsafe_path_kind = managed_projection_path_kind(before.st_mode)", + " if unsafe_path_kind:", + " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}')", + " validate_managed_descriptor_path(entry_name, parent_descriptor, descriptor, unsafe_path_prefix)", + " if before.st_size < 0 or before.st_size > MANAGED_MCP_MAX_BYTES:", + " raise ValueError('managed MCP projection has invalid size')", + " chunks = []", + " remaining = before.st_size", + " while remaining:", + " chunk = os.read(descriptor, remaining)", + " if not chunk:", + " break", + " chunks.append(chunk)", + " remaining -= len(chunk)", + " after = os.fstat(descriptor)", + " linked_after = os.stat(entry_name, dir_fd=parent_descriptor, follow_symlinks=False)", + " if unsafe_path_prefix:", + " unsafe_path_kind = managed_projection_path_kind(linked_after.st_mode)", + " if unsafe_path_kind:", + " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}')", + " stable = (managed_fingerprint(before) == managed_fingerprint(after) and managed_fingerprint(after) == managed_fingerprint(linked_after))", + " if remaining or not stable:", + " raise ValueError('managed MCP projection changed while reading')", + " assert_managed_parent_stable(path, parent_identity, unsafe_path_prefix)", + " return b''.join(chunks), managed_fingerprint(after), descriptor, parent_descriptor, parent_identity", + " except Exception:", + " os.close(descriptor)", + " raise", " except Exception:", - " os.close(descriptor)", + " os.close(parent_descriptor)", " raise", "def decode_managed_projection(raw):", " return strict_json_loads(raw.decode('utf-8')) if raw else {}", - "def close_managed_projection_descriptor(descriptor):", - " if descriptor is None:", - " return", - " try:", - " os.close(descriptor)", - " except OSError:", - " pass", + "def close_managed_projection_descriptors(descriptor, parent_descriptor):", + " for current in (descriptor, parent_descriptor):", + " if current is None:", + " continue", + " try:", + " os.close(current)", + " except OSError:", + " pass", "def load_managed_projection_for_update(path):", - " raw, identity, descriptor = open_managed_projection(path, True)", + " raw, identity, descriptor, parent_descriptor, parent_identity = open_managed_projection(path, True)", " try:", - " return decode_managed_projection(raw), identity, descriptor", + " return decode_managed_projection(raw), identity, descriptor, parent_descriptor, parent_identity", " except Exception:", - " close_managed_projection_descriptor(descriptor)", + " close_managed_projection_descriptors(descriptor, parent_descriptor)", " raise", "def read_managed_projection(path, unsafe_path_prefix=None):", - " raw, identity, descriptor = open_managed_projection(path, unsafe_path_prefix=unsafe_path_prefix)", + " raw, identity, descriptor, parent_descriptor, _ = open_managed_projection(path, unsafe_path_prefix=unsafe_path_prefix)", " try:", " return decode_managed_projection(raw), identity", " finally:", - " close_managed_projection_descriptor(descriptor)", + " close_managed_projection_descriptors(descriptor, parent_descriptor)", ]; export const DEEPAGENTS_MANAGED_PROJECTION_MUTATION_HELPERS = [ @@ -137,10 +214,11 @@ export const DEEPAGENTS_MANAGED_PROJECTION_MUTATION_HELPERS = [ " if not payload or len(payload) > MANAGED_MCP_MAX_BYTES:", " raise ValueError('managed MCP projection has invalid rendered size')", " return payload", - "def rewrite_managed_projection(path, value, identity, descriptor):", - " if identity is None or descriptor is None:", + "def rewrite_managed_projection(path, value, identity, descriptor, parent_descriptor, parent_identity):", + " if identity is None or descriptor is None or parent_descriptor is None or parent_identity is None:", " raise ValueError('managed MCP projection descriptor is unavailable')", - " assert_managed_source_stable(path, identity)", + " assert_managed_parent_stable(path, parent_identity)", + " assert_managed_source_stable(path.name, parent_descriptor, identity)", " payload = managed_projection_bytes(value)", " os.lseek(descriptor, 0, os.SEEK_SET)", " os.ftruncate(descriptor, 0)", @@ -155,40 +233,62 @@ export const DEEPAGENTS_MANAGED_PROJECTION_MUTATION_HELPERS = [ " persisted = os.read(descriptor, len(payload) + 1)", " if persisted != payload or os.fstat(descriptor).st_size != len(payload):", " raise ValueError('managed MCP projection verification failed')", - " validate_managed_descriptor_path(path, descriptor)", - "def publish_absent_managed_projection(path, value):", - " assert_managed_source_stable(path, None)", - " payload = managed_projection_bytes(value)", - " tmp_fd, tmp_name = tempfile.mkstemp(prefix='.nemoclaw-mcp.', dir=path.parent)", - " try:", - " os.fchmod(tmp_fd, 0o600)", - " with os.fdopen(tmp_fd, 'wb') as tmp_file:", - " tmp_file.write(payload)", - " tmp_file.flush()", - " os.fsync(tmp_file.fileno())", + " validate_managed_descriptor_path(path.name, parent_descriptor, descriptor)", + " assert_managed_parent_stable(path, parent_identity)", + "def create_managed_projection_temp(parent_descriptor):", + " flags = os.O_RDWR | os.O_CREAT | os.O_EXCL | os.O_CLOEXEC | os.O_NOFOLLOW", + " for _ in range(16):", + " name = '.nemoclaw-mcp.' + os.urandom(16).hex()", " try:", - " os.link(tmp_name, path, follow_symlinks=False)", - " except FileExistsError as exc:", - " raise ValueError('managed MCP projection appeared during publication') from exc", - " os.unlink(tmp_name)", - " finally:", - " try:", - " os.unlink(tmp_name)", - " except FileNotFoundError:", + " return os.open(name, flags, 0o600, dir_fd=parent_descriptor), name", + " except FileExistsError:", " pass", - " persisted, _ = read_managed_projection(path)", - " if persisted != value:", - " raise ValueError('managed MCP projection verification failed')", - "def write_managed_projection(path, value, identity, descriptor):", - " if identity is None:", - " if descriptor is not None:", - " raise ValueError('unexpected managed MCP projection descriptor')", - " publish_absent_managed_projection(path, value)", - " else:", + " raise FileExistsError('could not allocate managed MCP projection temporary file')", + "def publish_absent_managed_projection(path, value, parent_descriptor, parent_identity):", + " owns_parent_descriptor = parent_descriptor is None", + " if parent_descriptor is None:", + " parent_descriptor, parent_identity, created = open_managed_parent(path, True)", + " if not created:", + " os.close(parent_descriptor)", + " raise ValueError('managed MCP projection parent appeared during publication')", + " try:", + " if not owns_parent_descriptor:", + " assert_managed_parent_stable(path, parent_identity)", + " assert_managed_source_stable(path.name, parent_descriptor, None)", + " payload = managed_projection_bytes(value)", + " tmp_fd, tmp_name = create_managed_projection_temp(parent_descriptor)", " try:", - " rewrite_managed_projection(path, value, identity, descriptor)", + " with os.fdopen(tmp_fd, 'wb') as tmp_file:", + " tmp_file.write(payload)", + " tmp_file.flush()", + " os.fsync(tmp_file.fileno())", + " try:", + " os.link(tmp_name, path.name, src_dir_fd=parent_descriptor, dst_dir_fd=parent_descriptor, follow_symlinks=False)", + " except FileExistsError as exc:", + " raise ValueError('managed MCP projection appeared during publication') from exc", + " os.unlink(tmp_name, dir_fd=parent_descriptor)", " finally:", - " close_managed_projection_descriptor(descriptor)", + " try:", + " os.unlink(tmp_name, dir_fd=parent_descriptor)", + " except FileNotFoundError:", + " pass", + " assert_managed_parent_stable(path, parent_identity)", + " persisted, _ = read_managed_projection(path)", + " if persisted != value:", + " raise ValueError('managed MCP projection verification failed')", + " finally:", + " if owns_parent_descriptor:", + " os.close(parent_descriptor)", + "def write_managed_projection(path, value, identity, descriptor, parent_descriptor, parent_identity):", + " try:", + " if identity is None:", + " if descriptor is not None:", + " raise ValueError('unexpected managed MCP projection descriptor')", + " publish_absent_managed_projection(path, value, parent_descriptor, parent_identity)", + " else:", + " rewrite_managed_projection(path, value, identity, descriptor, parent_descriptor, parent_identity)", + " finally:", + " close_managed_projection_descriptors(descriptor, parent_descriptor)", ]; export const DEEPAGENTS_MANAGED_PROJECTION_HELPERS = [ diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-registration.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-registration.ts index 0b9365de1b0..5657a097489 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-registration.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-registration.ts @@ -52,19 +52,20 @@ export function buildDeepAgentsMcpRegisterCommand( }; return [ "/opt/venv/bin/python3 -I - <<'PY'", - "import json, os, pathlib, stat, sys, tempfile", + "import json, os, pathlib, stat, sys", `payload = json.loads(${pythonJsonLiteral(payload)})`, `config_path = pathlib.Path(${JSON.stringify(DEEPAGENTS_MCP_CONFIG_PATH)})`, ...DEEPAGENTS_STRICT_JSON_HELPERS, ...DEEPAGENTS_MANAGED_PROJECTION_HELPERS, ...MANAGED_HTTP_SERVER_MATCH_HELPERS, "source_descriptor = None", + "source_parent_descriptor = None", "def fail_registration(message):", - " close_managed_projection_descriptor(source_descriptor)", + " close_managed_projection_descriptors(source_descriptor, source_parent_descriptor)", " print(message, file=sys.stderr)", " raise SystemExit(2)", "try:", - " data, source_identity, source_descriptor = load_managed_projection_for_update(config_path)", + " data, source_identity, source_descriptor, source_parent_descriptor, source_parent_identity = load_managed_projection_for_update(config_path)", "except (OSError, UnicodeDecodeError, ValueError) as exc:", ` fail_registration(f'Invalid ${DEEPAGENTS_MCP_CONFIG_PATH}: {exc}')`, "if not isinstance(data, dict):", @@ -95,9 +96,8 @@ export function buildDeepAgentsMcpRegisterCommand( " else:", " next_servers[name] = expected", "data = {'mcpServers': next_servers}", - "config_path.parent.mkdir(parents=True, exist_ok=True)", "try:", - " write_managed_projection(config_path, data, source_identity, source_descriptor)", + " write_managed_projection(config_path, data, source_identity, source_descriptor, source_parent_descriptor, source_parent_identity)", "except (OSError, ValueError) as exc:", ` fail_registration(f'Could not publish ${DEEPAGENTS_MCP_CONFIG_PATH}: {exc}')`, "PY", diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-teardown.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-teardown.ts index 2df3550c3b1..94135102f84 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-teardown.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-teardown.ts @@ -61,18 +61,20 @@ export function buildDeepAgentsMcpRemoveCommand( "config_path = managed_path if is_v2 else legacy_path", "managed_identity = None", "managed_descriptor = None", + "managed_parent_identity = None", + "managed_parent_descriptor = None", "legacy_identity = None", "def finish(outcome):", - " close_managed_projection_descriptor(managed_descriptor)", + " close_managed_projection_descriptors(managed_descriptor, managed_parent_descriptor)", " print('NEMOCLAW_DEEPAGENTS_MCP_REMOVAL=' + outcome)", " raise SystemExit(0)", "def fail_teardown(message):", - " close_managed_projection_descriptor(managed_descriptor)", + " close_managed_projection_descriptors(managed_descriptor, managed_parent_descriptor)", " print(message, file=sys.stderr)", " raise SystemExit(2)", - "def repair_v2_projection(identity, descriptor):", + "def repair_v2_projection(identity, descriptor, parent_descriptor, parent_identity):", " try:", - " write_managed_projection(config_path, {'mcpServers': {}}, identity, descriptor)", + " write_managed_projection(config_path, {'mcpServers': {}}, identity, descriptor, parent_descriptor, parent_identity)", " except (OSError, ValueError) as exc:", " fail_teardown(f'Refusing unsafe managed MCP v2 repair at {config_path}: {exc}')", "def write_legacy_data(value):", @@ -97,7 +99,7 @@ export function buildDeepAgentsMcpRemoveCommand( " pass", "if is_v2:", " try:", - " raw, managed_identity, managed_descriptor = open_managed_projection(config_path, True)", + " raw, managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity = open_managed_projection(config_path, True)", " except (OSError, ValueError) as exc:", " fail_teardown(f'Invalid managed MCP v2 projection at {config_path}: {exc}')", " if managed_descriptor is None:", @@ -106,7 +108,7 @@ export function buildDeepAgentsMcpRemoveCommand( " data = decode_managed_projection(raw)", " except (UnicodeDecodeError, ValueError) as exc:", " if payload['force']:", - " repair_v2_projection(managed_identity, managed_descriptor)", + " repair_v2_projection(managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity)", " finish('removed')", " fail_teardown(f'Invalid managed MCP v2 projection at {config_path}: {exc}')", "else:", @@ -118,7 +120,7 @@ export function buildDeepAgentsMcpRemoveCommand( " finish('unowned')", "if not isinstance(data, dict):", " if is_v2 and payload['force']:", - " repair_v2_projection(managed_identity, managed_descriptor)", + " repair_v2_projection(managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity)", " finish('removed')", " if is_v2:", " fail_teardown(f'Invalid managed MCP v2 projection at {config_path}: expected object')", @@ -128,7 +130,7 @@ export function buildDeepAgentsMcpRemoveCommand( " if not is_v2 and 'mcpServers' not in data:", " finish('absent')", " if is_v2 and payload['force']:", - " repair_v2_projection(managed_identity, managed_descriptor)", + " repair_v2_projection(managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity)", " finish('removed')", " if is_v2:", " fail_teardown(f'Invalid managed MCP v2 server map at {config_path}')", @@ -138,7 +140,7 @@ export function buildDeepAgentsMcpRemoveCommand( "if is_v2:", " if data and set(data) != {'mcpServers'}:", " if payload['force']:", - " repair_v2_projection(managed_identity, managed_descriptor)", + " repair_v2_projection(managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity)", " finish('removed')", " fail_teardown(f'Invalid managed MCP v2 projection at {config_path}: only mcpServers is allowed')", " if present and not payload['force'] and not managed_http_server_matches(current, payload['expected'], True):", @@ -158,7 +160,7 @@ export function buildDeepAgentsMcpRemoveCommand( "if data:", " try:", " if is_v2:", - " write_managed_projection(config_path, data, managed_identity, managed_descriptor)", + " write_managed_projection(config_path, data, managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity)", " persisted = read_managed_projection(config_path)[0]", " else:", " write_legacy_data(data)", diff --git a/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts b/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts index 3582140ab2c..e4b521a09fa 100644 --- a/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts @@ -7,12 +7,6 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { - baseEntry, - runDeepAgentsConfigCommand, -} from "../../../../test/helpers/mcp-bridge-adapter-deepagents-fixture"; - -import { buildDeepAgentsMcpStatusCommand } from "./mcp-bridge-adapter-status"; const sourceRequireHook = path.resolve("test/helpers/onboard-script-mocks.cjs"); const sourceNodeOptions = [process.env.NODE_OPTIONS, `--require=${sourceRequireHook}`] @@ -205,31 +199,13 @@ ${body} describe("MCP status wire-level credential-resolution probe", { timeout: 15_000 }, () => { it( - "returns a type-specific failure for unsafe Deep Agents projection races (#10754)", - { timeout: 120_000 }, + "preserves fixed unsafe projection diagnostics across credential observations (#10754)", + { timeout: 30_000 }, () => { - const statusCommand = buildDeepAgentsMcpStatusCommand(baseEntry); - const projection = { mcpServers: {} }; - const unsafeResults = [ - ...(["symlink", "fifo"] as const).map((appearedType) => - runDeepAgentsConfigCommand(statusCommand, projection, "v2", undefined, 0o600, { - swapAfterMissingManagedOpen: appearedType, - timeoutMs: 30_000, - }), - ), - runDeepAgentsConfigCommand(statusCommand, projection, "v2", undefined, 0o600, { - swapAfterManagedEloop: true, - symlink: true, - timeoutMs: 30_000, - }), + const unsafeDetails = [ + "Unsafe managed Deep Agents MCP projection path: symbolic link", + "Unsafe managed Deep Agents MCP projection path: FIFO", ]; - unsafeResults.forEach((result) => { - expect(result.status).toBe(2); - expect(result.stdout.trim()).toBe(""); - expect(result.stderr).toContain("Unsafe managed Deep Agents MCP projection path:"); - expect(result.configIsSymlink || result.configIsFifo).toBe(true); - }); - const unsafeDetails = unsafeResults.map((result) => result.stderr.trim()); const home = createTempHome("nemoclaw-mcp-status-projection-"); const { stdout } = runHarness( home, @@ -281,7 +257,7 @@ describe("MCP status wire-level credential-resolution probe", { timeout: 15_000 stderr: string; }>; - expect(outcomes).toHaveLength(9); + expect(outcomes).toHaveLength(6); outcomes.forEach((outcome) => { expect(["unavailable", "absent", "canonical"]).toContain(outcome.observation); expect(outcome.exitCode).toBe(2); diff --git a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts index 3b1d9aa4076..7fcdaa16fa7 100644 --- a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts +++ b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts @@ -34,6 +34,8 @@ export interface DeepAgentsConfigCommandResult { legacyConfigExists: boolean; legacyConfig: Record | null; legacyConfigText: string | null; + managedParentIsSymlink: boolean; + managedParentTargetText: string | null; managedSymlinkTargetExists: boolean; managedSymlinkTargetText: string | null; managedTargetReadAccessed: boolean; @@ -44,6 +46,7 @@ export interface DeepAgentsManagedFixtureOptions { directory?: boolean; fifo?: boolean; mode?: number; + parentSymlink?: boolean; swapAfterManagedEloop?: boolean; swapAfterManagedOpen?: "fifo" | "symlink"; swapAfterMissingManagedOpen?: "fifo" | "symlink"; @@ -222,8 +225,7 @@ function createDeepAgentsFixturePythonExecutable( " managed_eloop_stage = 2", "def is_managed_entry(value, directory_descriptor):", " try:", - " candidate = os.fspath(value)", - " return candidate == managed_path if directory_descriptor is None else candidate == managed_entry_name", + " return directory_descriptor is not None and os.fspath(value) == managed_entry_name", " except TypeError:", " return False", "def managed_boundary_open(value, flags, mode=0o777, *, dir_fd=None):", @@ -303,8 +305,11 @@ export function runDeepAgentsConfigCommand( ): DeepAgentsConfigCommandResult { const managedSwap = resolveManagedSwap(managedOptions); const fixtureRoot = managedSwap?.kind === "socket" ? "/tmp" : os.tmpdir(); - const tmp = fs.mkdtempSync(path.join(fixtureRoot, "nemoclaw-deepagents-mcp-")); + const tmp = fs.realpathSync(fs.mkdtempSync(path.join(fixtureRoot, "nemoclaw-deepagents-mcp-"))); const configPath = path.join(tmp, ".deepagents", ".nemoclaw-mcp.json"); + const managedParentPath = path.dirname(configPath); + const managedParentTarget = path.join(tmp, "managed-parent-target"); + const managedParentTargetConfig = path.join(managedParentTarget, path.basename(configPath)); const managedSymlinkTarget = path.join(tmp, "managed-projection-target.json"); const managedTargetReadReady = path.join(tmp, "managed-target-read-ready"); const managedTargetReadAccess = path.join(tmp, "managed-target-read-accessed"); @@ -324,15 +329,25 @@ export function runDeepAgentsConfigCommand( }; const managedSymlink = managedOptions.symlink === true || managedOptions.danglingSymlink === true; const managedTargetReadPath = managedOptions.targetReadProbe - ? managedSymlink - ? managedSymlinkTarget - : null + ? managedOptions.parentSymlink + ? managedParentTargetConfig + : managedSymlink + ? managedSymlinkTarget + : null : null; if (managedOptions.targetReadProbe && managedTargetReadPath === null) { - throw new Error("targetReadProbe requires a projection symlink"); + throw new Error("targetReadProbe requires a projection or parent symlink"); } const managedInitialPath = managedSymlink ? managedSymlinkTarget : configPath; - if (managedOptions.directory) { + if (managedOptions.parentSymlink) { + fs.mkdirSync(managedParentTarget, { recursive: true }); + if (managedTargetReadPath === managedParentTargetConfig) { + createFifo(managedParentTargetConfig, managedOptions.mode); + } else { + initializeConfig(managedParentTargetConfig, initialConfig, managedOptions.mode); + } + fs.symlinkSync(managedParentTarget, managedParentPath); + } else if (managedOptions.directory) { fs.mkdirSync(configPath, { recursive: true, mode: managedOptions.mode ?? 0o700 }); } else if (managedOptions.fifo) { createFifo(configPath, managedOptions.mode); @@ -405,6 +420,12 @@ export function runDeepAgentsConfigCommand( // A missing projection has no path type to report. } const configExists = configStat !== null; + let managedParentIsSymlink = false; + try { + managedParentIsSymlink = fs.lstatSync(managedParentPath).isSymbolicLink(); + } catch { + // A missing projection parent cannot redirect the managed path. + } const legacyConfigExists = fs.existsSync(legacyConfigPath); const configIsFifo = configStat?.isFIFO() === true; const configIsSocket = configStat?.isSocket() === true; @@ -431,6 +452,7 @@ export function runDeepAgentsConfigCommand( } }; const managedSymlinkTargetText = readRegularFile(managedSymlinkTarget); + const managedParentTargetText = readRegularFile(managedParentTargetConfig); const managedTargetReadAccessed = managedTargetReadPath !== null && waitForPath(managedTargetReadAccess, 200); const legacyConfigText = legacyConfigExists ? fs.readFileSync(legacyConfigPath, "utf-8") : null; @@ -459,6 +481,8 @@ export function runDeepAgentsConfigCommand( legacyConfigExists, legacyConfig: parseConfigText(legacyConfigText), legacyConfigText, + managedParentIsSymlink, + managedParentTargetText, managedSymlinkTargetExists, managedSymlinkTargetText, managedTargetReadAccessed, From 068ab29e2763e048e4d2419aa23797bfb77629ac Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Wed, 2 Sep 2026 02:18:06 -0700 Subject: [PATCH 25/49] test(mcp): read fixture targets from descriptors Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- test/helpers/mcp-bridge-adapter-deepagents-fixture.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts index 7fcdaa16fa7..57dfa7a40e1 100644 --- a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts +++ b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts @@ -439,7 +439,6 @@ export function runDeepAgentsConfigCommand( const readRegularFile = (target: string): string | null => { let descriptor: number | null = null; try { - if (!fs.lstatSync(target).isFile()) return null; descriptor = fs.openSync( target, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK, @@ -451,8 +450,12 @@ export function runDeepAgentsConfigCommand( if (descriptor !== null) fs.closeSync(descriptor); } }; - const managedSymlinkTargetText = readRegularFile(managedSymlinkTarget); - const managedParentTargetText = readRegularFile(managedParentTargetConfig); + const managedSymlinkTargetText = + managedTargetReadPath === managedSymlinkTarget ? null : readRegularFile(managedSymlinkTarget); + const managedParentTargetText = + managedTargetReadPath === managedParentTargetConfig + ? null + : readRegularFile(managedParentTargetConfig); const managedTargetReadAccessed = managedTargetReadPath !== null && waitForPath(managedTargetReadAccess, 200); const legacyConfigText = legacyConfigExists ? fs.readFileSync(legacyConfigPath, "utf-8") : null; From a30831ee8812be9dd01819ed63202d49f4abffc7 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Wed, 2 Sep 2026 02:47:30 -0700 Subject: [PATCH 26/49] test(mcp): race projection from independent process Signed-off-by: Prekshi Vyas --- ...idge-adapter-deepagents-projection.test.ts | 242 +++-------- .../mcp-bridge-adapter-deepagents-fixture.ts | 388 ++++++++++-------- 2 files changed, 276 insertions(+), 354 deletions(-) diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts index 76b9435c0cf..cef8df8e8dc 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; import { baseEntry, + isSafeManagedProjectionRaceResult, runDeepAgentsConfigCommand, } from "../../../../test/helpers/mcp-bridge-adapter-deepagents-fixture"; import type { McpBridgeEntry } from "../../state/registry"; @@ -15,6 +16,7 @@ import { DEEPAGENTS_MCP_MAX_SERVERS } from "./mcp-bridge-adapter-deepagents-proj import { buildDeepAgentsMcpStatusCommand } from "./mcp-bridge-adapter-status"; const emptyProjection = { mcpServers: {} }; +const emptyProjectionText = `${JSON.stringify(emptyProjection, null, 2)}\n`; const duplicateProjection = '{"mcpServers":{},"mcpServers":{"shadow":{}}}\n'; const attackerProjection = '{"mcpServers":{"attacker":{"type":"stdio"}}}\n'; const managedCommandTimeoutMs = 30_000; @@ -145,184 +147,53 @@ describe("Deep Agents managed MCP projection safety", () => { expect(result.managedTargetReadAccessed).toBe(false); }); - it.each([ - { - diagnostic: "symbolic link", - expectedConfigIsFifo: false, - expectedConfigIsSocket: false, - expectedConfigIsSymlink: true, - expectedTargetText: `${JSON.stringify(emptyProjection, null, 2)}\n`, - replacementType: "symlink" as const, - }, - { - diagnostic: "FIFO", - expectedConfigIsFifo: true, - expectedConfigIsSocket: false, - expectedConfigIsSymlink: false, - expectedTargetText: null, - replacementType: "fifo" as const, - }, - { - diagnostic: "non-regular file", - expectedConfigIsFifo: false, - expectedConfigIsSocket: true, - expectedConfigIsSymlink: false, - expectedTargetText: null, - replacementType: "socket" as const, - }, - ])( - "rejects a $replacementType replacement before projection open (#10754)", - ({ - diagnostic, - expectedConfigIsFifo, - expectedConfigIsSocket, - expectedConfigIsSymlink, - expectedTargetText, - replacementType, - }) => { - const result = runDeepAgentsConfigCommand( - buildDeepAgentsMcpStatusCommand(baseEntry), - emptyProjection, - "v2", - undefined, - 0o600, - { swapOnManagedOpen: replacementType, timeoutMs: managedCommandTimeoutMs }, - ); - expect(result.status).toBe(2); - expect(result.stdout.trim()).toBe(""); - expect(result.stderr).toContain( - `Unsafe managed Deep Agents MCP projection path: ${diagnostic}`, - ); - expect(result.configIsFifo).toBe(expectedConfigIsFifo); - expect(result.configIsSocket).toBe(expectedConfigIsSocket); - expect(result.configIsSymlink).toBe(expectedConfigIsSymlink); - expect(result.managedSymlinkTargetText).toBe(expectedTargetText); - }, - ); - - it.each([ - { - diagnostic: "symbolic link", - expectedConfigIsFifo: false, - expectedConfigIsSymlink: true, - expectedTargetText: `${JSON.stringify(emptyProjection, null, 2)}\n`, - replacementType: "symlink" as const, - }, - { - diagnostic: "FIFO", - expectedConfigIsFifo: true, - expectedConfigIsSymlink: false, - expectedTargetText: null, - replacementType: "fifo" as const, - }, - ])( - "rejects a $replacementType replacement after projection open (#10754)", - ({ - diagnostic, - expectedConfigIsFifo, - expectedConfigIsSymlink, - expectedTargetText, - replacementType, - }) => { - const result = runDeepAgentsConfigCommand( - buildDeepAgentsMcpStatusCommand(baseEntry), - emptyProjection, - "v2", - undefined, - 0o600, - { swapAfterManagedOpen: replacementType, timeoutMs: managedCommandTimeoutMs }, - ); - expect(result.status).toBe(2); - expect(result.stdout.trim()).toBe(""); - expect(result.stderr).toContain( - `Unsafe managed Deep Agents MCP projection path: ${diagnostic}`, - ); - expect(result.configIsFifo).toBe(expectedConfigIsFifo); - expect(result.configIsSymlink).toBe(expectedConfigIsSymlink); - expect(result.managedSymlinkTargetText).toBe(expectedTargetText); - }, - ); - - it("rejects a symbolic-link replacement while reading the projection (#10754)", () => { + it("rejects an existing socket projection path (#10754)", () => { const result = runDeepAgentsConfigCommand( buildDeepAgentsMcpStatusCommand(baseEntry), - emptyProjection, - "v2", undefined, - 0o600, - { swapOnManagedRead: "symlink", timeoutMs: managedCommandTimeoutMs }, - ); - expect(result.status).toBe(2); - expect(result.stdout.trim()).toBe(""); - expect(result.stderr).toContain( - "Unsafe managed Deep Agents MCP projection path: symbolic link", - ); - expect(result.configIsSymlink).toBe(true); - expect(result.managedSymlinkTargetText).toBe(`${JSON.stringify(emptyProjection, null, 2)}\n`); - }); - - it("preserves conclusive ELOOP classification across a follow-up path swap (#10754)", () => { - const result = runDeepAgentsConfigCommand( - buildDeepAgentsMcpStatusCommand(baseEntry), - emptyProjection, "v2", undefined, 0o600, - { - swapAfterManagedEloop: true, - symlink: true, - timeoutMs: managedCommandTimeoutMs, - }, + { socket: true, timeoutMs: managedCommandTimeoutMs }, ); expect(result.status).toBe(2); expect(result.stdout.trim()).toBe(""); expect(result.stderr).toContain( - "Unsafe managed Deep Agents MCP projection path: symbolic link", + "Unsafe managed Deep Agents MCP projection path: non-regular file", ); - expect(result.configIsSymlink).toBe(true); - expect(result.managedSymlinkTargetText).toBe(`${JSON.stringify(emptyProjection, null, 2)}\n`); + expect(result.configIsSocket).toBe(true); }); it.each([ { - appearedType: "symlink" as const, - diagnostic: "symbolic link", expectedConfigIsFifo: false, expectedConfigIsSymlink: true, - expectedTargetText: `${JSON.stringify(emptyProjection, null, 2)}\n`, + expectedTargetText: emptyProjectionText, + raceProjection: "symlink" as const, }, { - appearedType: "fifo" as const, - diagnostic: "FIFO", expectedConfigIsFifo: true, expectedConfigIsSymlink: false, expectedTargetText: null, + raceProjection: "fifo" as const, }, ])( - "classifies a $diagnostic that appears after a missing-path open (#10754)", - ({ - appearedType, - diagnostic, - expectedConfigIsFifo, - expectedConfigIsSymlink, - expectedTargetText, - }) => { + "rejects or safely loses a concurrent $raceProjection projection race (#10754)", + { timeout: 60_000 }, + ({ expectedConfigIsFifo, expectedConfigIsSymlink, expectedTargetText, raceProjection }) => { const result = runDeepAgentsConfigCommand( buildDeepAgentsMcpStatusCommand(baseEntry), emptyProjection, "v2", undefined, 0o600, - { swapAfterMissingManagedOpen: appearedType, timeoutMs: managedCommandTimeoutMs }, + { raceProjection, timeoutMs: managedCommandTimeoutMs }, ); - expect(result.status).toBe(2); - expect(result.stdout.trim()).toBe(""); - expect(result.stderr).toContain( - `Unsafe managed Deep Agents MCP projection path: ${diagnostic}`, - ); - expect(result.configIsFifo).toBe(expectedConfigIsFifo); + expect(result.managedRaceIterations).toBeGreaterThan(0); + expect(isSafeManagedProjectionRaceResult(result)).toBe(true); expect(result.configIsSymlink).toBe(expectedConfigIsSymlink); + expect(result.configIsFifo).toBe(expectedConfigIsFifo); expect(result.managedSymlinkTargetText).toBe(expectedTargetText); }, ); @@ -418,50 +289,65 @@ describe("Deep Agents managed MCP projection safety", () => { expect(symlink.managedSymlinkTargetText).toBe(`${JSON.stringify(emptyProjection, null, 2)}\n`); }); - it("preserves a projection that appears during absent publication", () => { - const absentResult = runDeepAgentsConfigCommand( - registrationCommand, - undefined, - "v2", - undefined, - 0o600, - { swapBeforeManagedLink: attackerProjection, timeoutMs: managedCommandTimeoutMs }, - ); - expect(absentResult.status).toBe(2); - expect(absentResult.stderr).toContain("appeared during publication"); - expect(absentResult.configText).toBe(attackerProjection); - }); + it( + "preserves a projection that appears during absent publication (#10754)", + { timeout: 60_000 }, + () => { + const absentResult = runDeepAgentsConfigCommand( + registrationCommand, + undefined, + "v2", + undefined, + 0o600, + { raceAbsentPublication: attackerProjection, timeoutMs: managedCommandTimeoutMs }, + ); + expect(absentResult.managedRaceIterations).toBeGreaterThan(0); + expect(absentResult.status).toBe(2); + expect(absentResult.configIsSymlink).toBe(true); + expect(absentResult.managedSymlinkTargetText).toBe(attackerProjection); + }, + ); - it("preserves a replacement during a descriptor rewrite", () => { + it("preserves a replacement during a descriptor rewrite (#10754)", { timeout: 60_000 }, () => { const existingResult = runDeepAgentsConfigCommand( registrationCommand, emptyProjection, "v2", undefined, 0o600, - { swapOnManagedSeek: attackerProjection, timeoutMs: managedCommandTimeoutMs }, + { raceProjection: "symlink", timeoutMs: managedCommandTimeoutMs }, ); - expect(existingResult.status).toBe(2); - expect(existingResult.stderr).toContain("links, or path identity"); - expect(existingResult.configText).toBe(attackerProjection); - }); - - it("keeps forced removal identity-bound during a descriptor rewrite (#10754)", () => { - const forcedCommand = buildDeepAgentsMcpRemoveCommand(baseEntry, true); - const raced = runDeepAgentsConfigCommand( - forcedCommand, - { ui: { theme: "dark" } }, - "v2", - undefined, - 0o600, - { swapOnManagedSeek: attackerProjection, timeoutMs: managedCommandTimeoutMs }, + expect(existingResult.managedRaceIterations).toBeGreaterThan(0); + expect([0, 2]).toContain(existingResult.status); + expect(existingResult.configIsSymlink).toBe(true); + expect(existingResult.managedSymlinkTargetText).toBe( + `${JSON.stringify(emptyProjection, null, 2)}\n`, ); - expect(raced.status).toBe(2); - expect(raced.stderr).toContain("Refusing unsafe managed MCP v2 repair"); - expect(raced.stderr).not.toContain("Traceback"); - expect(raced.configText).toBe(attackerProjection); }); + it( + "keeps forced removal identity-bound during a descriptor rewrite (#10754)", + { timeout: 60_000 }, + () => { + const forcedCommand = buildDeepAgentsMcpRemoveCommand(baseEntry, true); + const raced = runDeepAgentsConfigCommand( + forcedCommand, + { ui: { theme: "dark" } }, + "v2", + undefined, + 0o600, + { raceProjection: "symlink", timeoutMs: managedCommandTimeoutMs }, + ); + expect(raced.managedRaceIterations).toBeGreaterThan(0); + expect([0, 2]).toContain(raced.status); + expect(raced.stderr).not.toContain("Traceback"); + expect(raced.configIsSymlink).toBe(true); + expect(raced.managedSymlinkTargetText).toBe( + `${JSON.stringify({ ui: { theme: "dark" } }, null, 2)}\n`, + ); + }, + ); + it("preserves a projection symlink during forced removal", () => { const forcedCommand = buildDeepAgentsMcpRemoveCommand(baseEntry, true); const forcedSymlink = runDeepAgentsConfigCommand( diff --git a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts index 7fcdaa16fa7..52661bb50a1 100644 --- a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts +++ b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts @@ -36,6 +36,7 @@ export interface DeepAgentsConfigCommandResult { legacyConfigText: string | null; managedParentIsSymlink: boolean; managedParentTargetText: string | null; + managedRaceIterations: number; managedSymlinkTargetExists: boolean; managedSymlinkTargetText: string | null; managedTargetReadAccessed: boolean; @@ -47,65 +48,19 @@ export interface DeepAgentsManagedFixtureOptions { fifo?: boolean; mode?: number; parentSymlink?: boolean; - swapAfterManagedEloop?: boolean; - swapAfterManagedOpen?: "fifo" | "symlink"; - swapAfterMissingManagedOpen?: "fifo" | "symlink"; - swapBeforeManagedLink?: string; - swapOnManagedOpen?: "fifo" | "socket" | "symlink"; - swapOnManagedRead?: "fifo" | "symlink"; - swapOnManagedSeek?: string; + raceAbsentPublication?: string; + raceProjection?: "fifo" | "symlink"; + socket?: boolean; symlink?: boolean; targetReadProbe?: boolean; timeoutMs?: number; } -type ManagedSwap = { - content?: string; - kind: "fifo" | "regular" | "socket" | "symlink"; - phase: - | "after-missing-projection-open" - | "after-projection-eloop" - | "after-projection-open" - | "before-projection-open" - | "projection-publication-begins" - | "projection-read-begins" - | "projection-rewrite-begins"; -}; - -function resolveManagedSwap(options: DeepAgentsManagedFixtureOptions): ManagedSwap | undefined { - if (options.swapOnManagedOpen) { - return { kind: options.swapOnManagedOpen, phase: "before-projection-open" }; - } - if (options.swapAfterManagedOpen) { - return { kind: options.swapAfterManagedOpen, phase: "after-projection-open" }; - } - if (options.swapAfterMissingManagedOpen) { - return { - kind: options.swapAfterMissingManagedOpen, - phase: "after-missing-projection-open", - }; - } - if (options.swapOnManagedRead) { - return { kind: options.swapOnManagedRead, phase: "projection-read-begins" }; - } - if (options.swapAfterManagedEloop) { - return { kind: "symlink", phase: "after-projection-eloop" }; - } - if (options.swapBeforeManagedLink !== undefined) { - return { - content: options.swapBeforeManagedLink, - kind: "regular", - phase: "projection-publication-begins", - }; - } - if (options.swapOnManagedSeek !== undefined) { - return { - content: options.swapOnManagedSeek, - kind: "regular", - phase: "projection-rewrite-begins", - }; - } - return undefined; +export function isSafeManagedProjectionRaceResult(result: DeepAgentsConfigCommandResult): boolean { + return ( + (result.status === 0 && result.stdout.trim() === "absent" && result.stderr.trim() === "") || + (result.status === 2 && result.stdout.trim() === "") + ); } function createFifo(target: string, mode = 0o600): void { @@ -128,6 +83,154 @@ function waitForPath(target: string, timeoutMs: number): boolean { return fs.existsSync(target); } +type ManagedFixtureProcess = { + child: ReturnType; + finishedPath: string; + resultPath: string; + stopPath: string; +}; + +function startManagedFixtureProcess( + fixtureRoot: string, + label: string, + script: string, + args: string[], +): ManagedFixtureProcess { + const readyPath = path.join(fixtureRoot, `${label}-ready`); + const stopPath = path.join(fixtureRoot, `${label}-stop`); + const finishedPath = path.join(fixtureRoot, `${label}-finished`); + const resultPath = path.join(fixtureRoot, `${label}-result`); + const child = spawn( + "python3", + ["-I", "-c", script, ...args, readyPath, stopPath, finishedPath, resultPath], + { stdio: "ignore" }, + ); + if (!waitForPath(readyPath, 2000)) { + child.kill("SIGTERM"); + throw new Error(`${label} did not become ready`); + } + pause(20); + return { child, finishedPath, resultPath, stopPath }; +} + +function stopManagedFixtureProcess(process: ManagedFixtureProcess): number { + if (!fs.existsSync(process.stopPath)) fs.writeFileSync(process.stopPath, "", { flag: "wx" }); + if (!waitForPath(process.finishedPath, 2000)) { + process.child.kill("SIGTERM"); + throw new Error("managed fixture process did not stop"); + } + process.child.kill("SIGTERM"); + const result = Number.parseInt(fs.readFileSync(process.resultPath, "utf-8"), 10); + return Number.isFinite(result) ? result : 0; +} + +function startManagedPathRace( + fixtureRoot: string, + managedPath: string, + managedTarget: string, + kind: "fifo" | "symlink", + safeContent: string, +): ManagedFixtureProcess { + const script = [ + "import base64, os, sys, time", + "managed_path, managed_target, race_kind, encoded = sys.argv[1:5]", + "ready, stop, finished, result = sys.argv[5:9]", + "safe_content = base64.b64decode(encoded)", + "safe_temp = managed_path + '.race-safe'", + "unsafe_temp = managed_path + '.race-unsafe'", + "def remove_temp(target):", + " try:", + " os.unlink(target)", + " except FileNotFoundError:", + " pass", + "def replace_safe():", + " remove_temp(safe_temp)", + " descriptor = os.open(safe_temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)", + " try:", + " offset = 0", + " while offset < len(safe_content):", + " offset += os.write(descriptor, safe_content[offset:])", + " os.fsync(descriptor)", + " finally:", + " os.close(descriptor)", + " os.replace(safe_temp, managed_path)", + "def replace_unsafe():", + " remove_temp(unsafe_temp)", + " if race_kind == 'symlink':", + " os.symlink(managed_target, unsafe_temp)", + " else:", + " os.mkfifo(unsafe_temp, 0o600)", + " os.replace(unsafe_temp, managed_path)", + "iterations = 0", + "replace_unsafe()", + "open(ready, 'x').close()", + "while not os.path.exists(stop):", + " replace_safe()", + " iterations += 1", + " replace_unsafe()", + " iterations += 1", + " time.sleep(0.0001)", + "replace_unsafe()", + "open(result, 'w', encoding='utf-8').write(str(iterations))", + "open(finished, 'x').close()", + ].join("\n"); + return startManagedFixtureProcess(fixtureRoot, "managed-path-race", script, [ + managedPath, + managedTarget, + kind, + Buffer.from(safeContent).toString("base64"), + ]); +} + +function startAbsentPublicationRace( + fixtureRoot: string, + managedPath: string, + managedTarget: string, +): ManagedFixtureProcess { + const script = [ + "import os, sys, time", + "managed_path, managed_target = sys.argv[1:3]", + "ready, stop, finished, result = sys.argv[3:7]", + "parent = os.path.dirname(managed_path)", + "triggered = 0", + "open(ready, 'x').close()", + "while not os.path.exists(stop) and not triggered:", + " if os.path.isdir(parent):", + " try:", + " os.symlink(managed_target, managed_path)", + " triggered = 1", + " except FileExistsError:", + " pass", + " time.sleep(0.0001)", + "while not os.path.exists(stop):", + " time.sleep(0.001)", + "open(result, 'w', encoding='utf-8').write(str(triggered))", + "open(finished, 'x').close()", + ].join("\n"); + return startManagedFixtureProcess(fixtureRoot, "managed-publication-race", script, [ + managedPath, + managedTarget, + ]); +} + +function startManagedSocket(fixtureRoot: string, managedPath: string): ManagedFixtureProcess { + const script = [ + "import os, socket, sys, time", + "managed_path = sys.argv[1]", + "ready, stop, finished, result = sys.argv[2:6]", + "os.makedirs(os.path.dirname(managed_path), exist_ok=True)", + "managed_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)", + "managed_socket.bind(managed_path)", + "open(ready, 'x').close()", + "while not os.path.exists(stop):", + " time.sleep(0.001)", + "managed_socket.close()", + "open(result, 'w', encoding='utf-8').write('1')", + "open(finished, 'x').close()", + ].join("\n"); + return startManagedFixtureProcess(fixtureRoot, "managed-socket", script, [managedPath]); +} + function startTargetReadProbe(target: string, ready: string, accessed: string) { const script = [ "import os, sys", @@ -151,12 +254,10 @@ function createDeepAgentsFixturePythonExecutable( fixtureRoot: string, configPath: string, legacyConfigPath: string, - managedSymlinkTarget: string, runtimeKind: "v2" | "legacy" | "unknown", - managedSwap?: ManagedSwap, ): { executablePath: string; scriptPath: string } { - const executablePath = path.join(fixtureRoot, "managed-swap-python"); - const scriptPath = path.join(fixtureRoot, "managed-swap-wrapper.py"); + const executablePath = path.join(fixtureRoot, "fixture-python"); + const scriptPath = path.join(fixtureRoot, "fixture-wrapper.py"); const runtimeConfigPath = runtimeKind === "v2" ? configPath @@ -172,117 +273,12 @@ function createDeepAgentsFixturePythonExecutable( { mode: 0o600 }, ); const wrapper = [ - "import errno", - "import os", - "import socket", "import sys", `fixture_root = ${JSON.stringify(fixtureRoot)}`, - `managed_path = ${JSON.stringify(configPath)}`, - `managed_target = ${JSON.stringify(managedSymlinkTarget)}`, - `managed_swap_kind = ${JSON.stringify(managedSwap?.kind ?? "")}`, - `managed_swap_phase = ${JSON.stringify(managedSwap?.phase ?? "")}`, - `managed_swap_content = ${JSON.stringify(managedSwap?.content ?? "")}`, - "managed_eloop_stage = 0", - "managed_socket = None", - "managed_swapped = False", - "managed_descriptor = None", - "managed_entry_name = os.path.basename(managed_path)", - "managed_real_link = os.link", - "managed_real_lseek = os.lseek", - "managed_real_open = os.open", - "managed_real_read = os.read", - "def replace_managed_path():", - " global managed_socket, managed_swapped", - " if managed_swapped:", - " return", - " managed_swapped = True", - " try:", - " os.unlink(managed_path)", - " except FileNotFoundError:", - " pass", - " if managed_swap_kind == 'symlink':", - " os.symlink(managed_target, managed_path)", - " elif managed_swap_kind == 'fifo':", - " os.mkfifo(managed_path, 0o600)", - " elif managed_swap_kind == 'socket':", - " managed_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)", - " managed_socket.bind(managed_path)", - " else:", - " with open(managed_path, 'w', encoding='utf-8') as replacement:", - " replacement.write(managed_swap_content)", - " os.chmod(managed_path, 0o600)", - "def replace_managed_symlink_with_regular():", - " global managed_eloop_stage", - " os.unlink(managed_path)", - " with open(managed_target, 'rb') as source, open(managed_path, 'wb') as replacement:", - " replacement.write(source.read())", - " os.chmod(managed_path, 0o600)", - " managed_eloop_stage = 1", - "def restore_managed_symlink():", - " global managed_eloop_stage", - " os.unlink(managed_path)", - " os.symlink(managed_target, managed_path)", - " managed_eloop_stage = 2", - "def is_managed_entry(value, directory_descriptor):", - " try:", - " return directory_descriptor is not None and os.fspath(value) == managed_entry_name", - " except TypeError:", - " return False", - "def managed_boundary_open(value, flags, mode=0o777, *, dir_fd=None):", - " global managed_descriptor", - " projection_entry = is_managed_entry(value, dir_fd)", - " if projection_entry and managed_swap_phase == 'before-projection-open':", - " replace_managed_path()", - " try:", - " if dir_fd is None:", - " descriptor = managed_real_open(value, flags, mode)", - " else:", - " descriptor = managed_real_open(value, flags, mode, dir_fd=dir_fd)", - " except OSError as exc:", - " if projection_entry and managed_swap_phase == 'after-missing-projection-open' and isinstance(exc, FileNotFoundError):", - " replace_managed_path()", - " elif projection_entry and managed_swap_phase == 'after-projection-eloop' and exc.errno == errno.ELOOP:", - " replace_managed_symlink_with_regular()", - " raise", - " if projection_entry:", - " managed_descriptor = descriptor", - " if managed_swap_phase == 'after-projection-open':", - " replace_managed_path()", - " return descriptor", - "def managed_boundary_read(descriptor, length):", - " if managed_swap_phase == 'projection-read-begins' and descriptor == managed_descriptor:", - " replace_managed_path()", - " return managed_real_read(descriptor, length)", - "def managed_boundary_link(source, destination, *args, **kwargs):", - " if managed_swap_phase == 'projection-publication-begins' and is_managed_entry(destination, kwargs.get('dst_dir_fd')):", - " replace_managed_path()", - " return managed_real_link(source, destination, *args, **kwargs)", - "def managed_boundary_lseek(descriptor, position, how):", - " if managed_swap_phase == 'projection-rewrite-begins' and descriptor == managed_descriptor:", - " replace_managed_path()", - " return managed_real_lseek(descriptor, position, how)", - "def install_managed_boundaries():", - " if not managed_swap_phase:", - " return", - " os.link = managed_boundary_link", - " os.lseek = managed_boundary_lseek", - " os.open = managed_boundary_open", - " os.read = managed_boundary_read", - "def restore_managed_boundaries():", - " os.link = managed_real_link", - " os.lseek = managed_real_lseek", - " os.open = managed_real_open", - " os.read = managed_real_read", "source = sys.stdin.read()", "namespace = {'__name__': '__main__'}", "sys.path.insert(0, fixture_root)", - "install_managed_boundaries()", - "try:", - " exec(compile(source, '', 'exec'), namespace)", - "finally:", - " restore_managed_boundaries()", - " if managed_swap_phase == 'after-projection-eloop' and managed_eloop_stage == 1:", - " restore_managed_symlink()", + "exec(compile(source, '', 'exec'), namespace)", "", ].join("\n"); fs.writeFileSync(scriptPath, wrapper, { mode: 0o600 }); @@ -303,8 +299,15 @@ export function runDeepAgentsConfigCommand( managedOptions: DeepAgentsManagedFixtureOptions = {}, runtimeEnvironment: NodeJS.ProcessEnv = {}, ): DeepAgentsConfigCommandResult { - const managedSwap = resolveManagedSwap(managedOptions); - const fixtureRoot = managedSwap?.kind === "socket" ? "/tmp" : os.tmpdir(); + const activeFixtureProcesses = [ + managedOptions.raceAbsentPublication !== undefined, + managedOptions.raceProjection !== undefined, + managedOptions.socket === true, + ].filter(Boolean).length; + if (activeFixtureProcesses > 1) { + throw new Error("managed fixture process options are mutually exclusive"); + } + const fixtureRoot = managedOptions.socket ? "/tmp" : os.tmpdir(); const tmp = fs.realpathSync(fs.mkdtempSync(path.join(fixtureRoot, "nemoclaw-deepagents-mcp-"))); const configPath = path.join(tmp, ".deepagents", ".nemoclaw-mcp.json"); const managedParentPath = path.dirname(configPath); @@ -351,31 +354,36 @@ export function runDeepAgentsConfigCommand( fs.mkdirSync(configPath, { recursive: true, mode: managedOptions.mode ?? 0o700 }); } else if (managedOptions.fifo) { createFifo(configPath, managedOptions.mode); + } else if (managedOptions.socket) { + // The socket holder creates the path after the rest of the fixture is ready. } else { if (managedTargetReadPath === managedSymlinkTarget) { createFifo(managedSymlinkTarget, managedOptions.mode); } else if ( !managedOptions.danglingSymlink && - managedSwap?.phase !== "after-missing-projection-open" + managedOptions.raceAbsentPublication === undefined ) { initializeConfig(managedInitialPath, initialConfig, managedOptions.mode); if (initialConfig !== undefined) fs.chmodSync(managedInitialPath, managedOptions.mode ?? 0o600); } - if (managedSwap?.phase === "after-missing-projection-open") { - fs.mkdirSync(path.dirname(configPath), { recursive: true }); - } if (managedSymlink) { fs.mkdirSync(path.dirname(configPath), { recursive: true }); fs.symlinkSync(managedSymlinkTarget, configPath); } } - if (managedSwap?.kind === "symlink") { + if (managedOptions.raceProjection === "symlink") { initializeConfig(managedSymlinkTarget, initialConfig, managedOptions.mode); } + if (managedOptions.raceAbsentPublication !== undefined) { + initializeConfig(managedSymlinkTarget, managedOptions.raceAbsentPublication); + } initializeConfig(legacyConfigPath, initialLegacyConfig); if (initialLegacyConfig !== undefined) fs.chmodSync(legacyConfigPath, initialLegacyMode); let targetReadProbe: ReturnType | null = null; + let managedRace: ManagedFixtureProcess | null = null; + let socketFixture: ManagedFixtureProcess | null = null; + let managedRaceIterations = 0; try { if (managedTargetReadPath !== null) { targetReadProbe = startTargetReadProbe( @@ -384,22 +392,39 @@ export function runDeepAgentsConfigCommand( managedTargetReadAccess, ); } + if (managedOptions.socket) { + socketFixture = startManagedSocket(tmp, configPath); + } else if (managedOptions.raceProjection) { + if (initialConfig === undefined) throw new Error("raceProjection requires initialConfig"); + const safeContent = + typeof initialConfig === "string" + ? initialConfig + : `${JSON.stringify(initialConfig, null, 2)}\n`; + managedRace = startManagedPathRace( + tmp, + configPath, + managedSymlinkTarget, + managedOptions.raceProjection, + safeContent, + ); + } else if (managedOptions.raceAbsentPublication !== undefined) { + managedRace = startAbsentPublicationRace(tmp, configPath, managedSymlinkTarget); + } const fixturePython = createDeepAgentsFixturePythonExecutable( tmp, configPath, legacyConfigPath, - managedSymlinkTarget, runtimeKind, - managedSwap, ); const fixtureCommand = command .replaceAll(DEEPAGENTS_MCP_CONFIG_PATH, configPath) .replaceAll("/sandbox/.deepagents/.mcp.json", legacyConfigPath) .replaceAll("/opt/venv/bin/python3", fixturePython.executablePath); const canonicalEnvironment = Object.fromEntries( - [...command.matchAll(/openshell:resolve:env:([A-Za-z_][A-Za-z0-9_]*)/gu)].map( - ([, name]) => [name!, `openshell:resolve:env:${name!}`], - ), + [...command.matchAll(/openshell:resolve:env:([A-Za-z_][A-Za-z0-9_]*)/gu)].map(([, name]) => [ + name!, + `openshell:resolve:env:${name!}`, + ]), ); const fixtureCommandPath = path.join(tmp, "managed-command.sh"); fs.writeFileSync(fixtureCommandPath, fixtureCommand, { mode: 0o600 }); @@ -413,6 +438,14 @@ export function runDeepAgentsConfigCommand( }, timeout: managedOptions.timeoutMs ?? 5000, }); + if (managedRace !== null) { + managedRaceIterations = stopManagedFixtureProcess(managedRace); + managedRace = null; + } + if (socketFixture !== null) { + stopManagedFixtureProcess(socketFixture); + socketFixture = null; + } let configStat: fs.Stats | null = null; try { configStat = fs.lstatSync(configPath); @@ -483,11 +516,14 @@ export function runDeepAgentsConfigCommand( legacyConfigText, managedParentIsSymlink, managedParentTargetText, + managedRaceIterations, managedSymlinkTargetExists, managedSymlinkTargetText, managedTargetReadAccessed, }; } finally { + if (managedRace !== null) managedRace.child.kill("SIGTERM"); + if (socketFixture !== null) socketFixture.child.kill("SIGTERM"); targetReadProbe?.kill("SIGTERM"); fs.rmSync(tmp, { recursive: true, force: true }); } From 269c1adb1d1d0874278be722b12d5180f4de76eb Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Wed, 2 Sep 2026 03:19:17 -0700 Subject: [PATCH 27/49] fix(mcp): tighten projection review boundaries Signed-off-by: Prekshi Vyas --- ...idge-adapter-deepagents-projection.test.ts | 30 +++++++++++++++++-- ...cp-bridge-adapter-deepagents-projection.ts | 14 ++++----- .../mcp-bridge-adapter-deepagents-fixture.ts | 7 ----- 3 files changed, 34 insertions(+), 17 deletions(-) diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts index cef8df8e8dc..96552973c78 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts @@ -4,7 +4,6 @@ import { describe, expect, it } from "vitest"; import { baseEntry, - isSafeManagedProjectionRaceResult, runDeepAgentsConfigCommand, } from "../../../../test/helpers/mcp-bridge-adapter-deepagents-fixture"; import type { McpBridgeEntry } from "../../state/registry"; @@ -26,6 +25,16 @@ const rollbackCommand = buildDeepAgentsMcpRegisterCommand(baseEntry, true, [base const removalCommand = buildDeepAgentsMcpRemoveCommand(baseEntry); describe("Deep Agents managed MCP projection safety", () => { + it("renders status without mutation-only projection helpers", () => { + const statusCommand = buildDeepAgentsMcpStatusCommand(baseEntry); + + expect(statusCommand).toContain("def read_managed_projection"); + expect(statusCommand).not.toContain("def load_managed_projection_for_update"); + expect(registrationCommand).toContain("def load_managed_projection_for_update"); + expect(removalCommand).toContain("def load_managed_projection_for_update"); + expect(rollbackCommand).toContain("def load_managed_projection_for_update"); + }); + it( "applies the shared server cap before normal and rollback v2 publication", { timeout: 60_000 }, @@ -168,19 +177,27 @@ describe("Deep Agents managed MCP projection safety", () => { { expectedConfigIsFifo: false, expectedConfigIsSymlink: true, + expectedSafetyDiagnostic: "Unsafe managed Deep Agents MCP projection path: symbolic link", expectedTargetText: emptyProjectionText, raceProjection: "symlink" as const, }, { expectedConfigIsFifo: true, expectedConfigIsSymlink: false, + expectedSafetyDiagnostic: "Unsafe managed Deep Agents MCP projection path: FIFO", expectedTargetText: null, raceProjection: "fifo" as const, }, ])( "rejects or safely loses a concurrent $raceProjection projection race (#10754)", { timeout: 60_000 }, - ({ expectedConfigIsFifo, expectedConfigIsSymlink, expectedTargetText, raceProjection }) => { + ({ + expectedConfigIsFifo, + expectedConfigIsSymlink, + expectedSafetyDiagnostic, + expectedTargetText, + raceProjection, + }) => { const result = runDeepAgentsConfigCommand( buildDeepAgentsMcpStatusCommand(baseEntry), emptyProjection, @@ -191,7 +208,14 @@ describe("Deep Agents managed MCP projection safety", () => { ); expect(result.managedRaceIterations).toBeGreaterThan(0); - expect(isSafeManagedProjectionRaceResult(result)).toBe(true); + expect([ + { status: 0, stderr: "", stdout: "absent" }, + { status: 2, stderr: expectedSafetyDiagnostic, stdout: "" }, + ]).toContainEqual({ + status: result.status, + stderr: result.stderr.trim(), + stdout: result.stdout.trim(), + }); expect(result.configIsSymlink).toBe(expectedConfigIsSymlink); expect(result.configIsFifo).toBe(expectedConfigIsFifo); expect(result.managedSymlinkTargetText).toBe(expectedTargetText); diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts index c21db5ed120..cd0378cd0b8 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts @@ -193,13 +193,6 @@ export const DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS = [ " os.close(current)", " except OSError:", " pass", - "def load_managed_projection_for_update(path):", - " raw, identity, descriptor, parent_descriptor, parent_identity = open_managed_projection(path, True)", - " try:", - " return decode_managed_projection(raw), identity, descriptor, parent_descriptor, parent_identity", - " except Exception:", - " close_managed_projection_descriptors(descriptor, parent_descriptor)", - " raise", "def read_managed_projection(path, unsafe_path_prefix=None):", " raw, identity, descriptor, parent_descriptor, _ = open_managed_projection(path, unsafe_path_prefix=unsafe_path_prefix)", " try:", @@ -209,6 +202,13 @@ export const DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS = [ ]; export const DEEPAGENTS_MANAGED_PROJECTION_MUTATION_HELPERS = [ + "def load_managed_projection_for_update(path):", + " raw, identity, descriptor, parent_descriptor, parent_identity = open_managed_projection(path, True)", + " try:", + " return decode_managed_projection(raw), identity, descriptor, parent_descriptor, parent_identity", + " except Exception:", + " close_managed_projection_descriptors(descriptor, parent_descriptor)", + " raise", "def managed_projection_bytes(value):", " payload = (json.dumps(value, indent=2, sort_keys=True) + '\\n').encode('utf-8')", " if not payload or len(payload) > MANAGED_MCP_MAX_BYTES:", diff --git a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts index c976ea486f1..00e1777cae1 100644 --- a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts +++ b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts @@ -56,13 +56,6 @@ export interface DeepAgentsManagedFixtureOptions { timeoutMs?: number; } -export function isSafeManagedProjectionRaceResult(result: DeepAgentsConfigCommandResult): boolean { - return ( - (result.status === 0 && result.stdout.trim() === "absent" && result.stderr.trim() === "") || - (result.status === 2 && result.stdout.trim() === "") - ); -} - function createFifo(target: string, mode = 0o600): void { fs.mkdirSync(path.dirname(target), { recursive: true }); const fifo = spawnSync("mkfifo", [target], { encoding: "utf-8", timeout: 5000 }); From a6ad937bc0d74893073c1a9ece13242dcd1721c2 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:29:18 -0700 Subject: [PATCH 28/49] test(mcp): harden fixture result read Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- .../mcp-bridge-adapter-deepagents-fixture.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts index 00e1777cae1..2095a8cd6a0 100644 --- a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts +++ b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts @@ -113,7 +113,19 @@ function stopManagedFixtureProcess(process: ManagedFixtureProcess): number { throw new Error("managed fixture process did not stop"); } process.child.kill("SIGTERM"); - const result = Number.parseInt(fs.readFileSync(process.resultPath, "utf-8"), 10); + const descriptor = fs.openSync( + process.resultPath, + fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK, + ); + let result: number; + try { + if (!fs.fstatSync(descriptor).isFile()) { + throw new Error("managed fixture process result is not a regular file"); + } + result = Number.parseInt(fs.readFileSync(descriptor, "utf-8"), 10); + } finally { + fs.closeSync(descriptor); + } return Number.isFinite(result) ? result : 0; } From 0aa37e9190ea43aeeddd152e72842e1cfcc4ed07 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Wed, 2 Sep 2026 03:31:03 -0700 Subject: [PATCH 29/49] test(mcp): remove projection helper shape assertion Signed-off-by: Prekshi Vyas --- .../mcp-bridge-adapter-deepagents-projection.test.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts index 96552973c78..47ff7f9657d 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts @@ -25,16 +25,6 @@ const rollbackCommand = buildDeepAgentsMcpRegisterCommand(baseEntry, true, [base const removalCommand = buildDeepAgentsMcpRemoveCommand(baseEntry); describe("Deep Agents managed MCP projection safety", () => { - it("renders status without mutation-only projection helpers", () => { - const statusCommand = buildDeepAgentsMcpStatusCommand(baseEntry); - - expect(statusCommand).toContain("def read_managed_projection"); - expect(statusCommand).not.toContain("def load_managed_projection_for_update"); - expect(registrationCommand).toContain("def load_managed_projection_for_update"); - expect(removalCommand).toContain("def load_managed_projection_for_update"); - expect(rollbackCommand).toContain("def load_managed_projection_for_update"); - }); - it( "applies the shared server cap before normal and rollback v2 publication", { timeout: 60_000 }, From 286ef7229969ab087859e2e9f5d3d48f40c77830 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:56:38 -0700 Subject: [PATCH 30/49] test(mcp): create fixture stop marker atomically Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- test/helpers/mcp-bridge-adapter-deepagents-fixture.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts index 2095a8cd6a0..41d1294e8d9 100644 --- a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts +++ b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts @@ -107,7 +107,11 @@ function startManagedFixtureProcess( } function stopManagedFixtureProcess(process: ManagedFixtureProcess): number { - if (!fs.existsSync(process.stopPath)) fs.writeFileSync(process.stopPath, "", { flag: "wx" }); + try { + fs.writeFileSync(process.stopPath, "", { flag: "wx" }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + } if (!waitForPath(process.finishedPath, 2000)) { process.child.kill("SIGTERM"); throw new Error("managed fixture process did not stop"); From 266da41540cd0a5439784ef3e124ef0c823773b6 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Wed, 2 Sep 2026 03:59:37 -0700 Subject: [PATCH 31/49] test(e2e): isolate blueprint command fixtures Signed-off-by: Prekshi Vyas --- test/e2e-test.sh | 62 ++++++++++++++++++++++++++++++++++-------------- 1 file changed, 44 insertions(+), 18 deletions(-) diff --git a/test/e2e-test.sh b/test/e2e-test.sh index 5a11a76ca11..32ecaa19bf4 100755 --- a/test/e2e-test.sh +++ b/test/e2e-test.sh @@ -111,19 +111,39 @@ fi # ------------------------------------------------------- info "4. Verify blueprint runner plan command" # ------------------------------------------------------- -cd /opt/nemoclaw-blueprint -# Runner will fail at openshell prereq check (expected in test container). -# Use 'ncp' profile (empty endpoint skips SSRF DNS lookup in sandbox). -# Catch only the expected error — anything else propagates as a real failure. -NEMOCLAW_BLUEPRINT_PATH=/opt/nemoclaw-blueprint node --input-type=module -e " - const { main } = await import('/opt/nemoclaw/dist/blueprint/runner.js'); - try { - await main(['plan', '--profile', 'ncp', '--dry-run']); - } catch (err) { - if (!err.message.includes('openshell CLI not found')) throw err; - console.log('EXPECTED_ERROR: ' + err.message); - } -" 2>&1 | tee /tmp/plan-output.txt +prepare_command_blueprint() { + local destination="$1" + cp -R /opt/nemoclaw-blueprint/. "$destination" + node --input-type=module -e " + import { createRequire } from 'node:module'; + import { readFileSync, writeFileSync } from 'node:fs'; + const require = createRequire('/opt/nemoclaw/'); + const YAML = require('yaml'); + const path = process.argv[1]; + const bp = YAML.parse(readFileSync(path, 'utf-8')); + bp.components.policy.additions.nim_service.endpoints[0].host = '93.184.216.34'; + writeFileSync(path, YAML.stringify(bp)); + " "$destination/blueprint.yaml" +} + +( + PLAN_BLUEPRINT_PATH=$(mktemp -d) + trap 'rm -rf "$PLAN_BLUEPRINT_PATH"' EXIT + prepare_command_blueprint "$PLAN_BLUEPRINT_PATH" + cd "$PLAN_BLUEPRINT_PATH" + # Steps 3 and 3b validate the shipped blueprint. This command smoke test uses + # a copy with a stable public IP fixture so DNS and the local service hostname + # cannot preempt the expected OpenShell prerequisite boundary. + NEMOCLAW_BLUEPRINT_PATH="$PLAN_BLUEPRINT_PATH" node --input-type=module -e " + const { main } = await import('/opt/nemoclaw/dist/blueprint/runner.js'); + try { + await main(['plan', '--profile', 'ncp', '--dry-run']); + } catch (err) { + if (!err.message.includes('openshell CLI not found')) throw err; + console.log('EXPECTED_ERROR: ' + err.message); + } + " +) 2>&1 | tee /tmp/plan-output.txt if grep -q "RUN_ID:" /tmp/plan-output.txt; then pass "Blueprint plan generates run ID" else @@ -148,13 +168,15 @@ info "4b. Verify blueprint runner apply smoke test" # return the same metadata + YAML shape as OpenShell 0.0.72; an empty successful # response is intentionally rejected by the runner. FAKE_OPENSHELL_BIN=$(mktemp -d) +APPLY_BLUEPRINT_PATH=$(mktemp -d) APPLY_OUTPUT=$(mktemp) APPLY_CALLS="$FAKE_OPENSHELL_BIN/calls" cleanup_apply_fixture() { - rm -rf "$FAKE_OPENSHELL_BIN" + rm -rf "$FAKE_OPENSHELL_BIN" "$APPLY_BLUEPRINT_PATH" rm -f "$APPLY_OUTPUT" "$APPLY_CALLS" } trap cleanup_apply_fixture EXIT +prepare_command_blueprint "$APPLY_BLUEPRINT_PATH" cat >"$FAKE_OPENSHELL_BIN/openshell" <<'SH' #!/usr/bin/env bash set -euo pipefail @@ -188,7 +210,7 @@ if [ "${1:-} ${2:-}" = "policy get" ]; then fi if [ "${1:-} ${2:-}" = "policy get" ] && [[ " $* " == *" --output json "* ]]; then sandbox="${@: -1}" - policy_file=/opt/nemoclaw-blueprint/policies/openclaw-sandbox.yaml + policy_file="${OPENSHELL_SANDBOX_POLICY:?}" policy_hash=sha256:fixture-policy policy_version=1 if [ -f "${BASH_SOURCE[0]%/*}/effective-policy.yaml" ]; then @@ -218,8 +240,12 @@ case "$*" in echo "unexpected policy read: expected policy get -g fixture-gateway --base " >&2 exit 64 fi + policy_file="${OPENSHELL_SANDBOX_POLICY:?}" + if [ -f "${BASH_SOURCE[0]%/*}/effective-policy.yaml" ]; then + policy_file="${BASH_SOURCE[0]%/*}/effective-policy.yaml" + fi printf '%s\n' 'Policy for sandbox fixture' '---' - cat /opt/nemoclaw-blueprint/policies/openclaw-sandbox.yaml + cat "$policy_file" ;; "policy get "*) echo "unexpected policy read: expected policy get -g fixture-gateway --base " >&2 @@ -229,8 +255,8 @@ esac SH chmod 0755 "$FAKE_OPENSHELL_BIN/openshell" PATH="$FAKE_OPENSHELL_BIN:$PATH" \ - NEMOCLAW_BLUEPRINT_PATH=/opt/nemoclaw-blueprint \ - OPENSHELL_SANDBOX_POLICY=/opt/nemoclaw-blueprint/policies/openclaw-sandbox.yaml \ + NEMOCLAW_BLUEPRINT_PATH="$APPLY_BLUEPRINT_PATH" \ + OPENSHELL_SANDBOX_POLICY="$APPLY_BLUEPRINT_PATH/policies/openclaw-sandbox.yaml" \ node --input-type=module -e " const { main } = await import('/opt/nemoclaw/dist/blueprint/runner.js'); await main(['apply', '--profile', 'ncp']); From 83d233072cc52b8d2bb7c7649e5808e9cd7b2d37 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Wed, 2 Sep 2026 04:15:15 -0700 Subject: [PATCH 32/49] test(e2e): use synthetic blueprint command fixture Signed-off-by: Prekshi Vyas --- test/e2e-test.sh | 54 +++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 44 insertions(+), 10 deletions(-) diff --git a/test/e2e-test.sh b/test/e2e-test.sh index 32ecaa19bf4..9a27498a085 100755 --- a/test/e2e-test.sh +++ b/test/e2e-test.sh @@ -113,17 +113,51 @@ info "4. Verify blueprint runner plan command" # ------------------------------------------------------- prepare_command_blueprint() { local destination="$1" - cp -R /opt/nemoclaw-blueprint/. "$destination" node --input-type=module -e " import { createRequire } from 'node:module'; - import { readFileSync, writeFileSync } from 'node:fs'; + import { writeFileSync } from 'node:fs'; + import { join } from 'node:path'; const require = createRequire('/opt/nemoclaw/'); const YAML = require('yaml'); - const path = process.argv[1]; - const bp = YAML.parse(readFileSync(path, 'utf-8')); - bp.components.policy.additions.nim_service.endpoints[0].host = '93.184.216.34'; - writeFileSync(path, YAML.stringify(bp)); - " "$destination/blueprint.yaml" + const destination = process.argv[1]; + const blueprint = { + version: '1.0', + components: { + sandbox: { + image: 'openclaw', + name: 'openclaw', + forward_ports: [18789], + }, + inference: { + profiles: { + ncp: { + provider_type: 'nvidia', + provider_name: 'nvidia-ncp', + endpoint: '', + model: 'nvidia/nemotron-3-super-120b-a12b', + }, + }, + }, + policy: { + additions: { + nim_service: { + name: 'nim_service', + endpoints: [{ host: '93.184.216.34', port: 8000, access: 'full' }], + }, + }, + }, + }, + }; + writeFileSync(join(destination, 'blueprint.yaml'), YAML.stringify(blueprint)); + writeFileSync( + join(destination, 'sandbox-policy.yaml'), + YAML.stringify({ version: 1, network_policies: {} }), + ); + writeFileSync( + join(destination, 'private-networks.yaml'), + YAML.stringify({ ipv4: [], ipv6: [], names: [] }), + ); + " "$destination" } ( @@ -132,8 +166,8 @@ prepare_command_blueprint() { prepare_command_blueprint "$PLAN_BLUEPRINT_PATH" cd "$PLAN_BLUEPRINT_PATH" # Steps 3 and 3b validate the shipped blueprint. This command smoke test uses - # a copy with a stable public IP fixture so DNS and the local service hostname - # cannot preempt the expected OpenShell prerequisite boundary. + # an independent fixture with a stable public IP so DNS cannot preempt the + # expected OpenShell prerequisite boundary. NEMOCLAW_BLUEPRINT_PATH="$PLAN_BLUEPRINT_PATH" node --input-type=module -e " const { main } = await import('/opt/nemoclaw/dist/blueprint/runner.js'); try { @@ -256,7 +290,7 @@ SH chmod 0755 "$FAKE_OPENSHELL_BIN/openshell" PATH="$FAKE_OPENSHELL_BIN:$PATH" \ NEMOCLAW_BLUEPRINT_PATH="$APPLY_BLUEPRINT_PATH" \ - OPENSHELL_SANDBOX_POLICY="$APPLY_BLUEPRINT_PATH/policies/openclaw-sandbox.yaml" \ + OPENSHELL_SANDBOX_POLICY="$APPLY_BLUEPRINT_PATH/sandbox-policy.yaml" \ node --input-type=module -e " const { main } = await import('/opt/nemoclaw/dist/blueprint/runner.js'); await main(['apply', '--profile', 'ncp']); From b63ef9c0162ed96c89eac3430371691513ebbc8d Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Wed, 2 Sep 2026 04:42:32 -0700 Subject: [PATCH 33/49] fix(mcp): pin legacy config parent descriptors --- ...adapter-deepagents-legacy-teardown.test.ts | 27 +++ .../mcp-bridge-adapter-deepagents-legacy.ts | 39 ++--- ...idge-adapter-deepagents-projection.test.ts | 19 ++- ...bridge-adapter-deepagents-rollback.test.ts | 22 +++ .../mcp-bridge-adapter-deepagents-teardown.ts | 48 ++---- .../mcp-bridge/deepagents-legacy-config.ts | 160 ++++++++++++++---- .../mcp-bridge-adapter-deepagents-fixture.ts | 38 ++++- 7 files changed, 257 insertions(+), 96 deletions(-) diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy-teardown.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy-teardown.test.ts index 1a9bcfa3735..9c315445314 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy-teardown.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy-teardown.test.ts @@ -80,4 +80,31 @@ describe("Deep Agents MCP config adapter legacy teardown", () => { ui: { theme: "dark" }, }); }); + + it("refuses removal through a symlinked legacy parent", () => { + const legacyConfig = { + mcpServers: { + github: { + type: "http", + url: baseEntry.url, + headers: { Authorization: "Bearer openshell:resolve:env:GITHUB_TOKEN" }, + }, + }, + }; + const original = `${JSON.stringify(legacyConfig, null, 2)}\n`; + + const removal = runDeepAgentsConfigCommand( + buildDeepAgentsMcpRemoveCommand(baseEntry, false, true), + undefined, + "legacy", + legacyConfig, + 0o600, + { legacyParentSymlink: true }, + ); + + expect(removal.status).toBe(2); + expect(removal.stderr).toContain("legacy MCP config parent is unsafe: symbolic link"); + expect(removal.legacyParentIsSymlink).toBe(true); + expect(removal.legacyParentTargetText).toBe(original); + }); }); diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy.ts index bcce0026c77..ae22f2e3c1e 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy.ts @@ -40,7 +40,7 @@ export function buildDeepAgentsMcpRollbackRegisterCommand( }; return [ "/opt/venv/bin/python3 -I - <<'PY'", - "import json, os, pathlib, stat, sys, tempfile", + "import json, os, pathlib, stat, sys", `payload = json.loads(${pythonJsonLiteral(payload)})`, `managed_path = pathlib.Path(${JSON.stringify(DEEPAGENTS_MCP_CONFIG_PATH)})`, `legacy_path = pathlib.Path(${JSON.stringify(DEEPAGENTS_LEGACY_MCP_CONFIG_PATH)})`, @@ -74,15 +74,19 @@ export function buildDeepAgentsMcpRollbackRegisterCommand( "managed_parent_identity = None", "managed_parent_descriptor = None", "legacy_identity = None", + "legacy_descriptor = None", + "legacy_parent_identity = None", + "legacy_parent_descriptor = None", "def fail_rollback(message):", " close_managed_projection_descriptors(managed_descriptor, managed_parent_descriptor)", + " close_legacy_config_descriptors(legacy_descriptor, legacy_parent_descriptor)", " print(message, file=sys.stderr)", " raise SystemExit(2)", "try:", " if is_v2:", " data, managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity = load_managed_projection_for_update(config_path)", - " elif os.path.lexists(config_path):", - " data, legacy_identity = read_legacy_config(config_path)", + " else:", + " data, legacy_identity, legacy_descriptor, legacy_parent_descriptor, legacy_parent_identity = load_legacy_config_for_update(config_path, True)", "except (OSError, UnicodeDecodeError, ValueError) as exc:", " fail_rollback(f'Invalid managed MCP rollback state at {config_path}: {exc}')", "if not isinstance(data, dict):", @@ -111,35 +115,16 @@ export function buildDeepAgentsMcpRollbackRegisterCommand( " fail_rollback(f'Refusing to overwrite user-owned legacy MCP server at {config_path}')", " servers[payload['server']] = payload['expected']", "if not is_v2:", - " config_path.parent.mkdir(parents=True, exist_ok=True)", - " tmp_fd, tmp_name = tempfile.mkstemp(prefix='.nemoclaw-mcp.', dir=config_path.parent)", " try:", - " os.fchmod(tmp_fd, 0o600)", - " with os.fdopen(tmp_fd, 'w', encoding='utf-8') as tmp_file:", - " json.dump(data, tmp_file, indent=2, sort_keys=True)", - " tmp_file.write('\\n')", - " tmp_file.flush()", - " os.fsync(tmp_file.fileno())", - " assert_legacy_source_stable(config_path, legacy_identity)", - " if legacy_identity is None:", - " os.link(tmp_name, config_path, follow_symlinks=False)", - " os.unlink(tmp_name)", - " else:", - " os.replace(tmp_name, config_path)", - " finally:", - " try:", - " os.unlink(tmp_name)", - " except FileNotFoundError:", - " pass", + " persisted = write_legacy_config(config_path, data, legacy_identity, legacy_descriptor, legacy_parent_descriptor, legacy_parent_identity)", + " except (OSError, ValueError) as exc:", + " fail_rollback(f'Could not publish managed MCP rollback state at {config_path}: {exc}')", "else:", " try:", " write_managed_projection(config_path, data, managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity)", + " persisted = read_managed_projection(config_path)[0]", " except (OSError, ValueError) as exc:", " fail_rollback(f'Could not publish managed MCP rollback state at {config_path}: {exc}')", - "try:", - " persisted = read_managed_projection(config_path)[0] if is_v2 else read_legacy_config(config_path)[0]", - "except (OSError, UnicodeDecodeError, ValueError) as exc:", - " fail_rollback(f'Could not verify managed MCP rollback state at {config_path}: {exc}')", "if is_v2:", " persisted_servers = persisted.get('mcpServers') if isinstance(persisted, dict) else None", " restored = isinstance(persisted_servers, dict) and set(persisted_servers) == set(payload['expectedServers']) and all(managed_http_server_matches(persisted_servers.get(name), expected, True) for name, expected in payload['expectedServers'].items())", @@ -148,6 +133,8 @@ export function buildDeepAgentsMcpRollbackRegisterCommand( " restored = isinstance(persisted_servers, dict) and persisted_servers.get(payload['server']) == payload['expected']", "if not restored:", " fail_rollback(f'Managed MCP rollback verification failed at {config_path}')", + "close_managed_projection_descriptors(managed_descriptor, managed_parent_descriptor)", + "close_legacy_config_descriptors(legacy_descriptor, legacy_parent_descriptor)", "print('NEMOCLAW_DEEPAGENTS_MCP_ROLLBACK_RESTORED=1')", "PY", ].join("\n"); diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts index 47ff7f9657d..994121103f8 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts @@ -198,14 +198,23 @@ describe("Deep Agents managed MCP projection safety", () => { ); expect(result.managedRaceIterations).toBeGreaterThan(0); - expect([ - { status: 0, stderr: "", stdout: "absent" }, - { status: 2, stderr: expectedSafetyDiagnostic, stdout: "" }, - ]).toContainEqual({ + const actualOutcome = { status: result.status, stderr: result.stderr.trim(), stdout: result.stdout.trim(), - }); + }; + const safelyLostRace = + actualOutcome.status === 0 && + actualOutcome.stderr === "" && + actualOutcome.stdout === "absent"; + const safelyRejectedRace = + actualOutcome.status === 2 && + actualOutcome.stdout === "" && + (actualOutcome.stderr === expectedSafetyDiagnostic || + /^Could not inspect managed Deep Agents MCP state at .+: managed MCP projection has unsafe ownership, mode, type, links, or path identity$/u.test( + actualOutcome.stderr, + )); + expect(safelyLostRace || safelyRejectedRace, JSON.stringify(actualOutcome)).toBe(true); expect(result.configIsSymlink).toBe(expectedConfigIsSymlink); expect(result.configIsFifo).toBe(expectedConfigIsFifo); expect(result.managedSymlinkTargetText).toBe(expectedTargetText); diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-rollback.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-rollback.test.ts index 0cedb3f7642..59724441013 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-rollback.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-rollback.test.ts @@ -38,6 +38,28 @@ describe("Deep Agents MCP config adapter rollback", () => { }); }); + it("refuses rollback through a symlinked legacy parent", () => { + const legacyConfig = { + mcpServers: { local: { type: "stdio", command: "user-owned" } }, + ui: { theme: "dark" }, + }; + const original = `${JSON.stringify(legacyConfig, null, 2)}\n`; + + const rollback = runDeepAgentsConfigCommand( + buildDeepAgentsMcpRegisterCommand(baseEntry, true, [baseEntry], true), + undefined, + "legacy", + legacyConfig, + 0o600, + { legacyParentSymlink: true }, + ); + + expect(rollback.status).toBe(2); + expect(rollback.stderr).toContain("legacy MCP config parent is unsafe: symbolic link"); + expect(rollback.legacyParentIsSymlink).toBe(true); + expect(rollback.legacyParentTargetText).toBe(original); + }); + it("keeps v2 teardown and rollback isolated from the legacy user file", () => { const managedServer = { type: "http", diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-teardown.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-teardown.ts index 94135102f84..ff758362065 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-teardown.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-teardown.ts @@ -34,7 +34,7 @@ export function buildDeepAgentsMcpRemoveCommand( }; return [ "/opt/venv/bin/python3 -I - <<'PY'", - "import json, os, pathlib, stat, sys, tempfile", + "import json, os, pathlib, stat, sys", `payload = json.loads(${pythonJsonLiteral(payload)})`, `managed_path = pathlib.Path(${JSON.stringify(DEEPAGENTS_MCP_CONFIG_PATH)})`, `legacy_path = pathlib.Path(${JSON.stringify(DEEPAGENTS_LEGACY_MCP_CONFIG_PATH)})`, @@ -64,12 +64,17 @@ export function buildDeepAgentsMcpRemoveCommand( "managed_parent_identity = None", "managed_parent_descriptor = None", "legacy_identity = None", + "legacy_descriptor = None", + "legacy_parent_identity = None", + "legacy_parent_descriptor = None", "def finish(outcome):", " close_managed_projection_descriptors(managed_descriptor, managed_parent_descriptor)", + " close_legacy_config_descriptors(legacy_descriptor, legacy_parent_descriptor)", " print('NEMOCLAW_DEEPAGENTS_MCP_REMOVAL=' + outcome)", " raise SystemExit(0)", "def fail_teardown(message):", " close_managed_projection_descriptors(managed_descriptor, managed_parent_descriptor)", + " close_legacy_config_descriptors(legacy_descriptor, legacy_parent_descriptor)", " print(message, file=sys.stderr)", " raise SystemExit(2)", "def repair_v2_projection(identity, descriptor, parent_descriptor, parent_identity):", @@ -77,26 +82,6 @@ export function buildDeepAgentsMcpRemoveCommand( " write_managed_projection(config_path, {'mcpServers': {}}, identity, descriptor, parent_descriptor, parent_identity)", " except (OSError, ValueError) as exc:", " fail_teardown(f'Refusing unsafe managed MCP v2 repair at {config_path}: {exc}')", - "def write_legacy_data(value):", - " tmp_fd, tmp_name = tempfile.mkstemp(prefix='.nemoclaw-mcp.', dir=config_path.parent)", - " try:", - " os.fchmod(tmp_fd, 0o600)", - " with os.fdopen(tmp_fd, 'w', encoding='utf-8') as tmp_file:", - " json.dump(value, tmp_file, indent=2, sort_keys=True)", - " tmp_file.write('\\n')", - " tmp_file.flush()", - " os.fsync(tmp_file.fileno())", - " assert_legacy_source_stable(config_path, legacy_identity)", - " if legacy_identity is None:", - " os.link(tmp_name, config_path, follow_symlinks=False)", - " os.unlink(tmp_name)", - " else:", - " os.replace(tmp_name, config_path)", - " finally:", - " try:", - " os.unlink(tmp_name)", - " except FileNotFoundError:", - " pass", "if is_v2:", " try:", " raw, managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity = open_managed_projection(config_path, True)", @@ -112,12 +97,14 @@ export function buildDeepAgentsMcpRemoveCommand( " finish('removed')", " fail_teardown(f'Invalid managed MCP v2 projection at {config_path}: {exc}')", "else:", - " if not os.path.lexists(config_path):", - " finish('absent')", " try:", - " data, legacy_identity = read_legacy_config(config_path)", - " except (OSError, UnicodeDecodeError, ValueError):", + " data, legacy_identity, legacy_descriptor, legacy_parent_descriptor, legacy_parent_identity = load_legacy_config_for_update(config_path)", + " except (OSError, UnicodeDecodeError, ValueError) as exc:", + " if str(exc).startswith('legacy MCP config parent is unsafe'):", + " fail_teardown(f'Refusing unsafe legacy MCP teardown at {config_path}: {exc}')", " finish('unowned')", + " if legacy_descriptor is None:", + " finish('absent')", "if not isinstance(data, dict):", " if is_v2 and payload['force']:", " repair_v2_projection(managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity)", @@ -163,17 +150,16 @@ export function buildDeepAgentsMcpRemoveCommand( " write_managed_projection(config_path, data, managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity)", " persisted = read_managed_projection(config_path)[0]", " else:", - " write_legacy_data(data)", - " persisted = read_legacy_config(config_path)[0]", + " persisted = write_legacy_config(config_path, data, legacy_identity, legacy_descriptor, legacy_parent_descriptor, legacy_parent_identity)", " except (OSError, UnicodeDecodeError, ValueError) as exc:", " fail_teardown(f'MCP teardown mutation failed at {config_path}: {exc}')", " if persisted != data:", " fail_teardown(f'MCP teardown verification failed at {config_path}')", "else:", - " assert_legacy_source_stable(config_path, legacy_identity)", - " config_path.unlink()", - " if os.path.lexists(config_path):", - " fail_teardown(f'Managed MCP teardown verification failed at {config_path}')", + " try:", + " remove_legacy_config(config_path, legacy_identity, legacy_descriptor, legacy_parent_descriptor, legacy_parent_identity)", + " except (OSError, ValueError) as exc:", + " fail_teardown(f'MCP teardown mutation failed at {config_path}: {exc}')", "finish('removed')", "PY", ].join("\n"); diff --git a/src/lib/actions/sandbox/mcp-bridge/deepagents-legacy-config.ts b/src/lib/actions/sandbox/mcp-bridge/deepagents-legacy-config.ts index 248f4caa262..c1120eb33f6 100644 --- a/src/lib/actions/sandbox/mcp-bridge/deepagents-legacy-config.ts +++ b/src/lib/actions/sandbox/mcp-bridge/deepagents-legacy-config.ts @@ -7,42 +7,138 @@ export const DEEPAGENTS_LEGACY_CONFIG_HELPERS = [ "LEGACY_MCP_MAX_BYTES = 262144", "def legacy_fingerprint(metadata):", " return (metadata.st_dev, metadata.st_ino, metadata.st_size, metadata.st_mtime_ns, metadata.st_ctime_ns, metadata.st_mode, metadata.st_nlink, metadata.st_uid)", - "def read_legacy_config(path):", + "def validate_legacy_descriptor_path(entry_name, parent_descriptor, descriptor):", + " opened = os.fstat(descriptor)", + " linked = os.stat(entry_name, dir_fd=parent_descriptor, follow_symlinks=False)", + " safe = (stat.S_ISREG(opened.st_mode) and opened.st_uid == os.getuid() and stat.S_IMODE(opened.st_mode) == 0o600 and opened.st_nlink == 1 and (opened.st_dev, opened.st_ino) == (linked.st_dev, linked.st_ino))", + " if not safe:", + " raise ValueError('legacy MCP config has unsafe ownership, mode, type, or links')", + " return legacy_fingerprint(opened)", + "def read_legacy_descriptor(entry_name, parent_descriptor, descriptor):", + " before = os.fstat(descriptor)", + " validate_legacy_descriptor_path(entry_name, parent_descriptor, descriptor)", + " if before.st_size <= 0 or before.st_size > LEGACY_MCP_MAX_BYTES:", + " raise ValueError('legacy MCP config has invalid size')", + " chunks = []", + " remaining = before.st_size", + " while remaining:", + " chunk = os.read(descriptor, remaining)", + " if not chunk:", + " break", + " chunks.append(chunk)", + " remaining -= len(chunk)", + " after = os.fstat(descriptor)", + " linked_after = os.stat(entry_name, dir_fd=parent_descriptor, follow_symlinks=False)", + " stable = (legacy_fingerprint(before) == legacy_fingerprint(after) and legacy_fingerprint(after) == legacy_fingerprint(linked_after))", + " if remaining or not stable:", + " raise ValueError('legacy MCP config changed while reading')", + " return strict_json_loads(b''.join(chunks).decode('utf-8')), legacy_fingerprint(after)", + "def close_legacy_config_descriptors(descriptor, parent_descriptor):", + " close_managed_projection_descriptors(descriptor, parent_descriptor)", + "def load_legacy_config_for_update(path, create_parent=False):", + " unsafe_parent_prefix = 'legacy MCP config parent is unsafe'", + " try:", + " parent_descriptor, parent_identity, _ = open_managed_parent(path, create_parent, unsafe_parent_prefix)", + " except FileNotFoundError:", + " return {}, None, None, None, None", + " entry_name = path.name", " flags = os.O_RDONLY | os.O_CLOEXEC | os.O_NONBLOCK | os.O_NOFOLLOW", - " descriptor = os.open(path, flags)", " try:", - " before = os.fstat(descriptor)", - " linked = os.stat(path, follow_symlinks=False)", - " safe = (stat.S_ISREG(before.st_mode) and before.st_uid == os.getuid() and stat.S_IMODE(before.st_mode) == 0o600 and before.st_nlink == 1 and (before.st_dev, before.st_ino) == (linked.st_dev, linked.st_ino))", - " if not safe:", - " raise ValueError('legacy MCP config has unsafe ownership, mode, type, or links')", - " if before.st_size <= 0 or before.st_size > LEGACY_MCP_MAX_BYTES:", - " raise ValueError('legacy MCP config has invalid size')", - " chunks = []", - " remaining = before.st_size", - " while remaining:", - " chunk = os.read(descriptor, remaining)", - " if not chunk:", - " break", - " chunks.append(chunk)", - " remaining -= len(chunk)", - " after = os.fstat(descriptor)", - " linked_after = os.stat(path, follow_symlinks=False)", - " stable = (legacy_fingerprint(before) == legacy_fingerprint(after) and legacy_fingerprint(after) == legacy_fingerprint(linked_after))", - " if remaining or not stable:", - " raise ValueError('legacy MCP config changed while reading')", + " try:", + " descriptor = os.open(entry_name, flags, dir_fd=parent_descriptor)", + " except FileNotFoundError:", + " assert_managed_source_stable(entry_name, parent_descriptor, None)", + " assert_managed_parent_stable(path, parent_identity, unsafe_parent_prefix)", + " return {}, None, None, parent_descriptor, parent_identity", + " try:", + " data, identity = read_legacy_descriptor(entry_name, parent_descriptor, descriptor)", + " assert_managed_parent_stable(path, parent_identity, unsafe_parent_prefix)", + " return data, identity, descriptor, parent_descriptor, parent_identity", + " except Exception:", + " os.close(descriptor)", + " raise", + " except Exception:", + " os.close(parent_descriptor)", + " raise", + "def read_legacy_config(path):", + " data, identity, descriptor, parent_descriptor, _ = load_legacy_config_for_update(path)", + " try:", + " if descriptor is None:", + " raise FileNotFoundError(path)", + " return data, identity", " finally:", - " os.close(descriptor)", - " raw = b''.join(chunks).decode('utf-8')", - " data = strict_json_loads(raw)", - " return data, legacy_fingerprint(before)", - "def assert_legacy_source_stable(path, identity):", + " close_legacy_config_descriptors(descriptor, parent_descriptor)", + "def assert_legacy_source_stable(path, identity, descriptor, parent_descriptor, parent_identity):", + " if parent_descriptor is None or parent_identity is None:", + " raise ValueError('legacy MCP config parent descriptor is unavailable')", + " assert_managed_parent_stable(path, parent_identity, 'legacy MCP config parent is unsafe')", + " assert_managed_source_stable(path.name, parent_descriptor, identity)", " if identity is None:", - " if os.path.lexists(path):", - " raise ValueError('legacy MCP config appeared during mutation')", + " if descriptor is not None:", + " raise ValueError('unexpected legacy MCP config descriptor')", " return", - " current = os.stat(path, follow_symlinks=False)", - " safe = (stat.S_ISREG(current.st_mode) and current.st_uid == os.getuid() and stat.S_IMODE(current.st_mode) == 0o600 and current.st_nlink == 1 and legacy_fingerprint(current) == identity)", - " if not safe:", + " if descriptor is None or validate_legacy_descriptor_path(path.name, parent_descriptor, descriptor) != identity:", " raise ValueError('legacy MCP config changed before mutation')", + "def legacy_config_bytes(value):", + " payload = (json.dumps(value, indent=2, sort_keys=True) + '\\n').encode('utf-8')", + " if not payload or len(payload) > LEGACY_MCP_MAX_BYTES:", + " raise ValueError('legacy MCP config has invalid rendered size')", + " return payload", + "def create_legacy_config_temp(parent_descriptor):", + " flags = os.O_RDWR | os.O_CREAT | os.O_EXCL | os.O_CLOEXEC | os.O_NOFOLLOW", + " for _ in range(16):", + " name = '.nemoclaw-mcp.' + os.urandom(16).hex()", + " try:", + " return os.open(name, flags, 0o600, dir_fd=parent_descriptor), name", + " except FileExistsError:", + " pass", + " raise FileExistsError('could not allocate legacy MCP config temporary file')", + "def write_legacy_config(path, value, identity, descriptor, parent_descriptor, parent_identity):", + " assert_legacy_source_stable(path, identity, descriptor, parent_descriptor, parent_identity)", + " payload = legacy_config_bytes(value)", + " tmp_fd, tmp_name = create_legacy_config_temp(parent_descriptor)", + " try:", + " offset = 0", + " while offset < len(payload):", + " written = os.write(tmp_fd, payload[offset:])", + " if written <= 0:", + " raise OSError('legacy MCP config write made no progress')", + " offset += written", + " os.fsync(tmp_fd)", + " os.close(tmp_fd)", + " tmp_fd = None", + " assert_legacy_source_stable(path, identity, descriptor, parent_descriptor, parent_identity)", + " if identity is None:", + " try:", + " os.link(tmp_name, path.name, src_dir_fd=parent_descriptor, dst_dir_fd=parent_descriptor, follow_symlinks=False)", + " except FileExistsError as exc:", + " raise ValueError('legacy MCP config appeared during publication') from exc", + " os.unlink(tmp_name, dir_fd=parent_descriptor)", + " else:", + " os.replace(tmp_name, path.name, src_dir_fd=parent_descriptor, dst_dir_fd=parent_descriptor)", + " assert_managed_parent_stable(path, parent_identity, 'legacy MCP config parent is unsafe')", + " verification = os.open(path.name, os.O_RDONLY | os.O_CLOEXEC | os.O_NONBLOCK | os.O_NOFOLLOW, dir_fd=parent_descriptor)", + " try:", + " persisted, _ = read_legacy_descriptor(path.name, parent_descriptor, verification)", + " finally:", + " os.close(verification)", + " assert_managed_parent_stable(path, parent_identity, 'legacy MCP config parent is unsafe')", + " return persisted", + " finally:", + " if tmp_fd is not None:", + " os.close(tmp_fd)", + " try:", + " os.unlink(tmp_name, dir_fd=parent_descriptor)", + " except FileNotFoundError:", + " pass", + "def remove_legacy_config(path, identity, descriptor, parent_descriptor, parent_identity):", + " assert_legacy_source_stable(path, identity, descriptor, parent_descriptor, parent_identity)", + " os.unlink(path.name, dir_fd=parent_descriptor)", + " try:", + " os.stat(path.name, dir_fd=parent_descriptor, follow_symlinks=False)", + " except FileNotFoundError:", + " pass", + " else:", + " raise ValueError('legacy MCP config removal verification failed')", + " assert_managed_parent_stable(path, parent_identity, 'legacy MCP config parent is unsafe')", ]; diff --git a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts index 41d1294e8d9..1f43ee8c7ad 100644 --- a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts +++ b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts @@ -34,6 +34,8 @@ export interface DeepAgentsConfigCommandResult { legacyConfigExists: boolean; legacyConfig: Record | null; legacyConfigText: string | null; + legacyParentIsSymlink: boolean; + legacyParentTargetText: string | null; managedParentIsSymlink: boolean; managedParentTargetText: string | null; managedRaceIterations: number; @@ -46,6 +48,7 @@ export interface DeepAgentsManagedFixtureOptions { danglingSymlink?: boolean; directory?: boolean; fifo?: boolean; + legacyParentSymlink?: boolean; mode?: number; parentSymlink?: boolean; raceAbsentPublication?: string; @@ -326,6 +329,9 @@ export function runDeepAgentsConfigCommand( const managedTargetReadReady = path.join(tmp, "managed-target-read-ready"); const managedTargetReadAccess = path.join(tmp, "managed-target-read-accessed"); const legacyConfigPath = path.join(tmp, ".deepagents", ".mcp.json"); + const legacyParentPath = path.dirname(legacyConfigPath); + const legacyParentTarget = path.join(tmp, "legacy-parent-target"); + const legacyParentTargetConfig = path.join(legacyParentTarget, path.basename(legacyConfigPath)); const initializeConfig = ( target: string, value: Record | string | undefined, @@ -340,6 +346,20 @@ export function runDeepAgentsConfigCommand( ); }; const managedSymlink = managedOptions.symlink === true || managedOptions.danglingSymlink === true; + if ( + managedOptions.legacyParentSymlink && + (initialConfig !== undefined || + managedOptions.danglingSymlink || + managedOptions.directory || + managedOptions.fifo || + managedOptions.parentSymlink || + managedOptions.raceAbsentPublication !== undefined || + managedOptions.raceProjection !== undefined || + managedOptions.socket || + managedOptions.symlink) + ) { + throw new Error("legacyParentSymlink cannot share the managed projection parent fixture"); + } const managedTargetReadPath = managedOptions.targetReadProbe ? managedOptions.parentSymlink ? managedParentTargetConfig @@ -387,8 +407,13 @@ export function runDeepAgentsConfigCommand( if (managedOptions.raceAbsentPublication !== undefined) { initializeConfig(managedSymlinkTarget, managedOptions.raceAbsentPublication); } - initializeConfig(legacyConfigPath, initialLegacyConfig); - if (initialLegacyConfig !== undefined) fs.chmodSync(legacyConfigPath, initialLegacyMode); + if (managedOptions.legacyParentSymlink) { + fs.mkdirSync(legacyParentTarget, { recursive: true }); + initializeConfig(legacyParentTargetConfig, initialLegacyConfig, initialLegacyMode); + fs.symlinkSync(legacyParentTarget, legacyParentPath); + } else { + initializeConfig(legacyConfigPath, initialLegacyConfig, initialLegacyMode); + } let targetReadProbe: ReturnType | null = null; let managedRace: ManagedFixtureProcess | null = null; let socketFixture: ManagedFixtureProcess | null = null; @@ -469,6 +494,12 @@ export function runDeepAgentsConfigCommand( // A missing projection parent cannot redirect the managed path. } const legacyConfigExists = fs.existsSync(legacyConfigPath); + let legacyParentIsSymlink = false; + try { + legacyParentIsSymlink = fs.lstatSync(legacyParentPath).isSymbolicLink(); + } catch { + // A missing legacy parent cannot redirect the legacy config path. + } const configIsFifo = configStat?.isFIFO() === true; const configIsSocket = configStat?.isSocket() === true; const configIsSymlink = configStat?.isSymbolicLink() === true; @@ -501,6 +532,7 @@ export function runDeepAgentsConfigCommand( const managedTargetReadAccessed = managedTargetReadPath !== null && waitForPath(managedTargetReadAccess, 200); const legacyConfigText = legacyConfigExists ? fs.readFileSync(legacyConfigPath, "utf-8") : null; + const legacyParentTargetText = readRegularFile(legacyParentTargetConfig); const parseConfigText = (text: string | null): Record | null => { if (!text) return null; try { @@ -526,6 +558,8 @@ export function runDeepAgentsConfigCommand( legacyConfigExists, legacyConfig: parseConfigText(legacyConfigText), legacyConfigText, + legacyParentIsSymlink, + legacyParentTargetText, managedParentIsSymlink, managedParentTargetText, managedRaceIterations, From dbf5c9fd68d07fb32c99d6cdc9e97d483eaa7ea7 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Wed, 2 Sep 2026 05:06:47 -0700 Subject: [PATCH 34/49] test(policy): mock typed read failure --- test/runtime/policy/policies.test.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/test/runtime/policy/policies.test.ts b/test/runtime/policy/policies.test.ts index 283d2c912a8..3a5c1132e1b 100644 --- a/test/runtime/policy/policies.test.ts +++ b/test/runtime/policy/policies.test.ts @@ -616,10 +616,16 @@ exit 1 it("applyPreset does not create temp dirs when bounded policy observation loses OpenShell", () => { const policyTempPrefix = path.join(os.tmpdir(), "nemoclaw-policy-"); - const resolveSpy = vi - .spyOn(resolveOpenshellModule, "resolveOpenshell") - .mockReturnValueOnce(fakeOpenshell) - .mockReturnValue(null); + const policyReadSpy = vi + .spyOn(policyReaderModule.syncCliOpenShellSandboxPolicyReader, "readSandboxPolicy") + .mockReturnValue({ + ok: false, + error: { + kind: "command", + reason: "failed", + message: "OpenShell became unavailable during the bounded policy read.", + }, + }); const mkdtempSpy = vi.spyOn(fs, "mkdtempSync"); const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); @@ -639,7 +645,7 @@ exit 1 mkdtempSpy.mock.calls.filter(([prefix]) => String(prefix).startsWith(policyTempPrefix)), ).toEqual([]); } finally { - resolveSpy.mockRestore(); + policyReadSpy.mockRestore(); mkdtempSpy.mockRestore(); errSpy.mockRestore(); logSpy.mockRestore(); From 07d8cb43fb2ef6c239f7b9193cc7842273ea110e Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Wed, 2 Sep 2026 05:30:12 -0700 Subject: [PATCH 35/49] refactor(mcp): share Deep Agents runtime selection --- .../mcp-bridge-adapter-deepagents-legacy.ts | 15 +--- ...-adapter-deepagents-runtime-guards.test.ts | 84 ++++++++++++------- .../mcp-bridge-adapter-deepagents-teardown.ts | 15 +--- .../sandbox/mcp-bridge-adapter-status.ts | 15 +--- .../mcp-bridge/deepagents-legacy-config.ts | 16 ++++ 5 files changed, 80 insertions(+), 65 deletions(-) diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy.ts index ae22f2e3c1e..659c8ac2996 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy.ts @@ -10,6 +10,7 @@ import { import { DEEPAGENTS_LEGACY_CONFIG_HELPERS, DEEPAGENTS_LEGACY_MCP_CONFIG_PATH, + DEEPAGENTS_RUNTIME_SELECTION_HELPERS, } from "./mcp-bridge/deepagents-legacy-config"; import { MANAGED_HTTP_SERVER_MATCH_HELPERS, @@ -47,19 +48,9 @@ export function buildDeepAgentsMcpRollbackRegisterCommand( ...DEEPAGENTS_STRICT_JSON_HELPERS, ...DEEPAGENTS_MANAGED_PROJECTION_HELPERS, ...DEEPAGENTS_LEGACY_CONFIG_HELPERS, + ...DEEPAGENTS_RUNTIME_SELECTION_HELPERS, ...MANAGED_HTTP_SERVER_MATCH_HELPERS, - `runtime_kind = "auto"`, - "if runtime_kind == 'auto':", - " runtime_kind = 'unknown'", - " try:", - " from deepagents_code import _nemoclaw_managed as managed", - " runtime_path = str(getattr(managed, '_MCP_CONFIG_FILE', ''))", - " if runtime_path == str(managed_path):", - " runtime_kind = 'v2'", - " elif runtime_path == str(legacy_path):", - " runtime_kind = 'legacy'", - " except Exception:", - " pass", + "runtime_kind = select_deepagents_runtime_kind('auto', managed_path, legacy_path)", "if runtime_kind not in ('v2', 'legacy'):", " print('Could not identify the managed Deep Agents MCP runtime; refusing rollback', file=sys.stderr)", " raise SystemExit(2)", diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-runtime-guards.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-runtime-guards.test.ts index 73ad1997741..49958524dc3 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-runtime-guards.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-runtime-guards.test.ts @@ -10,38 +10,64 @@ import { buildDeepAgentsMcpRegisterCommand, buildDeepAgentsMcpRemoveCommand, } from "./mcp-bridge-adapter-deepagents"; +import { buildDeepAgentsMcpStatusCommand } from "./mcp-bridge-adapter-status"; describe("Deep Agents MCP config adapter runtime guards", () => { - it.each( - Array.from( - [ - buildDeepAgentsMcpRemoveCommand(baseEntry, true, true), - buildDeepAgentsMcpRegisterCommand(baseEntry, true, [baseEntry], true), - ], - (value) => [value], - ), - )( - "fails closed without touching either config when the runtime generation is unknown [case %#]", - (command) => { - const v2Config = { - mcpServers: { - github: { - type: "http", - url: baseEntry.url, - headers: { Authorization: "Bearer openshell:resolve:env:GITHUB_TOKEN" }, - }, - }, - }; - const legacyConfig = { - mcpServers: { local: { type: "stdio", command: "user-owned" } }, - ui: { theme: "dark" }, - }; + const managedServer = { + type: "http", + url: baseEntry.url, + headers: { Authorization: "Bearer openshell:resolve:env:GITHUB_TOKEN" }, + }; + const userServer = { type: "stdio", command: "user-owned" }; + const registeredV2Config = { mcpServers: { github: managedServer } }; + const registeredLegacyConfig = { + mcpServers: { github: managedServer, local: userServer }, + ui: { theme: "dark" }, + }; + const driftedLegacyConfig = { + mcpServers: { github: { type: "http", url: "https://user.example/mcp" } }, + }; + const emptyV2Config = { mcpServers: {} }; + const emptyLegacyConfig = { + mcpServers: { local: userServer }, + ui: { theme: "dark" }, + }; + const statusCommand = buildDeepAgentsMcpStatusCommand(baseEntry); + const removalCommand = buildDeepAgentsMcpRemoveCommand(baseEntry, true, true); + const rollbackCommand = buildDeepAgentsMcpRegisterCommand(baseEntry, true, [baseEntry], true); + const runtimeSelectionCases = [ + ["status", "v2", statusCommand, registeredV2Config, driftedLegacyConfig, [0, "registered", "", true, true]], + ["status", "legacy", statusCommand, registeredV2Config, driftedLegacyConfig, [0, "mismatch", "", true, true]], + ["status", "unknown", statusCommand, registeredV2Config, driftedLegacyConfig, [2, "", "Could not identify the managed Deep Agents MCP runtime", true, true]], + ["adaptive teardown", "v2", removalCommand, registeredV2Config, registeredLegacyConfig, [0, "NEMOCLAW_DEEPAGENTS_MCP_REMOVAL=removed", "", false, true]], + ["adaptive teardown", "legacy", removalCommand, registeredV2Config, registeredLegacyConfig, [0, "NEMOCLAW_DEEPAGENTS_MCP_REMOVAL=removed", "", true, false]], + ["adaptive teardown", "unknown", removalCommand, registeredV2Config, registeredLegacyConfig, [2, "", "Could not identify the managed Deep Agents MCP runtime; refusing teardown", true, true]], + ["rollback", "v2", rollbackCommand, emptyV2Config, emptyLegacyConfig, [0, "NEMOCLAW_DEEPAGENTS_MCP_ROLLBACK_RESTORED=1", "", true, false]], + ["rollback", "legacy", rollbackCommand, emptyV2Config, emptyLegacyConfig, [0, "NEMOCLAW_DEEPAGENTS_MCP_ROLLBACK_RESTORED=1", "", false, true]], + ["rollback", "unknown", rollbackCommand, emptyV2Config, emptyLegacyConfig, [2, "", "Could not identify the managed Deep Agents MCP runtime; refusing rollback", false, false]], + ] as const; + const hasGithubServer = (config: Record | null): boolean => { + const servers = config?.mcpServers; + return typeof servers === "object" && servers !== null && Object.hasOwn(servers, "github"); + }; + + it.each(runtimeSelectionCases)( + "uses the shared runtime marker for %s: %s", + (_operation, runtimeKind, command, initialConfig, initialLegacyConfig, expected) => { + const result = runDeepAgentsConfigCommand( + command, + initialConfig, + runtimeKind, + initialLegacyConfig, + ); - const result = runDeepAgentsConfigCommand(command, v2Config, "unknown", legacyConfig); - expect(result.status).toBe(2); - expect(result.stderr).toContain("Could not identify the managed Deep Agents MCP runtime"); - expect(result.config).toEqual(v2Config); - expect(result.legacyConfig).toEqual(legacyConfig); + expect([ + result.status, + result.stdout.trim(), + result.stderr.trim(), + hasGithubServer(result.config), + hasGithubServer(result.legacyConfig), + ]).toEqual(expected); }, ); diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-teardown.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-teardown.ts index ff758362065..d62099bdfb7 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-teardown.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-teardown.ts @@ -6,6 +6,7 @@ import { runDeepAgentsAdapterCommand } from "./mcp-bridge-adapter-deepagents-com import { DEEPAGENTS_LEGACY_CONFIG_HELPERS, DEEPAGENTS_LEGACY_MCP_CONFIG_PATH, + DEEPAGENTS_RUNTIME_SELECTION_HELPERS, } from "./mcp-bridge/deepagents-legacy-config"; import { DEEPAGENTS_MANAGED_PROJECTION_HELPERS, @@ -41,19 +42,9 @@ export function buildDeepAgentsMcpRemoveCommand( ...DEEPAGENTS_STRICT_JSON_HELPERS, ...DEEPAGENTS_MANAGED_PROJECTION_HELPERS, ...DEEPAGENTS_LEGACY_CONFIG_HELPERS, + ...DEEPAGENTS_RUNTIME_SELECTION_HELPERS, ...MANAGED_HTTP_SERVER_MATCH_HELPERS, - `runtime_kind = "${adaptiveTeardown ? "auto" : "v2"}"`, - "if runtime_kind == 'auto':", - " runtime_kind = 'unknown'", - " try:", - " from deepagents_code import _nemoclaw_managed as managed", - " runtime_path = str(getattr(managed, '_MCP_CONFIG_FILE', ''))", - " if runtime_path == str(managed_path):", - " runtime_kind = 'v2'", - " elif runtime_path == str(legacy_path):", - " runtime_kind = 'legacy'", - " except Exception:", - " pass", + `runtime_kind = select_deepagents_runtime_kind("${adaptiveTeardown ? "auto" : "v2"}", managed_path, legacy_path)`, "if runtime_kind not in ('v2', 'legacy'):", " print('Could not identify the managed Deep Agents MCP runtime; refusing teardown', file=sys.stderr)", " raise SystemExit(2)", diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-status.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-status.ts index 3bca5c8c498..a6c9ceb281c 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-status.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-status.ts @@ -6,6 +6,7 @@ import type { McpAttachedCredentialRevision } from "./mcp-bridge-provider-readin import { DEEPAGENTS_LEGACY_CONFIG_HELPERS, DEEPAGENTS_LEGACY_MCP_CONFIG_PATH, + DEEPAGENTS_RUNTIME_SELECTION_HELPERS, } from "./mcp-bridge/deepagents-legacy-config"; import { DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS, @@ -242,19 +243,9 @@ export function buildDeepAgentsMcpStatusCommand( ...DEEPAGENTS_STRICT_JSON_HELPERS, ...DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS, ...DEEPAGENTS_LEGACY_CONFIG_HELPERS, + ...DEEPAGENTS_RUNTIME_SELECTION_HELPERS, ...MANAGED_HTTP_SERVER_MATCH_HELPERS, - `runtime_kind = "auto"`, - "if runtime_kind == 'auto':", - " runtime_kind = 'unknown'", - " try:", - " from deepagents_code import _nemoclaw_managed as managed", - " runtime_path = str(getattr(managed, '_MCP_CONFIG_FILE', ''))", - " if runtime_path == str(managed_path):", - " runtime_kind = 'v2'", - " elif runtime_path == str(legacy_path):", - " runtime_kind = 'legacy'", - " except Exception:", - " pass", + "runtime_kind = select_deepagents_runtime_kind('auto', managed_path, legacy_path)", "if runtime_kind not in ('v2', 'legacy'):", " print('Could not identify the managed Deep Agents MCP runtime', file=sys.stderr)", " raise SystemExit(2)", diff --git a/src/lib/actions/sandbox/mcp-bridge/deepagents-legacy-config.ts b/src/lib/actions/sandbox/mcp-bridge/deepagents-legacy-config.ts index c1120eb33f6..ab13b4eee74 100644 --- a/src/lib/actions/sandbox/mcp-bridge/deepagents-legacy-config.ts +++ b/src/lib/actions/sandbox/mcp-bridge/deepagents-legacy-config.ts @@ -3,6 +3,22 @@ export const DEEPAGENTS_LEGACY_MCP_CONFIG_PATH = "/sandbox/.deepagents/.mcp.json"; +export const DEEPAGENTS_RUNTIME_SELECTION_HELPERS = [ + "def select_deepagents_runtime_kind(requested_kind, managed_path, legacy_path):", + " if requested_kind != 'auto':", + " return requested_kind", + " try:", + " from deepagents_code import _nemoclaw_managed as managed", + " runtime_path = str(getattr(managed, '_MCP_CONFIG_FILE', ''))", + " if runtime_path == str(managed_path):", + " return 'v2'", + " if runtime_path == str(legacy_path):", + " return 'legacy'", + " except Exception:", + " pass", + " return 'unknown'", +]; + export const DEEPAGENTS_LEGACY_CONFIG_HELPERS = [ "LEGACY_MCP_MAX_BYTES = 262144", "def legacy_fingerprint(metadata):", From db783abb8844193dc20932c96d7c37ea46df17c3 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Wed, 2 Sep 2026 05:57:18 -0700 Subject: [PATCH 36/49] fix(mcp): narrow projection status safety Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- docs/manage-sandboxes/manage-mcp-servers.mdx | 5 +- ...adapter-deepagents-legacy-teardown.test.ts | 27 - .../mcp-bridge-adapter-deepagents-legacy.ts | 62 ++- ...idge-adapter-deepagents-projection.test.ts | 324 ++++++------ ...cp-bridge-adapter-deepagents-projection.ts | 294 ++++------- ...-bridge-adapter-deepagents-registration.ts | 10 +- ...bridge-adapter-deepagents-rollback.test.ts | 22 - ...-adapter-deepagents-runtime-guards.test.ts | 84 ++- .../mcp-bridge-adapter-deepagents-teardown.ts | 85 ++-- .../sandbox/mcp-bridge-adapter-status.ts | 15 +- .../mcp-bridge-status-resolution.test.ts | 36 +- .../mcp-bridge/deepagents-legacy-config.ts | 176 ++----- test/e2e-test.sh | 96 +--- .../mcp-bridge-adapter-deepagents-fixture.ts | 480 +++++++----------- test/runtime/policy/policies.test.ts | 16 +- 15 files changed, 692 insertions(+), 1040 deletions(-) diff --git a/docs/manage-sandboxes/manage-mcp-servers.mdx b/docs/manage-sandboxes/manage-mcp-servers.mdx index 4a579130499..8a564cd373d 100644 --- a/docs/manage-sandboxes/manage-mcp-servers.mdx +++ b/docs/manage-sandboxes/manage-mcp-servers.mdx @@ -42,9 +42,8 @@ For a trusted private server, status resolves the endpoint without changing mana Before it reads `/sandbox/.deepagents/.nemoclaw-mcp.json`, Deep Agents Code v2 `mcp status` checks the path type. It does not follow a final symbolic link or read from a FIFO. If the path is a symbolic link, FIFO, or another non-regular file, the command names the path type, writes no status output, and exits with status `2`. -The diagnostic starts with `Unsafe managed Deep Agents MCP projection path:` and names a type such as `symbolic link`, `FIFO`, `non-regular file`, or `non-directory parent`. -Other Deep Agents inspection failures can also exit with status `2`. -Use the standard error diagnostic to distinguish an unsafe path from another inspection failure. +The diagnostic starts with `Unsafe managed Deep Agents MCP projection path:` and names a type such as `symbolic link`, `FIFO`, or `non-regular file`. +Exit status `2` is specific to this unsafe path-type result. Deep Agents Code v1 reads the legacy `.deepagents/.mcp.json` path and does not expose these type-specific diagnostics. diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy-teardown.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy-teardown.test.ts index 9c315445314..1a9bcfa3735 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy-teardown.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy-teardown.test.ts @@ -80,31 +80,4 @@ describe("Deep Agents MCP config adapter legacy teardown", () => { ui: { theme: "dark" }, }); }); - - it("refuses removal through a symlinked legacy parent", () => { - const legacyConfig = { - mcpServers: { - github: { - type: "http", - url: baseEntry.url, - headers: { Authorization: "Bearer openshell:resolve:env:GITHUB_TOKEN" }, - }, - }, - }; - const original = `${JSON.stringify(legacyConfig, null, 2)}\n`; - - const removal = runDeepAgentsConfigCommand( - buildDeepAgentsMcpRemoveCommand(baseEntry, false, true), - undefined, - "legacy", - legacyConfig, - 0o600, - { legacyParentSymlink: true }, - ); - - expect(removal.status).toBe(2); - expect(removal.stderr).toContain("legacy MCP config parent is unsafe: symbolic link"); - expect(removal.legacyParentIsSymlink).toBe(true); - expect(removal.legacyParentTargetText).toBe(original); - }); }); diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy.ts index 659c8ac2996..50251a1de33 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-legacy.ts @@ -10,7 +10,6 @@ import { import { DEEPAGENTS_LEGACY_CONFIG_HELPERS, DEEPAGENTS_LEGACY_MCP_CONFIG_PATH, - DEEPAGENTS_RUNTIME_SELECTION_HELPERS, } from "./mcp-bridge/deepagents-legacy-config"; import { MANAGED_HTTP_SERVER_MATCH_HELPERS, @@ -41,16 +40,26 @@ export function buildDeepAgentsMcpRollbackRegisterCommand( }; return [ "/opt/venv/bin/python3 -I - <<'PY'", - "import json, os, pathlib, stat, sys", + "import json, os, pathlib, stat, sys, tempfile", `payload = json.loads(${pythonJsonLiteral(payload)})`, `managed_path = pathlib.Path(${JSON.stringify(DEEPAGENTS_MCP_CONFIG_PATH)})`, `legacy_path = pathlib.Path(${JSON.stringify(DEEPAGENTS_LEGACY_MCP_CONFIG_PATH)})`, ...DEEPAGENTS_STRICT_JSON_HELPERS, ...DEEPAGENTS_MANAGED_PROJECTION_HELPERS, ...DEEPAGENTS_LEGACY_CONFIG_HELPERS, - ...DEEPAGENTS_RUNTIME_SELECTION_HELPERS, ...MANAGED_HTTP_SERVER_MATCH_HELPERS, - "runtime_kind = select_deepagents_runtime_kind('auto', managed_path, legacy_path)", + `runtime_kind = "auto" # NEMOCLAW_DEEPAGENTS_RUNTIME_TEST_ANCHOR`, + "if runtime_kind == 'auto':", + " runtime_kind = 'unknown'", + " try:", + " from deepagents_code import _nemoclaw_managed as managed", + " runtime_path = str(getattr(managed, '_MCP_CONFIG_FILE', ''))", + " if runtime_path == str(managed_path):", + " runtime_kind = 'v2'", + " elif runtime_path == str(legacy_path):", + " runtime_kind = 'legacy'", + " except Exception:", + " pass", "if runtime_kind not in ('v2', 'legacy'):", " print('Could not identify the managed Deep Agents MCP runtime; refusing rollback', file=sys.stderr)", " raise SystemExit(2)", @@ -62,22 +71,16 @@ export function buildDeepAgentsMcpRollbackRegisterCommand( "data = {}", "managed_identity = None", "managed_descriptor = None", - "managed_parent_identity = None", - "managed_parent_descriptor = None", "legacy_identity = None", - "legacy_descriptor = None", - "legacy_parent_identity = None", - "legacy_parent_descriptor = None", "def fail_rollback(message):", - " close_managed_projection_descriptors(managed_descriptor, managed_parent_descriptor)", - " close_legacy_config_descriptors(legacy_descriptor, legacy_parent_descriptor)", + " close_managed_projection_descriptor(managed_descriptor)", " print(message, file=sys.stderr)", " raise SystemExit(2)", "try:", " if is_v2:", - " data, managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity = load_managed_projection_for_update(config_path)", - " else:", - " data, legacy_identity, legacy_descriptor, legacy_parent_descriptor, legacy_parent_identity = load_legacy_config_for_update(config_path, True)", + " data, managed_identity, managed_descriptor = load_managed_projection_for_update(config_path)", + " elif os.path.lexists(config_path):", + " data, legacy_identity = read_legacy_config(config_path)", "except (OSError, UnicodeDecodeError, ValueError) as exc:", " fail_rollback(f'Invalid managed MCP rollback state at {config_path}: {exc}')", "if not isinstance(data, dict):", @@ -105,17 +108,36 @@ export function buildDeepAgentsMcpRollbackRegisterCommand( " if payload['server'] in servers and current != payload['expected']:", " fail_rollback(f'Refusing to overwrite user-owned legacy MCP server at {config_path}')", " servers[payload['server']] = payload['expected']", + "config_path.parent.mkdir(parents=True, exist_ok=True)", "if not is_v2:", + " tmp_fd, tmp_name = tempfile.mkstemp(prefix='.nemoclaw-mcp.', dir=config_path.parent)", " try:", - " persisted = write_legacy_config(config_path, data, legacy_identity, legacy_descriptor, legacy_parent_descriptor, legacy_parent_identity)", - " except (OSError, ValueError) as exc:", - " fail_rollback(f'Could not publish managed MCP rollback state at {config_path}: {exc}')", + " os.fchmod(tmp_fd, 0o600)", + " with os.fdopen(tmp_fd, 'w', encoding='utf-8') as tmp_file:", + " json.dump(data, tmp_file, indent=2, sort_keys=True)", + " tmp_file.write('\\n')", + " tmp_file.flush()", + " os.fsync(tmp_file.fileno())", + " assert_legacy_source_stable(config_path, legacy_identity)", + " if legacy_identity is None:", + " os.link(tmp_name, config_path, follow_symlinks=False)", + " os.unlink(tmp_name)", + " else:", + " os.replace(tmp_name, config_path)", + " finally:", + " try:", + " os.unlink(tmp_name)", + " except FileNotFoundError:", + " pass", "else:", " try:", - " write_managed_projection(config_path, data, managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity)", - " persisted = read_managed_projection(config_path)[0]", + " write_managed_projection(config_path, data, managed_identity, managed_descriptor)", " except (OSError, ValueError) as exc:", " fail_rollback(f'Could not publish managed MCP rollback state at {config_path}: {exc}')", + "try:", + " persisted = read_managed_projection(config_path)[0] if is_v2 else read_legacy_config(config_path)[0]", + "except (OSError, UnicodeDecodeError, ValueError) as exc:", + " fail_rollback(f'Could not verify managed MCP rollback state at {config_path}: {exc}')", "if is_v2:", " persisted_servers = persisted.get('mcpServers') if isinstance(persisted, dict) else None", " restored = isinstance(persisted_servers, dict) and set(persisted_servers) == set(payload['expectedServers']) and all(managed_http_server_matches(persisted_servers.get(name), expected, True) for name, expected in payload['expectedServers'].items())", @@ -124,8 +146,6 @@ export function buildDeepAgentsMcpRollbackRegisterCommand( " restored = isinstance(persisted_servers, dict) and persisted_servers.get(payload['server']) == payload['expected']", "if not restored:", " fail_rollback(f'Managed MCP rollback verification failed at {config_path}')", - "close_managed_projection_descriptors(managed_descriptor, managed_parent_descriptor)", - "close_legacy_config_descriptors(legacy_descriptor, legacy_parent_descriptor)", "print('NEMOCLAW_DEEPAGENTS_MCP_ROLLBACK_RESTORED=1')", "PY", ].join("\n"); diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts index 994121103f8..f0a4ad5d8c5 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts @@ -15,7 +15,6 @@ import { DEEPAGENTS_MCP_MAX_SERVERS } from "./mcp-bridge-adapter-deepagents-proj import { buildDeepAgentsMcpStatusCommand } from "./mcp-bridge-adapter-status"; const emptyProjection = { mcpServers: {} }; -const emptyProjectionText = `${JSON.stringify(emptyProjection, null, 2)}\n`; const duplicateProjection = '{"mcpServers":{},"mcpServers":{"shadow":{}}}\n'; const attackerProjection = '{"mcpServers":{"attacker":{"type":"stdio"}}}\n'; const managedCommandTimeoutMs = 30_000; @@ -25,36 +24,27 @@ const rollbackCommand = buildDeepAgentsMcpRegisterCommand(baseEntry, true, [base const removalCommand = buildDeepAgentsMcpRemoveCommand(baseEntry); describe("Deep Agents managed MCP projection safety", () => { - it( - "applies the shared server cap before normal and rollback v2 publication", - { timeout: 60_000 }, - () => { - const entries = Array.from( - { length: DEEPAGENTS_MCP_MAX_SERVERS + 1 }, - (_, index): McpBridgeEntry => ({ - ...baseEntry, - server: `server${String(index)}`, - env: [`SERVER_${String(index)}_TOKEN`], - }), - ); + it("applies the shared server cap before normal and rollback v2 publication", () => { + const entries = Array.from( + { length: DEEPAGENTS_MCP_MAX_SERVERS + 1 }, + (_, index): McpBridgeEntry => ({ + ...baseEntry, + server: `server${String(index)}`, + env: [`SERVER_${String(index)}_TOKEN`], + }), + ); - expect(() => buildDeepAgentsMcpRegisterCommand(entries[0], false, entries)).toThrow( - `at most ${String(DEEPAGENTS_MCP_MAX_SERVERS)} servers`, - ); - const rollback = runDeepAgentsConfigCommand( - buildDeepAgentsMcpRegisterCommand(entries[0], true, entries, true), - undefined, - "v2", - undefined, - 0o600, - { timeoutMs: managedCommandTimeoutMs }, - ); - expect(rollback.status).toBe(2); - expect(rollback.stderr).toContain( - `supports at most ${String(DEEPAGENTS_MCP_MAX_SERVERS)} servers`, - ); - }, - ); + expect(() => buildDeepAgentsMcpRegisterCommand(entries[0], false, entries)).toThrow( + `at most ${String(DEEPAGENTS_MCP_MAX_SERVERS)} servers`, + ); + const rollback = runDeepAgentsConfigCommand( + buildDeepAgentsMcpRegisterCommand(entries[0], true, entries, true), + ); + expect(rollback.status).toBe(2); + expect(rollback.stderr).toContain( + `supports at most ${String(DEEPAGENTS_MCP_MAX_SERVERS)} servers`, + ); + }); it.each([ { @@ -146,47 +136,40 @@ describe("Deep Agents managed MCP projection safety", () => { expect(result.managedTargetReadAccessed).toBe(false); }); - it("rejects an existing socket projection path (#10754)", () => { - const result = runDeepAgentsConfigCommand( - buildDeepAgentsMcpStatusCommand(baseEntry), - undefined, - "v2", - undefined, - 0o600, - { socket: true, timeoutMs: managedCommandTimeoutMs }, - ); - expect(result.status).toBe(2); - expect(result.stdout.trim()).toBe(""); - expect(result.stderr).toContain( - "Unsafe managed Deep Agents MCP projection path: non-regular file", - ); - expect(result.configIsSocket).toBe(true); - }); - it.each([ { + diagnostic: "symbolic link", expectedConfigIsFifo: false, + expectedConfigIsSocket: false, expectedConfigIsSymlink: true, - expectedSafetyDiagnostic: "Unsafe managed Deep Agents MCP projection path: symbolic link", - expectedTargetText: emptyProjectionText, - raceProjection: "symlink" as const, + expectedTargetText: `${JSON.stringify(emptyProjection, null, 2)}\n`, + replacementType: "symlink" as const, }, { + diagnostic: "FIFO", expectedConfigIsFifo: true, + expectedConfigIsSocket: false, expectedConfigIsSymlink: false, - expectedSafetyDiagnostic: "Unsafe managed Deep Agents MCP projection path: FIFO", expectedTargetText: null, - raceProjection: "fifo" as const, + replacementType: "fifo" as const, + }, + { + diagnostic: "non-regular file", + expectedConfigIsFifo: false, + expectedConfigIsSocket: true, + expectedConfigIsSymlink: false, + expectedTargetText: null, + replacementType: "socket" as const, }, ])( - "rejects or safely loses a concurrent $raceProjection projection race (#10754)", - { timeout: 60_000 }, + "rejects a $replacementType replacement before projection open (#10754)", ({ + diagnostic, expectedConfigIsFifo, + expectedConfigIsSocket, expectedConfigIsSymlink, - expectedSafetyDiagnostic, expectedTargetText, - raceProjection, + replacementType, }) => { const result = runDeepAgentsConfigCommand( buildDeepAgentsMcpStatusCommand(baseEntry), @@ -194,69 +177,139 @@ describe("Deep Agents managed MCP projection safety", () => { "v2", undefined, 0o600, - { raceProjection, timeoutMs: managedCommandTimeoutMs }, + { swapOnManagedOpen: replacementType, timeoutMs: managedCommandTimeoutMs }, ); - - expect(result.managedRaceIterations).toBeGreaterThan(0); - const actualOutcome = { - status: result.status, - stderr: result.stderr.trim(), - stdout: result.stdout.trim(), - }; - const safelyLostRace = - actualOutcome.status === 0 && - actualOutcome.stderr === "" && - actualOutcome.stdout === "absent"; - const safelyRejectedRace = - actualOutcome.status === 2 && - actualOutcome.stdout === "" && - (actualOutcome.stderr === expectedSafetyDiagnostic || - /^Could not inspect managed Deep Agents MCP state at .+: managed MCP projection has unsafe ownership, mode, type, links, or path identity$/u.test( - actualOutcome.stderr, - )); - expect(safelyLostRace || safelyRejectedRace, JSON.stringify(actualOutcome)).toBe(true); + expect(result.status).toBe(2); + expect(result.stdout.trim()).toBe(""); + expect(result.stderr).toContain( + `Unsafe managed Deep Agents MCP projection path: ${diagnostic}`, + ); + expect(result.configIsFifo).toBe(expectedConfigIsFifo); + expect(result.configIsSocket).toBe(expectedConfigIsSocket); expect(result.configIsSymlink).toBe(expectedConfigIsSymlink); + expect(result.managedSymlinkTargetText).toBe(expectedTargetText); + }, + ); + + it.each([ + { + diagnostic: "symbolic link", + expectedConfigIsFifo: false, + expectedConfigIsSymlink: true, + expectedTargetText: `${JSON.stringify(emptyProjection, null, 2)}\n`, + replacementType: "symlink" as const, + }, + { + diagnostic: "FIFO", + expectedConfigIsFifo: true, + expectedConfigIsSymlink: false, + expectedTargetText: null, + replacementType: "fifo" as const, + }, + ])( + "rejects a $replacementType replacement after projection open (#10754)", + ({ + diagnostic, + expectedConfigIsFifo, + expectedConfigIsSymlink, + expectedTargetText, + replacementType, + }) => { + const result = runDeepAgentsConfigCommand( + buildDeepAgentsMcpStatusCommand(baseEntry), + emptyProjection, + "v2", + undefined, + 0o600, + { swapAfterManagedOpen: replacementType, timeoutMs: managedCommandTimeoutMs }, + ); + expect(result.status).toBe(2); + expect(result.stdout.trim()).toBe(""); + expect(result.stderr).toContain( + `Unsafe managed Deep Agents MCP projection path: ${diagnostic}`, + ); expect(result.configIsFifo).toBe(expectedConfigIsFifo); + expect(result.configIsSymlink).toBe(expectedConfigIsSymlink); expect(result.managedSymlinkTargetText).toBe(expectedTargetText); }, ); - it("rejects a symlinked projection parent without reading its target (#10754)", () => { - const status = runDeepAgentsConfigCommand( + it("rejects a symbolic-link replacement while reading the projection (#10754)", () => { + const result = runDeepAgentsConfigCommand( buildDeepAgentsMcpStatusCommand(baseEntry), + emptyProjection, + "v2", undefined, + 0o600, + { swapOnManagedRead: "symlink", timeoutMs: managedCommandTimeoutMs }, + ); + expect(result.status).toBe(2); + expect(result.stdout.trim()).toBe(""); + expect(result.stderr).toContain( + "Unsafe managed Deep Agents MCP projection path: symbolic link", + ); + expect(result.configIsSymlink).toBe(true); + expect(result.managedSymlinkTargetText).toBe(`${JSON.stringify(emptyProjection, null, 2)}\n`); + }); + + it("preserves conclusive ELOOP classification across a follow-up path swap (#10754)", () => { + const result = runDeepAgentsConfigCommand( + buildDeepAgentsMcpStatusCommand(baseEntry), + emptyProjection, "v2", undefined, 0o600, - { parentSymlink: true, targetReadProbe: true, timeoutMs: managedCommandTimeoutMs }, + { + swapAfterManagedEloop: true, + symlink: true, + timeoutMs: managedCommandTimeoutMs, + }, ); - expect(status.status).toBe(2); - expect(status.stdout.trim()).toBe(""); - expect(status.stderr).toContain( + expect(result.status).toBe(2); + expect(result.stdout.trim()).toBe(""); + expect(result.stderr).toContain( "Unsafe managed Deep Agents MCP projection path: symbolic link", ); - expect(status.managedParentIsSymlink).toBe(true); - expect(status.managedTargetReadAccessed).toBe(false); + expect(result.configIsSymlink).toBe(true); + expect(result.managedSymlinkTargetText).toBe(`${JSON.stringify(emptyProjection, null, 2)}\n`); }); it.each([ - ["registration", registrationCommand], - ["forced removal", buildDeepAgentsMcpRemoveCommand(baseEntry, true)], + { + appearedType: "symlink" as const, + diagnostic: "symbolic link", + expectedConfigIsFifo: false, + expectedConfigIsSymlink: true, + expectedTargetText: `${JSON.stringify(emptyProjection, null, 2)}\n`, + }, + { + appearedType: "fifo" as const, + diagnostic: "FIFO", + expectedConfigIsFifo: true, + expectedConfigIsSymlink: false, + expectedTargetText: null, + }, ])( - "rejects a symlinked projection parent during %s without mutating its target (#10754)", - (_operation, command) => { - const initialText = `${JSON.stringify(emptyProjection, null, 2)}\n`; - const mutation = runDeepAgentsConfigCommand( - command, + "classifies a $diagnostic that appears after a missing-path open (#10754)", + ({ appearedType, diagnostic }) => { + const result = runDeepAgentsConfigCommand( + buildDeepAgentsMcpStatusCommand(baseEntry), emptyProjection, "v2", undefined, 0o600, - { parentSymlink: true }, + { swapAfterMissingManagedOpen: appearedType, timeoutMs: managedCommandTimeoutMs }, + ); + + expect(result.status).toBe(2); + expect(result.stdout.trim()).toBe(""); + expect(result.stderr).toContain( + `Unsafe managed Deep Agents MCP projection path: ${diagnostic}`, + ); + expect(appearedType === "symlink" ? result.configIsSymlink : result.configIsFifo).toBe(true); + expect(result.managedSymlinkTargetText).toBe( + appearedType === "symlink" ? `${JSON.stringify(emptyProjection, null, 2)}\n` : null, ); - expect(mutation.status).toBe(2); - expect(mutation.managedParentIsSymlink).toBe(true); - expect(mutation.managedParentTargetText).toBe(initialText); }, ); @@ -312,64 +365,49 @@ describe("Deep Agents managed MCP projection safety", () => { expect(symlink.managedSymlinkTargetText).toBe(`${JSON.stringify(emptyProjection, null, 2)}\n`); }); - it( - "preserves a projection that appears during absent publication (#10754)", - { timeout: 60_000 }, - () => { - const absentResult = runDeepAgentsConfigCommand( - registrationCommand, - undefined, - "v2", - undefined, - 0o600, - { raceAbsentPublication: attackerProjection, timeoutMs: managedCommandTimeoutMs }, - ); - expect(absentResult.managedRaceIterations).toBeGreaterThan(0); - expect(absentResult.status).toBe(2); - expect(absentResult.configIsSymlink).toBe(true); - expect(absentResult.managedSymlinkTargetText).toBe(attackerProjection); - }, - ); + it("preserves a projection that appears during absent publication", () => { + const absentResult = runDeepAgentsConfigCommand( + registrationCommand, + undefined, + "v2", + undefined, + 0o600, + { swapBeforeManagedLink: attackerProjection, timeoutMs: managedCommandTimeoutMs }, + ); + expect(absentResult.status).toBe(2); + expect(absentResult.stderr).toContain("appeared during publication"); + expect(absentResult.configText).toBe(attackerProjection); + }); - it("preserves a replacement during a descriptor rewrite (#10754)", { timeout: 60_000 }, () => { + it("preserves a replacement during a descriptor rewrite", () => { const existingResult = runDeepAgentsConfigCommand( registrationCommand, emptyProjection, "v2", undefined, 0o600, - { raceProjection: "symlink", timeoutMs: managedCommandTimeoutMs }, - ); - expect(existingResult.managedRaceIterations).toBeGreaterThan(0); - expect([0, 2]).toContain(existingResult.status); - expect(existingResult.configIsSymlink).toBe(true); - expect(existingResult.managedSymlinkTargetText).toBe( - `${JSON.stringify(emptyProjection, null, 2)}\n`, + { swapOnManagedSeek: attackerProjection, timeoutMs: managedCommandTimeoutMs }, ); + expect(existingResult.status).toBe(2); + expect(existingResult.stderr).toContain("links, or path identity"); + expect(existingResult.configText).toBe(attackerProjection); }); - it( - "keeps forced removal identity-bound during a descriptor rewrite (#10754)", - { timeout: 60_000 }, - () => { - const forcedCommand = buildDeepAgentsMcpRemoveCommand(baseEntry, true); - const raced = runDeepAgentsConfigCommand( - forcedCommand, - { ui: { theme: "dark" } }, - "v2", - undefined, - 0o600, - { raceProjection: "symlink", timeoutMs: managedCommandTimeoutMs }, - ); - expect(raced.managedRaceIterations).toBeGreaterThan(0); - expect([0, 2]).toContain(raced.status); - expect(raced.stderr).not.toContain("Traceback"); - expect(raced.configIsSymlink).toBe(true); - expect(raced.managedSymlinkTargetText).toBe( - `${JSON.stringify({ ui: { theme: "dark" } }, null, 2)}\n`, - ); - }, - ); + it("keeps forced removal identity-bound during a descriptor rewrite (#10754)", () => { + const forcedCommand = buildDeepAgentsMcpRemoveCommand(baseEntry, true); + const raced = runDeepAgentsConfigCommand( + forcedCommand, + { ui: { theme: "dark" } }, + "v2", + undefined, + 0o600, + { swapOnManagedSeek: attackerProjection, timeoutMs: managedCommandTimeoutMs }, + ); + expect(raced.status).toBe(2); + expect(raced.stderr).toContain("Refusing unsafe managed MCP v2 repair"); + expect(raced.stderr).not.toContain("Traceback"); + expect(raced.configText).toBe(attackerProjection); + }); it("preserves a projection symlink during forced removal", () => { const forcedCommand = buildDeepAgentsMcpRemoveCommand(baseEntry, true); diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts index cd0378cd0b8..d484ec70a7c 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts @@ -28,76 +28,11 @@ export const DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS = [ " if not stat.S_ISREG(mode):", " return 'non-regular file'", " return None", - "def managed_parent_path_kind(mode):", - " if stat.S_ISLNK(mode):", - " return 'symbolic link'", - " if not stat.S_ISDIR(mode):", - " return 'non-directory parent'", - " return None", "def managed_fingerprint(metadata):", " return (metadata.st_dev, metadata.st_ino, metadata.st_size, metadata.st_mtime_ns, metadata.st_ctime_ns, metadata.st_mode, metadata.st_nlink, metadata.st_uid)", - "def managed_parent_fingerprint(metadata):", - " return (metadata.st_dev, metadata.st_ino, metadata.st_mode, metadata.st_uid)", - "def raise_managed_parent_error(parent_descriptor, component, exc, unsafe_path_prefix=None):", - " unsafe_path_kind = None", - " try:", - " unsafe_path_kind = managed_parent_path_kind(os.stat(component, dir_fd=parent_descriptor, follow_symlinks=False).st_mode)", - " except OSError:", - " pass", - " if unsafe_path_kind and unsafe_path_prefix:", - " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}') from exc", - " if unsafe_path_kind:", - " raise ValueError(f'managed MCP projection parent is a {unsafe_path_kind}') from exc", - " raise exc", - "def open_managed_parent(path, create=False, unsafe_path_prefix=None):", - " if not path.is_absolute() or not path.name or path.name in ('.', '..'):", - " raise ValueError('managed MCP projection path must be absolute')", - " components = path.parts[1:-1]", - " descriptor = os.open('/', os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC)", - " created = False", - " try:", - " for index, component in enumerate(components):", - " flags = os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC | os.O_NOFOLLOW", - " try:", - " next_descriptor = os.open(component, flags, dir_fd=descriptor)", - " except FileNotFoundError:", - " if not create or index != len(components) - 1:", - " raise", - " try:", - " os.mkdir(component, 0o700, dir_fd=descriptor)", - " created = True", - " except FileExistsError:", - " pass", - " try:", - " next_descriptor = os.open(component, flags, dir_fd=descriptor)", - " except OSError as exc:", - " raise_managed_parent_error(descriptor, component, exc, unsafe_path_prefix)", - " except OSError as exc:", - " raise_managed_parent_error(descriptor, component, exc, unsafe_path_prefix)", - " os.close(descriptor)", - " descriptor = next_descriptor", - " metadata = os.fstat(descriptor)", - " if not stat.S_ISDIR(metadata.st_mode):", - " raise ValueError('managed MCP projection parent is not a directory')", - " return descriptor, managed_parent_fingerprint(metadata), created", - " except Exception:", - " os.close(descriptor)", - " raise", - "def assert_managed_parent_stable(path, identity, unsafe_path_prefix=None):", - " try:", - " descriptor, current, _ = open_managed_parent(path, unsafe_path_prefix=unsafe_path_prefix)", - " except FileNotFoundError:", - " current = None", - " descriptor = None", + "def assert_managed_source_stable(path, identity, unsafe_path_prefix=None):", " try:", - " if current != identity:", - " raise ValueError('managed MCP projection parent changed during operation')", - " finally:", - " if descriptor is not None:", - " os.close(descriptor)", - "def assert_managed_source_stable(entry_name, parent_descriptor, identity, unsafe_path_prefix=None):", - " try:", - " current_metadata = os.stat(entry_name, dir_fd=parent_descriptor, follow_symlinks=False)", + " current_metadata = os.stat(path, follow_symlinks=False)", " except FileNotFoundError:", " current_metadata = None", " current = managed_fingerprint(current_metadata) if current_metadata is not None else None", @@ -110,9 +45,9 @@ export const DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS = [ " return", " if current != identity:", " raise ValueError('managed MCP projection changed before mutation')", - "def validate_managed_descriptor_path(entry_name, parent_descriptor, descriptor, unsafe_path_prefix=None):", + "def validate_managed_descriptor_path(path, descriptor, unsafe_path_prefix=None):", " opened = os.fstat(descriptor)", - " linked = os.stat(entry_name, dir_fd=parent_descriptor, follow_symlinks=False)", + " linked = os.stat(path, follow_symlinks=False)", " if unsafe_path_prefix:", " unsafe_path_kind = managed_projection_path_kind(linked.st_mode)", " if unsafe_path_kind:", @@ -122,103 +57,90 @@ export const DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS = [ " raise ValueError('managed MCP projection has unsafe ownership, mode, type, links, or path identity')", " return managed_fingerprint(opened)", "def open_managed_projection(path, writable=False, unsafe_path_prefix=None):", - " try:", - " parent_descriptor, parent_identity, _ = open_managed_parent(path, unsafe_path_prefix=unsafe_path_prefix)", - " except FileNotFoundError:", - " return b'', None, None, None, None", - " entry_name = path.name", " access = os.O_RDWR if writable else os.O_RDONLY", " flags = access | os.O_CLOEXEC | os.O_NONBLOCK | os.O_NOFOLLOW", " try:", - " try:", - " descriptor = os.open(entry_name, flags, dir_fd=parent_descriptor)", - " except FileNotFoundError:", - " assert_managed_source_stable(entry_name, parent_descriptor, None, unsafe_path_prefix)", - " assert_managed_parent_stable(path, parent_identity, unsafe_path_prefix)", - " return b'', None, None, parent_descriptor, parent_identity", - " except OSError as exc:", - " unsafe_path_kind = None", - " if unsafe_path_prefix:", - " if exc.errno == errno.ELOOP:", - " unsafe_path_kind = 'symbolic link'", - " else:", - " try:", - " unsafe_path_kind = managed_projection_path_kind(os.stat(entry_name, dir_fd=parent_descriptor, follow_symlinks=False).st_mode)", - " except OSError:", - " pass", + " descriptor = os.open(path, flags)", + " except FileNotFoundError:", + " assert_managed_source_stable(path, None, unsafe_path_prefix)", + " return b'', None, None", + " except OSError as exc:", + " unsafe_path_kind = None", + " if unsafe_path_prefix:", + " if exc.errno == errno.ELOOP:", + " unsafe_path_kind = 'symbolic link'", + " else:", + " try:", + " unsafe_path_kind = managed_projection_path_kind(os.stat(path, follow_symlinks=False).st_mode)", + " except OSError:", + " pass", + " if unsafe_path_kind:", + " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}') from exc", + " raise", + " try:", + " before = os.fstat(descriptor)", + " if unsafe_path_prefix:", + " unsafe_path_kind = managed_projection_path_kind(before.st_mode)", " if unsafe_path_kind:", - " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}') from exc", - " raise", - " try:", - " before = os.fstat(descriptor)", - " if unsafe_path_prefix:", - " unsafe_path_kind = managed_projection_path_kind(before.st_mode)", - " if unsafe_path_kind:", - " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}')", - " validate_managed_descriptor_path(entry_name, parent_descriptor, descriptor, unsafe_path_prefix)", - " if before.st_size < 0 or before.st_size > MANAGED_MCP_MAX_BYTES:", - " raise ValueError('managed MCP projection has invalid size')", - " chunks = []", - " remaining = before.st_size", - " while remaining:", - " chunk = os.read(descriptor, remaining)", - " if not chunk:", - " break", - " chunks.append(chunk)", - " remaining -= len(chunk)", - " after = os.fstat(descriptor)", - " linked_after = os.stat(entry_name, dir_fd=parent_descriptor, follow_symlinks=False)", - " if unsafe_path_prefix:", - " unsafe_path_kind = managed_projection_path_kind(linked_after.st_mode)", - " if unsafe_path_kind:", - " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}')", - " stable = (managed_fingerprint(before) == managed_fingerprint(after) and managed_fingerprint(after) == managed_fingerprint(linked_after))", - " if remaining or not stable:", - " raise ValueError('managed MCP projection changed while reading')", - " assert_managed_parent_stable(path, parent_identity, unsafe_path_prefix)", - " return b''.join(chunks), managed_fingerprint(after), descriptor, parent_descriptor, parent_identity", - " except Exception:", - " os.close(descriptor)", - " raise", + " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}')", + " validate_managed_descriptor_path(path, descriptor, unsafe_path_prefix)", + " if before.st_size < 0 or before.st_size > MANAGED_MCP_MAX_BYTES:", + " raise ValueError('managed MCP projection has invalid size')", + " chunks = []", + " remaining = before.st_size", + " while remaining:", + " chunk = os.read(descriptor, remaining)", + " if not chunk:", + " break", + " chunks.append(chunk)", + " remaining -= len(chunk)", + " after = os.fstat(descriptor)", + " linked_after = os.stat(path, follow_symlinks=False)", + " if unsafe_path_prefix:", + " unsafe_path_kind = managed_projection_path_kind(linked_after.st_mode)", + " if unsafe_path_kind:", + " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}')", + " stable = (managed_fingerprint(before) == managed_fingerprint(after) and managed_fingerprint(after) == managed_fingerprint(linked_after))", + " if remaining or not stable:", + " raise ValueError('managed MCP projection changed while reading')", + " return b''.join(chunks), managed_fingerprint(after), descriptor", " except Exception:", - " os.close(parent_descriptor)", + " os.close(descriptor)", " raise", "def decode_managed_projection(raw):", " return strict_json_loads(raw.decode('utf-8')) if raw else {}", - "def close_managed_projection_descriptors(descriptor, parent_descriptor):", - " for current in (descriptor, parent_descriptor):", - " if current is None:", - " continue", - " try:", - " os.close(current)", - " except OSError:", - " pass", + "def close_managed_projection_descriptor(descriptor):", + " if descriptor is None:", + " return", + " try:", + " os.close(descriptor)", + " except OSError:", + " pass", + "def load_managed_projection_for_update(path):", + " raw, identity, descriptor = open_managed_projection(path, True)", + " try:", + " return decode_managed_projection(raw), identity, descriptor", + " except Exception:", + " close_managed_projection_descriptor(descriptor)", + " raise", "def read_managed_projection(path, unsafe_path_prefix=None):", - " raw, identity, descriptor, parent_descriptor, _ = open_managed_projection(path, unsafe_path_prefix=unsafe_path_prefix)", + " raw, identity, descriptor = open_managed_projection(path, unsafe_path_prefix=unsafe_path_prefix)", " try:", " return decode_managed_projection(raw), identity", " finally:", - " close_managed_projection_descriptors(descriptor, parent_descriptor)", + " close_managed_projection_descriptor(descriptor)", ]; export const DEEPAGENTS_MANAGED_PROJECTION_MUTATION_HELPERS = [ - "def load_managed_projection_for_update(path):", - " raw, identity, descriptor, parent_descriptor, parent_identity = open_managed_projection(path, True)", - " try:", - " return decode_managed_projection(raw), identity, descriptor, parent_descriptor, parent_identity", - " except Exception:", - " close_managed_projection_descriptors(descriptor, parent_descriptor)", - " raise", "def managed_projection_bytes(value):", " payload = (json.dumps(value, indent=2, sort_keys=True) + '\\n').encode('utf-8')", " if not payload or len(payload) > MANAGED_MCP_MAX_BYTES:", " raise ValueError('managed MCP projection has invalid rendered size')", " return payload", - "def rewrite_managed_projection(path, value, identity, descriptor, parent_descriptor, parent_identity):", - " if identity is None or descriptor is None or parent_descriptor is None or parent_identity is None:", + "def rewrite_managed_projection(path, value, identity, descriptor):", + " if identity is None or descriptor is None:", " raise ValueError('managed MCP projection descriptor is unavailable')", - " assert_managed_parent_stable(path, parent_identity)", - " assert_managed_source_stable(path.name, parent_descriptor, identity)", + " assert_managed_source_stable(path, identity)", " payload = managed_projection_bytes(value)", " os.lseek(descriptor, 0, os.SEEK_SET)", " os.ftruncate(descriptor, 0)", @@ -233,62 +155,40 @@ export const DEEPAGENTS_MANAGED_PROJECTION_MUTATION_HELPERS = [ " persisted = os.read(descriptor, len(payload) + 1)", " if persisted != payload or os.fstat(descriptor).st_size != len(payload):", " raise ValueError('managed MCP projection verification failed')", - " validate_managed_descriptor_path(path.name, parent_descriptor, descriptor)", - " assert_managed_parent_stable(path, parent_identity)", - "def create_managed_projection_temp(parent_descriptor):", - " flags = os.O_RDWR | os.O_CREAT | os.O_EXCL | os.O_CLOEXEC | os.O_NOFOLLOW", - " for _ in range(16):", - " name = '.nemoclaw-mcp.' + os.urandom(16).hex()", + " validate_managed_descriptor_path(path, descriptor)", + "def publish_absent_managed_projection(path, value):", + " assert_managed_source_stable(path, None)", + " payload = managed_projection_bytes(value)", + " tmp_fd, tmp_name = tempfile.mkstemp(prefix='.nemoclaw-mcp.', dir=path.parent)", + " try:", + " os.fchmod(tmp_fd, 0o600)", + " with os.fdopen(tmp_fd, 'wb') as tmp_file:", + " tmp_file.write(payload)", + " tmp_file.flush()", + " os.fsync(tmp_file.fileno())", + " try:", + " os.link(tmp_name, path, follow_symlinks=False)", + " except FileExistsError as exc:", + " raise ValueError('managed MCP projection appeared during publication') from exc", + " os.unlink(tmp_name)", + " finally:", " try:", - " return os.open(name, flags, 0o600, dir_fd=parent_descriptor), name", - " except FileExistsError:", + " os.unlink(tmp_name)", + " except FileNotFoundError:", " pass", - " raise FileExistsError('could not allocate managed MCP projection temporary file')", - "def publish_absent_managed_projection(path, value, parent_descriptor, parent_identity):", - " owns_parent_descriptor = parent_descriptor is None", - " if parent_descriptor is None:", - " parent_descriptor, parent_identity, created = open_managed_parent(path, True)", - " if not created:", - " os.close(parent_descriptor)", - " raise ValueError('managed MCP projection parent appeared during publication')", - " try:", - " if not owns_parent_descriptor:", - " assert_managed_parent_stable(path, parent_identity)", - " assert_managed_source_stable(path.name, parent_descriptor, None)", - " payload = managed_projection_bytes(value)", - " tmp_fd, tmp_name = create_managed_projection_temp(parent_descriptor)", + " persisted, _ = read_managed_projection(path)", + " if persisted != value:", + " raise ValueError('managed MCP projection verification failed')", + "def write_managed_projection(path, value, identity, descriptor):", + " if identity is None:", + " if descriptor is not None:", + " raise ValueError('unexpected managed MCP projection descriptor')", + " publish_absent_managed_projection(path, value)", + " else:", " try:", - " with os.fdopen(tmp_fd, 'wb') as tmp_file:", - " tmp_file.write(payload)", - " tmp_file.flush()", - " os.fsync(tmp_file.fileno())", - " try:", - " os.link(tmp_name, path.name, src_dir_fd=parent_descriptor, dst_dir_fd=parent_descriptor, follow_symlinks=False)", - " except FileExistsError as exc:", - " raise ValueError('managed MCP projection appeared during publication') from exc", - " os.unlink(tmp_name, dir_fd=parent_descriptor)", + " rewrite_managed_projection(path, value, identity, descriptor)", " finally:", - " try:", - " os.unlink(tmp_name, dir_fd=parent_descriptor)", - " except FileNotFoundError:", - " pass", - " assert_managed_parent_stable(path, parent_identity)", - " persisted, _ = read_managed_projection(path)", - " if persisted != value:", - " raise ValueError('managed MCP projection verification failed')", - " finally:", - " if owns_parent_descriptor:", - " os.close(parent_descriptor)", - "def write_managed_projection(path, value, identity, descriptor, parent_descriptor, parent_identity):", - " try:", - " if identity is None:", - " if descriptor is not None:", - " raise ValueError('unexpected managed MCP projection descriptor')", - " publish_absent_managed_projection(path, value, parent_descriptor, parent_identity)", - " else:", - " rewrite_managed_projection(path, value, identity, descriptor, parent_descriptor, parent_identity)", - " finally:", - " close_managed_projection_descriptors(descriptor, parent_descriptor)", + " close_managed_projection_descriptor(descriptor)", ]; export const DEEPAGENTS_MANAGED_PROJECTION_HELPERS = [ diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-registration.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-registration.ts index 5657a097489..0b9365de1b0 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-registration.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-registration.ts @@ -52,20 +52,19 @@ export function buildDeepAgentsMcpRegisterCommand( }; return [ "/opt/venv/bin/python3 -I - <<'PY'", - "import json, os, pathlib, stat, sys", + "import json, os, pathlib, stat, sys, tempfile", `payload = json.loads(${pythonJsonLiteral(payload)})`, `config_path = pathlib.Path(${JSON.stringify(DEEPAGENTS_MCP_CONFIG_PATH)})`, ...DEEPAGENTS_STRICT_JSON_HELPERS, ...DEEPAGENTS_MANAGED_PROJECTION_HELPERS, ...MANAGED_HTTP_SERVER_MATCH_HELPERS, "source_descriptor = None", - "source_parent_descriptor = None", "def fail_registration(message):", - " close_managed_projection_descriptors(source_descriptor, source_parent_descriptor)", + " close_managed_projection_descriptor(source_descriptor)", " print(message, file=sys.stderr)", " raise SystemExit(2)", "try:", - " data, source_identity, source_descriptor, source_parent_descriptor, source_parent_identity = load_managed_projection_for_update(config_path)", + " data, source_identity, source_descriptor = load_managed_projection_for_update(config_path)", "except (OSError, UnicodeDecodeError, ValueError) as exc:", ` fail_registration(f'Invalid ${DEEPAGENTS_MCP_CONFIG_PATH}: {exc}')`, "if not isinstance(data, dict):", @@ -96,8 +95,9 @@ export function buildDeepAgentsMcpRegisterCommand( " else:", " next_servers[name] = expected", "data = {'mcpServers': next_servers}", + "config_path.parent.mkdir(parents=True, exist_ok=True)", "try:", - " write_managed_projection(config_path, data, source_identity, source_descriptor, source_parent_descriptor, source_parent_identity)", + " write_managed_projection(config_path, data, source_identity, source_descriptor)", "except (OSError, ValueError) as exc:", ` fail_registration(f'Could not publish ${DEEPAGENTS_MCP_CONFIG_PATH}: {exc}')`, "PY", diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-rollback.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-rollback.test.ts index 59724441013..0cedb3f7642 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-rollback.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-rollback.test.ts @@ -38,28 +38,6 @@ describe("Deep Agents MCP config adapter rollback", () => { }); }); - it("refuses rollback through a symlinked legacy parent", () => { - const legacyConfig = { - mcpServers: { local: { type: "stdio", command: "user-owned" } }, - ui: { theme: "dark" }, - }; - const original = `${JSON.stringify(legacyConfig, null, 2)}\n`; - - const rollback = runDeepAgentsConfigCommand( - buildDeepAgentsMcpRegisterCommand(baseEntry, true, [baseEntry], true), - undefined, - "legacy", - legacyConfig, - 0o600, - { legacyParentSymlink: true }, - ); - - expect(rollback.status).toBe(2); - expect(rollback.stderr).toContain("legacy MCP config parent is unsafe: symbolic link"); - expect(rollback.legacyParentIsSymlink).toBe(true); - expect(rollback.legacyParentTargetText).toBe(original); - }); - it("keeps v2 teardown and rollback isolated from the legacy user file", () => { const managedServer = { type: "http", diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-runtime-guards.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-runtime-guards.test.ts index 49958524dc3..73ad1997741 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-runtime-guards.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-runtime-guards.test.ts @@ -10,64 +10,38 @@ import { buildDeepAgentsMcpRegisterCommand, buildDeepAgentsMcpRemoveCommand, } from "./mcp-bridge-adapter-deepagents"; -import { buildDeepAgentsMcpStatusCommand } from "./mcp-bridge-adapter-status"; describe("Deep Agents MCP config adapter runtime guards", () => { - const managedServer = { - type: "http", - url: baseEntry.url, - headers: { Authorization: "Bearer openshell:resolve:env:GITHUB_TOKEN" }, - }; - const userServer = { type: "stdio", command: "user-owned" }; - const registeredV2Config = { mcpServers: { github: managedServer } }; - const registeredLegacyConfig = { - mcpServers: { github: managedServer, local: userServer }, - ui: { theme: "dark" }, - }; - const driftedLegacyConfig = { - mcpServers: { github: { type: "http", url: "https://user.example/mcp" } }, - }; - const emptyV2Config = { mcpServers: {} }; - const emptyLegacyConfig = { - mcpServers: { local: userServer }, - ui: { theme: "dark" }, - }; - const statusCommand = buildDeepAgentsMcpStatusCommand(baseEntry); - const removalCommand = buildDeepAgentsMcpRemoveCommand(baseEntry, true, true); - const rollbackCommand = buildDeepAgentsMcpRegisterCommand(baseEntry, true, [baseEntry], true); - const runtimeSelectionCases = [ - ["status", "v2", statusCommand, registeredV2Config, driftedLegacyConfig, [0, "registered", "", true, true]], - ["status", "legacy", statusCommand, registeredV2Config, driftedLegacyConfig, [0, "mismatch", "", true, true]], - ["status", "unknown", statusCommand, registeredV2Config, driftedLegacyConfig, [2, "", "Could not identify the managed Deep Agents MCP runtime", true, true]], - ["adaptive teardown", "v2", removalCommand, registeredV2Config, registeredLegacyConfig, [0, "NEMOCLAW_DEEPAGENTS_MCP_REMOVAL=removed", "", false, true]], - ["adaptive teardown", "legacy", removalCommand, registeredV2Config, registeredLegacyConfig, [0, "NEMOCLAW_DEEPAGENTS_MCP_REMOVAL=removed", "", true, false]], - ["adaptive teardown", "unknown", removalCommand, registeredV2Config, registeredLegacyConfig, [2, "", "Could not identify the managed Deep Agents MCP runtime; refusing teardown", true, true]], - ["rollback", "v2", rollbackCommand, emptyV2Config, emptyLegacyConfig, [0, "NEMOCLAW_DEEPAGENTS_MCP_ROLLBACK_RESTORED=1", "", true, false]], - ["rollback", "legacy", rollbackCommand, emptyV2Config, emptyLegacyConfig, [0, "NEMOCLAW_DEEPAGENTS_MCP_ROLLBACK_RESTORED=1", "", false, true]], - ["rollback", "unknown", rollbackCommand, emptyV2Config, emptyLegacyConfig, [2, "", "Could not identify the managed Deep Agents MCP runtime; refusing rollback", false, false]], - ] as const; - const hasGithubServer = (config: Record | null): boolean => { - const servers = config?.mcpServers; - return typeof servers === "object" && servers !== null && Object.hasOwn(servers, "github"); - }; - - it.each(runtimeSelectionCases)( - "uses the shared runtime marker for %s: %s", - (_operation, runtimeKind, command, initialConfig, initialLegacyConfig, expected) => { - const result = runDeepAgentsConfigCommand( - command, - initialConfig, - runtimeKind, - initialLegacyConfig, - ); + it.each( + Array.from( + [ + buildDeepAgentsMcpRemoveCommand(baseEntry, true, true), + buildDeepAgentsMcpRegisterCommand(baseEntry, true, [baseEntry], true), + ], + (value) => [value], + ), + )( + "fails closed without touching either config when the runtime generation is unknown [case %#]", + (command) => { + const v2Config = { + mcpServers: { + github: { + type: "http", + url: baseEntry.url, + headers: { Authorization: "Bearer openshell:resolve:env:GITHUB_TOKEN" }, + }, + }, + }; + const legacyConfig = { + mcpServers: { local: { type: "stdio", command: "user-owned" } }, + ui: { theme: "dark" }, + }; - expect([ - result.status, - result.stdout.trim(), - result.stderr.trim(), - hasGithubServer(result.config), - hasGithubServer(result.legacyConfig), - ]).toEqual(expected); + const result = runDeepAgentsConfigCommand(command, v2Config, "unknown", legacyConfig); + expect(result.status).toBe(2); + expect(result.stderr).toContain("Could not identify the managed Deep Agents MCP runtime"); + expect(result.config).toEqual(v2Config); + expect(result.legacyConfig).toEqual(legacyConfig); }, ); diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-teardown.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-teardown.ts index d62099bdfb7..c27d89dc2f3 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-teardown.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-teardown.ts @@ -6,7 +6,6 @@ import { runDeepAgentsAdapterCommand } from "./mcp-bridge-adapter-deepagents-com import { DEEPAGENTS_LEGACY_CONFIG_HELPERS, DEEPAGENTS_LEGACY_MCP_CONFIG_PATH, - DEEPAGENTS_RUNTIME_SELECTION_HELPERS, } from "./mcp-bridge/deepagents-legacy-config"; import { DEEPAGENTS_MANAGED_PROJECTION_HELPERS, @@ -35,16 +34,26 @@ export function buildDeepAgentsMcpRemoveCommand( }; return [ "/opt/venv/bin/python3 -I - <<'PY'", - "import json, os, pathlib, stat, sys", + "import json, os, pathlib, stat, sys, tempfile", `payload = json.loads(${pythonJsonLiteral(payload)})`, `managed_path = pathlib.Path(${JSON.stringify(DEEPAGENTS_MCP_CONFIG_PATH)})`, `legacy_path = pathlib.Path(${JSON.stringify(DEEPAGENTS_LEGACY_MCP_CONFIG_PATH)})`, ...DEEPAGENTS_STRICT_JSON_HELPERS, ...DEEPAGENTS_MANAGED_PROJECTION_HELPERS, ...DEEPAGENTS_LEGACY_CONFIG_HELPERS, - ...DEEPAGENTS_RUNTIME_SELECTION_HELPERS, ...MANAGED_HTTP_SERVER_MATCH_HELPERS, - `runtime_kind = select_deepagents_runtime_kind("${adaptiveTeardown ? "auto" : "v2"}", managed_path, legacy_path)`, + `runtime_kind = "${adaptiveTeardown ? "auto" : "v2"}" # NEMOCLAW_DEEPAGENTS_RUNTIME_TEST_ANCHOR`, + "if runtime_kind == 'auto':", + " runtime_kind = 'unknown'", + " try:", + " from deepagents_code import _nemoclaw_managed as managed", + " runtime_path = str(getattr(managed, '_MCP_CONFIG_FILE', ''))", + " if runtime_path == str(managed_path):", + " runtime_kind = 'v2'", + " elif runtime_path == str(legacy_path):", + " runtime_kind = 'legacy'", + " except Exception:", + " pass", "if runtime_kind not in ('v2', 'legacy'):", " print('Could not identify the managed Deep Agents MCP runtime; refusing teardown', file=sys.stderr)", " raise SystemExit(2)", @@ -52,30 +61,43 @@ export function buildDeepAgentsMcpRemoveCommand( "config_path = managed_path if is_v2 else legacy_path", "managed_identity = None", "managed_descriptor = None", - "managed_parent_identity = None", - "managed_parent_descriptor = None", "legacy_identity = None", - "legacy_descriptor = None", - "legacy_parent_identity = None", - "legacy_parent_descriptor = None", "def finish(outcome):", - " close_managed_projection_descriptors(managed_descriptor, managed_parent_descriptor)", - " close_legacy_config_descriptors(legacy_descriptor, legacy_parent_descriptor)", + " close_managed_projection_descriptor(managed_descriptor)", " print('NEMOCLAW_DEEPAGENTS_MCP_REMOVAL=' + outcome)", " raise SystemExit(0)", "def fail_teardown(message):", - " close_managed_projection_descriptors(managed_descriptor, managed_parent_descriptor)", - " close_legacy_config_descriptors(legacy_descriptor, legacy_parent_descriptor)", + " close_managed_projection_descriptor(managed_descriptor)", " print(message, file=sys.stderr)", " raise SystemExit(2)", - "def repair_v2_projection(identity, descriptor, parent_descriptor, parent_identity):", + "def repair_v2_projection(identity, descriptor):", " try:", - " write_managed_projection(config_path, {'mcpServers': {}}, identity, descriptor, parent_descriptor, parent_identity)", + " write_managed_projection(config_path, {'mcpServers': {}}, identity, descriptor)", " except (OSError, ValueError) as exc:", " fail_teardown(f'Refusing unsafe managed MCP v2 repair at {config_path}: {exc}')", + "def write_legacy_data(value):", + " tmp_fd, tmp_name = tempfile.mkstemp(prefix='.nemoclaw-mcp.', dir=config_path.parent)", + " try:", + " os.fchmod(tmp_fd, 0o600)", + " with os.fdopen(tmp_fd, 'w', encoding='utf-8') as tmp_file:", + " json.dump(value, tmp_file, indent=2, sort_keys=True)", + " tmp_file.write('\\n')", + " tmp_file.flush()", + " os.fsync(tmp_file.fileno())", + " assert_legacy_source_stable(config_path, legacy_identity)", + " if legacy_identity is None:", + " os.link(tmp_name, config_path, follow_symlinks=False)", + " os.unlink(tmp_name)", + " else:", + " os.replace(tmp_name, config_path)", + " finally:", + " try:", + " os.unlink(tmp_name)", + " except FileNotFoundError:", + " pass", "if is_v2:", " try:", - " raw, managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity = open_managed_projection(config_path, True)", + " raw, managed_identity, managed_descriptor = open_managed_projection(config_path, True)", " except (OSError, ValueError) as exc:", " fail_teardown(f'Invalid managed MCP v2 projection at {config_path}: {exc}')", " if managed_descriptor is None:", @@ -84,21 +106,19 @@ export function buildDeepAgentsMcpRemoveCommand( " data = decode_managed_projection(raw)", " except (UnicodeDecodeError, ValueError) as exc:", " if payload['force']:", - " repair_v2_projection(managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity)", + " repair_v2_projection(managed_identity, managed_descriptor)", " finish('removed')", " fail_teardown(f'Invalid managed MCP v2 projection at {config_path}: {exc}')", "else:", + " if not os.path.lexists(config_path):", + " finish('absent')", " try:", - " data, legacy_identity, legacy_descriptor, legacy_parent_descriptor, legacy_parent_identity = load_legacy_config_for_update(config_path)", - " except (OSError, UnicodeDecodeError, ValueError) as exc:", - " if str(exc).startswith('legacy MCP config parent is unsafe'):", - " fail_teardown(f'Refusing unsafe legacy MCP teardown at {config_path}: {exc}')", + " data, legacy_identity = read_legacy_config(config_path)", + " except (OSError, UnicodeDecodeError, ValueError):", " finish('unowned')", - " if legacy_descriptor is None:", - " finish('absent')", "if not isinstance(data, dict):", " if is_v2 and payload['force']:", - " repair_v2_projection(managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity)", + " repair_v2_projection(managed_identity, managed_descriptor)", " finish('removed')", " if is_v2:", " fail_teardown(f'Invalid managed MCP v2 projection at {config_path}: expected object')", @@ -108,7 +128,7 @@ export function buildDeepAgentsMcpRemoveCommand( " if not is_v2 and 'mcpServers' not in data:", " finish('absent')", " if is_v2 and payload['force']:", - " repair_v2_projection(managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity)", + " repair_v2_projection(managed_identity, managed_descriptor)", " finish('removed')", " if is_v2:", " fail_teardown(f'Invalid managed MCP v2 server map at {config_path}')", @@ -118,7 +138,7 @@ export function buildDeepAgentsMcpRemoveCommand( "if is_v2:", " if data and set(data) != {'mcpServers'}:", " if payload['force']:", - " repair_v2_projection(managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity)", + " repair_v2_projection(managed_identity, managed_descriptor)", " finish('removed')", " fail_teardown(f'Invalid managed MCP v2 projection at {config_path}: only mcpServers is allowed')", " if present and not payload['force'] and not managed_http_server_matches(current, payload['expected'], True):", @@ -138,19 +158,20 @@ export function buildDeepAgentsMcpRemoveCommand( "if data:", " try:", " if is_v2:", - " write_managed_projection(config_path, data, managed_identity, managed_descriptor, managed_parent_descriptor, managed_parent_identity)", + " write_managed_projection(config_path, data, managed_identity, managed_descriptor)", " persisted = read_managed_projection(config_path)[0]", " else:", - " persisted = write_legacy_config(config_path, data, legacy_identity, legacy_descriptor, legacy_parent_descriptor, legacy_parent_identity)", + " write_legacy_data(data)", + " persisted = read_legacy_config(config_path)[0]", " except (OSError, UnicodeDecodeError, ValueError) as exc:", " fail_teardown(f'MCP teardown mutation failed at {config_path}: {exc}')", " if persisted != data:", " fail_teardown(f'MCP teardown verification failed at {config_path}')", "else:", - " try:", - " remove_legacy_config(config_path, legacy_identity, legacy_descriptor, legacy_parent_descriptor, legacy_parent_identity)", - " except (OSError, ValueError) as exc:", - " fail_teardown(f'MCP teardown mutation failed at {config_path}: {exc}')", + " assert_legacy_source_stable(config_path, legacy_identity)", + " config_path.unlink()", + " if os.path.lexists(config_path):", + " fail_teardown(f'Managed MCP teardown verification failed at {config_path}')", "finish('removed')", "PY", ].join("\n"); diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-status.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-status.ts index a6c9ceb281c..e46d13bbcde 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-status.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-status.ts @@ -6,7 +6,6 @@ import type { McpAttachedCredentialRevision } from "./mcp-bridge-provider-readin import { DEEPAGENTS_LEGACY_CONFIG_HELPERS, DEEPAGENTS_LEGACY_MCP_CONFIG_PATH, - DEEPAGENTS_RUNTIME_SELECTION_HELPERS, } from "./mcp-bridge/deepagents-legacy-config"; import { DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS, @@ -243,9 +242,19 @@ export function buildDeepAgentsMcpStatusCommand( ...DEEPAGENTS_STRICT_JSON_HELPERS, ...DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS, ...DEEPAGENTS_LEGACY_CONFIG_HELPERS, - ...DEEPAGENTS_RUNTIME_SELECTION_HELPERS, ...MANAGED_HTTP_SERVER_MATCH_HELPERS, - "runtime_kind = select_deepagents_runtime_kind('auto', managed_path, legacy_path)", + `runtime_kind = "auto" # NEMOCLAW_DEEPAGENTS_RUNTIME_TEST_ANCHOR`, + "if runtime_kind == 'auto':", + " runtime_kind = 'unknown'", + " try:", + " from deepagents_code import _nemoclaw_managed as managed", + " runtime_path = str(getattr(managed, '_MCP_CONFIG_FILE', ''))", + " if runtime_path == str(managed_path):", + " runtime_kind = 'v2'", + " elif runtime_path == str(legacy_path):", + " runtime_kind = 'legacy'", + " except Exception:", + " pass", "if runtime_kind not in ('v2', 'legacy'):", " print('Could not identify the managed Deep Agents MCP runtime', file=sys.stderr)", " raise SystemExit(2)", diff --git a/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts b/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts index e4b521a09fa..3582140ab2c 100644 --- a/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts @@ -7,6 +7,12 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { + baseEntry, + runDeepAgentsConfigCommand, +} from "../../../../test/helpers/mcp-bridge-adapter-deepagents-fixture"; + +import { buildDeepAgentsMcpStatusCommand } from "./mcp-bridge-adapter-status"; const sourceRequireHook = path.resolve("test/helpers/onboard-script-mocks.cjs"); const sourceNodeOptions = [process.env.NODE_OPTIONS, `--require=${sourceRequireHook}`] @@ -199,13 +205,31 @@ ${body} describe("MCP status wire-level credential-resolution probe", { timeout: 15_000 }, () => { it( - "preserves fixed unsafe projection diagnostics across credential observations (#10754)", - { timeout: 30_000 }, + "returns a type-specific failure for unsafe Deep Agents projection races (#10754)", + { timeout: 120_000 }, () => { - const unsafeDetails = [ - "Unsafe managed Deep Agents MCP projection path: symbolic link", - "Unsafe managed Deep Agents MCP projection path: FIFO", + const statusCommand = buildDeepAgentsMcpStatusCommand(baseEntry); + const projection = { mcpServers: {} }; + const unsafeResults = [ + ...(["symlink", "fifo"] as const).map((appearedType) => + runDeepAgentsConfigCommand(statusCommand, projection, "v2", undefined, 0o600, { + swapAfterMissingManagedOpen: appearedType, + timeoutMs: 30_000, + }), + ), + runDeepAgentsConfigCommand(statusCommand, projection, "v2", undefined, 0o600, { + swapAfterManagedEloop: true, + symlink: true, + timeoutMs: 30_000, + }), ]; + unsafeResults.forEach((result) => { + expect(result.status).toBe(2); + expect(result.stdout.trim()).toBe(""); + expect(result.stderr).toContain("Unsafe managed Deep Agents MCP projection path:"); + expect(result.configIsSymlink || result.configIsFifo).toBe(true); + }); + const unsafeDetails = unsafeResults.map((result) => result.stderr.trim()); const home = createTempHome("nemoclaw-mcp-status-projection-"); const { stdout } = runHarness( home, @@ -257,7 +281,7 @@ describe("MCP status wire-level credential-resolution probe", { timeout: 15_000 stderr: string; }>; - expect(outcomes).toHaveLength(6); + expect(outcomes).toHaveLength(9); outcomes.forEach((outcome) => { expect(["unavailable", "absent", "canonical"]).toContain(outcome.observation); expect(outcome.exitCode).toBe(2); diff --git a/src/lib/actions/sandbox/mcp-bridge/deepagents-legacy-config.ts b/src/lib/actions/sandbox/mcp-bridge/deepagents-legacy-config.ts index ab13b4eee74..248f4caa262 100644 --- a/src/lib/actions/sandbox/mcp-bridge/deepagents-legacy-config.ts +++ b/src/lib/actions/sandbox/mcp-bridge/deepagents-legacy-config.ts @@ -3,158 +3,46 @@ export const DEEPAGENTS_LEGACY_MCP_CONFIG_PATH = "/sandbox/.deepagents/.mcp.json"; -export const DEEPAGENTS_RUNTIME_SELECTION_HELPERS = [ - "def select_deepagents_runtime_kind(requested_kind, managed_path, legacy_path):", - " if requested_kind != 'auto':", - " return requested_kind", - " try:", - " from deepagents_code import _nemoclaw_managed as managed", - " runtime_path = str(getattr(managed, '_MCP_CONFIG_FILE', ''))", - " if runtime_path == str(managed_path):", - " return 'v2'", - " if runtime_path == str(legacy_path):", - " return 'legacy'", - " except Exception:", - " pass", - " return 'unknown'", -]; - export const DEEPAGENTS_LEGACY_CONFIG_HELPERS = [ "LEGACY_MCP_MAX_BYTES = 262144", "def legacy_fingerprint(metadata):", " return (metadata.st_dev, metadata.st_ino, metadata.st_size, metadata.st_mtime_ns, metadata.st_ctime_ns, metadata.st_mode, metadata.st_nlink, metadata.st_uid)", - "def validate_legacy_descriptor_path(entry_name, parent_descriptor, descriptor):", - " opened = os.fstat(descriptor)", - " linked = os.stat(entry_name, dir_fd=parent_descriptor, follow_symlinks=False)", - " safe = (stat.S_ISREG(opened.st_mode) and opened.st_uid == os.getuid() and stat.S_IMODE(opened.st_mode) == 0o600 and opened.st_nlink == 1 and (opened.st_dev, opened.st_ino) == (linked.st_dev, linked.st_ino))", - " if not safe:", - " raise ValueError('legacy MCP config has unsafe ownership, mode, type, or links')", - " return legacy_fingerprint(opened)", - "def read_legacy_descriptor(entry_name, parent_descriptor, descriptor):", - " before = os.fstat(descriptor)", - " validate_legacy_descriptor_path(entry_name, parent_descriptor, descriptor)", - " if before.st_size <= 0 or before.st_size > LEGACY_MCP_MAX_BYTES:", - " raise ValueError('legacy MCP config has invalid size')", - " chunks = []", - " remaining = before.st_size", - " while remaining:", - " chunk = os.read(descriptor, remaining)", - " if not chunk:", - " break", - " chunks.append(chunk)", - " remaining -= len(chunk)", - " after = os.fstat(descriptor)", - " linked_after = os.stat(entry_name, dir_fd=parent_descriptor, follow_symlinks=False)", - " stable = (legacy_fingerprint(before) == legacy_fingerprint(after) and legacy_fingerprint(after) == legacy_fingerprint(linked_after))", - " if remaining or not stable:", - " raise ValueError('legacy MCP config changed while reading')", - " return strict_json_loads(b''.join(chunks).decode('utf-8')), legacy_fingerprint(after)", - "def close_legacy_config_descriptors(descriptor, parent_descriptor):", - " close_managed_projection_descriptors(descriptor, parent_descriptor)", - "def load_legacy_config_for_update(path, create_parent=False):", - " unsafe_parent_prefix = 'legacy MCP config parent is unsafe'", - " try:", - " parent_descriptor, parent_identity, _ = open_managed_parent(path, create_parent, unsafe_parent_prefix)", - " except FileNotFoundError:", - " return {}, None, None, None, None", - " entry_name = path.name", - " flags = os.O_RDONLY | os.O_CLOEXEC | os.O_NONBLOCK | os.O_NOFOLLOW", - " try:", - " try:", - " descriptor = os.open(entry_name, flags, dir_fd=parent_descriptor)", - " except FileNotFoundError:", - " assert_managed_source_stable(entry_name, parent_descriptor, None)", - " assert_managed_parent_stable(path, parent_identity, unsafe_parent_prefix)", - " return {}, None, None, parent_descriptor, parent_identity", - " try:", - " data, identity = read_legacy_descriptor(entry_name, parent_descriptor, descriptor)", - " assert_managed_parent_stable(path, parent_identity, unsafe_parent_prefix)", - " return data, identity, descriptor, parent_descriptor, parent_identity", - " except Exception:", - " os.close(descriptor)", - " raise", - " except Exception:", - " os.close(parent_descriptor)", - " raise", "def read_legacy_config(path):", - " data, identity, descriptor, parent_descriptor, _ = load_legacy_config_for_update(path)", + " flags = os.O_RDONLY | os.O_CLOEXEC | os.O_NONBLOCK | os.O_NOFOLLOW", + " descriptor = os.open(path, flags)", " try:", - " if descriptor is None:", - " raise FileNotFoundError(path)", - " return data, identity", + " before = os.fstat(descriptor)", + " linked = os.stat(path, follow_symlinks=False)", + " safe = (stat.S_ISREG(before.st_mode) and before.st_uid == os.getuid() and stat.S_IMODE(before.st_mode) == 0o600 and before.st_nlink == 1 and (before.st_dev, before.st_ino) == (linked.st_dev, linked.st_ino))", + " if not safe:", + " raise ValueError('legacy MCP config has unsafe ownership, mode, type, or links')", + " if before.st_size <= 0 or before.st_size > LEGACY_MCP_MAX_BYTES:", + " raise ValueError('legacy MCP config has invalid size')", + " chunks = []", + " remaining = before.st_size", + " while remaining:", + " chunk = os.read(descriptor, remaining)", + " if not chunk:", + " break", + " chunks.append(chunk)", + " remaining -= len(chunk)", + " after = os.fstat(descriptor)", + " linked_after = os.stat(path, follow_symlinks=False)", + " stable = (legacy_fingerprint(before) == legacy_fingerprint(after) and legacy_fingerprint(after) == legacy_fingerprint(linked_after))", + " if remaining or not stable:", + " raise ValueError('legacy MCP config changed while reading')", " finally:", - " close_legacy_config_descriptors(descriptor, parent_descriptor)", - "def assert_legacy_source_stable(path, identity, descriptor, parent_descriptor, parent_identity):", - " if parent_descriptor is None or parent_identity is None:", - " raise ValueError('legacy MCP config parent descriptor is unavailable')", - " assert_managed_parent_stable(path, parent_identity, 'legacy MCP config parent is unsafe')", - " assert_managed_source_stable(path.name, parent_descriptor, identity)", + " os.close(descriptor)", + " raw = b''.join(chunks).decode('utf-8')", + " data = strict_json_loads(raw)", + " return data, legacy_fingerprint(before)", + "def assert_legacy_source_stable(path, identity):", " if identity is None:", - " if descriptor is not None:", - " raise ValueError('unexpected legacy MCP config descriptor')", + " if os.path.lexists(path):", + " raise ValueError('legacy MCP config appeared during mutation')", " return", - " if descriptor is None or validate_legacy_descriptor_path(path.name, parent_descriptor, descriptor) != identity:", + " current = os.stat(path, follow_symlinks=False)", + " safe = (stat.S_ISREG(current.st_mode) and current.st_uid == os.getuid() and stat.S_IMODE(current.st_mode) == 0o600 and current.st_nlink == 1 and legacy_fingerprint(current) == identity)", + " if not safe:", " raise ValueError('legacy MCP config changed before mutation')", - "def legacy_config_bytes(value):", - " payload = (json.dumps(value, indent=2, sort_keys=True) + '\\n').encode('utf-8')", - " if not payload or len(payload) > LEGACY_MCP_MAX_BYTES:", - " raise ValueError('legacy MCP config has invalid rendered size')", - " return payload", - "def create_legacy_config_temp(parent_descriptor):", - " flags = os.O_RDWR | os.O_CREAT | os.O_EXCL | os.O_CLOEXEC | os.O_NOFOLLOW", - " for _ in range(16):", - " name = '.nemoclaw-mcp.' + os.urandom(16).hex()", - " try:", - " return os.open(name, flags, 0o600, dir_fd=parent_descriptor), name", - " except FileExistsError:", - " pass", - " raise FileExistsError('could not allocate legacy MCP config temporary file')", - "def write_legacy_config(path, value, identity, descriptor, parent_descriptor, parent_identity):", - " assert_legacy_source_stable(path, identity, descriptor, parent_descriptor, parent_identity)", - " payload = legacy_config_bytes(value)", - " tmp_fd, tmp_name = create_legacy_config_temp(parent_descriptor)", - " try:", - " offset = 0", - " while offset < len(payload):", - " written = os.write(tmp_fd, payload[offset:])", - " if written <= 0:", - " raise OSError('legacy MCP config write made no progress')", - " offset += written", - " os.fsync(tmp_fd)", - " os.close(tmp_fd)", - " tmp_fd = None", - " assert_legacy_source_stable(path, identity, descriptor, parent_descriptor, parent_identity)", - " if identity is None:", - " try:", - " os.link(tmp_name, path.name, src_dir_fd=parent_descriptor, dst_dir_fd=parent_descriptor, follow_symlinks=False)", - " except FileExistsError as exc:", - " raise ValueError('legacy MCP config appeared during publication') from exc", - " os.unlink(tmp_name, dir_fd=parent_descriptor)", - " else:", - " os.replace(tmp_name, path.name, src_dir_fd=parent_descriptor, dst_dir_fd=parent_descriptor)", - " assert_managed_parent_stable(path, parent_identity, 'legacy MCP config parent is unsafe')", - " verification = os.open(path.name, os.O_RDONLY | os.O_CLOEXEC | os.O_NONBLOCK | os.O_NOFOLLOW, dir_fd=parent_descriptor)", - " try:", - " persisted, _ = read_legacy_descriptor(path.name, parent_descriptor, verification)", - " finally:", - " os.close(verification)", - " assert_managed_parent_stable(path, parent_identity, 'legacy MCP config parent is unsafe')", - " return persisted", - " finally:", - " if tmp_fd is not None:", - " os.close(tmp_fd)", - " try:", - " os.unlink(tmp_name, dir_fd=parent_descriptor)", - " except FileNotFoundError:", - " pass", - "def remove_legacy_config(path, identity, descriptor, parent_descriptor, parent_identity):", - " assert_legacy_source_stable(path, identity, descriptor, parent_descriptor, parent_identity)", - " os.unlink(path.name, dir_fd=parent_descriptor)", - " try:", - " os.stat(path.name, dir_fd=parent_descriptor, follow_symlinks=False)", - " except FileNotFoundError:", - " pass", - " else:", - " raise ValueError('legacy MCP config removal verification failed')", - " assert_managed_parent_stable(path, parent_identity, 'legacy MCP config parent is unsafe')", ]; diff --git a/test/e2e-test.sh b/test/e2e-test.sh index 9a27498a085..5a11a76ca11 100755 --- a/test/e2e-test.sh +++ b/test/e2e-test.sh @@ -111,73 +111,19 @@ fi # ------------------------------------------------------- info "4. Verify blueprint runner plan command" # ------------------------------------------------------- -prepare_command_blueprint() { - local destination="$1" - node --input-type=module -e " - import { createRequire } from 'node:module'; - import { writeFileSync } from 'node:fs'; - import { join } from 'node:path'; - const require = createRequire('/opt/nemoclaw/'); - const YAML = require('yaml'); - const destination = process.argv[1]; - const blueprint = { - version: '1.0', - components: { - sandbox: { - image: 'openclaw', - name: 'openclaw', - forward_ports: [18789], - }, - inference: { - profiles: { - ncp: { - provider_type: 'nvidia', - provider_name: 'nvidia-ncp', - endpoint: '', - model: 'nvidia/nemotron-3-super-120b-a12b', - }, - }, - }, - policy: { - additions: { - nim_service: { - name: 'nim_service', - endpoints: [{ host: '93.184.216.34', port: 8000, access: 'full' }], - }, - }, - }, - }, - }; - writeFileSync(join(destination, 'blueprint.yaml'), YAML.stringify(blueprint)); - writeFileSync( - join(destination, 'sandbox-policy.yaml'), - YAML.stringify({ version: 1, network_policies: {} }), - ); - writeFileSync( - join(destination, 'private-networks.yaml'), - YAML.stringify({ ipv4: [], ipv6: [], names: [] }), - ); - " "$destination" -} - -( - PLAN_BLUEPRINT_PATH=$(mktemp -d) - trap 'rm -rf "$PLAN_BLUEPRINT_PATH"' EXIT - prepare_command_blueprint "$PLAN_BLUEPRINT_PATH" - cd "$PLAN_BLUEPRINT_PATH" - # Steps 3 and 3b validate the shipped blueprint. This command smoke test uses - # an independent fixture with a stable public IP so DNS cannot preempt the - # expected OpenShell prerequisite boundary. - NEMOCLAW_BLUEPRINT_PATH="$PLAN_BLUEPRINT_PATH" node --input-type=module -e " - const { main } = await import('/opt/nemoclaw/dist/blueprint/runner.js'); - try { - await main(['plan', '--profile', 'ncp', '--dry-run']); - } catch (err) { - if (!err.message.includes('openshell CLI not found')) throw err; - console.log('EXPECTED_ERROR: ' + err.message); - } - " -) 2>&1 | tee /tmp/plan-output.txt +cd /opt/nemoclaw-blueprint +# Runner will fail at openshell prereq check (expected in test container). +# Use 'ncp' profile (empty endpoint skips SSRF DNS lookup in sandbox). +# Catch only the expected error — anything else propagates as a real failure. +NEMOCLAW_BLUEPRINT_PATH=/opt/nemoclaw-blueprint node --input-type=module -e " + const { main } = await import('/opt/nemoclaw/dist/blueprint/runner.js'); + try { + await main(['plan', '--profile', 'ncp', '--dry-run']); + } catch (err) { + if (!err.message.includes('openshell CLI not found')) throw err; + console.log('EXPECTED_ERROR: ' + err.message); + } +" 2>&1 | tee /tmp/plan-output.txt if grep -q "RUN_ID:" /tmp/plan-output.txt; then pass "Blueprint plan generates run ID" else @@ -202,15 +148,13 @@ info "4b. Verify blueprint runner apply smoke test" # return the same metadata + YAML shape as OpenShell 0.0.72; an empty successful # response is intentionally rejected by the runner. FAKE_OPENSHELL_BIN=$(mktemp -d) -APPLY_BLUEPRINT_PATH=$(mktemp -d) APPLY_OUTPUT=$(mktemp) APPLY_CALLS="$FAKE_OPENSHELL_BIN/calls" cleanup_apply_fixture() { - rm -rf "$FAKE_OPENSHELL_BIN" "$APPLY_BLUEPRINT_PATH" + rm -rf "$FAKE_OPENSHELL_BIN" rm -f "$APPLY_OUTPUT" "$APPLY_CALLS" } trap cleanup_apply_fixture EXIT -prepare_command_blueprint "$APPLY_BLUEPRINT_PATH" cat >"$FAKE_OPENSHELL_BIN/openshell" <<'SH' #!/usr/bin/env bash set -euo pipefail @@ -244,7 +188,7 @@ if [ "${1:-} ${2:-}" = "policy get" ]; then fi if [ "${1:-} ${2:-}" = "policy get" ] && [[ " $* " == *" --output json "* ]]; then sandbox="${@: -1}" - policy_file="${OPENSHELL_SANDBOX_POLICY:?}" + policy_file=/opt/nemoclaw-blueprint/policies/openclaw-sandbox.yaml policy_hash=sha256:fixture-policy policy_version=1 if [ -f "${BASH_SOURCE[0]%/*}/effective-policy.yaml" ]; then @@ -274,12 +218,8 @@ case "$*" in echo "unexpected policy read: expected policy get -g fixture-gateway --base " >&2 exit 64 fi - policy_file="${OPENSHELL_SANDBOX_POLICY:?}" - if [ -f "${BASH_SOURCE[0]%/*}/effective-policy.yaml" ]; then - policy_file="${BASH_SOURCE[0]%/*}/effective-policy.yaml" - fi printf '%s\n' 'Policy for sandbox fixture' '---' - cat "$policy_file" + cat /opt/nemoclaw-blueprint/policies/openclaw-sandbox.yaml ;; "policy get "*) echo "unexpected policy read: expected policy get -g fixture-gateway --base " >&2 @@ -289,8 +229,8 @@ esac SH chmod 0755 "$FAKE_OPENSHELL_BIN/openshell" PATH="$FAKE_OPENSHELL_BIN:$PATH" \ - NEMOCLAW_BLUEPRINT_PATH="$APPLY_BLUEPRINT_PATH" \ - OPENSHELL_SANDBOX_POLICY="$APPLY_BLUEPRINT_PATH/sandbox-policy.yaml" \ + NEMOCLAW_BLUEPRINT_PATH=/opt/nemoclaw-blueprint \ + OPENSHELL_SANDBOX_POLICY=/opt/nemoclaw-blueprint/policies/openclaw-sandbox.yaml \ node --input-type=module -e " const { main } = await import('/opt/nemoclaw/dist/blueprint/runner.js'); await main(['apply', '--profile', 'ncp']); diff --git a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts index 1f43ee8c7ad..743f48ef5f9 100644 --- a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts +++ b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts @@ -34,11 +34,6 @@ export interface DeepAgentsConfigCommandResult { legacyConfigExists: boolean; legacyConfig: Record | null; legacyConfigText: string | null; - legacyParentIsSymlink: boolean; - legacyParentTargetText: string | null; - managedParentIsSymlink: boolean; - managedParentTargetText: string | null; - managedRaceIterations: number; managedSymlinkTargetExists: boolean; managedSymlinkTargetText: string | null; managedTargetReadAccessed: boolean; @@ -48,17 +43,68 @@ export interface DeepAgentsManagedFixtureOptions { danglingSymlink?: boolean; directory?: boolean; fifo?: boolean; - legacyParentSymlink?: boolean; mode?: number; - parentSymlink?: boolean; - raceAbsentPublication?: string; - raceProjection?: "fifo" | "symlink"; - socket?: boolean; + swapAfterManagedEloop?: boolean; + swapAfterManagedOpen?: "fifo" | "symlink"; + swapAfterMissingManagedOpen?: "fifo" | "symlink"; + swapBeforeManagedLink?: string; + swapOnManagedOpen?: "fifo" | "socket" | "symlink"; + swapOnManagedRead?: "fifo" | "symlink"; + swapOnManagedSeek?: string; symlink?: boolean; targetReadProbe?: boolean; timeoutMs?: number; } +type ManagedSwap = { + content?: string; + kind: "fifo" | "regular" | "socket" | "symlink"; + phase: + | "after-missing-projection-open" + | "after-projection-eloop" + | "after-projection-open" + | "before-projection-open" + | "projection-publication-begins" + | "projection-read-begins" + | "projection-rewrite-begins"; +}; + +function resolveManagedSwap(options: DeepAgentsManagedFixtureOptions): ManagedSwap | undefined { + if (options.swapOnManagedOpen) { + return { kind: options.swapOnManagedOpen, phase: "before-projection-open" }; + } + if (options.swapAfterManagedOpen) { + return { kind: options.swapAfterManagedOpen, phase: "after-projection-open" }; + } + if (options.swapAfterMissingManagedOpen) { + return { + kind: options.swapAfterMissingManagedOpen, + phase: "after-missing-projection-open", + }; + } + if (options.swapOnManagedRead) { + return { kind: options.swapOnManagedRead, phase: "projection-read-begins" }; + } + if (options.swapAfterManagedEloop) { + return { kind: "symlink", phase: "after-projection-eloop" }; + } + if (options.swapBeforeManagedLink !== undefined) { + return { + content: options.swapBeforeManagedLink, + kind: "regular", + phase: "projection-publication-begins", + }; + } + if (options.swapOnManagedSeek !== undefined) { + return { + content: options.swapOnManagedSeek, + kind: "regular", + phase: "projection-rewrite-begins", + }; + } + return undefined; +} + function createFifo(target: string, mode = 0o600): void { fs.mkdirSync(path.dirname(target), { recursive: true }); const fifo = spawnSync("mkfifo", [target], { encoding: "utf-8", timeout: 5000 }); @@ -79,170 +125,6 @@ function waitForPath(target: string, timeoutMs: number): boolean { return fs.existsSync(target); } -type ManagedFixtureProcess = { - child: ReturnType; - finishedPath: string; - resultPath: string; - stopPath: string; -}; - -function startManagedFixtureProcess( - fixtureRoot: string, - label: string, - script: string, - args: string[], -): ManagedFixtureProcess { - const readyPath = path.join(fixtureRoot, `${label}-ready`); - const stopPath = path.join(fixtureRoot, `${label}-stop`); - const finishedPath = path.join(fixtureRoot, `${label}-finished`); - const resultPath = path.join(fixtureRoot, `${label}-result`); - const child = spawn( - "python3", - ["-I", "-c", script, ...args, readyPath, stopPath, finishedPath, resultPath], - { stdio: "ignore" }, - ); - if (!waitForPath(readyPath, 2000)) { - child.kill("SIGTERM"); - throw new Error(`${label} did not become ready`); - } - pause(20); - return { child, finishedPath, resultPath, stopPath }; -} - -function stopManagedFixtureProcess(process: ManagedFixtureProcess): number { - try { - fs.writeFileSync(process.stopPath, "", { flag: "wx" }); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; - } - if (!waitForPath(process.finishedPath, 2000)) { - process.child.kill("SIGTERM"); - throw new Error("managed fixture process did not stop"); - } - process.child.kill("SIGTERM"); - const descriptor = fs.openSync( - process.resultPath, - fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK, - ); - let result: number; - try { - if (!fs.fstatSync(descriptor).isFile()) { - throw new Error("managed fixture process result is not a regular file"); - } - result = Number.parseInt(fs.readFileSync(descriptor, "utf-8"), 10); - } finally { - fs.closeSync(descriptor); - } - return Number.isFinite(result) ? result : 0; -} - -function startManagedPathRace( - fixtureRoot: string, - managedPath: string, - managedTarget: string, - kind: "fifo" | "symlink", - safeContent: string, -): ManagedFixtureProcess { - const script = [ - "import base64, os, sys, time", - "managed_path, managed_target, race_kind, encoded = sys.argv[1:5]", - "ready, stop, finished, result = sys.argv[5:9]", - "safe_content = base64.b64decode(encoded)", - "safe_temp = managed_path + '.race-safe'", - "unsafe_temp = managed_path + '.race-unsafe'", - "def remove_temp(target):", - " try:", - " os.unlink(target)", - " except FileNotFoundError:", - " pass", - "def replace_safe():", - " remove_temp(safe_temp)", - " descriptor = os.open(safe_temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)", - " try:", - " offset = 0", - " while offset < len(safe_content):", - " offset += os.write(descriptor, safe_content[offset:])", - " os.fsync(descriptor)", - " finally:", - " os.close(descriptor)", - " os.replace(safe_temp, managed_path)", - "def replace_unsafe():", - " remove_temp(unsafe_temp)", - " if race_kind == 'symlink':", - " os.symlink(managed_target, unsafe_temp)", - " else:", - " os.mkfifo(unsafe_temp, 0o600)", - " os.replace(unsafe_temp, managed_path)", - "iterations = 0", - "replace_unsafe()", - "open(ready, 'x').close()", - "while not os.path.exists(stop):", - " replace_safe()", - " iterations += 1", - " replace_unsafe()", - " iterations += 1", - " time.sleep(0.0001)", - "replace_unsafe()", - "open(result, 'w', encoding='utf-8').write(str(iterations))", - "open(finished, 'x').close()", - ].join("\n"); - return startManagedFixtureProcess(fixtureRoot, "managed-path-race", script, [ - managedPath, - managedTarget, - kind, - Buffer.from(safeContent).toString("base64"), - ]); -} - -function startAbsentPublicationRace( - fixtureRoot: string, - managedPath: string, - managedTarget: string, -): ManagedFixtureProcess { - const script = [ - "import os, sys, time", - "managed_path, managed_target = sys.argv[1:3]", - "ready, stop, finished, result = sys.argv[3:7]", - "parent = os.path.dirname(managed_path)", - "triggered = 0", - "open(ready, 'x').close()", - "while not os.path.exists(stop) and not triggered:", - " if os.path.isdir(parent):", - " try:", - " os.symlink(managed_target, managed_path)", - " triggered = 1", - " except FileExistsError:", - " pass", - " time.sleep(0.0001)", - "while not os.path.exists(stop):", - " time.sleep(0.001)", - "open(result, 'w', encoding='utf-8').write(str(triggered))", - "open(finished, 'x').close()", - ].join("\n"); - return startManagedFixtureProcess(fixtureRoot, "managed-publication-race", script, [ - managedPath, - managedTarget, - ]); -} - -function startManagedSocket(fixtureRoot: string, managedPath: string): ManagedFixtureProcess { - const script = [ - "import os, socket, sys, time", - "managed_path = sys.argv[1]", - "ready, stop, finished, result = sys.argv[2:6]", - "os.makedirs(os.path.dirname(managed_path), exist_ok=True)", - "managed_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)", - "managed_socket.bind(managed_path)", - "open(ready, 'x').close()", - "while not os.path.exists(stop):", - " time.sleep(0.001)", - "managed_socket.close()", - "open(result, 'w', encoding='utf-8').write('1')", - "open(finished, 'x').close()", - ].join("\n"); - return startManagedFixtureProcess(fixtureRoot, "managed-socket", script, [managedPath]); -} - function startTargetReadProbe(target: string, ready: string, accessed: string) { const script = [ "import os, sys", @@ -266,10 +148,12 @@ function createDeepAgentsFixturePythonExecutable( fixtureRoot: string, configPath: string, legacyConfigPath: string, + managedSymlinkTarget: string, runtimeKind: "v2" | "legacy" | "unknown", + managedSwap?: ManagedSwap, ): { executablePath: string; scriptPath: string } { - const executablePath = path.join(fixtureRoot, "fixture-python"); - const scriptPath = path.join(fixtureRoot, "fixture-wrapper.py"); + const executablePath = path.join(fixtureRoot, "managed-swap-python"); + const scriptPath = path.join(fixtureRoot, "managed-swap-wrapper.py"); const runtimeConfigPath = runtimeKind === "v2" ? configPath @@ -285,12 +169,118 @@ function createDeepAgentsFixturePythonExecutable( { mode: 0o600 }, ); const wrapper = [ + "import errno", + "import os", + "import socket", "import sys", `fixture_root = ${JSON.stringify(fixtureRoot)}`, + `managed_path = ${JSON.stringify(configPath)}`, + `managed_target = ${JSON.stringify(managedSymlinkTarget)}`, + `managed_swap_kind = ${JSON.stringify(managedSwap?.kind ?? "")}`, + `managed_swap_phase = ${JSON.stringify(managedSwap?.phase ?? "")}`, + `managed_swap_content = ${JSON.stringify(managedSwap?.content ?? "")}`, + "managed_eloop_stage = 0", + "managed_socket = None", + "managed_swapped = False", + "managed_descriptor = None", + "managed_entry_name = os.path.basename(managed_path)", + "managed_real_link = os.link", + "managed_real_lseek = os.lseek", + "managed_real_open = os.open", + "managed_real_read = os.read", + "def replace_managed_path():", + " global managed_socket, managed_swapped", + " if managed_swapped:", + " return", + " managed_swapped = True", + " try:", + " os.unlink(managed_path)", + " except FileNotFoundError:", + " pass", + " if managed_swap_kind == 'symlink':", + " os.symlink(managed_target, managed_path)", + " elif managed_swap_kind == 'fifo':", + " os.mkfifo(managed_path, 0o600)", + " elif managed_swap_kind == 'socket':", + " managed_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)", + " managed_socket.bind(managed_path)", + " else:", + " with open(managed_path, 'w', encoding='utf-8') as replacement:", + " replacement.write(managed_swap_content)", + " os.chmod(managed_path, 0o600)", + "def replace_managed_symlink_with_regular():", + " global managed_eloop_stage", + " os.unlink(managed_path)", + " with open(managed_target, 'rb') as source, open(managed_path, 'wb') as replacement:", + " replacement.write(source.read())", + " os.chmod(managed_path, 0o600)", + " managed_eloop_stage = 1", + "def restore_managed_symlink():", + " global managed_eloop_stage", + " os.unlink(managed_path)", + " os.symlink(managed_target, managed_path)", + " managed_eloop_stage = 2", + "def is_managed_entry(value, directory_descriptor):", + " try:", + " candidate = os.fspath(value)", + " return candidate == managed_path if directory_descriptor is None else candidate == managed_entry_name", + " except TypeError:", + " return False", + "def managed_boundary_open(value, flags, mode=0o777, *, dir_fd=None):", + " global managed_descriptor", + " projection_entry = is_managed_entry(value, dir_fd)", + " if projection_entry and managed_swap_phase == 'before-projection-open':", + " replace_managed_path()", + " try:", + " if dir_fd is None:", + " descriptor = managed_real_open(value, flags, mode)", + " else:", + " descriptor = managed_real_open(value, flags, mode, dir_fd=dir_fd)", + " except OSError as exc:", + " if projection_entry and managed_swap_phase == 'after-missing-projection-open' and isinstance(exc, FileNotFoundError):", + " replace_managed_path()", + " elif projection_entry and managed_swap_phase == 'after-projection-eloop' and exc.errno == errno.ELOOP:", + " replace_managed_symlink_with_regular()", + " raise", + " if projection_entry:", + " managed_descriptor = descriptor", + " if managed_swap_phase == 'after-projection-open':", + " replace_managed_path()", + " return descriptor", + "def managed_boundary_read(descriptor, length):", + " if managed_swap_phase == 'projection-read-begins' and descriptor == managed_descriptor:", + " replace_managed_path()", + " return managed_real_read(descriptor, length)", + "def managed_boundary_link(source, destination, *args, **kwargs):", + " if managed_swap_phase == 'projection-publication-begins' and is_managed_entry(destination, kwargs.get('dst_dir_fd')):", + " replace_managed_path()", + " return managed_real_link(source, destination, *args, **kwargs)", + "def managed_boundary_lseek(descriptor, position, how):", + " if managed_swap_phase == 'projection-rewrite-begins' and descriptor == managed_descriptor:", + " replace_managed_path()", + " return managed_real_lseek(descriptor, position, how)", + "def install_managed_boundaries():", + " if not managed_swap_phase:", + " return", + " os.link = managed_boundary_link", + " os.lseek = managed_boundary_lseek", + " os.open = managed_boundary_open", + " os.read = managed_boundary_read", + "def restore_managed_boundaries():", + " os.link = managed_real_link", + " os.lseek = managed_real_lseek", + " os.open = managed_real_open", + " os.read = managed_real_read", "source = sys.stdin.read()", "namespace = {'__name__': '__main__'}", "sys.path.insert(0, fixture_root)", - "exec(compile(source, '', 'exec'), namespace)", + "install_managed_boundaries()", + "try:", + " exec(compile(source, '', 'exec'), namespace)", + "finally:", + " restore_managed_boundaries()", + " if managed_swap_phase == 'after-projection-eloop' and managed_eloop_stage == 1:", + " restore_managed_symlink()", "", ].join("\n"); fs.writeFileSync(scriptPath, wrapper, { mode: 0o600 }); @@ -311,27 +301,14 @@ export function runDeepAgentsConfigCommand( managedOptions: DeepAgentsManagedFixtureOptions = {}, runtimeEnvironment: NodeJS.ProcessEnv = {}, ): DeepAgentsConfigCommandResult { - const activeFixtureProcesses = [ - managedOptions.raceAbsentPublication !== undefined, - managedOptions.raceProjection !== undefined, - managedOptions.socket === true, - ].filter(Boolean).length; - if (activeFixtureProcesses > 1) { - throw new Error("managed fixture process options are mutually exclusive"); - } - const fixtureRoot = managedOptions.socket ? "/tmp" : os.tmpdir(); - const tmp = fs.realpathSync(fs.mkdtempSync(path.join(fixtureRoot, "nemoclaw-deepagents-mcp-"))); + const managedSwap = resolveManagedSwap(managedOptions); + const fixtureRoot = managedSwap?.kind === "socket" ? "/tmp" : os.tmpdir(); + const tmp = fs.mkdtempSync(path.join(fixtureRoot, "nemoclaw-deepagents-mcp-")); const configPath = path.join(tmp, ".deepagents", ".nemoclaw-mcp.json"); - const managedParentPath = path.dirname(configPath); - const managedParentTarget = path.join(tmp, "managed-parent-target"); - const managedParentTargetConfig = path.join(managedParentTarget, path.basename(configPath)); const managedSymlinkTarget = path.join(tmp, "managed-projection-target.json"); const managedTargetReadReady = path.join(tmp, "managed-target-read-ready"); const managedTargetReadAccess = path.join(tmp, "managed-target-read-accessed"); const legacyConfigPath = path.join(tmp, ".deepagents", ".mcp.json"); - const legacyParentPath = path.dirname(legacyConfigPath); - const legacyParentTarget = path.join(tmp, "legacy-parent-target"); - const legacyParentTargetConfig = path.join(legacyParentTarget, path.basename(legacyConfigPath)); const initializeConfig = ( target: string, value: Record | string | undefined, @@ -346,78 +323,44 @@ export function runDeepAgentsConfigCommand( ); }; const managedSymlink = managedOptions.symlink === true || managedOptions.danglingSymlink === true; - if ( - managedOptions.legacyParentSymlink && - (initialConfig !== undefined || - managedOptions.danglingSymlink || - managedOptions.directory || - managedOptions.fifo || - managedOptions.parentSymlink || - managedOptions.raceAbsentPublication !== undefined || - managedOptions.raceProjection !== undefined || - managedOptions.socket || - managedOptions.symlink) - ) { - throw new Error("legacyParentSymlink cannot share the managed projection parent fixture"); - } const managedTargetReadPath = managedOptions.targetReadProbe - ? managedOptions.parentSymlink - ? managedParentTargetConfig - : managedSymlink - ? managedSymlinkTarget - : null + ? managedSymlink + ? managedSymlinkTarget + : null : null; if (managedOptions.targetReadProbe && managedTargetReadPath === null) { - throw new Error("targetReadProbe requires a projection or parent symlink"); + throw new Error("targetReadProbe requires a projection symlink"); } const managedInitialPath = managedSymlink ? managedSymlinkTarget : configPath; - if (managedOptions.parentSymlink) { - fs.mkdirSync(managedParentTarget, { recursive: true }); - if (managedTargetReadPath === managedParentTargetConfig) { - createFifo(managedParentTargetConfig, managedOptions.mode); - } else { - initializeConfig(managedParentTargetConfig, initialConfig, managedOptions.mode); - } - fs.symlinkSync(managedParentTarget, managedParentPath); - } else if (managedOptions.directory) { + if (managedOptions.directory) { fs.mkdirSync(configPath, { recursive: true, mode: managedOptions.mode ?? 0o700 }); } else if (managedOptions.fifo) { createFifo(configPath, managedOptions.mode); - } else if (managedOptions.socket) { - // The socket holder creates the path after the rest of the fixture is ready. } else { if (managedTargetReadPath === managedSymlinkTarget) { createFifo(managedSymlinkTarget, managedOptions.mode); } else if ( !managedOptions.danglingSymlink && - managedOptions.raceAbsentPublication === undefined + managedSwap?.phase !== "after-missing-projection-open" ) { initializeConfig(managedInitialPath, initialConfig, managedOptions.mode); if (initialConfig !== undefined) fs.chmodSync(managedInitialPath, managedOptions.mode ?? 0o600); } + if (managedSwap?.phase === "after-missing-projection-open") { + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + } if (managedSymlink) { fs.mkdirSync(path.dirname(configPath), { recursive: true }); fs.symlinkSync(managedSymlinkTarget, configPath); } } - if (managedOptions.raceProjection === "symlink") { + if (managedSwap?.kind === "symlink") { initializeConfig(managedSymlinkTarget, initialConfig, managedOptions.mode); } - if (managedOptions.raceAbsentPublication !== undefined) { - initializeConfig(managedSymlinkTarget, managedOptions.raceAbsentPublication); - } - if (managedOptions.legacyParentSymlink) { - fs.mkdirSync(legacyParentTarget, { recursive: true }); - initializeConfig(legacyParentTargetConfig, initialLegacyConfig, initialLegacyMode); - fs.symlinkSync(legacyParentTarget, legacyParentPath); - } else { - initializeConfig(legacyConfigPath, initialLegacyConfig, initialLegacyMode); - } + initializeConfig(legacyConfigPath, initialLegacyConfig); + if (initialLegacyConfig !== undefined) fs.chmodSync(legacyConfigPath, initialLegacyMode); let targetReadProbe: ReturnType | null = null; - let managedRace: ManagedFixtureProcess | null = null; - let socketFixture: ManagedFixtureProcess | null = null; - let managedRaceIterations = 0; try { if (managedTargetReadPath !== null) { targetReadProbe = startTargetReadProbe( @@ -426,39 +369,22 @@ export function runDeepAgentsConfigCommand( managedTargetReadAccess, ); } - if (managedOptions.socket) { - socketFixture = startManagedSocket(tmp, configPath); - } else if (managedOptions.raceProjection) { - if (initialConfig === undefined) throw new Error("raceProjection requires initialConfig"); - const safeContent = - typeof initialConfig === "string" - ? initialConfig - : `${JSON.stringify(initialConfig, null, 2)}\n`; - managedRace = startManagedPathRace( - tmp, - configPath, - managedSymlinkTarget, - managedOptions.raceProjection, - safeContent, - ); - } else if (managedOptions.raceAbsentPublication !== undefined) { - managedRace = startAbsentPublicationRace(tmp, configPath, managedSymlinkTarget); - } const fixturePython = createDeepAgentsFixturePythonExecutable( tmp, configPath, legacyConfigPath, + managedSymlinkTarget, runtimeKind, + managedSwap, ); const fixtureCommand = command .replaceAll(DEEPAGENTS_MCP_CONFIG_PATH, configPath) .replaceAll("/sandbox/.deepagents/.mcp.json", legacyConfigPath) .replaceAll("/opt/venv/bin/python3", fixturePython.executablePath); const canonicalEnvironment = Object.fromEntries( - [...command.matchAll(/openshell:resolve:env:([A-Za-z_][A-Za-z0-9_]*)/gu)].map(([, name]) => [ - name!, - `openshell:resolve:env:${name!}`, - ]), + [...command.matchAll(/openshell:resolve:env:([A-Za-z_][A-Za-z0-9_]*)/gu)].map( + ([, name]) => [name!, `openshell:resolve:env:${name!}`], + ), ); const fixtureCommandPath = path.join(tmp, "managed-command.sh"); fs.writeFileSync(fixtureCommandPath, fixtureCommand, { mode: 0o600 }); @@ -472,14 +398,6 @@ export function runDeepAgentsConfigCommand( }, timeout: managedOptions.timeoutMs ?? 5000, }); - if (managedRace !== null) { - managedRaceIterations = stopManagedFixtureProcess(managedRace); - managedRace = null; - } - if (socketFixture !== null) { - stopManagedFixtureProcess(socketFixture); - socketFixture = null; - } let configStat: fs.Stats | null = null; try { configStat = fs.lstatSync(configPath); @@ -487,19 +405,7 @@ export function runDeepAgentsConfigCommand( // A missing projection has no path type to report. } const configExists = configStat !== null; - let managedParentIsSymlink = false; - try { - managedParentIsSymlink = fs.lstatSync(managedParentPath).isSymbolicLink(); - } catch { - // A missing projection parent cannot redirect the managed path. - } const legacyConfigExists = fs.existsSync(legacyConfigPath); - let legacyParentIsSymlink = false; - try { - legacyParentIsSymlink = fs.lstatSync(legacyParentPath).isSymbolicLink(); - } catch { - // A missing legacy parent cannot redirect the legacy config path. - } const configIsFifo = configStat?.isFIFO() === true; const configIsSocket = configStat?.isSocket() === true; const configIsSymlink = configStat?.isSymbolicLink() === true; @@ -525,14 +431,9 @@ export function runDeepAgentsConfigCommand( }; const managedSymlinkTargetText = managedTargetReadPath === managedSymlinkTarget ? null : readRegularFile(managedSymlinkTarget); - const managedParentTargetText = - managedTargetReadPath === managedParentTargetConfig - ? null - : readRegularFile(managedParentTargetConfig); const managedTargetReadAccessed = managedTargetReadPath !== null && waitForPath(managedTargetReadAccess, 200); const legacyConfigText = legacyConfigExists ? fs.readFileSync(legacyConfigPath, "utf-8") : null; - const legacyParentTargetText = readRegularFile(legacyParentTargetConfig); const parseConfigText = (text: string | null): Record | null => { if (!text) return null; try { @@ -558,18 +459,11 @@ export function runDeepAgentsConfigCommand( legacyConfigExists, legacyConfig: parseConfigText(legacyConfigText), legacyConfigText, - legacyParentIsSymlink, - legacyParentTargetText, - managedParentIsSymlink, - managedParentTargetText, - managedRaceIterations, managedSymlinkTargetExists, managedSymlinkTargetText, managedTargetReadAccessed, }; } finally { - if (managedRace !== null) managedRace.child.kill("SIGTERM"); - if (socketFixture !== null) socketFixture.child.kill("SIGTERM"); targetReadProbe?.kill("SIGTERM"); fs.rmSync(tmp, { recursive: true, force: true }); } diff --git a/test/runtime/policy/policies.test.ts b/test/runtime/policy/policies.test.ts index 3a5c1132e1b..283d2c912a8 100644 --- a/test/runtime/policy/policies.test.ts +++ b/test/runtime/policy/policies.test.ts @@ -616,16 +616,10 @@ exit 1 it("applyPreset does not create temp dirs when bounded policy observation loses OpenShell", () => { const policyTempPrefix = path.join(os.tmpdir(), "nemoclaw-policy-"); - const policyReadSpy = vi - .spyOn(policyReaderModule.syncCliOpenShellSandboxPolicyReader, "readSandboxPolicy") - .mockReturnValue({ - ok: false, - error: { - kind: "command", - reason: "failed", - message: "OpenShell became unavailable during the bounded policy read.", - }, - }); + const resolveSpy = vi + .spyOn(resolveOpenshellModule, "resolveOpenshell") + .mockReturnValueOnce(fakeOpenshell) + .mockReturnValue(null); const mkdtempSpy = vi.spyOn(fs, "mkdtempSync"); const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); @@ -645,7 +639,7 @@ exit 1 mkdtempSpy.mock.calls.filter(([prefix]) => String(prefix).startsWith(policyTempPrefix)), ).toEqual([]); } finally { - policyReadSpy.mockRestore(); + resolveSpy.mockRestore(); mkdtempSpy.mockRestore(); errSpy.mockRestore(); logSpy.mockRestore(); From eefd9982856d75feef29ec5d58fe6aafaf9dad2f Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Wed, 2 Sep 2026 06:16:17 -0700 Subject: [PATCH 37/49] fix(ci): restore review-head validation fixtures --- docs/manage-sandboxes/manage-mcp-servers.mdx | 2 +- test/e2e-test.sh | 96 ++++++++++++++++---- test/runtime/policy/policies.test.ts | 16 +++- 3 files changed, 90 insertions(+), 24 deletions(-) diff --git a/docs/manage-sandboxes/manage-mcp-servers.mdx b/docs/manage-sandboxes/manage-mcp-servers.mdx index 8a564cd373d..b74dbb11036 100644 --- a/docs/manage-sandboxes/manage-mcp-servers.mdx +++ b/docs/manage-sandboxes/manage-mcp-servers.mdx @@ -43,7 +43,7 @@ Before it reads `/sandbox/.deepagents/.nemoclaw-mcp.json`, Deep Agents Code v2 ` It does not follow a final symbolic link or read from a FIFO. If the path is a symbolic link, FIFO, or another non-regular file, the command names the path type, writes no status output, and exits with status `2`. The diagnostic starts with `Unsafe managed Deep Agents MCP projection path:` and names a type such as `symbolic link`, `FIFO`, or `non-regular file`. -Exit status `2` is specific to this unsafe path-type result. +For this unsafe path-type result, the command exits with status `2`. Deep Agents Code v1 reads the legacy `.deepagents/.mcp.json` path and does not expose these type-specific diagnostics. diff --git a/test/e2e-test.sh b/test/e2e-test.sh index 5a11a76ca11..9a27498a085 100755 --- a/test/e2e-test.sh +++ b/test/e2e-test.sh @@ -111,19 +111,73 @@ fi # ------------------------------------------------------- info "4. Verify blueprint runner plan command" # ------------------------------------------------------- -cd /opt/nemoclaw-blueprint -# Runner will fail at openshell prereq check (expected in test container). -# Use 'ncp' profile (empty endpoint skips SSRF DNS lookup in sandbox). -# Catch only the expected error — anything else propagates as a real failure. -NEMOCLAW_BLUEPRINT_PATH=/opt/nemoclaw-blueprint node --input-type=module -e " - const { main } = await import('/opt/nemoclaw/dist/blueprint/runner.js'); - try { - await main(['plan', '--profile', 'ncp', '--dry-run']); - } catch (err) { - if (!err.message.includes('openshell CLI not found')) throw err; - console.log('EXPECTED_ERROR: ' + err.message); - } -" 2>&1 | tee /tmp/plan-output.txt +prepare_command_blueprint() { + local destination="$1" + node --input-type=module -e " + import { createRequire } from 'node:module'; + import { writeFileSync } from 'node:fs'; + import { join } from 'node:path'; + const require = createRequire('/opt/nemoclaw/'); + const YAML = require('yaml'); + const destination = process.argv[1]; + const blueprint = { + version: '1.0', + components: { + sandbox: { + image: 'openclaw', + name: 'openclaw', + forward_ports: [18789], + }, + inference: { + profiles: { + ncp: { + provider_type: 'nvidia', + provider_name: 'nvidia-ncp', + endpoint: '', + model: 'nvidia/nemotron-3-super-120b-a12b', + }, + }, + }, + policy: { + additions: { + nim_service: { + name: 'nim_service', + endpoints: [{ host: '93.184.216.34', port: 8000, access: 'full' }], + }, + }, + }, + }, + }; + writeFileSync(join(destination, 'blueprint.yaml'), YAML.stringify(blueprint)); + writeFileSync( + join(destination, 'sandbox-policy.yaml'), + YAML.stringify({ version: 1, network_policies: {} }), + ); + writeFileSync( + join(destination, 'private-networks.yaml'), + YAML.stringify({ ipv4: [], ipv6: [], names: [] }), + ); + " "$destination" +} + +( + PLAN_BLUEPRINT_PATH=$(mktemp -d) + trap 'rm -rf "$PLAN_BLUEPRINT_PATH"' EXIT + prepare_command_blueprint "$PLAN_BLUEPRINT_PATH" + cd "$PLAN_BLUEPRINT_PATH" + # Steps 3 and 3b validate the shipped blueprint. This command smoke test uses + # an independent fixture with a stable public IP so DNS cannot preempt the + # expected OpenShell prerequisite boundary. + NEMOCLAW_BLUEPRINT_PATH="$PLAN_BLUEPRINT_PATH" node --input-type=module -e " + const { main } = await import('/opt/nemoclaw/dist/blueprint/runner.js'); + try { + await main(['plan', '--profile', 'ncp', '--dry-run']); + } catch (err) { + if (!err.message.includes('openshell CLI not found')) throw err; + console.log('EXPECTED_ERROR: ' + err.message); + } + " +) 2>&1 | tee /tmp/plan-output.txt if grep -q "RUN_ID:" /tmp/plan-output.txt; then pass "Blueprint plan generates run ID" else @@ -148,13 +202,15 @@ info "4b. Verify blueprint runner apply smoke test" # return the same metadata + YAML shape as OpenShell 0.0.72; an empty successful # response is intentionally rejected by the runner. FAKE_OPENSHELL_BIN=$(mktemp -d) +APPLY_BLUEPRINT_PATH=$(mktemp -d) APPLY_OUTPUT=$(mktemp) APPLY_CALLS="$FAKE_OPENSHELL_BIN/calls" cleanup_apply_fixture() { - rm -rf "$FAKE_OPENSHELL_BIN" + rm -rf "$FAKE_OPENSHELL_BIN" "$APPLY_BLUEPRINT_PATH" rm -f "$APPLY_OUTPUT" "$APPLY_CALLS" } trap cleanup_apply_fixture EXIT +prepare_command_blueprint "$APPLY_BLUEPRINT_PATH" cat >"$FAKE_OPENSHELL_BIN/openshell" <<'SH' #!/usr/bin/env bash set -euo pipefail @@ -188,7 +244,7 @@ if [ "${1:-} ${2:-}" = "policy get" ]; then fi if [ "${1:-} ${2:-}" = "policy get" ] && [[ " $* " == *" --output json "* ]]; then sandbox="${@: -1}" - policy_file=/opt/nemoclaw-blueprint/policies/openclaw-sandbox.yaml + policy_file="${OPENSHELL_SANDBOX_POLICY:?}" policy_hash=sha256:fixture-policy policy_version=1 if [ -f "${BASH_SOURCE[0]%/*}/effective-policy.yaml" ]; then @@ -218,8 +274,12 @@ case "$*" in echo "unexpected policy read: expected policy get -g fixture-gateway --base " >&2 exit 64 fi + policy_file="${OPENSHELL_SANDBOX_POLICY:?}" + if [ -f "${BASH_SOURCE[0]%/*}/effective-policy.yaml" ]; then + policy_file="${BASH_SOURCE[0]%/*}/effective-policy.yaml" + fi printf '%s\n' 'Policy for sandbox fixture' '---' - cat /opt/nemoclaw-blueprint/policies/openclaw-sandbox.yaml + cat "$policy_file" ;; "policy get "*) echo "unexpected policy read: expected policy get -g fixture-gateway --base " >&2 @@ -229,8 +289,8 @@ esac SH chmod 0755 "$FAKE_OPENSHELL_BIN/openshell" PATH="$FAKE_OPENSHELL_BIN:$PATH" \ - NEMOCLAW_BLUEPRINT_PATH=/opt/nemoclaw-blueprint \ - OPENSHELL_SANDBOX_POLICY=/opt/nemoclaw-blueprint/policies/openclaw-sandbox.yaml \ + NEMOCLAW_BLUEPRINT_PATH="$APPLY_BLUEPRINT_PATH" \ + OPENSHELL_SANDBOX_POLICY="$APPLY_BLUEPRINT_PATH/sandbox-policy.yaml" \ node --input-type=module -e " const { main } = await import('/opt/nemoclaw/dist/blueprint/runner.js'); await main(['apply', '--profile', 'ncp']); diff --git a/test/runtime/policy/policies.test.ts b/test/runtime/policy/policies.test.ts index 283d2c912a8..3a5c1132e1b 100644 --- a/test/runtime/policy/policies.test.ts +++ b/test/runtime/policy/policies.test.ts @@ -616,10 +616,16 @@ exit 1 it("applyPreset does not create temp dirs when bounded policy observation loses OpenShell", () => { const policyTempPrefix = path.join(os.tmpdir(), "nemoclaw-policy-"); - const resolveSpy = vi - .spyOn(resolveOpenshellModule, "resolveOpenshell") - .mockReturnValueOnce(fakeOpenshell) - .mockReturnValue(null); + const policyReadSpy = vi + .spyOn(policyReaderModule.syncCliOpenShellSandboxPolicyReader, "readSandboxPolicy") + .mockReturnValue({ + ok: false, + error: { + kind: "command", + reason: "failed", + message: "OpenShell became unavailable during the bounded policy read.", + }, + }); const mkdtempSpy = vi.spyOn(fs, "mkdtempSync"); const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); @@ -639,7 +645,7 @@ exit 1 mkdtempSpy.mock.calls.filter(([prefix]) => String(prefix).startsWith(policyTempPrefix)), ).toEqual([]); } finally { - resolveSpy.mockRestore(); + policyReadSpy.mockRestore(); mkdtempSpy.mockRestore(); errSpy.mockRestore(); logSpy.mockRestore(); From 1cf2440c013c80b3a3133a08555093f2faee5590 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Wed, 2 Sep 2026 06:35:40 -0700 Subject: [PATCH 38/49] test(mcp): simplify projection status coverage --- ...idge-adapter-deepagents-projection.test.ts | 15 ++-- .../mcp-bridge-status-resolution.test.ts | 79 +++++++++---------- 2 files changed, 48 insertions(+), 46 deletions(-) diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts index f0a4ad5d8c5..35c105428f6 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts @@ -291,7 +291,13 @@ describe("Deep Agents managed MCP projection safety", () => { }, ])( "classifies a $diagnostic that appears after a missing-path open (#10754)", - ({ appearedType, diagnostic }) => { + ({ + appearedType, + diagnostic, + expectedConfigIsFifo, + expectedConfigIsSymlink, + expectedTargetText, + }) => { const result = runDeepAgentsConfigCommand( buildDeepAgentsMcpStatusCommand(baseEntry), emptyProjection, @@ -306,10 +312,9 @@ describe("Deep Agents managed MCP projection safety", () => { expect(result.stderr).toContain( `Unsafe managed Deep Agents MCP projection path: ${diagnostic}`, ); - expect(appearedType === "symlink" ? result.configIsSymlink : result.configIsFifo).toBe(true); - expect(result.managedSymlinkTargetText).toBe( - appearedType === "symlink" ? `${JSON.stringify(emptyProjection, null, 2)}\n` : null, - ); + expect(result.configIsFifo).toBe(expectedConfigIsFifo); + expect(result.configIsSymlink).toBe(expectedConfigIsSymlink); + expect(result.managedSymlinkTargetText).toBe(expectedTargetText); }, ); diff --git a/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts b/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts index 3582140ab2c..44a7f9ad6ed 100644 --- a/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts @@ -205,31 +205,29 @@ ${body} describe("MCP status wire-level credential-resolution probe", { timeout: 15_000 }, () => { it( - "returns a type-specific failure for unsafe Deep Agents projection races (#10754)", + "preserves an unsafe Deep Agents projection failure across credential observations (#10754)", { timeout: 120_000 }, () => { const statusCommand = buildDeepAgentsMcpStatusCommand(baseEntry); const projection = { mcpServers: {} }; - const unsafeResults = [ - ...(["symlink", "fifo"] as const).map((appearedType) => - runDeepAgentsConfigCommand(statusCommand, projection, "v2", undefined, 0o600, { - swapAfterMissingManagedOpen: appearedType, - timeoutMs: 30_000, - }), - ), - runDeepAgentsConfigCommand(statusCommand, projection, "v2", undefined, 0o600, { - swapAfterManagedEloop: true, - symlink: true, + const unsafeResult = runDeepAgentsConfigCommand( + statusCommand, + projection, + "v2", + undefined, + 0o600, + { + swapAfterMissingManagedOpen: "symlink", timeoutMs: 30_000, - }), - ]; - unsafeResults.forEach((result) => { - expect(result.status).toBe(2); - expect(result.stdout.trim()).toBe(""); - expect(result.stderr).toContain("Unsafe managed Deep Agents MCP projection path:"); - expect(result.configIsSymlink || result.configIsFifo).toBe(true); - }); - const unsafeDetails = unsafeResults.map((result) => result.stderr.trim()); + }, + ); + expect(unsafeResult.status).toBe(2); + expect(unsafeResult.stdout.trim()).toBe(""); + expect(unsafeResult.stderr).toContain( + "Unsafe managed Deep Agents MCP projection path: symbolic link", + ); + expect(unsafeResult.configIsSymlink).toBe(true); + const unsafeDetail = unsafeResult.stderr.trim(); const home = createTempHome("nemoclaw-mcp-status-projection-"); const { stdout } = runHarness( home, @@ -249,26 +247,25 @@ describe("MCP status wire-level credential-resolution probe", { timeout: 15_000 }, }); const outcomes = []; - for (const detail of ${JSON.stringify(unsafeDetails)}) { - for (const observation of [null, "absent", "canonical"]) { - providerCredentialObservation = observation; - processRecovery.executeSandboxCommand = () => ({ - status: 2, - stdout: "", - stderr: detail + "\n", - }); - logLines.length = 0; - errorLines.length = 0; - await bridge.dispatchMcpBridgeCommand("alpha", ["status", "github", "--no-probe", "--json"]); - outcomes.push({ - detail, - observation: observation ?? "unavailable", - exitCode: process.exitCode ?? 0, - stdout: logLines.join("\n"), - stderr: errorLines.join("\n"), - }); - process.exitCode = 0; - } + const detail = ${JSON.stringify(unsafeDetail)}; + for (const observation of [null, "absent", "canonical"]) { + providerCredentialObservation = observation; + processRecovery.executeSandboxCommand = () => ({ + status: 2, + stdout: "", + stderr: detail + "\n", + }); + logLines.length = 0; + errorLines.length = 0; + await bridge.dispatchMcpBridgeCommand("alpha", ["status", "github", "--no-probe", "--json"]); + outcomes.push({ + detail, + observation: observation ?? "unavailable", + exitCode: process.exitCode ?? 0, + stdout: logLines.join("\n"), + stderr: errorLines.join("\n"), + }); + process.exitCode = 0; } writeHarnessResult(JSON.stringify(outcomes)); `, @@ -281,7 +278,7 @@ describe("MCP status wire-level credential-resolution probe", { timeout: 15_000 stderr: string; }>; - expect(outcomes).toHaveLength(9); + expect(outcomes).toHaveLength(3); outcomes.forEach((outcome) => { expect(["unavailable", "absent", "canonical"]).toContain(outcome.observation); expect(outcome.exitCode).toBe(2); From 9f9eeb6751a649a24b7b715c34bb5e3aab3bddb6 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Wed, 2 Sep 2026 06:36:10 -0700 Subject: [PATCH 39/49] fix(mcp): keep projection status change scoped Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- test/e2e-test.sh | 96 ++++++---------------------- test/runtime/policy/policies.test.ts | 16 ++--- 2 files changed, 23 insertions(+), 89 deletions(-) diff --git a/test/e2e-test.sh b/test/e2e-test.sh index 9a27498a085..5a11a76ca11 100755 --- a/test/e2e-test.sh +++ b/test/e2e-test.sh @@ -111,73 +111,19 @@ fi # ------------------------------------------------------- info "4. Verify blueprint runner plan command" # ------------------------------------------------------- -prepare_command_blueprint() { - local destination="$1" - node --input-type=module -e " - import { createRequire } from 'node:module'; - import { writeFileSync } from 'node:fs'; - import { join } from 'node:path'; - const require = createRequire('/opt/nemoclaw/'); - const YAML = require('yaml'); - const destination = process.argv[1]; - const blueprint = { - version: '1.0', - components: { - sandbox: { - image: 'openclaw', - name: 'openclaw', - forward_ports: [18789], - }, - inference: { - profiles: { - ncp: { - provider_type: 'nvidia', - provider_name: 'nvidia-ncp', - endpoint: '', - model: 'nvidia/nemotron-3-super-120b-a12b', - }, - }, - }, - policy: { - additions: { - nim_service: { - name: 'nim_service', - endpoints: [{ host: '93.184.216.34', port: 8000, access: 'full' }], - }, - }, - }, - }, - }; - writeFileSync(join(destination, 'blueprint.yaml'), YAML.stringify(blueprint)); - writeFileSync( - join(destination, 'sandbox-policy.yaml'), - YAML.stringify({ version: 1, network_policies: {} }), - ); - writeFileSync( - join(destination, 'private-networks.yaml'), - YAML.stringify({ ipv4: [], ipv6: [], names: [] }), - ); - " "$destination" -} - -( - PLAN_BLUEPRINT_PATH=$(mktemp -d) - trap 'rm -rf "$PLAN_BLUEPRINT_PATH"' EXIT - prepare_command_blueprint "$PLAN_BLUEPRINT_PATH" - cd "$PLAN_BLUEPRINT_PATH" - # Steps 3 and 3b validate the shipped blueprint. This command smoke test uses - # an independent fixture with a stable public IP so DNS cannot preempt the - # expected OpenShell prerequisite boundary. - NEMOCLAW_BLUEPRINT_PATH="$PLAN_BLUEPRINT_PATH" node --input-type=module -e " - const { main } = await import('/opt/nemoclaw/dist/blueprint/runner.js'); - try { - await main(['plan', '--profile', 'ncp', '--dry-run']); - } catch (err) { - if (!err.message.includes('openshell CLI not found')) throw err; - console.log('EXPECTED_ERROR: ' + err.message); - } - " -) 2>&1 | tee /tmp/plan-output.txt +cd /opt/nemoclaw-blueprint +# Runner will fail at openshell prereq check (expected in test container). +# Use 'ncp' profile (empty endpoint skips SSRF DNS lookup in sandbox). +# Catch only the expected error — anything else propagates as a real failure. +NEMOCLAW_BLUEPRINT_PATH=/opt/nemoclaw-blueprint node --input-type=module -e " + const { main } = await import('/opt/nemoclaw/dist/blueprint/runner.js'); + try { + await main(['plan', '--profile', 'ncp', '--dry-run']); + } catch (err) { + if (!err.message.includes('openshell CLI not found')) throw err; + console.log('EXPECTED_ERROR: ' + err.message); + } +" 2>&1 | tee /tmp/plan-output.txt if grep -q "RUN_ID:" /tmp/plan-output.txt; then pass "Blueprint plan generates run ID" else @@ -202,15 +148,13 @@ info "4b. Verify blueprint runner apply smoke test" # return the same metadata + YAML shape as OpenShell 0.0.72; an empty successful # response is intentionally rejected by the runner. FAKE_OPENSHELL_BIN=$(mktemp -d) -APPLY_BLUEPRINT_PATH=$(mktemp -d) APPLY_OUTPUT=$(mktemp) APPLY_CALLS="$FAKE_OPENSHELL_BIN/calls" cleanup_apply_fixture() { - rm -rf "$FAKE_OPENSHELL_BIN" "$APPLY_BLUEPRINT_PATH" + rm -rf "$FAKE_OPENSHELL_BIN" rm -f "$APPLY_OUTPUT" "$APPLY_CALLS" } trap cleanup_apply_fixture EXIT -prepare_command_blueprint "$APPLY_BLUEPRINT_PATH" cat >"$FAKE_OPENSHELL_BIN/openshell" <<'SH' #!/usr/bin/env bash set -euo pipefail @@ -244,7 +188,7 @@ if [ "${1:-} ${2:-}" = "policy get" ]; then fi if [ "${1:-} ${2:-}" = "policy get" ] && [[ " $* " == *" --output json "* ]]; then sandbox="${@: -1}" - policy_file="${OPENSHELL_SANDBOX_POLICY:?}" + policy_file=/opt/nemoclaw-blueprint/policies/openclaw-sandbox.yaml policy_hash=sha256:fixture-policy policy_version=1 if [ -f "${BASH_SOURCE[0]%/*}/effective-policy.yaml" ]; then @@ -274,12 +218,8 @@ case "$*" in echo "unexpected policy read: expected policy get -g fixture-gateway --base " >&2 exit 64 fi - policy_file="${OPENSHELL_SANDBOX_POLICY:?}" - if [ -f "${BASH_SOURCE[0]%/*}/effective-policy.yaml" ]; then - policy_file="${BASH_SOURCE[0]%/*}/effective-policy.yaml" - fi printf '%s\n' 'Policy for sandbox fixture' '---' - cat "$policy_file" + cat /opt/nemoclaw-blueprint/policies/openclaw-sandbox.yaml ;; "policy get "*) echo "unexpected policy read: expected policy get -g fixture-gateway --base " >&2 @@ -289,8 +229,8 @@ esac SH chmod 0755 "$FAKE_OPENSHELL_BIN/openshell" PATH="$FAKE_OPENSHELL_BIN:$PATH" \ - NEMOCLAW_BLUEPRINT_PATH="$APPLY_BLUEPRINT_PATH" \ - OPENSHELL_SANDBOX_POLICY="$APPLY_BLUEPRINT_PATH/sandbox-policy.yaml" \ + NEMOCLAW_BLUEPRINT_PATH=/opt/nemoclaw-blueprint \ + OPENSHELL_SANDBOX_POLICY=/opt/nemoclaw-blueprint/policies/openclaw-sandbox.yaml \ node --input-type=module -e " const { main } = await import('/opt/nemoclaw/dist/blueprint/runner.js'); await main(['apply', '--profile', 'ncp']); diff --git a/test/runtime/policy/policies.test.ts b/test/runtime/policy/policies.test.ts index 3a5c1132e1b..283d2c912a8 100644 --- a/test/runtime/policy/policies.test.ts +++ b/test/runtime/policy/policies.test.ts @@ -616,16 +616,10 @@ exit 1 it("applyPreset does not create temp dirs when bounded policy observation loses OpenShell", () => { const policyTempPrefix = path.join(os.tmpdir(), "nemoclaw-policy-"); - const policyReadSpy = vi - .spyOn(policyReaderModule.syncCliOpenShellSandboxPolicyReader, "readSandboxPolicy") - .mockReturnValue({ - ok: false, - error: { - kind: "command", - reason: "failed", - message: "OpenShell became unavailable during the bounded policy read.", - }, - }); + const resolveSpy = vi + .spyOn(resolveOpenshellModule, "resolveOpenshell") + .mockReturnValueOnce(fakeOpenshell) + .mockReturnValue(null); const mkdtempSpy = vi.spyOn(fs, "mkdtempSync"); const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); @@ -645,7 +639,7 @@ exit 1 mkdtempSpy.mock.calls.filter(([prefix]) => String(prefix).startsWith(policyTempPrefix)), ).toEqual([]); } finally { - policyReadSpy.mockRestore(); + resolveSpy.mockRestore(); mkdtempSpy.mockRestore(); errSpy.mockRestore(); logSpy.mockRestore(); From 6572de2341ab48e7e05386d859cb1094ae192a8c Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Wed, 2 Sep 2026 06:48:16 -0700 Subject: [PATCH 40/49] test(mcp): exercise unsafe status composition --- docs/manage-sandboxes/manage-mcp-servers.mdx | 1 - .../mcp-bridge-status-resolution.test.ts | 136 ++++++++++-------- 2 files changed, 74 insertions(+), 63 deletions(-) diff --git a/docs/manage-sandboxes/manage-mcp-servers.mdx b/docs/manage-sandboxes/manage-mcp-servers.mdx index b74dbb11036..2f2a7e57fe9 100644 --- a/docs/manage-sandboxes/manage-mcp-servers.mdx +++ b/docs/manage-sandboxes/manage-mcp-servers.mdx @@ -43,7 +43,6 @@ Before it reads `/sandbox/.deepagents/.nemoclaw-mcp.json`, Deep Agents Code v2 ` It does not follow a final symbolic link or read from a FIFO. If the path is a symbolic link, FIFO, or another non-regular file, the command names the path type, writes no status output, and exits with status `2`. The diagnostic starts with `Unsafe managed Deep Agents MCP projection path:` and names a type such as `symbolic link`, `FIFO`, or `non-regular file`. -For this unsafe path-type result, the command exits with status `2`. Deep Agents Code v1 reads the legacy `.deepagents/.mcp.json` path and does not expose these type-specific diagnostics. diff --git a/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts b/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts index 44a7f9ad6ed..86c29cd8483 100644 --- a/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts @@ -7,12 +7,6 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { - baseEntry, - runDeepAgentsConfigCommand, -} from "../../../../test/helpers/mcp-bridge-adapter-deepagents-fixture"; - -import { buildDeepAgentsMcpStatusCommand } from "./mcp-bridge-adapter-status"; const sourceRequireHook = path.resolve("test/helpers/onboard-script-mocks.cjs"); const sourceNodeOptions = [process.env.NODE_OPTIONS, `--require=${sourceRequireHook}`] @@ -43,6 +37,7 @@ const gatewayRuntime = require("./src/lib/gateway-runtime-action.js"); const providerCommands = require("./src/lib/adapters/openshell/provider-command.js"); const policies = require("./src/lib/policy/index.js"); const processRecovery = require("./src/lib/actions/sandbox/process-recovery.js"); +const deepAgentsFixture = require("./test/helpers/mcp-bridge-adapter-deepagents-fixture.ts"); gatewayRuntime.recoverNamedGatewayRuntime = async () => ({ recovered: true, attempted: false, @@ -93,6 +88,8 @@ providerCommands.runOpenshellProviderCommand = (args) => { let activePolicyState = "match"; policies.getPresetContentGatewayState = () => activePolicyState; const executedSandboxCommands = []; +let deepAgentsManagedOptions = null; +let lastDeepAgentsFixtureResult = null; let providerCredentialObservation = "v11"; let credentialObservationCount = 0; processRecovery.executeSandboxExecCommand = () => { @@ -107,6 +104,17 @@ processRecovery.executeSandboxExecCommand = () => { }; processRecovery.executeSandboxCommand = (sandboxName, command) => { executedSandboxCommands.push(command); + if (deepAgentsManagedOptions !== null) { + lastDeepAgentsFixtureResult = deepAgentsFixture.runDeepAgentsConfigCommand( + command, + { mcpServers: {} }, + "v2", + undefined, + 0o600, + deepAgentsManagedOptions, + ); + return lastDeepAgentsFixtureResult; + } if (command.includes("NEMOCLAW_MCP_PROBE")) { const resultMarker = command.match(/__NEMOCLAW_SANDBOX_EXEC_STARTED___[0-9a-f]{32}/)?.[0]; if (!resultMarker) throw new Error("credential probe result marker missing"); @@ -204,30 +212,41 @@ ${body} } describe("MCP status wire-level credential-resolution probe", { timeout: 15_000 }, () => { - it( - "preserves an unsafe Deep Agents projection failure across credential observations (#10754)", + it.each([ + { + caseName: "a symlink appearing after a missing-path open", + credentialObservation: null, + diagnostic: "symbolic link", + expectedConfigIsFifo: false, + expectedConfigIsSymlink: true, + managedOptions: { swapAfterMissingManagedOpen: "symlink", timeoutMs: 30_000 }, + }, + { + caseName: "a FIFO appearing after a missing-path open", + credentialObservation: "absent", + diagnostic: "FIFO", + expectedConfigIsFifo: true, + expectedConfigIsSymlink: false, + managedOptions: { swapAfterMissingManagedOpen: "fifo", timeoutMs: 30_000 }, + }, + { + caseName: "a symlink restored after ELOOP", + credentialObservation: "canonical", + diagnostic: "symbolic link", + expectedConfigIsFifo: false, + expectedConfigIsSymlink: true, + managedOptions: { swapAfterManagedEloop: true, symlink: true, timeoutMs: 30_000 }, + }, + ])( + "propagates $caseName through status with credential observation $credentialObservation (#10754)", { timeout: 120_000 }, - () => { - const statusCommand = buildDeepAgentsMcpStatusCommand(baseEntry); - const projection = { mcpServers: {} }; - const unsafeResult = runDeepAgentsConfigCommand( - statusCommand, - projection, - "v2", - undefined, - 0o600, - { - swapAfterMissingManagedOpen: "symlink", - timeoutMs: 30_000, - }, - ); - expect(unsafeResult.status).toBe(2); - expect(unsafeResult.stdout.trim()).toBe(""); - expect(unsafeResult.stderr).toContain( - "Unsafe managed Deep Agents MCP projection path: symbolic link", - ); - expect(unsafeResult.configIsSymlink).toBe(true); - const unsafeDetail = unsafeResult.stderr.trim(); + ({ + credentialObservation, + diagnostic, + expectedConfigIsFifo, + expectedConfigIsSymlink, + managedOptions, + }) => { const home = createTempHome("nemoclaw-mcp-status-projection-"); const { stdout } = runHarness( home, @@ -246,45 +265,38 @@ describe("MCP status wire-level credential-resolution probe", { timeout: 15_000 managedServerNames: ["github"], }, }); - const outcomes = []; - const detail = ${JSON.stringify(unsafeDetail)}; - for (const observation of [null, "absent", "canonical"]) { - providerCredentialObservation = observation; - processRecovery.executeSandboxCommand = () => ({ - status: 2, - stdout: "", - stderr: detail + "\n", - }); - logLines.length = 0; - errorLines.length = 0; - await bridge.dispatchMcpBridgeCommand("alpha", ["status", "github", "--no-probe", "--json"]); - outcomes.push({ - detail, - observation: observation ?? "unavailable", - exitCode: process.exitCode ?? 0, - stdout: logLines.join("\n"), - stderr: errorLines.join("\n"), - }); - process.exitCode = 0; - } - writeHarnessResult(JSON.stringify(outcomes)); + providerCredentialObservation = ${JSON.stringify(credentialObservation)}; + deepAgentsManagedOptions = ${JSON.stringify(managedOptions)}; + await bridge.dispatchMcpBridgeCommand("alpha", ["status", "github", "--no-probe", "--json"]); + const exitCode = process.exitCode ?? 0; + process.exitCode = 0; + writeHarnessResult(JSON.stringify({ + exitCode, + stdout: logLines.join("\n"), + stderr: errorLines.join("\n"), + fixtureStatus: lastDeepAgentsFixtureResult?.status ?? null, + configIsFifo: lastDeepAgentsFixtureResult?.configIsFifo ?? false, + configIsSymlink: lastDeepAgentsFixtureResult?.configIsSymlink ?? false, + })); `, ); - const outcomes = JSON.parse(stdout) as Array<{ - detail: string; - observation: string; + const outcome = JSON.parse(stdout) as { exitCode: number; stdout: string; stderr: string; - }>; + fixtureStatus: number | null; + configIsFifo: boolean; + configIsSymlink: boolean; + }; - expect(outcomes).toHaveLength(3); - outcomes.forEach((outcome) => { - expect(["unavailable", "absent", "canonical"]).toContain(outcome.observation); - expect(outcome.exitCode).toBe(2); - expect(outcome.stdout).toBe(""); - expect(outcome.stderr).toContain(outcome.detail); - }); + expect(outcome.fixtureStatus).toBe(2); + expect(outcome.exitCode).toBe(2); + expect(outcome.stdout).toBe(""); + expect(outcome.stderr).toContain( + `Unsafe managed Deep Agents MCP projection path: ${diagnostic}`, + ); + expect(outcome.configIsFifo).toBe(expectedConfigIsFifo); + expect(outcome.configIsSymlink).toBe(expectedConfigIsSymlink); }, ); From dead0c84e061c79ce706c40bec3fba1d353c099d Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:02:52 -0700 Subject: [PATCH 41/49] docs(mcp): clarify projection path diagnostics Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- docs/manage-sandboxes/manage-mcp-servers.mdx | 9 +++++---- .../mcp-bridge-adapter-deepagents-projection.test.ts | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/manage-sandboxes/manage-mcp-servers.mdx b/docs/manage-sandboxes/manage-mcp-servers.mdx index 2f2a7e57fe9..d2cb2c55d59 100644 --- a/docs/manage-sandboxes/manage-mcp-servers.mdx +++ b/docs/manage-sandboxes/manage-mcp-servers.mdx @@ -39,11 +39,12 @@ For a trusted private server, status resolves the endpoint without changing mana -Before it reads `/sandbox/.deepagents/.nemoclaw-mcp.json`, Deep Agents Code v2 `mcp status` checks the path type. +On a LangChain Deep Agents Code image with managed MCP capability v2, `mcp status` checks the final path type before it reads `/sandbox/.deepagents/.nemoclaw-mcp.json`. It does not follow a final symbolic link or read from a FIFO. -If the path is a symbolic link, FIFO, or another non-regular file, the command names the path type, writes no status output, and exits with status `2`. -The diagnostic starts with `Unsafe managed Deep Agents MCP projection path:` and names a type such as `symbolic link`, `FIFO`, or `non-regular file`. -Deep Agents Code v1 reads the legacy `.deepagents/.mcp.json` path and does not expose these type-specific diagnostics. +If the final entry is unsafe, the command writes no standard output and exits with status `2`. +The standard error line starts with ` Unsafe managed Deep Agents MCP projection path:`. +It ends with `symbolic link`, `FIFO`, or `non-regular file`. +A Deep Agents v1 image reads the legacy `.deepagents/.mcp.json` path and does not expose these type-specific diagnostics. diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts index 35c105428f6..884963b6fa3 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts @@ -398,7 +398,7 @@ describe("Deep Agents managed MCP projection safety", () => { expect(existingResult.configText).toBe(attackerProjection); }); - it("keeps forced removal identity-bound during a descriptor rewrite (#10754)", () => { + it("keeps forced removal identity-bound during a descriptor rewrite", () => { const forcedCommand = buildDeepAgentsMcpRemoveCommand(baseEntry, true); const raced = runDeepAgentsConfigCommand( forcedCommand, From 88dcb68afbc624fdbe62a47eda8c117015ffe750 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:08:07 -0700 Subject: [PATCH 42/49] docs(mcp): limit unsafe projection claim Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- docs/manage-sandboxes/manage-mcp-servers.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/manage-sandboxes/manage-mcp-servers.mdx b/docs/manage-sandboxes/manage-mcp-servers.mdx index d2cb2c55d59..d25f8c3ab7b 100644 --- a/docs/manage-sandboxes/manage-mcp-servers.mdx +++ b/docs/manage-sandboxes/manage-mcp-servers.mdx @@ -41,7 +41,7 @@ For a trusted private server, status resolves the endpoint without changing mana On a LangChain Deep Agents Code image with managed MCP capability v2, `mcp status` checks the final path type before it reads `/sandbox/.deepagents/.nemoclaw-mcp.json`. It does not follow a final symbolic link or read from a FIFO. -If the final entry is unsafe, the command writes no standard output and exits with status `2`. +If the final entry is a symbolic link, FIFO, or another non-regular object, the command writes no standard output and exits with status `2`. The standard error line starts with ` Unsafe managed Deep Agents MCP projection path:`. It ends with `symbolic link`, `FIFO`, or `non-regular file`. A Deep Agents v1 image reads the legacy `.deepagents/.mcp.json` path and does not expose these type-specific diagnostics. From da3d372770213f02b77ef4bcc01e0899d199b1c8 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Wed, 2 Sep 2026 09:30:55 -0700 Subject: [PATCH 43/49] docs(mcp): correct unsafe projection prefix --- docs/manage-sandboxes/manage-mcp-servers.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/manage-sandboxes/manage-mcp-servers.mdx b/docs/manage-sandboxes/manage-mcp-servers.mdx index d25f8c3ab7b..948842c5429 100644 --- a/docs/manage-sandboxes/manage-mcp-servers.mdx +++ b/docs/manage-sandboxes/manage-mcp-servers.mdx @@ -42,7 +42,7 @@ For a trusted private server, status resolves the endpoint without changing mana On a LangChain Deep Agents Code image with managed MCP capability v2, `mcp status` checks the final path type before it reads `/sandbox/.deepagents/.nemoclaw-mcp.json`. It does not follow a final symbolic link or read from a FIFO. If the final entry is a symbolic link, FIFO, or another non-regular object, the command writes no standard output and exits with status `2`. -The standard error line starts with ` Unsafe managed Deep Agents MCP projection path:`. +The standard error line starts with `Unsafe managed Deep Agents MCP projection path:`. It ends with `symbolic link`, `FIFO`, or `non-regular file`. A Deep Agents v1 image reads the legacy `.deepagents/.mcp.json` path and does not expose these type-specific diagnostics. From 53aeb71b28bf2e2bec262b7463ac7655b98a21ee Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Wed, 2 Sep 2026 09:51:53 -0700 Subject: [PATCH 44/49] test(mcp): preserve unsafe projection evidence --- .../sandbox/mcp-bridge-adapter-deepagents-projection.test.ts | 2 ++ test/helpers/mcp-bridge-adapter-deepagents-fixture.ts | 3 +++ 2 files changed, 5 insertions(+) diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts index 884963b6fa3..2aa145c7018 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts @@ -426,6 +426,7 @@ describe("Deep Agents managed MCP projection safety", () => { ); expect(forcedSymlink.status).toBe(2); expect(forcedSymlink.configExists).toBe(true); + expect(forcedSymlink.configIsSymlink).toBe(true); expect(forcedSymlink.managedSymlinkTargetExists).toBe(true); expect(forcedSymlink.managedSymlinkTargetText).toBe( `${JSON.stringify(emptyProjection, null, 2)}\n`, @@ -443,6 +444,7 @@ describe("Deep Agents managed MCP projection safety", () => { { mode: 0o644 }, ); expect(forcedUnsafeMode.status).toBe(2); + expect(forcedUnsafeMode.configMode).toBe(0o644); expect(forcedUnsafeMode.config).toEqual(emptyProjection); }); diff --git a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts index 743f48ef5f9..f7c46dbcfde 100644 --- a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts +++ b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts @@ -29,6 +29,7 @@ export interface DeepAgentsConfigCommandResult { configIsFifo: boolean; configIsSocket: boolean; configIsSymlink: boolean; + configMode: number | null; config: Record | null; configText: string | null; legacyConfigExists: boolean; @@ -410,6 +411,7 @@ export function runDeepAgentsConfigCommand( const configIsSocket = configStat?.isSocket() === true; const configIsSymlink = configStat?.isSymbolicLink() === true; const configIsDirectory = configStat?.isDirectory() === true; + const configMode = configStat === null ? null : configStat.mode & 0o777; const configText = configExists && !configIsFifo && !configIsSocket && !configIsSymlink && !configIsDirectory ? fs.readFileSync(configPath, "utf-8") @@ -454,6 +456,7 @@ export function runDeepAgentsConfigCommand( configIsFifo, configIsSocket, configIsSymlink, + configMode, config: parseConfigText(configText), configText, legacyConfigExists, From 9c2ed4d29317a26d5700dabc98921bbc7622e0d5 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:08:44 -0700 Subject: [PATCH 45/49] docs(mcp): describe diagnostic indentation Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- docs/manage-sandboxes/manage-mcp-servers.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/manage-sandboxes/manage-mcp-servers.mdx b/docs/manage-sandboxes/manage-mcp-servers.mdx index 948842c5429..c018783c33c 100644 --- a/docs/manage-sandboxes/manage-mcp-servers.mdx +++ b/docs/manage-sandboxes/manage-mcp-servers.mdx @@ -42,7 +42,7 @@ For a trusted private server, status resolves the endpoint without changing mana On a LangChain Deep Agents Code image with managed MCP capability v2, `mcp status` checks the final path type before it reads `/sandbox/.deepagents/.nemoclaw-mcp.json`. It does not follow a final symbolic link or read from a FIFO. If the final entry is a symbolic link, FIFO, or another non-regular object, the command writes no standard output and exits with status `2`. -The standard error line starts with `Unsafe managed Deep Agents MCP projection path:`. +The standard error line starts with two spaces followed by `Unsafe managed Deep Agents MCP projection path:`. It ends with `symbolic link`, `FIFO`, or `non-regular file`. A Deep Agents v1 image reads the legacy `.deepagents/.mcp.json` path and does not expose these type-specific diagnostics. From bd8f1fcb46119c00c4c28e3ccf40f7d7afb2cb70 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Wed, 2 Sep 2026 10:17:12 -0700 Subject: [PATCH 46/49] fix(mcp): reject projection removal races --- ...idge-adapter-deepagents-projection.test.ts | 16 +++++++++++ ...cp-bridge-adapter-deepagents-projection.ts | 7 ++++- .../sandbox/mcp-bridge-adapter-status.ts | 3 ++ .../actions/sandbox/mcp-bridge-adapters.ts | 1 + .../mcp-bridge-status-resolution.test.ts | 28 ++++++++++++++----- src/lib/actions/sandbox/mcp-bridge-status.ts | 4 ++- .../mcp-bridge-adapter-deepagents-fixture.ts | 8 +++++- 7 files changed, 57 insertions(+), 10 deletions(-) diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts index 2aa145c7018..cc50536e7c5 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts @@ -234,6 +234,22 @@ describe("Deep Agents managed MCP projection safety", () => { }, ); + it("rejects a projection removed after it is opened (#10754)", () => { + const result = runDeepAgentsConfigCommand( + buildDeepAgentsMcpStatusCommand(baseEntry), + emptyProjection, + "v2", + undefined, + 0o600, + { removeAfterManagedOpen: true, timeoutMs: managedCommandTimeoutMs }, + ); + expect(result.status).toBe(2); + expect(result.stdout.trim()).toBe(""); + expect(result.stderr).toContain("Could not inspect managed Deep Agents MCP state"); + expect(result.stderr).toContain("managed MCP projection changed while reading"); + expect(result.configExists).toBe(false); + }); + it("rejects a symbolic-link replacement while reading the projection (#10754)", () => { const result = runDeepAgentsConfigCommand( buildDeepAgentsMcpStatusCommand(baseEntry), diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts index d484ec70a7c..626d8f399ab 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts @@ -2,6 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 export const DEEPAGENTS_MCP_MAX_SERVERS = 64; +export const DEEPAGENTS_CHANGED_MCP_PROJECTION_DETAIL = + "managed MCP projection changed while reading"; export const DEEPAGENTS_STRICT_JSON_HELPERS = [ "def reject_duplicate_keys(pairs):", @@ -102,8 +104,11 @@ export const DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS = [ " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}')", " stable = (managed_fingerprint(before) == managed_fingerprint(after) and managed_fingerprint(after) == managed_fingerprint(linked_after))", " if remaining or not stable:", - " raise ValueError('managed MCP projection changed while reading')", + ` raise ValueError(${JSON.stringify(DEEPAGENTS_CHANGED_MCP_PROJECTION_DETAIL)})`, " return b''.join(chunks), managed_fingerprint(after), descriptor", + " except FileNotFoundError as exc:", + " os.close(descriptor)", + ` raise ValueError(${JSON.stringify(DEEPAGENTS_CHANGED_MCP_PROJECTION_DETAIL)}) from exc`, " except Exception:", " os.close(descriptor)", " raise", diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-status.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-status.ts index e46d13bbcde..f292883c6ae 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-status.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-status.ts @@ -8,10 +8,13 @@ import { DEEPAGENTS_LEGACY_MCP_CONFIG_PATH, } from "./mcp-bridge/deepagents-legacy-config"; import { + DEEPAGENTS_CHANGED_MCP_PROJECTION_DETAIL, DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS, DEEPAGENTS_STRICT_JSON_HELPERS, } from "./mcp-bridge-adapter-deepagents-projection"; +export { DEEPAGENTS_CHANGED_MCP_PROJECTION_DETAIL }; + // NemoClaw owns this dedicated projection. Deep Agents Code's user/project // `.mcp.json` discovery is disabled in the managed image so user-authored MCP // state can never be layered over the validated registry projection. diff --git a/src/lib/actions/sandbox/mcp-bridge-adapters.ts b/src/lib/actions/sandbox/mcp-bridge-adapters.ts index afc9657ce6e..f58054702ef 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapters.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapters.ts @@ -61,6 +61,7 @@ export { buildHermesMcpStatusCommand, buildOpenClawMcporterInspectCommand, DEFAULT_OPENCLAW_CONFIG_DIR, + DEEPAGENTS_CHANGED_MCP_PROJECTION_DETAIL, DEEPAGENTS_MCP_CONFIG_PATH, DEEPAGENTS_UNSAFE_MCP_PROJECTION_PREFIX, mcporterHeadersMatchExpected, diff --git a/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts b/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts index 86c29cd8483..8eec782d354 100644 --- a/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts @@ -216,35 +216,48 @@ describe("MCP status wire-level credential-resolution probe", { timeout: 15_000 { caseName: "a symlink appearing after a missing-path open", credentialObservation: null, - diagnostic: "symbolic link", + expectedConfigExists: true, expectedConfigIsFifo: false, expectedConfigIsSymlink: true, + expectedDiagnostic: "Unsafe managed Deep Agents MCP projection path: symbolic link", managedOptions: { swapAfterMissingManagedOpen: "symlink", timeoutMs: 30_000 }, }, { caseName: "a FIFO appearing after a missing-path open", credentialObservation: "absent", - diagnostic: "FIFO", + expectedConfigExists: true, expectedConfigIsFifo: true, expectedConfigIsSymlink: false, + expectedDiagnostic: "Unsafe managed Deep Agents MCP projection path: FIFO", managedOptions: { swapAfterMissingManagedOpen: "fifo", timeoutMs: 30_000 }, }, { caseName: "a symlink restored after ELOOP", credentialObservation: "canonical", - diagnostic: "symbolic link", + expectedConfigExists: true, expectedConfigIsFifo: false, expectedConfigIsSymlink: true, + expectedDiagnostic: "Unsafe managed Deep Agents MCP projection path: symbolic link", managedOptions: { swapAfterManagedEloop: true, symlink: true, timeoutMs: 30_000 }, }, + { + caseName: "a projection removed after it is opened", + credentialObservation: "canonical", + expectedConfigExists: false, + expectedConfigIsFifo: false, + expectedConfigIsSymlink: false, + expectedDiagnostic: "managed MCP projection changed while reading", + managedOptions: { removeAfterManagedOpen: true, timeoutMs: 30_000 }, + }, ])( "propagates $caseName through status with credential observation $credentialObservation (#10754)", { timeout: 120_000 }, ({ credentialObservation, - diagnostic, + expectedConfigExists, expectedConfigIsFifo, expectedConfigIsSymlink, + expectedDiagnostic, managedOptions, }) => { const home = createTempHome("nemoclaw-mcp-status-projection-"); @@ -275,6 +288,7 @@ describe("MCP status wire-level credential-resolution probe", { timeout: 15_000 stdout: logLines.join("\n"), stderr: errorLines.join("\n"), fixtureStatus: lastDeepAgentsFixtureResult?.status ?? null, + configExists: lastDeepAgentsFixtureResult?.configExists ?? false, configIsFifo: lastDeepAgentsFixtureResult?.configIsFifo ?? false, configIsSymlink: lastDeepAgentsFixtureResult?.configIsSymlink ?? false, })); @@ -285,6 +299,7 @@ describe("MCP status wire-level credential-resolution probe", { timeout: 15_000 stdout: string; stderr: string; fixtureStatus: number | null; + configExists: boolean; configIsFifo: boolean; configIsSymlink: boolean; }; @@ -292,9 +307,8 @@ describe("MCP status wire-level credential-resolution probe", { timeout: 15_000 expect(outcome.fixtureStatus).toBe(2); expect(outcome.exitCode).toBe(2); expect(outcome.stdout).toBe(""); - expect(outcome.stderr).toContain( - `Unsafe managed Deep Agents MCP projection path: ${diagnostic}`, - ); + expect(outcome.stderr).toContain(expectedDiagnostic); + expect(outcome.configExists).toBe(expectedConfigExists); expect(outcome.configIsFifo).toBe(expectedConfigIsFifo); expect(outcome.configIsSymlink).toBe(expectedConfigIsSymlink); }, diff --git a/src/lib/actions/sandbox/mcp-bridge-status.ts b/src/lib/actions/sandbox/mcp-bridge-status.ts index 7d04efa0f55..f62cf23f3f4 100644 --- a/src/lib/actions/sandbox/mcp-bridge-status.ts +++ b/src/lib/actions/sandbox/mcp-bridge-status.ts @@ -8,6 +8,7 @@ import { buildHermesMcpStatusCommand, buildOpenClawMcporterInspectCommand, DEFAULT_OPENCLAW_CONFIG_DIR, + DEEPAGENTS_CHANGED_MCP_PROJECTION_DETAIL, DEEPAGENTS_UNSAFE_MCP_PROJECTION_PREFIX, openClawMcporterRoot, } from "./mcp-bridge-adapters"; @@ -139,7 +140,8 @@ function getAdapterRegistration( const normalizedDetail = detail.trim(); if ( adapter === "deepagents-config" && - normalizedDetail.startsWith(DEEPAGENTS_UNSAFE_MCP_PROJECTION_PREFIX) + (normalizedDetail.startsWith(DEEPAGENTS_UNSAFE_MCP_PROJECTION_PREFIX) || + normalizedDetail.endsWith(`: ${DEEPAGENTS_CHANGED_MCP_PROJECTION_DETAIL}`)) ) { throw new McpBridgeError(normalizedDetail, 2); } diff --git a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts index f7c46dbcfde..e4cfcc20c9b 100644 --- a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts +++ b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts @@ -45,6 +45,7 @@ export interface DeepAgentsManagedFixtureOptions { directory?: boolean; fifo?: boolean; mode?: number; + removeAfterManagedOpen?: boolean; swapAfterManagedEloop?: boolean; swapAfterManagedOpen?: "fifo" | "symlink"; swapAfterMissingManagedOpen?: "fifo" | "symlink"; @@ -59,7 +60,7 @@ export interface DeepAgentsManagedFixtureOptions { type ManagedSwap = { content?: string; - kind: "fifo" | "regular" | "socket" | "symlink"; + kind: "absent" | "fifo" | "regular" | "socket" | "symlink"; phase: | "after-missing-projection-open" | "after-projection-eloop" @@ -77,6 +78,9 @@ function resolveManagedSwap(options: DeepAgentsManagedFixtureOptions): ManagedSw if (options.swapAfterManagedOpen) { return { kind: options.swapAfterManagedOpen, phase: "after-projection-open" }; } + if (options.removeAfterManagedOpen) { + return { kind: "absent", phase: "after-projection-open" }; + } if (options.swapAfterMissingManagedOpen) { return { kind: options.swapAfterMissingManagedOpen, @@ -198,6 +202,8 @@ function createDeepAgentsFixturePythonExecutable( " os.unlink(managed_path)", " except FileNotFoundError:", " pass", + " if managed_swap_kind == 'absent':", + " return", " if managed_swap_kind == 'symlink':", " os.symlink(managed_target, managed_path)", " elif managed_swap_kind == 'fifo':", From f3b933068925d6e41ce028ba4fa46ce847a34fcd Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:41:53 -0700 Subject: [PATCH 47/49] fix(mcp): keep status within file-type scope Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- ...idge-adapter-deepagents-projection.test.ts | 16 ----------- ...cp-bridge-adapter-deepagents-projection.ts | 7 +---- .../sandbox/mcp-bridge-adapter-status.ts | 3 -- .../actions/sandbox/mcp-bridge-adapters.ts | 1 - .../mcp-bridge-status-resolution.test.ts | 28 +++++-------------- src/lib/actions/sandbox/mcp-bridge-status.ts | 4 +-- .../mcp-bridge-adapter-deepagents-fixture.ts | 8 +----- 7 files changed, 10 insertions(+), 57 deletions(-) diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts index cc50536e7c5..2aa145c7018 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.test.ts @@ -234,22 +234,6 @@ describe("Deep Agents managed MCP projection safety", () => { }, ); - it("rejects a projection removed after it is opened (#10754)", () => { - const result = runDeepAgentsConfigCommand( - buildDeepAgentsMcpStatusCommand(baseEntry), - emptyProjection, - "v2", - undefined, - 0o600, - { removeAfterManagedOpen: true, timeoutMs: managedCommandTimeoutMs }, - ); - expect(result.status).toBe(2); - expect(result.stdout.trim()).toBe(""); - expect(result.stderr).toContain("Could not inspect managed Deep Agents MCP state"); - expect(result.stderr).toContain("managed MCP projection changed while reading"); - expect(result.configExists).toBe(false); - }); - it("rejects a symbolic-link replacement while reading the projection (#10754)", () => { const result = runDeepAgentsConfigCommand( buildDeepAgentsMcpStatusCommand(baseEntry), diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts index 626d8f399ab..d484ec70a7c 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts @@ -2,8 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 export const DEEPAGENTS_MCP_MAX_SERVERS = 64; -export const DEEPAGENTS_CHANGED_MCP_PROJECTION_DETAIL = - "managed MCP projection changed while reading"; export const DEEPAGENTS_STRICT_JSON_HELPERS = [ "def reject_duplicate_keys(pairs):", @@ -104,11 +102,8 @@ export const DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS = [ " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}')", " stable = (managed_fingerprint(before) == managed_fingerprint(after) and managed_fingerprint(after) == managed_fingerprint(linked_after))", " if remaining or not stable:", - ` raise ValueError(${JSON.stringify(DEEPAGENTS_CHANGED_MCP_PROJECTION_DETAIL)})`, + " raise ValueError('managed MCP projection changed while reading')", " return b''.join(chunks), managed_fingerprint(after), descriptor", - " except FileNotFoundError as exc:", - " os.close(descriptor)", - ` raise ValueError(${JSON.stringify(DEEPAGENTS_CHANGED_MCP_PROJECTION_DETAIL)}) from exc`, " except Exception:", " os.close(descriptor)", " raise", diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-status.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-status.ts index f292883c6ae..e46d13bbcde 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-status.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-status.ts @@ -8,13 +8,10 @@ import { DEEPAGENTS_LEGACY_MCP_CONFIG_PATH, } from "./mcp-bridge/deepagents-legacy-config"; import { - DEEPAGENTS_CHANGED_MCP_PROJECTION_DETAIL, DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS, DEEPAGENTS_STRICT_JSON_HELPERS, } from "./mcp-bridge-adapter-deepagents-projection"; -export { DEEPAGENTS_CHANGED_MCP_PROJECTION_DETAIL }; - // NemoClaw owns this dedicated projection. Deep Agents Code's user/project // `.mcp.json` discovery is disabled in the managed image so user-authored MCP // state can never be layered over the validated registry projection. diff --git a/src/lib/actions/sandbox/mcp-bridge-adapters.ts b/src/lib/actions/sandbox/mcp-bridge-adapters.ts index f58054702ef..afc9657ce6e 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapters.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapters.ts @@ -61,7 +61,6 @@ export { buildHermesMcpStatusCommand, buildOpenClawMcporterInspectCommand, DEFAULT_OPENCLAW_CONFIG_DIR, - DEEPAGENTS_CHANGED_MCP_PROJECTION_DETAIL, DEEPAGENTS_MCP_CONFIG_PATH, DEEPAGENTS_UNSAFE_MCP_PROJECTION_PREFIX, mcporterHeadersMatchExpected, diff --git a/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts b/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts index 8eec782d354..86c29cd8483 100644 --- a/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts @@ -216,48 +216,35 @@ describe("MCP status wire-level credential-resolution probe", { timeout: 15_000 { caseName: "a symlink appearing after a missing-path open", credentialObservation: null, - expectedConfigExists: true, + diagnostic: "symbolic link", expectedConfigIsFifo: false, expectedConfigIsSymlink: true, - expectedDiagnostic: "Unsafe managed Deep Agents MCP projection path: symbolic link", managedOptions: { swapAfterMissingManagedOpen: "symlink", timeoutMs: 30_000 }, }, { caseName: "a FIFO appearing after a missing-path open", credentialObservation: "absent", - expectedConfigExists: true, + diagnostic: "FIFO", expectedConfigIsFifo: true, expectedConfigIsSymlink: false, - expectedDiagnostic: "Unsafe managed Deep Agents MCP projection path: FIFO", managedOptions: { swapAfterMissingManagedOpen: "fifo", timeoutMs: 30_000 }, }, { caseName: "a symlink restored after ELOOP", credentialObservation: "canonical", - expectedConfigExists: true, + diagnostic: "symbolic link", expectedConfigIsFifo: false, expectedConfigIsSymlink: true, - expectedDiagnostic: "Unsafe managed Deep Agents MCP projection path: symbolic link", managedOptions: { swapAfterManagedEloop: true, symlink: true, timeoutMs: 30_000 }, }, - { - caseName: "a projection removed after it is opened", - credentialObservation: "canonical", - expectedConfigExists: false, - expectedConfigIsFifo: false, - expectedConfigIsSymlink: false, - expectedDiagnostic: "managed MCP projection changed while reading", - managedOptions: { removeAfterManagedOpen: true, timeoutMs: 30_000 }, - }, ])( "propagates $caseName through status with credential observation $credentialObservation (#10754)", { timeout: 120_000 }, ({ credentialObservation, - expectedConfigExists, + diagnostic, expectedConfigIsFifo, expectedConfigIsSymlink, - expectedDiagnostic, managedOptions, }) => { const home = createTempHome("nemoclaw-mcp-status-projection-"); @@ -288,7 +275,6 @@ describe("MCP status wire-level credential-resolution probe", { timeout: 15_000 stdout: logLines.join("\n"), stderr: errorLines.join("\n"), fixtureStatus: lastDeepAgentsFixtureResult?.status ?? null, - configExists: lastDeepAgentsFixtureResult?.configExists ?? false, configIsFifo: lastDeepAgentsFixtureResult?.configIsFifo ?? false, configIsSymlink: lastDeepAgentsFixtureResult?.configIsSymlink ?? false, })); @@ -299,7 +285,6 @@ describe("MCP status wire-level credential-resolution probe", { timeout: 15_000 stdout: string; stderr: string; fixtureStatus: number | null; - configExists: boolean; configIsFifo: boolean; configIsSymlink: boolean; }; @@ -307,8 +292,9 @@ describe("MCP status wire-level credential-resolution probe", { timeout: 15_000 expect(outcome.fixtureStatus).toBe(2); expect(outcome.exitCode).toBe(2); expect(outcome.stdout).toBe(""); - expect(outcome.stderr).toContain(expectedDiagnostic); - expect(outcome.configExists).toBe(expectedConfigExists); + expect(outcome.stderr).toContain( + `Unsafe managed Deep Agents MCP projection path: ${diagnostic}`, + ); expect(outcome.configIsFifo).toBe(expectedConfigIsFifo); expect(outcome.configIsSymlink).toBe(expectedConfigIsSymlink); }, diff --git a/src/lib/actions/sandbox/mcp-bridge-status.ts b/src/lib/actions/sandbox/mcp-bridge-status.ts index f62cf23f3f4..7d04efa0f55 100644 --- a/src/lib/actions/sandbox/mcp-bridge-status.ts +++ b/src/lib/actions/sandbox/mcp-bridge-status.ts @@ -8,7 +8,6 @@ import { buildHermesMcpStatusCommand, buildOpenClawMcporterInspectCommand, DEFAULT_OPENCLAW_CONFIG_DIR, - DEEPAGENTS_CHANGED_MCP_PROJECTION_DETAIL, DEEPAGENTS_UNSAFE_MCP_PROJECTION_PREFIX, openClawMcporterRoot, } from "./mcp-bridge-adapters"; @@ -140,8 +139,7 @@ function getAdapterRegistration( const normalizedDetail = detail.trim(); if ( adapter === "deepagents-config" && - (normalizedDetail.startsWith(DEEPAGENTS_UNSAFE_MCP_PROJECTION_PREFIX) || - normalizedDetail.endsWith(`: ${DEEPAGENTS_CHANGED_MCP_PROJECTION_DETAIL}`)) + normalizedDetail.startsWith(DEEPAGENTS_UNSAFE_MCP_PROJECTION_PREFIX) ) { throw new McpBridgeError(normalizedDetail, 2); } diff --git a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts index e4cfcc20c9b..f7c46dbcfde 100644 --- a/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts +++ b/test/helpers/mcp-bridge-adapter-deepagents-fixture.ts @@ -45,7 +45,6 @@ export interface DeepAgentsManagedFixtureOptions { directory?: boolean; fifo?: boolean; mode?: number; - removeAfterManagedOpen?: boolean; swapAfterManagedEloop?: boolean; swapAfterManagedOpen?: "fifo" | "symlink"; swapAfterMissingManagedOpen?: "fifo" | "symlink"; @@ -60,7 +59,7 @@ export interface DeepAgentsManagedFixtureOptions { type ManagedSwap = { content?: string; - kind: "absent" | "fifo" | "regular" | "socket" | "symlink"; + kind: "fifo" | "regular" | "socket" | "symlink"; phase: | "after-missing-projection-open" | "after-projection-eloop" @@ -78,9 +77,6 @@ function resolveManagedSwap(options: DeepAgentsManagedFixtureOptions): ManagedSw if (options.swapAfterManagedOpen) { return { kind: options.swapAfterManagedOpen, phase: "after-projection-open" }; } - if (options.removeAfterManagedOpen) { - return { kind: "absent", phase: "after-projection-open" }; - } if (options.swapAfterMissingManagedOpen) { return { kind: options.swapAfterMissingManagedOpen, @@ -202,8 +198,6 @@ function createDeepAgentsFixturePythonExecutable( " os.unlink(managed_path)", " except FileNotFoundError:", " pass", - " if managed_swap_kind == 'absent':", - " return", " if managed_swap_kind == 'symlink':", " os.symlink(managed_target, managed_path)", " elif managed_swap_kind == 'fifo':", From bd3e42ea3b757a40e35a4b70e5f87fe56562beb3 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:57:49 -0700 Subject: [PATCH 48/49] test(mcp): deduplicate unsafe status coverage Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- .../mcp-bridge-status-resolution.test.ts | 50 ++++--------------- 1 file changed, 11 insertions(+), 39 deletions(-) diff --git a/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts b/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts index 86c29cd8483..d4f0a46c89f 100644 --- a/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts @@ -212,41 +212,10 @@ ${body} } describe("MCP status wire-level credential-resolution probe", { timeout: 15_000 }, () => { - it.each([ - { - caseName: "a symlink appearing after a missing-path open", - credentialObservation: null, - diagnostic: "symbolic link", - expectedConfigIsFifo: false, - expectedConfigIsSymlink: true, - managedOptions: { swapAfterMissingManagedOpen: "symlink", timeoutMs: 30_000 }, - }, - { - caseName: "a FIFO appearing after a missing-path open", - credentialObservation: "absent", - diagnostic: "FIFO", - expectedConfigIsFifo: true, - expectedConfigIsSymlink: false, - managedOptions: { swapAfterMissingManagedOpen: "fifo", timeoutMs: 30_000 }, - }, - { - caseName: "a symlink restored after ELOOP", - credentialObservation: "canonical", - diagnostic: "symbolic link", - expectedConfigIsFifo: false, - expectedConfigIsSymlink: true, - managedOptions: { swapAfterManagedEloop: true, symlink: true, timeoutMs: 30_000 }, - }, - ])( - "propagates $caseName through status with credential observation $credentialObservation (#10754)", + it( + "propagates an unsafe projection through status when credential observation is unavailable (#10754)", { timeout: 120_000 }, - ({ - credentialObservation, - diagnostic, - expectedConfigIsFifo, - expectedConfigIsSymlink, - managedOptions, - }) => { + () => { const home = createTempHome("nemoclaw-mcp-status-projection-"); const { stdout } = runHarness( home, @@ -265,8 +234,11 @@ describe("MCP status wire-level credential-resolution probe", { timeout: 15_000 managedServerNames: ["github"], }, }); - providerCredentialObservation = ${JSON.stringify(credentialObservation)}; - deepAgentsManagedOptions = ${JSON.stringify(managedOptions)}; + providerCredentialObservation = null; + deepAgentsManagedOptions = ${JSON.stringify({ + swapAfterMissingManagedOpen: "symlink", + timeoutMs: 30_000, + })}; await bridge.dispatchMcpBridgeCommand("alpha", ["status", "github", "--no-probe", "--json"]); const exitCode = process.exitCode ?? 0; process.exitCode = 0; @@ -293,10 +265,10 @@ describe("MCP status wire-level credential-resolution probe", { timeout: 15_000 expect(outcome.exitCode).toBe(2); expect(outcome.stdout).toBe(""); expect(outcome.stderr).toContain( - `Unsafe managed Deep Agents MCP projection path: ${diagnostic}`, + "Unsafe managed Deep Agents MCP projection path: symbolic link", ); - expect(outcome.configIsFifo).toBe(expectedConfigIsFifo); - expect(outcome.configIsSymlink).toBe(expectedConfigIsSymlink); + expect(outcome.configIsFifo).toBe(false); + expect(outcome.configIsSymlink).toBe(true); }, ); From c4a0d7c85183d3e7e0bdb9c5e6bf8af8fde7118c Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:37:22 -0700 Subject: [PATCH 49/49] refactor(mcp): centralize unsafe projection rejection Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- ...cp-bridge-adapter-deepagents-projection.ts | 23 +++++++------------ 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts index d484ec70a7c..f45722618ba 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts @@ -28,6 +28,10 @@ export const DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS = [ " if not stat.S_ISREG(mode):", " return 'non-regular file'", " return None", + "def reject_unsafe_managed_projection(metadata, unsafe_path_prefix):", + " unsafe_path_kind = managed_projection_path_kind(metadata.st_mode) if unsafe_path_prefix else None", + " if unsafe_path_kind:", + " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}')", "def managed_fingerprint(metadata):", " return (metadata.st_dev, metadata.st_ino, metadata.st_size, metadata.st_mtime_ns, metadata.st_ctime_ns, metadata.st_mode, metadata.st_nlink, metadata.st_uid)", "def assert_managed_source_stable(path, identity, unsafe_path_prefix=None):", @@ -38,9 +42,7 @@ export const DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS = [ " current = managed_fingerprint(current_metadata) if current_metadata is not None else None", " if identity is None:", " if current is not None:", - " unsafe_path_kind = managed_projection_path_kind(current_metadata.st_mode) if unsafe_path_prefix else None", - " if unsafe_path_kind:", - " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}')", + " reject_unsafe_managed_projection(current_metadata, unsafe_path_prefix)", " raise ValueError('managed MCP projection appeared during mutation')", " return", " if current != identity:", @@ -48,10 +50,7 @@ export const DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS = [ "def validate_managed_descriptor_path(path, descriptor, unsafe_path_prefix=None):", " opened = os.fstat(descriptor)", " linked = os.stat(path, follow_symlinks=False)", - " if unsafe_path_prefix:", - " unsafe_path_kind = managed_projection_path_kind(linked.st_mode)", - " if unsafe_path_kind:", - " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}')", + " reject_unsafe_managed_projection(linked, unsafe_path_prefix)", " safe = (stat.S_ISREG(opened.st_mode) and opened.st_uid == os.getuid() and stat.S_IMODE(opened.st_mode) == 0o600 and opened.st_nlink == 1 and (opened.st_dev, opened.st_ino) == (linked.st_dev, linked.st_ino))", " if not safe:", " raise ValueError('managed MCP projection has unsafe ownership, mode, type, links, or path identity')", @@ -79,10 +78,7 @@ export const DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS = [ " raise", " try:", " before = os.fstat(descriptor)", - " if unsafe_path_prefix:", - " unsafe_path_kind = managed_projection_path_kind(before.st_mode)", - " if unsafe_path_kind:", - " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}')", + " reject_unsafe_managed_projection(before, unsafe_path_prefix)", " validate_managed_descriptor_path(path, descriptor, unsafe_path_prefix)", " if before.st_size < 0 or before.st_size > MANAGED_MCP_MAX_BYTES:", " raise ValueError('managed MCP projection has invalid size')", @@ -96,10 +92,7 @@ export const DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS = [ " remaining -= len(chunk)", " after = os.fstat(descriptor)", " linked_after = os.stat(path, follow_symlinks=False)", - " if unsafe_path_prefix:", - " unsafe_path_kind = managed_projection_path_kind(linked_after.st_mode)", - " if unsafe_path_kind:", - " raise ValueError(f'{unsafe_path_prefix}: {unsafe_path_kind}')", + " reject_unsafe_managed_projection(linked_after, unsafe_path_prefix)", " stable = (managed_fingerprint(before) == managed_fingerprint(after) and managed_fingerprint(after) == managed_fingerprint(linked_after))", " if remaining or not stable:", " raise ValueError('managed MCP projection changed while reading')",