Skip to content

Commit d5d1d2f

Browse files
committed
Merge remote-tracking branch 'origin/e2e-phase6-telegram-injection' into e2e-phase6-telegram-injection
2 parents d45654f + 57f25d3 commit d5d1d2f

27 files changed

Lines changed: 2198 additions & 145 deletions

docs/reference/commands-nemohermes.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -432,7 +432,7 @@ The exit code is the remote command's exit code.
432432

433433
| Flag | Description |
434434
|------|-------------|
435-
| `--workdir <dir>` | Working directory inside the sandbox |
435+
| `--workdir <dir>` | Working directory inside the sandbox. The directory is checked before the command runs; if it does not exist, NemoClaw reports `error: --workdir: <dir> does not exist inside the sandbox` and exits with status `1` without invoking the inner command. |
436436
| `--tty` / `--no-tty` | Allocate a pseudo-terminal; defaults to auto-detection (on when stdin and stdout are terminals) |
437437
| `--timeout <seconds>` | Timeout in seconds (`0` means no timeout) |
438438

@@ -622,7 +622,7 @@ nemohermes my-assistant exec [--workdir <dir>] [--tty|--no-tty] [--timeout <s>]
622622

623623
| Flag | Description |
624624
|------|-------------|
625-
| `--workdir <dir>` | Set the working directory inside the sandbox |
625+
| `--workdir <dir>` | Set the working directory inside the sandbox. The directory is checked before the command runs; if it does not exist, NemoClaw reports `error: --workdir: <dir> does not exist inside the sandbox` and exits with status `1` without invoking the inner command. |
626626
| `--tty`, `--no-tty` | Allocate or disable a pseudo-terminal; defaults to auto-detection |
627627
| `--timeout <s>` | Timeout in seconds. Use `0` for no timeout |
628628

docs/reference/commands.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -537,7 +537,7 @@ The exit code is the remote command's exit code.
537537

538538
| Flag | Description |
539539
|------|-------------|
540-
| `--workdir <dir>` | Working directory inside the sandbox |
540+
| `--workdir <dir>` | Working directory inside the sandbox. The directory is checked before the command runs; if it does not exist, NemoClaw reports `error: --workdir: <dir> does not exist inside the sandbox` and exits with status `1` without invoking the inner command. |
541541
| `--tty` / `--no-tty` | Allocate a pseudo-terminal; defaults to auto-detection (on when stdin and stdout are terminals) |
542542
| `--timeout <seconds>` | Timeout in seconds (`0` means no timeout) |
543543

@@ -807,7 +807,7 @@ $$nemoclaw my-assistant exec [--workdir <dir>] [--tty|--no-tty] [--timeout <s>]
807807

808808
| Flag | Description |
809809
|------|-------------|
810-
| `--workdir <dir>` | Set the working directory inside the sandbox |
810+
| `--workdir <dir>` | Set the working directory inside the sandbox. The directory is checked before the command runs; if it does not exist, NemoClaw reports `error: --workdir: <dir> does not exist inside the sandbox` and exits with status `1` without invoking the inner command. |
811811
| `--tty`, `--no-tty` | Allocate or disable a pseudo-terminal; defaults to auto-detection |
812812
| `--timeout <s>` | Timeout in seconds. Use `0` for no timeout |
813813

src/lib/actions/sandbox/exec.test.ts

Lines changed: 115 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
22
// SPDX-License-Identifier: Apache-2.0
33

4-
import { describe, expect, it } from "vitest";
4+
import { afterEach, describe, expect, it, vi } from "vitest";
55

6-
import { buildOpenshellExecArgs, computeExitCode } from "./exec";
6+
import {
7+
buildOpenshellExecArgs,
8+
buildWorkdirProbeArgs,
9+
computeExitCode,
10+
evaluateWorkdirProbe,
11+
validateWorkdirOrFail,
12+
workdirMissingMessage,
13+
} from "./exec";
714

