diff --git a/src/lib/actions/sandbox/dcode-activity-probe.test.ts b/src/lib/actions/sandbox/dcode-activity-probe.test.ts new file mode 100644 index 00000000000..50586788e04 --- /dev/null +++ b/src/lib/actions/sandbox/dcode-activity-probe.test.ts @@ -0,0 +1,138 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + DCODE_BUSY_PROBE_SCRIPT, + DCODE_PROBE_PREFIX, + DCODE_PROBE_STATE, + parseDcodeProbeState, +} from "./dcode-activity-probe"; + +/** Run the shell probe with controlled ps and /proc inputs. */ +function runProbeScriptWithProcessSources({ + processes = "", + procCmdlines = [], + psExitCode = 0, + unreadableProcEntries = 0, +}: { + processes?: string; + procCmdlines?: readonly string[]; + psExitCode?: number; + unreadableProcEntries?: number; +}): { status: number; output: string } { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dcode-probe-")); + const psPath = path.join(tempDir, "ps"); + const homeDir = path.join(tempDir, "home"); + const procRoot = path.join(tempDir, "proc's"); + fs.mkdirSync(homeDir); + fs.mkdirSync(procRoot); + fs.mkdirSync(path.join(homeDir, ".deepagents")); + for (const [index, cmdline] of procCmdlines.entries()) { + const processDir = path.join(procRoot, String(index + 100)); + fs.mkdirSync(processDir); + fs.writeFileSync(path.join(processDir, "cmdline"), cmdline); + } + for (const index of Array.from({ length: unreadableProcEntries }, (_, offset) => offset)) { + const processDir = path.join(procRoot, String(index + 200)); + fs.mkdirSync(processDir); + const cmdlinePath = path.join(processDir, "cmdline"); + fs.writeFileSync(cmdlinePath, "/usr/bin/dcode\0-n\0work\0"); + fs.chmodSync(cmdlinePath, 0o000); + } + fs.writeFileSync(psPath, `#!/bin/sh\ncat <<'EOF'\n${processes}\nEOF\nexit ${psExitCode}\n`); + fs.chmodSync(psPath, 0o755); + const testProbeScript = DCODE_BUSY_PROBE_SCRIPT.replace( + "proc_root=/proc", + 'proc_root="$NEMOCLAW_TEST_DCODE_PROC_ROOT"', + ); + const result = spawnSync("sh", ["-c", testProbeScript], { + encoding: "utf-8", + env: { + ...process.env, + HOME: homeDir, + PATH: `${tempDir}:/usr/bin:/bin`, + NEMOCLAW_DCODE_PROC_ROOT: path.join(tempDir, "sandbox-controlled-proc"), + NEMOCLAW_TEST_DCODE_PROC_ROOT: procRoot, + }, + }); + fs.rmSync(tempDir, { recursive: true, force: true }); + return { status: result.status ?? 255, output: result.stdout || "" }; +} + +/** Assert the probe emitted exactly one observable sentinel. */ +function expectProbeState( + result: { status: number; output: string }, + state: (typeof DCODE_PROBE_STATE)[keyof typeof DCODE_PROBE_STATE], +): void { + expect(result.status).toBe(0); + expect(result.output.trim()).toBe(`${DCODE_PROBE_PREFIX}${state}`); +} + +describe("dcode activity probe", () => { + it("does not let sandbox environment redirect the production proc scan (#6180)", () => { + expect(DCODE_BUSY_PROBE_SCRIPT).toContain("proc_root=/proc"); + expect(DCODE_BUSY_PROBE_SCRIPT).not.toContain("NEMOCLAW_DCODE_PROC_ROOT"); + }); + + it("falls back to proc cmdline scanning when ps cannot list processes (#6180)", () => { + expectProbeState( + runProbeScriptWithProcessSources({ + procCmdlines: ["/bin/sh\0-c\0sleep 30\0", "/usr/bin/python3\0-m\0not_deepagents_code\0"], + psExitCode: 1, + }), + DCODE_PROBE_STATE.idleDcodeRuntime, + ); + expectProbeState( + runProbeScriptWithProcessSources({ + procCmdlines: ["/opt/venv/bin/python3\0-I\0-m\0deepagents_code\0-n\0work\0"], + psExitCode: 1, + }), + DCODE_PROBE_STATE.active, + ); + }); + + it("fails closed when ps and proc cannot verify a marked dcode runtime (#6180)", () => { + expectProbeState( + runProbeScriptWithProcessSources({ psExitCode: 1 }), + DCODE_PROBE_STATE.unverifiableDcodeRuntime, + ); + }); + + it("fails closed when proc fallback visibility is incomplete (#6180)", () => { + expectProbeState( + runProbeScriptWithProcessSources({ + procCmdlines: ["", "/bin/sh\0-c\0sleep 30\0"], + psExitCode: 1, + }), + DCODE_PROBE_STATE.unverifiableDcodeRuntime, + ); + expectProbeState( + runProbeScriptWithProcessSources({ + procCmdlines: ["/bin/sh\0-c\0sleep 30\0"], + psExitCode: 1, + unreadableProcEntries: 1, + }), + DCODE_PROBE_STATE.unverifiableDcodeRuntime, + ); + }); + + it("parses every declared probe state", () => { + for (const state of Object.values(DCODE_PROBE_STATE)) { + expect(parseDcodeProbeState(`${DCODE_PROBE_PREFIX}${state}\n`)).toBe(state); + } + }); + + it("parses exactly one probe sentinel from sandbox exec output", () => { + expect(parseDcodeProbeState(`${DCODE_PROBE_PREFIX}idle\n`)).toBe( + DCODE_PROBE_STATE.idleDcodeRuntime, + ); + expect(parseDcodeProbeState(`${DCODE_PROBE_PREFIX}idle\n${DCODE_PROBE_PREFIX}active\n`)).toBe( + null, + ); + }); +}); diff --git a/src/lib/actions/sandbox/dcode-activity-probe.ts b/src/lib/actions/sandbox/dcode-activity-probe.ts new file mode 100644 index 00000000000..bef57cb1cb3 --- /dev/null +++ b/src/lib/actions/sandbox/dcode-activity-probe.ts @@ -0,0 +1,97 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export const DCODE_AGENT_NAME = "langchain-deepagents-code"; +export const DCODE_PROBE_PREFIX = "NEMOCLAW_DCODE_PROBE="; +export const DCODE_PROBE_STATE = { + active: "active", + idleDcodeRuntime: "idle", + unverifiableDcodeRuntime: "unverifiable", + noDcodeRuntime: "no-runtime", +} as const; +export type DcodeProbeState = (typeof DCODE_PROBE_STATE)[keyof typeof DCODE_PROBE_STATE]; + +export const DCODE_BUSY_PROBE_SCRIPT = String.raw`emit_dcode_probe_state() { + printf 'NEMOCLAW_DCODE_PROBE=%s\n' "$1" + exit 0 +} +has_dcode_runtime=0 +dc_bin="$(printf 'd%s' code)" +da_bin="$(printf 'deepagents-%s' code)" +home_dir="$HOME" +[ -n "$home_dir" ] || home_dir=/sandbox +[ -d /sandbox/.deepagents ] && has_dcode_runtime=1 +[ -d "$home_dir/.deepagents" ] && has_dcode_runtime=1 +command -v "$dc_bin" >/dev/null 2>&1 && has_dcode_runtime=1 +command -v "$da_bin" >/dev/null 2>&1 && has_dcode_runtime=1 +detect_dcode_processes() { + awk ' +/^[[:space:]]*[0-9]+[[:space:]]+([^[:space:]]*\/)?python[0-9.]*[[:space:]]+(-I[[:space:]]+)?-m[[:space:]]+deepagents[_]code([[:space:]]|$)/ { + found = 1 +} +/^[[:space:]]*[0-9]+[[:space:]]+([^[:space:]]*\/)?[d]code([[:space:]]|$)/ { + found = 1 +} +/^[[:space:]]*[0-9]+[[:space:]]+([^[:space:]]*\/)?deepagents[-_]code([[:space:]]|$)/ { + found = 1 +} +END { exit found ? 0 : 1 } +' +} +proc_root=/proc +processes="$(ps -eo pid=,args= 2>/dev/null)" || { + processes="" + saw_proc_process=0 + proc_scan_incomplete=0 + for cmdline in "$proc_root"/[0-9]*/cmdline; do + [ -e "$cmdline" ] || continue + [ -r "$cmdline" ] || { + proc_scan_incomplete=1 + continue + } + pid="$(basename "$(dirname "$cmdline")")" + command_line="$(tr '\000\n\r' ' ' < "$cmdline" 2>/dev/null)" || { + proc_scan_incomplete=1 + continue + } + [ -n "$command_line" ] || { + proc_scan_incomplete=1 + continue + } + saw_proc_process=1 + processes="$processes$pid $command_line +" + done + [ "$proc_scan_incomplete" -eq 0 ] || { + [ "$has_dcode_runtime" -eq 1 ] && emit_dcode_probe_state unverifiable + emit_dcode_probe_state no-runtime + } + [ "$saw_proc_process" -eq 1 ] || { + [ "$has_dcode_runtime" -eq 1 ] && emit_dcode_probe_state unverifiable + emit_dcode_probe_state no-runtime + } +} +printf '%s\n' "$processes" | detect_dcode_processes +matched=$? +[ "$matched" -eq 0 ] && emit_dcode_probe_state active +[ "$matched" -ne 1 ] && { + [ "$has_dcode_runtime" -eq 1 ] && emit_dcode_probe_state unverifiable + emit_dcode_probe_state no-runtime +} +[ "$has_dcode_runtime" -eq 1 ] && emit_dcode_probe_state idle +emit_dcode_probe_state no-runtime +`; + +/** Escape a probe literal before embedding it in a generated regular expression. */ +function escapeRegexLiteral(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** Parse a single dcode probe sentinel from sandbox command output. */ +export function parseDcodeProbeState(output: string): DcodeProbeState | null { + const escapedPrefix = escapeRegexLiteral(DCODE_PROBE_PREFIX); + const stateAlternation = Object.values(DCODE_PROBE_STATE).map(escapeRegexLiteral).join("|"); + const matches = [...output.matchAll(new RegExp(`^${escapedPrefix}(${stateAlternation})$`, "gm"))]; + if (matches.length !== 1) return null; + return (matches[0][1] as DcodeProbeState | undefined) ?? null; +} diff --git a/src/lib/actions/sandbox/snapshot.test.ts b/src/lib/actions/sandbox/snapshot.test.ts index 738d0a4dd8b..903eadee680 100644 --- a/src/lib/actions/sandbox/snapshot.test.ts +++ b/src/lib/actions/sandbox/snapshot.test.ts @@ -309,7 +309,10 @@ describe("runSandboxSnapshot", () => { function runProbeScriptWithProcesses( script: string, processes: string, - ): { status: number; output: string } { + ): { + status: number; + output: string; + } { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dcode-probe-")); const psPath = path.join(tempDir, "ps"); const homeDir = path.join(tempDir, "home"); diff --git a/src/lib/actions/sandbox/snapshot.ts b/src/lib/actions/sandbox/snapshot.ts index dadd4f95daf..a2a48301591 100644 --- a/src/lib/actions/sandbox/snapshot.ts +++ b/src/lib/actions/sandbox/snapshot.ts @@ -42,6 +42,12 @@ import * as registry from "../../state/registry"; import { getSandboxEntryInference } from "../../state/registry-entry-view"; import * as sandboxState from "../../state/sandbox"; import { cleanupShieldsDestroyArtifacts, removeSandboxRegistryEntry } from "./destroy"; +import { + DCODE_AGENT_NAME, + DCODE_BUSY_PROBE_SCRIPT, + DCODE_PROBE_STATE, + parseDcodeProbeState, +} from "./dcode-activity-probe"; import { buildSandboxExecMarkedCommand, createSandboxExecMarker, @@ -60,54 +66,6 @@ const G = useColor ? (trueColor ? "\x1b[38;2;118;185;0m" : "\x1b[38;5;148m") : " const B = useColor ? "\x1b[1m" : ""; const D = useColor ? "\x1b[2m" : ""; const R = useColor ? "\x1b[0m" : ""; -const DCODE_AGENT_NAME = "langchain-deepagents-code"; -const DCODE_PROBE_PREFIX = "NEMOCLAW_DCODE_PROBE="; -const DCODE_PROBE_STATE = { - active: "active", - idleDcodeRuntime: "idle", - unverifiableDcodeRuntime: "unverifiable", - noDcodeRuntime: "no-runtime", -} as const; -type DcodeProbeState = (typeof DCODE_PROBE_STATE)[keyof typeof DCODE_PROBE_STATE]; - -const DCODE_BUSY_PROBE_SCRIPT = String.raw`emit_dcode_probe_state() { - printf 'NEMOCLAW_DCODE_PROBE=%s\n' "$1" - exit 0 -} -has_dcode_runtime=0 -dc_bin="$(printf 'd%s' code)" -da_bin="$(printf 'deepagents-%s' code)" -home_dir="$HOME" -[ -n "$home_dir" ] || home_dir=/sandbox -[ -d /sandbox/.deepagents ] && has_dcode_runtime=1 -[ -d "$home_dir/.deepagents" ] && has_dcode_runtime=1 -command -v "$dc_bin" >/dev/null 2>&1 && has_dcode_runtime=1 -command -v "$da_bin" >/dev/null 2>&1 && has_dcode_runtime=1 -processes="$(ps -eo pid=,args= 2>/dev/null)" || { - [ "$has_dcode_runtime" -eq 1 ] && emit_dcode_probe_state unverifiable - emit_dcode_probe_state no-runtime -} -printf '%s\n' "$processes" | awk ' -/^[[:space:]]*[0-9]+[[:space:]]+([^[:space:]]*\/)?python[0-9.]*[[:space:]]+(-I[[:space:]]+)?-m[[:space:]]+deepagents[_]code([[:space:]]|$)/ { - found = 1 -} -/^[[:space:]]*[0-9]+[[:space:]]+([^[:space:]]*\/)?[d]code([[:space:]]|$)/ { - found = 1 -} -/^[[:space:]]*[0-9]+[[:space:]]+([^[:space:]]*\/)?deepagents[-_]code([[:space:]]|$)/ { - found = 1 -} -END { exit found ? 0 : 1 } -' -matched=$? -[ "$matched" -eq 0 ] && emit_dcode_probe_state active -[ "$matched" -ne 1 ] && { - [ "$has_dcode_runtime" -eq 1 ] && emit_dcode_probe_state unverifiable - emit_dcode_probe_state no-runtime -} -[ "$has_dcode_runtime" -eq 1 ] && emit_dcode_probe_state idle -emit_dcode_probe_state no-runtime -`; export type SnapshotRequest = | { kind: "help" } @@ -428,15 +386,6 @@ function isSnapshotCreationAllowedByShields(sandboxName: string): boolean { return isShieldsDown(sandboxName); } -function parseDcodeProbeState(output: string): DcodeProbeState | null { - const escapedPrefix = DCODE_PROBE_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const matches = [ - ...output.matchAll(new RegExp(`^${escapedPrefix}(active|idle|unverifiable|no-runtime)$`, "gm")), - ]; - if (matches.length !== 1) return null; - return (matches[0][1] as DcodeProbeState | undefined) ?? null; -} - function shouldCheckDcodeActivity(sandboxName: string): boolean { const entry = registry.getSandbox(sandboxName); // Preserve the existing snapshot path for registered non-dcode sandboxes while