diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 773ff48565c..875e32191cf 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -3019,6 +3019,26 @@ NemoClaw reads the following environment variables to configure service ports, o Set them before running `$$nemoclaw onboard` or any command that starts services. All ports must be non-privileged integers between 1024 and 65535. +### CLI Logging + +The centralized CLI logger writes its output to `stderr` and uses `info` verbosity by default. +These controls affect leveled logger output; they do not suppress command results or command-specific output that has not migrated to the centralized logger. + +| Variable | Accepted values | Effect | +|----------|-----------------|--------| +| `NEMOCLAW_LOG_LEVEL` | `error`, `warn`, `info`, or `debug` (case-insensitive; surrounding whitespace is ignored) | Sets the logging threshold. A valid value takes precedence over `NEMOCLAW_DEBUG`. An invalid, blank, or unset value falls through to `NEMOCLAW_DEBUG`. | +| `NEMOCLAW_DEBUG` | `1`, `true`, `y`, or `yes` (case-insensitive) | Enables `debug` logging when `NEMOCLAW_LOG_LEVEL` does not contain a valid value. | + +The environment precedence is `NEMOCLAW_LOG_LEVEL`, then `NEMOCLAW_DEBUG`, followed by the default `info` level. +The `error` level prints errors only, `warn` also prints warnings, `info` also prints informational messages, and `debug` prints all levels with timestamps. +Use these NemoClaw-specific variables instead of the generic `DEBUG` variable. `DEBUG` is not a NemoClaw logger control and can enable dependency diagnostics that include raw command arguments. + +Commands whose parser owns the base logging options also accept the hidden long-form `--debug` and `--quiet` flags, even though these options do not appear in command help. +The flags are mutually exclusive. +`--debug` overrides the environment-derived threshold and selects `debug`, while `--quiet` caps verbosity at `warn` without increasing an environment-derived `error` threshold. +There is no global `-q` logging shorthand. +Passthrough commands do not consume flags intended for the downstream command as host logging options, so use the environment variables when you need unambiguous host logging around a passthrough invocation. + | Variable | Default | Service | |----------|---------|---------| | `NEMOCLAW_GATEWAY_PORT` | 8080 | OpenShell gateway port | diff --git a/src/commands/sandbox/agent.test.ts b/src/commands/sandbox/agent.test.ts index b56f28c9bfe..c1438491ab5 100644 --- a/src/commands/sandbox/agent.test.ts +++ b/src/commands/sandbox/agent.test.ts @@ -8,6 +8,7 @@ vi.mock("../../lib/actions/sandbox/agent/passthrough", () => ({ runAgentPassthrough: runAgentPassthroughMock, })); +import { log } from "../../lib/cli/logger"; import SandboxAgentCommand from "./agent"; const rootDir = process.cwd(); @@ -21,7 +22,7 @@ describe("SandboxAgentCommand oclif parse path", () => { }); afterEach(() => { - logSpy.mockRestore(); + vi.restoreAllMocks(); }); it("forwards the OpenClaw argv verbatim to runAgentPassthrough", async () => { @@ -31,6 +32,30 @@ describe("SandboxAgentCommand oclif parse path", () => { }); }); + it("forwards downstream logging flags without changing host logging", async () => { + const configure = vi.spyOn(log, "configure").mockImplementation(() => undefined); + + await SandboxAgentCommand.run(["alpha", "--debug", "-m", "hi"], rootDir); + + expect(runAgentPassthroughMock).toHaveBeenCalledWith("alpha", { + extraArgs: ["--debug", "-m", "hi"], + }); + expect(configure).toHaveBeenCalledWith({ debug: false, quiet: false }); + expect(configure).not.toHaveBeenCalledWith({ debug: true, quiet: false }); + }); + + it("preserves the option boundary without treating later flags as host flags", async () => { + const configure = vi.spyOn(log, "configure").mockImplementation(() => undefined); + + await SandboxAgentCommand.run(["alpha", "--", "--quiet"], rootDir); + + expect(runAgentPassthroughMock).toHaveBeenCalledWith("alpha", { + extraArgs: ["--", "--quiet"], + }); + expect(configure).toHaveBeenCalledWith({ debug: false, quiet: false }); + expect(configure).not.toHaveBeenCalledWith({ debug: false, quiet: true }); + }); + it("passes --help after the sandbox name to agent-aware dispatch (#5790)", async () => { await SandboxAgentCommand.run(["alpha", "--help"], rootDir); expect(runAgentPassthroughMock).toHaveBeenCalledWith("alpha", { diff --git a/src/commands/sandbox/exec.test.ts b/src/commands/sandbox/exec.test.ts index ccc8e6aeabf..73b1a536d6b 100644 --- a/src/commands/sandbox/exec.test.ts +++ b/src/commands/sandbox/exec.test.ts @@ -1,13 +1,14 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const execSandboxMock = vi.hoisted(() => vi.fn(async () => {})); vi.mock("../../lib/actions/sandbox/exec", () => ({ execSandbox: execSandboxMock, })); +import { log } from "../../lib/cli/logger"; import SandboxExecCommand from "./exec"; const rootDir = process.cwd(); @@ -17,6 +18,10 @@ describe("SandboxExecCommand oclif parse path", () => { execSandboxMock.mockReset(); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it("forwards everything after -- as the inner command argv", async () => { await SandboxExecCommand.run( ["alpha", "--", "openclaw", "agent", "--agent", "main", "-m", "hi"], @@ -29,6 +34,21 @@ describe("SandboxExecCommand oclif parse path", () => { ); }); + it("does not assign host meaning to logging flags after --", async () => { + const configure = vi.spyOn(log, "configure").mockImplementation(() => undefined); + + await SandboxExecCommand.run(["alpha", "--", "agent-cli", "--debug", "--quiet"], rootDir); + + expect(execSandboxMock).toHaveBeenCalledWith("alpha", ["agent-cli", "--debug", "--quiet"], { + workdir: undefined, + tty: null, + timeoutSeconds: undefined, + }); + expect(configure).toHaveBeenCalledWith({ debug: false, quiet: false }); + expect(configure).not.toHaveBeenCalledWith({ debug: true, quiet: false }); + expect(configure).not.toHaveBeenCalledWith({ debug: false, quiet: true }); + }); + it("preserves repeated flag/value pairs after -- in their original order", async () => { await SandboxExecCommand.run( [ @@ -75,7 +95,7 @@ describe("SandboxExecCommand oclif parse path", () => { "-c", "pass", ], - { workdir: undefined, tty: null, timeoutSeconds: undefined }, + { workdir: undefined, tty: null, timeoutSeconds: undefined, stdin: undefined }, ); }); diff --git a/src/commands/simple-global-oclif-adapters.test.ts b/src/commands/simple-global-oclif-adapters.test.ts index 7d79837b566..450f4b73de0 100644 --- a/src/commands/simple-global-oclif-adapters.test.ts +++ b/src/commands/simple-global-oclif-adapters.test.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => { class GatewayTokenCommandError extends Error { @@ -87,6 +87,7 @@ vi.mock("../lib/uninstall-command", () => ({ })); vi.mock("../lib/core/version", () => ({ getVersion: mocks.getVersion })); +import { log } from "../lib/cli/logger"; import DebugCliCommand from "./debug"; import DeployCliCommand from "./deploy"; import RootHelpCommand from "./root/help"; @@ -110,6 +111,10 @@ describe("simple global oclif adapters", () => { vi.clearAllMocks(); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it("maps debug and deploy parser output to actions", async () => { await DebugCliCommand.run( ["--quick", "--output", "/tmp/debug.tar.gz", "--sandbox", "alpha"], @@ -127,6 +132,19 @@ describe("simple global oclif adapters", () => { expect(mocks.runDeployAction).toHaveBeenCalledWith("gpu-alpha"); }); + it("keeps debug -q scoped to quick diagnostics instead of global quiet mode", async () => { + const configure = vi.spyOn(log, "configure").mockImplementation(() => undefined); + + await DebugCliCommand.run(["-q"], rootDir); + + expect(mocks.runDebugCommandWithOptions).toHaveBeenCalledWith( + { quick: true }, + expect.objectContaining({ runDebug: expect.any(Function) }), + ); + expect(configure).toHaveBeenCalledWith({ debug: false, quiet: false }); + expect(configure).not.toHaveBeenCalledWith({ debug: false, quiet: true }); + }); + it("builds debug defaults from the sandbox registry and OpenShell liveness", async () => { mocks.listSandboxes.mockReturnValue({ defaultSandbox: "alpha", @@ -309,4 +327,16 @@ describe("simple global oclif adapters", () => { }), ); }); + + it("forwards uninstall flags without assigning host logging semantics", async () => { + const configure = vi.spyOn(log, "configure").mockImplementation(() => undefined); + + await UninstallCliCommand.run(["--yes", "--debug"], rootDir); + + expect(mocks.runUninstallCommand).toHaveBeenCalledWith( + expect.objectContaining({ args: ["--yes", "--debug"] }), + ); + expect(configure).toHaveBeenCalledWith({ debug: false, quiet: false }); + expect(configure).not.toHaveBeenCalledWith({ debug: true, quiet: false }); + }); }); diff --git a/src/lib/cli/logger.test.ts b/src/lib/cli/logger.test.ts new file mode 100644 index 00000000000..e195b24f411 --- /dev/null +++ b/src/lib/cli/logger.test.ts @@ -0,0 +1,242 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Re-import logger fresh for each test to reset singleton state. +async function freshLogger() { + vi.resetModules(); + return import("./logger"); +} + +describe("Logger", () => { + let stderrSpy: ReturnType; + + beforeEach(() => { + vi.stubEnv("NEMOCLAW_LOG_LEVEL", undefined); + vi.stubEnv("NEMOCLAW_DEBUG", undefined); + vi.stubEnv("DEBUG", undefined); + stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + }); + + afterEach(() => { + stderrSpy.mockRestore(); + vi.unstubAllEnvs(); + }); + + function output(): string { + return stderrSpy.mock.calls.map((call: unknown[]) => String(call[0])).join(""); + } + + it("defaults to info level", async () => { + const { log } = await freshLogger(); + expect(log.level).toBe("info"); + }); + + it("reads a trimmed case-insensitive NEMOCLAW_LOG_LEVEL", async () => { + vi.stubEnv("NEMOCLAW_LOG_LEVEL", " DEBUG "); + const { log } = await freshLogger(); + expect(log.level).toBe("debug"); + }); + + it.each([ + "1", + "true", + "y", + "yes", + "TRUE", + ])("enables debug for the supported NEMOCLAW_DEBUG value %s", async (value) => { + vi.stubEnv("NEMOCLAW_DEBUG", value); + const { log } = await freshLogger(); + expect(log.level).toBe("debug"); + }); + + it("does not treat the framework DEBUG variable as a NemoClaw logging control", async () => { + vi.stubEnv("DEBUG", "*"); + const { log } = await freshLogger(); + expect(log.level).toBe("info"); + }); + + it("gives a valid NEMOCLAW_LOG_LEVEL precedence over NEMOCLAW_DEBUG", async () => { + vi.stubEnv("NEMOCLAW_LOG_LEVEL", "error"); + vi.stubEnv("NEMOCLAW_DEBUG", "true"); + const { log } = await freshLogger(); + expect(log.level).toBe("error"); + }); + + it("falls through an invalid NEMOCLAW_LOG_LEVEL to NEMOCLAW_DEBUG", async () => { + vi.stubEnv("NEMOCLAW_LOG_LEVEL", "verbose"); + vi.stubEnv("NEMOCLAW_DEBUG", "true"); + const { log } = await freshLogger(); + expect(log.level).toBe("debug"); + }); + + it("suppresses debug messages at info level", async () => { + const { log } = await freshLogger(); + log.setLevel("info"); + log.debug("should not appear"); + expect(stderrSpy).not.toHaveBeenCalled(); + }); + + it("shows debug messages after setDebug(true)", async () => { + const { log } = await freshLogger(); + log.setDebug(true); + log.debug("visible debug"); + expect(output()).toContain("visible debug"); + }); + + it("keeps diagnostic arguments after prose that mentions credentials", async () => { + const { log } = await freshLogger(); + log.setDebug(true); + log.debug("Failed to refresh token, retrying", { attempt: 3 }); + log.debugObject("Token refresh failed", { attempt: 4 }); + expect(output()).toContain('"attempt": 3'); + expect(output()).toContain('"attempt": 4'); + }); + + it("quiet mode suppresses info and still shows warnings", async () => { + const { log } = await freshLogger(); + log.setQuiet(true); + log.info("suppressed info"); + log.warn("visible warning"); + expect(output()).toBe("visible warning\n"); + }); + + it("can remove quiet and debug overrides without leaking state", async () => { + const { log } = await freshLogger(); + log.setQuiet(true); + expect(log.level).toBe("warn"); + log.setQuiet(false); + expect(log.level).toBe("info"); + log.setDebug(true); + expect(log.level).toBe("debug"); + log.setDebug(false); + expect(log.level).toBe("info"); + }); + + it("configure resets prior overrides to the current environment baseline", async () => { + vi.stubEnv("NEMOCLAW_LOG_LEVEL", "error"); + const { log } = await freshLogger(); + log.setDebug(true); + log.setQuiet(true); + log.configure(); + expect(log.level).toBe("error"); + expect(log.isQuiet()).toBe(false); + }); + + it("quiet overrides environment debug without retaining debug timestamps", async () => { + vi.stubEnv("NEMOCLAW_LOG_LEVEL", "debug"); + const { log } = await freshLogger(); + log.configure({ quiet: true }); + log.warn("visible warning"); + expect(log.level).toBe("warn"); + expect(output()).toBe("visible warning\n"); + }); + + it("shows only errors at error level", async () => { + const { log } = await freshLogger(); + log.setLevel("error"); + log.warn("suppressed warning"); + log.error("critical error"); + expect(output()).toBe("critical error\n"); + }); + + it("redacts secrets from messages, arguments, labels, and structured values", async () => { + const secret = `nvapi-${"a".repeat(40)}`; + const { log } = await freshLogger(); + log.setDebug(true); + log.debug(`token=${secret}`, { authorization: `Bearer ${secret}` }); + log.debug("Authorization: Basic opaque-basic-header"); + log.debug("Proxy-Authorization: Digest username=opaque-user, response=opaque-response"); + log.debug("Cookie: session=opaque-cookie-header"); + log.debug("Set-Cookie: session=opaque-set-cookie-header; HttpOnly"); + log.debug("OPENAI_API_KEY", "opaque-split-env-value"); + log.debug("NEMOCLAW_PROVIDER_KEY", "-opaque-leading-dash-value"); + log.debug("author", "safe author argument"); + log.debugObject(`context ${secret}`, { + apiKey: secret, + auth: "opaque-auth-secret", + API_SERVER_KEY: "opaque-server-key", + NEMOCLAW_PROVIDER_KEY: "opaque-provider-key", + privateKey: "opaque-private-key", + sessionKey: "opaque-session-key", + "API Key": "opaque-api-secret", + headers: { + "Proxy-Authorization": "Basic opaque-basic-secret", + Cookie: "session=opaque-cookie-secret", + }, + publicKey: "safe public key", + author: "safe author", + argv: [ + "--password", + "opaque-cli-password", + "--api-key", + "opaque-cli-api-key", + "--public-key", + "safe CLI public key", + "--author", + "safe CLI author", + ], + nested: { message: `Bearer ${secret}` }, + url: "https://user:password@example.test/path?access_token=raw-token", + }); + expect(output()).not.toContain(secret); + expect(output()).not.toContain("user:password"); + expect(output()).not.toContain("raw-token"); + expect(output()).not.toContain("opaque-auth-secret"); + expect(output()).not.toContain("opaque-server-key"); + expect(output()).not.toContain("opaque-provider-key"); + expect(output()).not.toContain("opaque-private-key"); + expect(output()).not.toContain("opaque-session-key"); + expect(output()).not.toContain("opaque-basic-secret"); + expect(output()).not.toContain("opaque-basic-header"); + expect(output()).not.toContain("opaque-user"); + expect(output()).not.toContain("opaque-response"); + expect(output()).not.toContain("opaque-cookie-secret"); + expect(output()).not.toContain("opaque-cookie-header"); + expect(output()).not.toContain("opaque-set-cookie-header"); + expect(output()).not.toContain("opaque-api-secret"); + expect(output()).not.toContain("opaque-split-env-value"); + expect(output()).not.toContain("opaque-leading-dash-value"); + expect(output()).not.toContain("opaque-cli-password"); + expect(output()).not.toContain("opaque-cli-api-key"); + expect(output()).toContain("safe public key"); + expect(output()).toContain("safe author"); + expect(output()).toContain("safe author argument"); + expect(output()).toContain("safe CLI public key"); + expect(output()).toContain("safe CLI author"); + expect(output()).toContain(""); + }); + + it("serializes circular values, BigInt, Error, Map, and Set without throwing", async () => { + const { log } = await freshLogger(); + log.setDebug(true); + const value: Record = { + count: 1n, + error: new Error("failure"), + map: new Map([["token", "secret-value"]]), + set: new Set(["one", "two"]), + }; + value.self = value; + expect(() => log.debugObject("context", value)).not.toThrow(); + expect(output()).toContain('"count": "1"'); + expect(output()).toContain('"self": "[Circular]"'); + expect(output()).toContain('"name": "Error"'); + }); + + it("does not let a synchronous stderr failure escape", async () => { + const { log } = await freshLogger(); + stderrSpy.mockImplementation(() => { + throw new Error("closed sink"); + }); + expect(() => log.error("failure")).not.toThrow(); + log.setDebug(true); + expect(() => log.debugObject("context", { ok: true })).not.toThrow(); + }); + + it("suppresses debugObject at info level", async () => { + const { log } = await freshLogger(); + log.debugObject("context", { key: "val" }); + expect(stderrSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/cli/logger.ts b/src/lib/cli/logger.ts new file mode 100644 index 00000000000..1e9b48df456 --- /dev/null +++ b/src/lib/cli/logger.ts @@ -0,0 +1,220 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { redact, redactForLog, redactLogSequence } from "../security/redact"; + +/** + * Centralized logger for NemoClaw CLI. + * + * Levels (lowest → highest verbosity): + * error < warn < info < debug + * + * Default level: info (errors, warnings, and info messages shown). + * Quiet mode: at most warn (only warnings and errors shown). + * Debug mode: debug (all messages shown with timestamps). + * + * Configure via: + * NEMOCLAW_LOG_LEVEL=debug nemoclaw ... + * NEMOCLAW_DEBUG=1 nemoclaw ... + * nemoclaw ... --debug + * nemoclaw ... --quiet + */ + +export type LogLevel = "error" | "warn" | "info" | "debug"; + +export type LoggerConfig = { + debug?: boolean; + quiet?: boolean; +}; + +const LEVEL_RANK: Record = { + error: 0, + warn: 1, + info: 2, + debug: 3, +}; + +const TRUE_ENV_VALUES = new Set(["1", "true", "y", "yes"]); +const UNSERIALIZABLE = "[unserializable]"; + +function resolveLevel(): LogLevel { + const env = process.env.NEMOCLAW_LOG_LEVEL?.trim().toLowerCase(); + if (env === "error" || env === "warn" || env === "info" || env === "debug") return env; + const debugEnv = process.env.NEMOCLAW_DEBUG?.trim().toLowerCase(); + if (debugEnv && TRUE_ENV_VALUES.has(debugEnv)) return "debug"; + return "info"; +} + +function normalizeForSerialization(value: unknown, seen = new WeakSet()): unknown { + if (value === null || typeof value === "string" || typeof value === "number") return value; + if (typeof value === "boolean") return value; + if (typeof value === "bigint") return value.toString(); + if (typeof value === "undefined") return "[undefined]"; + if (typeof value === "symbol" || typeof value === "function") return String(value); + + try { + if (seen.has(value)) return "[Circular]"; + seen.add(value); + + if (value instanceof Error) { + const normalized: Record = { + name: value.name, + message: value.message, + }; + if (value.stack) normalized.stack = value.stack; + if (value.cause !== undefined) { + normalized.cause = normalizeForSerialization(value.cause, seen); + } + for (const [key, entry] of Object.entries(value)) { + normalized[key] = normalizeForSerialization(entry, seen); + } + return normalized; + } + + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? "Invalid Date" : value.toISOString(); + } + if (value instanceof RegExp || value instanceof URL) return String(value); + if (Buffer.isBuffer(value)) return `[Buffer ${value.length} bytes]`; + if (ArrayBuffer.isView(value)) { + return `[${value.constructor.name} ${value.byteLength} bytes]`; + } + if (value instanceof ArrayBuffer) return `[ArrayBuffer ${value.byteLength} bytes]`; + if (value instanceof Map) { + const entries: Record = {}; + for (const [key, entry] of value.entries()) { + entries[String(key)] = normalizeForSerialization(entry, seen); + } + return entries; + } + if (value instanceof Set) { + return [...value].map((entry) => normalizeForSerialization(entry, seen)); + } + if (Array.isArray(value)) { + return value.map((entry) => normalizeForSerialization(entry, seen)); + } + + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [key, normalizeForSerialization(entry, seen)]), + ); + } catch { + return UNSERIALIZABLE; + } +} + +function safeSerialize(value: unknown): string { + try { + const normalized = normalizeForSerialization(value); + const serialized = JSON.stringify(redactForLog(normalized), null, 2); + return serialized ? redact(serialized) : JSON.stringify(UNSERIALIZABLE); + } catch { + return JSON.stringify(UNSERIALIZABLE); + } +} + +function safeText(value: unknown): string { + try { + if (typeof value === "string") return redact(String(redactForLog(value))); + if (value === null || typeof value === "number" || typeof value === "boolean") { + return String(value); + } + if (typeof value === "bigint") return value.toString(); + if (typeof value === "undefined") return "undefined"; + return safeSerialize(value); + } catch { + return UNSERIALIZABLE; + } +} + +class Logger { + private _level: LogLevel = "info"; + private _quiet = false; + private _debug = false; + + constructor() { + this.configure(); + } + + get level(): LogLevel { + if (this._debug) return "debug"; + if (this._quiet && LEVEL_RANK[this._level] > LEVEL_RANK.warn) return "warn"; + return this._level; + } + + /** Reset to environment defaults, then apply command-line overrides. */ + configure(config: LoggerConfig = {}): void { + this._level = resolveLevel(); + this._quiet = config.quiet === true; + this._debug = config.debug === true; + } + + setLevel(level: LogLevel): void { + this._level = level; + } + + setQuiet(quiet: boolean): void { + this._quiet = quiet; + } + + setDebug(debug: boolean): void { + this._debug = debug; + } + + isDebug(): boolean { + return this.level === "debug"; + } + + isQuiet(): boolean { + return this._quiet; + } + + private shouldLog(level: LogLevel): boolean { + return LEVEL_RANK[level] <= LEVEL_RANK[this.level]; + } + + private prefix(level: LogLevel): string { + if (!this.isDebug()) return ""; + return `[${new Date().toISOString()}] [${level.toUpperCase()}] `; + } + + private emit(line: string): void { + try { + process.stderr.write(line); + } catch { + // A diagnostic sink must not turn an otherwise successful command into a failure. + } + } + + private write(level: LogLevel, message: string, args: unknown[]): void { + if (!this.shouldLog(level)) return; + const [safeMessage, ...safeArgs] = redactLogSequence([message, ...args]).map(safeText); + const parts = [this.prefix(level) + safeMessage, ...safeArgs].join(" "); + this.emit(`${parts}\n`); + } + + error(message: string, ...args: unknown[]): void { + this.write("error", message, args); + } + + warn(message: string, ...args: unknown[]): void { + this.write("warn", message, args); + } + + info(message: string, ...args: unknown[]): void { + this.write("info", message, args); + } + + debug(message: string, ...args: unknown[]): void { + this.write("debug", message, args); + } + + /** Log a redacted structured value without allowing serialization errors to escape. */ + debugObject(label: string, obj: unknown): void { + if (!this.shouldLog("debug")) return; + const [safeLabel, safeObject] = redactLogSequence([label, obj]); + this.emit(`${this.prefix("debug")}${safeText(safeLabel)}: ${safeSerialize(safeObject)}\n`); + } +} + +/** Singleton logger shared across all NemoClaw modules. */ +export const log = new Logger(); diff --git a/src/lib/cli/nemoclaw-oclif-command.test.ts b/src/lib/cli/nemoclaw-oclif-command.test.ts index aaf6f819ad4..cdf76a685c3 100644 --- a/src/lib/cli/nemoclaw-oclif-command.test.ts +++ b/src/lib/cli/nemoclaw-oclif-command.test.ts @@ -2,8 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import { afterEach, describe, expect, it, vi } from "vitest"; - -import { NemoClawCommand, type CommandExitResult } from "./nemoclaw-oclif-command"; +import { log } from "./logger"; +import { type CommandExitResult, NemoClawCommand } from "./nemoclaw-oclif-command"; class TestCommand extends NemoClawCommand { static id = "test"; @@ -25,6 +25,15 @@ class TestCommand extends NemoClawCommand { } } +class ParsingTestCommand extends NemoClawCommand { + static id = "parsing-test"; + static flags = {}; + + public async run(): Promise { + await this.parse(ParsingTestCommand); + } +} + function makeCommand(): TestCommand { return Object.create(TestCommand.prototype) as TestCommand; } @@ -32,6 +41,8 @@ function makeCommand(): TestCommand { describe("NemoClawCommand", () => { afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllEnvs(); + log.configure(); process.exitCode = undefined; }); @@ -68,4 +79,25 @@ describe("NemoClawCommand", () => { JSON.stringify({ provider: "build", apiKey: "" }, null, 2), ); }); + + it("applies host logging flags from oclif parser output", async () => { + const configure = vi.spyOn(log, "configure").mockImplementation(() => undefined); + + await ParsingTestCommand.run(["--quiet"], process.cwd()); + await ParsingTestCommand.run(["--debug"], process.cwd()); + + expect(configure).toHaveBeenCalledWith({ debug: false, quiet: true }); + expect(configure).toHaveBeenCalledWith({ debug: true, quiet: false }); + }); + + it("keeps NEMOCLAW_LOG_LEVEL precedence unless a CLI flag overrides it", async () => { + vi.stubEnv("NEMOCLAW_LOG_LEVEL", "error"); + vi.stubEnv("NEMOCLAW_DEBUG", "true"); + + await ParsingTestCommand.run([], process.cwd()); + expect(log.level).toBe("error"); + + await ParsingTestCommand.run(["--debug"], process.cwd()); + expect(log.level).toBe("debug"); + }); }); diff --git a/src/lib/cli/nemoclaw-oclif-command.ts b/src/lib/cli/nemoclaw-oclif-command.ts index 031a979e77b..cfee9056011 100644 --- a/src/lib/cli/nemoclaw-oclif-command.ts +++ b/src/lib/cli/nemoclaw-oclif-command.ts @@ -1,9 +1,9 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { Command, Flags } from "@oclif/core"; - +import { Command, Flags, type Interfaces } from "@oclif/core"; import { redactForLog } from "../security/redact"; +import { log } from "./logger"; export type CommandExitResult = { exitCode?: number | null; @@ -20,8 +20,55 @@ export type CommandExitResult = { export abstract class NemoClawCommand extends Command { static baseFlags = { help: Flags.help({ char: "h" }), + // Hidden logging flags. Universal visible flags would have to be + // documented in every command section of docs/reference/commands.mdx + // (cli-parity gate), so the documented interface is + // NEMOCLAW_LOG_LEVEL/NEMOCLAW_DEBUG; the flags remain as a convenience. + debug: Flags.boolean({ + description: "Enable debug output (equivalent to NEMOCLAW_LOG_LEVEL=debug)", + default: false, + hidden: true, + exclusive: ["quiet"], + }), + quiet: Flags.boolean({ + description: "Suppress informational output; show only warnings and errors", + default: false, + hidden: true, + exclusive: ["debug"], + }), }; + protected override async init(): Promise { + await super.init(); + // Every invocation starts from the current environment. Raw-argv + // passthrough commands intentionally stop here: only environment-based + // logging configuration applies to them. + log.configure({ debug: false, quiet: false }); + } + + protected override async parse< + F extends Interfaces.OutputFlags, + B extends Interfaces.OutputFlags, + A extends Interfaces.OutputArgs, + >( + options?: Interfaces.Input, + argv?: string[], + ): Promise> { + const parsed = await super.parse(options, argv); + + // Logging flags belong to the host only when a command invokes oclif's + // parser. Commands that deliberately consume raw argv (for example + // `sandbox agent` and `uninstall`) must forward similarly named flags + // without changing host logging. Using parser output also honors `--`: + // downstream flags after the boundary never acquire host meaning. + log.configure({ + debug: parsed.flags.debug === true, + quiet: parsed.flags.quiet === true, + }); + + return parsed; + } + protected logJson(json: unknown): void { console.log(JSON.stringify(redactForLog(json), null, 2)); } diff --git a/src/lib/openclaw/agent-json-provenance.ts b/src/lib/openclaw/agent-json-provenance.ts index 8a38b91ed60..a22978bf95e 100644 --- a/src/lib/openclaw/agent-json-provenance.ts +++ b/src/lib/openclaw/agent-json-provenance.ts @@ -23,15 +23,15 @@ type WalkEntry = { }; function snippet(value: string, limit = 300): string { - const squashed = value + const sanitized = value .replace(ANSI_OSC_PATTERN, "") .replace(ANSI_CSI_PATTERN, "") .replace(/\r|\u0008/gu, "") - .replace(CONTROL_PATTERN, "") - .replace(/\s+/gu, " ") - .trim(); - const redacted = redactProvenanceDetail(squashed); - return redacted.length <= limit ? redacted : `${redacted.slice(0, limit - 3)}...`; + .replace(CONTROL_PATTERN, ""); + // Preserve line boundaries while redacting so line-oriented header patterns + // cannot consume unrelated details that happen to follow on another line. + const squashed = redactProvenanceDetail(sanitized).replace(/\s+/gu, " ").trim(); + return squashed.length <= limit ? squashed : `${squashed.slice(0, limit - 3)}...`; } function redactProvenanceDetail(value: string): string { diff --git a/src/lib/security/credential-filter-secret-patterns.test.ts b/src/lib/security/credential-filter-secret-patterns.test.ts index 314999e474c..e10cc4dabe6 100644 --- a/src/lib/security/credential-filter-secret-patterns.test.ts +++ b/src/lib/security/credential-filter-secret-patterns.test.ts @@ -22,6 +22,7 @@ describe("isCredentialField", () => { expect(isCredentialField("bearerToken")).toBe(true); expect(isCredentialField("privateKey")).toBe(true); expect(isCredentialField("sessionToken")).toBe(true); + expect(isCredentialField("sessionKey")).toBe(true); // OpenClaw channel token fields (#5027). expect(isCredentialField("botToken")).toBe(true); expect(isCredentialField("appToken")).toBe(true); @@ -58,6 +59,8 @@ describe("isCredentialField", () => { expect(isCredentialField("GITHUB_TOKEN")).toBe(true); expect(isCredentialField("BRAVE_API_KEY")).toBe(true); expect(isCredentialField("OPENAI_API_KEY")).toBe(true); + expect(isCredentialField("API_SERVER_KEY")).toBe(true); + expect(isCredentialField("NEMOCLAW_PROVIDER_KEY")).toBe(true); expect(isCredentialField("DB_PASSWORD")).toBe(true); expect(isCredentialField("DB_PASSWD")).toBe(true); expect(isCredentialField("DB_PASS")).toBe(true); @@ -96,6 +99,7 @@ describe("isCredentialField", () => { expect(isCredentialField("tokenizer")).toBe(false); expect(isCredentialField("maxTokens")).toBe(false); expect(isCredentialField("X-Request-Id")).toBe(false); + expect(isCredentialField("author")).toBe(false); }); it("does not strip public keys (verification material, not secrets)", () => { diff --git a/src/lib/security/redact.test.ts b/src/lib/security/redact.test.ts index 86fbfacd677..f815d02d200 100644 --- a/src/lib/security/redact.test.ts +++ b/src/lib/security/redact.test.ts @@ -3,7 +3,14 @@ import { describe, expect, it } from "vitest"; -import { redact, redactForLog, redactFull, redactSensitiveText, redactUrl } from "./redact.js"; +import { + redact, + redactForLog, + redactFull, + redactLogSequence, + redactSensitiveText, + redactUrl, +} from "./redact.js"; describe("URL redaction", () => { it.each([ @@ -154,6 +161,11 @@ describe("redactForLog", () => { passRate: 0.9, passCount: 4, passThrough: "enabled", + tokenizer: "cl100k_base", + maxTokens: 1024, + secretary: "safe role", + credentialing: "complete", + passwordless: true, correlationMarker: "reply-correlation-marker-123", }; @@ -197,6 +209,219 @@ describe("redactForLog", () => { }); }); + it("uses canonical credential fields for opaque structured values without false positives", () => { + expect( + redactForLog({ + auth: "opaque-auth-secret", + API_SERVER_KEY: "opaque-server-key", + NEMOCLAW_PROVIDER_KEY: "opaque-provider-key", + privateKey: "opaque-private-key", + sessionKey: "opaque-session-key", + setCookie: "session=opaque-set-cookie-secret", + "API Key": "opaque-api-secret", + APIKey: "opaque-api-secret-with-acronym", + apikey: "opaque-run-together-api-secret", + APIKEY: "opaque-uppercase-api-secret", + headers: { + "Proxy-Authorization": "Basic opaque-basic-secret", + Cookie: "session=opaque-cookie-secret", + }, + secretValue: "opaque-secret-value", + tokenValue: "opaque-token-value", + passwordValue: "opaque-password-value", + credentials: "opaque-credentials-value", + publicKey: "safe public key", + PUBLIC_KEY: "safe uppercase public key", + author: "safe author", + oauth: "safe auth method", + }), + ).toEqual({ + auth: "", + API_SERVER_KEY: "", + NEMOCLAW_PROVIDER_KEY: "", + privateKey: "", + sessionKey: "", + setCookie: "", + "API Key": "", + APIKey: "", + apikey: "", + APIKEY: "", + headers: { + "Proxy-Authorization": "", + Cookie: "", + }, + secretValue: "", + tokenValue: "", + passwordValue: "", + credentials: "", + publicKey: "safe public key", + PUBLIC_KEY: "safe uppercase public key", + author: "safe author", + oauth: "safe auth method", + }); + }); + + it("redacts opaque CLI values by sequence and inline flag context", () => { + expect( + redactForLog({ + argv: [ + "--password", + "opaque-password", + "--api-key", + "opaque-api-key", + "--private-key=opaque-inline-private-key", + "--session-key", + "opaque-session-key", + "--password", + "-opaque-leading-dash", + "--api-key", + "--opaque-leading-double-dash", + "--public-key", + "safe-public-key", + "--author", + "safe-author", + "--password", + "--verbose", + "safe-tail", + ], + }), + ).toEqual({ + argv: [ + "--password", + "", + "--api-key", + "", + "--private-key=", + "--session-key", + "", + "--password", + "", + "--api-key", + "", + "--public-key", + "safe-public-key", + "--author", + "safe-author", + "--password", + "", + "safe-tail", + ], + }); + + expect( + redactLogSequence([ + "OPENAI_API_KEY", + "opaque-env-value", + "NEMOCLAW_PROVIDER_KEY", + "-opaque-leading-dash-value", + "token", + "opaque-token-label", + "API Key:", + "opaque-api-key-label", + "proxyAuth", + "opaque-proxy-auth-label", + "proxyAuth:", + "opaque-proxy-auth-colon-label", + "public key", + "safe-public-key", + "author", + "safe-author", + "Failed to refresh token, retrying", + { attempt: 3 }, + "Token refresh failed", + { attempt: 4 }, + ]), + ).toEqual([ + "OPENAI_API_KEY", + "", + "NEMOCLAW_PROVIDER_KEY", + "", + "token", + "", + "API Key:", + "", + "proxyAuth", + "", + "proxyAuth:", + "", + "public key", + "safe-public-key", + "author", + "safe-author", + "Failed to refresh token, retrying", + { attempt: 3 }, + "Token refresh failed", + { attempt: 4 }, + ]); + }); + + it("redacts Basic, Digest, proxy-auth, and cookie text without matching safe labels", () => { + const text = [ + "Authorization: Basic opaque-basic-value", + "Proxy-Authorization: Digest username=opaque-user, response=opaque-response", + "Authorization: Basic-Plus opaque-basic-plus", + "Authorization: Bearer+DPoP opaque-bearer-plus", + "Proxy-Authorization: Digest-v2 opaque-digest-v2", + "Authorization=Basic opaque-equals-auth", + "Proxy-Authorization=Digest opaque-equals-proxy", + "Cookie=session=opaque-equals-cookie", + "Set-Cookie=session=opaque-equals-set-cookie", + "Cookie: session=opaque-cookie-value", + "Set-Cookie: session=opaque-set-cookie-value; HttpOnly", + 'headers={"Authorization":"Basic opaque-json-value"}', + "author: safe-author", + ].join("\n"); + + const result = redactFull(text); + for (const secret of [ + "opaque-basic-value", + "opaque-user", + "opaque-response", + "opaque-basic-plus", + "opaque-bearer-plus", + "opaque-digest-v2", + "opaque-equals-auth", + "opaque-equals-proxy", + "opaque-equals-cookie", + "opaque-equals-set-cookie", + "opaque-cookie-value", + "opaque-set-cookie-value", + "opaque-json-value", + ]) { + expect(result).not.toContain(secret); + } + expect(result).toContain("author: safe-author"); + }); + + it("preserves same-line diagnostics after Basic and Bearer credentials", () => { + expect(redactFull("Authorization: Bearer opaque-bearer-value request failed")).toBe( + "Authorization: Bearer request failed", + ); + }); + + it("redacts folded credential headers without consuming the next diagnostic line", () => { + for (const header of ["Authorization", "Proxy-Authorization", "Cookie", "Set-Cookie"]) { + const result = redactFull(`${header}:\r\n\topaque-folded-value\r\nnext diagnostic`); + expect(result).toBe(`${header}: \r\nnext diagnostic`); + } + expect(redactFull("Authorization:\ropaque-bare-cr\rnext diagnostic")).toBe( + "Authorization: \rnext diagnostic", + ); + }); + + it("fails closed for malformed quoted credential fields", () => { + for (const input of [ + '{"Authorization":"Basic opaque-unterminated', + '{"Cookie":"session=opaque-unterminated', + '{"Authorization": Basic opaque-unquoted}', + ]) { + expect(redactFull(input)).not.toContain("opaque-"); + } + expect(redactFull('{"Authorization":"Basic opaque-complete","status":"kept"}')).toBe( + '{"Authorization":"Basic ","status":"kept"}', + ); + }); + it("redacts known secret patterns inside otherwise safe strings", () => { const result = redactForLog({ message: "upstream returned Authorization: Bearer abcdefghijklmnop", diff --git a/src/lib/security/redact.ts b/src/lib/security/redact.ts index 211f48c8fc8..1fd128b2d2f 100644 --- a/src/lib/security/redact.ts +++ b/src/lib/security/redact.ts @@ -19,9 +19,9 @@ import type { StdioOptions } from "node:child_process"; */ import { listMessagingCredentialMetadata } from "../messaging/channels"; +import { isCredentialField } from "./credential-filter"; import { CONTEXT_PATTERNS, - hasPassCredentialSegment, SECRET_BLOCK_PATTERNS, SECRET_PATTERNS, TOKEN_PREFIX_PATTERNS, @@ -180,6 +180,43 @@ const FULL_REDACT_PATTERNS: [RegExp, string][] = [ new RegExp(p.source, p.flags), "", ]), + [ + /("(?:authorization|proxy-authorization|cookie|set-cookie)"\s*:\s*")((?:(?:basic|bearer|digest)\s+)?)(?:\\.|[^"\\])*"/gi, + '$1$2"', + ], + [ + /('(?:authorization|proxy-authorization|cookie|set-cookie)'\s*:\s*')((?:(?:basic|bearer|digest)\s+)?)(?:\\.|[^'\\])*'/gi, + "$1$2'", + ], + [ + /("(?:authorization|proxy-authorization|cookie|set-cookie)"[ \t]*[:=])(?![ \t]*"(?:\\.|[^"\\])*")[^\r\n]*/gi, + "$1 ", + ], + [ + /('(?:authorization|proxy-authorization|cookie|set-cookie)'[ \t]*[:=])(?![ \t]*'(?:\\.|[^'\\])*')[^\r\n]*/gi, + "$1 ", + ], + [ + /(\b(?:authorization|proxy-authorization|cookie|set-cookie)[ \t]*[:=])[^\r\n]*\r(?!\n)[^\r\n]*/gi, + "$1 ", + ], + [ + /(\b(?:authorization|proxy-authorization|cookie|set-cookie)[ \t]*[:=])[^\r\n]*(?:\r?\n[ \t]+[^\r\n]*)+/gi, + "$1 ", + ], + [ + /(\b(?:authorization|proxy-authorization)[ \t]*[:=][ \t]*(?:basic|bearer)[ \t]+)\S+/gi, + "$1", + ], + [ + /(\b(?:authorization|proxy-authorization)[ \t]*[:=][ \t]*digest[ \t]+)[^\r\n]*/gi, + "$1", + ], + [ + /(\b(?:authorization|proxy-authorization)[ \t]*[:=])(?![ \t]*(?:basic|bearer|digest)(?:[ \t]|$))[ \t]*[^\r\n]*/gi, + "$1 ", + ], + [/(\b(?:cookie|set-cookie)[ \t]*[:=][ \t]*)[^\r\n]*/gi, "$1"], [ /((?:^|[^A-Za-z0-9])(?:[A-Za-z0-9]{1,128}_(?:key|token|secret|credential|password|passwd|pass)|(?:x[-_])?api[-_]key|token|secret|credential|password|passwd|pass)["']?(?:[ \t]{0,32}[=:][ \t]{0,32}|[ \t]{1,32})["']?)[^\s'"]+((?:"|')?)/gi, "$1$2", @@ -254,20 +291,79 @@ export function redactUrl(value: unknown): string | null { return `${parsed.url.toString()}${parsed.suffix}`; } +const SENSITIVE_KEY_WORDS: ReadonlySet = new Set([ + "apikey", + "auth", + "authorization", + "bearer", + "cookie", + "credential", + "credentials", + "password", + "secret", + "token", +]); + function isSensitiveKey(key: string): boolean { + if (isCredentialField(key)) return true; + const words = key + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter(Boolean); return ( - /(?:api[_-]?key|token|secret|password|credential|authorization|bearer)/i.test(key) || - hasPassCredentialSegment(key) + words.some((word) => SENSITIVE_KEY_WORDS.has(word)) || + (words.includes("api") && words.includes("key")) + ); +} + +function credentialFlagKey(value: unknown): string | null { + if (typeof value !== "string") return null; + const flag = /^--?([A-Za-z0-9][A-Za-z0-9._-]*)$/.exec(value.trim()); + return flag && isSensitiveKey(flag[1]) ? flag[1] : null; +} + +const CREDENTIAL_CONTEXT_LABEL_PATTERN = + /^(?:tokens?|secrets?|passwords?|passphrases?|credentials?|auth|authorization|bearer|cookies?|set[ _-]*cookie|proxy[ _-]*(?:auth|authorization)|(?:api|access|refresh|client|bearer|auth|private|signing|session|bot|app|resolved)[ _-]*(?:tokens?|keys?|secrets?|passwords?))$/i; + +function credentialContextKey(value: unknown): string | null { + if (typeof value !== "string") return null; + const flag = credentialFlagKey(value); + if (flag) return flag; + const candidate = value.trim().replace(/[:=]$/, "").trim(); + return candidate && + (isCredentialField(candidate) || CREDENTIAL_CONTEXT_LABEL_PATTERN.test(candidate)) + ? candidate + : null; +} + +/** Redact opaque values whose credential context is carried by the previous argument. */ +export function redactLogSequence(values: readonly unknown[]): unknown[] { + return values.map((value, index) => + index > 0 && credentialContextKey(values[index - 1]) !== null ? "" : value, ); } +function redactInlineCredentialFlag(value: string): string { + const match = /^(--?)([A-Za-z0-9][A-Za-z0-9._-]*)=(.*)$/s.exec(value); + if (!match || !isSensitiveKey(match[2])) return redactFull(value); + return `${match[1]}${match[2]}=`; +} + export function redactForLog(value: unknown, seen: WeakSet = new WeakSet()): unknown { - if (typeof value === "string") return redactFull(value); + if (typeof value === "string") return redactInlineCredentialFlag(value); if (value === null || typeof value !== "object") return value; if (seen.has(value)) return "[Circular]"; seen.add(value); - if (Array.isArray(value)) return value.map((entry) => redactForLog(entry, seen)); + if (Array.isArray(value)) { + return value.map((entry, index) => + index > 0 && credentialFlagKey(value[index - 1]) !== null + ? "" + : redactForLog(entry, seen), + ); + } const redacted: Record = {}; for (const [key, entry] of Object.entries(value as Record)) {