diff --git a/docs/manage-sandboxes/manage-mcp-servers.mdx b/docs/manage-sandboxes/manage-mcp-servers.mdx index 9ea4ab8c406..c018783c33c 100644 --- a/docs/manage-sandboxes/manage-mcp-servers.mdx +++ b/docs/manage-sandboxes/manage-mcp-servers.mdx @@ -37,6 +37,17 @@ 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. + + +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 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. + + + ## 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..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 @@ -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 }, @@ -74,40 +46,306 @@ describe("Deep Agents managed MCP projection safety", () => { ); }); - it("fails status inspection closed without following hostile projection paths", () => { - 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, + 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 (#10754)", + ({ + diagnostic, + expectedConfigIsDirectory, + expectedConfigIsFifo, + expectedConfigIsSymlink, + expectedTargetText, + initialConfig, + options, + }) => { + const result = runDeepAgentsConfigCommand( + buildDeepAgentsMcpStatusCommand(baseEntry), + initialConfig, + "v2", + undefined, + 0o600, + { ...options, 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.configIsDirectory).toBe(expectedConfigIsDirectory); + expect(result.configIsFifo).toBe(expectedConfigIsFifo); + expect(result.configIsSymlink).toBe(expectedConfigIsSymlink); + expect(result.managedSymlinkTargetText).toBe(expectedTargetText); + }, + ); + + 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", + 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)", () => { + const result = runDeepAgentsConfigCommand( + buildDeepAgentsMcpStatusCommand(baseEntry), emptyProjection, "v2", undefined, 0o600, - { symlink: true }, + { swapOnManagedRead: "symlink", timeoutMs: managedCommandTimeoutMs }, ); - expect(symlink.status).toBe(2); - expect(symlink.stdout.trim()).toBe(""); - expect(symlink.stderr).toContain("Could not inspect managed Deep Agents MCP state"); - expect(symlink.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: symbolic link", + ); + expect(result.configIsSymlink).toBe(true); + expect(result.managedSymlinkTargetText).toBe(`${JSON.stringify(emptyProjection, null, 2)}\n`); + }); - const fifo = runDeepAgentsConfigCommand(statusCommand, undefined, "v2", undefined, 0o600, { - fifo: true, - }); - expect(fifo.status).toBe(2); - expect(fifo.stdout.trim()).toBe(""); - expect(fifo.stderr).toContain("Could not inspect managed Deep Agents MCP state"); + 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, + }, + ); + 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, + 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, + expectedConfigIsFifo, + expectedConfigIsSymlink, + expectedTargetText, + }) => { + const result = runDeepAgentsConfigCommand( + buildDeepAgentsMcpStatusCommand(baseEntry), + emptyProjection, + "v2", + undefined, + 0o600, + { 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(result.configIsFifo).toBe(expectedConfigIsFifo); + expect(result.configIsSymlink).toBe(expectedConfigIsSymlink); + expect(result.managedSymlinkTargetText).toBe(expectedTargetText); + }, + ); + + it("keeps the generic diagnostic for ordinary projection inspection failures (#10754)", () => { + const result = runDeepAgentsConfigCommand( + buildDeepAgentsMcpStatusCommand(baseEntry), + '{"mcpServers":', + ); + + 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("Expecting value"); + expect(result.stderr).not.toContain("Traceback"); + expect(result.configText).toBe('{"mcpServers":'); }); 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, @@ -119,7 +357,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, }); @@ -127,38 +370,52 @@ 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", () => { - 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)`, + it("preserves a projection that appears during absent publication", () => { + 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)`, + it("preserves a replacement during a descriptor rewrite", () => { + 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", () => { 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("preserves a projection symlink during forced removal", () => { + const forcedCommand = buildDeepAgentsMcpRemoveCommand(baseEntry, true); const forcedSymlink = runDeepAgentsConfigCommand( forcedCommand, emptyProjection, @@ -169,11 +426,15 @@ 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`, ); + }); + it("preserves an unsafe projection mode during forced removal", () => { + const forcedCommand = buildDeepAgentsMcpRemoveCommand(baseEntry, true); const forcedUnsafeMode = runDeepAgentsConfigCommand( forcedCommand, emptyProjection, @@ -183,8 +444,12 @@ describe("Deep Agents managed MCP projection safety", () => { { mode: 0o644 }, ); expect(forcedUnsafeMode.status).toBe(2); + expect(forcedUnsafeMode.configMode).toBe(0o644); expect(forcedUnsafeMode.config).toEqual(emptyProjection); + }); + it("preserves a projection FIFO during forced removal", () => { + const forcedCommand = buildDeepAgentsMcpRemoveCommand(baseEntry, true); const forcedFifo = runDeepAgentsConfigCommand( forcedCommand, undefined, @@ -195,11 +460,17 @@ 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); 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..f45722618ba 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-deepagents-projection.ts @@ -18,40 +18,68 @@ 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 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 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:", + " reject_unsafe_managed_projection(current_metadata, unsafe_path_prefix)", " raise ValueError('managed MCP projection appeared during mutation')", " 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)", + " 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')", " 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:", " 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:", + " 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)", - " validate_managed_descriptor_path(path, descriptor)", + " 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')", " chunks = []", @@ -64,6 +92,7 @@ export const DEEPAGENTS_MANAGED_PROJECTION_READ_HELPERS = [ " remaining -= len(chunk)", " after = os.fstat(descriptor)", " linked_after = os.stat(path, follow_symlinks=False)", + " 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')", @@ -87,8 +116,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 03518cab344..e46d13bbcde 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"; @@ -259,10 +261,16 @@ export function buildDeepAgentsMcpStatusCommand( "is_v2 = runtime_kind == 'v2'", "config_path = managed_path if is_v2 else legacy_path", "try:", - " 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/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 27980f526bd..d4f0a46c89f 100644 --- a/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-status-resolution.test.ts @@ -37,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, @@ -87,18 +88,33 @@ 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 = () => { credentialObservationCount += 1; - return { - status: 0, - stdout: providerCredentialObservation, - stderr: "", - }; + return providerCredentialObservation === null + ? undefined + : { + status: 0, + stdout: providerCredentialObservation, + stderr: "", + }; }; 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"); @@ -196,6 +212,66 @@ ${body} } describe("MCP status wire-level credential-resolution probe", { timeout: 15_000 }, () => { + it( + "propagates an unsafe projection through status when credential observation is unavailable (#10754)", + { timeout: 120_000 }, + () => { + 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"], + }, + }); + 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; + 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 outcome = JSON.parse(stdout) as { + exitCode: number; + stdout: string; + stderr: string; + fixtureStatus: number | null; + configIsFifo: boolean; + configIsSymlink: boolean; + }; + + 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: symbolic link", + ); + expect(outcome.configIsFifo).toBe(false); + expect(outcome.configIsSymlink).toBe(true); + }, + ); + 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..7d04efa0f55 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"; @@ -96,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 } @@ -119,20 +122,31 @@ 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" }; } 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); + } + if (credentialInspectionSkip) return credentialInspectionSkip; 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 b6a4a5d62c1..f7c46dbcfde 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"; @@ -25,6 +25,11 @@ export interface DeepAgentsConfigCommandResult { stdout: string; stderr: string; configExists: boolean; + configIsDirectory: boolean; + configIsFifo: boolean; + configIsSocket: boolean; + configIsSymlink: boolean; + configMode: number | null; config: Record | null; configText: string | null; legacyConfigExists: boolean; @@ -32,12 +37,260 @@ export interface DeepAgentsConfigCommandResult { legacyConfigText: string | null; managedSymlinkTargetExists: boolean; managedSymlinkTargetText: string | null; + managedTargetReadAccessed: boolean; } export interface DeepAgentsManagedFixtureOptions { + danglingSymlink?: boolean; + directory?: boolean; fifo?: boolean; mode?: number; + 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 }); + 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, + 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 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 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)", + "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 }); + fs.writeFileSync( + executablePath, + '#!/bin/sh\nexec python3 -I "$DEEPAGENTS_FIXTURE_PYTHON_WRAPPER" "$@"\n', + { mode: 0o700 }, + ); + return { executablePath, scriptPath }; } export function runDeepAgentsConfigCommand( @@ -49,9 +302,13 @@ export function runDeepAgentsConfigCommand( managedOptions: DeepAgentsManagedFixtureOptions = {}, runtimeEnvironment: NodeJS.ProcessEnv = {}, ): DeepAgentsConfigCommandResult { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "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 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, @@ -66,66 +323,151 @@ export function runDeepAgentsConfigCommand( { mode }, ); }; - const managedInitialPath = managedOptions.symlink ? 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); + const managedSymlink = managedOptions.symlink === true || managedOptions.danglingSymlink === true; + const managedTargetReadPath = managedOptions.targetReadProbe + ? managedSymlink + ? managedSymlinkTarget + : null + : null; + if (managedOptions.targetReadProbe && managedTargetReadPath === null) { + throw new Error("targetReadProbe requires a projection symlink"); + } + const managedInitialPath = managedSymlink ? managedSymlinkTarget : configPath; + if (managedOptions.directory) { + fs.mkdirSync(configPath, { recursive: true, mode: managedOptions.mode ?? 0o700 }); + } else if (managedOptions.fifo) { + createFifo(configPath, managedOptions.mode); } else { - initializeConfig(managedInitialPath, initialConfig, managedOptions.mode); - if (initialConfig !== undefined) fs.chmodSync(managedInitialPath, managedOptions.mode ?? 0o600); - if (managedOptions.symlink && initialConfig !== undefined) { + if (managedTargetReadPath === managedSymlinkTarget) { + createFifo(managedSymlinkTarget, managedOptions.mode); + } 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-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") { + initializeConfig(managedSymlinkTarget, initialConfig, managedOptions.mode); + } 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, + legacyConfigPath, + managedSymlinkTarget, + runtimeKind, + managedSwap, + ); const fixtureCommand = command .replaceAll(DEEPAGENTS_MCP_CONFIG_PATH, configPath) .replaceAll("/sandbox/.deepagents/.mcp.json", legacyConfigPath) - .replaceAll("/opt/venv/bin/python3", "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 canonicalEnvironment = Object.fromEntries( [...command.matchAll(/openshell:resolve:env:([A-Za-z_][A-Za-z0-9_]*)/gu)].map( ([, name]) => [name!, `openshell:resolve:env:${name!}`], ), ); - 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, ...canonicalEnvironment, ...runtimeEnvironment }, - timeout: 5000, + env: { + ...process.env, + ...canonicalEnvironment, + ...runtimeEnvironment, + DEEPAGENTS_FIXTURE_PYTHON_WRAPPER: fixturePython.scriptPath, + }, + timeout: managedOptions.timeoutMs ?? 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 configText = configExists && !configIsFifo ? fs.readFileSync(configPath, "utf-8") : null; + const configIsFifo = configStat?.isFIFO() === true; + 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") + : null; const managedSymlinkTargetExists = fs.existsSync(managedSymlinkTarget); - const managedSymlinkTargetText = managedSymlinkTargetExists - ? fs.readFileSync(managedSymlinkTarget, "utf-8") - : null; + const readRegularFile = (target: string): string | null => { + let descriptor: number | null = null; + try { + 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 = + managedTargetReadPath === managedSymlinkTarget ? null : readRegularFile(managedSymlinkTarget); + 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; + try { + const parsed: unknown = JSON.parse(text); + return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) + ? (parsed as Record) + : null; + } catch { + return null; + } + }; return { status: result.status, stdout: result.stdout, stderr: result.stderr, configExists, - config: configText ? (JSON.parse(configText) as Record) : null, + configIsDirectory, + configIsFifo, + configIsSocket, + configIsSymlink, + configMode, + config: parseConfigText(configText), configText, legacyConfigExists, - legacyConfig: legacyConfigText - ? (JSON.parse(legacyConfigText) as Record) - : null, + legacyConfig: parseConfigText(legacyConfigText), legacyConfigText, managedSymlinkTargetExists, managedSymlinkTargetText, + managedTargetReadAccessed, }; } finally { + targetReadProbe?.kill("SIGTERM"); fs.rmSync(tmp, { recursive: true, force: true }); } }