diff --git a/test/e2e/live/network-policy-interactive.ts b/test/e2e/live/network-policy-interactive.ts index f057e12c95f..66f5e966e60 100644 --- a/test/e2e/live/network-policy-interactive.ts +++ b/test/e2e/live/network-policy-interactive.ts @@ -1,17 +1,33 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -// Trust boundary: the Expect program receives only a numeric preset index -// parsed from NemoClaw's own numbered menu plus the literal confirmation Y. -// No dispatch input, secret, or other user-controlled text enters the script. +import type { HostCliClient } from "../fixtures/clients/host.ts"; +import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; + +// Trust boundary: the Expect program receives a fixture-owned preset name. +// It compares that name with NemoClaw's rendered menu entries before it sends +// the matching numeric index and the literal confirmation Y. // Exit codes: 2=preset timeout, 3=preset EOF, 4=confirmation timeout, -// 5=confirmation EOF, and 6=post-confirmation timeout. +// 5=confirmation EOF, 6=post-confirmation timeout, and +// 7=requested preset absent from menu. export const POLICY_ADD_EXPECT_SCRIPT = String.raw` set timeout 60 +match_max 20000 spawn env NEMOCLAW_NON_INTERACTIVE= node $env(NEMOCLAW_E2E_CLI) $env(NEMOCLAW_E2E_SANDBOX) policy-add expect { -glob "*Choose preset*" { - send -- "$env(NEMOCLAW_E2E_PRESET_NUM)\r" + set preset_number "" + foreach line [split $expect_out(buffer) "\n"] { + if {[regexp {^[[:space:]]*([0-9]+)\)[[:space:]]+[●○][[:space:]]+([^[:space:]]+)} $line -> candidate_number candidate_name] && $candidate_name eq $env(NEMOCLAW_E2E_PRESET)} { + set preset_number $candidate_number + break + } + } + if {$preset_number eq ""} { + puts stderr "requested policy preset was not present in the interactive menu" + exit 7 + } + send -- "$preset_number\r" } timeout { puts stderr "timed out waiting for the policy preset prompt" @@ -46,18 +62,27 @@ set wait_result [wait] exit [lindex $wait_result 3] `; -export function findPolicyPresetNumber(output: string, preset: string): string | null { - const escapedPreset = preset.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const match = new RegExp(`^\\s*(\\d+)\\)\\s+(?:[●○]\\s+)?${escapedPreset}(?:\\s|$)`, "m").exec( - output, - ); - return match?.[1] ?? null; +export interface RunInteractivePolicyAddOptions { + artifactName: string; + cliEntrypoint: string; + env: NodeJS.ProcessEnv; + preset: string; + sandboxName: string; + timeoutMs: number; } -export function requirePolicyPresetNumber(output: string, preset: string): string { - const presetNumber = findPolicyPresetNumber(output, preset); - if (!presetNumber) { - throw new Error(`preset ${preset} not found in interactive policy-add list: ${output}`); - } - return presetNumber; +export function runInteractivePolicyAdd( + host: Pick, + options: RunInteractivePolicyAddOptions, +): Promise { + return host.command("expect", ["-c", POLICY_ADD_EXPECT_SCRIPT], { + artifactName: options.artifactName, + env: { + ...options.env, + NEMOCLAW_E2E_CLI: options.cliEntrypoint, + NEMOCLAW_E2E_PRESET: options.preset, + NEMOCLAW_E2E_SANDBOX: options.sandboxName, + }, + timeoutMs: options.timeoutMs, + }); } diff --git a/test/e2e/live/network-policy.test.ts b/test/e2e/live/network-policy.test.ts index a8e1a8b7020..5f2b250e9c1 100644 --- a/test/e2e/live/network-policy.test.ts +++ b/test/e2e/live/network-policy.test.ts @@ -28,10 +28,7 @@ import { CLI_DIST_ENTRYPOINT, CLI_ENTRYPOINT, REPO_ROOT } from "../fixtures/path import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; import { pollDeniedReasonLog } from "./network-policy-denied-log.ts"; import { requireInferenceLocalCompletionText } from "./network-policy-inference.ts"; -import { - POLICY_ADD_EXPECT_SCRIPT, - requirePolicyPresetNumber, -} from "./network-policy-interactive.ts"; +import { runInteractivePolicyAdd } from "./network-policy-interactive.ts"; import { isTransientProviderValidationFailure } from "./network-policy-transient-provider.ts"; import { expectPackageDatabaseReadOnly } from "./package-database-read-only.ts"; import { parseVerifiedActivePolicyPresets } from "./policy-list-state.ts"; @@ -125,30 +122,12 @@ async function applyPresetInteractively( host: HostCliClient, preset: string, ): Promise { - const listResult = await host.command( - "bash", - [ - "-lc", - 'env NEMOCLAW_NON_INTERACTIVE= node "$NEMOCLAW_E2E_CLI" "$NEMOCLAW_E2E_SANDBOX" policy-add { - it("selects the exact requested preset from the interactive list", () => { - const output = ` - 14) ○ hermes-slack — unrelated prefix - 15) ○ slack — Slack API access - 16) ● pypi — Python Package Index -`; - - expect(findPolicyPresetNumber(output, "slack")).toBe("15"); - expect(findPolicyPresetNumber(output, "pypi")).toBe("16"); - expect(findPolicyPresetNumber(output, "missing")).toBeNull(); - expect(() => requirePolicyPresetNumber(output, "missing")).toThrow(/preset missing not found/); + it("passes the preset-picker inputs to Expect (#9045)", async () => { + const result = { + artifacts: { result: "result.json", stderr: "stderr.txt", stdout: "stdout.txt" }, + command: ["expect"], + exitCode: 0, + signal: null, + stderr: "", + stdout: "", + timedOut: false, + }; + const command = vi.fn().mockResolvedValue(result); + + await expect( + runInteractivePolicyAdd( + { command }, + { + artifactName: "policy-add-slack-interactive", + cliEntrypoint: "/repo/dist/cli.js", + env: { PATH: "/usr/bin" }, + preset: "slack", + sandboxName: "e2e-net-policy", + timeoutMs: 120_000, + }, + ), + ).resolves.toBe(result); + + expect(command).toHaveBeenCalledOnce(); + expect(command).toHaveBeenCalledWith("expect", ["-c", POLICY_ADD_EXPECT_SCRIPT], { + artifactName: "policy-add-slack-interactive", + env: { + PATH: "/usr/bin", + NEMOCLAW_E2E_CLI: "/repo/dist/cli.js", + NEMOCLAW_E2E_PRESET: "slack", + NEMOCLAW_E2E_SANDBOX: "e2e-net-policy", + }, + timeoutMs: 120_000, + }); }); - it("waits for each prompt before sending the corresponding response", () => { - expect(POLICY_ADD_EXPECT_SCRIPT).toContain('-glob "*Choose preset*"'); - expect(POLICY_ADD_EXPECT_SCRIPT).toContain('send -- "$env(NEMOCLAW_E2E_PRESET_NUM)\\r"'); - expect(POLICY_ADD_EXPECT_SCRIPT).toContain('-glob "*Y/n*"'); - expect(POLICY_ADD_EXPECT_SCRIPT).toContain('send -- "Y\\r"'); - expect(POLICY_ADD_EXPECT_SCRIPT).not.toMatch(/printf.*Y/); + const expectAvailable = + spawnSync("expect", ["-v"], { + encoding: "utf8", + killSignal: "SIGKILL", + timeout: 5_000, + }).status === 0; + const fixtureDir = mkdtempSync(join(tmpdir(), "nemoclaw-policy-add-pty-")); + const fixtureCli = join(fixtureDir, "fake-policy-add.mjs"); + writeFileSync( + fixtureCli, + `import { createInterface } from "node:readline/promises"; +import process from "node:process"; + +if (!process.stdin.isTTY || !process.stderr.isTTY) { + console.error("policy picker requires a terminal"); + process.exit(20); +} + +const prompts = createInterface({ input: process.stdin, output: process.stderr, terminal: true }); +process.stderr.write("\\n Available presets:\\n"); +process.stderr.write(" 14) ○ hermes-slack — unrelated prefix\\n"); +process.stderr.write(" 15) ○ slack — Slack API access\\n"); +process.stderr.write(" 16) ● pypi — Python Package Index\\n\\n"); +const preset = await prompts.question(" Choose preset [14]: "); +if (preset.trim() !== "15") { + console.error("unexpected preset selection: " + preset); + process.exit(21); +} +const confirmation = await prompts.question(" Apply 'slack'? [Y/n]: "); +if (confirmation.trim() !== "Y") { + console.error("unexpected confirmation: " + confirmation); + process.exit(22); +} +prompts.close(); +console.log("FAKE_POLICY_ADD_OK"); +`, + "utf8", + ); + afterAll(() => rmSync(fixtureDir, { force: true, recursive: true })); + + describe.runIf(expectAvailable)("Expect pseudo-terminal behavior", () => { + function runExpect(preset: string) { + return spawnSync("expect", ["-c", POLICY_ADD_EXPECT_SCRIPT], { + encoding: "utf8", + env: { + ...process.env, + NEMOCLAW_E2E_CLI: fixtureCli, + NEMOCLAW_E2E_PRESET: preset, + NEMOCLAW_E2E_SANDBOX: "e2e-net-policy", + }, + killSignal: "SIGKILL", + timeout: 5_000, + }); + } + + it("selects the exact preset when the picker rejects input without a terminal (#9045)", () => { + const noTerminal = spawnSync(process.execPath, [fixtureCli], { + encoding: "utf8", + input: "15\\nY\\n", + killSignal: "SIGKILL", + timeout: 5_000, + }); + expect(noTerminal.status, noTerminal.stderr).toBe(20); + + const result = runExpect("slack"); + expect(result.status, `${result.stdout}\\n${result.stderr}`).toBe(0); + expect(result.stdout).toContain("FAKE_POLICY_ADD_OK"); + }); + + it("rejects a preset that the rendered menu does not contain (#9045)", () => { + const result = runExpect("missing"); + + expect(result.status, `${result.stdout}\\n${result.stderr}`).toBe(7); + expect(result.stderr).toContain( + "requested policy preset was not present in the interactive menu", + ); + expect(result.stdout).not.toContain("FAKE_POLICY_ADD_OK"); + }); }); });