Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 42 additions & 17 deletions test/e2e/live/network-policy-interactive.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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<HostCliClient, "command">,
options: RunInteractivePolicyAddOptions,
): Promise<ShellProbeResult> {
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,
});
}
33 changes: 6 additions & 27 deletions test/e2e/live/network-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -125,30 +122,12 @@ async function applyPresetInteractively(
host: HostCliClient,
preset: string,
): Promise<ShellProbeResult> {
const listResult = await host.command(
"bash",
[
"-lc",
'env NEMOCLAW_NON_INTERACTIVE= node "$NEMOCLAW_E2E_CLI" "$NEMOCLAW_E2E_SANDBOX" policy-add </dev/null',
],
{
artifactName: `policy-add-${preset}-interactive-list`,
env: baseEnv({
NEMOCLAW_E2E_CLI: CLI_ENTRYPOINT,
NEMOCLAW_E2E_SANDBOX: SANDBOX_NAME,
}),
timeoutMs: SANDBOX_EXEC_TIMEOUT_MS,
},
);
const presetNumber = requirePolicyPresetNumber(text(listResult), preset);

const result = await host.command("expect", ["-c", POLICY_ADD_EXPECT_SCRIPT], {
const result = await runInteractivePolicyAdd(host, {
artifactName: `policy-add-${preset}-interactive`,
env: baseEnv({
NEMOCLAW_E2E_CLI: CLI_ENTRYPOINT,
NEMOCLAW_E2E_SANDBOX: SANDBOX_NAME,
NEMOCLAW_E2E_PRESET_NUM: presetNumber,
}),
cliEntrypoint: CLI_ENTRYPOINT,
env: baseEnv(),
preset,
sandboxName: SANDBOX_NAME,
timeoutMs: SANDBOX_EXEC_TIMEOUT_MS,
});
await sleep(POLICY_SETTLE_MS);
Expand Down
141 changes: 121 additions & 20 deletions test/e2e/support/network-policy-interactive.test.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,134 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { describe, expect, it } from "vitest";
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, describe, expect, it, vi } from "vitest";

import {
findPolicyPresetNumber,
POLICY_ADD_EXPECT_SCRIPT,
requirePolicyPresetNumber,
runInteractivePolicyAdd,
} from "../live/network-policy-interactive.ts";

describe("network-policy interactive preset harness", () => {
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");
});
});
});
Loading