Files
openclaw/src/logging/console.ts
Peter Steinberger e2a112a556 feat(onboard): guided CLI onboarding with live AI verification and classic fallback (#101880)
* feat(onboard): guided CLI onboarding with live AI verification and classic fallback

Interactive `openclaw onboard` (and bare `openclaw` on a fresh install) now
runs a guided flow with macOS-app parity: detect existing AI access, live-test
candidates with a real completion before persisting anything, walk down the
ladder on failure with mapped reasons, and offer verified manual API-key entry
from installed provider manifests (masked input). In-flow escapes: classic
wizard, Crestodian chat, skip-AI. Classic wizard gains an optional post-auth
live verification step. `--classic`, `--modern`, and `--non-interactive`
contracts unchanged. Docs corrected for post-#99935 routing.

Closes #101851

* improve(onboard): quiet probe diagnostics in wizard TTY, carry risk ack into classic escape

Candidate live-tests during guided setup are probes: rename their run id and
lane to the existing probe conventions (logging/subsystem.ts console
suppression, command-queue quiet probe lanes) so expected failures stop
leaking raw diagnostics into the Clack UI; file diagnostics unchanged. The
classic-wizard escape now passes the already-collected risk acknowledgement
through instead of re-prompting in the same session.

* fix(onboard): quiet the session-derived setup-inference probe lane too

The live-test run enqueues on two lanes: the explicit probe lane and one
derived from its temp session key. Extend the shared quiet-probe predicate to
cover the derived lane so a failing candidate cannot leak lane-task
diagnostics into the wizard TTY.

* improve(onboard): suppress subsystem console output during wizard live tests

Provider-transport subsystem loggers (model-fetch start/response, transport
errors) carry no run id, so probe suppression cannot catch them and a failing
candidate printed raw log lines into the Clack TTY. Reuse the TUI console
subsystem-filter seam via a finally-safe scoped helper around guided
activation and the classic live-verify; file logging is unchanged and the
gateway (macOS app) surface is unaffected.

* fix(onboard): never auto-replace a configured model when its live check fails

The re-run verification probe executes outside the configured workspace (setup
never runs workspace plugins), so a workspace-backed current model can fail
the check while working fine in the agent. Stop the auto ladder on an
existing-model failure and hand the decision to the manual stage instead of
silently persisting a different candidate as the default. Docs note the
fail-safe and the workspace caveat.

* feat(onboard): two-way switching between Crestodian chat and the menu wizards

From the chat, `open setup wizard`, `open classic wizard`, and `open channel
wizard for <channel>` hand off to the guided flow, the classic wizard, or the
masked `channels add` wizard after the chat TUI tears down (mirrors the
open-tui handoff; gateway surface gets a text pointer instead). The hosted
channel wizard no longer dead-ends at sensitive steps — it offers the switch
and remembers the channel. New read-only `channel info <channel>` operation
and ring-zero action surface label, blurb, configured state, and the real
docs URL from channel-setup discovery so the assistant can explain Slack or
Telegram prerequisites instead of guessing; both prompts instruct it to use
them. `channels add --channel <id>` now preselects the channel. Docs cover
the interchangeable flows.

* fix(onboard): avoid param reassignment in open-setup handoff

* improve(onboard): separate ask-about vs connect intent in channel prompt guidance

Live test showed the agent detouring an explicit connect request through
channel_info because the guidance said to consult it first. Both prompts now
distinguish asking about a channel (channel info + docs link) from asking to
connect (connect right away).

* fix(channels): mark channel token entry as sensitive input

The shared single-token prompt lacked sensitive:true, so terminal wizards
echoed pasted channel tokens and the Crestodian chat bridge (which refuses
plain-text secrets based on this flag) hosted the Telegram token step in
visible chat. Found live-testing the chat-to-wizard switch; pre-existing on
main but load-bearing for the masked-wizard contract this PR documents.

* fix(onboard): restore terminal state around the guided flow's TUI launch

Mirror the classic finalize handoff so the chat TUI never inherits the wizard
prompter's raw/paused terminal state on the default first-run path.

* fix(channels): type the token prompter mock for the sensitive-flag assertion

* fix(gateway): map the TUI-only open-setup action to none for app clients

Engine-side surface gating already prevents open-setup replies on the gateway
surface; this keeps the client-visible action enum stable even if that gate
ever regresses. (Reviewed with the switching round; missed in its commit.)

* docs: regenerate docs map for onboarding page changes
2026-07-09 12:40:55 +01:00

322 lines
11 KiB
TypeScript

// Console logging helpers format and write messages to console streams.
import util from "node:util";
import { stripAnsi } from "../../packages/terminal-core/src/ansi.js";
import type { OpenClawConfig } from "../config/types.js";
import { isVerbose } from "../global-state.js";
import { readLoggingConfig, shouldSkipMutatingLoggingConfigRead } from "./config.js";
import { resolveEnvLogLevelOverride } from "./env-log-level.js";
import { type LogLevel, normalizeLogLevel } from "./levels.js";
import { getLogger } from "./logger.js";
import { redactSensitiveText } from "./redact.js";
import { loggingState } from "./state.js";
import { formatLocalIsoWithOffset, formatTimestamp } from "./timestamps.js";
import type { ConsoleStyle, LoggerSettings } from "./types.js";
export type { ConsoleStyle } from "./types.js";
type ConsoleSettings = {
level: LogLevel;
style: ConsoleStyle;
};
export type ConsoleLoggerSettings = ConsoleSettings;
type ConsoleConfigLoader = () => OpenClawConfig["logging"] | undefined;
const loadConfigFallbackDefault: ConsoleConfigLoader = () => undefined;
let loadConfigFallback: ConsoleConfigLoader = loadConfigFallbackDefault;
export function setConsoleConfigLoaderForTests(loader?: ConsoleConfigLoader): void {
loadConfigFallback = loader ?? loadConfigFallbackDefault;
}
function normalizeConsoleLevel(level?: string): LogLevel {
if (isVerbose()) {
return "debug";
}
if (!level && process.env.VITEST === "true" && process.env.OPENCLAW_TEST_CONSOLE !== "1") {
return "silent";
}
return normalizeLogLevel(level, "info");
}
function normalizeConsoleStyle(style?: string): ConsoleStyle {
if (style === "compact" || style === "json" || style === "pretty") {
return style;
}
if (!process.stdout.isTTY) {
return "compact";
}
return "pretty";
}
function resolveConsoleSettings(): ConsoleSettings {
const envLevel = resolveEnvLogLevelOverride();
// Test runs default to silent console logging unless explicitly overridden.
// Skip config-file and full config fallback reads in this fast path.
if (
process.env.VITEST === "true" &&
process.env.OPENCLAW_TEST_CONSOLE !== "1" &&
!isVerbose() &&
!envLevel &&
!loggingState.overrideSettings
) {
return { level: "silent", style: normalizeConsoleStyle(undefined) };
}
let cfg: OpenClawConfig["logging"] | undefined =
(loggingState.overrideSettings as LoggerSettings | null) ?? readLoggingConfig();
if (!cfg && !shouldSkipMutatingLoggingConfigRead()) {
if (loggingState.resolvingConsoleSettings) {
cfg = undefined;
} else {
loggingState.resolvingConsoleSettings = true;
try {
cfg = loadConfigFallback();
} finally {
loggingState.resolvingConsoleSettings = false;
}
}
}
const level = envLevel ?? normalizeConsoleLevel(cfg?.consoleLevel);
const style = normalizeConsoleStyle(cfg?.consoleStyle);
return { level, style };
}
function consoleSettingsChanged(a: ConsoleSettings | null, b: ConsoleSettings) {
if (!a) {
return true;
}
return a.level !== b.level || a.style !== b.style;
}
export function getConsoleSettings(): ConsoleLoggerSettings {
const settings = resolveConsoleSettings();
const cached = loggingState.cachedConsoleSettings as ConsoleSettings | null;
if (!cached || consoleSettingsChanged(cached, settings)) {
loggingState.cachedConsoleSettings = settings;
}
return loggingState.cachedConsoleSettings as ConsoleSettings;
}
export function getResolvedConsoleSettings(): ConsoleLoggerSettings {
return getConsoleSettings();
}
// Route all console output (including tslog console writes) to stderr.
// This keeps stdout clean for RPC/JSON modes.
export function routeLogsToStderr(): void {
loggingState.forceConsoleToStderr = true;
}
export function setConsoleSubsystemFilter(filters?: string[] | null): void {
if (!filters || filters.length === 0) {
loggingState.consoleSubsystemFilter = null;
return;
}
const normalized = filters.map((value) => value.trim()).filter((value) => value.length > 0);
loggingState.consoleSubsystemFilter = normalized.length > 0 ? normalized : null;
}
/** Hides subsystem console lines for TTY-owned work while preserving file logging. */
export async function withConsoleSubsystemsSuppressed<T>(work: () => Promise<T>): Promise<T> {
const previousFilter = loggingState.consoleSubsystemFilter
? [...loggingState.consoleSubsystemFilter]
: null;
setConsoleSubsystemFilter(["__openclaw_tui_quiet__"]);
try {
return await work();
} finally {
setConsoleSubsystemFilter(previousFilter);
}
}
export function setConsoleTimestampPrefix(enabled: boolean): void {
loggingState.consoleTimestampPrefix = enabled;
}
function normalizeConsoleSubsystem(subsystem?: string | null): string | null {
if (typeof subsystem !== "string") {
return null;
}
const normalized = subsystem.trim();
return normalized.length > 0 ? normalized : null;
}
export function shouldLogSubsystemToConsole(subsystem?: string | null): boolean {
const filter = loggingState.consoleSubsystemFilter;
if (!filter || filter.length === 0) {
return true;
}
const normalizedSubsystem = normalizeConsoleSubsystem(subsystem);
if (!normalizedSubsystem) {
return false;
}
return filter.some(
(prefix) => normalizedSubsystem === prefix || normalizedSubsystem.startsWith(`${prefix}/`),
);
}
const SUPPRESSED_CONSOLE_PREFIXES = [
"Closing session:",
"Opening session:",
"Removing old closed session:",
"Session already closed",
"Session already open",
] as const;
function shouldSuppressConsoleMessage(message: string): boolean {
if (SUPPRESSED_CONSOLE_PREFIXES.some((prefix) => message.startsWith(prefix))) {
return true;
}
if (isVerbose()) {
return false;
}
return false;
}
function isEpipeError(err: unknown): boolean {
const code = (err as { code?: string })?.code;
return code === "EPIPE" || code === "EIO";
}
export function formatConsoleTimestamp(style: ConsoleStyle): string {
const now = new Date();
if (style === "pretty") {
return formatTimestamp(now, { style: "short" }).replace(/[+-]\d{2}:\d{2}$/, "");
}
return formatLocalIsoWithOffset(now);
}
function hasTimestampPrefix(value: string): boolean {
return /^(?:\d{2}:\d{2}:\d{2}|\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?)/.test(
value,
);
}
/**
* Route console.* calls through file logging while still emitting to stdout/stderr.
* This keeps user-facing output unchanged but guarantees every console call is captured in log files.
*/
export function enableConsoleCapture(): void {
if (loggingState.consolePatched) {
return;
}
loggingState.consolePatched = true;
// Handle async EPIPE errors on stdout/stderr. The synchronous try/catch in
// the forward() wrapper below only covers errors thrown during write dispatch.
// When the receiving pipe closes (e.g. during shutdown), Node emits the error
// asynchronously on the stream. Without a listener this becomes an uncaught
// exception that crashes the gateway.
// Guard separately from consolePatched so test resets don't stack listeners.
if (!loggingState.streamErrorHandlersInstalled) {
loggingState.streamErrorHandlersInstalled = true;
for (const stream of [process.stdout, process.stderr]) {
stream.on("error", (err) => {
if (isEpipeError(err)) {
// stdout/stderr broken means the process is orphaned (e.g. the parent
// service restarted and closed the journal pipe). Exit cleanly instead
// of spinning in a tight loop where every log attempt re-triggers EPIPE.
const exitCode = process.exitCode;
process.exit(exitCode !== undefined && exitCode !== 0 && exitCode !== "0" ? exitCode : 0);
return;
}
throw err;
});
}
}
let logger: ReturnType<typeof getLogger> | null = null;
const getLoggerLazy = () => {
if (!logger) {
logger = getLogger();
}
return logger;
};
const original = {
log: console.log,
info: console.info,
warn: console.warn,
error: console.error,
debug: console.debug,
trace: console.trace,
};
loggingState.rawConsole = {
log: original.log,
info: original.info,
warn: original.warn,
error: original.error,
};
const forward =
(level: LogLevel, orig: (...args: unknown[]) => void) =>
(...args: unknown[]) => {
const formatted = util.format(...args);
if (shouldSuppressConsoleMessage(formatted)) {
return;
}
const trimmed = stripAnsi(formatted).trimStart();
const shouldPrefixTimestamp =
loggingState.consoleTimestampPrefix && trimmed.length > 0 && !hasTimestampPrefix(trimmed);
const timestamp = shouldPrefixTimestamp
? formatConsoleTimestamp(getConsoleSettings().style)
: "";
try {
const resolvedLogger = getLoggerLazy();
// Map console levels to file logger
if (level === "trace") {
resolvedLogger.trace(formatted);
} else if (level === "debug") {
resolvedLogger.debug(formatted);
} else if (level === "info") {
resolvedLogger.info(formatted);
} else if (level === "warn") {
resolvedLogger.warn(formatted);
} else if (level === "error" || level === "fatal") {
resolvedLogger.error(formatted);
} else {
resolvedLogger.info(formatted);
}
} catch {
// never block console output on logging failures
}
if (loggingState.forceConsoleToStderr) {
// In --json mode, all console.* writes are diagnostics and should stay off stdout.
try {
const redacted = redactSensitiveText(formatted);
const line = timestamp ? `${timestamp} ${redacted}` : redacted;
process.stderr.write(`${line}\n`);
} catch (err) {
if (isEpipeError(err)) {
return;
}
throw err;
}
} else {
try {
const redacted = redactSensitiveText(formatted);
if (!timestamp) {
if (args.length === 0) {
orig.apply(console, args as []);
return;
}
orig.call(console, redacted);
return;
}
orig.call(console, redacted ? `${timestamp} ${redacted}` : timestamp);
} catch (err) {
if (isEpipeError(err)) {
return;
}
throw err;
}
}
};
console.log = forward("info", original.log);
console.info = forward("info", original.info);
console.warn = forward("warn", original.warn);
console.error = forward("error", original.error);
console.debug = forward("debug", original.debug);
console.trace = forward("trace", original.trace);
}