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
270 changes: 270 additions & 0 deletions test/e2e-scenario/framework-tests/e2e-clients.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import fs from "node:fs";
import os from "node:os";
import path from "node:path";

import { describe, expect, it } from "vitest";

import { assertExitZero, type CommandRunner } from "../framework/clients/index.ts";
import {
GatewayClient,
HostCliClient,
ProviderClient,
SandboxClient,
StateClient,
trustedProviderEndpoint,
} from "../framework/clients/index.ts";
import type { ShellProbeResult, ShellProbeRunOptions, TrustedShellCommand } from "../framework/shell-probe.ts";

interface RunnerCall {
command: string;
args: string[];
options?: ShellProbeRunOptions;
}

class FakeRunner implements CommandRunner {
readonly calls: RunnerCall[] = [];
stdout = "";
stderr = "";
exitCode: number | null = 0;
signal: NodeJS.Signals | null = null;

async run(command: TrustedShellCommand, options?: ShellProbeRunOptions): Promise<ShellProbeResult> {
this.calls.push({ command: command.command, args: [...command.args], options });
return {
command: [command.command, ...command.args],
exitCode: this.exitCode,
signal: this.signal,
timedOut: false,
stdout: this.stdout,
stderr: this.stderr,
artifacts: {
stdout: "/tmp/stdout.txt",
stderr: "/tmp/stderr.txt",
result: "/tmp/result.json",
},
};
}
}

