feat(daemon): split console/file logging defaults with rotation (#88)

This commit is contained in:
Mohamed Boudra
2026-03-02 12:18:30 +07:00
committed by GitHub
parent 3a6a8cfa38
commit 01faa3467d
10 changed files with 643 additions and 149 deletions
+13
View File
@@ -18241,6 +18241,18 @@
"fsevents": "~2.3.2"
}
},
"node_modules/rotating-file-stream": {
"version": "3.2.9",
"resolved": "https://registry.npmjs.org/rotating-file-stream/-/rotating-file-stream-3.2.9.tgz",
"integrity": "sha512-i9i0KkHh12ryl4xtELg+0gyoFre2PJ9RcQQLzquWsiqygyYsrZLckrqqYrthhnJZGZb4g+KUHtcoWYVq34gaug==",
"license": "MIT",
"engines": {
"node": ">=14.0"
},
"funding": {
"url": "https://www.blockchain.com/btc/address/12p1p5q7sK75tPyuesZmssiMYr4TKzpSCN"
}
},
"node_modules/rou3": {
"version": "0.7.12",
"license": "MIT"
@@ -21767,6 +21779,7 @@
"pino": "^10.2.0",
"pino-pretty": "^13.1.3",
"qrcode": "^1.5.4",
"rotating-file-stream": "^3.2.9",
"sherpa-onnx": "^1.12.23",
"sherpa-onnx-node": "^1.12.23",
"strip-ansi": "^7.1.2",
@@ -1,5 +1,5 @@
import { spawn, spawnSync } from 'node:child_process'
import { closeSync, existsSync, openSync, readFileSync } from 'node:fs'
import { existsSync, readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import path from 'node:path'
import { loadConfig, resolvePaseoHome } from '@getpaseo/server'
@@ -347,64 +347,58 @@ export async function startLocalDaemonDetached(
const paseoHome = resolvePaseoHome(childEnv)
const logPath = path.join(paseoHome, DAEMON_LOG_FILENAME)
const daemonRunnerEntry = resolveDaemonRunnerEntry()
const logFd = openSync(logPath, 'a')
const child = spawn(
process.execPath,
[...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)],
{
detached: true,
env: childEnv,
stdio: ['ignore', 'ignore', 'ignore'],
}
)
try {
const child = spawn(
process.execPath,
[...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)],
{
detached: true,
env: childEnv,
stdio: ['ignore', logFd, logFd],
}
)
child.unref()
child.unref()
const startup = await new Promise<DetachedStartupResult>((resolve) => {
let settled = false
const startup = await new Promise<DetachedStartupResult>((resolve) => {
let settled = false
const finish = (value: DetachedStartupResult) => {
if (settled) return
settled = true
resolve(value)
}
const finish = (value: DetachedStartupResult) => {
if (settled) return
settled = true
resolve(value)
}
const timer = setTimeout(() => finish(startupReady()), DETACHED_STARTUP_GRACE_MS)
const timer = setTimeout(() => finish(startupReady()), DETACHED_STARTUP_GRACE_MS)
child.once('error', (error) => {
clearTimeout(timer)
finish(startupExited({ code: null, signal: null, error }))
})
child.once('exit', (code, signal) => {
clearTimeout(timer)
finish(startupExited({ code, signal }))
})
child.once('error', (error) => {
clearTimeout(timer)
finish(startupExited({ code: null, signal: null, error }))
})
if (startup.exitedEarly) {
const reason = startup.error
? startup.error.message
: `exit code ${startup.code ?? 'unknown'}${startup.signal ? ` (${startup.signal})` : ''}`
const recentLogs = tailFile(logPath)
throw new Error(
[
`Daemon failed to start in background (${reason}).`,
recentLogs ? `Recent daemon logs:\n${recentLogs}` : null,
]
.filter(Boolean)
.join('\n\n')
)
}
child.once('exit', (code, signal) => {
clearTimeout(timer)
finish(startupExited({ code, signal }))
})
})
return {
pid: child.pid ?? null,
logPath,
}
} finally {
closeSync(logFd)
if (startup.exitedEarly) {
const reason = startup.error
? startup.error.message
: `exit code ${startup.code ?? 'unknown'}${startup.signal ? ` (${startup.signal})` : ''}`
const recentLogs = tailFile(logPath)
throw new Error(
[
`Daemon failed to start in background (${reason}).`,
recentLogs ? `Recent daemon logs:\n${recentLogs}` : null,
]
.filter(Boolean)
.join('\n\n')
)
}
return {
pid: child.pid ?? null,
logPath,
}
}
+1
View File
@@ -93,6 +93,7 @@
"sherpa-onnx-node": "^1.12.23",
"strip-ansi": "^7.1.2",
"tiny-invariant": "^1.3.3",
"rotating-file-stream": "^3.2.9",
"uuid": "^9.0.1",
"ws": "^8.14.2",
"zod": "^3.23.8",
+1 -1
View File
@@ -26,7 +26,7 @@ async function main() {
try {
paseoHome = resolvePaseoHome();
const persistedConfig = loadPersistedConfig(paseoHome);
logger = createRootLogger(persistedConfig);
logger = createRootLogger(persistedConfig, { paseoHome });
config = loadConfig(paseoHome);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
+193 -69
View File
@@ -1,108 +1,232 @@
import path from "node:path";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { resolveLogConfig } from "./logger.js";
import type { PersistedConfig } from "./persisted-config.js";
describe("resolveLogConfig", () => {
const originalEnv = process.env;
const paseoHome = "/tmp/paseo-logger-tests";
beforeEach(() => {
process.env = { ...originalEnv };
delete process.env.PASEO_LOG;
delete process.env.PASEO_LOG_FORMAT;
delete process.env.PASEO_LOG_CONSOLE_LEVEL;
delete process.env.PASEO_LOG_FILE_LEVEL;
delete process.env.PASEO_LOG_FILE_PATH;
delete process.env.PASEO_LOG_FILE_ROTATE_SIZE;
delete process.env.PASEO_LOG_FILE_ROTATE_COUNT;
});
afterEach(() => {
process.env = originalEnv;
});
it("returns defaults when no config or env vars", () => {
const result = resolveLogConfig(undefined);
expect(result).toEqual({ level: "debug", format: "pretty" });
});
it("uses config.json values over defaults", () => {
const config: PersistedConfig = {
log: {
level: "debug",
format: "json",
},
};
const result = resolveLogConfig(config);
expect(result).toEqual({ level: "debug", format: "json" });
});
it("uses env PASEO_LOG over config.json level", () => {
process.env.PASEO_LOG = "warn";
const config: PersistedConfig = {
log: {
level: "debug",
format: "json",
},
};
const result = resolveLogConfig(config);
expect(result).toEqual({ level: "warn", format: "json" });
});
it("uses env PASEO_LOG_FORMAT over config.json format", () => {
process.env.PASEO_LOG_FORMAT = "pretty";
const config: PersistedConfig = {
log: {
level: "debug",
format: "json",
},
};
const result = resolveLogConfig(config);
expect(result).toEqual({ level: "debug", format: "pretty" });
});
it("env vars override both config.json and defaults", () => {
process.env.PASEO_LOG = "error";
process.env.PASEO_LOG_FORMAT = "json";
const config: PersistedConfig = {
log: {
it("returns dual-sink defaults when no config or env vars", () => {
const result = resolveLogConfig(undefined, { paseoHome });
expect(result).toEqual({
level: "trace",
console: {
level: "info",
format: "pretty",
},
};
const result = resolveLogConfig(config);
expect(result).toEqual({ level: "error", format: "json" });
file: {
level: "trace",
path: path.join(paseoHome, "daemon.log"),
rotate: {
maxSize: "10m",
maxFiles: 2,
},
},
});
});
it("handles partial config - level only", () => {
it("uses config.json destination-specific values over defaults", () => {
const config: PersistedConfig = {
log: {
console: {
level: "warn",
format: "json",
},
file: {
level: "debug",
path: "/tmp/custom.log",
rotate: {
maxSize: "25m",
maxFiles: 5,
},
},
},
};
const result = resolveLogConfig(config, { paseoHome });
expect(result).toEqual({
level: "debug",
console: {
level: "warn",
format: "json",
},
file: {
level: "debug",
path: "/tmp/custom.log",
rotate: {
maxSize: "25m",
maxFiles: 5,
},
},
});
});
it("uses env vars over config.json values", () => {
process.env.PASEO_LOG_CONSOLE_LEVEL = "error";
process.env.PASEO_LOG_FILE_LEVEL = "fatal";
process.env.PASEO_LOG_FORMAT = "json";
process.env.PASEO_LOG_FILE_PATH = "logs/daemon-custom.log";
process.env.PASEO_LOG_FILE_ROTATE_SIZE = "15m";
process.env.PASEO_LOG_FILE_ROTATE_COUNT = "4";
const config: PersistedConfig = {
log: {
console: {
level: "info",
format: "pretty",
},
file: {
level: "trace",
path: "/tmp/will-be-overridden.log",
rotate: {
maxSize: "30m",
maxFiles: 8,
},
},
},
};
const result = resolveLogConfig(config, { paseoHome });
expect(result).toEqual({
level: "error",
console: {
level: "error",
format: "json",
},
file: {
level: "fatal",
path: path.resolve(paseoHome, "logs/daemon-custom.log"),
rotate: {
maxSize: "15m",
maxFiles: 4,
},
},
});
});
it("keeps backwards compatibility for legacy log.level and log.format", () => {
const config: PersistedConfig = {
log: {
level: "warn",
},
};
const result = resolveLogConfig(config);
expect(result).toEqual({ level: "warn", format: "pretty" });
});
it("handles partial config - format only", () => {
const config: PersistedConfig = {
log: {
format: "json",
},
};
const result = resolveLogConfig(config);
expect(result).toEqual({ level: "debug", format: "json" });
const result = resolveLogConfig(config, { paseoHome });
expect(result).toEqual({
level: "warn",
console: {
level: "warn",
format: "json",
},
file: {
level: "warn",
path: path.join(paseoHome, "daemon.log"),
rotate: {
maxSize: "10m",
maxFiles: 2,
},
},
});
});
it("handles empty log object in config", () => {
it("keeps backwards compatibility for legacy env vars", () => {
process.env.PASEO_LOG = "error";
process.env.PASEO_LOG_FORMAT = "json";
const result = resolveLogConfig(undefined, { paseoHome });
expect(result).toEqual({
level: "error",
console: {
level: "error",
format: "json",
},
file: {
level: "error",
path: path.join(paseoHome, "daemon.log"),
rotate: {
maxSize: "10m",
maxFiles: 2,
},
},
});
});
it("supports partial destination config and retains defaults", () => {
const config: PersistedConfig = {
log: {},
log: {
console: {
level: "warn",
},
},
};
const result = resolveLogConfig(config);
expect(result).toEqual({ level: "debug", format: "pretty" });
const result = resolveLogConfig(config, { paseoHome });
expect(result).toEqual({
level: "trace",
console: {
level: "warn",
format: "pretty",
},
file: {
level: "trace",
path: path.join(paseoHome, "daemon.log"),
rotate: {
maxSize: "10m",
maxFiles: 2,
},
},
});
});
it("supports all log levels", () => {
const levels: Array<"trace" | "debug" | "info" | "warn" | "error" | "fatal"> =
["trace", "debug", "info", "warn", "error", "fatal"];
it("ignores invalid rotate count env var and falls back to config value", () => {
process.env.PASEO_LOG_FILE_ROTATE_COUNT = "0";
const config: PersistedConfig = {
log: {
file: {
rotate: {
maxFiles: 7,
},
},
},
};
const result = resolveLogConfig(config, { paseoHome });
expect(result.file.rotate.maxFiles).toBe(7);
});
it("supports all log levels for destination-specific env vars", () => {
const levels: Array<"trace" | "debug" | "info" | "warn" | "error" | "fatal"> = [
"trace",
"debug",
"info",
"warn",
"error",
"fatal",
];
for (const level of levels) {
process.env.PASEO_LOG = level;
const result = resolveLogConfig(undefined);
process.env.PASEO_LOG_CONSOLE_LEVEL = level;
process.env.PASEO_LOG_FILE_LEVEL = level;
const result = resolveLogConfig(undefined, { paseoHome });
expect(result.console.level).toBe(level);
expect(result.file.level).toBe(level);
expect(result.level).toBe(level);
}
});
+224 -25
View File
@@ -1,49 +1,248 @@
import { mkdirSync } from "node:fs";
import path from "node:path";
import pino from "pino";
import pretty from "pino-pretty";
import { createStream as createRotatingFileStream } from "rotating-file-stream";
import type { PersistedConfig } from "./persisted-config.js";
import { resolvePaseoHome } from "./paseo-home.js";
export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal";
export type LogFormat = "pretty" | "json";
export interface ResolvedLogConfig {
level: LogLevel;
format: LogFormat;
console: {
level: LogLevel;
format: LogFormat;
};
file: {
level: LogLevel;
path: string;
rotate: {
maxSize: string;
maxFiles: number;
};
};
}
type LegacyLogConfig = {
level?: LogLevel;
format?: LogFormat;
};
type LoggerConfigInput = PersistedConfig | LegacyLogConfig | undefined;
type ResolveLogConfigOptions = {
paseoHome?: string;
env?: NodeJS.ProcessEnv;
};
const LOG_LEVELS: LogLevel[] = ["trace", "debug", "info", "warn", "error", "fatal"];
const LOG_FORMATS: LogFormat[] = ["pretty", "json"];
const LOG_LEVEL_PRIORITIES: Record<LogLevel, number> = {
trace: 10,
debug: 20,
info: 30,
warn: 40,
error: 50,
fatal: 60,
};
const DEFAULT_CONSOLE_LEVEL: LogLevel = "info";
const DEFAULT_CONSOLE_FORMAT: LogFormat = "pretty";
const DEFAULT_FILE_LEVEL: LogLevel = "trace";
const DEFAULT_FILE_ROTATE_SIZE = "10m";
const DEFAULT_FILE_ROTATE_MAX_FILES = 2;
const DEFAULT_DAEMON_LOG_FILENAME = "daemon.log";
function parseLogLevel(value: string | undefined): LogLevel | undefined {
if (!value || !LOG_LEVELS.includes(value as LogLevel)) {
return undefined;
}
return value as LogLevel;
}
function parseLogFormat(value: string | undefined): LogFormat | undefined {
if (!value || !LOG_FORMATS.includes(value as LogFormat)) {
return undefined;
}
return value as LogFormat;
}
function parsePositiveInteger(value: string | undefined): number | undefined {
if (!value || value.trim().length === 0) {
return undefined;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
return undefined;
}
return parsed;
}
function resolveFilePath(paseoHome: string, configuredPath: string | undefined): string {
const fallback = path.join(paseoHome, DEFAULT_DAEMON_LOG_FILENAME);
if (!configuredPath) {
return fallback;
}
if (path.isAbsolute(configuredPath)) {
return configuredPath;
}
return path.resolve(paseoHome, configuredPath);
}
function minLogLevel(levels: LogLevel[]): LogLevel {
let minLevel = levels[0];
for (const level of levels) {
if (LOG_LEVEL_PRIORITIES[level] < LOG_LEVEL_PRIORITIES[minLevel]) {
minLevel = level;
}
}
return minLevel;
}
function resolveConfiguredPaseoHome(
options: ResolveLogConfigOptions | undefined
): string {
if (options?.paseoHome) {
return options.paseoHome;
}
return resolvePaseoHome(options?.env ?? process.env);
}
function normalizeLoggerConfigInput(config: LoggerConfigInput): PersistedConfig | undefined {
if (!config) {
return undefined;
}
if ("log" in config) {
return config as PersistedConfig;
}
if ("level" in config || "format" in config) {
const legacy = config as LegacyLogConfig;
return {
log: {
...(legacy.level ? { level: legacy.level } : {}),
...(legacy.format ? { format: legacy.format } : {}),
},
};
}
return config as PersistedConfig;
}
function toRotatingFileStreamSize(size: string): string {
const trimmed = size.trim();
const match = trimmed.match(/^(\d+)\s*([bBkKmMgG])?$/);
if (!match) {
return trimmed;
}
const value = match[1];
const unit = (match[2] ?? "M").toUpperCase();
return `${value}${unit}`;
}
export function resolveLogConfig(
persistedConfig: PersistedConfig | undefined
configInput: LoggerConfigInput,
options?: ResolveLogConfigOptions
): ResolvedLogConfig {
const envLevel = process.env.PASEO_LOG as LogLevel | undefined;
const envFormat = process.env.PASEO_LOG_FORMAT as LogFormat | undefined;
const persistedConfig = normalizeLoggerConfigInput(configInput);
const env = options?.env ?? process.env;
const paseoHome = resolveConfiguredPaseoHome(options);
const persistedLog = persistedConfig?.log;
const level: LogLevel =
envLevel ?? persistedConfig?.log?.level ?? "debug";
const format: LogFormat =
envFormat ?? persistedConfig?.log?.format ?? "pretty";
const envGlobalLevel = parseLogLevel(env.PASEO_LOG);
const persistedGlobalLevel = persistedLog?.level;
const consoleLevel: LogLevel =
parseLogLevel(env.PASEO_LOG_CONSOLE_LEVEL) ??
envGlobalLevel ??
persistedLog?.console?.level ??
persistedGlobalLevel ??
DEFAULT_CONSOLE_LEVEL;
return { level, format };
const fileLevel: LogLevel =
parseLogLevel(env.PASEO_LOG_FILE_LEVEL) ??
envGlobalLevel ??
persistedLog?.file?.level ??
persistedGlobalLevel ??
DEFAULT_FILE_LEVEL;
const consoleFormat: LogFormat =
parseLogFormat(env.PASEO_LOG_FORMAT) ??
persistedLog?.console?.format ??
persistedLog?.format ??
DEFAULT_CONSOLE_FORMAT;
const filePath = resolveFilePath(
paseoHome,
env.PASEO_LOG_FILE_PATH ?? persistedLog?.file?.path
);
const rotateMaxSize =
env.PASEO_LOG_FILE_ROTATE_SIZE?.trim() ||
persistedLog?.file?.rotate?.maxSize ||
DEFAULT_FILE_ROTATE_SIZE;
const rotateMaxFiles =
parsePositiveInteger(env.PASEO_LOG_FILE_ROTATE_COUNT) ??
persistedLog?.file?.rotate?.maxFiles ??
DEFAULT_FILE_ROTATE_MAX_FILES;
return {
level: minLogLevel([consoleLevel, fileLevel]),
console: {
level: consoleLevel,
format: consoleFormat,
},
file: {
level: fileLevel,
path: filePath,
rotate: {
maxSize: rotateMaxSize,
maxFiles: rotateMaxFiles,
},
},
};
}
export function createRootLogger(
persistedConfig: PersistedConfig | undefined
configInput: LoggerConfigInput,
options?: ResolveLogConfigOptions
): pino.Logger {
const config = resolveLogConfig(persistedConfig);
const config = resolveLogConfig(configInput, options);
const transport =
config.format === "pretty"
? {
target: "pino-pretty",
options: {
colorize: true,
singleLine: true,
ignore: "pid,hostname",
},
}
: undefined;
mkdirSync(path.dirname(config.file.path), { recursive: true });
return pino({
level: config.level,
transport,
const consoleStream =
config.console.format === "pretty"
? pretty({
colorize: true,
singleLine: true,
ignore: "pid,hostname",
})
: pino.destination({ dest: 1, sync: false });
const fileStream = createRotatingFileStream(path.basename(config.file.path), {
path: path.dirname(config.file.path),
size: toRotatingFileStreamSize(config.file.rotate.maxSize),
maxFiles: config.file.rotate.maxFiles,
});
return pino(
{ level: config.level },
pino.multistream([
{ level: config.console.level, stream: consoleStream },
{ level: config.file.level, stream: fileStream },
])
);
}
export function createChildLogger(parent: pino.Logger, name: string): pino.Logger {
@@ -57,3 +57,53 @@ describe("PersistedConfigSchema agent provider runtime settings", () => {
expect(result.success).toBe(false);
});
});
describe("PersistedConfigSchema logging config", () => {
test("accepts destination-specific logging config", () => {
const parsed = PersistedConfigSchema.parse({
log: {
console: {
level: "info",
format: "pretty",
},
file: {
level: "trace",
path: "daemon.log",
rotate: {
maxSize: "10m",
maxFiles: 2,
},
},
},
});
expect(parsed.log?.console?.level).toBe("info");
expect(parsed.log?.file?.level).toBe("trace");
expect(parsed.log?.file?.rotate?.maxFiles).toBe(2);
});
test("accepts legacy logging config fields", () => {
const parsed = PersistedConfigSchema.parse({
log: {
level: "debug",
format: "json",
},
});
expect(parsed.log?.level).toBe("debug");
expect(parsed.log?.format).toBe("json");
});
test("rejects unknown logging config fields", () => {
const result = PersistedConfigSchema.safeParse({
log: {
console: {
level: "info",
color: "red",
},
},
});
expect(result.success).toBe(false);
});
});
+28 -3
View File
@@ -4,12 +4,37 @@ import { z } from "zod";
import { AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js";
import { AgentProviderRuntimeSettingsMapSchema } from "./agent/provider-launch-config.js";
const LogLevelSchema = z.enum(["trace", "debug", "info", "warn", "error", "fatal"]);
const LogFormatSchema = z.enum(["pretty", "json"]);
const LogConfigSchema = z
.object({
level: z
.enum(["trace", "debug", "info", "warn", "error", "fatal"])
// Legacy global log settings (kept for backwards compatibility).
level: LogLevelSchema.optional(),
format: LogFormatSchema.optional(),
console: z
.object({
level: LogLevelSchema.optional(),
format: LogFormatSchema.optional(),
})
.strict()
.optional(),
file: z
.object({
level: LogLevelSchema.optional(),
path: z.string().min(1).optional(),
rotate: z
.object({
maxSize: z.string().min(1).optional(),
maxFiles: z.number().int().positive().optional(),
})
.strict()
.optional(),
})
.strict()
.optional(),
format: z.enum(["pretty", "json"]).optional(),
})
.strict();
@@ -320,6 +320,45 @@
"pretty",
"json"
]
},
"console": {
"type": "object",
"properties": {
"level": {
"$ref": "#/definitions/PaseoConfigV1/properties/log/properties/level"
},
"format": {
"$ref": "#/definitions/PaseoConfigV1/properties/log/properties/format"
}
},
"additionalProperties": false
},
"file": {
"type": "object",
"properties": {
"level": {
"$ref": "#/definitions/PaseoConfigV1/properties/log/properties/level"
},
"path": {
"type": "string",
"minLength": 1
},
"rotate": {
"type": "object",
"properties": {
"maxSize": {
"type": "string",
"minLength": 1
},
"maxFiles": {
"type": "integer",
"exclusiveMinimum": 0
}
},
"additionalProperties": false
}
},
"additionalProperties": false
}
},
"additionalProperties": false
@@ -168,12 +168,61 @@ docker run --rm -i \\
</p>
</section>
<section className="space-y-4">
<h2 className="text-xl font-medium">Logging</h2>
<p className="text-white/60 leading-relaxed">
Daemon logging uses separate console and file sinks by default:
</p>
<ul className="text-white/60 space-y-2 list-disc list-inside">
<li>
Console: <code className="font-mono">info</code> and above
</li>
<li>
File (<code className="font-mono">$PASEO_HOME/daemon.log</code>):{' '}
<code className="font-mono">trace</code> and above
</li>
<li>
File rotation: <code className="font-mono">10m</code> max file size,{' '}
<code className="font-mono">2</code> retained files total (active + 1 rotated)
</li>
</ul>
<pre className="bg-card border border-border rounded-lg p-4 font-mono text-sm overflow-x-auto text-white/80">
{`{
"log": {
"console": {
"level": "info",
"format": "pretty"
},
"file": {
"level": "trace",
"path": "daemon.log",
"rotate": {
"maxSize": "10m",
"maxFiles": 2
}
}
}
}`}
</pre>
<p className="text-white/60 leading-relaxed">
Legacy fields <code className="font-mono">log.level</code> and{' '}
<code className="font-mono">log.format</code> are still supported and map to the new
destination settings.
</p>
</section>
<section className="space-y-4">
<h2 className="text-xl font-medium">Common env vars</h2>
<ul className="text-white/60 space-y-2 list-disc list-inside">
<li><code className="font-mono">PASEO_HOME</code> — set Paseo home directory</li>
<li><code className="font-mono">PASEO_LISTEN</code> — override <code className="font-mono">daemon.listen</code></li>
<li><code className="font-mono">PASEO_ALLOWED_HOSTS</code> — override/extend <code className="font-mono">daemon.allowedHosts</code></li>
<li><code className="font-mono">PASEO_LOG_CONSOLE_LEVEL</code> — override <code className="font-mono">log.console.level</code></li>
<li><code className="font-mono">PASEO_LOG_FILE_LEVEL</code> — override <code className="font-mono">log.file.level</code></li>
<li><code className="font-mono">PASEO_LOG_FILE_PATH</code> — override <code className="font-mono">log.file.path</code></li>
<li><code className="font-mono">PASEO_LOG_FILE_ROTATE_SIZE</code> — override <code className="font-mono">log.file.rotate.maxSize</code></li>
<li><code className="font-mono">PASEO_LOG_FILE_ROTATE_COUNT</code> — override <code className="font-mono">log.file.rotate.maxFiles</code></li>
<li><code className="font-mono">PASEO_LOG</code>, <code className="font-mono">PASEO_LOG_FORMAT</code> — legacy log overrides (still supported)</li>
<li><code className="font-mono">OPENAI_API_KEY</code> — override OpenAI provider key</li>
<li><code className="font-mono">PASEO_VOICE_LLM_PROVIDER</code> — override voice LLM provider (<code className="font-mono">claude</code>, <code className="font-mono">codex</code>, <code className="font-mono">opencode</code>)</li>
<li><code className="font-mono">PASEO_DICTATION_STT_PROVIDER</code>, <code className="font-mono">PASEO_VOICE_STT_PROVIDER</code>, <code className="font-mono">PASEO_VOICE_TTS_PROVIDER</code> — override voice provider selection (<code className="font-mono">local</code> or <code className="font-mono">openai</code>)</li>