815
describe("buildOpenshellExecArgs", () => {
916
it("targets the sandbox by name and forwards the user command after --", () => {
@@ -115,3 +122,109 @@ describe("computeExitCode", () => {
115122
});
116123
});
117124
});
125+
126+
describe("buildWorkdirProbeArgs", () => {
127+
it("targets the sandbox by name and probes the directory with test -d", () => {
128+
expect(buildWorkdirProbeArgs("alpha", "/sandbox/workspace")).toEqual([
129+
"sandbox",
130+
"exec",
131+
"--name",
132+
"alpha",
133+
"--",
134+
"test",
135+
"-d",
136+
"/sandbox/workspace",
137+
]);
138+
});
139+
140+
it("does not split a path argument that contains whitespace", () => {
141+
const argv = buildWorkdirProbeArgs("alpha", "/sandbox/with spaces/dir");
142+
expect(argv[argv.length - 1]).toBe("/sandbox/with spaces/dir");
143+
});
144+
});
145+
146+
describe("workdirMissingMessage", () => {
147+
it("renders a user-facing CLI error with the offending path", () => {
148+
expect(workdirMissingMessage("/sandbox/workspace")).toBe(
149+
"error: --workdir: /sandbox/workspace does not exist inside the sandbox",
150+
);
151+
});
152+
});
153+
154+
describe("evaluateWorkdirProbe", () => {
155+
it("returns 'ok' when the probe exits 0", () => {
156+
expect(evaluateWorkdirProbe({ status: 0 })).toBe("ok");
157+
});
158+
159+
it("returns 'missing' only for the canonical test -d failure (exit 1)", () => {
160+
expect(evaluateWorkdirProbe({ status: 1 })).toBe("missing");
161+
});
162+
163+
it("returns 'unclear' for any other exit code so the main exec surfaces it", () => {
164+
expect(evaluateWorkdirProbe({ status: 2 })).toBe("unclear");
165+
expect(evaluateWorkdirProbe({ status: 127 })).toBe("unclear");
166+
expect(evaluateWorkdirProbe({ status: null })).toBe("unclear");
167+
});
168+
169+
it("returns 'unclear' when spawn reports a transport error", () => {
170+
expect(evaluateWorkdirProbe({ status: null, error: new Error("ENOENT") })).toBe("unclear");
171+
});
172+
});
173+
174+
describe("validateWorkdirOrFail", () => {
175+
afterEach(() => {
176+
vi.restoreAllMocks();
177+
});
178+
179+
it("passes through when the directory exists", () => {
180+
const run = vi.fn(() => ({ status: 0 }));
181+
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((_code?: number) => {
182+
throw new Error("process.exit should not be called for ok outcome");
183+
}) as never);
184+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
185+
186+
validateWorkdirOrFail("openshell", "alpha", "/sandbox/workspace", run);
187+
188+
expect(run).toHaveBeenCalledWith("openshell", [
189+
"sandbox",
190+
"exec",
191+
"--name",
192+
"alpha",
193+
"--",
194+
"test",
195+
"-d",
196+
"/sandbox/workspace",
197+
]);
198+
expect(exitSpy).not.toHaveBeenCalled();
199+
expect(errSpy).not.toHaveBeenCalled();
200+
});
201+
202+
it("prints a friendly error and exits 1 when the directory is missing", () => {
203+
const run = vi.fn(() => ({ status: 1 }));
204+
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((_code?: number) => {
205+
throw new Error("exit");
206+
}) as never);
207+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
208+
209+
expect(() => validateWorkdirOrFail("openshell", "alpha", "/sandbox/workspace", run)).toThrow(
210+
"exit",
211+
);
212+
expect(errSpy).toHaveBeenCalledWith(
213+
"error: --workdir: /sandbox/workspace does not exist inside the sandbox",
214+
);
215+
expect(exitSpy).toHaveBeenCalledWith(1);
216+
});
217+
218+
it("does not abort when the probe outcome is unclear (lets main exec surface it)", () => {
219+
const run = vi.fn(() => ({ status: 127 }));
220+
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((_code?: number) => {
221+
throw new Error("process.exit should not be called for unclear outcome");
222+
}) as never);
223+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
224+
225+
validateWorkdirOrFail("openshell", "alpha", "/sandbox/workspace", run);
226+
227+
expect(exitSpy).not.toHaveBeenCalled();
228+
expect(errSpy).not.toHaveBeenCalled();
229+
});
230+
});

