Daemon hardening epic

This commit is contained in:
Mohamed Boudra
2026-05-05 18:19:45 +07:00
parent 1571f002c7
commit 0cb9da17cd
27 changed files with 806 additions and 710 deletions

View File

@@ -39,7 +39,7 @@ import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
import type { VoiceCallerContext, VoiceSpeakHandler } from "../voice-types.js";
import { expandUserPath, isSameOrDescendantPath, resolvePathFromBase } from "../path-utils.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js";
import { captureTerminalLines } from "../../terminal/terminal.js";
import { captureTerminalLines } from "../../terminal/terminal-capture.js";
import type {
AgentWorktreeSetupContinuation,
CreatePaseoWorktreeSetupContinuationInput,

View File

@@ -126,6 +126,7 @@ import type {
AgentProviderRuntimeSettingsMap,
ProviderOverride,
} from "./agent/provider-launch-config.js";
import type { PersistedConfig } from "./persisted-config.js";
import {
ScriptRouteStore,
createScriptProxyMiddleware,
@@ -201,6 +202,7 @@ export interface PaseoDaemonConfig {
downloadTokenTtlMs?: number;
agentProviderSettings?: AgentProviderRuntimeSettingsMap;
providerOverrides?: Record<string, ProviderOverride>;
log?: PersistedConfig["log"];
onLifecycleIntent?: (intent: DaemonLifecycleIntent) => void;
}

View File

@@ -3,7 +3,12 @@ import { resolvePaseoNodeEnv } from "./paseo-env.js";
import { z } from "zod";
import type { PaseoDaemonConfig } from "./bootstrap.js";
import { loadPersistedConfig } from "./persisted-config.js";
import {
loadPersistedConfig,
LogFormatSchema,
LogLevelSchema,
type PersistedConfig,
} from "./persisted-config.js";
import type { AgentProvider } from "./agent/agent-sdk-types.js";
import type {
AgentProviderRuntimeSettingsMap,
@@ -35,6 +40,14 @@ function parseBooleanEnv(value: string | undefined): boolean | undefined {
return undefined;
}
function normalizeLogEnv(value: string | undefined): string | undefined {
if (value === undefined) {
return undefined;
}
return value.trim().toLowerCase();
}
export type CliConfigOverrides = Partial<{
listen: string;
relayEnabled: boolean;
@@ -43,6 +56,24 @@ export type CliConfigOverrides = Partial<{
hostnames: HostnamesConfig;
}>;
function resolveLogConfigFromEnv(
env: NodeJS.ProcessEnv,
persisted: ReturnType<typeof loadPersistedConfig>,
): PersistedConfig["log"] {
const envLogLevel = LogLevelSchema.safeParse(normalizeLogEnv(env.PASEO_LOG_LEVEL));
const envLogFormat = LogFormatSchema.safeParse(normalizeLogEnv(env.PASEO_LOG_FORMAT));
if (!envLogLevel.success && !envLogFormat.success) {
return persisted.log;
}
return {
...persisted.log,
...(envLogLevel.success ? { level: envLogLevel.data } : {}),
...(envLogFormat.success ? { format: envLogFormat.data } : {}),
};
}
const OptionalVoiceLlmProviderSchema = z
.union([z.string(), z.null(), z.undefined()])
.transform((value): string | null =>
@@ -271,5 +302,6 @@ export function loadConfig(
voiceLlmModel: voiceLlm.model,
agentProviderSettings: extractAgentProviderSettings(providerOverrides),
providerOverrides,
log: resolveLogConfigFromEnv(env, persisted),
};
}

View File

@@ -6,7 +6,6 @@ import os from "node:os";
import { mkdtemp, rm } from "node:fs/promises";
import { Writable } from "node:stream";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { generateLocalPairingOffer } from "../pairing-offer.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
@@ -202,8 +201,9 @@ describe("ConnectionOfferV2 (daemon E2E)", () => {
const tempHome = await mkdtemp(path.join(os.tmpdir(), "paseo-offer-e2e-"));
const port = await getAvailablePort();
const indexPath = fileURLToPath(new URL("../index.ts", import.meta.url));
const tsxBin = path.resolve(process.cwd(), "../../node_modules/.bin/tsx");
const serverRoot = path.resolve(import.meta.dirname, "../../..");
const supervisorPath = path.join(serverRoot, "scripts/supervisor-entrypoint.ts");
const tsxBin = path.resolve(serverRoot, "../../node_modules/.bin/tsx");
const env = {
...process.env,
@@ -216,7 +216,7 @@ describe("ConnectionOfferV2 (daemon E2E)", () => {
};
const stdoutLines: string[] = [];
const proc = spawn(tsxBin, [indexPath, "--no-relay"], {
const proc = spawn(tsxBin, [supervisorPath, "--dev", "--no-relay"], {
env,
stdio: ["ignore", "pipe", "pipe"],
});

View File

@@ -2,7 +2,6 @@ import { createPaseoDaemon } from "./bootstrap.js";
import { loadConfig } from "./config.js";
import { resolvePaseoHome } from "./paseo-home.js";
import { createRootLogger } from "./logger.js";
import { loadPersistedConfig } from "./persisted-config.js";
import { acquirePidLock, PidLockError, releasePidLock, updatePidLock } from "./pid-lock.js";
import type { DaemonLifecycleIntent } from "./bootstrap.js";
@@ -24,9 +23,8 @@ interface BootstrapResult {
function bootstrapFromEnvironment(): BootstrapResult {
try {
const paseoHome = resolvePaseoHome();
const persistedConfig = loadPersistedConfig(paseoHome);
const logger = createRootLogger(persistedConfig, { paseoHome });
const config = loadConfig(paseoHome);
const logger = createRootLogger({ log: config.log }, { paseoHome, file: false });
return { paseoHome, logger, config };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);

View File

@@ -1,47 +1,82 @@
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, readFile, readdir, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { resolveLogConfig } from "./logger.js";
import { loadConfig } from "./config.js";
import type { PersistedConfig } from "./persisted-config.js";
const repoRoot = path.resolve(fileURLToPath(new URL("../../../..", import.meta.url)));
const loggerModuleUrl = new URL("./logger.ts", import.meta.url).href;
async function runLoggerFixture(source: string): Promise<{ stdout: string; stderr: string }> {
const tempDir = await mkdtemp(path.join(tmpdir(), "paseo-logger-fixture-"));
const runnerPath = path.join(tempDir, "runner.mjs");
await writeFile(
runnerPath,
`
import { createRootLogger } from ${JSON.stringify(loggerModuleUrl)};
${source}
`,
);
const child = spawn(process.execPath, ["--import", "tsx", runnerPath], {
cwd: repoRoot,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout?.setEncoding("utf8");
child.stderr?.setEncoding("utf8");
child.stdout?.on("data", (chunk) => {
stdout += chunk;
});
child.stderr?.on("data", (chunk) => {
stderr += chunk;
});
const code = await new Promise<number | null>((resolve, reject) => {
child.on("error", reject);
child.on("close", resolve);
});
expect(code, stderr).toBe(0);
return { stdout, stderr };
}
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 dual-sink defaults when no config or env vars", () => {
it("defaults to stdout JSON without file logging", () => {
const result = resolveLogConfig(undefined, { paseoHome });
expect(result).toEqual({
level: "debug",
level: "info",
console: {
level: "info",
format: "pretty",
},
file: {
level: "debug",
path: path.join(paseoHome, "daemon.log"),
rotate: {
maxSize: "10m",
maxFiles: 2,
},
format: "json",
},
});
});
it("uses config.json destination-specific values over defaults", () => {
it("keeps legacy level and format as stdout configuration", () => {
const result = resolveLogConfig({ level: "warn", format: "pretty" }, { paseoHome });
expect(result).toEqual({
level: "warn",
console: {
level: "warn",
format: "pretty",
},
});
});
it("enables file output only when log.file is present", () => {
const config: PersistedConfig = {
log: {
console: {
@@ -50,7 +85,7 @@ describe("resolveLogConfig", () => {
},
file: {
level: "debug",
path: "/tmp/custom.log",
path: "logs/programmatic.log",
rotate: {
maxSize: "25m",
maxFiles: 5,
@@ -58,9 +93,8 @@ describe("resolveLogConfig", () => {
},
},
};
const result = resolveLogConfig(config, { paseoHome });
expect(result).toEqual({
expect(resolveLogConfig(config, { paseoHome })).toEqual({
level: "debug",
console: {
level: "warn",
@@ -68,166 +102,98 @@ describe("resolveLogConfig", () => {
},
file: {
level: "debug",
path: "/tmp/custom.log",
rotate: {
maxSize: "25m",
maxFiles: 5,
},
path: path.join(paseoHome, "logs", "programmatic.log"),
},
});
});
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",
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("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: {
console: {
level: "warn",
},
},
};
const result = resolveLogConfig(config, { paseoHome });
expect(result).toEqual({
level: "debug",
console: {
level: "warn",
format: "pretty",
},
file: {
level: "debug",
path: path.join(paseoHome, "daemon.log"),
rotate: {
maxSize: "10m",
maxFiles: 2,
},
},
});
});
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_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);
}
});
});
describe("loadConfig logger config", () => {
it("applies log format env at the config boundary", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-logger-config-"));
const paseoHome = path.join(root, ".paseo");
await mkdir(paseoHome, { recursive: true });
await writeFile(
path.join(paseoHome, "config.json"),
JSON.stringify({ version: 1, log: { format: "json" } }),
);
const config = loadConfig(paseoHome, {
env: { PASEO_LOG_FORMAT: "pretty" },
});
expect(config.log?.format).toBe("pretty");
expect(resolveLogConfig(config, { paseoHome }).console.format).toBe("pretty");
});
});
describe("createRootLogger", () => {
it("writes JSON to stdout by default and does not initialize file logging", async () => {
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-logger-default-"));
const missingLogDir = path.join(paseoHome, "logs");
const { stdout } = await runLoggerFixture(`
const logger = createRootLogger(undefined, { paseoHome: ${JSON.stringify(paseoHome)} });
logger.info({ proof: "stdout-default" }, "default logger");
logger.flush();
`);
expect(stdout).toContain('"proof":"stdout-default"');
expect(stdout).toContain('"msg":"default logger"');
expect(existsSync(path.join(paseoHome, "daemon.log"))).toBe(false);
expect(existsSync(missingLogDir)).toBe(false);
});
it("writes to an explicit file target without creating rotation files", async () => {
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-logger-file-"));
const logPath = path.join(paseoHome, "logs", "programmatic.log");
await runLoggerFixture(`
const logger = createRootLogger(
{ log: { file: { path: ${JSON.stringify(logPath)} } } },
{ paseoHome: ${JSON.stringify(paseoHome)} },
);
logger.info({ proof: "file-explicit" }, "explicit file logger");
logger.flush();
`);
const logText = await readFile(logPath, "utf8");
const files = await readdir(path.dirname(logPath));
expect(logText).toContain('"proof":"file-explicit"');
expect(logText).toContain('"msg":"explicit file logger"');
expect(files).toEqual(["programmatic.log"]);
});
it("can disable file output for supervised workers", async () => {
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-logger-no-worker-file-"));
const logPath = path.join(paseoHome, "daemon.log");
const { stdout } = await runLoggerFixture(`
const logger = createRootLogger(
{ log: { file: { path: ${JSON.stringify(logPath)} } } },
{ paseoHome: ${JSON.stringify(paseoHome)}, file: false },
);
logger.info({ proof: "stdout-only" }, "worker logger");
logger.flush();
`);
expect(stdout).toContain('"proof":"stdout-only"');
expect(stdout).toContain('"msg":"worker logger"');
expect(existsSync(logPath)).toBe(false);
});
it("keeps pretty output available as a format choice", async () => {
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-logger-pretty-"));
const { stdout } = await runLoggerFixture(`
const logger = createRootLogger({ level: "info", format: "pretty" }, {
paseoHome: ${JSON.stringify(paseoHome)},
});
logger.info("pretty logger");
logger.flush();
`);
expect(stdout).toContain("pretty logger");
});
});

View File

@@ -1,8 +1,7 @@
import { existsSync, mkdirSync, readdirSync, renameSync, unlinkSync } from "node:fs";
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";
@@ -15,13 +14,9 @@ export interface ResolvedLogConfig {
level: LogLevel;
format: LogFormat;
};
file: {
file?: {
level: LogLevel;
path: string;
rotate: {
maxSize: string;
maxFiles: number;
};
};
}
@@ -34,11 +29,9 @@ type LoggerConfigInput = PersistedConfig | LegacyLogConfig | undefined;
interface ResolveLogConfigOptions {
paseoHome?: string;
env?: NodeJS.ProcessEnv;
file?: boolean;
}
const LOG_LEVELS: Set<LogLevel> = new Set(["trace", "debug", "info", "warn", "error", "fatal"]);
const LOG_FORMATS: Set<LogFormat> = new Set(["pretty", "json"]);
const LOG_LEVEL_PRIORITIES: Record<LogLevel, number> = {
trace: 10,
debug: 20,
@@ -49,10 +42,8 @@ const LOG_LEVEL_PRIORITIES: Record<LogLevel, number> = {
};
const DEFAULT_CONSOLE_LEVEL: LogLevel = "info";
const DEFAULT_CONSOLE_FORMAT: LogFormat = "pretty";
const DEFAULT_CONSOLE_FORMAT: LogFormat = "json";
const DEFAULT_FILE_LEVEL: LogLevel = "debug";
const DEFAULT_FILE_ROTATE_SIZE = "10m";
const DEFAULT_FILE_ROTATE_MAX_FILES = 2;
const DEFAULT_DAEMON_LOG_FILENAME = "daemon.log";
const REDACT_PATHS = [
"authorization",
@@ -69,33 +60,6 @@ const REDACT_PATHS = [
"req.headers.Sec-WebSocket-Protocol",
];
function parseLogLevel(value: string | undefined): LogLevel | undefined {
if (!value || !LOG_LEVELS.has(value as LogLevel)) {
return undefined;
}
return value as LogLevel;
}
function parseLogFormat(value: string | undefined): LogFormat | undefined {
if (!value || !LOG_FORMATS.has(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) {
@@ -125,7 +89,7 @@ function resolveConfiguredPaseoHome(options: ResolveLogConfigOptions | undefined
if (options?.paseoHome) {
return options.paseoHome;
}
return resolvePaseoHome(options?.env ?? process.env);
return resolvePaseoHome();
}
function normalizeLoggerConfigInput(config: LoggerConfigInput): PersistedConfig | undefined {
@@ -150,126 +114,50 @@ function normalizeLoggerConfigInput(config: LoggerConfigInput): PersistedConfig
return config as PersistedConfig;
}
function rotateOnRestart(filePath: string, maxFiles: number): void {
if (!existsSync(filePath)) return;
const dir = path.dirname(filePath);
const base = path.basename(filePath);
const now = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
const ts = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}`;
try {
renameSync(filePath, path.join(dir, `${ts}-00-${base}`));
} catch {
return;
}
// Clean up old rotated logs beyond maxFiles.
// Both our restart-rotated files (YYYYMMDD-HHMM-00-daemon.log) and
// rotating-file-stream's size-rotated files (YYYYMMDD-HHMM-NN-daemon.log)
// end with -${base} and sort chronologically by name.
const rotatedFiles = readdirSync(dir)
.filter((f) => f.endsWith(`-${base}`) && f !== base)
.sort()
.toReversed();
for (const file of rotatedFiles.slice(maxFiles)) {
try {
unlinkSync(path.join(dir, file));
} catch {}
}
}
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}`;
}
interface LogLevelResolution {
consoleLevel: LogLevel;
fileLevel: LogLevel;
fileLevel?: LogLevel;
consoleFormat: LogFormat;
}
function resolveLogLevelsAndFormat(
env: NodeJS.ProcessEnv,
persistedLog: NonNullable<ReturnType<typeof normalizeLoggerConfigInput>>["log"] | undefined,
): LogLevelResolution {
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;
const fileLevel: LogLevel =
parseLogLevel(env.PASEO_LOG_FILE_LEVEL) ??
envGlobalLevel ??
persistedLog?.file?.level ??
persistedGlobalLevel ??
DEFAULT_FILE_LEVEL;
persistedLog?.console?.level ?? persistedGlobalLevel ?? DEFAULT_CONSOLE_LEVEL;
const fileLevel = persistedLog?.file
? (persistedLog.file.level ?? persistedGlobalLevel ?? DEFAULT_FILE_LEVEL)
: undefined;
const consoleFormat: LogFormat =
parseLogFormat(env.PASEO_LOG_FORMAT) ??
persistedLog?.console?.format ??
persistedLog?.format ??
DEFAULT_CONSOLE_FORMAT;
persistedLog?.console?.format ?? persistedLog?.format ?? DEFAULT_CONSOLE_FORMAT;
return { consoleLevel, fileLevel, consoleFormat };
}
interface RotateResolution {
maxSize: string;
maxFiles: number;
}
function resolveRotateConfig(
env: NodeJS.ProcessEnv,
persistedLog: NonNullable<ReturnType<typeof normalizeLoggerConfigInput>>["log"] | undefined,
): RotateResolution {
return {
maxSize:
env.PASEO_LOG_FILE_ROTATE_SIZE?.trim() ||
persistedLog?.file?.rotate?.maxSize ||
DEFAULT_FILE_ROTATE_SIZE,
maxFiles:
parsePositiveInteger(env.PASEO_LOG_FILE_ROTATE_COUNT) ??
persistedLog?.file?.rotate?.maxFiles ??
DEFAULT_FILE_ROTATE_MAX_FILES,
};
}
export function resolveLogConfig(
configInput: LoggerConfigInput,
options?: ResolveLogConfigOptions,
): ResolvedLogConfig {
const persistedConfig = normalizeLoggerConfigInput(configInput);
const env = options?.env ?? process.env;
const paseoHome = resolveConfiguredPaseoHome(options);
const persistedLog = persistedConfig?.log;
const { consoleLevel, fileLevel, consoleFormat } = resolveLogLevelsAndFormat(env, persistedLog);
const filePath = resolveFilePath(paseoHome, env.PASEO_LOG_FILE_PATH ?? persistedLog?.file?.path);
const rotate = resolveRotateConfig(env, persistedLog);
const { consoleLevel, fileLevel, consoleFormat } = resolveLogLevelsAndFormat(persistedLog);
const file =
options?.file !== false && persistedLog?.file
? {
level: fileLevel ?? DEFAULT_FILE_LEVEL,
path: resolveFilePath(paseoHome, persistedLog.file.path),
}
: undefined;
return {
level: minLogLevel([consoleLevel, fileLevel]),
level: minLogLevel(file ? [consoleLevel, file.level] : [consoleLevel]),
console: {
level: consoleLevel,
format: consoleFormat,
},
file: {
level: fileLevel,
path: filePath,
rotate,
},
...(file ? { file } : {}),
};
}
@@ -278,32 +166,26 @@ export function createRootLogger(
options?: ResolveLogConfigOptions,
): pino.Logger {
const config = resolveLogConfig(configInput, options);
if (config.file) {
mkdirSync(path.dirname(config.file.path), { recursive: true });
}
mkdirSync(path.dirname(config.file.path), { recursive: true });
const consoleStream =
const stream =
config.console.format === "pretty"
? pretty({
colorize: true,
singleLine: true,
ignore: "pid,hostname",
destination: config.file?.path ?? 1,
})
: pino.destination({ dest: 1, sync: false });
rotateOnRestart(config.file.path, config.file.rotate.maxFiles);
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,
});
: pino.destination({ dest: config.file?.path ?? 1, sync: false });
return pino(
{ level: config.level, redact: { paths: REDACT_PATHS, remove: true } },
pino.multistream([
{ level: config.console.level, stream: consoleStream },
{ level: config.file.level, stream: fileStream },
]),
{
level: config.file?.level ?? config.console.level,
redact: { paths: REDACT_PATHS, remove: true },
},
stream,
);
}

View File

@@ -9,8 +9,8 @@ import {
} from "./agent/provider-launch-config.js";
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
const LogLevelSchema = z.enum(["trace", "debug", "info", "warn", "error", "fatal"]);
const LogFormatSchema = z.enum(["pretty", "json"]);
export const LogLevelSchema = z.enum(["trace", "debug", "info", "warn", "error", "fatal"]);
export const LogFormatSchema = z.enum(["pretty", "json"]);
const LogConfigSchema = z
.object({

View File

@@ -0,0 +1,71 @@
import stripAnsi from "strip-ansi";
import type { TerminalCell } from "../shared/messages.js";
import type { TerminalSession } from "./terminal.js";
export interface CaptureTerminalLinesOptions {
start?: number;
end?: number;
stripAnsi?: boolean;
}
export interface CaptureTerminalLinesResult {
lines: string[];
totalLines: number;
}
function cellsToPlainText(cells: TerminalCell[], options: { stripAnsi: boolean }): string {
const text = cells
.map((cell) => cell.char)
.join("")
.trimEnd();
return options.stripAnsi ? stripAnsi(text) : text;
}
function resolveCaptureLineIndex(
lineNumber: number | undefined,
totalLines: number,
fallback: "start" | "end",
): number {
if (totalLines === 0) {
return fallback === "start" ? 0 : -1;
}
const defaultIndex = fallback === "start" ? 0 : totalLines - 1;
if (typeof lineNumber !== "number") {
return defaultIndex;
}
const resolvedIndex = lineNumber < 0 ? totalLines + lineNumber : lineNumber;
if (resolvedIndex < 0) {
return 0;
}
if (resolvedIndex >= totalLines) {
return totalLines - 1;
}
return resolvedIndex;
}
export function captureTerminalLines(
terminal: TerminalSession,
options: CaptureTerminalLinesOptions = {},
): CaptureTerminalLinesResult {
const state = terminal.getState();
const allLines = [...state.scrollback, ...state.grid].map((cells) =>
cellsToPlainText(cells, { stripAnsi: options.stripAnsi ?? true }),
);
const totalLines = allLines.length;
const startIndex = resolveCaptureLineIndex(options.start, totalLines, "start");
const endIndex = resolveCaptureLineIndex(options.end, totalLines, "end");
if (totalLines === 0 || startIndex > endIndex) {
return {
lines: [],
totalLines,
};
}
return {
lines: allLines.slice(startIndex, endIndex + 1),
totalLines,
};
}

View File

@@ -1,10 +0,0 @@
import { expect, it } from "vitest";
import { resolveTerminalBackend } from "./terminal-manager-factory.js";
it("uses the worker terminal backend by default", () => {
expect(resolveTerminalBackend({})).toBe("worker");
});
it("allows explicitly opting back into the in-process terminal backend", () => {
expect(resolveTerminalBackend({ PASEO_TERMINAL_BACKEND: "in-process" })).toBe("in-process");
});

View File

@@ -1,18 +1,6 @@
import { createTerminalManager, type TerminalManager } from "./terminal-manager.js";
import type { TerminalManager } from "./terminal-manager.js";
import { createWorkerTerminalManager } from "./worker-terminal-manager.js";
export type TerminalBackend = "in-process" | "worker";
export function resolveTerminalBackend(env: NodeJS.ProcessEnv = process.env): TerminalBackend {
return env.PASEO_TERMINAL_BACKEND === "in-process" ? "in-process" : "worker";
}
export function createConfiguredTerminalManager(options?: {
backend?: TerminalBackend;
}): TerminalManager {
const backend = options?.backend ?? resolveTerminalBackend();
if (backend === "worker") {
return createWorkerTerminalManager();
}
return createTerminalManager();
export function createConfiguredTerminalManager(): TerminalManager {
return createWorkerTerminalManager();
}

View File

@@ -1,5 +1,5 @@
import { createTerminalManager } from "./terminal-manager.js";
import { captureTerminalLines } from "./terminal.js";
import { captureTerminalLines } from "./terminal-capture.js";
import type { TerminalSession } from "./terminal.js";
import type {
TerminalWorkerRequest,

View File

@@ -5,7 +5,7 @@ import type {
TerminalStateSnapshot,
} from "./terminal.js";
import type { TerminalState } from "../shared/messages.js";
import type { CaptureTerminalLinesResult } from "./terminal.js";
import type { CaptureTerminalLinesResult } from "./terminal-capture.js";
export interface WorkerTerminalInfo {
id: string;

View File

@@ -6,9 +6,13 @@ import { tmpdir, userInfo } from "node:os";
import { basename, dirname, join } from "node:path";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import stripAnsi from "strip-ansi";
import { createExternalProcessEnv } from "../server/paseo-env.js";
import type { TerminalCell, TerminalState } from "../shared/messages.js";
export {
captureTerminalLines,
type CaptureTerminalLinesOptions,
type CaptureTerminalLinesResult,
} from "./terminal-capture.js";
const { Terminal } = xterm;
const require = createRequire(import.meta.url);
@@ -96,17 +100,6 @@ interface BuildTerminalEnvironmentInput {
zshShellIntegrationDir?: string;
}
export interface CaptureTerminalLinesOptions {
start?: number;
end?: number;
stripAnsi?: boolean;
}
export interface CaptureTerminalLinesResult {
lines: string[];
totalLines: number;
}
interface EnsureNodePtySpawnHelperExecutableOptions {
packageRoot?: string;
platform?: NodeJS.Platform;
@@ -523,63 +516,6 @@ function extractLastOutputLinesFromText(text: string, limit: number): string[] {
return lines.slice(-limit);
}
function cellsToPlainText(cells: TerminalCell[], options: { stripAnsi: boolean }): string {
const text = cells
.map((cell) => cell.char)
.join("")
.trimEnd();
return options.stripAnsi ? stripAnsi(text) : text;
}
function resolveCaptureLineIndex(
lineNumber: number | undefined,
totalLines: number,
fallback: "start" | "end",
): number {
if (totalLines === 0) {
return fallback === "start" ? 0 : -1;
}
const defaultIndex = fallback === "start" ? 0 : totalLines - 1;
if (typeof lineNumber !== "number") {
return defaultIndex;
}
const resolvedIndex = lineNumber < 0 ? totalLines + lineNumber : lineNumber;
if (resolvedIndex < 0) {
return 0;
}
if (resolvedIndex >= totalLines) {
return totalLines - 1;
}
return resolvedIndex;
}
export function captureTerminalLines(
terminal: TerminalSession,
options: CaptureTerminalLinesOptions = {},
): CaptureTerminalLinesResult {
const state = terminal.getState();
const allLines = [...state.scrollback, ...state.grid].map((cells) =>
cellsToPlainText(cells, { stripAnsi: options.stripAnsi ?? true }),
);
const totalLines = allLines.length;
const startIndex = resolveCaptureLineIndex(options.start, totalLines, "start");
const endIndex = resolveCaptureLineIndex(options.end, totalLines, "end");
if (totalLines === 0 || startIndex > endIndex) {
return {
lines: [],
totalLines,
};
}
return {
lines: allLines.slice(startIndex, endIndex + 1),
totalLines,
};
}
export async function createTerminal(options: CreateTerminalOptions): Promise<TerminalSession> {
const {
cwd,

View File

@@ -5,7 +5,6 @@ import { copyFile, rm, stat } from "fs/promises";
import { join, basename, dirname, resolve, sep } from "path";
import net from "node:net";
import { createHash } from "node:crypto";
import * as pty from "node-pty";
import stripAnsi from "strip-ansi";
import { buildStringCommandShellInvocation } from "./string-command-shell.js";
import { readPaseoConfigJson, resolvePaseoConfigPath } from "./paseo-config-file.js";
@@ -30,7 +29,6 @@ import { runGitCommand } from "./run-git-command.js";
import { spawnProcess } from "./spawn.js";
import { resolvePaseoHome } from "../server/paseo-home.js";
import { createExternalProcessEnv } from "../server/paseo-env.js";
import { ensureNodePtySpawnHelperExecutableForCurrentPlatform } from "../terminal/terminal.js";
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
const execFileAsync = promisify(execFile);
@@ -465,54 +463,29 @@ async function execSetupCommandStreamed(options: {
cwd: options.cwd,
});
const spawnWithPipes = () => {
const shellInvocation = buildStringCommandShellInvocation({ command: options.command });
const child = spawnProcess(shellInvocation.shell, shellInvocation.args, {
cwd: options.cwd,
env: options.env,
stdio: ["ignore", "pipe", "pipe"],
});
const shellInvocation = buildStringCommandShellInvocation({ command: options.command });
const child = spawnProcess(shellInvocation.shell, shellInvocation.args, {
cwd: options.cwd,
env: options.env,
stdio: ["ignore", "pipe", "pipe"],
});
child.stdout?.on("data", (chunk: Buffer | string) => {
emitOutput("stdout", chunk.toString());
});
child.stdout?.on("data", (chunk: Buffer | string) => {
emitOutput("stdout", chunk.toString());
});
child.stderr?.on("data", (chunk: Buffer | string) => {
emitOutput("stderr", chunk.toString());
});
child.stderr?.on("data", (chunk: Buffer | string) => {
emitOutput("stderr", chunk.toString());
});
child.on("error", (error) => {
emitOutput("stderr", error instanceof Error ? error.message : String(error));
finish(null);
});
child.on("close", (code) => {
finish(typeof code === "number" ? code : null);
});
};
try {
ensureNodePtySpawnHelperExecutableForCurrentPlatform();
const shellInvocation = buildStringCommandShellInvocation({ command: options.command });
const terminal = pty.spawn(shellInvocation.shell, shellInvocation.args, {
cwd: options.cwd,
env: options.env,
name: "xterm-color",
cols: 120,
rows: 30,
});
terminal.onData((data) => {
emitOutput("stdout", data);
});
terminal.onExit(({ exitCode }) => {
finish(typeof exitCode === "number" ? exitCode : null);
});
} catch (error) {
child.on("error", (error) => {
emitOutput("stderr", error instanceof Error ? error.message : String(error));
spawnWithPipes();
}
finish(null);
});
child.on("close", (code) => {
finish(typeof code === "number" ? code : null);
});
});
}