describe("E2E fixture clients", () => {
it("host client runs the configured NemoClaw CLI", async () => {
const runner = new FakeRunner();
runner.stdout = "nemoclaw 0.1.0\n";
const host = new HostCliClient(runner, { cliPath: "./bin/nemoclaw.js" });

await host.expectNemoclawAvailable();

expect(runner.calls).toEqual([
{
command: "./bin/nemoclaw.js",
args: ["--version"],
options: { artifactName: "nemoclaw-version" },
},
]);
});

it("host client propagates cwd, env, and timeout options", async () => {
const runner = new FakeRunner();
const host = new HostCliClient(runner, { cliPath: "nemoclaw", cwd: "/tmp/project" });

await host.nemoclaw(["status"], {
env: { NEMOCLAW_TEST_VALUE: "1" },
timeoutMs: 123,
});

expect(runner.calls[0]).toEqual({
command: "nemoclaw",
args: ["status"],
options: {
artifactName: "nemoclaw-status",
cwd: "/tmp/project",
env: { NEMOCLAW_TEST_VALUE: "1" },
timeoutMs: 123,
},
});
});

it("gateway client delegates through NemoClaw gateway status", async () => {
const runner = new FakeRunner();
const host = new HostCliClient(runner, { cliPath: "nemoclaw" });
const gateway = new GatewayClient(host);

await gateway.expectHealthy();

expect(runner.calls[0]).toEqual({
command: "nemoclaw",
args: ["gateway", "status"],
options: { artifactName: "gateway-status" },
});
});

it("sandbox client builds OpenShell sandbox commands", async () => {
const runner = new FakeRunner();
const sandbox = new SandboxClient(runner, { openshellPath: "openshell" });

await sandbox.exec("assistant", ["echo", "ok"]);

expect(runner.calls[0]).toEqual({
command: "openshell",
args: ["sandbox", "exec", "assistant", "--", "echo", "ok"],
options: {
artifactName: "sandbox-exec-assistant",
},
});
});

it("sandbox client rejects flag-shaped sandbox names before command construction", async () => {
const runner = new FakeRunner();
const sandbox = new SandboxClient(runner, { openshellPath: "openshell" });

await expect(() => sandbox.status("--bad")).toThrow(/sandbox name is invalid/);
expect(runner.calls).toEqual([]);
});

it("sandbox client preserves shell-looking payloads as argv after --", async () => {
const runner = new FakeRunner();
const sandbox = new SandboxClient(runner, { openshellPath: "openshell" });

await sandbox.exec("assistant", ["sh", "-c", "echo '$TOKEN' && rm -rf /tmp/not-real"]);

expect(runner.calls[0]?.args).toEqual([
"sandbox",
"exec",
"assistant",
"--",
"sh",
"-c",
"echo '$TOKEN' && rm -rf /tmp/not-real",
]);
});

it("provider client parses JSON from curl output", async () => {
const runner = new FakeRunner();
runner.stdout = JSON.stringify({ ok: true });
const provider = new ProviderClient(runner);

await expect(provider.getJson(trustedProviderEndpoint("http://127.0.0.1:8080/health"))).resolves.toEqual({ ok: true });
expect(runner.calls[0]).toEqual({
command: "curl",
args: ["-fsS", "http://127.0.0.1:8080/health"],
options: {
artifactName: "curl-http-127.0.0.1-8080-health",
redactionValues: [],
},
});
});

it("provider client does not follow redirects after endpoint validation", async () => {
const runner = new FakeRunner();
runner.stdout = JSON.stringify({ ok: true });
const provider = new ProviderClient(runner);
const endpoint = trustedProviderEndpoint("https://api.example.test/v1/models", {
allowedHosts: ["api.example.test"],
});

await provider.getJson(endpoint);

expect(runner.calls[0]?.args).toEqual(["-fsS", "https://api.example.test/v1/models"]);
expect(runner.calls[0]?.args).not.toContain("-L");
});

it("provider endpoint rejects unsafe schemes, hosts, and userinfo", () => {
expect(() => trustedProviderEndpoint("file:///etc/passwd")).toThrow(/protocol/);
expect(() => trustedProviderEndpoint("http://example.com/health")).toThrow(/loopback/);
expect(() => trustedProviderEndpoint("https://api.example.test/models")).toThrow(/allowedHosts/);
expect(() => trustedProviderEndpoint("http://169.254.169.254/latest/meta-data")).toThrow(/blocked/);
expect(() => trustedProviderEndpoint("https://token@example.com/models")).toThrow(/credentials/);
expect(() =>
trustedProviderEndpoint("https://api.example.test/models", { allowedHosts: ["api.other.test"] }),
).toThrow(/not allowed/);
expect(() => trustedProviderEndpoint("https://10.0.0.1/models", { allowedHosts: ["10.0.0.1"] })).toThrow(
/private or link-local/,
);
expect(() =>
trustedProviderEndpoint("https://[fd00::1]/models", { allowedHosts: ["fd00::1"] }),
).toThrow(/private or link-local/);
});

it("provider endpoint allows loopback HTTP, including IPv6 loopback", () => {
expect(trustedProviderEndpoint("http://127.0.0.1:8080/health").url).toBe("http://127.0.0.1:8080/health");
expect(trustedProviderEndpoint("http://[::1]:8080/health").url).toBe("http://[::1]:8080/health");
});

it("provider client sanitizes labels and redacts credential-bearing query values", async () => {
const runner = new FakeRunner();
runner.stdout = JSON.stringify({ ok: true });
const provider = new ProviderClient(runner);
const endpoint = trustedProviderEndpoint(
"https://api.example.test/v1/models?api_key=query-token-value",
{ allowedHosts: ["api.example.test"] },
);

await expect(provider.getJson(endpoint)).resolves.toEqual({ ok: true });

expect(runner.calls[0]?.options?.artifactName).toBe("curl-https-api.example.test-v1-models");
expect(runner.calls[0]?.options?.redactionValues).toEqual(
expect.arrayContaining(["api_key=query-token-value", "query-token-value"]),
);
});

it("provider client reports invalid JSON without echoing response body", async () => {
const runner = new FakeRunner();
runner.stdout = "not-json with query-token-value";
const provider = new ProviderClient(runner);
const endpoint = trustedProviderEndpoint(
"https://api.example.test/v1/models?api_key=query-token-value",
{ allowedHosts: ["api.example.test"] },
);

await expect(provider.getJson(endpoint)).rejects.toThrow(/provider response was not JSON/);
await expect(provider.getJson(endpoint)).rejects.not.toThrow(/query-token-value|not-json/);
});

it("provider client failure labels omit query strings", async () => {
const runner = new FakeRunner();
runner.exitCode = 22;
const provider = new ProviderClient(runner);
const endpoint = trustedProviderEndpoint("https://api.example.test/v1/models?api_key=query-token-value", {
allowedHosts: ["api.example.test"],
});

await expect(provider.getJson(endpoint)).rejects.toThrow("curl https://api.example.test/v1/models failed: exit=22");
await expect(provider.getJson(endpoint)).rejects.not.toThrow(/query-token-value|api_key/);
});

it("assertExitZero reports non-zero and signaled commands", () => {
const result: ShellProbeResult = {
command: ["cmd"],
exitCode: 7,
signal: null,
timedOut: false,
stdout: "",
stderr: "",
artifacts: { stdout: "", stderr: "", result: "" },
};

expect(() => assertExitZero(result, "cmd")).toThrow("cmd failed: exit=7");
expect(() => assertExitZero({ ...result, exitCode: null, signal: "SIGTERM" }, "cmd")).toThrow(
"cmd failed: signal=SIGTERM",
);
});

it("state client reads text and JSON files", async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-e2e-state-"));
try {
const file = path.join(tmp, "state.json");
fs.writeFileSync(file, JSON.stringify({ sandbox: "assistant" }), "utf8");
const state = new StateClient();

await expect(state.exists(file)).resolves.toBe(true);
await expect(state.exists(path.join(tmp, "missing.json"))).resolves.toBe(false);
await expect(state.readJson(file)).resolves.toEqual({ sandbox: "assistant" });
await expect(state.exists(`bad${"\0"}path`)).rejects.toThrow();
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
});
26 changes: 26 additions & 0 deletions test/e2e-scenario/framework/clients/command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import type { ShellProbeResult, ShellProbeRunOptions, TrustedShellCommand } from "../shell-probe.ts";

export interface CommandRunner {
run(command: TrustedShellCommand, options?: ShellProbeRunOptions): Promise<ShellProbeResult>;
}

export function assertExitZero(result: ShellProbeResult, label: string): void {
if (result.exitCode === 0) return;
const fallback = result.signal
? `signal=${result.signal}`
: `exit=${result.exitCode ?? "unknown"}`;
const detail = result.stderr.trim() || result.stdout.trim() || fallback;
throw new Error(`${label} failed: ${detail}`);
}

export function artifactLabel(raw: string): string {
const label = raw
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "");
return label || "request";
}
24 changes: 24 additions & 0 deletions test/e2e-scenario/framework/clients/gateway.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import type { ShellProbeResult } from "../shell-probe.ts";
import { assertExitZero } from "./command.ts";
import type { HostCliClient } from "./host.ts";

export class GatewayClient {
private readonly host: HostCliClient;

constructor(host: HostCliClient) {
this.host = host;
}

status(): Promise<ShellProbeResult> {
return this.host.nemoclaw(["gateway", "status"], { artifactName: "gateway-status" });
}

async expectHealthy(): Promise<ShellProbeResult> {
const result = await this.status();
assertExitZero(result, "nemoclaw gateway status");
return result;
}
}
51 changes: 51 additions & 0 deletions test/e2e-scenario/framework/clients/host.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import type { ShellProbeResult, ShellProbeRunOptions } from "../shell-probe.ts";
import { trustedShellCommand } from "../shell-probe.ts";
import { artifactLabel, assertExitZero, type CommandRunner } from "./command.ts";

export interface HostClientOptions {
cliPath?: string;
cwd?: string;
}

export class HostCliClient {
private readonly runner: CommandRunner;
private readonly cliPath: string;
private readonly cwd?: string;

constructor(runner: CommandRunner, options: HostClientOptions = {}) {
this.runner = runner;
this.cliPath = options.cliPath ?? process.env.NEMOCLAW_CLI_BIN ?? "nemoclaw";
this.cwd = options.cwd;
}

command(command: string, args: string[] = [], options: ShellProbeRunOptions = {}): Promise<ShellProbeResult> {
const merged: ShellProbeRunOptions = { ...options };
if (this.cwd && !merged.cwd) {
merged.cwd = this.cwd;
}
return this.runner.run(
trustedShellCommand({
command,
args,
reason: `run host command ${command}`,
}),
merged,
);
}

nemoclaw(args: string[] = [], options: ShellProbeRunOptions = {}): Promise<ShellProbeResult> {
return this.command(this.cliPath, args, {
artifactName: `nemoclaw-${artifactLabel(args.join("-") || "default")}`,
...options,
});
}

async expectNemoclawAvailable(): Promise<ShellProbeResult> {
const result = await this.nemoclaw(["--version"], { artifactName: "nemoclaw-version" });
assertExitZero(result, "nemoclaw --version");
return result;
}
}
9 changes: 9 additions & 0 deletions test/e2e-scenario/framework/clients/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

export { assertExitZero, type CommandRunner } from "./command.ts";
export { GatewayClient } from "./gateway.ts";
export { HostCliClient } from "./host.ts";
export { ProviderClient, trustedProviderEndpoint, type TrustedProviderEndpoint } from "./provider.ts";
export { SandboxClient } from "./sandbox.ts";
export { StateClient } from "./state.ts";
Loading
Loading