Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
16 changes: 16 additions & 0 deletions src/commands/sandbox/logs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,20 @@ describe("SandboxLogsCommand", () => {
since: null,
});
});

it("rejects invalid tail values before running the logs action", async () => {
await expect(SandboxLogsCommand.run(["alpha", "--tail", "0"], rootDir)).rejects.toThrow(
/tail/i,
);

expect(showSandboxLogs).not.toHaveBeenCalled();
});

it("rejects malformed since values before running the logs action", async () => {
await expect(SandboxLogsCommand.run(["alpha", "--since", "someday"], rootDir)).rejects.toThrow(
/since requires a positive duration/,
);

expect(showSandboxLogs).not.toHaveBeenCalled();
});
});
136 changes: 136 additions & 0 deletions src/lib/actions/sandbox/logs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { describe, expect, it, vi } from "vitest";
import type { LogProbeResult } from "../../domain/sandbox/logs";
import { showSandboxLogsWithDeps } from "./logs";

vi.mock("../../runner", () => ({ ROOT: process.cwd() }));

class ExitError extends Error {
constructor(public readonly code: number) {
super(`exit ${code}`);
}
}

type CapturedLogsRun = {
calls: { args: string[]; options: Record<string, unknown> }[];
errors: string[];
exitCode: number | null;
stdout: string;
};

function captureLogsRun(
options: Parameters<typeof showSandboxLogsWithDeps>[1],
results: Record<string, LogProbeResult>,
overrides: Partial<Parameters<typeof showSandboxLogsWithDeps>[2]> = {},
): CapturedLogsRun {
const calls: CapturedLogsRun["calls"] = [];
const stdout: string[] = [];
const errors: string[] = [];
let exitCode: number | null = null;
const errorSpy = vi.spyOn(console, "error").mockImplementation((...args) => {
errors.push(args.map(String).join(" "));
});

const runOpenshell = vi.fn((args: string[], callOptions = {}) => {
calls.push({ args, options: callOptions as Record<string, unknown> });
return results[args[0]] ?? { status: 0 };
});

try {
showSandboxLogsWithDeps("alpha", options, {
exit: (code) => {
exitCode = code;
throw new ExitError(code);
},
isDockerRuntimeDown: () => false,
runOpenshell,
writeStdout: (chunk) => stdout.push(chunk),
...overrides,
});
} catch (error) {
if (!(error instanceof ExitError)) throw error;
} finally {
errorSpy.mockRestore();
}

return { calls, errors, exitCode, stdout: stdout.join("") };
}

describe("showSandboxLogsWithDeps", () => {
it("enables audit logs, reads both log sources, and writes merged output", () => {
const result = captureLogsRun(
{ follow: false, lines: "50", since: null },
{
settings: { status: 0 },
sandbox: { status: 0, stdout: "[1] gateway\n" },
logs: { status: 0, stdout: "[2] openshell\n" },
},
);

expect(result.exitCode).toBe(0);
expect(result.stdout).toBe("[1] gateway\n[2] openshell\n");
expect(result.calls.map((call) => call.args)).toEqual([
["settings", "set", "alpha", "--key", "ocsf_json_enabled", "--value", "true"],
["sandbox", "exec", "-n", "alpha", "--", "tail", "-n", "50", "/tmp/gateway.log"],
["logs", "alpha", "-n", "50", "--source", "all"],
]);
});

it("skips the OpenClaw gateway tail when --since targets OpenShell logs", () => {
const result = captureLogsRun(
{ follow: false, lines: "200", since: "5m" },
{
settings: { status: 0 },
logs: { status: 0, stdout: "[3] openshell only\n" },
},
);

expect(result.exitCode).toBe(0);
expect(result.stdout).toBe("[3] openshell only\n");
expect(result.calls.map((call) => call.args)).toEqual([
["settings", "set", "alpha", "--key", "ocsf_json_enabled", "--value", "true"],
["logs", "alpha", "-n", "200", "--source", "all", "--since", "5m"],
]);
});

it("warns about degraded audit and OpenClaw sources while continuing to OpenShell logs", () => {
const timeout = new Error("spawn openshell ETIMEDOUT");
const result = captureLogsRun(
{ follow: false, lines: "200", since: null },
{
settings: { status: 7, stderr: "settings unavailable\n" },
sandbox: { status: null, error: timeout },
logs: { status: 0, stdout: "[4] openshell fallback\n" },
},
);

expect(result.exitCode).toBe(0);
expect(result.stdout).toBe("[4] openshell fallback\n");
expect(result.errors.join("\n")).toContain(
"failed to enable OpenShell audit logs for sandbox 'alpha' (exit 7)",
);
expect(result.errors.join("\n")).toContain("settings unavailable");
expect(result.errors.join("\n")).toContain("Policy denial events may be missing");
expect(result.errors.join("\n")).toContain(
"OpenClaw log source unavailable (spawn openshell ETIMEDOUT)",
);
});

it("prints Docker outage guidance and exits before OpenShell log probes", () => {
const guidance = vi.fn();
const result = captureLogsRun(
{ follow: false, lines: "200", since: null },
{},
{
isDockerRuntimeDown: () => true,
printDockerRuntimeDownGuidance: guidance,
},
);

expect(result.exitCode).toBe(1);
expect(guidance).toHaveBeenCalledWith("alpha", { retryCommand: "logs" });
expect(result.calls).toEqual([]);
});
});
77 changes: 56 additions & 21 deletions src/lib/actions/sandbox/logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,22 +21,40 @@ import {
printDockerRuntimeDownGuidance,
} from "./gateway-failure-classifier";

