diff --git a/docs/reference/troubleshooting.mdx b/docs/reference/troubleshooting.mdx index 21d98e92689..e35128ca0fa 100644 --- a/docs/reference/troubleshooting.mdx +++ b/docs/reference/troubleshooting.mdx @@ -1415,7 +1415,8 @@ The tool's own stdout/stderr bytes and its exit code are left unchanged. The bre The first interactive `$$nemoclaw connect` shell also prints a one-line reminder of this denial signature and the `logs` command below. The reminder is shown once per top-level interactive session, and only when all of these hold: an egress proxy is configured, the shell is interactive with a terminal attached to stderr, and it is a top-level shell (not a nested subshell or pane). Suppress it with `NEMOCLAW_NO_POLICY_HINT=1`. -On OpenShell 0.0.44 or newer the reminder names your real sandbox; on older OpenShell it shows `` as a placeholder — run `$$nemoclaw list` to see your sandbox names. +The reminder names the sandbox when NemoClaw receives a valid sandbox name during sandbox creation. +If no valid name is available, it shows ``; run `$$nemoclaw list` to see your sandbox names. If the reported sandbox name contains characters that are not valid in a sandbox name (uppercase letters, underscores, control characters, and similar) or exceeds 63 characters, the reminder shows the `` placeholder for safety rather than echoing the untrusted value. The reminder is intentionally proactive: the denial itself is surfaced by the OpenShell proxy, so the `curl`/`git` error text is left unchanged and the reminder points you to the logs instead. diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index c4354d7ac14..76a40eb8b8e 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -3420,6 +3420,45 @@ GATEWAYURLENVEOF # WhatsApp reinjects it only for its gateway-backed login command. printf "export NEMOCLAW_OPENCLAW_ALLOW_INSECURE_PRIVATE_WS='1'\n" fi + # #7795: bake the sandbox name for the connect-shell hints below. + # OpenShell exports OPENSHELL_SANDBOX as the boolean "1" to every process it + # spawns inside the sandbox — this entrypoint included — and only its own + # root-owned PID 1 keeps the real name, which this unprivileged entrypoint + # cannot read. So the hints had no way to resolve the name and always fell + # back to the '' placeholder. NEMOCLAW_SANDBOX_NAME is injected by the + # host at sandbox-create time (see buildSandboxRuntimeEnvArgs in + # src/lib/onboard/sandbox-create-launch.ts) and is the only in-container + # source of the name; capture it here for the renderer below. + # + # Apply the same RFC-1123 allowlist the renderer uses (mirrors + # NAME_VALID_PATTERN in src/lib/name-validation.ts). Missing or invalid + # values cannot reach a copyable command. An accepted value is limited to + # [a-z0-9-] and needs no further escaping. + # Evaluate the ranges in a subshell under the C locale so [a-z0-9-] stays + # ASCII and is not widened by the entrypoint's LC_COLLATE/LC_CTYPE. + local _sandbox_label_src _sandbox_label + _sandbox_label_src="${NEMOCLAW_SANDBOX_NAME:-}" + ( + LC_ALL=C + _sandbox_label="" + case "$_sandbox_label_src" in + "" | 0 | 1 | true | TRUE | false | FALSE) ;; + [!a-z]* | *- | *[!a-z0-9-]*) ;; + *) + if [ "${#_sandbox_label_src}" -le 63 ]; then + _sandbox_label="$_sandbox_label_src" + fi + ;; + esac + # Emit the negative case too, never nothing: the file is sourced into a + # shell the sandbox controls, so an explicit unset stops a pre-set value + # from surviving when no valid name is available. + if [ -n "$_sandbox_label" ]; then + printf "export _NEMOCLAW_SANDBOX_LABEL='%s'\n" "$_sandbox_label" + else + printf 'unset _NEMOCLAW_SANDBOX_LABEL\n' + fi + ) cat <<'GUARDENVEOF' # nemoclaw-configure-guard begin # #4538: a raw in-sandbox `openclaw doctor --fix` (run directly from a connect @@ -3872,18 +3911,21 @@ openclaw() { # behavior is this proactive connect-shell reminder. It does NOT make the # denial-time curl/git/wget error itself denial-adjacent — that is intentional, # given the source boundary above — so the tool error stays unchanged. -_nemoclaw_policy_denial_hint_label() { - # OpenShell >=0.0.44 sets OPENSHELL_SANDBOX to the sandbox name; older - # versions set the boolean "1". OPENSHELL_SANDBOX is untrusted input that is - # interpolated into a copyable `nemoclaw … logs` command, so allowlist it - # rather than merely stripping: only render it when it is a valid sandbox name. - # This mirrors NAME_VALID_PATTERN in src/lib/name-validation.ts - # (/^[a-z]([a-z0-9-]*[a-z0-9])?$/, max 63): starts with a lowercase letter, - # then lowercase alphanumerics/hyphens, no trailing hyphen. Anything else - # (digit-leading labels, control characters, ANSI escapes, shell - # metacharacters, whitespace) falls back to a placeholder the user resolves - # with `nemoclaw list`. Shell `case` globs match newlines as ordinary - # characters, so an embedded newline is rejected by the metacharacter class. +_nemoclaw_valid_sandbox_label() { + # Print $1 when it is a valid sandbox name, print nothing otherwise. Callers + # treat empty output as "unusable" and move on to the next source. + # + # The candidates are untrusted input interpolated into a copyable `nemoclaw …` + # command, so allowlist rather than merely strip: only render a value that is + # a valid sandbox name. This mirrors NAME_VALID_PATTERN in + # src/lib/name-validation.ts (/^[a-z]([a-z0-9-]*[a-z0-9])?$/, max 63): starts + # with a lowercase letter, then lowercase alphanumerics/hyphens, no trailing + # hyphen. Anything else (digit-leading labels, control characters, ANSI + # escapes, shell metacharacters, whitespace) is rejected, and the caller falls + # back to a placeholder the user resolves with `nemoclaw list`. Shell `case` + # globs match newlines as ordinary characters, so an embedded newline is + # rejected by the metacharacter class. The boolean forms are OpenShell's older + # "this is a sandbox" marker rather than a name. # # Evaluate the ranges under the C locale so [a-z0-9-] stays ASCII and is not # widened by the caller's LC_COLLATE/LC_CTYPE (e.g. a locale that folds @@ -3891,20 +3933,42 @@ _nemoclaw_policy_denial_hint_label() { # is only ever called inside $(…) command substitution (a subshell), so the # assignment cannot leak into the interactive shell. LC_ALL=C - # Allowlist pattern mirrors NAME_VALID_PATTERN in src/lib/name-validation.ts - # (RFC-1123 label: /^[a-z]([a-z0-9-]*[a-z0-9])?$/, max 63). Keep them in sync. - case "${OPENSHELL_SANDBOX:-}" in - "" | 0 | 1 | true | TRUE | false | FALSE) printf '' ;; - [!a-z]* | *- | *[!a-z0-9-]*) printf '' ;; + case "${1:-}" in + "" | 0 | 1 | true | TRUE | false | FALSE) ;; + [!a-z]* | *- | *[!a-z0-9-]*) ;; *) - if [ "${#OPENSHELL_SANDBOX}" -le 63 ]; then - printf '%s' "$OPENSHELL_SANDBOX" - else - printf '' + if [ "${#1}" -le 63 ]; then + printf '%s' "$1" fi ;; esac } +_nemoclaw_policy_denial_hint_label() { + # Render the first source that yields a valid sandbox name. + # + # OPENSHELL_SANDBOX is the runtime value. OpenShell exports it as the boolean + # "1" to sandbox processes. Keep it as the first candidate so a caller-provided + # valid sandbox name takes precedence over the generated fallback. + # + # _NEMOCLAW_SANDBOX_LABEL is the fallback that makes the hints work in the + # connect shell: the host-injected NEMOCLAW_SANDBOX_NAME, captured by the + # entrypoint when it generated this file. It is re-emitted (or explicitly + # unset) on every regeneration, so it cannot go stale, and it is allowlisted + # again here because the sandbox can reassign it after this file is sourced. + # Remove this fallback after the supported OpenShell contract supplies a + # validated sandbox name to every connect-shell process. Ref: #7795. + # + # Both call sites invoke this inside $(…) command substitution (a subshell), + # so the assignment below cannot leak into the interactive shell. + _nemoclaw_hint_label="$(_nemoclaw_valid_sandbox_label "${OPENSHELL_SANDBOX:-}")" + case "$_nemoclaw_hint_label" in + "") _nemoclaw_hint_label="$(_nemoclaw_valid_sandbox_label "${_NEMOCLAW_SANDBOX_LABEL:-}")" ;; + esac + case "$_nemoclaw_hint_label" in + "") printf '' ;; + *) printf '%s' "$_nemoclaw_hint_label" ;; + esac +} _nemoclaw_policy_denial_hint_text() { { printf ' Note: this sandbox restricts outbound network access by policy.\n' diff --git a/src/lib/onboard/sandbox-create-launch.test.ts b/src/lib/onboard/sandbox-create-launch.test.ts index d39c4611479..3a650fc9c11 100644 --- a/src/lib/onboard/sandbox-create-launch.test.ts +++ b/src/lib/onboard/sandbox-create-launch.test.ts @@ -61,6 +61,46 @@ describe("buildSandboxRuntimeEnvArgs", () => { expect(omitted).toContain("NEMOCLAW_DASHBOARD_PORT=19000"); expect(omitted).toContain("NEMOCLAW_PROXY_HOST=host.docker.internal"); }); + + // OpenShell exports OPENSHELL_SANDBOX as the boolean "1" to sandbox processes, + // so this injection is the sandbox's only source for its own name. Without it + // the in-sandbox hints print a `` placeholder instead of a copyable + // host-side command. It used to be injected only for LangChain Deep Agents + // Code. + it("injects NEMOCLAW_SANDBOX_NAME for every agent (#7795)", () => { + const base = { + chatUiUrl: "http://127.0.0.1:19000/", + manageDashboard: true, + getDashboardForwardPort: () => "19000", + hermesDashboardState: disabledHermesDashboardState, + extraPlaceholderKeys: [], + env: {} as NodeJS.ProcessEnv, + sandboxName: "my-assistant", + }; + + for (const agentName of ["openclaw", "hermes", "langchain-deepagents-code"]) { + const envArgs = buildSandboxRuntimeEnvArgs({ + ...base, + agent: { name: agentName, configPaths: { dir: "/sandbox/.openclaw" } } as any, + }).envArgs; + expect(envArgs, `${agentName} should receive the sandbox name`).toContain( + "NEMOCLAW_SANDBOX_NAME=my-assistant", + ); + } + }); + + it("omits NEMOCLAW_SANDBOX_NAME when no sandbox name is known", () => { + const envArgs = buildSandboxRuntimeEnvArgs({ + agent: { name: "openclaw", configPaths: { dir: "/sandbox/.openclaw" } } as any, + chatUiUrl: "http://127.0.0.1:19000/", + manageDashboard: true, + getDashboardForwardPort: () => "19000", + hermesDashboardState: disabledHermesDashboardState, + extraPlaceholderKeys: [], + env: {} as NodeJS.ProcessEnv, + }).envArgs; + expect(envArgs.some((arg) => arg.startsWith("NEMOCLAW_SANDBOX_NAME="))).toBe(false); + }); }); describe("prepareSandboxCreateLaunch", () => { diff --git a/src/lib/onboard/sandbox-create-launch.ts b/src/lib/onboard/sandbox-create-launch.ts index a0232e221bd..1f3aa3cd01b 100644 --- a/src/lib/onboard/sandbox-create-launch.ts +++ b/src/lib/onboard/sandbox-create-launch.ts @@ -151,11 +151,18 @@ export function buildSandboxRuntimeEnvArgs(input: SandboxRuntimeEnvArgsInput): { envArgs.push(formatEnvAssignment("NEMOCLAW_PROXY_PORT", sandboxProxyPort)); } + // Every sandbox needs to know its own name at runtime, not only the LangChain + // Deep Agents Code image. OpenShell exports OPENSHELL_SANDBOX as the boolean + // "1" to the processes it spawns inside the sandbox, so this injection is the + // only in-container source of the name. nemoclaw-start.sh bakes it into the + // connect-shell env so the in-sandbox hints can print a copyable host-side + // `nemoclaw …` command instead of a `` placeholder. (#7795) + const sandboxName = input.sandboxName; + if (sandboxName) { + envArgs.push(formatEnvAssignment("NEMOCLAW_SANDBOX_NAME", sandboxName)); + } + if (agent?.name === "langchain-deepagents-code") { - const sandboxName = input.sandboxName; - if (sandboxName) { - envArgs.push(formatEnvAssignment("NEMOCLAW_SANDBOX_NAME", sandboxName)); - } envArgs.push( formatEnvAssignment( "NEMOCLAW_OBSERVABILITY", diff --git a/test/repro-7795-connect-shell-sandbox-label.test.ts b/test/repro-7795-connect-shell-sandbox-label.test.ts new file mode 100644 index 00000000000..8ac1243abd0 --- /dev/null +++ b/test/repro-7795-connect-shell-sandbox-label.test.ts @@ -0,0 +1,282 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Behavioral regression coverage for #7795. + * + * The in-sandbox hints that print a copyable host-side `nemoclaw …` + * command resolved the sandbox name from `OPENSHELL_SANDBOX` at render time. + * OpenShell records the name on the container, but exports the variable as the + * boolean "1" to every process it spawns inside the sandbox — the entrypoint and + * the `connect` shell included — and keeps the real value only in its own + * root-owned PID 1 environment, which the unprivileged entrypoint cannot read. + * So the name was unavailable in-sandbox and every hint fell back to the literal + * `` placeholder, leaving the copyable command unusable. + * + * The fix injects the host's already-validated sandbox name as + * `NEMOCLAW_SANDBOX_NAME` for every sandbox at create time (it was previously + * injected only for LangChain Deep Agents Code), and the entrypoint bakes it + * into the generated /tmp/nemoclaw-proxy-env.sh as + * `_NEMOCLAW_SANDBOX_LABEL` for the renderer to fall back to. + * + * These tests run the real generator (`write_runtime_shell_env`) under the env + * the entrypoint actually gets, then source its output in a shell where + * `OPENSHELL_SANDBOX=1`, reproducing the connect shell exactly, rather than + * asserting on source text. Both consumers of the label are covered: the + * `openclaw channels add/remove` guard (#7292/#7295) and the policy-denial logs + * breadcrumb (#5978). + */ + +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 { NAME_MAX_LENGTH, NAME_VALID_PATTERN } from "../src/lib/name-validation.js"; + +const START_SCRIPT = path.resolve(import.meta.dirname, "../scripts/nemoclaw-start.sh"); + +function runtimeShellEnvBlock(source: string): string { + const start = source.indexOf("write_runtime_shell_env() {"); + const end = source.indexOf("# cleanup_on_signal", start); + expect(start).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(start); + return source.slice(start, end); +} + +/** + * Run the real generator with the host-injected `NEMOCLAW_SANDBOX_NAME` set to + * `injectedName` and return the generated connect-shell env file. The generator + * also runs with `OPENSHELL_SANDBOX=1`, which is what OpenShell actually exports + * to the entrypoint. + */ +function generateConnectEnv(tmpDir: string, injectedName: string | undefined): string { + const proxyEnv = path.join(tmpDir, "proxy-env.sh"); + const source = fs.readFileSync(START_SCRIPT, "utf8"); + const block = `${runtimeShellEnvBlock(source)}\nwrite_runtime_shell_env`.replaceAll( + "/tmp/nemoclaw-proxy-env.sh", + proxyEnv, + ); + const writer = path.join(tmpDir, "write-env.sh"); + fs.writeFileSync( + writer, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + 'emit_sandbox_sourced_file() { local target="$1"; cat > "$target"; chmod 444 "$target"; }', + 'PROXY_HOST="10.200.0.1"', + 'PROXY_PORT="3128"', + '_PROXY_URL="http://${PROXY_HOST}:${PROXY_PORT}"', + '_NO_PROXY_VAL="localhost,127.0.0.1,::1,${PROXY_HOST}"', + '_SANDBOX_SAFETY_NET="/tmp/safety-net.js"', + '_PROXY_FIX_SCRIPT="/tmp/http-proxy-fix.js"', + '_NEMOTRON_FIX_SCRIPT="/tmp/nemotron-fix.js"', + '_CIAO_GUARD_SCRIPT="/tmp/ciao-guard.js"', + "emit_messaging_connect_runtime_preload_exports() { :; }", + "_TOOL_REDIRECTS=()", + "set +u", + block, + ].join("\n"), + { mode: 0o700 }, + ); + // Drop any inherited value first so `undefined` faithfully models the + // "host injected no name" case; spread the injected one back in branch-free. + const hostEnv: NodeJS.ProcessEnv = { ...process.env }; + delete hostEnv.NEMOCLAW_SANDBOX_NAME; + const env: NodeJS.ProcessEnv = { + ...hostEnv, + OPENSHELL_SANDBOX: "1", + ...(injectedName === undefined ? {} : { NEMOCLAW_SANDBOX_NAME: injectedName }), + }; + const result = spawnSync("bash", [writer], { encoding: "utf8", timeout: 5_000, env }); + expect(result.status, result.stderr).toBe(0); + return fs.readFileSync(proxyEnv, "utf8"); +} + +/** + * Source the generated env file in a shell that mirrors the connect shell + * (`OPENSHELL_SANDBOX=1`) and run `snippet`. Returns the merged output. + */ +function inConnectShell( + tmpDir: string, + snippet: string, + extraEnv: NodeJS.ProcessEnv = {}, +): { output: string; status: number } { + const proxyEnv = path.join(tmpDir, "proxy-env.sh"); + const result = spawnSync( + "bash", + ["--noprofile", "--norc", "-c", `source ${JSON.stringify(proxyEnv)}; ${snippet}`], + { + encoding: "utf8", + timeout: 10_000, + env: { + ...process.env, + // The exact value `openshell sandbox connect` exports (#7795). + OPENSHELL_SANDBOX: "1", + HTTPS_PROXY: "http://127.0.0.1:3128", + ...extraEnv, + }, + }, + ); + return { + output: `${result.stdout ?? ""}${result.stderr ?? ""}`, + status: result.status ?? -1, + }; +} + +function withTmpDir(fn: (tmpDir: string) => T): T { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nc-7795-")); + try { + return fn(tmpDir); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} + +describe("connect-shell sandbox label for host-side hints (#7795)", () => { + it("names the sandbox in the channels guard hint when the connect shell has OPENSHELL_SANDBOX=1", () => { + withTmpDir((tmpDir) => { + generateConnectEnv(tmpDir, "my-assistant"); + const { output, status } = inConnectShell(tmpDir, "openclaw channels add discord"); + expect(status).toBe(1); + expect(output).toContain("Run 'nemoclaw my-assistant channels add discord' on the host."); + expect(output).not.toContain("nemoclaw channels"); + }); + }); + + it("names the sandbox in the policy-denial logs breadcrumb under the same conditions (#5978)", () => { + withTmpDir((tmpDir) => { + generateConnectEnv(tmpDir, "my-assistant"); + const { output } = inConnectShell(tmpDir, "_nemoclaw_policy_denial_hint_text"); + expect(output).toContain("nemoclaw my-assistant logs --tail 50"); + expect(output).not.toContain("nemoclaw logs"); + }); + }); + + it("bakes the validated name into the generated connect env", () => { + withTmpDir((tmpDir) => { + const envFile = generateConnectEnv(tmpDir, "my-assistant"); + expect(envFile).toContain("export _NEMOCLAW_SANDBOX_LABEL='my-assistant'"); + }); + }); + + // Regression lock: the pre-#7795 source must keep priority, so a caller that + // does carry the name in OPENSHELL_SANDBOX still renders it (#5978, #7295). + it("still prefers a usable runtime OPENSHELL_SANDBOX over the baked label", () => { + withTmpDir((tmpDir) => { + generateConnectEnv(tmpDir, "baked-name"); + const { output } = inConnectShell(tmpDir, "_nemoclaw_policy_denial_hint_text", { + OPENSHELL_SANDBOX: "runtime-name", + }); + expect(output).toContain("nemoclaw runtime-name logs --tail 50"); + }); + }); + + // With no usable name from either source, keep the placeholder used before + // #7795. + it.each([ + "1", + "true", + "0", + "false", + "", + ])("falls back to for the unusable injected value %j", (containerValue) => { + withTmpDir((tmpDir) => { + const envFile = generateConnectEnv(tmpDir, containerValue); + expect(envFile).toContain("unset _NEMOCLAW_SANDBOX_LABEL"); + expect(envFile).not.toContain("export _NEMOCLAW_SANDBOX_LABEL"); + const { output } = inConnectShell(tmpDir, "_nemoclaw_policy_denial_hint_text"); + expect(output).toContain("nemoclaw logs --tail 50"); + }); + }); + + it("falls back to when the host injected no sandbox name", () => { + withTmpDir((tmpDir) => { + const envFile = generateConnectEnv(tmpDir, undefined); + expect(envFile).toContain("unset _NEMOCLAW_SANDBOX_LABEL"); + const { output } = inConnectShell(tmpDir, "_nemoclaw_policy_denial_hint_text"); + expect(output).toContain("nemoclaw logs --tail 50"); + }); + }); + + // The baked value crosses the same trust boundary as the runtime one: it comes + // from container-level configuration, so the generator allowlists it before it + // can reach a copyable command. + it.each([ + ["shell metacharacters", "qa-7795; rm -rf /"], + ["ANSI escape and newline", "qa\u001b[31m-7795\nINJECTED"], + ["command substitution", "$(touch /tmp/pwned-7795)"], + ["uppercase leading", "Qa-7795"], + ["digit leading", "9abc"], + ["underscore", "qa_7795"], + ["trailing hyphen", "qa-7795-"], + ])("rejects an invalid injected sandbox name (%s) instead of interpolating it", (_label, value) => { + withTmpDir((tmpDir) => { + const envFile = generateConnectEnv(tmpDir, value); + expect(envFile).toContain("unset _NEMOCLAW_SANDBOX_LABEL"); + expect(envFile).not.toContain("export _NEMOCLAW_SANDBOX_LABEL"); + const { output } = inConnectShell(tmpDir, "openclaw channels add discord"); + expect(output).toContain("Run 'nemoclaw channels add discord' on the host."); + expect(output).not.toContain("\u001b"); + expect(output).not.toContain("INJECTED"); + expect(output).not.toContain("rm -rf"); + }); + }); + + it("rejects an injected name longer than the sandbox name limit", () => { + withTmpDir((tmpDir) => { + const tooLong = `a${"b".repeat(NAME_MAX_LENGTH)}`; + expect(tooLong.length).toBeGreaterThan(NAME_MAX_LENGTH); + const envFile = generateConnectEnv(tmpDir, tooLong); + expect(envFile).toContain("unset _NEMOCLAW_SANDBOX_LABEL"); + const { output } = inConnectShell(tmpDir, "_nemoclaw_policy_denial_hint_text"); + expect(output).toContain("nemoclaw logs --tail 50"); + }); + }); + + // The generated file is sourced into a shell the sandbox controls, so the + // renderer must not trust a label the sandbox supplies itself. + it("re-allowlists the label, so a sandbox-set value cannot inject a host command", () => { + withTmpDir((tmpDir) => { + generateConnectEnv(tmpDir, "my-assistant"); + const { output } = inConnectShell( + tmpDir, + "_NEMOCLAW_SANDBOX_LABEL='evil; rm -rf /'; _nemoclaw_policy_denial_hint_text", + ); + expect(output).toContain("nemoclaw logs --tail 50"); + expect(output).not.toContain("rm -rf"); + }); + }); + + // Without the explicit unset branch a pre-set value would survive into the + // copyable command whenever no trusted name is available. + it("unsets a pre-existing label when the host injected no usable name", () => { + withTmpDir((tmpDir) => { + generateConnectEnv(tmpDir, "1"); + const { output } = inConnectShell(tmpDir, "_nemoclaw_policy_denial_hint_text", { + _NEMOCLAW_SANDBOX_LABEL: "smuggled-name", + }); + expect(output).toContain("nemoclaw logs --tail 50"); + expect(output).not.toContain("smuggled-name"); + }); + }); + + // Anti-drift: the shell allowlist and the TypeScript validator must agree, so + // a name the CLI accepts is a name the hint renders. + // A fresh tmp dir per name: the generator chmods its output 444, so the same + // directory cannot be regenerated into. + it.each([ + "a", + "qa-7795", + "my-assistant", + "a1", + "x".repeat(NAME_MAX_LENGTH), + ])("agrees with NAME_VALID_PATTERN for %j, a name the CLI accepts", (name) => { + expect(NAME_VALID_PATTERN.test(name), `${name} should be a valid sandbox name`).toBe(true); + withTmpDir((tmpDir) => { + const envFile = generateConnectEnv(tmpDir, name); + expect(envFile).toContain(`export _NEMOCLAW_SANDBOX_LABEL='${name}'`); + }); + }); +});