diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 4f3af21d73e..8a6ed6aff2b 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -112,6 +112,7 @@ Built-in commands available today: - `/tasks` lists active/recent background tasks for the current session. - `/context [list|detail|json]` explains how context is assembled. - `/export-session [path]` exports the current session to HTML. Alias: `/export`. +- `/export-trajectory [path]` exports a JSONL trajectory bundle for the current session. Alias: `/trajectory`. - `/whoami` shows your sender id. Alias: `/id`. - `/skill [input]` runs a skill by name. - `/allowlist [list|add|remove] ...` manages allowlist entries. Text-only. diff --git a/extensions/codex/src/app-server/run-attempt.ts b/extensions/codex/src/app-server/run-attempt.ts index 5dccb4460a4..f034c3b6f02 100644 --- a/extensions/codex/src/app-server/run-attempt.ts +++ b/extensions/codex/src/app-server/run-attempt.ts @@ -47,6 +47,12 @@ import { buildTurnStartParams, startOrResumeThread, } from "./thread-lifecycle.js"; +import { + createCodexTrajectoryRecorder, + normalizeCodexTrajectoryError, + recordCodexTrajectoryCompletion, + recordCodexTrajectoryContext, +} from "./trajectory.js"; import { mirrorCodexAppServerTranscript } from "./transcript-mirror.js"; let clientFactory = defaultCodexAppServerClientFactory; @@ -129,8 +135,16 @@ export async function runCodexAppServerAttempt( messages: historyMessages, ctx: hookContext, }); + const trajectoryRecorder = createCodexTrajectoryRecorder({ + attempt: params, + cwd: effectiveWorkspace, + developerInstructions: promptBuild.developerInstructions, + prompt: promptBuild.prompt, + tools: toolBridge.specs, + }); let client: CodexAppServerClient; let thread: CodexAppServerThreadBinding; + let trajectoryEndRecorded = false; try { ({ client, thread } = await withCodexStartupTimeout({ timeoutMs: params.timeoutMs, @@ -154,6 +168,20 @@ export async function runCodexAppServerAttempt( params.abortSignal?.removeEventListener("abort", abortFromUpstream); throw error; } + trajectoryRecorder?.recordEvent("session.started", { + sessionFile: params.sessionFile, + threadId: thread.threadId, + authProfileId: startupAuthProfileId, + workspaceDir: effectiveWorkspace, + toolCount: toolBridge.specs.length, + }); + recordCodexTrajectoryContext(trajectoryRecorder, { + attempt: params, + cwd: effectiveWorkspace, + developerInstructions: promptBuild.developerInstructions, + prompt: promptBuild.prompt, + tools: toolBridge.specs, + }); let projector: CodexAppServerEventProjector | undefined; let turnId: string | undefined; @@ -230,7 +258,23 @@ export async function runCodexAppServerAttempt( if (!call || call.threadId !== thread.threadId || call.turnId !== turnId) { return undefined; } - return toolBridge.handleToolCall(call) as Promise; + trajectoryRecorder?.recordEvent("tool.call", { + threadId: call.threadId, + turnId: call.turnId, + toolCallId: call.callId, + name: call.tool, + arguments: call.arguments, + }); + const response = await toolBridge.handleToolCall(call); + trajectoryRecorder?.recordEvent("tool.result", { + threadId: call.threadId, + turnId: call.turnId, + toolCallId: call.callId, + name: call.tool, + success: response.success, + contentItems: response.contentItems, + }); + return response as JsonValue; }); const llmInputEvent = { @@ -268,6 +312,14 @@ export async function runCodexAppServerAttempt( { timeoutMs: params.timeoutMs, signal: runAbortController.signal }, ); } catch (error) { + trajectoryRecorder?.recordEvent("session.ended", { + status: "error", + threadId: thread.threadId, + timedOut, + aborted: runAbortController.signal.aborted, + promptError: normalizeCodexTrajectoryError(error), + }); + trajectoryEndRecorded = true; runAgentHarnessLlmOutputHook({ event: { runId: params.runId, @@ -289,10 +341,17 @@ export async function runCodexAppServerAttempt( }); notificationCleanup(); requestCleanup(); + await trajectoryRecorder?.flush(); params.abortSignal?.removeEventListener("abort", abortFromUpstream); throw error; } turnId = turn.turn.id; + trajectoryRecorder?.recordEvent("prompt.submitted", { + threadId: thread.threadId, + turnId, + prompt: promptBuild.prompt, + imagesCount: params.images?.length ?? 0, + }); projector = new CodexAppServerEventProjector(params, thread.threadId, turnId); const activeTurnId = turnId; const activeProjector = projector; @@ -353,6 +412,23 @@ export async function runCodexAppServerAttempt( const finalAborted = result.aborted || runAbortController.signal.aborted; const finalPromptError = timedOut ? "codex app-server attempt timed out" : result.promptError; const finalPromptErrorSource = timedOut ? "prompt" : result.promptErrorSource; + recordCodexTrajectoryCompletion(trajectoryRecorder, { + attempt: params, + result, + threadId: thread.threadId, + turnId: activeTurnId, + timedOut, + yieldDetected, + }); + trajectoryRecorder?.recordEvent("session.ended", { + status: finalPromptError ? "error" : finalAborted || timedOut ? "interrupted" : "success", + threadId: thread.threadId, + turnId: activeTurnId, + timedOut, + yieldDetected, + promptError: normalizeCodexTrajectoryError(finalPromptError), + }); + trajectoryEndRecorded = true; await mirrorTranscriptBestEffort({ params, agentId: sessionAgentId, @@ -390,6 +466,16 @@ export async function runCodexAppServerAttempt( promptErrorSource: finalPromptErrorSource, }; } finally { + if (trajectoryRecorder && !trajectoryEndRecorded) { + trajectoryRecorder.recordEvent("session.ended", { + status: timedOut || runAbortController.signal.aborted ? "interrupted" : "cleanup", + threadId: thread.threadId, + turnId: activeTurnId, + timedOut, + aborted: runAbortController.signal.aborted, + }); + } + await trajectoryRecorder?.flush(); clearTimeout(timeout); notificationCleanup(); requestCleanup(); diff --git a/extensions/codex/src/app-server/trajectory.test.ts b/extensions/codex/src/app-server/trajectory.test.ts new file mode 100644 index 00000000000..91b0e3a3076 --- /dev/null +++ b/extensions/codex/src/app-server/trajectory.test.ts @@ -0,0 +1,155 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + createCodexTrajectoryRecorder, + resolveCodexTrajectoryAppendFlags, + resolveCodexTrajectoryPointerFlags, +} from "./trajectory.js"; + +const tempDirs: string[] = []; + +function makeTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-codex-trajectory-")); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("Codex trajectory recorder", () => { + it("keeps write flags usable when O_NOFOLLOW is unavailable", () => { + const constants = { + O_APPEND: 0x01, + O_CREAT: 0x02, + O_TRUNC: 0x04, + O_WRONLY: 0x08, + }; + + expect(resolveCodexTrajectoryAppendFlags(constants)).toBe(0x0b); + expect(resolveCodexTrajectoryPointerFlags(constants)).toBe(0x0e); + }); + + it("records by default unless explicitly disabled", async () => { + const tmpDir = makeTempDir(); + const sessionFile = path.join(tmpDir, "session.jsonl"); + const recorder = createCodexTrajectoryRecorder({ + cwd: tmpDir, + attempt: { + sessionFile, + sessionId: "session-1", + sessionKey: "agent:main:session-1", + runId: "run-1", + provider: "codex", + modelId: "gpt-5.4", + model: { api: "responses" }, + } as never, + env: {}, + }); + + expect(recorder).not.toBeNull(); + recorder?.recordEvent("session.started", { + apiKey: "secret", + headers: [{ name: "Authorization", value: "Bearer sk-test-secret-token" }], + command: "curl -H 'Authorization: Bearer sk-other-secret-token'", + }); + await recorder?.flush(); + + const filePath = path.join(tmpDir, "session.trajectory.jsonl"); + const content = fs.readFileSync(filePath, "utf8"); + expect(content).toContain('"type":"session.started"'); + expect(content).not.toContain("secret"); + expect(content).not.toContain("sk-test-secret-token"); + expect(content).not.toContain("sk-other-secret-token"); + expect(fs.statSync(filePath).mode & 0o777).toBe(0o600); + expect(fs.existsSync(path.join(tmpDir, "session.trajectory-path.json"))).toBe(true); + }); + + it("sanitizes session ids when resolving an override directory", async () => { + const tmpDir = makeTempDir(); + const recorder = createCodexTrajectoryRecorder({ + cwd: tmpDir, + attempt: { + sessionFile: path.join(tmpDir, "session.jsonl"), + sessionId: "../evil/session", + model: { api: "responses" }, + } as never, + env: { OPENCLAW_TRAJECTORY_DIR: tmpDir }, + }); + + recorder?.recordEvent("session.started"); + await recorder?.flush(); + + expect(fs.existsSync(path.join(tmpDir, "___evil_session.jsonl"))).toBe(true); + }); + + it("honors explicit disablement", () => { + const tmpDir = makeTempDir(); + const recorder = createCodexTrajectoryRecorder({ + cwd: tmpDir, + attempt: { + sessionFile: path.join(tmpDir, "session.jsonl"), + sessionId: "session-1", + model: { api: "responses" }, + } as never, + env: { OPENCLAW_TRAJECTORY: "0" }, + }); + + expect(recorder).toBeNull(); + }); + + it("refuses to append through a symlinked parent directory", async () => { + const tmpDir = makeTempDir(); + const targetDir = path.join(tmpDir, "target"); + const linkDir = path.join(tmpDir, "link"); + fs.mkdirSync(targetDir); + fs.symlinkSync(targetDir, linkDir); + const recorder = createCodexTrajectoryRecorder({ + cwd: tmpDir, + attempt: { + sessionFile: path.join(linkDir, "session.jsonl"), + sessionId: "session-1", + model: { api: "responses" }, + } as never, + env: {}, + }); + + recorder?.recordEvent("session.started"); + await recorder?.flush(); + + expect(fs.existsSync(path.join(targetDir, "session.trajectory.jsonl"))).toBe(false); + }); + + it("truncates events that exceed the runtime event byte limit", async () => { + const tmpDir = makeTempDir(); + const recorder = createCodexTrajectoryRecorder({ + cwd: tmpDir, + attempt: { + sessionFile: path.join(tmpDir, "session.jsonl"), + sessionId: "session-1", + model: { api: "responses" }, + } as never, + env: {}, + }); + + recorder?.recordEvent("context.compiled", { + fields: Object.fromEntries( + Array.from({ length: 100 }, (_, index) => [`field-${index}`, "x".repeat(3_000)]), + ), + }); + await recorder?.flush(); + + const parsed = JSON.parse( + fs.readFileSync(path.join(tmpDir, "session.trajectory.jsonl"), "utf8"), + ) as { data?: { truncated?: boolean; reason?: string } }; + expect(parsed.data).toMatchObject({ + truncated: true, + reason: "trajectory-event-size-limit", + }); + }); +}); diff --git a/extensions/codex/src/app-server/trajectory.ts b/extensions/codex/src/app-server/trajectory.ts new file mode 100644 index 00000000000..a924fdd6432 --- /dev/null +++ b/extensions/codex/src/app-server/trajectory.ts @@ -0,0 +1,433 @@ +import nodeFs from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import type { + EmbeddedRunAttemptParams, + EmbeddedRunAttemptResult, +} from "openclaw/plugin-sdk/agent-harness"; +import { resolveUserPath } from "openclaw/plugin-sdk/agent-harness"; + +type CodexTrajectoryRecorder = { + filePath: string; + recordEvent: (type: string, data?: Record) => void; + flush: () => Promise; +}; + +type CodexTrajectoryInit = { + attempt: EmbeddedRunAttemptParams; + cwd: string; + developerInstructions?: string; + prompt?: string; + tools?: Array<{ name?: string; description?: string; inputSchema?: unknown }>; + env?: NodeJS.ProcessEnv; +}; + +const SENSITIVE_FIELD_RE = /(?:authorization|cookie|credential|key|password|passwd|secret|token)/iu; +const PRIVATE_PAYLOAD_FIELD_RE = /(?:image|screenshot|attachment|fileData|dataUri)/iu; +const AUTHORIZATION_VALUE_RE = /\b(Bearer|Basic)\s+[A-Za-z0-9+/._~=-]{8,}/giu; +const JWT_VALUE_RE = /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/gu; +const COOKIE_PAIR_RE = /\b([A-Za-z][A-Za-z0-9_.-]{1,64})=([A-Za-z0-9+/._~%=-]{16,})(?=;|\s|$)/gu; +const TRAJECTORY_RUNTIME_FILE_MAX_BYTES = 50 * 1024 * 1024; +const TRAJECTORY_RUNTIME_EVENT_MAX_BYTES = 256 * 1024; + +type CodexTrajectoryOpenFlagConstants = Pick< + typeof nodeFs.constants, + "O_APPEND" | "O_CREAT" | "O_TRUNC" | "O_WRONLY" +> & + Partial>; + +export function resolveCodexTrajectoryAppendFlags( + constants: CodexTrajectoryOpenFlagConstants = nodeFs.constants, +): number { + const noFollow = constants.O_NOFOLLOW; + return ( + constants.O_CREAT | + constants.O_APPEND | + constants.O_WRONLY | + (typeof noFollow === "number" ? noFollow : 0) + ); +} + +export function resolveCodexTrajectoryPointerFlags( + constants: CodexTrajectoryOpenFlagConstants = nodeFs.constants, +): number { + const noFollow = constants.O_NOFOLLOW; + return ( + constants.O_CREAT | + constants.O_TRUNC | + constants.O_WRONLY | + (typeof noFollow === "number" ? noFollow : 0) + ); +} + +async function assertNoSymlinkParents(filePath: string): Promise { + const resolvedDir = path.resolve(path.dirname(filePath)); + const parsed = path.parse(resolvedDir); + const relativeParts = path.relative(parsed.root, resolvedDir).split(path.sep).filter(Boolean); + let current = parsed.root; + for (const part of relativeParts) { + current = path.join(current, part); + const stat = await fs.lstat(current); + if (stat.isSymbolicLink()) { + if (path.dirname(current) === parsed.root) { + continue; + } + throw new Error(`Refusing to write trajectory under symlinked directory: ${current}`); + } + if (!stat.isDirectory()) { + throw new Error(`Refusing to write trajectory under non-directory: ${current}`); + } + } +} + +function verifyStableOpenedTrajectoryFile(params: { + preOpenStat?: nodeFs.Stats; + postOpenStat: nodeFs.Stats; + filePath: string; +}): void { + if (!params.postOpenStat.isFile()) { + throw new Error(`Refusing to write trajectory to non-file: ${params.filePath}`); + } + if (params.postOpenStat.nlink > 1) { + throw new Error(`Refusing to write trajectory to hardlinked file: ${params.filePath}`); + } + const pre = params.preOpenStat; + if (pre && (pre.dev !== params.postOpenStat.dev || pre.ino !== params.postOpenStat.ino)) { + throw new Error(`Refusing to write trajectory after file changed: ${params.filePath}`); + } +} + +async function safeAppendTrajectoryFile(filePath: string, line: string): Promise { + await assertNoSymlinkParents(filePath); + + let preOpenStat: nodeFs.Stats | undefined; + try { + const stat = await fs.lstat(filePath); + if (stat.isSymbolicLink()) { + throw new Error(`Refusing to write trajectory through symlink: ${filePath}`); + } + if (!stat.isFile()) { + throw new Error(`Refusing to write trajectory to non-file: ${filePath}`); + } + preOpenStat = stat; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") { + throw err; + } + } + const lineBytes = Buffer.byteLength(line, "utf8"); + if ((preOpenStat?.size ?? 0) + lineBytes > TRAJECTORY_RUNTIME_FILE_MAX_BYTES) { + return; + } + + const handle = await fs.open(filePath, resolveCodexTrajectoryAppendFlags(), 0o600); + try { + const stat = await handle.stat(); + verifyStableOpenedTrajectoryFile({ preOpenStat, postOpenStat: stat, filePath }); + if (stat.size + lineBytes > TRAJECTORY_RUNTIME_FILE_MAX_BYTES) { + return; + } + await handle.chmod(0o600); + await handle.appendFile(line, "utf8"); + } finally { + await handle.close(); + } +} + +function boundedTrajectoryLine(event: Record): string | undefined { + const line = JSON.stringify(event); + const bytes = Buffer.byteLength(line, "utf8"); + if (bytes <= TRAJECTORY_RUNTIME_EVENT_MAX_BYTES) { + return `${line}\n`; + } + const truncated = JSON.stringify({ + ...event, + data: { + truncated: true, + originalBytes: bytes, + limitBytes: TRAJECTORY_RUNTIME_EVENT_MAX_BYTES, + reason: "trajectory-event-size-limit", + }, + }); + if (Buffer.byteLength(truncated, "utf8") <= TRAJECTORY_RUNTIME_EVENT_MAX_BYTES) { + return `${truncated}\n`; + } + return undefined; +} + +function resolveTrajectoryPointerFilePath(sessionFile: string): string { + return sessionFile.endsWith(".jsonl") + ? `${sessionFile.slice(0, -".jsonl".length)}.trajectory-path.json` + : `${sessionFile}.trajectory-path.json`; +} + +function writeTrajectoryPointerBestEffort(params: { + filePath: string; + sessionFile: string; + sessionId: string; +}): void { + const pointerPath = resolveTrajectoryPointerFilePath(params.sessionFile); + try { + const pointerDir = path.resolve(path.dirname(pointerPath)); + if (nodeFs.lstatSync(pointerDir).isSymbolicLink()) { + return; + } + try { + if (nodeFs.lstatSync(pointerPath).isSymbolicLink()) { + return; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + return; + } + } + const fd = nodeFs.openSync(pointerPath, resolveCodexTrajectoryPointerFlags(), 0o600); + try { + nodeFs.writeFileSync( + fd, + `${JSON.stringify( + { + traceSchema: "openclaw-trajectory-pointer", + schemaVersion: 1, + sessionId: params.sessionId, + runtimeFile: params.filePath, + }, + null, + 2, + )}\n`, + "utf8", + ); + nodeFs.fchmodSync(fd, 0o600); + } finally { + nodeFs.closeSync(fd); + } + } catch { + // Pointer files are best-effort; the runtime sidecar itself is authoritative. + } +} + +export function createCodexTrajectoryRecorder( + params: CodexTrajectoryInit, +): CodexTrajectoryRecorder | null { + const env = params.env ?? process.env; + const enabled = parseTrajectoryEnabled(env); + if (!enabled) { + return null; + } + + const filePath = resolveTrajectoryFilePath({ + env, + sessionFile: params.attempt.sessionFile, + sessionId: params.attempt.sessionId, + }); + const ready = fs + .mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 }) + .catch(() => undefined); + writeTrajectoryPointerBestEffort({ + filePath, + sessionFile: params.attempt.sessionFile, + sessionId: params.attempt.sessionId, + }); + let queue = Promise.resolve(); + let seq = 0; + + return { + filePath, + recordEvent: (type, data) => { + const event = { + traceSchema: "openclaw-trajectory", + schemaVersion: 1, + traceId: params.attempt.sessionId, + source: "runtime", + type, + ts: new Date().toISOString(), + seq: (seq += 1), + sourceSeq: seq, + sessionId: params.attempt.sessionId, + sessionKey: params.attempt.sessionKey, + runId: params.attempt.runId, + workspaceDir: params.cwd, + provider: params.attempt.provider, + modelId: params.attempt.modelId, + modelApi: params.attempt.model.api, + data: data ? sanitizeValue(data) : undefined, + }; + const line = boundedTrajectoryLine(event); + if (!line) { + return; + } + queue = queue + .then(() => ready) + .then(() => safeAppendTrajectoryFile(filePath, line)) + .catch(() => undefined); + }, + flush: async () => { + await queue; + }, + }; +} + +export function recordCodexTrajectoryContext( + recorder: CodexTrajectoryRecorder | null, + params: CodexTrajectoryInit, +): void { + if (!recorder) { + return; + } + recorder.recordEvent("context.compiled", { + systemPrompt: params.developerInstructions, + prompt: params.prompt ?? params.attempt.prompt, + imagesCount: params.attempt.images?.length ?? 0, + tools: toTrajectoryToolDefinitions(params.tools), + }); +} + +export function recordCodexTrajectoryCompletion( + recorder: CodexTrajectoryRecorder | null, + params: { + attempt: EmbeddedRunAttemptParams; + result: EmbeddedRunAttemptResult; + threadId: string; + turnId: string; + timedOut: boolean; + yieldDetected?: boolean; + }, +): void { + if (!recorder) { + return; + } + recorder.recordEvent("model.completed", { + threadId: params.threadId, + turnId: params.turnId, + timedOut: params.timedOut, + yieldDetected: params.yieldDetected ?? false, + aborted: params.result.aborted, + promptError: normalizeCodexTrajectoryError(params.result.promptError), + usage: params.result.attemptUsage, + assistantTexts: params.result.assistantTexts, + messagesSnapshot: params.result.messagesSnapshot, + }); +} + +function parseTrajectoryEnabled(env: NodeJS.ProcessEnv): boolean { + const value = env.OPENCLAW_TRAJECTORY?.trim().toLowerCase(); + if (value === "1" || value === "true" || value === "yes" || value === "on") { + return true; + } + if (value === "0" || value === "false" || value === "no" || value === "off") { + return false; + } + return true; +} + +function resolveTrajectoryFilePath(params: { + env: NodeJS.ProcessEnv; + sessionFile: string; + sessionId: string; +}): string { + const dirOverride = params.env.OPENCLAW_TRAJECTORY_DIR?.trim(); + if (dirOverride) { + return resolveContainedPath( + resolveUserPath(dirOverride), + `${safeTrajectorySessionFileName(params.sessionId)}.jsonl`, + ); + } + return params.sessionFile.endsWith(".jsonl") + ? `${params.sessionFile.slice(0, -".jsonl".length)}.trajectory.jsonl` + : `${params.sessionFile}.trajectory.jsonl`; +} + +function safeTrajectorySessionFileName(sessionId: string): string { + const safe = sessionId.replaceAll(/[^A-Za-z0-9_-]/g, "_").slice(0, 120); + return /[A-Za-z0-9]/u.test(safe) ? safe : "session"; +} + +function resolveContainedPath(baseDir: string, fileName: string): string { + const resolvedBase = path.resolve(baseDir); + const resolvedFile = path.resolve(resolvedBase, fileName); + const relative = path.relative(resolvedBase, resolvedFile); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error("Trajectory file path escaped its configured directory"); + } + return resolvedFile; +} + +function toTrajectoryToolDefinitions( + tools: Array<{ name?: string; description?: string; inputSchema?: unknown }> | undefined, +): Array<{ name: string; description?: string; parameters?: unknown }> | undefined { + if (!tools || tools.length === 0) { + return undefined; + } + return tools + .flatMap((tool) => { + const name = tool.name?.trim(); + if (!name) { + return []; + } + return [ + { + name, + description: tool.description, + parameters: sanitizeValue(tool.inputSchema), + }, + ]; + }) + .toSorted((left, right) => left.name.localeCompare(right.name)); +} + +function sanitizeValue(value: unknown, depth = 0, key = ""): unknown { + if (value == null || typeof value === "boolean" || typeof value === "number") { + return value; + } + if (typeof value === "string") { + if (SENSITIVE_FIELD_RE.test(key)) { + return ""; + } + if (value.startsWith("data:") && value.length > 256) { + return ``; + } + if (PRIVATE_PAYLOAD_FIELD_RE.test(key) && value.length > 256) { + return ""; + } + const redacted = redactSensitiveString(value); + return redacted.length > 20_000 ? `${redacted.slice(0, 20_000)}…` : redacted; + } + if (depth >= 6) { + return ""; + } + if (Array.isArray(value)) { + return value.slice(0, 100).map((entry) => sanitizeValue(entry, depth + 1, key)); + } + if (typeof value === "object") { + const next: Record = {}; + for (const [key, child] of Object.entries(value).slice(0, 100)) { + next[key] = sanitizeValue(child, depth + 1, key); + } + return next; + } + return JSON.stringify(value); +} + +function redactSensitiveString(value: string): string { + return value + .replace(AUTHORIZATION_VALUE_RE, "$1 ") + .replace(JWT_VALUE_RE, "") + .replace(COOKIE_PAIR_RE, "$1="); +} + +export function normalizeCodexTrajectoryError(value: unknown): string | null { + if (!value) { + return null; + } + if (value instanceof Error) { + return value.message; + } + if (typeof value === "string") { + return value; + } + try { + return JSON.stringify(value); + } catch { + return "Unknown error"; + } +} diff --git a/src/agents/anthropic-payload-log.test.ts b/src/agents/anthropic-payload-log.test.ts index e4eb40a3c0e..0f5cfefd5cc 100644 --- a/src/agents/anthropic-payload-log.test.ts +++ b/src/agents/anthropic-payload-log.test.ts @@ -11,6 +11,7 @@ describe("createAnthropicPayloadLogger", () => { writer: { filePath: "memory", write: (line) => lines.push(line), + flush: async () => undefined, }, }); expect(logger).not.toBeNull(); diff --git a/src/agents/cache-trace.test.ts b/src/agents/cache-trace.test.ts index 31b7b5aa8e7..a1aa70c53f6 100644 --- a/src/agents/cache-trace.test.ts +++ b/src/agents/cache-trace.test.ts @@ -19,6 +19,7 @@ describe("createCacheTrace", () => { writer: { filePath: "memory", write: (line) => lines.push(line), + flush: async () => undefined, }, }); return { lines, trace }; @@ -48,6 +49,7 @@ describe("createCacheTrace", () => { writer: { filePath: "memory", write: (line) => lines.push(line), + flush: async () => undefined, }, }); @@ -78,6 +80,7 @@ describe("createCacheTrace", () => { writer: { filePath: "memory", write: (line) => lines.push(line), + flush: async () => undefined, }, }); @@ -103,6 +106,7 @@ describe("createCacheTrace", () => { writer: { filePath: "memory", write: (line) => lines.push(line), + flush: async () => undefined, }, }); @@ -147,6 +151,7 @@ describe("createCacheTrace", () => { writer: { filePath: "memory", write: (line) => lines.push(line), + flush: async () => undefined, }, }); diff --git a/src/agents/payload-redaction.ts b/src/agents/payload-redaction.ts index f371ba95212..00ba471d995 100644 --- a/src/agents/payload-redaction.ts +++ b/src/agents/payload-redaction.ts @@ -13,6 +13,10 @@ const NON_CREDENTIAL_FIELD_NAMES = new Set([ "tokens", ]); +const AUTHORIZATION_VALUE_RE = /\b(Bearer|Basic)\s+[A-Za-z0-9+/._~=-]{8,}/giu; +const JWT_VALUE_RE = /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/gu; +const COOKIE_PAIR_RE = /\b([A-Za-z][A-Za-z0-9_.-]{1,64})=([A-Za-z0-9+/._~%=-]{16,})(?=;|\s|$)/gu; + function normalizeFieldName(value: string): string { return normalizeLowercaseStringOrEmpty(value.replaceAll(/[^a-z0-9]/gi, "")); } @@ -36,6 +40,18 @@ function isCredentialFieldName(key: string): boolean { ); } +function redactSensitivePayloadString(value: string): string { + return value + .replace(AUTHORIZATION_VALUE_RE, "$1 ") + .replace(JWT_VALUE_RE, "") + .replace(COOKIE_PAIR_RE, "$1="); +} + +function hasSensitiveNameValuePair(record: Record): boolean { + const rawName = typeof record.name === "string" ? record.name : record.key; + return typeof rawName === "string" && isCredentialFieldName(rawName); +} + function hasImageMime(record: Record): boolean { const candidates = [ normalizeLowercaseStringOrEmpty(record.mimeType), @@ -67,6 +83,9 @@ function visitDiagnosticPayload( if (Array.isArray(input)) { return input.map((entry) => visit(entry)); } + if (typeof input === "string") { + return redactSensitivePayloadString(input); + } if (!input || typeof input !== "object") { return input; } @@ -77,11 +96,12 @@ function visitDiagnosticPayload( const record = input as Record; const out: Record = {}; + const redactValueField = hasSensitiveNameValuePair(record); for (const [key, val] of Object.entries(record)) { if (opts?.omitField?.(key)) { continue; } - out[key] = visit(val); + out[key] = redactValueField && key === "value" ? "" : visit(val); } if (shouldRedactImageData(record)) { diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index f713f38c312..b5adfbe1639 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -25,6 +25,14 @@ import { getPluginToolMeta } from "../../../plugins/tools.js"; import { isAcpSessionKey, isSubagentSessionKey } from "../../../routing/session-key.js"; import { normalizeOptionalLowercaseString } from "../../../shared/string-coerce.js"; import { normalizeOptionalString } from "../../../shared/string-coerce.js"; +import { + buildTrajectoryArtifacts, + buildTrajectoryRunMetadata, +} from "../../../trajectory/metadata.js"; +import { + createTrajectoryRuntimeRecorder, + toTrajectoryToolDefinitions, +} from "../../../trajectory/runtime.js"; import { buildTtsSystemPromptHint } from "../../../tts/tts.js"; import { resolveUserPath } from "../../../utils.js"; import { normalizeMessageChannel } from "../../../utils/message-channel.js"; @@ -964,6 +972,14 @@ export async function runEmbeddedAttempt( let sessionManager: ReturnType | undefined; let session: Awaited>["session"] | undefined; let removeToolResultContextGuard: (() => void) | undefined; + let trajectoryRecorder: ReturnType | null = null; + let trajectoryEndRecorded = false; + let aborted = Boolean(params.abortSignal?.aborted); + let externalAbort = false; + let timedOut = false; + let idleTimedOut = false; + let timedOutDuringCompaction = false; + let promptError: unknown = null; try { await repairSessionFileIfNeeded({ sessionFile: params.sessionFile, @@ -1233,6 +1249,55 @@ export async function runEmbeddedAttempt( modelApi: params.model.api, workspaceDir: params.workspaceDir, }); + trajectoryRecorder = createTrajectoryRuntimeRecorder({ + cfg: params.config, + env: process.env, + runId: params.runId, + sessionId: activeSession.sessionId, + sessionKey: params.sessionKey, + sessionFile: params.sessionFile, + provider: params.provider, + modelId: params.modelId, + modelApi: params.model.api, + workspaceDir: params.workspaceDir, + }); + trajectoryRecorder?.recordEvent("session.started", { + trigger: params.trigger, + sessionFile: params.sessionFile, + workspaceDir: effectiveWorkspace, + agentId: sessionAgentId, + messageProvider: params.messageProvider, + messageChannel: params.messageChannel, + toolCount: effectiveTools.length, + clientToolCount: clientToolDefs.length, + }); + trajectoryRecorder?.recordEvent( + "trace.metadata", + buildTrajectoryRunMetadata({ + env: process.env, + config: params.config, + workspaceDir: effectiveWorkspace, + sessionFile: params.sessionFile, + sessionKey: params.sessionKey, + agentId: sessionAgentId, + trigger: params.trigger, + messageProvider: params.messageProvider, + messageChannel: params.messageChannel, + provider: params.provider, + modelId: params.modelId, + modelApi: params.model.api, + timeoutMs: params.timeoutMs, + fastMode: params.fastMode, + thinkLevel: params.thinkLevel, + reasoningLevel: params.reasoningLevel, + toolResultFormat: params.toolResultFormat, + disableTools: params.disableTools, + toolsAllow: params.toolsAllow, + skillsSnapshot: params.skillsSnapshot, + systemPromptReport, + userPromptPrefixText, + }), + ); // Rebuild each turn from the session's original stream base so prior-turn // wrappers do not pin us to stale provider/API transport behavior. @@ -1609,12 +1674,7 @@ export async function runEmbeddedAttempt( throw err; } - let aborted = Boolean(params.abortSignal?.aborted); - let externalAbort = false; let yieldAborted = false; - let timedOut = false; - let idleTimedOut = false; - let timedOutDuringCompaction = false; const getAbortReason = (signal: AbortSignal): unknown => "reason" in signal ? (signal as { reason?: unknown }).reason : undefined; const makeTimeoutAbortReason = (): Error => { @@ -1862,7 +1922,6 @@ export async function runEmbeddedAttempt( // Hook runner was already obtained earlier before tool creation const hookAgentId = sessionAgentId; - let promptError: unknown = null; let preflightRecovery: EmbeddedRunAttemptResult["preflightRecovery"]; let promptErrorSource: "prompt" | "compaction" | "precheck" | null = null; let skipPromptSubmission = false; @@ -2070,6 +2129,16 @@ export async function runEmbeddedAttempt( messages: activeSession.messages, note: `images: prompt=${imageResult.images.length}`, }); + trajectoryRecorder?.recordEvent("context.compiled", { + systemPrompt: systemPromptText, + prompt: effectivePrompt, + messages: activeSession.messages, + tools: toTrajectoryToolDefinitions(effectiveTools), + imagesCount: imageResult.images.length, + streamStrategy, + transport: effectiveAgentTransport, + transcriptLeafId, + }); // Diagnostic: log context sizes before prompt to help debug early overflow errors. if (log.isEnabled("debug")) { @@ -2218,6 +2287,12 @@ export async function runEmbeddedAttempt( activeSession.agent.state.messages = normalizedReplayMessages; } finalPromptText = effectivePrompt; + trajectoryRecorder?.recordEvent("prompt.submitted", { + prompt: effectivePrompt, + systemPrompt: systemPromptText, + messages: activeSession.messages, + imagesCount: imageResult.images.length, + }); const btwSnapshotMessages = normalizedReplayMessages.slice(-MAX_BTW_SNAPSHOT_MESSAGES); updateActiveEmbeddedRunSnapshot(params.sessionId, { transcriptLeafId, @@ -2628,6 +2703,57 @@ export async function runEmbeddedAttempt( const replayMetadata = replayMetadataFromState( observeReplayMetadata(getReplayState(), observedReplayMetadata), ); + trajectoryRecorder?.recordEvent("model.completed", { + aborted, + externalAbort, + timedOut, + idleTimedOut, + timedOutDuringCompaction, + promptError: promptError ? formatErrorMessage(promptError) : undefined, + promptErrorSource, + usage: attemptUsage, + promptCache, + compactionCount: getCompactionCount(), + assistantTexts, + finalPromptText, + messagesSnapshot, + }); + trajectoryRecorder?.recordEvent( + "trace.artifacts", + buildTrajectoryArtifacts({ + status: promptError ? "error" : aborted || timedOut ? "interrupted" : "success", + aborted, + externalAbort, + timedOut, + idleTimedOut, + timedOutDuringCompaction, + promptError: promptError ? formatErrorMessage(promptError) : undefined, + promptErrorSource, + usage: attemptUsage, + promptCache, + compactionCount: getCompactionCount(), + assistantTexts, + finalPromptText, + itemLifecycle: getItemLifecycle(), + toolMetas: toolMetasNormalized, + didSendViaMessagingTool: didSendViaMessagingTool(), + successfulCronAdds: getSuccessfulCronAdds(), + messagingToolSentTexts: getMessagingToolSentTexts(), + messagingToolSentMediaUrls: getMessagingToolSentMediaUrls(), + messagingToolSentTargets: getMessagingToolSentTargets(), + lastToolError: getLastToolError?.(), + }), + ); + trajectoryRecorder?.recordEvent("session.ended", { + status: promptError ? "error" : aborted || timedOut ? "interrupted" : "success", + aborted, + externalAbort, + timedOut, + idleTimedOut, + timedOutDuringCompaction, + promptError: promptError ? formatErrorMessage(promptError) : undefined, + }); + trajectoryEndRecorded = true; return { replayMetadata, @@ -2668,6 +2794,18 @@ export async function runEmbeddedAttempt( yieldDetected: yieldDetected || undefined, }; } finally { + if (trajectoryRecorder && !trajectoryEndRecorded) { + trajectoryRecorder.recordEvent("session.ended", { + status: promptError ? "error" : aborted || timedOut ? "interrupted" : "cleanup", + aborted, + externalAbort, + timedOut, + idleTimedOut, + timedOutDuringCompaction, + promptError: promptError ? formatErrorMessage(promptError) : undefined, + }); + } + await trajectoryRecorder?.flush(); // Always tear down the session (and release the lock) before we leave this attempt. // // BUGFIX: Wait for the agent to be truly idle before flushing pending tool results. diff --git a/src/agents/queued-file-writer.test.ts b/src/agents/queued-file-writer.test.ts new file mode 100644 index 00000000000..6486d23234c --- /dev/null +++ b/src/agents/queued-file-writer.test.ts @@ -0,0 +1,83 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { getQueuedFileWriter, resolveQueuedFileAppendFlags } from "./queued-file-writer.js"; + +const tempDirs: string[] = []; + +function makeTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-queued-writer-")); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("getQueuedFileWriter", () => { + it("keeps append flags usable when O_NOFOLLOW is unavailable", () => { + expect( + resolveQueuedFileAppendFlags({ + O_APPEND: 0x01, + O_CREAT: 0x02, + O_WRONLY: 0x04, + }), + ).toBe(0x07); + }); + + it("creates log files with restrictive permissions", async () => { + const tmpDir = makeTempDir(); + const filePath = path.join(tmpDir, "trace.jsonl"); + const writer = getQueuedFileWriter(new Map(), filePath); + + writer.write("line\n"); + await writer.flush(); + + expect(fs.readFileSync(filePath, "utf8")).toBe("line\n"); + expect(fs.statSync(filePath).mode & 0o777).toBe(0o600); + }); + + it("refuses to append through a symlink", async () => { + const tmpDir = makeTempDir(); + const targetPath = path.join(tmpDir, "target.txt"); + const filePath = path.join(tmpDir, "trace.jsonl"); + fs.writeFileSync(targetPath, "before\n", "utf8"); + fs.symlinkSync(targetPath, filePath); + const writer = getQueuedFileWriter(new Map(), filePath); + + writer.write("after\n"); + await writer.flush(); + + expect(fs.readFileSync(targetPath, "utf8")).toBe("before\n"); + }); + + it("refuses to append through a symlinked parent directory", async () => { + const tmpDir = makeTempDir(); + const targetDir = path.join(tmpDir, "target"); + const linkDir = path.join(tmpDir, "link"); + fs.mkdirSync(targetDir); + fs.symlinkSync(targetDir, linkDir); + const writer = getQueuedFileWriter(new Map(), path.join(linkDir, "trace.jsonl")); + + writer.write("after\n"); + await writer.flush(); + + expect(fs.existsSync(path.join(targetDir, "trace.jsonl"))).toBe(false); + }); + + it("stops appending when the configured file cap is reached", async () => { + const tmpDir = makeTempDir(); + const filePath = path.join(tmpDir, "trace.jsonl"); + const writer = getQueuedFileWriter(new Map(), filePath, { maxFileBytes: 6 }); + + writer.write("12345\n"); + writer.write("after\n"); + await writer.flush(); + + expect(fs.readFileSync(filePath, "utf8")).toBe("12345\n"); + }); +}); diff --git a/src/agents/queued-file-writer.ts b/src/agents/queued-file-writer.ts index 906ebee6f82..6823d80e150 100644 --- a/src/agents/queued-file-writer.ts +++ b/src/agents/queued-file-writer.ts @@ -1,14 +1,120 @@ +import nodeFs from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; export type QueuedFileWriter = { filePath: string; write: (line: string) => void; + flush: () => Promise; }; +export type QueuedFileWriterOptions = { + maxFileBytes?: number; +}; + +type QueuedFileAppendFlagConstants = Pick< + typeof nodeFs.constants, + "O_APPEND" | "O_CREAT" | "O_WRONLY" +> & + Partial>; + +export function resolveQueuedFileAppendFlags( + constants: QueuedFileAppendFlagConstants = nodeFs.constants, +): number { + const noFollow = constants.O_NOFOLLOW; + return ( + constants.O_CREAT | + constants.O_APPEND | + constants.O_WRONLY | + (typeof noFollow === "number" ? noFollow : 0) + ); +} + +async function assertNoSymlinkParents(filePath: string): Promise { + const resolvedDir = path.resolve(path.dirname(filePath)); + const parsed = path.parse(resolvedDir); + const relativeParts = path.relative(parsed.root, resolvedDir).split(path.sep).filter(Boolean); + let current = parsed.root; + for (const part of relativeParts) { + current = path.join(current, part); + const stat = await fs.lstat(current); + if (stat.isSymbolicLink()) { + if (path.dirname(current) === parsed.root) { + continue; + } + throw new Error(`Refusing to write queued log under symlinked directory: ${current}`); + } + if (!stat.isDirectory()) { + throw new Error(`Refusing to write queued log under non-directory: ${current}`); + } + } +} + +function verifyStableOpenedFile(params: { + preOpenStat?: nodeFs.Stats; + postOpenStat: nodeFs.Stats; + filePath: string; +}): void { + if (!params.postOpenStat.isFile()) { + throw new Error(`Refusing to write queued log to non-file: ${params.filePath}`); + } + if (params.postOpenStat.nlink > 1) { + throw new Error(`Refusing to write queued log to hardlinked file: ${params.filePath}`); + } + const pre = params.preOpenStat; + if (pre && (pre.dev !== params.postOpenStat.dev || pre.ino !== params.postOpenStat.ino)) { + throw new Error(`Refusing to write queued log after file changed: ${params.filePath}`); + } +} + +async function safeAppendFile( + filePath: string, + line: string, + options: QueuedFileWriterOptions, +): Promise { + await assertNoSymlinkParents(filePath); + + let preOpenStat: nodeFs.Stats | undefined; + try { + const stat = await fs.lstat(filePath); + if (stat.isSymbolicLink()) { + throw new Error(`Refusing to write queued log through symlink: ${filePath}`); + } + if (!stat.isFile()) { + throw new Error(`Refusing to write queued log to non-file: ${filePath}`); + } + preOpenStat = stat; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") { + throw err; + } + } + const lineBytes = Buffer.byteLength(line, "utf8"); + if ( + options.maxFileBytes !== undefined && + (preOpenStat?.size ?? 0) + lineBytes > options.maxFileBytes + ) { + return; + } + + const handle = await fs.open(filePath, resolveQueuedFileAppendFlags(), 0o600); + try { + const stat = await handle.stat(); + verifyStableOpenedFile({ preOpenStat, postOpenStat: stat, filePath }); + if (options.maxFileBytes !== undefined && stat.size + lineBytes > options.maxFileBytes) { + return; + } + await handle.chmod(0o600); + await handle.appendFile(line, "utf8"); + } finally { + await handle.close(); + } +} + export function getQueuedFileWriter( writers: Map, filePath: string, + options: QueuedFileWriterOptions = {}, ): QueuedFileWriter { const existing = writers.get(filePath); if (existing) { @@ -16,7 +122,7 @@ export function getQueuedFileWriter( } const dir = path.dirname(filePath); - const ready = fs.mkdir(dir, { recursive: true }).catch(() => undefined); + const ready = fs.mkdir(dir, { recursive: true, mode: 0o700 }).catch(() => undefined); let queue = Promise.resolve(); const writer: QueuedFileWriter = { @@ -24,9 +130,12 @@ export function getQueuedFileWriter( write: (line: string) => { queue = queue .then(() => ready) - .then(() => fs.appendFile(filePath, line, "utf8")) + .then(() => safeAppendFile(filePath, line, options)) .catch(() => undefined); }, + flush: async () => { + await queue; + }, }; writers.set(filePath, writer); diff --git a/src/auto-reply/commands-registry.shared.ts b/src/auto-reply/commands-registry.shared.ts index 97b5f6a4c79..982dcb44b2f 100644 --- a/src/auto-reply/commands-registry.shared.ts +++ b/src/auto-reply/commands-registry.shared.ts @@ -251,6 +251,23 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { }, ], }), + defineChatCommand({ + key: "export-trajectory", + nativeName: "export-trajectory", + description: "Export a JSONL trajectory bundle for the active session.", + textAliases: ["/export-trajectory", "/trajectory"], + acceptsArgs: true, + category: "status", + tier: "essential", + args: [ + { + name: "path", + description: "Output directory (default: workspace)", + type: "string", + required: false, + }, + ], + }), defineChatCommand({ key: "tts", nativeName: "tts", diff --git a/src/auto-reply/reply/commands-export-trajectory.test.ts b/src/auto-reply/reply/commands-export-trajectory.test.ts new file mode 100644 index 00000000000..a35c417a254 --- /dev/null +++ b/src/auto-reply/reply/commands-export-trajectory.test.ts @@ -0,0 +1,234 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { HandleCommandsParams } from "./commands-types.js"; + +const hoisted = vi.hoisted(() => ({ + resolveDefaultSessionStorePathMock: vi.fn(() => "/tmp/target-store/sessions.json"), + resolveSessionFilePathMock: vi.fn(() => "/tmp/target-store/session.jsonl"), + resolveSessionFilePathOptionsMock: vi.fn( + (params: { agentId: string; storePath: string }) => params, + ), + loadSessionStoreMock: vi.fn(() => ({ + "agent:target:session": { + sessionId: "session-1", + updatedAt: 1, + }, + })), + exportTrajectoryBundleMock: vi.fn(() => ({ + outputDir: "/tmp/workspace/.openclaw/trajectory-exports/openclaw-trajectory-session", + manifest: { + eventCount: 7, + runtimeEventCount: 3, + transcriptEventCount: 4, + }, + events: [{ type: "context.compiled" }], + runtimeFile: "/tmp/target-store/session.trajectory.jsonl", + supplementalFiles: ["metadata.json", "artifacts.json", "prompts.json"], + })), + resolveDefaultTrajectoryExportDirMock: vi.fn( + () => "/tmp/workspace/.openclaw/trajectory-exports/openclaw-trajectory-session", + ), + existsSyncMock: vi.fn((file: fs.PathLike, actualExistsSync: (path: fs.PathLike) => boolean) => + actualExistsSync(file), + ), +})); + +vi.mock("../../config/sessions/paths.js", () => ({ + resolveDefaultSessionStorePath: hoisted.resolveDefaultSessionStorePathMock, + resolveSessionFilePath: hoisted.resolveSessionFilePathMock, + resolveSessionFilePathOptions: hoisted.resolveSessionFilePathOptionsMock, +})); + +vi.mock("../../config/sessions/store.js", () => ({ + loadSessionStore: hoisted.loadSessionStoreMock, +})); + +vi.mock("../../trajectory/export.js", () => ({ + exportTrajectoryBundle: hoisted.exportTrajectoryBundleMock, + resolveDefaultTrajectoryExportDir: hoisted.resolveDefaultTrajectoryExportDirMock, +})); + +vi.mock("node:fs", async () => { + const actual = await vi.importActual("node:fs"); + const mockedFs = { + ...actual, + existsSync: (file: fs.PathLike) => hoisted.existsSyncMock(file, actual.existsSync), + }; + return { + ...mockedFs, + default: mockedFs, + }; +}); + +const tempDirs: string[] = []; + +function makeTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-export-command-")); + tempDirs.push(dir); + return dir; +} + +function makeParams(workspaceDir = makeTempDir()): HandleCommandsParams { + return { + cfg: {}, + ctx: { + SessionKey: "agent:main:slash-session", + }, + command: { + commandBodyNormalized: "/export-trajectory", + isAuthorizedSender: true, + senderIsOwner: true, + senderId: "sender-1", + channel: "quietchat", + surface: "quietchat", + ownerList: [], + rawBodyNormalized: "/export-trajectory", + }, + sessionEntry: { + sessionId: "session-1", + updatedAt: 1, + }, + sessionKey: "agent:target:session", + workspaceDir, + directives: {}, + elevated: { enabled: true, allowed: true, failures: [] }, + defaultGroupActivation: () => "mention", + resolvedVerboseLevel: "off", + resolvedReasoningLevel: "off", + resolveDefaultThinkingLevel: async () => undefined, + provider: "openai", + model: "gpt-5.4", + contextTokens: 0, + isGroup: false, + } as unknown as HandleCommandsParams; +} + +describe("buildExportTrajectoryReply", () => { + beforeEach(() => { + vi.clearAllMocks(); + hoisted.existsSyncMock.mockImplementation( + (file: fs.PathLike, actualExistsSync: (path: fs.PathLike) => boolean) => + file.toString() === "/tmp/target-store/session.jsonl" || actualExistsSync(file), + ); + }); + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("builds a trajectory bundle from the target session", async () => { + const { buildExportTrajectoryReply } = await import("./commands-export-trajectory.js"); + + const reply = await buildExportTrajectoryReply(makeParams()); + + expect(reply.text).toContain("✅ Trajectory exported!"); + expect(reply.text).toContain("session-branch.json"); + expect(reply.text).not.toContain("session.jsonl"); + expect(reply.text).not.toContain("runtime.jsonl"); + expect(hoisted.resolveDefaultSessionStorePathMock).toHaveBeenCalledWith("target"); + expect(hoisted.exportTrajectoryBundleMock).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "session-1", + sessionKey: "agent:target:session", + workspaceDir: expect.stringContaining("openclaw-export-command-"), + }), + ); + }); + + it("keeps user-named output paths inside the workspace trajectory export directory", async () => { + const { buildExportTrajectoryReply } = await import("./commands-export-trajectory.js"); + const params = makeParams(); + params.command.commandBodyNormalized = "/export-trajectory my-bundle"; + + await buildExportTrajectoryReply(params); + + expect(hoisted.exportTrajectoryBundleMock).toHaveBeenCalledWith( + expect.objectContaining({ + outputDir: path.join(params.workspaceDir, ".openclaw", "trajectory-exports", "my-bundle"), + }), + ); + }); + + it("rejects absolute output paths", async () => { + const { buildExportTrajectoryReply } = await import("./commands-export-trajectory.js"); + const params = makeParams(); + params.command.commandBodyNormalized = "/export-trajectory /tmp/outside"; + + const reply = await buildExportTrajectoryReply(params); + + expect(reply.text).toContain("Failed to resolve output path"); + expect(hoisted.exportTrajectoryBundleMock).not.toHaveBeenCalled(); + }); + + it("rejects home-relative output paths", async () => { + const { buildExportTrajectoryReply } = await import("./commands-export-trajectory.js"); + const params = makeParams(); + params.command.commandBodyNormalized = "/export-trajectory ~/bundle"; + + const reply = await buildExportTrajectoryReply(params); + + expect(reply.text).toContain("Failed to resolve output path"); + expect(hoisted.exportTrajectoryBundleMock).not.toHaveBeenCalled(); + }); + + it("does not echo absolute session paths when the transcript is missing", async () => { + const { buildExportTrajectoryReply } = await import("./commands-export-trajectory.js"); + hoisted.existsSyncMock.mockImplementation( + (file: fs.PathLike, actualExistsSync: (path: fs.PathLike) => boolean) => + file.toString() === "/tmp/target-store/session.jsonl" ? false : actualExistsSync(file), + ); + + const reply = await buildExportTrajectoryReply(makeParams()); + + expect(reply.text).toBe("❌ Session file not found."); + expect(reply.text).not.toContain("/tmp/target-store/session.jsonl"); + expect(hoisted.exportTrajectoryBundleMock).not.toHaveBeenCalled(); + }); + + it("rejects output paths redirected by a symlinked exports directory", async () => { + const { buildExportTrajectoryReply } = await import("./commands-export-trajectory.js"); + const workspaceDir = makeTempDir(); + const outsideDir = makeTempDir(); + fs.mkdirSync(path.join(workspaceDir, ".openclaw"), { recursive: true }); + fs.symlinkSync(outsideDir, path.join(workspaceDir, ".openclaw", "trajectory-exports")); + const params = makeParams(workspaceDir); + params.command.commandBodyNormalized = "/export-trajectory my-bundle"; + + const reply = await buildExportTrajectoryReply(params); + + expect(reply.text).toContain("Failed to resolve output path"); + expect(hoisted.exportTrajectoryBundleMock).not.toHaveBeenCalled(); + }); + + it("rejects default output paths redirected by a symlinked exports directory", async () => { + const { buildExportTrajectoryReply } = await import("./commands-export-trajectory.js"); + const workspaceDir = makeTempDir(); + const outsideDir = makeTempDir(); + fs.mkdirSync(path.join(workspaceDir, ".openclaw"), { recursive: true }); + fs.symlinkSync(outsideDir, path.join(workspaceDir, ".openclaw", "trajectory-exports")); + + const reply = await buildExportTrajectoryReply(makeParams(workspaceDir)); + + expect(reply.text).toContain("Failed to resolve output path"); + expect(hoisted.exportTrajectoryBundleMock).not.toHaveBeenCalled(); + }); + + it("rejects symlinked state directories before creating export folders", async () => { + const { buildExportTrajectoryReply } = await import("./commands-export-trajectory.js"); + const workspaceDir = makeTempDir(); + const outsideDir = makeTempDir(); + fs.symlinkSync(outsideDir, path.join(workspaceDir, ".openclaw")); + const params = makeParams(workspaceDir); + params.command.commandBodyNormalized = "/export-trajectory my-bundle"; + + const reply = await buildExportTrajectoryReply(params); + + expect(reply.text).toContain("Failed to resolve output path"); + expect(fs.existsSync(path.join(outsideDir, "trajectory-exports"))).toBe(false); + expect(hoisted.exportTrajectoryBundleMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/auto-reply/reply/commands-export-trajectory.ts b/src/auto-reply/reply/commands-export-trajectory.ts new file mode 100644 index 00000000000..768c0dce642 --- /dev/null +++ b/src/auto-reply/reply/commands-export-trajectory.ts @@ -0,0 +1,205 @@ +import fs from "node:fs"; +import path from "node:path"; +import { + resolveDefaultSessionStorePath, + resolveSessionFilePath, + resolveSessionFilePathOptions, +} from "../../config/sessions/paths.js"; +import { loadSessionStore } from "../../config/sessions/store.js"; +import type { SessionEntry } from "../../config/sessions/types.js"; +import { formatErrorMessage } from "../../infra/errors.js"; +import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; +import { + exportTrajectoryBundle, + resolveDefaultTrajectoryExportDir, +} from "../../trajectory/export.js"; +import type { ReplyPayload } from "../types.js"; +import type { HandleCommandsParams } from "./commands-types.js"; + +function parseExportTrajectoryArgs(commandBodyNormalized: string): { outputPath?: string } { + const normalized = commandBodyNormalized.trim(); + if (normalized === "/export-trajectory" || normalized === "/trajectory") { + return {}; + } + const args = normalized.replace(/^\/(export-trajectory|trajectory)\s*/, "").trim(); + const outputPath = args.split(/\s+/).find((part) => !part.startsWith("-")); + return { outputPath }; +} + +function isPathInsideOrEqual(baseDir: string, candidate: string): boolean { + const relative = path.relative(baseDir, candidate); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +function validateExistingExportDirectory(params: { + dir: string; + label: string; + realWorkspace: string; +}): string { + const linkStat = fs.lstatSync(params.dir); + if (linkStat.isSymbolicLink() || !linkStat.isDirectory()) { + throw new Error(`${params.label} must be a real directory inside the workspace`); + } + const realDir = fs.realpathSync(params.dir); + if (!isPathInsideOrEqual(params.realWorkspace, realDir)) { + throw new Error("Trajectory exports directory must stay inside the workspace"); + } + return realDir; +} + +function mkdirIfMissingThenValidate(params: { + dir: string; + label: string; + realWorkspace: string; +}): string { + if (!fs.existsSync(params.dir)) { + try { + fs.mkdirSync(params.dir, { mode: 0o700 }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") { + throw error; + } + } + } + return validateExistingExportDirectory(params); +} + +function resolveTrajectoryExportBaseDir(workspaceDir: string): { + baseDir: string; + realBase: string; +} { + const workspacePath = path.resolve(workspaceDir); + const realWorkspace = fs.realpathSync(workspacePath); + const stateDir = path.join(workspacePath, ".openclaw"); + mkdirIfMissingThenValidate({ + dir: stateDir, + label: "OpenClaw state directory", + realWorkspace, + }); + const baseDir = path.join(stateDir, "trajectory-exports"); + const realBase = mkdirIfMissingThenValidate({ + dir: baseDir, + label: "Trajectory exports directory", + realWorkspace, + }); + return { baseDir: path.resolve(baseDir), realBase }; +} + +function resolveTrajectoryCommandOutputDir(params: { + outputPath?: string; + workspaceDir: string; + sessionId: string; +}): string { + const { baseDir, realBase } = resolveTrajectoryExportBaseDir(params.workspaceDir); + const raw = params.outputPath?.trim(); + if (!raw) { + const defaultDir = resolveDefaultTrajectoryExportDir({ + workspaceDir: params.workspaceDir, + sessionId: params.sessionId, + }); + return path.join(baseDir, path.basename(defaultDir)); + } + if (path.isAbsolute(raw) || raw.startsWith("~")) { + throw new Error("Output path must be relative to the workspace trajectory exports directory"); + } + const resolvedBase = path.resolve(baseDir); + const outputDir = path.resolve(resolvedBase, raw); + const relative = path.relative(resolvedBase, outputDir); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error("Output path must stay inside the workspace trajectory exports directory"); + } + let existingParent = outputDir; + while (!fs.existsSync(existingParent)) { + const next = path.dirname(existingParent); + if (next === existingParent) { + break; + } + existingParent = next; + } + const realExistingParent = fs.realpathSync(existingParent); + if (!isPathInsideOrEqual(realBase, realExistingParent)) { + throw new Error("Output path must stay inside the real trajectory exports directory"); + } + return outputDir; +} + +export async function buildExportTrajectoryReply( + params: HandleCommandsParams, +): Promise { + const args = parseExportTrajectoryArgs(params.command.commandBodyNormalized); + const targetAgentId = resolveAgentIdFromSessionKey(params.sessionKey) || params.agentId; + const storePath = params.storePath ?? resolveDefaultSessionStorePath(targetAgentId); + const store = loadSessionStore(storePath, { skipCache: true }); + const entry = store[params.sessionKey] as SessionEntry | undefined; + if (!entry?.sessionId) { + return { text: `❌ Session not found: ${params.sessionKey}` }; + } + + let sessionFile: string; + try { + sessionFile = resolveSessionFilePath( + entry.sessionId, + entry, + resolveSessionFilePathOptions({ agentId: targetAgentId, storePath }), + ); + } catch (err) { + return { + text: `❌ Failed to resolve session file: ${formatErrorMessage(err)}`, + }; + } + if (!fs.existsSync(sessionFile)) { + return { text: "❌ Session file not found." }; + } + + let outputDir: string; + try { + outputDir = resolveTrajectoryCommandOutputDir({ + outputPath: args.outputPath, + workspaceDir: params.workspaceDir, + sessionId: entry.sessionId, + }); + } catch (err) { + return { + text: `❌ Failed to resolve output path: ${formatErrorMessage(err)}`, + }; + } + + let bundle: ReturnType; + try { + bundle = exportTrajectoryBundle({ + outputDir, + sessionFile, + sessionId: entry.sessionId, + sessionKey: params.sessionKey, + workspaceDir: params.workspaceDir, + }); + } catch (err) { + return { + text: `❌ Failed to export trajectory: ${formatErrorMessage(err)}`, + }; + } + + const relativePath = path.relative(params.workspaceDir, bundle.outputDir); + const displayPath = + relativePath && !relativePath.startsWith("..") && !path.isAbsolute(relativePath) + ? relativePath + : path.basename(bundle.outputDir); + const files = ["manifest.json", "events.jsonl", "session-branch.json"]; + if (bundle.events.some((event) => event.type === "context.compiled")) { + files.push("system-prompt.txt", "tools.json"); + } + files.push(...bundle.supplementalFiles); + + return { + text: [ + "✅ Trajectory exported!", + "", + `📦 Bundle: ${displayPath}`, + `🧵 Session: ${entry.sessionId}`, + `📊 Events: ${bundle.manifest.eventCount}`, + `🧪 Runtime events: ${bundle.manifest.runtimeEventCount}`, + `📝 Transcript events: ${bundle.manifest.transcriptEventCount}`, + `📁 Files: ${files.join(", ")}`, + ].join("\n"), + }; +} diff --git a/src/auto-reply/reply/commands-handlers.runtime.ts b/src/auto-reply/reply/commands-handlers.runtime.ts index 8fc4d44c04e..489d1ec0d2d 100644 --- a/src/auto-reply/reply/commands-handlers.runtime.ts +++ b/src/auto-reply/reply/commands-handlers.runtime.ts @@ -8,6 +8,7 @@ import { handleConfigCommand, handleDebugCommand } from "./commands-config.js"; import { handleContextCommand } from "./commands-context-command.js"; import { handleCommandsListCommand, + handleExportTrajectoryCommand, handleExportSessionCommand, handleHelpCommand, handleStatusCommand, @@ -54,6 +55,7 @@ export function loadCommandHandlers(): CommandHandler[] { handleApproveCommand, handleContextCommand, handleExportSessionCommand, + handleExportTrajectoryCommand, handleWhoamiCommand, handleSubagentsCommand, handleAcpCommand, diff --git a/src/auto-reply/reply/commands-info.test.ts b/src/auto-reply/reply/commands-info.test.ts index def45a6fd6e..6e31f7bd25d 100644 --- a/src/auto-reply/reply/commands-info.test.ts +++ b/src/auto-reply/reply/commands-info.test.ts @@ -3,12 +3,13 @@ import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { MsgContext } from "../templating.js"; import { handleContextCommand } from "./commands-context-command.js"; -import { handleStatusCommand } from "./commands-info.js"; +import { handleExportTrajectoryCommand, handleStatusCommand } from "./commands-info.js"; import { buildStatusReply } from "./commands-status.js"; import type { HandleCommandsParams } from "./commands-types.js"; import { handleWhoamiCommand } from "./commands-whoami.js"; const buildContextReplyMock = vi.hoisted(() => vi.fn()); +const buildExportTrajectoryReplyMock = vi.hoisted(() => vi.fn(async () => ({ text: "exported" }))); const listSkillCommandsForAgentsMock = vi.hoisted(() => vi.fn(() => [])); const buildCommandsMessagePaginatedMock = vi.hoisted(() => vi.fn(() => ({ text: "/commands", currentPage: 1, totalPages: 1 })), @@ -18,6 +19,10 @@ vi.mock("./commands-context-report.js", () => ({ buildContextReply: buildContextReplyMock, })); +vi.mock("./commands-export-trajectory.js", () => ({ + buildExportTrajectoryReply: buildExportTrajectoryReplyMock, +})); + vi.mock("./commands-status.js", () => ({ buildStatusReply: vi.fn(async () => ({ text: "status reply" })), })); @@ -87,6 +92,7 @@ function buildInfoParams( describe("info command handlers", () => { beforeEach(() => { vi.clearAllMocks(); + buildExportTrajectoryReplyMock.mockResolvedValue({ text: "exported" }); buildContextReplyMock.mockImplementation(async (params: HandleCommandsParams) => { const normalized = params.command.commandBodyNormalized; if (normalized === "/context list") { @@ -104,6 +110,18 @@ describe("info command handlers", () => { }); }); + it("only lets owners export trajectory bundles", async () => { + const params = buildInfoParams("/export-trajectory", { + commands: { text: true }, + } as OpenClawConfig); + params.command.senderIsOwner = false; + + const result = await handleExportTrajectoryCommand(params, true); + + expect(result).toEqual({ shouldContinue: false }); + expect(buildExportTrajectoryReplyMock).not.toHaveBeenCalled(); + }); + it("returns sender details for /whoami", async () => { const result = await handleWhoamiCommand( buildInfoParams( diff --git a/src/auto-reply/reply/commands-info.ts b/src/auto-reply/reply/commands-info.ts index 94a4e9c3e37..d5544252cfb 100644 --- a/src/auto-reply/reply/commands-info.ts +++ b/src/auto-reply/reply/commands-info.ts @@ -11,7 +11,9 @@ import { } from "../status.js"; import { buildThreadingToolContext } from "./agent-runner-utils.js"; import { resolveChannelAccountId } from "./channel-context.js"; +import { rejectNonOwnerCommand, rejectUnauthorizedCommand } from "./command-gates.js"; import { buildExportSessionReply } from "./commands-export-session.js"; +import { buildExportTrajectoryReply } from "./commands-export-trajectory.js"; import { buildStatusReply } from "./commands-status.js"; import type { CommandHandler } from "./commands-types.js"; import { extractExplicitGroupId } from "./group-id.js"; @@ -234,3 +236,27 @@ export const handleExportSessionCommand: CommandHandler = async (params, allowTe } return { shouldContinue: false, reply: await buildExportSessionReply(params) }; }; + +export const handleExportTrajectoryCommand: CommandHandler = async (params, allowTextCommands) => { + if (!allowTextCommands) { + return null; + } + const normalized = params.command.commandBodyNormalized; + if ( + normalized !== "/export-trajectory" && + !normalized.startsWith("/export-trajectory ") && + normalized !== "/trajectory" && + !normalized.startsWith("/trajectory ") + ) { + return null; + } + const unauthorized = rejectUnauthorizedCommand(params, "/export-trajectory"); + if (unauthorized) { + return unauthorized; + } + const nonOwner = rejectNonOwnerCommand(params, "/export-trajectory"); + if (nonOwner) { + return nonOwner; + } + return { shouldContinue: false, reply: await buildExportTrajectoryReply(params) }; +}; diff --git a/src/commands/channel-setup/plugin-install.test.ts b/src/commands/channel-setup/plugin-install.test.ts index ef70f8dce58..d8947046292 100644 --- a/src/commands/channel-setup/plugin-install.test.ts +++ b/src/commands/channel-setup/plugin-install.test.ts @@ -113,6 +113,11 @@ import { reloadChannelSetupPluginRegistryForChannel, } from "./plugin-install.js"; +const bundledChatNpmSpec = "@openclaw/bundled-chat@1.2.3"; +const bundledChatIntegrity = "sha512-bundled-chat"; +const bundledChatForkNpmSpec = "@vendor/bundled-chat-fork@1.2.3"; +const bundledChatForkIntegrity = "sha512-vendor-bundled-chat-fork"; + const baseEntry: ChannelPluginCatalogEntry = { id: "bundled-chat", pluginId: "bundled-chat", @@ -125,9 +130,9 @@ const baseEntry: ChannelPluginCatalogEntry = { blurb: "Test", }, install: { - npmSpec: "@openclaw/bundled-chat@1.2.3", - expectedIntegrity: "sha512-bundled-chat", + npmSpec: bundledChatNpmSpec, localPath: bundledPluginRoot("bundled-chat"), + expectedIntegrity: bundledChatIntegrity, }, }; @@ -139,7 +144,7 @@ function mockBundledChatSource() { { pluginId: "bundled-chat", localPath: bundledPluginRootAt("/opt/openclaw", "bundled-chat"), - npmSpec: "@openclaw/bundled-chat@1.2.3", + npmSpec: bundledChatNpmSpec, }, ], ]), @@ -309,12 +314,13 @@ describe("ensureChannelSetupPluginInstalled", () => { expect(result.cfg.plugins?.entries?.["bundled-chat"]?.enabled).toBe(true); expect(result.cfg.plugins?.allow).toContain("bundled-chat"); expect(result.cfg.plugins?.installs?.["bundled-chat"]?.source).toBe("npm"); - expect(result.cfg.plugins?.installs?.["bundled-chat"]?.spec).toBe( - "@openclaw/bundled-chat@1.2.3", - ); + expect(result.cfg.plugins?.installs?.["bundled-chat"]?.spec).toBe(bundledChatNpmSpec); expect(result.cfg.plugins?.installs?.["bundled-chat"]?.installPath).toBe("/tmp/bundled-chat"); expect(installPluginFromNpmSpec).toHaveBeenCalledWith( - expect.objectContaining({ spec: "@openclaw/bundled-chat@1.2.3" }), + expect.objectContaining({ + expectedIntegrity: bundledChatIntegrity, + spec: bundledChatNpmSpec, + }), ); }); @@ -415,8 +421,8 @@ describe("ensureChannelSetupPluginInstalled", () => { blurb: "Test", }, install: { - npmSpec: "@vendor/bundled-chat-fork@1.2.3", - expectedIntegrity: "sha512-vendor-bundled-chat-fork", + npmSpec: bundledChatForkNpmSpec, + expectedIntegrity: bundledChatForkIntegrity, }, }, prompter, @@ -429,7 +435,7 @@ describe("ensureChannelSetupPluginInstalled", () => { options: [ expect.objectContaining({ value: "npm", - label: "Download from npm (@vendor/bundled-chat-fork@1.2.3)", + label: `Download from npm (${bundledChatForkNpmSpec})`, }), expect.objectContaining({ value: "skip", diff --git a/src/commands/onboarding-plugin-install.test.ts b/src/commands/onboarding-plugin-install.test.ts index 316bbfdbd55..ed3f18f6dd7 100644 --- a/src/commands/onboarding-plugin-install.test.ts +++ b/src/commands/onboarding-plugin-install.test.ts @@ -23,7 +23,10 @@ vi.mock("../plugins/install.js", () => ({ })); const enablePluginInConfig = vi.hoisted(() => - vi.fn((cfg: OpenClawConfig): PluginEnableResult => ({ config: cfg, enabled: true })), + vi.fn<(cfg: OpenClawConfig, pluginId: string) => PluginEnableResult>((cfg) => ({ + config: cfg, + enabled: true, + })), ); vi.mock("../plugins/enable.js", () => ({ enablePluginInConfig, diff --git a/src/gateway/gateway-trajectory-export.live.test.ts b/src/gateway/gateway-trajectory-export.live.test.ts new file mode 100644 index 00000000000..3e7c66adb0d --- /dev/null +++ b/src/gateway/gateway-trajectory-export.live.test.ts @@ -0,0 +1,343 @@ +import { randomBytes, randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { isLiveTestEnabled } from "../agents/live-test-helpers.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { extractFirstTextBlock } from "../shared/chat-message-content.js"; +import { GatewayClient } from "./client.js"; +import { + connectTestGatewayClient, + createBootstrapWorkspace, + ensurePairedTestGatewayClientIdentity, + getFreeGatewayPort, +} from "./gateway-cli-backend.live-helpers.js"; +import { extractPayloadText } from "./test-helpers.agent-results.js"; + +const LIVE = isLiveTestEnabled(); +const CODEX_HARNESS_LIVE = process.env.OPENCLAW_LIVE_CODEX_HARNESS === "1"; +const CODEX_HARNESS_DEBUG = process.env.OPENCLAW_LIVE_CODEX_HARNESS_DEBUG === "1"; +const describeLive = LIVE && CODEX_HARNESS_LIVE ? describe : describe.skip; +const LIVE_TIMEOUT_MS = 420_000; +const GATEWAY_CONNECT_TIMEOUT_MS = 60_000; +const AGENT_REQUEST_TIMEOUT_MS = 180_000; +const DEFAULT_CODEX_MODEL = "codex/gpt-5.4"; + +type EnvSnapshot = { + agentRuntime?: string; + configPath?: string; + gatewayToken?: string; + openaiApiKey?: string; + openaiBaseUrl?: string; + skipBrowserControl?: string; + skipCanvas?: string; + skipChannels?: string; + skipCron?: string; + skipGmail?: string; + stateDir?: string; + trajectory?: string; + trajectoryDir?: string; +}; + +function logLiveStep(step: string, details?: Record): void { + if (!CODEX_HARNESS_DEBUG) { + return; + } + const suffix = details && Object.keys(details).length > 0 ? ` ${JSON.stringify(details)}` : ""; + console.error(`[gateway-trajectory-live] ${step}${suffix}`); +} + +function snapshotEnv(): EnvSnapshot { + return { + agentRuntime: process.env.OPENCLAW_AGENT_RUNTIME, + configPath: process.env.OPENCLAW_CONFIG_PATH, + gatewayToken: process.env.OPENCLAW_GATEWAY_TOKEN, + openaiApiKey: process.env.OPENAI_API_KEY, + openaiBaseUrl: process.env.OPENAI_BASE_URL, + skipBrowserControl: process.env.OPENCLAW_SKIP_BROWSER_CONTROL_SERVER, + skipCanvas: process.env.OPENCLAW_SKIP_CANVAS_HOST, + skipChannels: process.env.OPENCLAW_SKIP_CHANNELS, + skipCron: process.env.OPENCLAW_SKIP_CRON, + skipGmail: process.env.OPENCLAW_SKIP_GMAIL_WATCHER, + stateDir: process.env.OPENCLAW_STATE_DIR, + trajectory: process.env.OPENCLAW_TRAJECTORY, + trajectoryDir: process.env.OPENCLAW_TRAJECTORY_DIR, + }; +} + +function restoreEnv(snapshot: EnvSnapshot): void { + restoreEnvVar("OPENCLAW_AGENT_RUNTIME", snapshot.agentRuntime); + restoreEnvVar("OPENCLAW_CONFIG_PATH", snapshot.configPath); + restoreEnvVar("OPENCLAW_GATEWAY_TOKEN", snapshot.gatewayToken); + restoreEnvVar("OPENAI_API_KEY", snapshot.openaiApiKey); + restoreEnvVar("OPENAI_BASE_URL", snapshot.openaiBaseUrl); + restoreEnvVar("OPENCLAW_SKIP_BROWSER_CONTROL_SERVER", snapshot.skipBrowserControl); + restoreEnvVar("OPENCLAW_SKIP_CANVAS_HOST", snapshot.skipCanvas); + restoreEnvVar("OPENCLAW_SKIP_CHANNELS", snapshot.skipChannels); + restoreEnvVar("OPENCLAW_SKIP_CRON", snapshot.skipCron); + restoreEnvVar("OPENCLAW_SKIP_GMAIL_WATCHER", snapshot.skipGmail); + restoreEnvVar("OPENCLAW_STATE_DIR", snapshot.stateDir); + restoreEnvVar("OPENCLAW_TRAJECTORY", snapshot.trajectory); + restoreEnvVar("OPENCLAW_TRAJECTORY_DIR", snapshot.trajectoryDir); +} + +function restoreEnvVar(name: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[name]; + return; + } + process.env[name] = value; +} + +async function writeLiveGatewayConfig(params: { + configPath: string; + modelKey: string; + port: number; + token: string; + workspace: string; +}): Promise { + const cfg: OpenClawConfig = { + gateway: { + mode: "local", + port: params.port, + auth: { mode: "token", token: params.token }, + }, + plugins: { allow: ["codex"] }, + agents: { + list: [{ id: "dev", default: true }], + defaults: { + workspace: params.workspace, + embeddedHarness: { runtime: "codex", fallback: "none" }, + skipBootstrap: true, + model: { primary: params.modelKey }, + models: { [params.modelKey]: {} }, + sandbox: { mode: "off" }, + }, + }, + }; + await fs.writeFile(params.configPath, `${JSON.stringify(cfg, null, 2)}\n`); +} + +async function connectGatewayClient(params: { + url: string; + token: string; +}): Promise { + const deviceIdentity = await ensurePairedTestGatewayClientIdentity({ + displayName: "trajectory-live", + }); + const client = await connectTestGatewayClient({ + url: params.url, + token: params.token, + deviceIdentity, + timeoutMs: GATEWAY_CONNECT_TIMEOUT_MS, + requestTimeoutMs: 60_000, + clientDisplayName: "trajectory-live", + }); + (client as unknown as { tickIntervalMs?: number }).tickIntervalMs = + AGENT_REQUEST_TIMEOUT_MS + 120_000; + return client; +} + +async function requestAgentExactReply(params: { + client: GatewayClient; + expectedToken: string; + message: string; + sessionKey: string; +}): Promise { + const payload = (await params.client.request( + "agent", + { + sessionKey: params.sessionKey, + idempotencyKey: `idem-${randomUUID()}`, + message: params.message, + deliver: false, + thinking: "low", + }, + { expectFinal: true, timeoutMs: AGENT_REQUEST_TIMEOUT_MS }, + )) as { + status?: string; + result?: unknown; + }; + if (payload?.status !== "ok") { + throw new Error(`agent request failed: ${JSON.stringify(payload)}`); + } + const text = extractPayloadText(payload.result); + expect(text).toContain(params.expectedToken); + return text; +} + +async function listDirectoryNames(dirPath: string): Promise { + try { + return await fs.readdir(dirPath); + } catch { + return []; + } +} + +async function waitForPath(filePath: string, timeoutMs = 60_000): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + try { + await fs.stat(filePath); + return; + } catch { + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } + throw new Error(`timed out waiting for ${filePath}`); +} + +describeLive("gateway live trajectory export", () => { + let cleanup: Array<() => Promise> = []; + + afterEach(async () => { + for (const step of cleanup.splice(0).toReversed()) { + await step(); + } + }); + + it( + "exports a combined runtime and transcript trajectory bundle through the live gateway", + async () => { + const { clearRuntimeConfigSnapshot } = await import("../config/config.js"); + const { startGatewayServer } = await import("./server.js"); + + const previousEnv = snapshotEnv(); + const tempDir = await fs.mkdtemp(path.join(process.cwd(), ".tmp-openclaw-trajectory-live-")); + cleanup.push(async () => { + restoreEnv(previousEnv); + clearRuntimeConfigSnapshot(); + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + const stateDir = path.join(tempDir, "state"); + const trajectoryDir = path.join(tempDir, "runtime-traces"); + const { workspaceDir } = await createBootstrapWorkspace(tempDir); + const configPath = path.join(tempDir, "openclaw.json"); + const token = `test-${randomUUID()}`; + const port = await getFreeGatewayPort(); + const modelKey = process.env.OPENCLAW_LIVE_CODEX_HARNESS_MODEL ?? DEFAULT_CODEX_MODEL; + + clearRuntimeConfigSnapshot(); + process.env.OPENCLAW_AGENT_RUNTIME = "codex"; + process.env.OPENCLAW_AGENT_HARNESS_FALLBACK = "none"; + delete process.env.OPENAI_BASE_URL; + delete process.env.OPENAI_API_KEY; + process.env.OPENCLAW_CONFIG_PATH = configPath; + process.env.OPENCLAW_GATEWAY_TOKEN = token; + process.env.OPENCLAW_SKIP_BROWSER_CONTROL_SERVER = "1"; + process.env.OPENCLAW_SKIP_CANVAS_HOST = "1"; + process.env.OPENCLAW_SKIP_CHANNELS = "1"; + process.env.OPENCLAW_SKIP_CRON = "1"; + process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1"; + process.env.OPENCLAW_STATE_DIR = stateDir; + process.env.OPENCLAW_TRAJECTORY = "1"; + process.env.OPENCLAW_TRAJECTORY_DIR = trajectoryDir; + + await fs.mkdir(stateDir, { recursive: true }); + await fs.mkdir(trajectoryDir, { recursive: true }); + await writeLiveGatewayConfig({ configPath, modelKey, port, token, workspace: workspaceDir }); + logLiveStep("config-written", { configPath, modelKey, port, workspaceDir }); + + const server = await startGatewayServer(port, { + bind: "loopback", + auth: { mode: "token", token }, + controlUiEnabled: false, + }); + logLiveStep("gateway-started", { port }); + cleanup.push(async () => { + await server.close(); + }); + + const client = await connectGatewayClient({ + url: `ws://127.0.0.1:${port}`, + token, + }); + logLiveStep("client-connected"); + cleanup.push(async () => { + await client.stopAndWait({ timeoutMs: 5_000 }); + }); + + const sessionKey = "agent:dev:live-trajectory-export"; + const replyToken = `TRAJECTORY-LIVE-${randomBytes(3).toString("hex").toUpperCase()}`; + logLiveStep("agent-turn:start", { sessionKey, replyToken }); + const firstReply = await requestAgentExactReply({ + client, + sessionKey, + expectedToken: replyToken, + message: `Reply with exactly ${replyToken} and nothing else.`, + }); + logLiveStep("agent-turn:done", { firstReply }); + expect(firstReply.trim()).toBe(replyToken); + + const trajectoryFiles = await listDirectoryNames(trajectoryDir); + logLiveStep("runtime-traces", { trajectoryDir, files: trajectoryFiles }); + expect(trajectoryFiles.length).toBeGreaterThan(0); + + const bundleDir = path.join(workspaceDir, ".openclaw", "trajectory-exports", "bundle"); + const beforeExport = new Set(await listDirectoryNames(tempDir)); + const exportRunId = `chat-export-${randomUUID()}`; + logLiveStep("export:start", { bundleDir, exportRunId }); + const exportResponse = (await client.request( + "chat.send", + { + sessionKey, + message: "/export-trajectory bundle", + idempotencyKey: exportRunId, + }, + { timeoutMs: 60_000 }, + )) as { status?: string; message?: unknown }; + logLiveStep("export:ack", { status: exportResponse?.status }); + expect( + exportResponse?.status === "accepted" || + exportResponse?.status === "ok" || + exportResponse?.status === "started", + ).toBe(true); + await waitForPath(path.join(bundleDir, "events.jsonl"), 60_000); + const finalText = + typeof exportResponse?.message === "object" + ? extractFirstTextBlock(exportResponse.message) + : undefined; + logLiveStep("export:done", { finalText }); + if (finalText) { + expect(finalText).toContain("Trajectory exported!"); + } + expect(await listDirectoryNames(bundleDir)).toEqual( + expect.arrayContaining([ + "artifacts.json", + "events.jsonl", + "manifest.json", + "metadata.json", + "prompts.json", + "session.jsonl", + "tools.json", + ]), + ); + expect(beforeExport.has("bundle")).toBe(false); + + const manifest = JSON.parse( + await fs.readFile(path.join(bundleDir, "manifest.json"), "utf8"), + ) as { + eventCount?: number; + runtimeEventCount?: number; + transcriptEventCount?: number; + }; + expect(manifest.eventCount).toBeGreaterThan(0); + expect(manifest.runtimeEventCount).toBeGreaterThan(0); + expect(manifest.transcriptEventCount).toBeGreaterThan(0); + + const exportedEvents = (await fs.readFile(path.join(bundleDir, "events.jsonl"), "utf8")) + .trim() + .split(/\r?\n/u) + .map((line) => JSON.parse(line) as { type?: string }); + const eventTypes = new Set(exportedEvents.map((event) => event.type)); + expect(eventTypes.has("context.compiled")).toBe(true); + expect(eventTypes.has("prompt.submitted")).toBe(true); + expect(eventTypes.has("model.completed")).toBe(true); + expect(eventTypes.has("session.ended")).toBe(true); + expect(eventTypes.has("user.message")).toBe(true); + expect(eventTypes.has("assistant.message")).toBe(true); + }, + LIVE_TIMEOUT_MS, + ); +}); diff --git a/src/logging/diagnostic-support-bundle.test.ts b/src/logging/diagnostic-support-bundle.test.ts new file mode 100644 index 00000000000..a51d1e238f9 --- /dev/null +++ b/src/logging/diagnostic-support-bundle.test.ts @@ -0,0 +1,75 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import JSZip from "jszip"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + jsonSupportBundleFile, + textSupportBundleFile, + writeSupportBundleDirectory, + writeSupportBundleZip, +} from "./diagnostic-support-bundle.js"; + +describe("diagnostic support bundle helpers", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-support-bundle-")); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it("writes directory bundles with restrictive file permissions and byte inventory", () => { + const outputDir = path.join(tempDir, "bundle"); + const contents = writeSupportBundleDirectory({ + outputDir, + files: [ + jsonSupportBundleFile("manifest.json", { ok: true }), + textSupportBundleFile("nested/summary.md", "hello"), + ], + }); + + expect(contents).toEqual([ + { + path: "manifest.json", + mediaType: "application/json", + bytes: Buffer.byteLength('{\n "ok": true\n}\n', "utf8"), + }, + { + path: "nested/summary.md", + mediaType: "text/plain; charset=utf-8", + bytes: Buffer.byteLength("hello\n", "utf8"), + }, + ]); + expect(fs.statSync(path.join(outputDir, "manifest.json")).mode & 0o777).toBe(0o600); + expect(fs.statSync(path.join(outputDir, "nested", "summary.md")).mode & 0o777).toBe(0o600); + }); + + it("rejects absolute and traversal bundle paths", async () => { + expect(() => jsonSupportBundleFile("../escape.json", {})).toThrow(/Invalid bundle/u); + expect(() => textSupportBundleFile("/tmp/escape.txt", "nope")).toThrow(/Invalid bundle/u); + + await expect( + writeSupportBundleZip({ + outputPath: path.join(tempDir, "bundle.zip"), + files: [{ path: "nested/../escape.txt", mediaType: "text/plain", content: "nope" }], + }), + ).rejects.toThrow(/Invalid bundle/u); + }); + + it("writes zip bundles through the same file model", async () => { + const outputPath = path.join(tempDir, "bundle.zip"); + const bytes = await writeSupportBundleZip({ + outputPath, + files: [jsonSupportBundleFile("manifest.json", { ok: true })], + }); + + expect(bytes).toBeGreaterThan(0); + expect(fs.statSync(outputPath).mode & 0o777).toBe(0o600); + + const zip = await JSZip.loadAsync(fs.readFileSync(outputPath)); + expect(await zip.file("manifest.json")?.async("string")).toBe('{\n "ok": true\n}\n'); + }); +}); diff --git a/src/logging/diagnostic-support-bundle.ts b/src/logging/diagnostic-support-bundle.ts new file mode 100644 index 00000000000..b7aac1f3dc6 --- /dev/null +++ b/src/logging/diagnostic-support-bundle.ts @@ -0,0 +1,147 @@ +import fs from "node:fs"; +import path from "node:path"; +import JSZip from "jszip"; + +export type DiagnosticSupportBundleFile = { + path: string; + mediaType: string; + content: string; +}; + +export type DiagnosticSupportBundleContent = { + path: string; + mediaType: string; + bytes: number; +}; + +export function supportBundleByteLength(content: string): number { + return Buffer.byteLength(content, "utf8"); +} + +export function jsonSupportBundleFile( + pathName: string, + value: unknown, +): DiagnosticSupportBundleFile { + return { + path: assertSafeBundleRelativePath(pathName), + mediaType: "application/json", + content: `${JSON.stringify(value, null, 2)}\n`, + }; +} + +export function jsonlSupportBundleFile( + pathName: string, + lines: readonly string[], +): DiagnosticSupportBundleFile { + return { + path: assertSafeBundleRelativePath(pathName), + mediaType: "application/x-ndjson", + content: `${lines.join("\n")}\n`, + }; +} + +export function textSupportBundleFile( + pathName: string, + content: string, +): DiagnosticSupportBundleFile { + return { + path: assertSafeBundleRelativePath(pathName), + mediaType: "text/plain; charset=utf-8", + content: content.endsWith("\n") ? content : `${content}\n`, + }; +} + +export function supportBundleContents( + files: readonly DiagnosticSupportBundleFile[], +): DiagnosticSupportBundleContent[] { + return files.map((file) => ({ + path: file.path, + mediaType: file.mediaType, + bytes: supportBundleByteLength(file.content), + })); +} + +export function assertSafeBundleRelativePath(pathName: string): string { + const normalized = pathName.replaceAll("\\", "/"); + if ( + !normalized || + normalized.startsWith("/") || + normalized.split("/").some((part) => part === "" || part === "." || part === "..") + ) { + throw new Error(`Invalid bundle file path: ${pathName}`); + } + return normalized; +} + +export function prepareSupportBundleDirectory(outputDir: string): void { + fs.mkdirSync(path.dirname(outputDir), { recursive: true, mode: 0o700 }); + fs.mkdirSync(outputDir, { mode: 0o700 }); +} + +export function resolveSupportBundleFilePath(outputDir: string, pathName: string): string { + const safePath = assertSafeBundleRelativePath(pathName); + const resolvedBase = path.resolve(outputDir); + const resolvedFile = path.resolve(resolvedBase, safePath); + const relative = path.relative(resolvedBase, resolvedFile); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error(`Bundle file path escaped output directory: ${pathName}`); + } + return resolvedFile; +} + +export function writeSupportBundleFile(outputDir: string, file: DiagnosticSupportBundleFile): void { + const filePath = resolveSupportBundleFilePath(outputDir, file.path); + fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 }); + fs.writeFileSync(filePath, file.content, { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }); +} + +export function copySupportBundleFile(params: { + outputDir: string; + sourceFile: string; + path: string; +}): DiagnosticSupportBundleContent { + const outputPath = resolveSupportBundleFilePath(params.outputDir, params.path); + fs.mkdirSync(path.dirname(outputPath), { recursive: true, mode: 0o700 }); + fs.copyFileSync(params.sourceFile, outputPath, fs.constants.COPYFILE_EXCL); + fs.chmodSync(outputPath, 0o600); + const stat = fs.statSync(outputPath); + return { + path: assertSafeBundleRelativePath(params.path), + mediaType: "application/x-ndjson", + bytes: stat.size, + }; +} + +export function writeSupportBundleDirectory(params: { + outputDir: string; + files: readonly DiagnosticSupportBundleFile[]; +}): DiagnosticSupportBundleContent[] { + prepareSupportBundleDirectory(params.outputDir); + for (const file of params.files) { + writeSupportBundleFile(params.outputDir, file); + } + return supportBundleContents(params.files); +} + +export async function writeSupportBundleZip(params: { + outputPath: string; + files: readonly DiagnosticSupportBundleFile[]; + compressionLevel?: number; +}): Promise { + const zip = new JSZip(); + for (const file of params.files) { + zip.file(assertSafeBundleRelativePath(file.path), file.content); + } + const buffer = await zip.generateAsync({ + type: "nodebuffer", + compression: "DEFLATE", + compressionOptions: { level: params.compressionLevel ?? 6 }, + }); + fs.mkdirSync(path.dirname(params.outputPath), { recursive: true, mode: 0o700 }); + fs.writeFileSync(params.outputPath, buffer, { mode: 0o600 }); + return buffer.length; +} diff --git a/src/logging/diagnostic-support-export.ts b/src/logging/diagnostic-support-export.ts index cb3a49ea61e..6aa3e317062 100644 --- a/src/logging/diagnostic-support-export.ts +++ b/src/logging/diagnostic-support-export.ts @@ -1,7 +1,6 @@ import fs from "node:fs"; import path from "node:path"; import process from "node:process"; -import JSZip from "jszip"; import { parseConfigJson5 } from "../config/io.js"; import { resolveConfigPath, resolveStateDir } from "../config/paths.js"; import { redactConfigObject } from "../config/redact-snapshot.js"; @@ -12,6 +11,15 @@ import { readLatestDiagnosticStabilityBundleSync, type ReadDiagnosticStabilityBundleResult, } from "./diagnostic-stability-bundle.js"; +import { + jsonSupportBundleFile, + jsonlSupportBundleFile, + supportBundleContents, + textSupportBundleFile, + writeSupportBundleZip, + type DiagnosticSupportBundleContent, + type DiagnosticSupportBundleFile, +} from "./diagnostic-support-bundle.js"; import { sanitizeSupportLogRecord } from "./diagnostic-support-log-redaction.js"; import { redactPathForSupport, @@ -54,11 +62,7 @@ export type DiagnosticSupportExportManifest = { arch: string; node: string; stateDir: string; - contents: Array<{ - path: string; - mediaType: string; - bytes: number; - }>; + contents: DiagnosticSupportBundleContent[]; privacy: { payloadFree: true; rawLogsIncluded: false; @@ -66,11 +70,7 @@ export type DiagnosticSupportExportManifest = { }; }; -export type DiagnosticSupportExportFile = { - path: string; - mediaType: string; - content: string; -}; +export type DiagnosticSupportExportFile = DiagnosticSupportBundleFile; export type DiagnosticSupportExportArtifact = { manifest: DiagnosticSupportExportManifest; @@ -157,26 +157,6 @@ function formatExportTimestamp(now: Date): string { return now.toISOString().replace(/[:.]/g, "-"); } -function byteLength(content: string): number { - return Buffer.byteLength(content, "utf8"); -} - -function jsonFile(pathName: string, value: unknown): DiagnosticSupportExportFile { - return { - path: pathName, - mediaType: "application/json", - content: `${JSON.stringify(value, null, 2)}\n`, - }; -} - -function textFile(pathName: string, content: string): DiagnosticSupportExportFile { - return { - path: pathName, - mediaType: "text/plain; charset=utf-8", - content: content.endsWith("\n") ? content : `${content}\n`, - }; -} - function normalizePositiveInteger(value: unknown, fallback: number): number { const parsed = typeof value === "number" ? value : Number(value); if (!Number.isFinite(parsed) || parsed < 1) { @@ -354,7 +334,7 @@ async function collectSupportSnapshot(params: { status: "included", path: params.path, }, - file: jsonFile(params.path, { + file: jsonSupportBundleFile(params.path, { status: "ok", capturedAt: params.generatedAt, data: sanitizeSupportSnapshotValue(data, params.redaction), @@ -368,7 +348,7 @@ async function collectSupportSnapshot(params: { path: params.path, error: redactedError, }, - file: jsonFile(params.path, { + file: jsonSupportBundleFile(params.path, { status: "failed", capturedAt: params.generatedAt, error: redactedError, @@ -626,14 +606,13 @@ export async function buildDiagnosticSupportExport( health: healthSnapshot.summary, }; const files: DiagnosticSupportExportFile[] = [ - jsonFile("diagnostics.json", diagnostics), - jsonFile("config/shape.json", config.shape), - jsonFile("config/sanitized.json", config.sanitized ?? null), - { - path: "logs/openclaw-sanitized.jsonl", - mediaType: "application/x-ndjson", - content: logTail.lines.map((line) => JSON.stringify(line)).join("\n") + "\n", - }, + jsonSupportBundleFile("diagnostics.json", diagnostics), + jsonSupportBundleFile("config/shape.json", config.shape), + jsonSupportBundleFile("config/sanitized.json", config.sanitized ?? null), + jsonlSupportBundleFile( + "logs/openclaw-sanitized.jsonl", + logTail.lines.map((line) => JSON.stringify(line)), + ), ]; for (const snapshot of [statusSnapshot, healthSnapshot]) { if (snapshot.file) { @@ -642,11 +621,11 @@ export async function buildDiagnosticSupportExport( } if (stability.status === "found") { - files.push(jsonFile("stability/latest.json", stability.bundle)); + files.push(jsonSupportBundleFile("stability/latest.json", stability.bundle)); } files.push( - textFile( + textSupportBundleFile( "summary.md", renderSummary({ generatedAt, @@ -667,11 +646,7 @@ export async function buildDiagnosticSupportExport( arch: process.arch, node: process.versions.node, stateDir: redactPathForSupport(stateDir, redaction), - contents: files.map((file) => ({ - path: file.path, - mediaType: file.mediaType, - bytes: byteLength(file.content), - })), + contents: supportBundleContents(files), privacy: { payloadFree: true, rawLogsIncluded: false, @@ -686,7 +661,7 @@ export async function buildDiagnosticSupportExport( return { manifest, - files: [jsonFile("manifest.json", manifest), ...files], + files: [jsonSupportBundleFile("manifest.json", manifest), ...files], }; } @@ -704,20 +679,14 @@ export async function writeDiagnosticSupportExport( now, }); const artifact = await buildDiagnosticSupportExport({ ...options, env, stateDir, now }); - const zip = new JSZip(); - for (const file of artifact.files) { - zip.file(file.path, file.content); - } - const buffer = await zip.generateAsync({ - type: "nodebuffer", - compression: "DEFLATE", - compressionOptions: { level: 6 }, + const bytes = await writeSupportBundleZip({ + outputPath, + files: artifact.files, + compressionLevel: 6, }); - fs.mkdirSync(path.dirname(outputPath), { recursive: true, mode: 0o700 }); - fs.writeFileSync(outputPath, buffer, { mode: 0o600 }); return { path: outputPath, - bytes: buffer.length, + bytes, manifest: artifact.manifest, }; } diff --git a/src/trajectory/export.test.ts b/src/trajectory/export.test.ts new file mode 100644 index 00000000000..37126294fcc --- /dev/null +++ b/src/trajectory/export.test.ts @@ -0,0 +1,750 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { Message, Usage } from "@mariozechner/pi-ai"; +import { SessionManager } from "@mariozechner/pi-coding-agent"; +import { afterEach, describe, expect, it } from "vitest"; +import { exportTrajectoryBundle, resolveDefaultTrajectoryExportDir } from "./export.js"; +import { resolveTrajectoryPointerFilePath } from "./runtime.js"; +import type { TrajectoryEvent } from "./types.js"; + +const tempDirs: string[] = []; + +function makeTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-trajectory-")); + tempDirs.push(dir); + return dir; +} + +const emptyUsage: Usage = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, +}; + +function userMessage(content: string): Message { + return { + role: "user", + content, + timestamp: 1, + }; +} + +function assistantMessage(content: Extract["content"]): Message { + return { + role: "assistant", + content, + api: "openai-responses", + provider: "openai", + model: "gpt-5.4", + usage: emptyUsage, + stopReason: "stop", + timestamp: 2, + }; +} + +function toolResultMessage(content: Extract["content"]): Message { + return { + role: "toolResult", + toolCallId: "call_1", + toolName: "read", + content, + isError: false, + timestamp: 3, + }; +} + +function writeSimpleSessionFile( + sessionFile: string, + params: { userEntryTimestamp?: string | number } = {}, +): void { + const header = { + type: "session", + version: 3, + id: "session-1", + timestamp: "2026-04-01T05:46:39.000Z", + cwd: path.dirname(sessionFile), + }; + const userEntry = { + type: "message", + id: "entry-user", + parentId: null, + timestamp: params.userEntryTimestamp ?? "2026-04-01T05:46:40.000Z", + message: userMessage("hello"), + }; + const assistantEntry = { + type: "message", + id: "entry-assistant", + parentId: "entry-user", + timestamp: "2026-04-01T05:46:41.000Z", + message: assistantMessage([{ type: "text", text: "done" }]), + }; + fs.writeFileSync( + sessionFile, + `${[header, userEntry, assistantEntry].map((entry) => JSON.stringify(entry)).join("\n")}\n`, + "utf8", + ); +} + +function writeToolCallOnlySessionFile(sessionFile: string): void { + const header = { + type: "session", + version: 3, + id: "session-1", + timestamp: "2026-04-01T05:46:39.000Z", + cwd: path.dirname(sessionFile), + }; + const assistantEntry = { + type: "message", + id: "entry-assistant", + parentId: null, + timestamp: "2026-04-01T05:46:41.000Z", + message: assistantMessage([ + { + type: "toolCall", + id: "call_1", + name: "read", + arguments: { filePath: "README.md" }, + }, + ]), + }; + fs.writeFileSync( + sessionFile, + `${[header, assistantEntry].map((entry) => JSON.stringify(entry)).join("\n")}\n`, + "utf8", + ); +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("exportTrajectoryBundle", () => { + it("sanitizes session ids in default export directory names", () => { + const outputDir = resolveDefaultTrajectoryExportDir({ + workspaceDir: "/tmp/workspace", + sessionId: "../evil/session", + now: new Date("2026-04-22T08:00:00.000Z"), + }); + + expect(outputDir).toBe( + path.join( + "/tmp/workspace", + ".openclaw", + "trajectory-exports", + "openclaw-trajectory-___evil_-2026-04-22T08-00-00", + ), + ); + }); + + it("refuses to write into an existing output directory", () => { + const tmpDir = makeTempDir(); + const sessionFile = path.join(tmpDir, "session.jsonl"); + const outputDir = path.join(tmpDir, "bundle"); + writeSimpleSessionFile(sessionFile); + fs.mkdirSync(outputDir); + + expect(() => + exportTrajectoryBundle({ + outputDir, + sessionFile, + sessionId: "session-1", + workspaceDir: tmpDir, + }), + ).toThrow(); + }); + + it("does not synthesize prompt files from export-time fallbacks", () => { + const tmpDir = makeTempDir(); + const sessionFile = path.join(tmpDir, "session.jsonl"); + const outputDir = path.join(tmpDir, "bundle"); + writeSimpleSessionFile(sessionFile); + + const bundle = exportTrajectoryBundle({ + outputDir, + sessionFile, + sessionId: "session-1", + workspaceDir: tmpDir, + systemPrompt: "fallback prompt", + tools: [{ name: "fallback" }], + }); + + expect(bundle.supplementalFiles).not.toContain("prompts.json"); + expect(fs.existsSync(path.join(outputDir, "prompts.json"))).toBe(false); + expect(fs.existsSync(path.join(outputDir, "system-prompt.txt"))).toBe(false); + expect(fs.existsSync(path.join(outputDir, "tools.json"))).toBe(false); + }); + + it("preserves numeric transcript timestamps", () => { + const tmpDir = makeTempDir(); + const sessionFile = path.join(tmpDir, "session.jsonl"); + const outputDir = path.join(tmpDir, "bundle"); + writeSimpleSessionFile(sessionFile, { + userEntryTimestamp: Date.parse("2026-04-01T05:46:40.000Z"), + }); + + exportTrajectoryBundle({ + outputDir, + sessionFile, + sessionId: "session-1", + workspaceDir: tmpDir, + }); + + const exportedEvents = fs + .readFileSync(path.join(outputDir, "events.jsonl"), "utf8") + .trim() + .split(/\r?\n/u) + .map((line) => JSON.parse(line) as TrajectoryEvent); + expect(exportedEvents.find((event) => event.type === "user.message")?.ts).toBe( + "2026-04-01T05:46:40.000Z", + ); + }); + + it("rejects oversized runtime trajectory files", () => { + const tmpDir = makeTempDir(); + const sessionFile = path.join(tmpDir, "session.jsonl"); + const runtimeFile = path.join(tmpDir, "session.trajectory.jsonl"); + const outputDir = path.join(tmpDir, "bundle"); + writeSimpleSessionFile(sessionFile); + fs.closeSync(fs.openSync(runtimeFile, "w")); + fs.truncateSync(runtimeFile, 50 * 1024 * 1024 + 1); + + expect(() => + exportTrajectoryBundle({ + outputDir, + sessionFile, + sessionId: "session-1", + workspaceDir: tmpDir, + runtimeFile, + }), + ).toThrow(/too large/u); + }); + + it("rejects oversized session transcript files before export", () => { + const tmpDir = makeTempDir(); + const sessionFile = path.join(tmpDir, "session.jsonl"); + const outputDir = path.join(tmpDir, "bundle"); + fs.closeSync(fs.openSync(sessionFile, "w")); + fs.truncateSync(sessionFile, 50 * 1024 * 1024 + 1); + + expect(() => + exportTrajectoryBundle({ + outputDir, + sessionFile, + sessionId: "session-1", + workspaceDir: tmpDir, + }), + ).toThrow(/session file is too large/u); + }); + + it("skips malformed-but-valid runtime json rows before sorting", () => { + const tmpDir = makeTempDir(); + const sessionFile = path.join(tmpDir, "session.jsonl"); + const runtimeFile = path.join(tmpDir, "session.trajectory.jsonl"); + const outputDir = path.join(tmpDir, "bundle"); + writeSimpleSessionFile(sessionFile); + fs.writeFileSync( + runtimeFile, + `${JSON.stringify({})}\n${JSON.stringify({ + traceSchema: "openclaw-trajectory", + schemaVersion: 1, + traceId: "session-1", + source: "runtime", + type: "session.started", + ts: "2026-04-22T08:00:00.000Z", + seq: 1, + sourceSeq: 1, + sessionId: "session-1", + })}\n`, + "utf8", + ); + + const bundle = exportTrajectoryBundle({ + outputDir, + sessionFile, + sessionId: "session-1", + workspaceDir: tmpDir, + }); + + expect(bundle.manifest.runtimeEventCount).toBe(1); + expect(bundle.events.some((event) => event.type === "session.started")).toBe(true); + }); + + it("uses the recorded runtime pointer before current environment overrides", () => { + const tmpDir = makeTempDir(); + const sessionFile = path.join(tmpDir, "session.jsonl"); + const recordedRuntimeFile = path.join(tmpDir, "recorded", "session-1.jsonl"); + const envRuntimeDir = path.join(tmpDir, "current-env"); + const outputDir = path.join(tmpDir, "bundle"); + writeSimpleSessionFile(sessionFile); + fs.mkdirSync(path.dirname(recordedRuntimeFile), { recursive: true }); + fs.mkdirSync(envRuntimeDir); + fs.writeFileSync( + resolveTrajectoryPointerFilePath(sessionFile), + `${JSON.stringify({ + traceSchema: "openclaw-trajectory-pointer", + schemaVersion: 1, + sessionId: "session-1", + runtimeFile: recordedRuntimeFile, + })}\n`, + "utf8", + ); + fs.writeFileSync( + recordedRuntimeFile, + `${JSON.stringify({ + traceSchema: "openclaw-trajectory", + schemaVersion: 1, + traceId: "session-1", + source: "runtime", + type: "recorded-runtime", + ts: "2026-04-22T08:00:00.000Z", + seq: 1, + sourceSeq: 1, + sessionId: "session-1", + })}\n`, + "utf8", + ); + fs.writeFileSync( + path.join(envRuntimeDir, "session-1.jsonl"), + `${JSON.stringify({ + traceSchema: "openclaw-trajectory", + schemaVersion: 1, + traceId: "session-1", + source: "runtime", + type: "env-runtime", + ts: "2026-04-22T08:00:00.000Z", + seq: 1, + sourceSeq: 1, + sessionId: "session-1", + })}\n`, + "utf8", + ); + const previous = process.env.OPENCLAW_TRAJECTORY_DIR; + process.env.OPENCLAW_TRAJECTORY_DIR = envRuntimeDir; + try { + const bundle = exportTrajectoryBundle({ + outputDir, + sessionFile, + sessionId: "session-1", + workspaceDir: tmpDir, + }); + + expect(bundle.runtimeFile).toBe(recordedRuntimeFile); + expect(bundle.events.some((event) => event.type === "recorded-runtime")).toBe(true); + expect(bundle.events.some((event) => event.type === "env-runtime")).toBe(false); + } finally { + if (previous === undefined) { + delete process.env.OPENCLAW_TRAJECTORY_DIR; + } else { + process.env.OPENCLAW_TRAJECTORY_DIR = previous; + } + } + }); + + it("ignores runtime pointers that do not look like this session's trajectory file", () => { + const tmpDir = makeTempDir(); + const sessionFile = path.join(tmpDir, "session.jsonl"); + const outsideFile = path.join(tmpDir, "outside.jsonl"); + const outputDir = path.join(tmpDir, "bundle"); + writeSimpleSessionFile(sessionFile); + fs.writeFileSync( + resolveTrajectoryPointerFilePath(sessionFile), + `${JSON.stringify({ + traceSchema: "openclaw-trajectory-pointer", + schemaVersion: 1, + sessionId: "session-1", + runtimeFile: outsideFile, + })}\n`, + "utf8", + ); + fs.writeFileSync( + outsideFile, + `${JSON.stringify({ + traceSchema: "openclaw-trajectory", + schemaVersion: 1, + traceId: "session-1", + source: "runtime", + type: "outside-runtime", + ts: "2026-04-22T08:00:00.000Z", + seq: 1, + sourceSeq: 1, + sessionId: "session-1", + })}\n`, + "utf8", + ); + + const bundle = exportTrajectoryBundle({ + outputDir, + sessionFile, + sessionId: "session-1", + workspaceDir: tmpDir, + }); + + expect(bundle.runtimeFile).toBeUndefined(); + expect(bundle.events.some((event) => event.type === "outside-runtime")).toBe(false); + }); + + it("does not fall back to runtime pointer targets that are not regular files", () => { + const tmpDir = makeTempDir(); + const sessionFile = path.join(tmpDir, "session.jsonl"); + const targetFile = path.join(tmpDir, "outside-target.jsonl"); + const symlinkFile = path.join(tmpDir, "recorded", "session-1.jsonl"); + const outputDir = path.join(tmpDir, "bundle"); + writeSimpleSessionFile(sessionFile); + fs.mkdirSync(path.dirname(symlinkFile), { recursive: true }); + fs.writeFileSync( + resolveTrajectoryPointerFilePath(sessionFile), + `${JSON.stringify({ + traceSchema: "openclaw-trajectory-pointer", + schemaVersion: 1, + sessionId: "session-1", + runtimeFile: symlinkFile, + })}\n`, + "utf8", + ); + fs.writeFileSync( + targetFile, + `${JSON.stringify({ + traceSchema: "openclaw-trajectory", + schemaVersion: 1, + traceId: "session-1", + source: "runtime", + type: "symlink-runtime", + ts: "2026-04-22T08:00:00.000Z", + seq: 1, + sourceSeq: 1, + sessionId: "session-1", + })}\n`, + "utf8", + ); + fs.symlinkSync(targetFile, symlinkFile); + + const bundle = exportTrajectoryBundle({ + outputDir, + sessionFile, + sessionId: "session-1", + workspaceDir: tmpDir, + }); + + expect(bundle.runtimeFile).toBeUndefined(); + expect(bundle.events.some((event) => event.type === "symlink-runtime")).toBe(false); + }); + + it("counts expanded transcript events when enforcing the total event limit", () => { + const tmpDir = makeTempDir(); + const sessionFile = path.join(tmpDir, "session.jsonl"); + const outputDir = path.join(tmpDir, "bundle"); + writeToolCallOnlySessionFile(sessionFile); + + expect(() => + exportTrajectoryBundle({ + outputDir, + sessionFile, + sessionId: "session-1", + workspaceDir: tmpDir, + maxTotalEvents: 1, + }), + ).toThrow(/too many events \(2; limit 1\)/u); + }); + + it("skips runtime events for other sessions", () => { + const tmpDir = makeTempDir(); + const sessionFile = path.join(tmpDir, "session.jsonl"); + const runtimeFile = path.join(tmpDir, "session.trajectory.jsonl"); + const outputDir = path.join(tmpDir, "bundle"); + writeSimpleSessionFile(sessionFile); + fs.writeFileSync( + runtimeFile, + `${JSON.stringify({ + traceSchema: "openclaw-trajectory", + schemaVersion: 1, + traceId: "other-session", + source: "runtime", + type: "other-runtime", + ts: "2026-04-22T08:00:00.000Z", + seq: 1, + sourceSeq: 1, + sessionId: "other-session", + })}\n`, + "utf8", + ); + + const bundle = exportTrajectoryBundle({ + outputDir, + sessionFile, + sessionId: "session-1", + workspaceDir: tmpDir, + }); + + expect(bundle.manifest.runtimeEventCount).toBe(0); + expect(bundle.events.some((event) => event.type === "other-runtime")).toBe(false); + }); + + it("redacts non-workspace paths in strings that also contain workspace paths", () => { + const tmpDir = makeTempDir(); + const homeDir = makeTempDir(); + const sessionFile = path.join(tmpDir, "session.jsonl"); + const runtimeFile = path.join(tmpDir, "session.trajectory.jsonl"); + const outputDir = path.join(tmpDir, "bundle"); + const previousHome = process.env.HOME; + writeSimpleSessionFile(sessionFile); + fs.writeFileSync( + runtimeFile, + `${JSON.stringify({ + traceSchema: "openclaw-trajectory", + schemaVersion: 1, + traceId: "session-1", + source: "runtime", + type: "mixed-paths", + ts: "2026-04-22T08:00:00.000Z", + seq: 1, + sourceSeq: 1, + sessionId: "session-1", + data: { + value: `workspace=${path.join(tmpDir, "inside.txt")} home=${path.join( + homeDir, + "secret.txt", + )}`, + }, + })}\n`, + "utf8", + ); + + process.env.HOME = homeDir; + try { + exportTrajectoryBundle({ + outputDir, + sessionFile, + sessionId: "session-1", + workspaceDir: tmpDir, + runtimeFile, + }); + } finally { + if (previousHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = previousHome; + } + } + + const events = fs.readFileSync(path.join(outputDir, "events.jsonl"), "utf8"); + expect(events).toContain("$WORKSPACE_DIR"); + expect(events).toContain("~"); + expect(events).not.toContain(tmpDir); + expect(events).not.toContain(homeDir); + }); + + it("exports merged runtime and transcript events plus convenience files", () => { + const tmpDir = makeTempDir(); + const sessionFile = path.join(tmpDir, "session.jsonl"); + const runtimeFile = path.join(tmpDir, "session.trajectory.jsonl"); + const outputDir = path.join(tmpDir, "bundle"); + const sessionManager = SessionManager.open(sessionFile); + + sessionManager.appendSessionInfo("Trajectory Test"); + sessionManager.appendMessage(userMessage("hello")); + sessionManager.appendMessage( + assistantMessage([ + { + type: "toolCall", + id: "call_1", + name: "read", + arguments: { filePath: path.join(tmpDir, "skills", "weather", "SKILL.md") }, + }, + ]), + ); + sessionManager.appendMessage(toolResultMessage([{ type: "text", text: "README contents" }])); + sessionManager.appendMessage(assistantMessage([{ type: "text", text: "done" }])); + + const runtimeEvents: TrajectoryEvent[] = [ + { + traceSchema: "openclaw-trajectory", + schemaVersion: 1, + traceId: "session-1", + source: "runtime", + type: "session.started", + ts: "2026-04-22T08:00:00.000Z", + seq: 1, + sourceSeq: 1, + sessionId: "session-1", + data: { + trigger: "user", + workspacePath: path.join(tmpDir, "inside.txt"), + prefixOnlyPath: `${tmpDir}2/outside.txt`, + }, + }, + { + traceSchema: "openclaw-trajectory", + schemaVersion: 1, + traceId: "session-1", + source: "runtime", + type: "context.compiled", + ts: "2026-04-22T08:00:01.000Z", + seq: 2, + sourceSeq: 2, + sessionId: "session-1", + data: { + systemPrompt: `system prompt for ${path.join(tmpDir, "instructions.md")}`, + tools: [ + { + name: "read", + description: `Reads ${path.join(tmpDir, "docs")}`, + parameters: { type: "object" }, + }, + ], + }, + }, + { + traceSchema: "openclaw-trajectory", + schemaVersion: 1, + traceId: "session-1", + source: "runtime", + type: "trace.metadata", + ts: "2026-04-22T08:00:01.500Z", + seq: 3, + sourceSeq: 3, + sessionId: "session-1", + data: { + harness: { type: "openclaw", version: "0.1.0" }, + model: { provider: "openai", name: "gpt-5.4" }, + skills: { + entries: [ + { + id: "weather", + filePath: path.join(tmpDir, "skills", "weather", "SKILL.md"), + }, + ], + }, + prompting: { + systemPromptReport: { + workspaceDir: tmpDir, + injectedWorkspaceFiles: [{ path: path.join(tmpDir, "AGENTS.md") }], + }, + }, + }, + }, + { + traceSchema: "openclaw-trajectory", + schemaVersion: 1, + traceId: "session-1", + source: "runtime", + type: "prompt.submitted", + ts: "2026-04-22T08:00:02.000Z", + seq: 4, + sourceSeq: 4, + sessionId: "session-1", + data: { + prompt: "Please read the weather skill", + }, + }, + { + traceSchema: "openclaw-trajectory", + schemaVersion: 1, + traceId: "session-1", + source: "runtime", + type: "trace.artifacts", + ts: "2026-04-22T08:00:03.000Z", + seq: 5, + sourceSeq: 5, + sessionId: "session-1", + data: { + finalStatus: "success", + assistantTexts: ["done"], + finalPromptText: `final prompt from ${path.join(tmpDir, "prompt.txt")}`, + itemLifecycle: { + startedCount: 1, + completedCount: 1, + activeCount: 0, + }, + }, + }, + ]; + fs.writeFileSync( + runtimeFile, + `${runtimeEvents.map((event) => JSON.stringify(event)).join("\n")}\n`, + "utf8", + ); + + const bundle = exportTrajectoryBundle({ + outputDir, + sessionFile, + sessionId: "session-1", + sessionKey: "agent:main:session-1", + workspaceDir: tmpDir, + runtimeFile, + systemPrompt: "fallback prompt", + tools: [{ name: "fallback" }], + }); + + expect(bundle.manifest.eventCount).toBeGreaterThanOrEqual(5); + expect(bundle.manifest.runtimeEventCount).toBe(runtimeEvents.length); + expect(fs.existsSync(path.join(outputDir, "manifest.json"))).toBe(true); + expect(fs.existsSync(path.join(outputDir, "events.jsonl"))).toBe(true); + expect(fs.existsSync(path.join(outputDir, "session.jsonl"))).toBe(false); + expect(fs.existsSync(path.join(outputDir, "runtime.jsonl"))).toBe(false); + expect(fs.existsSync(path.join(outputDir, "system-prompt.txt"))).toBe(true); + expect(fs.existsSync(path.join(outputDir, "tools.json"))).toBe(true); + expect(fs.existsSync(path.join(outputDir, "metadata.json"))).toBe(true); + expect(fs.existsSync(path.join(outputDir, "artifacts.json"))).toBe(true); + expect(fs.existsSync(path.join(outputDir, "prompts.json"))).toBe(true); + expect(bundle.supplementalFiles).toEqual(["metadata.json", "artifacts.json", "prompts.json"]); + + const exportedEvents = fs + .readFileSync(path.join(outputDir, "events.jsonl"), "utf8") + .trim() + .split(/\r?\n/u) + .map((line) => JSON.parse(line) as TrajectoryEvent); + expect(exportedEvents.some((event) => event.type === "tool.call")).toBe(true); + expect(exportedEvents.some((event) => event.type === "tool.result")).toBe(true); + expect(exportedEvents.some((event) => event.type === "context.compiled")).toBe(true); + expect(JSON.stringify(exportedEvents)).toContain("$WORKSPACE_DIR/inside.txt"); + expect(JSON.stringify(exportedEvents)).not.toContain("$WORKSPACE_DIR2"); + + const manifest = JSON.parse(fs.readFileSync(path.join(outputDir, "manifest.json"), "utf8")) as { + contents?: Array<{ path: string; mediaType: string; bytes: number }>; + sourceFiles?: { session?: string; runtime?: string }; + workspaceDir?: string; + }; + expect(manifest.workspaceDir).toBe("$WORKSPACE_DIR"); + expect(manifest.sourceFiles?.session).toBe("$WORKSPACE_DIR/session.jsonl"); + expect(manifest.sourceFiles?.runtime).toBe("$WORKSPACE_DIR/session.trajectory.jsonl"); + expect(manifest.contents?.map((entry) => entry.path).toSorted()).toEqual([ + "artifacts.json", + "events.jsonl", + "metadata.json", + "prompts.json", + "session-branch.json", + "system-prompt.txt", + "tools.json", + ]); + expect(manifest.contents?.every((entry) => entry.bytes > 0)).toBe(true); + + const metadata = JSON.parse(fs.readFileSync(path.join(outputDir, "metadata.json"), "utf8")) as { + skills?: { entries?: Array<{ id?: string; invoked?: boolean }> }; + }; + expect(metadata.skills?.entries?.[0]).toMatchObject({ + id: "weather", + invoked: true, + }); + const prompts = fs.readFileSync(path.join(outputDir, "prompts.json"), "utf8"); + const artifacts = fs.readFileSync(path.join(outputDir, "artifacts.json"), "utf8"); + const systemPrompt = fs.readFileSync(path.join(outputDir, "system-prompt.txt"), "utf8"); + const tools = fs.readFileSync(path.join(outputDir, "tools.json"), "utf8"); + expect(prompts).toContain("$WORKSPACE_DIR/AGENTS.md"); + expect(artifacts).toContain("$WORKSPACE_DIR/prompt.txt"); + expect(systemPrompt).toContain("$WORKSPACE_DIR/instructions.md"); + expect(tools).toContain("$WORKSPACE_DIR/docs"); + expect(`${prompts}\n${artifacts}\n${systemPrompt}\n${tools}`).not.toContain(tmpDir); + }); +}); diff --git a/src/trajectory/export.ts b/src/trajectory/export.ts new file mode 100644 index 00000000000..4752a2e8a48 --- /dev/null +++ b/src/trajectory/export.ts @@ -0,0 +1,891 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { AgentMessage } from "@mariozechner/pi-agent-core"; +import type { SessionEntry, SessionHeader } from "@mariozechner/pi-coding-agent"; +import { SessionManager } from "@mariozechner/pi-coding-agent"; +import { sanitizeDiagnosticPayload } from "../agents/payload-redaction.js"; +import { resolveStateDir } from "../config/paths.js"; +import { + jsonSupportBundleFile, + jsonlSupportBundleFile, + supportBundleContents, + textSupportBundleFile, + writeSupportBundleDirectory, + type DiagnosticSupportBundleContent, + type DiagnosticSupportBundleFile, +} from "../logging/diagnostic-support-bundle.js"; +import { + redactSupportString, + type SupportRedactionContext, +} from "../logging/diagnostic-support-redaction.js"; +import { safeJsonStringify } from "../utils/safe-json.js"; +import { + TRAJECTORY_RUNTIME_FILE_MAX_BYTES, + resolveTrajectoryFilePath, + resolveTrajectoryPointerFilePath, + safeTrajectorySessionFileName, +} from "./runtime.js"; +import type { + TrajectoryBundleManifest, + TrajectoryEvent, + TrajectoryToolDefinition, +} from "./types.js"; + +type BuildTrajectoryBundleParams = { + outputDir: string; + sessionFile: string; + sessionId: string; + sessionKey?: string; + workspaceDir: string; + runtimeFile?: string; + systemPrompt?: string; + tools?: TrajectoryToolDefinition[]; + maxTotalEvents?: number; +}; + +type RuntimeTrajectoryContext = { + systemPrompt?: string; + tools?: TrajectoryToolDefinition[]; +}; + +type JsonRecord = Record; +type TrajectoryExportRedaction = SupportRedactionContext & { + workspaceDir: string; +}; + +const MAX_TRAJECTORY_RUNTIME_EVENTS = 200_000; +const MAX_TRAJECTORY_TOTAL_EVENTS = 250_000; +const MAX_TRAJECTORY_SESSION_FILE_BYTES = 50 * 1024 * 1024; + +function parseJsonlFile( + filePath: string, + params: { + maxBytes: number; + maxEvents: number; + validate?: (value: unknown) => value is T; + }, +): T[] { + if (!fs.existsSync(filePath)) { + return []; + } + const stat = fs.statSync(filePath); + if (stat.size > params.maxBytes) { + throw new Error( + `Trajectory runtime file is too large to export (${stat.size} bytes; limit ${params.maxBytes})`, + ); + } + const content = fs.readFileSync(filePath, "utf8"); + const rows = content + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter(Boolean); + const parsed: T[] = []; + for (const row of rows) { + if (parsed.length >= params.maxEvents) { + throw new Error( + `Trajectory runtime file has too many events to export (limit ${params.maxEvents})`, + ); + } + try { + const value = JSON.parse(row) as unknown; + if (!params.validate || params.validate(value)) { + parsed.push(value as T); + } + } catch { + // Keep exports resilient even if a single debug line is malformed. + } + } + return parsed; +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function isRuntimeTrajectoryEventForSession( + value: unknown, + sessionId: string, +): value is TrajectoryEvent { + if (!isRecord(value)) { + return false; + } + return ( + value.traceSchema === "openclaw-trajectory" && + value.schemaVersion === 1 && + value.source === "runtime" && + typeof value.type === "string" && + typeof value.ts === "string" && + !Number.isNaN(Date.parse(value.ts)) && + isFiniteNumber(value.seq) && + value.sessionId === sessionId && + (!("data" in value) || value.data === undefined || isRecord(value.data)) + ); +} + +function isRegularNonSymlinkFile(filePath: string): boolean { + try { + const linkStat = fs.lstatSync(filePath); + if (linkStat.isSymbolicLink() || !linkStat.isFile()) { + return false; + } + const stat = fs.statSync(filePath); + return stat.isFile() && stat.dev === linkStat.dev && stat.ino === linkStat.ino; + } catch { + return false; + } +} + +function readRuntimePointerFile(sessionFile: string, sessionId: string): string | undefined { + const pointerPath = resolveTrajectoryPointerFilePath(sessionFile); + if (!isRegularNonSymlinkFile(pointerPath)) { + return undefined; + } + try { + const parsed = JSON.parse(fs.readFileSync(pointerPath, "utf8")) as unknown; + if (!isRecord(parsed)) { + return undefined; + } + if (parsed.sessionId !== sessionId || typeof parsed.runtimeFile !== "string") { + return undefined; + } + const runtimeFile = path.resolve(parsed.runtimeFile); + const safeRuntimeFileName = `${safeTrajectorySessionFileName(sessionId)}.jsonl`; + const defaultRuntimeFile = path.resolve( + resolveTrajectoryFilePath({ + env: {}, + sessionFile, + sessionId, + }), + ); + if (runtimeFile !== defaultRuntimeFile && path.basename(runtimeFile) !== safeRuntimeFileName) { + return undefined; + } + return runtimeFile; + } catch { + return undefined; + } +} + +function resolveTrajectoryRuntimeFile(params: { + runtimeFile?: string; + sessionFile: string; + sessionId: string; +}): string | undefined { + if (params.runtimeFile) { + return params.runtimeFile; + } + const candidates = [ + readRuntimePointerFile(params.sessionFile, params.sessionId), + resolveTrajectoryFilePath({ + env: {}, + sessionFile: params.sessionFile, + sessionId: params.sessionId, + }), + resolveTrajectoryFilePath({ + sessionFile: params.sessionFile, + sessionId: params.sessionId, + }), + ].filter((candidate): candidate is string => Boolean(candidate)); + return candidates.find((candidate) => isRegularNonSymlinkFile(candidate)); +} + +function normalizeTimestamp(value: unknown): string { + if (typeof value === "number" && Number.isFinite(value)) { + const parsed = new Date(value); + if (!Number.isNaN(parsed.getTime())) { + return parsed.toISOString(); + } + } + if (typeof value === "string") { + const parsed = new Date(value); + if (!Number.isNaN(parsed.getTime())) { + return parsed.toISOString(); + } + } + return new Date(0).toISOString(); +} + +function resolveMessageEventType(message: AgentMessage): string { + if (message.role === "user") { + return "user.message"; + } + if (message.role === "assistant") { + return "assistant.message"; + } + if (message.role === "toolResult") { + return "tool.result"; + } + return `message.${message.role}`; +} + +function extractAssistantToolCalls( + message: AgentMessage, +): Array<{ id?: string; name?: string; arguments?: unknown; index: number }> { + if (message.role !== "assistant" || !Array.isArray(message.content)) { + return []; + } + return message.content.flatMap((block, index) => { + if (!block || typeof block !== "object") { + return []; + } + const typedBlock = block as { + type?: unknown; + id?: unknown; + name?: unknown; + arguments?: unknown; + input?: unknown; + parameters?: unknown; + }; + const blockType = + typeof typedBlock.type === "string" ? typedBlock.type.trim().toLowerCase() : ""; + if (blockType !== "toolcall" && blockType !== "tooluse" && blockType !== "functioncall") { + return []; + } + return [ + { + id: typeof typedBlock.id === "string" ? typedBlock.id : undefined, + name: typeof typedBlock.name === "string" ? typedBlock.name : undefined, + arguments: typedBlock.arguments ?? typedBlock.input ?? typedBlock.parameters, + index, + }, + ]; + }); +} + +function buildTranscriptEvents(params: { + entries: SessionEntry[]; + sessionId: string; + sessionKey?: string; + workspaceDir: string; + traceId: string; +}): TrajectoryEvent[] { + const events: TrajectoryEvent[] = []; + let seq = 0; + for (const entry of params.entries) { + const push = (type: string, data?: Record) => { + events.push({ + traceSchema: "openclaw-trajectory", + schemaVersion: 1, + traceId: params.traceId, + source: "transcript", + type, + ts: normalizeTimestamp(entry.timestamp), + seq: 0, + sourceSeq: (seq += 1), + sessionId: params.sessionId, + sessionKey: params.sessionKey, + workspaceDir: params.workspaceDir, + entryId: entry.id, + parentEntryId: entry.parentId, + data, + }); + }; + + switch (entry.type) { + case "message": { + push(resolveMessageEventType(entry.message), { + message: sanitizeDiagnosticPayload(entry.message), + }); + for (const toolCall of extractAssistantToolCalls(entry.message)) { + push("tool.call", { + toolCallId: toolCall.id, + name: toolCall.name, + arguments: sanitizeDiagnosticPayload(toolCall.arguments), + assistantEntryId: entry.id, + blockIndex: toolCall.index, + }); + } + break; + } + case "compaction": + push("session.compaction", { + summary: entry.summary, + firstKeptEntryId: entry.firstKeptEntryId, + tokensBefore: entry.tokensBefore, + details: sanitizeDiagnosticPayload(entry.details), + fromHook: entry.fromHook ?? false, + }); + break; + case "branch_summary": + push("session.branch_summary", { + fromId: entry.fromId, + summary: entry.summary, + details: sanitizeDiagnosticPayload(entry.details), + fromHook: entry.fromHook ?? false, + }); + break; + case "custom": + push("session.custom", { + customType: entry.customType, + data: sanitizeDiagnosticPayload(entry.data), + }); + break; + case "custom_message": + push("session.custom_message", { + customType: entry.customType, + content: sanitizeDiagnosticPayload(entry.content), + details: sanitizeDiagnosticPayload(entry.details), + display: entry.display, + }); + break; + case "thinking_level_change": + push("session.thinking_level_change", { + thinkingLevel: entry.thinkingLevel, + }); + break; + case "model_change": + push("session.model_change", { + provider: entry.provider, + modelId: entry.modelId, + }); + break; + case "label": + push("session.label", { + targetId: entry.targetId, + label: entry.label, + }); + break; + case "session_info": + push("session.info", { + name: entry.name, + }); + break; + } + } + return events; +} + +function sortTrajectoryEvents(events: TrajectoryEvent[]): TrajectoryEvent[] { + const sourceOrder: Record = { + runtime: 0, + transcript: 1, + export: 2, + }; + const sorted = events.toSorted((left, right) => { + const byTs = left.ts.localeCompare(right.ts); + if (byTs !== 0) { + return byTs; + } + const bySource = sourceOrder[left.source] - sourceOrder[right.source]; + if (bySource !== 0) { + return bySource; + } + return (left.sourceSeq ?? left.seq) - (right.sourceSeq ?? right.seq); + }); + for (const [index, event] of sorted.entries()) { + event.seq = index + 1; + } + return sorted; +} + +function trajectoryJsonlFile( + pathName: string, + events: TrajectoryEvent[], +): DiagnosticSupportBundleFile { + const lines = events + .map((event) => safeJsonStringify(event)) + .filter((line): line is string => Boolean(line)); + return jsonlSupportBundleFile(pathName, lines); +} + +function buildTrajectoryExportRedaction(params: { + workspaceDir: string; +}): TrajectoryExportRedaction { + const env = process.env; + return { + env, + stateDir: resolveStateDir(env), + workspaceDir: path.resolve(params.workspaceDir), + }; +} + +function redactWorkspacePathString(value: string, redaction: TrajectoryExportRedaction): string { + const workspaceDir = redaction.workspaceDir; + if (!workspaceDir) { + return value; + } + const normalizedWorkspaceDir = workspaceDir.replaceAll("\\", "/"); + let next = value; + for (const candidate of new Set([workspaceDir, normalizedWorkspaceDir])) { + if (!candidate) { + continue; + } + const escaped = candidate.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + next = next.replace(new RegExp(`${escaped}(?=$|[\\\\/])`, "gu"), "$WORKSPACE_DIR"); + } + return next; +} + +function maybeRedactPathString(value: string, redaction: TrajectoryExportRedaction): string { + const workspaceRedacted = redactWorkspacePathString(value, redaction); + if ( + workspaceRedacted !== value || + path.isAbsolute(workspaceRedacted) || + workspaceRedacted.includes(redaction.stateDir) || + (redaction.env.HOME ? workspaceRedacted.includes(redaction.env.HOME) : false) || + (redaction.env.USERPROFILE ? workspaceRedacted.includes(redaction.env.USERPROFILE) : false) + ) { + return redactSupportString(workspaceRedacted, redaction); + } + return workspaceRedacted; +} + +function redactLocalPathValues(value: unknown, redaction: TrajectoryExportRedaction): unknown { + if (typeof value === "string") { + return maybeRedactPathString(value, redaction); + } + if (Array.isArray(value)) { + return value.map((entry) => redactLocalPathValues(entry, redaction)); + } + if (!value || typeof value !== "object") { + return value; + } + const record = value as Record; + const next: Record = {}; + for (const [key, entry] of Object.entries(record)) { + next[key] = redactLocalPathValues(entry, redaction); + } + return next; +} + +function redactEventForExport( + event: TrajectoryEvent, + redaction: TrajectoryExportRedaction, +): TrajectoryEvent { + return { + ...event, + workspaceDir: event.workspaceDir + ? maybeRedactPathString(event.workspaceDir, redaction) + : undefined, + data: event.data + ? (redactLocalPathValues(event.data, redaction) as Record) + : undefined, + }; +} + +function resolveRuntimeContext(runtimeEvents: TrajectoryEvent[]): RuntimeTrajectoryContext { + const latestContext = runtimeEvents + .slice() + .toReversed() + .find((event) => event.type === "context.compiled"); + const runtimeData = latestContext?.data; + const toolsValue = Array.isArray(runtimeData?.tools) + ? (runtimeData.tools as TrajectoryToolDefinition[]) + : undefined; + return { + systemPrompt: + typeof runtimeData?.systemPrompt === "string" ? runtimeData.systemPrompt : undefined, + tools: toolsValue, + }; +} + +function resolveLatestRuntimeEventData( + runtimeEvents: TrajectoryEvent[], + type: string, +): JsonRecord | undefined { + const event = runtimeEvents + .slice() + .toReversed() + .find((candidate) => candidate.type === type); + return event?.data; +} + +function normalizePathForMatch(value: string): string { + return value.replaceAll("\\", "/").trim().toLowerCase(); +} + +function collectPotentialPathStrings(value: unknown): string[] { + const found = new Set(); + const visit = (input: unknown) => { + if (!input || typeof input !== "object") { + return; + } + if (Array.isArray(input)) { + for (const entry of input) { + visit(entry); + } + return; + } + for (const [key, entry] of Object.entries(input)) { + if ( + typeof entry === "string" && + (key.toLowerCase().includes("path") || + entry.endsWith("SKILL.md") || + entry.endsWith("skill.md")) + ) { + found.add(entry); + } else { + visit(entry); + } + } + }; + visit(value); + return [...found]; +} + +function markInvokedSkills(params: { skills: unknown; events: TrajectoryEvent[] }): unknown { + if (!params.skills || typeof params.skills !== "object") { + return params.skills; + } + const skillsRecord = params.skills as { + entries?: Array>; + }; + if (!Array.isArray(skillsRecord.entries) || skillsRecord.entries.length === 0) { + return params.skills; + } + const invokedPaths = new Set( + params.events.flatMap((event) => { + if (event.type !== "tool.call") { + return []; + } + return collectPotentialPathStrings(event.data?.arguments); + }), + ); + const normalizedInvokedPaths = new Set( + [...invokedPaths].map((value) => normalizePathForMatch(value)), + ); + const entries = skillsRecord.entries.map((entry) => { + const rawPath = typeof entry.filePath === "string" ? entry.filePath : undefined; + const normalizedPath = rawPath ? normalizePathForMatch(rawPath) : undefined; + const skillDirName = + rawPath?.replaceAll("\\", "/").split("/").slice(-2, -1)[0]?.toLowerCase() ?? undefined; + const invoked = normalizedPath + ? [...normalizedInvokedPaths].some( + (candidate) => + candidate === normalizedPath || + candidate.endsWith(normalizedPath) || + (skillDirName ? candidate.endsWith(`/${skillDirName}/skill.md`) : false), + ) + : false; + return invoked + ? { + ...entry, + invoked, + invocationDetectedBy: "tool-call-file-path", + } + : { + ...entry, + invoked: false, + }; + }); + return { + ...skillsRecord, + entries, + }; +} + +function buildMetadataCapture(params: { + manifest: TrajectoryBundleManifest; + runtimeEvents: TrajectoryEvent[]; + events: TrajectoryEvent[]; +}): JsonRecord | undefined { + const runtimeMetadata = resolveLatestRuntimeEventData(params.runtimeEvents, "trace.metadata"); + if (!runtimeMetadata) { + return undefined; + } + const modelFallback = (() => { + const latest = params.runtimeEvents + .slice() + .toReversed() + .find((event) => event.provider || event.modelId || event.modelApi); + if (!latest?.provider && !latest?.modelId && !latest?.modelApi) { + return undefined; + } + return { + provider: latest.provider, + name: latest.modelId, + api: latest.modelApi, + }; + })(); + return { + traceSchema: "openclaw-trajectory", + schemaVersion: 1, + generatedAt: new Date().toISOString(), + traceId: params.manifest.traceId, + sessionId: params.manifest.sessionId, + sessionKey: params.manifest.sessionKey, + harness: runtimeMetadata.harness, + model: runtimeMetadata.model ?? modelFallback, + config: runtimeMetadata.config, + plugins: runtimeMetadata.plugins, + skills: markInvokedSkills({ + skills: runtimeMetadata.skills, + events: params.events, + }), + prompting: runtimeMetadata.prompting, + redaction: runtimeMetadata.redaction, + metadata: runtimeMetadata.metadata, + }; +} + +function buildArtifactsCapture(params: { + manifest: TrajectoryBundleManifest; + runtimeEvents: TrajectoryEvent[]; +}): JsonRecord | undefined { + const runtimeArtifacts = resolveLatestRuntimeEventData(params.runtimeEvents, "trace.artifacts"); + const runtimeCompletion = resolveLatestRuntimeEventData(params.runtimeEvents, "model.completed"); + const runtimeEnd = resolveLatestRuntimeEventData(params.runtimeEvents, "session.ended"); + if (!runtimeArtifacts && !runtimeCompletion && !runtimeEnd) { + return undefined; + } + return { + traceSchema: "openclaw-trajectory", + schemaVersion: 1, + generatedAt: new Date().toISOString(), + traceId: params.manifest.traceId, + sessionId: params.manifest.sessionId, + sessionKey: params.manifest.sessionKey, + finalStatus: runtimeArtifacts?.finalStatus ?? runtimeEnd?.status, + aborted: runtimeArtifacts?.aborted ?? runtimeEnd?.aborted, + externalAbort: runtimeArtifacts?.externalAbort ?? runtimeEnd?.externalAbort, + timedOut: runtimeArtifacts?.timedOut ?? runtimeEnd?.timedOut, + idleTimedOut: runtimeArtifacts?.idleTimedOut ?? runtimeEnd?.idleTimedOut, + timedOutDuringCompaction: + runtimeArtifacts?.timedOutDuringCompaction ?? runtimeEnd?.timedOutDuringCompaction, + promptError: + runtimeArtifacts?.promptError ?? runtimeEnd?.promptError ?? runtimeCompletion?.promptError, + promptErrorSource: runtimeArtifacts?.promptErrorSource ?? runtimeCompletion?.promptErrorSource, + usage: runtimeArtifacts?.usage ?? runtimeCompletion?.usage, + promptCache: runtimeArtifacts?.promptCache ?? runtimeCompletion?.promptCache, + compactionCount: runtimeArtifacts?.compactionCount ?? runtimeCompletion?.compactionCount, + assistantTexts: runtimeArtifacts?.assistantTexts ?? runtimeCompletion?.assistantTexts, + finalPromptText: runtimeArtifacts?.finalPromptText ?? runtimeCompletion?.finalPromptText, + itemLifecycle: runtimeArtifacts?.itemLifecycle, + toolMetas: runtimeArtifacts?.toolMetas, + didSendViaMessagingTool: runtimeArtifacts?.didSendViaMessagingTool, + successfulCronAdds: runtimeArtifacts?.successfulCronAdds, + messagingToolSentTexts: runtimeArtifacts?.messagingToolSentTexts, + messagingToolSentMediaUrls: runtimeArtifacts?.messagingToolSentMediaUrls, + messagingToolSentTargets: runtimeArtifacts?.messagingToolSentTargets, + lastToolError: runtimeArtifacts?.lastToolError, + }; +} + +function buildPromptsCapture(params: { + manifest: TrajectoryBundleManifest; + runtimeEvents: TrajectoryEvent[]; + runtimeContext: RuntimeTrajectoryContext; +}): JsonRecord | undefined { + const runtimeMetadata = resolveLatestRuntimeEventData(params.runtimeEvents, "trace.metadata"); + const latestCompiled = resolveLatestRuntimeEventData(params.runtimeEvents, "context.compiled"); + const submittedPrompts = params.runtimeEvents + .filter((event) => event.type === "prompt.submitted") + .map((event) => event.data?.prompt) + .filter((prompt): prompt is string => typeof prompt === "string"); + const systemPrompt = + (typeof latestCompiled?.systemPrompt === "string" ? latestCompiled.systemPrompt : undefined) ?? + params.runtimeContext.systemPrompt; + const skillsPrompt = + runtimeMetadata?.prompting && + typeof runtimeMetadata.prompting === "object" && + typeof (runtimeMetadata.prompting as JsonRecord).skillsPrompt === "string" + ? ((runtimeMetadata.prompting as JsonRecord).skillsPrompt as string) + : undefined; + const userPromptPrefixText = + runtimeMetadata?.prompting && + typeof runtimeMetadata.prompting === "object" && + typeof (runtimeMetadata.prompting as JsonRecord).userPromptPrefixText === "string" + ? ((runtimeMetadata.prompting as JsonRecord).userPromptPrefixText as string) + : undefined; + const promptReport = + runtimeMetadata?.prompting && + typeof runtimeMetadata.prompting === "object" && + typeof (runtimeMetadata.prompting as JsonRecord).systemPromptReport === "object" + ? (runtimeMetadata.prompting as JsonRecord).systemPromptReport + : undefined; + if (!systemPrompt && submittedPrompts.length === 0 && !skillsPrompt && !userPromptPrefixText) { + return undefined; + } + return { + traceSchema: "openclaw-trajectory", + schemaVersion: 1, + generatedAt: new Date().toISOString(), + traceId: params.manifest.traceId, + sessionId: params.manifest.sessionId, + sessionKey: params.manifest.sessionKey, + system: systemPrompt, + submittedPrompts, + latestSubmittedPrompt: submittedPrompts.at(-1), + skillsPrompt, + userPromptPrefixText, + systemPromptReport: promptReport, + }; +} + +export function resolveDefaultTrajectoryExportDir(params: { + workspaceDir: string; + sessionId: string; + now?: Date; +}): string { + const timestamp = (params.now ?? new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19); + const sessionFileName = safeTrajectorySessionFileName(params.sessionId); + return path.join( + params.workspaceDir, + ".openclaw", + "trajectory-exports", + `openclaw-trajectory-${sessionFileName.slice(0, 8)}-${timestamp}`, + ); +} + +export function exportTrajectoryBundle(params: BuildTrajectoryBundleParams): { + manifest: TrajectoryBundleManifest; + outputDir: string; + events: TrajectoryEvent[]; + header: SessionHeader | null; + runtimeFile?: string; + supplementalFiles: string[]; +} { + const redaction = buildTrajectoryExportRedaction({ + workspaceDir: params.workspaceDir, + }); + const sessionStat = fs.statSync(params.sessionFile); + if (sessionStat.size > MAX_TRAJECTORY_SESSION_FILE_BYTES) { + throw new Error( + `Trajectory session file is too large to export (${sessionStat.size} bytes; limit ${MAX_TRAJECTORY_SESSION_FILE_BYTES})`, + ); + } + const sessionManager = SessionManager.open(params.sessionFile); + const header = sessionManager.getHeader(); + const leafId = sessionManager.getLeafId(); + const branchEntries = sessionManager.getBranch(leafId ?? undefined); + const runtimeFile = resolveTrajectoryRuntimeFile({ + runtimeFile: params.runtimeFile, + sessionFile: params.sessionFile, + sessionId: params.sessionId, + }); + const runtimeEvents = runtimeFile + ? parseJsonlFile(runtimeFile, { + maxBytes: TRAJECTORY_RUNTIME_FILE_MAX_BYTES, + maxEvents: MAX_TRAJECTORY_RUNTIME_EVENTS, + validate: (value): value is TrajectoryEvent => + isRuntimeTrajectoryEventForSession(value, params.sessionId), + }) + : []; + const transcriptEvents = buildTranscriptEvents({ + entries: branchEntries, + sessionId: params.sessionId, + sessionKey: params.sessionKey, + workspaceDir: params.workspaceDir, + traceId: params.sessionId, + }); + const maxTotalEvents = params.maxTotalEvents ?? MAX_TRAJECTORY_TOTAL_EVENTS; + const totalEventCount = runtimeEvents.length + transcriptEvents.length; + if (totalEventCount > maxTotalEvents) { + throw new Error( + `Trajectory export has too many events (${totalEventCount}; limit ${maxTotalEvents})`, + ); + } + const rawEvents = sortTrajectoryEvents([...runtimeEvents, ...transcriptEvents]); + const events = rawEvents.map((event) => redactEventForExport(event, redaction)); + const manifest: TrajectoryBundleManifest = { + traceSchema: "openclaw-trajectory", + schemaVersion: 1, + generatedAt: new Date().toISOString(), + traceId: params.sessionId, + sessionId: params.sessionId, + sessionKey: params.sessionKey, + workspaceDir: maybeRedactPathString(params.workspaceDir, redaction), + leafId, + eventCount: events.length, + runtimeEventCount: runtimeEvents.length, + transcriptEventCount: transcriptEvents.length, + sourceFiles: { + session: maybeRedactPathString(params.sessionFile, redaction), + runtime: + runtimeFile && isRegularNonSymlinkFile(runtimeFile) + ? maybeRedactPathString(runtimeFile, redaction) + : undefined, + }, + }; + + const bundleRuntimeContext = resolveRuntimeContext(runtimeEvents); + const files: DiagnosticSupportBundleFile[] = []; + const supplementalFiles: string[] = []; + const metadataCapture = buildMetadataCapture({ + manifest, + runtimeEvents, + events: rawEvents, + }); + const artifactsCapture = buildArtifactsCapture({ + manifest, + runtimeEvents, + }); + const promptsCapture = buildPromptsCapture({ + manifest, + runtimeEvents, + runtimeContext: bundleRuntimeContext, + }); + if (metadataCapture) { + files.push( + jsonSupportBundleFile("metadata.json", redactLocalPathValues(metadataCapture, redaction)), + ); + supplementalFiles.push("metadata.json"); + } + if (artifactsCapture) { + files.push( + jsonSupportBundleFile("artifacts.json", redactLocalPathValues(artifactsCapture, redaction)), + ); + supplementalFiles.push("artifacts.json"); + } + if (promptsCapture) { + files.push( + jsonSupportBundleFile("prompts.json", redactLocalPathValues(promptsCapture, redaction)), + ); + supplementalFiles.push("prompts.json"); + } + if (supplementalFiles.length > 0) { + manifest.supplementalFiles = supplementalFiles; + } + + files.push(trajectoryJsonlFile("events.jsonl", events)); + files.push( + jsonSupportBundleFile( + "session-branch.json", + redactLocalPathValues( + sanitizeDiagnosticPayload({ + header, + leafId, + entries: branchEntries, + }), + redaction, + ), + ), + ); + if (bundleRuntimeContext.systemPrompt) { + files.push( + textSupportBundleFile( + "system-prompt.txt", + redactLocalPathValues(bundleRuntimeContext.systemPrompt, redaction) as string, + ), + ); + } + if (bundleRuntimeContext.tools) { + files.push( + jsonSupportBundleFile( + "tools.json", + redactLocalPathValues(bundleRuntimeContext.tools, redaction), + ), + ); + } + + const contents: DiagnosticSupportBundleContent[] = [...supportBundleContents(files)]; + manifest.contents = contents; + + writeSupportBundleDirectory({ + outputDir: params.outputDir, + files: [jsonSupportBundleFile("manifest.json", manifest), ...files], + }); + + return { + manifest, + outputDir: params.outputDir, + events, + header, + runtimeFile: runtimeFile && isRegularNonSymlinkFile(runtimeFile) ? runtimeFile : undefined, + supplementalFiles, + }; +} diff --git a/src/trajectory/metadata.test.ts b/src/trajectory/metadata.test.ts new file mode 100644 index 00000000000..79d3aff8c29 --- /dev/null +++ b/src/trajectory/metadata.test.ts @@ -0,0 +1,182 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { REDACTED_SENTINEL } from "../config/redact-snapshot.js"; +import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; +import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js"; +import { buildTrajectoryArtifacts, buildTrajectoryRunMetadata } from "./metadata.js"; + +afterEach(() => { + resetPluginRuntimeStateForTest(); +}); + +describe("trajectory metadata", () => { + it("redacts harness argv and local paths with the support redaction rules", () => { + const originalArgv = process.argv; + process.argv = [ + "node", + "/Users/tester/project/openclaw.js", + "--api-key", + "super-secret", + "--config=/Users/tester/.openclaw/openclaw.json", + ]; + try { + const metadata = buildTrajectoryRunMetadata({ + env: { + HOME: "/Users/tester", + OPENCLAW_STATE_DIR: "/Users/tester/.openclaw", + }, + workspaceDir: "/Users/tester/project", + sessionFile: "/Users/tester/project/session.jsonl", + timeoutMs: 30_000, + }); + + const harness = metadata.harness as { + invocation?: unknown[]; + entrypoint?: string; + workspaceDir?: string; + sessionFile?: string; + }; + expect(harness.invocation).toEqual([ + "node", + "~/project/openclaw.js", + "--api-key", + "", + "--config=$OPENCLAW_STATE_DIR/openclaw.json", + ]); + expect(harness.entrypoint).toBe("~/project/openclaw.js"); + expect(harness.workspaceDir).toBe("~/project"); + expect(harness.sessionFile).toBe("~/project/session.jsonl"); + } finally { + process.argv = originalArgv; + } + }); + + it("captures redacted config plus active plugin and skill inventory", () => { + const registry = createEmptyPluginRegistry(); + registry.plugins.push({ + id: "demo-plugin", + name: "Demo Plugin", + version: "1.2.3", + source: "bundled", + origin: "bundled", + enabled: true, + activated: true, + imported: true, + status: "loaded", + toolNames: ["demo_tool"], + hookNames: [], + channelIds: ["demo-channel"], + cliBackendIds: [], + providerIds: ["demo-provider"], + speechProviderIds: [], + realtimeTranscriptionProviderIds: [], + realtimeVoiceProviderIds: [], + mediaUnderstandingProviderIds: [], + imageGenerationProviderIds: [], + videoGenerationProviderIds: [], + musicGenerationProviderIds: [], + webFetchProviderIds: [], + webSearchProviderIds: [], + memoryEmbeddingProviderIds: [], + agentHarnessIds: ["pi"], + gatewayMethods: [], + cliCommands: [], + services: [], + commands: [], + httpRoutes: 0, + hookCount: 0, + configSchema: false, + }); + setActivePluginRegistry(registry, "trajectory-metadata-test"); + + const metadata = buildTrajectoryRunMetadata({ + config: { + providers: { + openai: { + apiKey: "super-secret", + }, + }, + } as never, + workspaceDir: "/tmp/workspace", + sessionFile: "/tmp/workspace/session.jsonl", + sessionKey: "agent:main:test", + agentId: "main", + trigger: "user", + provider: "openai", + modelId: "gpt-5.4", + modelApi: "responses", + timeoutMs: 30_000, + reasoningLevel: "high", + skillsSnapshot: { + prompt: "skill prompt", + version: 1, + skills: [{ name: "weather" }], + resolvedSkills: [ + { + name: "weather", + description: "Check weather", + filePath: "/tmp/workspace/skills/weather/SKILL.md", + baseDir: "/tmp/workspace/skills/weather", + source: "workspace", + sourceInfo: { + path: "/tmp/workspace/skills/weather/SKILL.md", + source: "workspace", + scope: "project", + origin: "top-level", + baseDir: "/tmp/workspace/skills/weather", + }, + disableModelInvocation: false, + }, + ], + }, + userPromptPrefixText: "prefix", + }); + + const config = metadata.config as { + redacted?: { providers?: { openai?: { apiKey?: string } } }; + }; + const plugins = metadata.plugins as { source?: string; entries?: Array<{ id: string }> }; + const skills = metadata.skills as { entries?: Array<{ id: string; filePath?: string }> }; + expect(config.redacted?.providers?.openai?.apiKey).toBe(REDACTED_SENTINEL); + expect(plugins.source).toBe("active-registry"); + expect(plugins.entries?.map((entry) => entry.id)).toEqual(["demo-plugin"]); + expect(skills.entries?.[0]).toMatchObject({ + id: "weather", + filePath: "/tmp/workspace/skills/weather/SKILL.md", + }); + }); + + it("captures final artifact summaries for export sidecars", () => { + const artifacts = buildTrajectoryArtifacts({ + status: "success", + aborted: false, + externalAbort: false, + timedOut: false, + idleTimedOut: false, + timedOutDuringCompaction: false, + compactionCount: 1, + assistantTexts: ["done"], + finalPromptText: "run tests", + itemLifecycle: { + startedCount: 2, + completedCount: 2, + activeCount: 0, + }, + toolMetas: [{ toolName: "bash", meta: "npm test" }], + didSendViaMessagingTool: false, + successfulCronAdds: 0, + messagingToolSentTexts: [], + messagingToolSentMediaUrls: [], + messagingToolSentTargets: [], + }); + + expect(artifacts).toMatchObject({ + finalStatus: "success", + assistantTexts: ["done"], + itemLifecycle: { + startedCount: 2, + completedCount: 2, + activeCount: 0, + }, + }); + }); +}); diff --git a/src/trajectory/metadata.ts b/src/trajectory/metadata.ts new file mode 100644 index 00000000000..40f8972fc2f --- /dev/null +++ b/src/trajectory/metadata.ts @@ -0,0 +1,322 @@ +import type { SkillSnapshot } from "../agents/skills.js"; +import { resolveStateDir } from "../config/paths.js"; +import { redactConfigObject } from "../config/redact-snapshot.js"; +import type { SessionSystemPromptReport } from "../config/sessions/types.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { resolveCommitHash } from "../infra/git-commit.js"; +import { resolveOsSummary } from "../infra/os-summary.js"; +import { + redactPathForSupport, + sanitizeSupportSnapshotValue, + type SupportRedactionContext, +} from "../logging/diagnostic-support-redaction.js"; +import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js"; +import { getActivePluginRegistry, listImportedRuntimePluginIds } from "../plugins/runtime.js"; +import { VERSION } from "../version.js"; + +type BuildTrajectoryRunMetadataParams = { + env?: NodeJS.ProcessEnv; + config?: OpenClawConfig; + workspaceDir: string; + sessionFile?: string; + sessionKey?: string; + agentId?: string; + trigger?: string; + messageProvider?: string; + messageChannel?: string; + provider?: string; + modelId?: string; + modelApi?: string | null; + timeoutMs: number; + fastMode?: boolean; + thinkLevel?: string; + reasoningLevel?: string; + toolResultFormat?: string; + disableTools?: boolean; + toolsAllow?: string[]; + skillsSnapshot?: SkillSnapshot; + systemPromptReport?: SessionSystemPromptReport; + userPromptPrefixText?: string; +}; + +type BuildTrajectoryArtifactsParams = { + status: "success" | "error" | "interrupted" | "cleanup"; + aborted: boolean; + externalAbort: boolean; + timedOut: boolean; + idleTimedOut: boolean; + timedOutDuringCompaction: boolean; + promptError?: string; + promptErrorSource?: string | null; + usage?: unknown; + promptCache?: unknown; + compactionCount: number; + assistantTexts: string[]; + finalPromptText?: string; + itemLifecycle: { + startedCount: number; + completedCount: number; + activeCount: number; + }; + toolMetas: Array<{ toolName: string; meta?: string }>; + didSendViaMessagingTool: boolean; + successfulCronAdds: number; + messagingToolSentTexts: string[]; + messagingToolSentMediaUrls: string[]; + messagingToolSentTargets: unknown[]; + lastToolError?: unknown; +}; + +function toSortedUniqueStrings(values: readonly string[] | undefined): string[] | undefined { + if (!values || values.length === 0) { + return undefined; + } + return [ + ...new Set(values.filter((value) => typeof value === "string" && value.trim().length > 0)), + ] + .map((value) => value.trim()) + .toSorted((left, right) => left.localeCompare(right)); +} + +function buildPluginsFromActiveRegistry() { + const registry = getActivePluginRegistry(); + if (!registry || registry.plugins.length === 0) { + return null; + } + return { + source: "active-registry", + importedRuntimePluginIds: listImportedRuntimePluginIds(), + entries: registry.plugins + .map((plugin) => ({ + id: plugin.id, + name: plugin.name, + version: plugin.version, + description: plugin.description, + origin: plugin.origin, + enabled: plugin.enabled, + explicitlyEnabled: plugin.explicitlyEnabled, + activated: plugin.activated, + imported: plugin.imported, + activationSource: plugin.activationSource, + activationReason: plugin.activationReason, + status: plugin.status, + error: plugin.error, + format: plugin.format, + bundleFormat: plugin.bundleFormat, + bundleCapabilities: plugin.bundleCapabilities, + kind: plugin.kind, + source: plugin.source, + rootDir: plugin.rootDir, + workspaceDir: plugin.workspaceDir, + toolNames: toSortedUniqueStrings(plugin.toolNames), + hookNames: toSortedUniqueStrings(plugin.hookNames), + channelIds: toSortedUniqueStrings(plugin.channelIds), + cliBackendIds: toSortedUniqueStrings(plugin.cliBackendIds), + providerIds: toSortedUniqueStrings(plugin.providerIds), + speechProviderIds: toSortedUniqueStrings(plugin.speechProviderIds), + realtimeTranscriptionProviderIds: toSortedUniqueStrings( + plugin.realtimeTranscriptionProviderIds, + ), + realtimeVoiceProviderIds: toSortedUniqueStrings(plugin.realtimeVoiceProviderIds), + mediaUnderstandingProviderIds: toSortedUniqueStrings(plugin.mediaUnderstandingProviderIds), + imageGenerationProviderIds: toSortedUniqueStrings(plugin.imageGenerationProviderIds), + videoGenerationProviderIds: toSortedUniqueStrings(plugin.videoGenerationProviderIds), + musicGenerationProviderIds: toSortedUniqueStrings(plugin.musicGenerationProviderIds), + webFetchProviderIds: toSortedUniqueStrings(plugin.webFetchProviderIds), + webSearchProviderIds: toSortedUniqueStrings(plugin.webSearchProviderIds), + memoryEmbeddingProviderIds: toSortedUniqueStrings(plugin.memoryEmbeddingProviderIds), + agentHarnessIds: toSortedUniqueStrings(plugin.agentHarnessIds), + })) + .toSorted((left, right) => left.id.localeCompare(right.id)), + }; +} + +function buildPluginsFromManifest(params: { + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}) { + const registry = loadPluginManifestRegistry({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }); + return { + source: "manifest-registry", + entries: registry.plugins + .map((plugin) => ({ + id: plugin.id, + name: plugin.name, + version: plugin.version, + description: plugin.description, + origin: plugin.origin, + enabledByDefault: plugin.enabledByDefault, + format: plugin.format, + bundleFormat: plugin.bundleFormat, + bundleCapabilities: toSortedUniqueStrings(plugin.bundleCapabilities), + kind: plugin.kind, + source: plugin.source, + rootDir: plugin.rootDir, + workspaceDir: plugin.workspaceDir, + channels: toSortedUniqueStrings(plugin.channels), + providers: toSortedUniqueStrings(plugin.providers), + cliBackends: toSortedUniqueStrings(plugin.cliBackends), + hooks: toSortedUniqueStrings(plugin.hooks), + skills: toSortedUniqueStrings(plugin.skills), + })) + .toSorted((left, right) => left.id.localeCompare(right.id)), + }; +} + +function buildSkillsCapture( + skillsSnapshot: SkillSnapshot | undefined, + redaction: SupportRedactionContext, +) { + if (!skillsSnapshot) { + return undefined; + } + const entries = + skillsSnapshot.resolvedSkills && skillsSnapshot.resolvedSkills.length > 0 + ? skillsSnapshot.resolvedSkills.map((skill) => ({ + id: skill.name, + name: skill.name, + description: skill.description, + filePath: redactPathForSupport(skill.filePath, redaction), + baseDir: redactPathForSupport(skill.baseDir, redaction), + source: skill.source, + sourceInfo: sanitizeSupportSnapshotValue(skill.sourceInfo, redaction), + disableModelInvocation: skill.disableModelInvocation, + available: true, + })) + : skillsSnapshot.skills.map((skill) => ({ + id: skill.name, + name: skill.name, + primaryEnv: skill.primaryEnv, + requiredEnv: skill.requiredEnv, + available: true, + })); + return { + snapshotVersion: skillsSnapshot.version, + skillFilter: toSortedUniqueStrings(skillsSnapshot.skillFilter), + entries: entries.toSorted((left, right) => left.name.localeCompare(right.name)), + }; +} + +function buildTrajectorySupportRedaction(env: NodeJS.ProcessEnv): SupportRedactionContext { + return { + env, + stateDir: resolveStateDir(env), + }; +} + +export function buildTrajectoryRunMetadata( + params: BuildTrajectoryRunMetadataParams, +): Record { + const env = params.env ?? process.env; + const redaction = buildTrajectorySupportRedaction(env); + const os = resolveOsSummary(); + const plugins = + buildPluginsFromActiveRegistry() ?? + buildPluginsFromManifest({ + config: params.config, + workspaceDir: params.workspaceDir, + env, + }); + return { + capturedAt: new Date().toISOString(), + harness: { + type: "openclaw", + name: "OpenClaw", + version: VERSION, + gitSha: + resolveCommitHash({ cwd: params.workspaceDir, env, moduleUrl: import.meta.url }) ?? + undefined, + os, + runtime: { + node: process.version, + }, + invocation: sanitizeSupportSnapshotValue([...process.argv], redaction, "programArguments"), + entrypoint: process.argv[1] ? redactPathForSupport(process.argv[1], redaction) : undefined, + workspaceDir: redactPathForSupport(params.workspaceDir, redaction), + sessionFile: params.sessionFile + ? redactPathForSupport(params.sessionFile, redaction) + : undefined, + }, + model: { + provider: params.provider, + name: params.modelId, + api: params.modelApi, + fastMode: params.fastMode ?? false, + thinkLevel: params.thinkLevel, + reasoningLevel: params.reasoningLevel ?? "off", + }, + config: { + redacted: params.config ? redactConfigObject(params.config) : undefined, + runtime: { + timeoutMs: params.timeoutMs, + trigger: params.trigger, + disableTools: params.disableTools ?? false, + toolResultFormat: params.toolResultFormat, + toolsAllow: toSortedUniqueStrings(params.toolsAllow), + }, + }, + plugins, + skills: buildSkillsCapture(params.skillsSnapshot, redaction), + prompting: { + skillsPrompt: params.skillsSnapshot?.prompt, + userPromptPrefixText: params.userPromptPrefixText, + systemPromptReport: params.systemPromptReport, + }, + redaction: { + config: { + mode: "redactConfigObject", + secretsMasked: true, + }, + payloads: { + mode: "sanitizeDiagnosticPayload", + credentialsRemoved: true, + imageDataRedacted: true, + }, + harness: { + mode: "diagnostic-support-redaction", + programArgumentsRedacted: true, + localPathsRedacted: true, + }, + }, + metadata: { + sessionKey: params.sessionKey, + agentId: params.agentId, + messageProvider: params.messageProvider, + messageChannel: params.messageChannel, + }, + }; +} + +export function buildTrajectoryArtifacts( + params: BuildTrajectoryArtifactsParams, +): Record { + return { + capturedAt: new Date().toISOString(), + finalStatus: params.status, + aborted: params.aborted, + externalAbort: params.externalAbort, + timedOut: params.timedOut, + idleTimedOut: params.idleTimedOut, + timedOutDuringCompaction: params.timedOutDuringCompaction, + promptError: params.promptError, + promptErrorSource: params.promptErrorSource, + usage: params.usage, + promptCache: params.promptCache, + compactionCount: params.compactionCount, + assistantTexts: params.assistantTexts, + finalPromptText: params.finalPromptText, + itemLifecycle: params.itemLifecycle, + toolMetas: params.toolMetas, + didSendViaMessagingTool: params.didSendViaMessagingTool, + successfulCronAdds: params.successfulCronAdds, + messagingToolSentTexts: params.messagingToolSentTexts, + messagingToolSentMediaUrls: params.messagingToolSentMediaUrls, + messagingToolSentTargets: params.messagingToolSentTargets, + lastToolError: params.lastToolError, + }; +} diff --git a/src/trajectory/runtime.test.ts b/src/trajectory/runtime.test.ts new file mode 100644 index 00000000000..c364b30dfb8 --- /dev/null +++ b/src/trajectory/runtime.test.ts @@ -0,0 +1,169 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + TRAJECTORY_RUNTIME_EVENT_MAX_BYTES, + createTrajectoryRuntimeRecorder, + resolveTrajectoryPointerOpenFlags, + resolveTrajectoryPointerFilePath, + resolveTrajectoryFilePath, + toTrajectoryToolDefinitions, +} from "./runtime.js"; + +const tempDirs: string[] = []; + +function makeTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-trajectory-runtime-")); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("trajectory runtime", () => { + it("resolves a session-adjacent trajectory file by default", () => { + expect( + resolveTrajectoryFilePath({ + sessionFile: "/tmp/session.jsonl", + sessionId: "session-1", + }), + ).toBe("/tmp/session.trajectory.jsonl"); + }); + + it("sanitizes session ids when resolving an override directory", () => { + expect( + resolveTrajectoryFilePath({ + env: { OPENCLAW_TRAJECTORY_DIR: "/tmp/traces" }, + sessionId: "../evil/session", + }), + ).toBe("/tmp/traces/___evil_session.jsonl"); + }); + + it("records sanitized runtime events by default", () => { + const writes: string[] = []; + const recorder = createTrajectoryRuntimeRecorder({ + sessionId: "session-1", + sessionKey: "agent:main:session-1", + sessionFile: "/tmp/session.jsonl", + provider: "openai", + modelId: "gpt-5.4", + modelApi: "responses", + workspaceDir: "/tmp/workspace", + writer: { + filePath: "/tmp/session.trajectory.jsonl", + write: (line) => { + writes.push(line); + }, + flush: async () => undefined, + }, + }); + + expect(recorder).not.toBeNull(); + recorder?.recordEvent("context.compiled", { + systemPrompt: "system prompt", + headers: [{ name: "Authorization", value: "Bearer sk-test-secret-token" }], + command: "curl -H 'Authorization: Bearer sk-other-secret-token'", + tools: toTrajectoryToolDefinitions([ + { name: "z-tool", parameters: { z: 1 } }, + { name: "a-tool", description: "alpha", parameters: { a: 1 } }, + { name: " ", description: "ignored" }, + ]), + }); + + expect(writes).toHaveLength(1); + const parsed = JSON.parse(writes[0]); + expect(parsed.type).toBe("context.compiled"); + expect(parsed.source).toBe("runtime"); + expect(parsed.sessionId).toBe("session-1"); + expect(parsed.data.tools).toEqual([ + { name: "a-tool", description: "alpha", parameters: { a: 1 } }, + { name: "z-tool", parameters: { z: 1 } }, + ]); + expect(JSON.stringify(parsed.data)).not.toContain("sk-test-secret-token"); + expect(JSON.stringify(parsed.data)).not.toContain("sk-other-secret-token"); + }); + + it("truncates events that exceed the runtime event byte limit", () => { + const writes: string[] = []; + const recorder = createTrajectoryRuntimeRecorder({ + sessionId: "session-1", + sessionFile: "/tmp/session.jsonl", + writer: { + filePath: "/tmp/session.trajectory.jsonl", + write: (line) => { + writes.push(line); + }, + flush: async () => undefined, + }, + }); + + recorder?.recordEvent("context.compiled", { + prompt: "x".repeat(TRAJECTORY_RUNTIME_EVENT_MAX_BYTES + 1), + }); + + expect(writes).toHaveLength(1); + const parsed = JSON.parse(writes[0]); + expect(parsed.data).toMatchObject({ + truncated: true, + reason: "trajectory-event-size-limit", + }); + expect(Buffer.byteLength(writes[0], "utf8")).toBeLessThanOrEqual( + TRAJECTORY_RUNTIME_EVENT_MAX_BYTES + 1, + ); + }); + + it("writes a session-adjacent pointer when using an override directory", () => { + const tmpDir = makeTempDir(); + const sessionFile = path.join(tmpDir, "session.jsonl"); + const trajectoryDir = path.join(tmpDir, "traces"); + const recorder = createTrajectoryRuntimeRecorder({ + env: { OPENCLAW_TRAJECTORY_DIR: trajectoryDir }, + sessionId: "session-1", + sessionFile, + writer: { + filePath: path.join(trajectoryDir, "session-1.jsonl"), + write: () => undefined, + flush: async () => undefined, + }, + }); + + expect(recorder).not.toBeNull(); + const pointer = JSON.parse( + fs.readFileSync(resolveTrajectoryPointerFilePath(sessionFile), "utf8"), + ) as { runtimeFile?: string }; + expect(pointer.runtimeFile).toBe(path.join(trajectoryDir, "session-1.jsonl")); + }); + + it("keeps pointer write flags usable when O_NOFOLLOW is unavailable", () => { + expect( + resolveTrajectoryPointerOpenFlags({ + O_CREAT: 0x01, + O_TRUNC: 0x02, + O_WRONLY: 0x04, + }), + ).toBe(0x07); + }); + + it("does not record runtime events when explicitly disabled", () => { + const recorder = createTrajectoryRuntimeRecorder({ + env: { + OPENCLAW_TRAJECTORY: "0", + }, + sessionId: "session-1", + sessionKey: "agent:main:session-1", + sessionFile: "/tmp/session.jsonl", + writer: { + filePath: "/tmp/session.trajectory.jsonl", + write: () => undefined, + flush: async () => undefined, + }, + }); + + expect(recorder).toBeNull(); + }); +}); diff --git a/src/trajectory/runtime.ts b/src/trajectory/runtime.ts new file mode 100644 index 00000000000..c2f6dfd93ea --- /dev/null +++ b/src/trajectory/runtime.ts @@ -0,0 +1,272 @@ +import fs from "node:fs"; +import path from "node:path"; +import { sanitizeDiagnosticPayload } from "../agents/payload-redaction.js"; +import { getQueuedFileWriter, type QueuedFileWriter } from "../agents/queued-file-writer.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { resolveUserPath } from "../utils.js"; +import { parseBooleanValue } from "../utils/boolean.js"; +import { safeJsonStringify } from "../utils/safe-json.js"; +import type { TrajectoryEvent, TrajectoryToolDefinition } from "./types.js"; + +type TrajectoryRuntimeInit = { + cfg?: OpenClawConfig; + env?: NodeJS.ProcessEnv; + runId?: string; + sessionId: string; + sessionKey?: string; + sessionFile?: string; + provider?: string; + modelId?: string; + modelApi?: string | null; + workspaceDir?: string; + writer?: QueuedFileWriter; +}; + +type TrajectoryRuntimeRecorder = { + enabled: true; + filePath: string; + recordEvent: (type: string, data?: Record) => void; + flush: () => Promise; +}; + +const writers = new Map(); +export const TRAJECTORY_RUNTIME_FILE_MAX_BYTES = 50 * 1024 * 1024; +export const TRAJECTORY_RUNTIME_EVENT_MAX_BYTES = 256 * 1024; +const MAX_TRAJECTORY_WRITERS = 100; + +type TrajectoryPointerOpenFlagConstants = Pick< + typeof fs.constants, + "O_CREAT" | "O_TRUNC" | "O_WRONLY" +> & + Partial>; + +export function safeTrajectorySessionFileName(sessionId: string): string { + const safe = sessionId.replaceAll(/[^A-Za-z0-9_-]/g, "_").slice(0, 120); + return /[A-Za-z0-9]/u.test(safe) ? safe : "session"; +} + +export function resolveTrajectoryPointerOpenFlags( + constants: TrajectoryPointerOpenFlagConstants = fs.constants, +): number { + const noFollow = constants.O_NOFOLLOW; + return ( + constants.O_CREAT | + constants.O_TRUNC | + constants.O_WRONLY | + (typeof noFollow === "number" ? noFollow : 0) + ); +} + +function resolveContainedPath(baseDir: string, fileName: string): string { + const resolvedBase = path.resolve(baseDir); + const resolvedFile = path.resolve(resolvedBase, fileName); + const relative = path.relative(resolvedBase, resolvedFile); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error("Trajectory file path escaped its configured directory"); + } + return resolvedFile; +} + +export function resolveTrajectoryFilePath(params: { + env?: NodeJS.ProcessEnv; + sessionFile?: string; + sessionId: string; +}): string { + const env = params.env ?? process.env; + const dirOverride = env.OPENCLAW_TRAJECTORY_DIR?.trim(); + if (dirOverride) { + return resolveContainedPath( + resolveUserPath(dirOverride), + `${safeTrajectorySessionFileName(params.sessionId)}.jsonl`, + ); + } + if (!params.sessionFile) { + return path.join( + process.cwd(), + `${safeTrajectorySessionFileName(params.sessionId)}.trajectory.jsonl`, + ); + } + return params.sessionFile.endsWith(".jsonl") + ? `${params.sessionFile.slice(0, -".jsonl".length)}.trajectory.jsonl` + : `${params.sessionFile}.trajectory.jsonl`; +} + +export function resolveTrajectoryPointerFilePath(sessionFile: string): string { + return sessionFile.endsWith(".jsonl") + ? `${sessionFile.slice(0, -".jsonl".length)}.trajectory-path.json` + : `${sessionFile}.trajectory-path.json`; +} + +function writeTrajectoryPointerBestEffort(params: { + filePath: string; + sessionFile?: string; + sessionId: string; +}): void { + if (!params.sessionFile) { + return; + } + const pointerPath = resolveTrajectoryPointerFilePath(params.sessionFile); + try { + const pointerDir = path.resolve(path.dirname(pointerPath)); + if (fs.lstatSync(pointerDir).isSymbolicLink()) { + return; + } + try { + if (fs.lstatSync(pointerPath).isSymbolicLink()) { + return; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + return; + } + } + const fd = fs.openSync(pointerPath, resolveTrajectoryPointerOpenFlags(), 0o600); + try { + fs.writeFileSync( + fd, + `${JSON.stringify( + { + traceSchema: "openclaw-trajectory-pointer", + schemaVersion: 1, + sessionId: params.sessionId, + runtimeFile: params.filePath, + }, + null, + 2, + )}\n`, + "utf8", + ); + fs.fchmodSync(fd, 0o600); + } finally { + fs.closeSync(fd); + } + } catch { + // Pointer files are best-effort; the runtime sidecar itself is authoritative. + } +} + +function trimTrajectoryWriterCache(): void { + while (writers.size >= MAX_TRAJECTORY_WRITERS) { + const oldestKey = writers.keys().next().value; + if (!oldestKey) { + return; + } + writers.delete(oldestKey); + } +} + +function truncateOversizedTrajectoryEvent( + event: TrajectoryEvent, + line: string, +): string | undefined { + const bytes = Buffer.byteLength(line, "utf8"); + if (bytes <= TRAJECTORY_RUNTIME_EVENT_MAX_BYTES) { + return line; + } + const truncated = safeJsonStringify({ + ...event, + data: { + truncated: true, + originalBytes: bytes, + limitBytes: TRAJECTORY_RUNTIME_EVENT_MAX_BYTES, + reason: "trajectory-event-size-limit", + }, + }); + if (truncated && Buffer.byteLength(truncated, "utf8") <= TRAJECTORY_RUNTIME_EVENT_MAX_BYTES) { + return truncated; + } + return undefined; +} + +export function toTrajectoryToolDefinitions( + tools: ReadonlyArray<{ name?: string; description?: string; parameters?: unknown }>, +): TrajectoryToolDefinition[] { + return tools + .flatMap((tool) => { + const name = tool.name?.trim(); + if (!name) { + return []; + } + return [ + { + name, + description: tool.description, + parameters: sanitizeDiagnosticPayload(tool.parameters), + }, + ]; + }) + .toSorted((left, right) => left.name.localeCompare(right.name)); +} + +export function createTrajectoryRuntimeRecorder( + params: TrajectoryRuntimeInit, +): TrajectoryRuntimeRecorder | null { + const env = params.env ?? process.env; + // Trajectory capture is now default-on. The env var remains as an explicit + // override so operators can still disable recording with OPENCLAW_TRAJECTORY=0. + const enabled = parseBooleanValue(env.OPENCLAW_TRAJECTORY) ?? true; + if (!enabled) { + return null; + } + + const filePath = resolveTrajectoryFilePath({ + env, + sessionFile: params.sessionFile, + sessionId: params.sessionId, + }); + if (!params.writer) { + trimTrajectoryWriterCache(); + } + const writer = + params.writer ?? + getQueuedFileWriter(writers, filePath, { + maxFileBytes: TRAJECTORY_RUNTIME_FILE_MAX_BYTES, + }); + writeTrajectoryPointerBestEffort({ + filePath, + sessionFile: params.sessionFile, + sessionId: params.sessionId, + }); + let seq = 0; + const traceId = params.sessionId; + + return { + enabled: true, + filePath, + recordEvent: (type, data) => { + const event: TrajectoryEvent = { + traceSchema: "openclaw-trajectory", + schemaVersion: 1, + traceId, + source: "runtime", + type, + ts: new Date().toISOString(), + seq: (seq += 1), + sourceSeq: seq, + sessionId: params.sessionId, + sessionKey: params.sessionKey, + runId: params.runId, + workspaceDir: params.workspaceDir, + provider: params.provider, + modelId: params.modelId, + modelApi: params.modelApi, + data: data ? (sanitizeDiagnosticPayload(data) as Record) : undefined, + }; + const line = safeJsonStringify(event); + if (!line) { + return; + } + const boundedLine = truncateOversizedTrajectoryEvent(event, line); + if (!boundedLine) { + return; + } + writer.write(`${boundedLine}\n`); + }, + flush: async () => { + await writer.flush(); + if (!params.writer) { + writers.delete(filePath); + } + }, + }; +} diff --git a/src/trajectory/types.ts b/src/trajectory/types.ts new file mode 100644 index 00000000000..8beb2f24642 --- /dev/null +++ b/src/trajectory/types.ts @@ -0,0 +1,52 @@ +export type TrajectoryEventSource = "runtime" | "transcript" | "export"; + +export type TrajectoryToolDefinition = { + name: string; + description?: string; + parameters?: unknown; +}; + +export type TrajectoryEvent = { + traceSchema: "openclaw-trajectory"; + schemaVersion: 1; + traceId: string; + source: TrajectoryEventSource; + type: string; + ts: string; + seq: number; + sourceSeq?: number; + sessionId: string; + sessionKey?: string; + runId?: string; + workspaceDir?: string; + provider?: string; + modelId?: string; + modelApi?: string | null; + entryId?: string; + parentEntryId?: string | null; + data?: Record; +}; + +export type TrajectoryBundleManifest = { + traceSchema: "openclaw-trajectory"; + schemaVersion: 1; + generatedAt: string; + traceId: string; + sessionId: string; + sessionKey?: string; + workspaceDir: string; + leafId: string | null; + eventCount: number; + runtimeEventCount: number; + transcriptEventCount: number; + sourceFiles: { + session: string; + runtime?: string; + }; + contents?: Array<{ + path: string; + mediaType: string; + bytes: number; + }>; + supplementalFiles?: string[]; +};