function exitWithSpawnResult(result: LogProbeResult) {
type RunOpenshellOptions = Parameters<typeof runOpenshell>[1];
type RunOpenshellFn = (args: string[], options?: RunOpenshellOptions) => LogProbeResult;
type SpawnFn = typeof spawn;
type ExitFn = (code: number) => never;

export type SandboxLogsRuntimeDeps = {
env?: NodeJS.ProcessEnv;
exit?: ExitFn;
getOpenshellBinary?: typeof getOpenshellBinary;
isDockerRuntimeDown?: typeof isDockerRuntimeDown;
printDockerRuntimeDownGuidance?: typeof printDockerRuntimeDownGuidance;
runOpenshell?: RunOpenshellFn;
spawn?: SpawnFn;
writeStdout?: (chunk: string) => void;
};

function exitWithSpawnResult(result: LogProbeResult, deps: SandboxLogsRuntimeDeps) {
const exit = deps.exit ?? process.exit;
if (result.status !== null) {
process.exit(result.status);
exit(result.status);
}

process.exit(exitCodeFromSignal(result.signal ?? null));
exit(exitCodeFromSignal(result.signal ?? null));
}

function runOpenclawGatewayLogs(
sandboxName: string,
options: SandboxLogsOptions,
deps: SandboxLogsRuntimeDeps,
): LogProbeResult {
const args = buildSandboxOpenclawGatewayLogsArgs(sandboxName, options);
// Capture stdout so the caller can merge with the OpenShell source
// (closes #4100). stderr still inherits so warnings print directly.
const result = runOpenshell(args, {
const result = (deps.runOpenshell ?? runOpenshell)(args, {
stdio: ["ignore", "pipe", "inherit"],
ignoreError: true,
timeout: getLogsProbeTimeoutMs(),
Expand All @@ -50,14 +68,19 @@ function runOpenclawGatewayLogs(
return result;
}

function streamSandboxFollowLogs(sandboxName: string, options: SandboxLogsOptions): void {
function streamSandboxFollowLogs(
sandboxName: string,
options: SandboxLogsOptions,
deps: SandboxLogsRuntimeDeps,
): void {
const openclawArgs = options.since
? null
: buildSandboxOpenclawGatewayLogsArgs(sandboxName, options);
const openshellArgs = buildSandboxLogsArgs(sandboxName, options);
const exit = deps.exit ?? process.exit;
const spawnOptions = {
cwd: ROOT,
env: process.env,
env: deps.env ?? process.env,
stdio: "inherit" as const,
};
const sources: Array<{
Expand Down Expand Up @@ -88,7 +111,7 @@ function streamSandboxFollowLogs(sandboxName: string, options: SandboxLogsOption
clearTimeout(forcedExitTimer);
forcedExitTimer = null;
}
process.exit(requestedExitCode ?? finalStatus);
exit(requestedExitCode ?? finalStatus);
};
const markSourceDone = (
source: (typeof sources)[number],
Expand All @@ -112,7 +135,7 @@ function streamSandboxFollowLogs(sandboxName: string, options: SandboxLogsOption
exiting = true;
requestedExitCode = exitCode;
stopChildren(signal);
forcedExitTimer = setTimeout(() => process.exit(exitCode), 2000);
forcedExitTimer = setTimeout(() => exit(exitCode), 2000);
forcedExitTimer.unref?.();
maybeExit();
};
Expand All @@ -125,10 +148,12 @@ function streamSandboxFollowLogs(sandboxName: string, options: SandboxLogsOption
});

const addSource = (label: string, args: string[]) => {
const spawnProcess = deps.spawn ?? spawn;
const openshellBinary = (deps.getOpenshellBinary ?? getOpenshellBinary)();
const source = {
label,
args,
child: spawn(getOpenshellBinary(), args, spawnOptions),
child: spawnProcess(openshellBinary, args, spawnOptions),
done: false,
};
sources.push(source);
Expand All @@ -143,15 +168,15 @@ function streamSandboxFollowLogs(sandboxName: string, options: SandboxLogsOption
if (openclawArgs) {
addSource("OpenClaw log source", openclawArgs);
}
enableSandboxAuditLogs(sandboxName);
enableSandboxAuditLogs(sandboxName, deps);
addSource("OpenShell log source", openshellArgs);
setupComplete = true;
maybeExit();
}

function enableSandboxAuditLogs(sandboxName: string) {
function enableSandboxAuditLogs(sandboxName: string, deps: SandboxLogsRuntimeDeps) {
const args = buildEnableSandboxAuditLogsArgs(sandboxName);
const result = runOpenshell(args, {
const result = (deps.runOpenshell ?? runOpenshell)(args, {
stdio: ["ignore", "ignore", "pipe"],
ignoreError: true,
timeout: getLogsProbeTimeoutMs(),
Expand All @@ -178,35 +203,45 @@ function warnSandboxAuditLogsUnavailable(
}

export function showSandboxLogs(sandboxName: string, options: SandboxLogsOptions | boolean) {
showSandboxLogsWithDeps(sandboxName, options);
}

export function showSandboxLogsWithDeps(
sandboxName: string,
options: SandboxLogsOptions | boolean,
deps: SandboxLogsRuntimeDeps = {},
) {
// Normalize/validate options before any host I/O so malformed flags still
// surface their own error rather than a Docker-outage message.
const logsOptions = normalizeSandboxLogsOptions(options);

// Preflight the Docker daemon so a host runtime outage is named as such
// instead of surfacing as opaque "log source unavailable" failures from the
// underlying OpenShell commands (#4428).
if (isDockerRuntimeDown(sandboxName)) {
printDockerRuntimeDownGuidance(sandboxName, { retryCommand: "logs" });
process.exit(1);
if ((deps.isDockerRuntimeDown ?? isDockerRuntimeDown)(sandboxName)) {
(deps.printDockerRuntimeDownGuidance ?? printDockerRuntimeDownGuidance)(sandboxName, {
retryCommand: "logs",
});
(deps.exit ?? process.exit)(1);
}

if (logsOptions.follow) {
streamSandboxFollowLogs(sandboxName, logsOptions);
streamSandboxFollowLogs(sandboxName, logsOptions, deps);
return;
}

enableSandboxAuditLogs(sandboxName);
enableSandboxAuditLogs(sandboxName, deps);

// Capture stdout from both sources so --tail N can be applied once
// to the merged stream rather than independently per source
// (which previously returned up to 2*N lines). Closes #4100.
let gatewayResult: LogProbeResult | null = null;
if (!logsOptions.since) {
gatewayResult = runOpenclawGatewayLogs(sandboxName, logsOptions);
gatewayResult = runOpenclawGatewayLogs(sandboxName, logsOptions, deps);
}

const openshellArgs = buildSandboxLogsArgs(sandboxName, logsOptions);
const openshellResult = runOpenshell(openshellArgs, {
const openshellResult = (deps.runOpenshell ?? runOpenshell)(openshellArgs, {
stdio: ["ignore", "pipe", "inherit"],
ignoreError: true,
});
Expand All @@ -218,13 +253,13 @@ export function showSandboxLogs(sandboxName: string, options: SandboxLogsOptions
if (openshellResult.stdout) sources.push(String(openshellResult.stdout));
const merged = mergeTailLogLines(sources, maxLines);
if (merged) {
process.stdout.write(merged);
(deps.writeStdout ?? process.stdout.write.bind(process.stdout))(merged);
}

if (openshellResult.status !== 0) {
console.error(
` Command failed (exit ${openshellResult.status}): openshell ${openshellArgs.join(" ")}`,
);
}
exitWithSpawnResult(openshellResult);
exitWithSpawnResult(openshellResult, deps);
}
Loading
Loading