diff --git a/agents/langchain-deepagents-code/dcode-wrapper.sh b/agents/langchain-deepagents-code/dcode-wrapper.sh index 2e337d98d47..7f7225a1c2d 100755 --- a/agents/langchain-deepagents-code/dcode-wrapper.sh +++ b/agents/langchain-deepagents-code/dcode-wrapper.sh @@ -14,6 +14,8 @@ export DEEPAGENTS_CODE_OPENAI_API_KEY="${DEEPAGENTS_CODE_OPENAI_API_KEY:-nemocla export OPENAI_BASE_URL="${OPENAI_BASE_URL:-https://inference.local/v1}" readonly DEEPAGENTS_ENV_FILE="/sandbox/.deepagents/.env" +readonly DEEPAGENTS_CONFIG_FILE="/sandbox/.deepagents/config.toml" +readonly OPENSHELL_TLS_KEY_PATH="/etc/openshell/tls/client/tls.key" run_dcode() { exec python3 -m deepagents_code "$@" @@ -61,6 +63,11 @@ run_dcode() { # rejects secret-shaped runtime/.env values, or (b) all dcode invocations # route through a Node entrypoint that imports the canonical patterns directly. +has_context_secret_shape() { + local upper="${1^^}" + [[ "$upper" =~ (_KEY|API_KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL)[=:[:space:]][\'\"]?[A-Z0-9_.+/=-]{10,} ]] +} + has_non_slack_secret_shape() { local value="$1" if [[ "$value" =~ (sk-proj-|sk-ant-)[A-Za-z0-9_-]{10,} ]]; then @@ -69,7 +76,7 @@ has_non_slack_secret_shape() { if [[ "$value" =~ sk-[A-Za-z0-9_-]{20,} ]]; then return 0 fi - if [[ "$value" =~ (nvapi-|nvcf-|ghp_|hf_|glpat-|gsk_|pypi-)[A-Za-z0-9_-]{10,} ]]; then + if [[ "$value" =~ (nvapi-|nvcf-|ghp_|hf_|glpat-|gsk_|pypi-|tvly-)[A-Za-z0-9_-]{10,} ]]; then return 0 fi if [[ "$value" =~ github_pat_[A-Za-z0-9_]{30,} ]]; then @@ -90,6 +97,9 @@ has_non_slack_secret_shape() { if [[ "$value" =~ [Bb]earer[[:space:]]+[A-Za-z0-9_.+/=-]{10,} ]]; then return 0 fi + if has_context_secret_shape "$value"; then + return 0 + fi if [[ "$value" =~ lsv2_(pt|sk)_[A-Za-z0-9]{10,}(_[A-Za-z0-9]+)* ]]; then return 0 fi @@ -154,7 +164,7 @@ is_secret_shaped_value() { if [[ "$value" =~ sk-[A-Za-z0-9_-]{20,} ]]; then return 0 fi - if [[ "$value" =~ (nvapi-|nvcf-|ghp_|hf_|glpat-|gsk_|pypi-)[A-Za-z0-9_-]{10,} ]]; then + if [[ "$value" =~ (nvapi-|nvcf-|ghp_|hf_|glpat-|gsk_|pypi-|tvly-)[A-Za-z0-9_-]{10,} ]]; then return 0 fi if [[ "$value" =~ github_pat_[A-Za-z0-9_]{30,} ]]; then @@ -181,6 +191,9 @@ is_secret_shaped_value() { if [[ "$value" =~ [Bb]earer[[:space:]]+[A-Za-z0-9_.+/=-]{10,} ]]; then return 0 fi + if has_context_secret_shape "$value"; then + return 0 + fi if [[ "$value" =~ lsv2_(pt|sk)_[A-Za-z0-9]{10,}(_[A-Za-z0-9]+)* ]]; then return 0 fi @@ -200,6 +213,16 @@ has_credential_name_context() { return 1 } +# SECURITY: OpenShell's supervisor injects this mounted TLS key path into the +# runtime environment. Allow only the exact name/value pair after the generic +# value scan. Never allow the name alone, and never apply this exception to the +# mutable Deep Agents Code .env file. +is_allowed_openshell_runtime_value() { + local name="$1" + local value="$2" + [ "$name" = "OPENSHELL_TLS_KEY" ] && [ "$value" = "$OPENSHELL_TLS_KEY_PATH" ] +} + is_dynamic_dotenv_value() { local value="$1" case "$value" in @@ -238,7 +261,7 @@ assert_no_secret_runtime_env() { if is_secret_shaped_value "$value"; then refuse_secret_env "runtime environment variable" "$name" fi - if has_credential_name_context "$name" && [ ${#value} -ge 10 ]; then + if has_credential_name_context "$name" && [ ${#value} -ge 10 ] && ! is_allowed_openshell_runtime_value "$name" "$value"; then refuse_secret_env "runtime environment variable" "$name" fi done < <(env -0) @@ -297,8 +320,164 @@ assert_no_secret_env_file() { assert_no_secret_runtime_env assert_no_secret_env_file +toml_section_scalar() { + local section="$1" + local key="$2" + local line current_section="" + [ -r "$DEEPAGENTS_CONFIG_FILE" ] || return 0 + while IFS= read -r line || [ -n "$line" ]; do + line="$(trim_whitespace "$line")" + case "$line" in + \[*\]) + current_section="${line#\[}" + current_section="${current_section%\]}" + continue + ;; + esac + [ "$current_section" = "$section" ] || continue + case "$line" in + "$key = \""*) + line="${line#"$key = \""}" + printf '%s' "${line%\"}" + return 0 + ;; + esac + done <"$DEEPAGENTS_CONFIG_FILE" + return 0 +} + +toml_provider_metadata() { + local field="$1" + local line route provider _api + [ -r "$DEEPAGENTS_CONFIG_FILE" ] || return 0 + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + "# NemoClaw provider route: "*) + line="${line#"# NemoClaw provider route: "}" + IFS=';' read -r route provider _api <<<"$line" + route="$(trim_whitespace "$route")" + provider="$(trim_whitespace "$provider")" + case "$provider" in + "upstream provider: "*) provider="${provider#"upstream provider: "}" ;; + *) provider="" ;; + esac + case "$field" in + route) printf '%s' "$route" ;; + provider) printf '%s' "$provider" ;; + esac + return 0 + ;; + esac + done <"$DEEPAGENTS_CONFIG_FILE" + return 0 +} + +is_safe_dcode_agent_name() { + local value="$1" + local pattern='^[A-Za-z0-9_ -]+$' + local LC_ALL=C + [ -n "$value" ] || return 1 + [ -n "$(trim_whitespace "$value")" ] || return 1 + [[ "$value" =~ $pattern ]] +} + +resolve_dcode_agent() { + local config_dir candidate + config_dir="${DEEPAGENTS_CONFIG_FILE%/*}" + candidate="$(toml_section_scalar agents default)" + if is_safe_dcode_agent_name "$candidate" && [ -d "$config_dir/$candidate" ]; then + printf '%s' "$candidate" + return 0 + fi + candidate="$(toml_section_scalar agents recent)" + if is_safe_dcode_agent_name "$candidate" && [ -d "$config_dir/$candidate" ]; then + printf '%s' "$candidate" + return 0 + fi + printf '%s' 'agent (default)' +} + +terminal_safe_identity_value() { + local value="$1" + local fallback="${2:-}" + local LC_ALL=C + if [ ${#value} -gt 256 ] || [[ "$value" =~ [[:cntrl:]] ]] || is_secret_shaped_value "$value"; then + printf '%s' "$fallback" + else + printf '%s' "$value" + fi +} + +safe_endpoint_identity_value() { + local value scheme authority + value="$(terminal_safe_identity_value "$1")" + [ -n "$value" ] || return 0 + case "$value" in + *\\* | *\?* | *\#*) return 0 ;; + esac + scheme="${value%%://*}" + [ "$scheme" != "$value" ] || return 0 + case "${scheme,,}" in + http | https) ;; + *) return 0 ;; + esac + authority="${value#*://}" + authority="${authority%%/*}" + case "$authority" in + "" | *@*) return 0 ;; + esac + printf '%s' "$value" +} + +print_identity() { + local sandbox_name agent model endpoint route provider + sandbox_name="$(terminal_safe_identity_value "${NEMOCLAW_SANDBOX_NAME:-unknown}" unknown)" + agent="$(terminal_safe_identity_value "$(resolve_dcode_agent)" 'agent (default)')" + model="$(terminal_safe_identity_value "$(toml_section_scalar models default)")" + [ -n "$model" ] || model="$(terminal_safe_identity_value "$(toml_section_scalar models recent)")" + endpoint="$(toml_section_scalar models.providers.openai base_url)" + route="$(terminal_safe_identity_value "$(toml_provider_metadata route)")" + provider="$(terminal_safe_identity_value "$(toml_provider_metadata provider)")" + [ -n "$endpoint" ] || endpoint="${OPENAI_BASE_URL:-}" + endpoint="$(safe_endpoint_identity_value "$endpoint")" + printf 'Sandbox: %s\n' "$sandbox_name" + printf 'Harness: %s\n' 'langchain-deepagents-code' + printf 'Agent: %s\n' "$agent" + if [ -n "$route" ]; then + printf 'Route: %s\n' "$route" + fi + if [ -n "$provider" ]; then + printf 'Provider: %s\n' "$provider" + fi + if [ -n "$model" ]; then + printf 'Model: %s\n' "$model" + fi + if [ -n "$endpoint" ]; then + printf 'Endpoint: %s\n' "$endpoint" + fi + printf 'Runtime: %s\n' 'Deep Agents Code (terminal)' +} + +print_managed_help() { + cat <<'EOF' +NemoClaw-managed commands: + dcode status Show managed sandbox and dcode runtime identity + dcode whoami Alias for dcode status + dcode identity Alias for dcode status + +EOF +} + case "${1:-}" in - --version | -v | -V | --help | -h) + status | whoami | identity) + print_identity + exit 0 + ;; + --help | -h | help) + print_managed_help + run_dcode "$@" + ;; + --version | -v | -V) run_dcode "$@" ;; esac diff --git a/agents/langchain-deepagents-code/start.sh b/agents/langchain-deepagents-code/start.sh index e3059971e2c..ed4fbedfe00 100755 --- a/agents/langchain-deepagents-code/start.sh +++ b/agents/langchain-deepagents-code/start.sh @@ -140,6 +140,7 @@ prepare_runtime_env() { write_export_if_set LANGSMITH_TRACING write_export_if_set LANGSMITH_PROJECT write_export_if_set DEEPAGENTS_CODE_LANGSMITH_PROJECT + write_export_if_set NEMOCLAW_SANDBOX_NAME } >"$tmp" # Dcode intentionally runs as the non-root sandbox user, unlike the # root-supervised OpenClaw/Hermes startup path. This atomic, sandbox-user-owned diff --git a/docs/get-started/quickstart-langchain-deepagents-code.mdx b/docs/get-started/quickstart-langchain-deepagents-code.mdx index 9f7f4f368d3..b82317c2692 100644 --- a/docs/get-started/quickstart-langchain-deepagents-code.mdx +++ b/docs/get-started/quickstart-langchain-deepagents-code.mdx @@ -65,6 +65,17 @@ dcode -n "Summarize this repository" The managed wrapper launches Deep Agents Code with `HOME=/sandbox`, update checks disabled, remote Deep Agents sandbox providers disabled, MCP auto-loading disabled, and shell allow-list overrides blocked. +To confirm which sandbox a session is in, run the identity command: + +```bash +dcode status +``` + +The command prints the sandbox name, NemoClaw harness, active `dcode` agent, configured inference route, upstream provider, model, endpoint, and runtime, then exits without starting the interactive UI. +`dcode whoami` and `dcode identity` are aliases. +`dcode --help` lists the managed aliases before the upstream Deep Agents Code help. +The sandbox name resolves when you run the command from a `nemoclaw connect` shell, which loads the NemoClaw runtime environment. + ## Python Environment Deep Agents Code runs from a NemoClaw-managed Python virtual environment at `/opt/venv`. diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 51fe60f5115..cd5d951a41f 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -2998,10 +2998,9 @@ async function createSandbox( const envMessagingState = MessagingHostStateApplier.readPlanStateFromEnv(); const plannedMessagingState = envMessagingState?.plan.sandboxName === sandboxName ? envMessagingState : undefined; - const plannedMessagingPlan = plannedMessagingState?.plan; sandboxBuildPatchConfig.prepareSandboxBuildPatchConfig({ configuredMessagingChannels: - getChannelsFromPlan(plannedMessagingPlan) ?? activeMessagingChannels, + getChannelsFromPlan(plannedMessagingState?.plan) ?? activeMessagingChannels, }); const { buildId } = await sandboxDockerfilePatchFlow.prepareSandboxDockerfilePatch({ agent, @@ -3025,6 +3024,7 @@ async function createSandbox( agent, chatUiUrl, createArgs, + sandboxName, env: process.env, extraPlaceholderKeys, getDashboardForwardPort, diff --git a/src/lib/onboard/sandbox-create-launch.test.ts b/src/lib/onboard/sandbox-create-launch.test.ts index 850ca7be0fb..dda31ff4c2f 100644 --- a/src/lib/onboard/sandbox-create-launch.test.ts +++ b/src/lib/onboard/sandbox-create-launch.test.ts @@ -223,4 +223,39 @@ describe("prepareSandboxCreateLaunch", () => { fs.rmSync(tmpDir, { recursive: true, force: true }); } }); + + it("forwards the validated sandbox name into the Deep Agents Code sandbox create env", () => { + const result = prepareSandboxCreateLaunch({ + agent: { name: "langchain-deepagents-code" } as any, + chatUiUrl: "", + createArgs: ["--name", "rendered-name"], + sandboxName: "dcode-demo", + env: {}, + extraPlaceholderKeys: [], + getDashboardForwardPort: vi.fn(() => "0"), + hermesDashboardState: disabledHermesDashboardState, + manageDashboard: false, + openshellShellCommand: (args) => args.join(" "), + buildEnv: () => ({}), + }); + + expect(result.envArgs).toContain("NEMOCLAW_SANDBOX_NAME=dcode-demo"); + expect(result.envArgs).not.toContain("NEMOCLAW_SANDBOX_NAME=rendered-name"); + }); + + it("does not forward the sandbox name for non-Deep-Agents-Code agents", () => { + const result = prepareSandboxCreateLaunch({ + agent: { name: "openclaw", configPaths: { dir: "/sandbox/.custom-openclaw" } } as any, + chatUiUrl: "http://127.0.0.1:19000/", + createArgs: ["--name", "demo"], + env: {}, + extraPlaceholderKeys: [], + getDashboardForwardPort: () => "19000", + hermesDashboardState: disabledHermesDashboardState, + openshellShellCommand: (args) => args.join(" "), + buildEnv: () => ({}), + }); + + expect(result.envArgs.some((arg) => arg.startsWith("NEMOCLAW_SANDBOX_NAME="))).toBe(false); + }); }); diff --git a/src/lib/onboard/sandbox-create-launch.ts b/src/lib/onboard/sandbox-create-launch.ts index ecbe10421ff..73203db0f04 100644 --- a/src/lib/onboard/sandbox-create-launch.ts +++ b/src/lib/onboard/sandbox-create-launch.ts @@ -17,6 +17,7 @@ export interface SandboxCreateLaunchInput { agent: AgentDefinition | null | undefined; chatUiUrl: string; createArgs: readonly string[]; + sandboxName?: string; env?: NodeJS.ProcessEnv; extraPlaceholderKeys: readonly string[]; getDashboardForwardPort(chatUiUrl: string): string; @@ -74,6 +75,13 @@ export function prepareSandboxCreateLaunch(input: SandboxCreateLaunchInput): San envArgs.push(formatEnvAssignment("NEMOCLAW_PROXY_PORT", sandboxProxyPort)); } + if (input.agent?.name === "langchain-deepagents-code") { + const sandboxName = input.sandboxName; + if (sandboxName) { + envArgs.push(formatEnvAssignment("NEMOCLAW_SANDBOX_NAME", sandboxName)); + } + } + appendExtraPlaceholderKeysEnvArg(envArgs, input.extraPlaceholderKeys, formatEnvAssignment); const sandboxEnv = (input.buildEnv ?? buildSubprocessEnv)(); diff --git a/test/dcode-wrapper-identity.test.ts b/test/dcode-wrapper-identity.test.ts new file mode 100644 index 00000000000..112079d49ff --- /dev/null +++ b/test/dcode-wrapper-identity.test.ts @@ -0,0 +1,426 @@ +// 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"; + +const WRAPPER = path.join( + import.meta.dirname, + "..", + "agents", + "langchain-deepagents-code", + "dcode-wrapper.sh", +); + +const canRun = process.platform === "linux"; + +const SAMPLE_CONFIG = [ + "# Generated by NemoClaw. This file contains no provider secrets.", + "# NemoClaw provider route: inference; upstream provider: nvidia-prod; API: openai-completions.", + "", + "[agents]", + 'default = "backend-dev"', + 'recent = "frontend-dev"', + "", + "[models]", + 'default = "openai:demo-model"', + "", + "[models.providers.openai]", + 'models = ["demo-model"]', + 'base_url = "https://inference.local/v1"', + "enabled = true", + "", +].join("\n"); + +const OPAQUE = "Zx3Qw9Lp7Rt2Vn5Bd8Kf1Mh6Cg4Js0Ay"; +const CANONICAL_TLS_KEY_PATH = "/etc/openshell/tls/client/tls.key"; + +type Fixture = { wrapperPath: string; ranMarker: string; envFile: string; configDir: string }; + +function buildFixture(tempDir: string, configContent: string): Fixture { + const wrapperPath = path.join(tempDir, "dcode"); + const ranMarker = path.join(tempDir, "dcode-ran"); + const envFile = path.join(tempDir, ".env"); + const configFile = path.join(tempDir, "config.toml"); + const fixture = fs + .readFileSync(WRAPPER, "utf8") + .replace( + 'readonly DEEPAGENTS_ENV_FILE="/sandbox/.deepagents/.env"', + `readonly DEEPAGENTS_ENV_FILE="${envFile}"`, + ) + .replace( + 'readonly DEEPAGENTS_CONFIG_FILE="/sandbox/.deepagents/config.toml"', + `readonly DEEPAGENTS_CONFIG_FILE="${configFile}"`, + ) + .replace( + "exec python3 -m deepagents_code", + `touch "${ranMarker}"; echo dcode-stub-ran; exit 0; : python3 -m deepagents_code`, + ); + fs.writeFileSync(envFile, "", "utf8"); + fs.writeFileSync(configFile, configContent, "utf8"); + fs.writeFileSync(wrapperPath, fixture, "utf8"); + fs.chmodSync(wrapperPath, 0o755); + return { wrapperPath, ranMarker, envFile, configDir: tempDir }; +} + +function addAgentDir(fixture: Fixture, name: string): void { + fs.mkdirSync(path.join(fixture.configDir, name)); +} + +type Run = { status: number | null; stdout: string; stderr: string; launched: boolean }; + +function runBashWrapper(fixture: Fixture, args: readonly string[], env: NodeJS.ProcessEnv): Run { + const result = spawnSync("bash", [fixture.wrapperPath, ...args], { + env: { + PATH: process.env.PATH ?? "/usr/bin:/bin", + HOME: path.dirname(fixture.wrapperPath), + ...env, + }, + encoding: "utf8", + timeout: 10000, + }); + return { + status: result.status, + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + launched: fs.existsSync(fixture.ranMarker), + }; +} + +function withTempDir(run: (dir: string) => void): void { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dcode-identity-")); + try { + run(dir); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +describe.skipIf(!canRun)( + "agents/langchain-deepagents-code/dcode-wrapper.sh identity command", + () => { + for (const sub of ["status", "whoami", "identity"]) { + it(`'${sub}' reports the sandbox identity and does not launch dcode`, () => { + withTempDir((dir) => { + const fixture = buildFixture(dir, SAMPLE_CONFIG); + addAgentDir(fixture, "backend-dev"); + const run = runBashWrapper(fixture, [sub], { + NEMOCLAW_SANDBOX_NAME: "dcode-demo", + }); + + expect(run.status).toBe(0); + expect(run.launched).toBe(false); + expect(run.stdout).toContain("Sandbox: dcode-demo"); + expect(run.stdout).toContain("Harness: langchain-deepagents-code"); + expect(run.stdout).toContain("Agent: backend-dev"); + expect(run.stdout).toContain("Route: inference"); + expect(run.stdout).toContain("Provider: nvidia-prod"); + expect(run.stdout).toContain("Model: openai:demo-model"); + expect(run.stdout).toContain("Endpoint: https://inference.local/v1"); + expect(run.stdout).toContain("Runtime: Deep Agents Code (terminal)"); + }); + }); + } + + it("uses a valid recent dcode agent when the configured default is stale", () => { + withTempDir((dir) => { + const fixture = buildFixture(dir, SAMPLE_CONFIG); + addAgentDir(fixture, "frontend-dev"); + const run = runBashWrapper(fixture, ["status"], {}); + + expect(run.status).toBe(0); + expect(run.stdout).toContain("Agent: frontend-dev"); + }); + }); + + it("uses the upstream default agent when configured preferences are stale", () => { + withTempDir((dir) => { + const run = runBashWrapper(buildFixture(dir, SAMPLE_CONFIG), ["status"], {}); + + expect(run.status).toBe(0); + expect(run.stdout).toContain("Agent: agent (default)"); + }); + }); + + it("ignores traversal-shaped agent preferences", () => { + withTempDir((dir) => { + const config = SAMPLE_CONFIG.replace('default = "backend-dev"', 'default = ".."'); + const fixture = buildFixture(dir, config); + addAgentDir(fixture, "frontend-dev"); + const run = runBashWrapper(fixture, ["status"], {}); + + expect(run.status).toBe(0); + expect(run.stdout).toContain("Agent: frontend-dev"); + }); + }); + + it("ignores agent preferences that dcode cannot activate", () => { + withTempDir((dir) => { + for (const invalidName of [".hidden", " "]) { + const config = SAMPLE_CONFIG.replace( + 'default = "backend-dev"', + `default = "${invalidName}"`, + ); + const fixture = buildFixture(dir, config); + addAgentDir(fixture, invalidName); + addAgentDir(fixture, "frontend-dev"); + const run = runBashWrapper(fixture, ["status"], {}); + + expect(run.status).toBe(0); + expect(run.stdout).toContain("Agent: frontend-dev"); + expect(run.stdout).not.toContain(`Agent: ${invalidName}`); + fs.rmSync(path.join(fixture.configDir, "frontend-dev"), { recursive: true }); + } + }); + }); + + it("does not write control characters from mutable identity metadata", () => { + withTempDir((dir) => { + const escape = "\u001b[31m"; + const config = SAMPLE_CONFIG.replace( + 'default = "openai:demo-model"', + `default = "openai:${escape}spoof"`, + ) + .replace("upstream provider: nvidia-prod", `upstream provider: nvidia-prod${escape}`) + .replace('base_url = "https://inference.local/v1"', ""); + const run = runBashWrapper(buildFixture(dir, config), ["status"], { + NEMOCLAW_SANDBOX_NAME: `demo${escape}`, + OPENAI_BASE_URL: `https://inference.local/${escape}`, + }); + + expect(run.status).toBe(0); + expect(run.stdout).not.toContain("\u001b"); + expect(run.stdout).toContain("Sandbox: unknown"); + expect(run.stdout).not.toContain("Provider:"); + expect(run.stdout).not.toContain("Model:"); + expect(run.stdout).not.toContain("Endpoint:"); + + const unsafeConfigEndpoint = SAMPLE_CONFIG.replace( + "https://inference.local/v1", + `https://inference.local/${escape}`, + ); + const configEndpointRun = runBashWrapper( + buildFixture(dir, unsafeConfigEndpoint), + ["status"], + { OPENAI_BASE_URL: "https://safe-fallback.example.test/v1" }, + ); + + expect(configEndpointRun.status).toBe(0); + expect(configEndpointRun.stdout).not.toContain("safe-fallback.example.test"); + expect(configEndpointRun.stdout).not.toContain("Endpoint:"); + }); + }); + + it("does not write oversized mutable identity metadata", () => { + withTempDir((dir) => { + const oversized = "x".repeat(257); + const config = SAMPLE_CONFIG.replace('base_url = "https://inference.local/v1"', ""); + const run = runBashWrapper(buildFixture(dir, config), ["status"], { + NEMOCLAW_SANDBOX_NAME: oversized, + OPENAI_BASE_URL: oversized, + }); + + expect(run.status).toBe(0); + expect(run.stdout).toContain("Sandbox: unknown"); + expect(run.stdout).not.toContain(oversized); + expect(run.stdout).not.toContain("Endpoint:"); + }); + }); + + it("does not write secret-shaped mutable identity metadata", () => { + withTempDir((dir) => { + const agentSecret = "PASSWORD opaquevalue12345"; + fs.mkdirSync(path.join(dir, agentSecret)); + const secretValues = [ + `tvly-${OPAQUE}`, + "API_KEY=opaquevalue12345", + "TOKEN:opaquevalue12345", + agentSecret, + ]; + for (const secret of secretValues) { + const config = SAMPLE_CONFIG.replace("route: inference", `route: ${secret}`) + .replace("upstream provider: nvidia-prod", `upstream provider: ${secret}`) + .replace('default = "backend-dev"', `default = "${agentSecret}"`) + .replace('default = "openai:demo-model"', `default = "openai:${secret}"`); + const run = runBashWrapper(buildFixture(dir, config), ["status"], {}); + + expect(run.status).toBe(0); + expect(run.stdout).not.toContain(secret); + expect(run.stdout).not.toContain(agentSecret); + expect(run.stdout).toContain("Sandbox: unknown"); + expect(run.stdout).toContain("Agent: agent (default)"); + expect(run.stdout).not.toContain("Route:"); + expect(run.stdout).not.toContain("Provider:"); + expect(run.stdout).not.toContain("Model:"); + } + }); + }); + + it("does not write unsafe endpoint values from mutable sources", () => { + withTempDir((dir) => { + const unsafeEndpoints = [ + "https://status-user:opaque-password@example.test/v1", + "https://example.test/v1?api_key=opaque-secret", + "https://example.test/v1#opaque-fragment", + "https://status-user:opaque-password\\u0040example.test/v1", + "https://example.test/v1\\u003Fapi_key=opaque-secret", + "https", + ]; + for (const endpoint of unsafeEndpoints) { + for (const source of ["config", "runtime"] as const) { + const config = + source === "config" + ? SAMPLE_CONFIG.replace("https://inference.local/v1", endpoint) + : SAMPLE_CONFIG.replace('base_url = "https://inference.local/v1"', ""); + const env = source === "runtime" ? { OPENAI_BASE_URL: endpoint } : {}; + const run = runBashWrapper(buildFixture(dir, config), ["status"], env); + const refusedByRuntimeGuard = source === "runtime" && /api_key=/i.test(endpoint); + + expect(run.status).toBe(refusedByRuntimeGuard ? 2 : 0); + expect(`${run.stdout}\n${run.stderr}`).not.toContain(endpoint); + expect(run.stdout).not.toContain("Endpoint:"); + } + } + }); + }); + + it("writes safe custom endpoint URLs from the runtime fallback", () => { + withTempDir((dir) => { + const endpoint = "https://api.example.test:8443/openai/v1"; + const config = SAMPLE_CONFIG.replace('base_url = "https://inference.local/v1"', ""); + const run = runBashWrapper(buildFixture(dir, config), ["status"], { + OPENAI_BASE_URL: endpoint, + }); + + expect(run.status).toBe(0); + expect(run.stdout).toContain(`Endpoint: ${endpoint}`); + }); + }); + + it("advertises the managed identity commands before delegating help upstream", () => { + withTempDir((dir) => { + const run = runBashWrapper(buildFixture(dir, SAMPLE_CONFIG), ["--help"], {}); + + expect(run.status).toBe(0); + expect(run.launched).toBe(true); + expect(run.stdout).toContain("NemoClaw-managed commands:"); + expect(run.stdout).toContain("dcode status"); + expect(run.stdout).toContain("dcode whoami"); + expect(run.stdout).toContain("dcode identity"); + }); + }); + + it("reports the sandbox as unknown when the name was not injected", () => { + withTempDir((dir) => { + const run = runBashWrapper(buildFixture(dir, SAMPLE_CONFIG), ["status"], {}); + + expect(run.status).toBe(0); + expect(run.launched).toBe(false); + expect(run.stdout).toContain("Sandbox: unknown"); + }); + }); + + it("still launches dcode for a normal interactive invocation", () => { + withTempDir((dir) => { + const run = runBashWrapper(buildFixture(dir, SAMPLE_CONFIG), [], { + NEMOCLAW_SANDBOX_NAME: "dcode-demo", + }); + + expect(run.status).toBe(0); + expect(run.launched).toBe(true); + }); + }); + }, +); + +describe.skipIf(!canRun)( + "agents/langchain-deepagents-code/dcode-wrapper.sh OpenShell infra-key allowlist", + () => { + it("starts dcode when runtime OPENSHELL_TLS_KEY carries the canonical mounted path", () => { + withTempDir((dir) => { + const run = runBashWrapper(buildFixture(dir, SAMPLE_CONFIG), ["--version"], { + OPENSHELL_TLS_KEY: CANONICAL_TLS_KEY_PATH, + }); + + expect(run.status).toBe(0); + expect(run.launched).toBe(true); + expect(run.stderr).not.toContain("refusing to start"); + }); + }); + + it("refuses noncanonical OpenShell TLS key values without printing them", () => { + const pemValue = [ + "-----BEGIN PRIVATE ", + "KEY-----\nraw-private-key\n-----END PRIVATE ", + "KEY-----", + ].join(""); + for (const value of [ + OPAQUE, + pemValue, + "relative/tls.key", + "/tmp/tls.key", + `${CANONICAL_TLS_KEY_PATH}.bak`, + `tvly-${OPAQUE}`, + ]) { + withTempDir((dir) => { + const run = runBashWrapper(buildFixture(dir, SAMPLE_CONFIG), ["--version"], { + OPENSHELL_TLS_KEY: value, + }); + + expect(run.status).toBe(2); + expect(run.launched).toBe(false); + expect(run.stderr).toContain("OPENSHELL_TLS_KEY"); + expect(run.stderr).not.toContain(value); + }); + } + }); + + it("refuses OpenShell TLS key values in the mutable env file", () => { + for (const value of [CANONICAL_TLS_KEY_PATH, OPAQUE]) { + withTempDir((dir) => { + const fixture = buildFixture(dir, SAMPLE_CONFIG); + fs.writeFileSync(fixture.envFile, `OPENSHELL_TLS_KEY=${value}\n`, "utf8"); + + const run = runBashWrapper(fixture, ["--version"], {}); + + expect(run.status).toBe(2); + expect(run.launched).toBe(false); + expect(run.stderr).toContain("OPENSHELL_TLS_KEY"); + expect(run.stderr).toContain(path.join(dir, ".env")); + expect(run.stderr).not.toContain(value); + }); + } + }); + + it("still refuses recognized provider tokens carried by OPENSHELL_TLS_KEY", () => { + for (const value of [`nvapi-${OPAQUE}`, `tvly-${OPAQUE}`]) { + withTempDir((dir) => { + const run = runBashWrapper(buildFixture(dir, SAMPLE_CONFIG), ["--version"], { + OPENSHELL_TLS_KEY: value, + }); + + expect(run.status).toBe(2); + expect(run.launched).toBe(false); + expect(run.stderr).toContain("OPENSHELL_TLS_KEY"); + expect(run.stderr).not.toContain(value); + }); + } + }); + + it("still refuses an opaque credential-name-context variable outside the allowlist", () => { + withTempDir((dir) => { + const run = runBashWrapper(buildFixture(dir, SAMPLE_CONFIG), [], { + CUSTOM_API_KEY: OPAQUE, + }); + + expect(run.status).toBe(2); + expect(run.launched).toBe(false); + expect(run.stderr).toContain("CUSTOM_API_KEY"); + }); + }); + }, +); diff --git a/test/langchain-deepagents-code-image.test.ts b/test/langchain-deepagents-code-image.test.ts index 7e7747c7a2e..7071e24cd59 100644 --- a/test/langchain-deepagents-code-image.test.ts +++ b/test/langchain-deepagents-code-image.test.ts @@ -10,6 +10,7 @@ import YAML from "yaml"; import { CONTEXT_PATTERNS, TOKEN_PREFIX_PATTERNS } from "../src/lib/security/secret-patterns.ts"; import { cloudExperimentalChecksForOnboarding } from "./e2e/live/cloud-experimental-check-list.ts"; +import { makeStartScriptFixture } from "./support/dcode-start-script-fixture.ts"; function fingerprint(patterns: readonly RegExp[]): string[] { return patterns.map((re) => `${re.source}::${re.flags}`); @@ -120,48 +121,6 @@ function policyBinaryPaths(policyText: string, policyName: string): string[] { }); } -function makeStartScriptFixture(tempDir: string): { - envFile: string; - scriptPath: string; -} { - const envFile = path.join(tempDir, "proxy-env.sh"); - const scriptPath = path.join(tempDir, "start.sh"); - const hostFile = path.join(tempDir, "trusted-proxy-host"); - const portFile = path.join(tempDir, "trusted-proxy-port"); - const original = readAgentFile("start.sh"); - expect(original).toContain("local target=/tmp/nemoclaw-proxy-env.sh"); - expect(original).toContain('tmp="$(mktemp /tmp/nemoclaw-proxy-env.XXXXXX)"'); - const fixture = original - .replace( - 'readonly MANAGED_PROXY_HOST_FILE="/usr/local/share/nemoclaw/dcode-proxy-host"', - `readonly MANAGED_PROXY_HOST_FILE="${hostFile}"`, - ) - .replace( - 'readonly MANAGED_PROXY_PORT_FILE="/usr/local/share/nemoclaw/dcode-proxy-port"', - `readonly MANAGED_PROXY_PORT_FILE="${portFile}"`, - ) - .replace( - "readonly MANAGED_PROXY_OWNER_UID=0", - `readonly MANAGED_PROXY_OWNER_UID=${process.getuid?.() ?? 0}`, - ) - .replace("local target=/tmp/nemoclaw-proxy-env.sh", `local target="${envFile}"`) - .replace( - 'tmp="$(mktemp /tmp/nemoclaw-proxy-env.XXXXXX)"', - `tmp="$(mktemp "${tempDir}/nemoclaw-proxy-env.XXXXXX")"`, - ); - expect(fixture).toContain(`local target="${envFile}"`); - expect(fixture).toContain(`tmp="$(mktemp "${tempDir}/nemoclaw-proxy-env.XXXXXX")"`); - expect(fixture).not.toContain("local target=/tmp/nemoclaw-proxy-env.sh"); - expect(fixture).not.toContain('tmp="$(mktemp /tmp/nemoclaw-proxy-env.XXXXXX)"'); - fs.writeFileSync(hostFile, "10.200.0.1\n", "utf8"); - fs.writeFileSync(portFile, "3128\n", "utf8"); - fs.chmodSync(hostFile, 0o444); - fs.chmodSync(portFile, 0o444); - fs.writeFileSync(scriptPath, fixture, "utf8"); - fs.chmodSync(scriptPath, 0o755); - return { envFile, scriptPath }; -} - const PROXY_URL_ENV_NAMES = ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"] as const; const NO_PROXY_ENV_NAMES = ["NO_PROXY", "no_proxy"] as const; const CLEARED_PROXY_ENV_NAMES = ["ALL_PROXY", "all_proxy"] as const; @@ -270,6 +229,25 @@ describe("LangChain Deep Agents Code image contracts", () => { expect(baseDockerfile).toContain("> /sandbox/.profile"); }); + it("serializes the sandbox name into the shell env file for in-sandbox identity", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dcode-start-")); + try { + const { envFile, scriptPath } = makeStartScriptFixture(tempDir); + + execFileSync("bash", [scriptPath, "sh", "-c", ":"], { + env: { + PATH: process.env.PATH ?? "/usr/bin:/bin", + NEMOCLAW_SANDBOX_NAME: "dcode-demo", + }, + encoding: "utf8", + }); + + expect(fs.readFileSync(envFile, "utf8")).toContain("export NEMOCLAW_SANDBOX_NAME=dcode-demo"); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + it("replaces inherited host proxy values with the managed runtime proxy (#6191)", () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dcode-start-")); const { envFile, scriptPath } = makeStartScriptFixture(tempDir); @@ -871,6 +849,8 @@ describe("LangChain Deep Agents Code image contracts", () => { const cases: Array<{ name: string; value: string }> = [ { name: "SLACK_BOT_TOKEN", value: "xoxb-sk-abcdefghijklmnopqrstuvwx" }, { name: "SLACK_APP_TOKEN", value: "xapp-ghp_abcdefghijklmnopqr" }, + { name: "SLACK_BOT_TOKEN", value: "xoxb-API_KEY=opaquevalue12345" }, + { name: "SLACK_APP_TOKEN", value: "xapp-TOKEN:opaquevalue12345" }, { name: "SLACK_BOT_TOKEN", value: `xoxb-lsv2_pt_${"a".repeat(36)}_${"b".repeat(10)}`, @@ -893,6 +873,8 @@ describe("LangChain Deep Agents Code image contracts", () => { const cases: Array<{ name: string; value: string }> = [ { name: "SLACK_BOT_TOKEN", value: "xoxb-nvapi-abcdefghijklmnop" }, { name: "SLACK_APP_TOKEN", value: "xapp-pypi-abcdefghijklmnop" }, + { name: "SLACK_BOT_TOKEN", value: "xoxb-PASSWORD opaquevalue12345" }, + { name: "SLACK_APP_TOKEN", value: "xapp-CREDENTIAL=opaquevalue12345" }, { name: "SLACK_APP_TOKEN", value: `xapp-lsv2_sk_${"a".repeat(36)}_${"b".repeat(10)}`, @@ -1382,6 +1364,7 @@ describe("LangChain Deep Agents Code image contracts", () => { { name: "glpat", sample: "glpat-abcdefghijklmn" }, { name: "gsk", sample: "gsk_abcdefghijklmnop" }, { name: "pypi", sample: "pypi-abcdefghijklmnop" }, + { name: "tavily", sample: "tvly-abcdefghijklmnop" }, { name: "telegram", sample: "123456789:AbcDefGhiJklMnoPqrStuVwxYz012345678" }, { name: "telegram_bot", sample: "bot123456789:AbcDefGhiJklMnoPqrStuVwxYz012345678" }, { diff --git a/test/support/dcode-start-script-fixture.ts b/test/support/dcode-start-script-fixture.ts new file mode 100644 index 00000000000..9ba4e6271fc --- /dev/null +++ b/test/support/dcode-start-script-fixture.ts @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +const START_SCRIPT = path.join( + import.meta.dirname, + "..", + "..", + "agents", + "langchain-deepagents-code", + "start.sh", +); + +export function makeStartScriptFixture(tempDir: string): { + envFile: string; + scriptPath: string; +} { + const envFile = path.join(tempDir, "proxy-env.sh"); + const scriptPath = path.join(tempDir, "start.sh"); + const hostFile = path.join(tempDir, "trusted-proxy-host"); + const portFile = path.join(tempDir, "trusted-proxy-port"); + const original = fs.readFileSync(START_SCRIPT, "utf8"); + assert.ok(original.includes("local target=/tmp/nemoclaw-proxy-env.sh")); + assert.ok(original.includes('tmp="$(mktemp /tmp/nemoclaw-proxy-env.XXXXXX)"')); + const fixture = original + .replace( + 'readonly MANAGED_PROXY_HOST_FILE="/usr/local/share/nemoclaw/dcode-proxy-host"', + `readonly MANAGED_PROXY_HOST_FILE="${hostFile}"`, + ) + .replace( + 'readonly MANAGED_PROXY_PORT_FILE="/usr/local/share/nemoclaw/dcode-proxy-port"', + `readonly MANAGED_PROXY_PORT_FILE="${portFile}"`, + ) + .replace( + "readonly MANAGED_PROXY_OWNER_UID=0", + `readonly MANAGED_PROXY_OWNER_UID=${process.getuid?.() ?? 0}`, + ) + .replace("local target=/tmp/nemoclaw-proxy-env.sh", `local target="${envFile}"`) + .replace( + 'tmp="$(mktemp /tmp/nemoclaw-proxy-env.XXXXXX)"', + `tmp="$(mktemp "${tempDir}/nemoclaw-proxy-env.XXXXXX")"`, + ); + assert.ok(fixture.includes(`local target="${envFile}"`)); + assert.ok(fixture.includes(`tmp="$(mktemp "${tempDir}/nemoclaw-proxy-env.XXXXXX")"`)); + assert.ok(!fixture.includes("local target=/tmp/nemoclaw-proxy-env.sh")); + assert.ok(!fixture.includes('tmp="$(mktemp /tmp/nemoclaw-proxy-env.XXXXXX)"')); + fs.writeFileSync(hostFile, "10.200.0.1\n", "utf8"); + fs.writeFileSync(portFile, "3128\n", "utf8"); + fs.chmodSync(hostFile, 0o444); + fs.chmodSync(portFile, 0o444); + fs.writeFileSync(scriptPath, fixture, "utf8"); + fs.chmodSync(scriptPath, 0o755); + return { envFile, scriptPath }; +}