feat: add trajectory bundle export and default-on runtime capture (#70291)

* Trajectory: export session bundles by default

* Harden trajectory export diagnostics integration

* Address trajectory export review feedback

* Share diagnostics and trajectory bundle plumbing

* Harden trajectory recording and export

* Confine trajectory export outputs

* Document trajectory export command

* Harden trajectory export bundle privacy

* Redact trajectory sidecar paths

* Fix plugin install checks after rebase

* Keep queued writers working without O_NOFOLLOW

* Keep Codex trajectory writes without O_NOFOLLOW

* Harden trajectory export path handling

* Redact mixed trajectory export paths
This commit is contained in:
scoootscooob
2026-04-22 23:29:01 -07:00
committed by GitHub
parent 6dba5cc2a0
commit a3d9c53db2
29 changed files with 4796 additions and 82 deletions

View File

@@ -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<JsonValue>;
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();

View File

@@ -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",
});
});
});

View File

@@ -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<string, unknown>) => void;
flush: () => Promise<void>;
};
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<Pick<typeof nodeFs.constants, "O_NOFOLLOW">>;
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<void> {
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<void> {
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, unknown>): 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 "<redacted>";
}
if (value.startsWith("data:") && value.length > 256) {
return `<redacted data-uri ${value.slice(0, value.indexOf(",")).length} chars>`;
}
if (PRIVATE_PAYLOAD_FIELD_RE.test(key) && value.length > 256) {
return "<redacted payload>";
}
const redacted = redactSensitiveString(value);
return redacted.length > 20_000 ? `${redacted.slice(0, 20_000)}` : redacted;
}
if (depth >= 6) {
return "<truncated>";
}
if (Array.isArray(value)) {
return value.slice(0, 100).map((entry) => sanitizeValue(entry, depth + 1, key));
}
if (typeof value === "object") {
const next: Record<string, unknown> = {};
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 <redacted>")
.replace(JWT_VALUE_RE, "<redacted-jwt>")
.replace(COOKIE_PAIR_RE, "$1=<redacted>");
}
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";
}
}