src/lib/actions/sandbox/exec.ts

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,15 @@ type SpawnLikeResult = {
1616
error?: Error;
1717
};
1818

19+
export type WorkdirProbeResult = {
20+
status: number | null;
21+
error?: Error;
22+
};
23+
24+
export type WorkdirProbeOutcome = "ok" | "missing" | "unclear";
25+
26+
export type WorkdirProbeRunner = (binary: string, args: readonly string[]) => WorkdirProbeResult;
27+
1928
export function buildOpenshellExecArgs(
2029
sandboxName: string,
2130
command: readonly string[],
@@ -32,6 +41,21 @@ export function buildOpenshellExecArgs(
3241
return argv;
3342
}
3443

44+
export function buildWorkdirProbeArgs(sandboxName: string, workdir: string): string[] {
45+
return ["sandbox", "exec", "--name", sandboxName, "--", "test", "-d", workdir];
46+
}
47+
48+
export function workdirMissingMessage(workdir: string): string {
49+
return `error: --workdir: ${workdir} does not exist inside the sandbox`;
50+
}
51+
52+
export function evaluateWorkdirProbe(probe: WorkdirProbeResult): WorkdirProbeOutcome {
53+
if (probe.error) return "unclear";
54+
if (probe.status === 0) return "ok";
55+
if (probe.status === 1) return "missing";
56+
return "unclear";
57+
}
58+
3559
export function computeExitCode(result: SpawnLikeResult): {
3660
code: number;
3761
errorMessage?: string;
@@ -56,6 +80,24 @@ function exitWithSpawnResult(result: SpawnLikeResult): never {
5680
process.exit(code);
5781
}
5882

83+
const defaultWorkdirProbeRunner: WorkdirProbeRunner = (binary, args) => {
84+
const probe = spawnSync(binary, args, { stdio: ["ignore", "ignore", "ignore"] });
85+
return { status: probe.status, error: probe.error };
86+
};
87+
88+
export function validateWorkdirOrFail(
89+
binary: string,
90+
sandboxName: string,
91+
workdir: string,
92+
run: WorkdirProbeRunner = defaultWorkdirProbeRunner,
93+
): void {
94+
const outcome = evaluateWorkdirProbe(run(binary, buildWorkdirProbeArgs(sandboxName, workdir)));
95+
if (outcome === "missing") {
96+
console.error(workdirMissingMessage(workdir));
97+
process.exit(1);
98+
}
99+
}
100+
59101
export async function execSandbox(
60102
sandboxName: string,
61103
command: readonly string[],
@@ -69,10 +111,12 @@ export async function execSandbox(
69111
);
70112
process.exit(2);
71113
}
72-
const result = spawnSync(
73-
getOpenshellBinary(),
74-
buildOpenshellExecArgs(sandboxName, command, options),
75-
{ stdio: "inherit" },
76-
);
114+
const binary = getOpenshellBinary();
115+
if (options.workdir) {
116+
validateWorkdirOrFail(binary, sandboxName, options.workdir);
117+
}
118+
const result = spawnSync(binary, buildOpenshellExecArgs(sandboxName, command, options), {
119+
stdio: "inherit",
120+
});
77121
exitWithSpawnResult(result);
78122
}

src/lib/agent/defs.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -530,13 +530,24 @@ export function loadAgent(name: string): AgentDefinition {
530530
* OpenClaw is listed first as the default.
531531
*/
532532
export function getAgentChoices(): AgentChoice[] {
533-
const agents = listAgents().map((name) => {
534-
const agent = loadAgent(name);
535-
return {
536-
name: agent.name,
537-
displayName: agent.displayName,
538-
description: agent.description ?? "",
539-
};
533+
// Build the menu defensively: a single malformed non-default manifest must
534+
// not abort interactive onboarding (e.g. an OpenClaw user accepting the
535+
// default). Skip agents that fail to load and surface a warning instead.
536+
const agents = listAgents().flatMap((name) => {
537+
try {
538+
const agent = loadAgent(name);
539+
return [
540+
{
541+
name: agent.name,
542+
displayName: agent.displayName,
543+
description: agent.description ?? "",
544+
},
545+
];
546+
} catch (error) {
547+
const reason = error instanceof Error ? error.message : String(error);
548+
console.error(` Warning: skipping agent '${name}' — failed to load manifest: ${reason}`);
549+
return [];
550+
}
540551
});
541552

542553
agents.sort((left, right) => {

src/lib/inventory/index.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,64 @@ describe("inventory commands", () => {
134134
expect(getLiveInference).not.toHaveBeenCalled();
135135
});
136136

137+
it("normalizes invalid configured inference fields out of inventory rows", async () => {
138+
const inventory = await getSandboxInventory({
139+
recoverRegistryEntries: async () => ({
140+
sandboxes: [
141+
{ name: "blank-provider", provider: "", model: "nvidia/test" },
142+
{ name: "blank-model", provider: "nvidia-prod", model: " " },
143+
{ name: "configured", provider: "nvidia-prod", model: "nvidia/test" },
144+
],
145+
defaultSandbox: "blank-provider",
146+
}),
147+
getLiveInference: () => null,
148+
loadLastSession: () => null,
149+
});
150+
151+
expect(inventory.sandboxes).toMatchObject([
152+
{ name: "blank-provider", provider: null, model: "nvidia/test" },
153+
{ name: "blank-model", provider: "nvidia-prod", model: null },
154+
{ name: "configured", provider: "nvidia-prod", model: "nvidia/test" },
155+
]);
156+
});
157+
158+
it("normalizes invalid configured inference fields out of status rows", () => {
159+
const report = getStatusReport({
160+
listSandboxes: () => ({
161+
sandboxes: [
162+
{ name: "blank-provider", provider: "", model: "nvidia/test" },
163+
{ name: "blank-model", provider: "nvidia-prod", model: " " },
164+
{ name: "configured", provider: "nvidia-prod", model: "nvidia/test" },
165+
],
166+
defaultSandbox: "blank-provider",
167+
}),
168+
getLiveInference: () => null,
169+
showServiceStatus: vi.fn(),
170+
});
171+
172+
expect(report.sandboxes).toMatchObject([
173+
{ name: "blank-provider", provider: null, model: "nvidia/test" },
174+
{ name: "blank-model", provider: "nvidia-prod", model: null },
175+
{ name: "configured", provider: "nvidia-prod", model: "nvidia/test" },
176+
]);
177+
});
178+
179+
it("omits invalid configured inference fields from status text", () => {
180+
const lines: string[] = [];
181+
showStatusCommand({
182+
listSandboxes: () => ({
183+
sandboxes: [{ name: "alpha", provider: "", model: " " }],
184+
defaultSandbox: "alpha",
185+
}),
186+
getLiveInference: () => null,
187+
showServiceStatus: vi.fn(),
188+
log: (message = "") => lines.push(message),
189+
});
190+
191+
expect(lines).toContain(" alpha *");
192+
expect(lines.some((line) => line.includes("Inference:"))).toBe(false);
193+
});
194+
137195
it("prints the empty-state onboarding hint when no sandboxes exist", async () => {
138196
const lines: string[] = [];
139197
await listSandboxesCommand({

0 commit comments

Comments
 (0)