diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index 6eaae4f3a897..9f190f8a288c 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -245,7 +245,6 @@ extensions/codex/src/app-server/thread-lifecycle-run.ts 1 extensions/codex/src/app-server/thread-supervision.ts 2 extensions/codex/src/app-server/tool-abort-terminal-reason.ts 1 extensions/codex/src/app-server/tool-progress-normalization.ts 1 -extensions/codex/src/app-server/trajectory.ts 3 extensions/codex/src/app-server/transcript-history-projection.ts 2 extensions/codex/src/app-server/transcript-mirror-attestation.ts 2 extensions/codex/src/app-server/transcript-mirror.ts 4 diff --git a/extensions/codex/src/app-server/trajectory.test.ts b/extensions/codex/src/app-server/trajectory.test.ts index ee50dd435e14..1eccee174803 100644 --- a/extensions/codex/src/app-server/trajectory.test.ts +++ b/extensions/codex/src/app-server/trajectory.test.ts @@ -1,9 +1,12 @@ // Codex tests cover SQLite-only trajectory plugin behavior. import fs from "node:fs"; import path from "node:path"; +import { createAgentHarnessHostCapabilitiesForTest } from "openclaw/plugin-sdk/plugin-test-runtime"; import { upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; import { appendSqliteTrajectoryRuntimeEvents, + createTrajectoryRuntimeRecorderForTest, + exportTrajectoryBundleForTest, loadSqliteTrajectoryRuntimeEvents, type SqliteTrajectoryRuntimeEventForTest, } from "openclaw/plugin-sdk/sqlite-runtime-testing"; @@ -84,7 +87,6 @@ function createMemoryBackedRecorder(params: { } as never, trajectory: host.trajectory, tools: params.tools, - env: {}, }); return { events: host.events, recorder: expectTrajectoryRecorder(recorder) }; } @@ -128,7 +130,6 @@ describe("Codex trajectory recorder", () => { sessionId: "session-1", model: { api: "responses" }, } as never, - env: {}, }), ).toBeNull(); }); @@ -157,7 +158,6 @@ describe("Codex trajectory recorder", () => { sessionId: "session-1", storePath, }), - env: {}, }); const trajectoryRecorder = expectTrajectoryRecorder(recorder); @@ -172,20 +172,6 @@ describe("Codex trajectory recorder", () => { ).resolves.toEqual([expect.objectContaining({ type: "session.started" })]); }); - it("redacts secrets and keeps recorded strings UTF-16 safe", async () => { - const { events, recorder } = createMemoryBackedRecorder({ tmpDir: testWorkspace.dir }); - recorder.recordEvent("model.output", { - text: `${"x".repeat(19_999)}😀`, - apiKey: "secret", - authorization: "Bearer sk-test-secret-token", - }); - await recorder.flush(); - - expect(events[0]?.data?.text).toBe(`${"x".repeat(19_999)}…`); - expect(events[0]?.data?.apiKey).toBe(""); - expect(events[0]?.data?.authorization).toBe(""); - }); - it("records namespace dynamic tools as callable trajectory definitions", async () => { const tools = [ { @@ -218,32 +204,33 @@ describe("Codex trajectory recorder", () => { ]); }); - it("honors explicit disablement", () => { - const host = createMemoryTrajectoryFacade(); - const recorder = createCodexTrajectoryRecorder({ - cwd: testWorkspace.dir, - attempt: { - sessionFile: "agent:main:session-1", - sessionId: "session-1", - model: { api: "responses" }, - } as never, - env: { OPENCLAW_TRAJECTORY: "0" }, - trajectory: host.trajectory, + it("lets the host bound oversized Codex events without losing terminal facts", async () => { + const tmpDir = testWorkspace.dir; + const storePath = path.join(tmpDir, "sessions", "sessions.json"); + const sessionTarget = { + agentId: "main", + sessionId: "session-1", + sessionKey: "agent:main:session-1", + storePath, + }; + await upsertSessionEntry({ + agentId: sessionTarget.agentId, + sessionKey: sessionTarget.sessionKey, + storePath, + entry: { sessionId: sessionTarget.sessionId, updatedAt: 10 }, }); - - expect(recorder).toBeNull(); - expect(host.events).toEqual([]); - }); - - it("preserves usage when truncating oversized model completion events", async () => { const attempt = { + agentId: "main", + cwd: tmpDir, + workspaceDir: tmpDir, + sessionFile: sessionTarget.sessionKey, sessionId: "session-1", sessionKey: "agent:main:session-1", runId: "run-1", provider: "codex", modelId: "gpt-5.4", model: { api: "responses" }, - } as never; + }; const usage = { input: 384_954, output: 5_624, @@ -251,34 +238,190 @@ describe("Codex trajectory recorder", () => { reasoningTokens: 2_038, total: 724_402, }; - const { events, recorder } = createMemoryBackedRecorder({ - tmpDir: testWorkspace.dir, - attempt, + const hostRecorder = createTrajectoryRuntimeRecorderForTest({ + sessionId: sessionTarget.sessionId, + sessionKey: sessionTarget.sessionKey, + sessionTarget, + runId: attempt.runId, + provider: "openai", + modelId: attempt.modelId, + modelApi: "responses", + workspaceDir: tmpDir, }); - - recordCodexTrajectoryCompletion(recorder, { - attempt, - threadId: "thread-1", - turnId: "turn-1", - timedOut: false, - result: { - terminal: { kind: "ok" }, - attemptUsage: usage, - assistantTexts: ["done"], - messagesSnapshot: Array.from({ length: 20 }, (_value, index) => ({ - role: index % 2 === 0 ? "user" : "assistant", - content: `message-${index} ${"x".repeat(32_000)}`, - })), - } as never, + if (!hostRecorder) { + throw new Error("Expected host trajectory recorder"); + } + const host = await createAgentHarnessHostCapabilitiesForTest({ + attempt: { ...attempt, trajectoryRecorder: hostRecorder } as never, + pluginId: "codex", }); - await recorder.flush(); + const recorder = createCodexTrajectoryRecorder({ + attempt: attempt as never, + cwd: tmpDir, + trajectory: host.capabilities.trajectory, + } as never); + const trajectoryRecorder = expectTrajectoryRecorder(recorder); - expect(events[0]?.data).toMatchObject({ + try { + recordCodexTrajectoryContext(trajectoryRecorder, { + attempt: attempt as never, + cwd: tmpDir, + developerInstructions: `Bearer ${"s".repeat(40)} ${"x".repeat(40_000)}`, + prompt: "inspect", + tools: [ + { + type: "function", + name: "huge_tool", + description: "x".repeat(40_000), + inputSchema: { type: "object" }, + }, + ], + } as never); + trajectoryRecorder.recordEvent("tool.result", { + toolCallId: "call-1", + toolName: "huge_tool", + status: "completed", + authorization: `Bearer ${"t".repeat(40)}`, + output: `token=${"t".repeat(40)} ${"x".repeat(40_000)}`, + }); + recordCodexTrajectoryCompletion(trajectoryRecorder, { + attempt: attempt as never, + threadId: "thread-1", + turnId: "turn-1", + timedOut: true, + yieldDetected: true, + result: { + terminal: { + kind: "timeout", + phase: "prompt", + source: "runtime", + aborted: true, + failure: { source: "prompt", error: "terminal prompt error" }, + }, + attemptUsage: usage, + assistantTexts: ["done"], + // Twelve entries stay below the old plugin's post-sanitization cap, + // but exceed the host cap when forwarded without pre-shrinking. + messagesSnapshot: Array.from({ length: 12 }, (_value, index) => ({ + role: index % 2 === 0 ? "user" : "assistant", + content: `message-${index} ${"x".repeat(32_000)}`, + })), + } as never, + }); + recordCodexTrajectoryCompletion(trajectoryRecorder, { + attempt: attempt as never, + threadId: "thread-compact", + turnId: "turn-compact", + timedOut: true, + result: { + terminal: { + kind: "timeout", + phase: "prompt", + source: "runtime", + aborted: true, + failure: { source: "prompt", error: "compact prompt error" }, + }, + attemptUsage: usage, + assistantTexts: Array.from( + { length: 12 }, + (_value, index) => `assistant-${index} ${"x".repeat(32_000)}`, + ), + messagesSnapshot: Array.from({ length: 12 }, (_value, index) => ({ + role: index % 2 === 0 ? "user" : "assistant", + content: `message-${index} ${"x".repeat(32_000)}`, + })), + } as never, + }); + await trajectoryRecorder.flush(); + } finally { + host.close(); + } + + const events = await loadSqliteTrajectoryRuntimeEvents({ + agentId: sessionTarget.agentId, + sessionId: sessionTarget.sessionId, + storePath, + }); + const context = events.find((event) => event.type === "context.compiled"); + const tool = events.find((event) => event.type === "tool.result"); + const completion = events.find( + (event) => event.type === "model.completed" && event.data?.turnId === "turn-1", + ); + const compactCompletion = events.find( + (event) => event.type === "model.completed" && event.data?.turnId === "turn-compact", + ); + expect(context?.data).toMatchObject({ + prompt: "inspect", + systemPrompt: { + truncated: true, + reason: "trajectory-field-size-limit", + }, + tools: [ + { + name: "huge_tool", + description: { + truncated: true, + reason: "trajectory-field-size-limit", + }, + parameters: { type: "object" }, + }, + ], + }); + expect(JSON.stringify(context)).not.toContain("s".repeat(40)); + expect(tool?.data).toMatchObject({ + toolCallId: "call-1", + toolName: "huge_tool", + status: "completed", + output: { + truncated: true, + reason: "trajectory-field-size-limit", + }, + }); + expect(tool?.data?.authorization).toBeUndefined(); + expect(JSON.stringify(tool)).not.toContain(`token=${"t".repeat(40)}`); + expect(completion?.data).toMatchObject({ truncated: true, reason: "trajectory-event-size-limit", + threadId: "thread-1", + turnId: "turn-1", + timedOut: true, + yieldDetected: true, + aborted: true, + promptError: "terminal prompt error", + usage, + assistantTexts: ["done"], + }); + expect(completion?.data?.messagesSnapshot).toBeUndefined(); + expect(completion?.data?.droppedFields).toEqual(["messagesSnapshot"]); + expect(compactCompletion?.data).toMatchObject({ + truncated: true, + reason: "trajectory-event-size-limit", + threadId: "thread-compact", + turnId: "turn-compact", + timedOut: true, + yieldDetected: false, + aborted: true, + promptError: "compact prompt error", usage, }); - expect(events[0]?.data?.messagesSnapshot).toBeUndefined(); - expect(events[0]?.data?.droppedFields).toContain("messagesSnapshot"); + expect(compactCompletion?.data?.assistantTexts).toBeUndefined(); + expect(compactCompletion?.data?.messagesSnapshot).toBeUndefined(); + expect(compactCompletion?.data?.droppedFields).toEqual(["assistantTexts", "messagesSnapshot"]); + + const bundle = await exportTrajectoryBundleForTest({ + outputDir: path.join(tmpDir, "bundle"), + sessionTarget, + sessionId: sessionTarget.sessionId, + sessionKey: sessionTarget.sessionKey, + workspaceDir: tmpDir, + }); + const exportedCompletion = bundle.events.find( + (event) => event.type === "model.completed" && event.data?.turnId === "turn-1", + ); + const exportedCompactCompletion = bundle.events.find( + (event) => event.type === "model.completed" && event.data?.turnId === "turn-compact", + ); + expect(exportedCompletion?.data).toEqual(completion?.data); + expect(exportedCompactCompletion?.data).toEqual(compactCompletion?.data); }); }); diff --git a/extensions/codex/src/app-server/trajectory.ts b/extensions/codex/src/app-server/trajectory.ts index 115c0fe3a751..b40cac260dd8 100644 --- a/extensions/codex/src/app-server/trajectory.ts +++ b/extensions/codex/src/app-server/trajectory.ts @@ -1,11 +1,6 @@ -/** - * Records optional Codex runtime trajectory events with bounded, redacted - * context and completion payloads. - */ +/** Records optional Codex runtime trajectory events through the host recorder. */ import type { EmbeddedRunAttemptParamsV2 as EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime"; -import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { attemptTerminal, type EmbeddedRunAttemptResult } from "./attempt-terminal.js"; -import { resolveCodexLocalRuntimeAttribution } from "./local-runtime-attribution.js"; import { flattenCodexDynamicToolFunctions, type CodexDynamicToolSpec } from "./protocol.js"; /** Runtime trajectory recorder used by Codex run attempts and event projectors. */ @@ -21,123 +16,19 @@ type CodexTrajectoryInit = { prompt?: string; trajectory?: NonNullable | null; tools?: CodexDynamicToolSpec[]; - 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_EVENT_MAX_BYTES = 256 * 1024; -const TRAJECTORY_RUNTIME_OVERSIZE_PRESERVED_DATA_KEYS = ["usage", "promptCache"] as const; - -type CodexTrajectoryEvent = Record & { - data?: Record; - type: string; -}; - -function boundedTrajectoryEvent(event: Record): CodexTrajectoryEvent | undefined { - const line = JSON.stringify(event); - const bytes = Buffer.byteLength(line, "utf8"); - if (bytes <= TRAJECTORY_RUNTIME_EVENT_MAX_BYTES) { - return event as CodexTrajectoryEvent; - } - - const originalData = - event.data && typeof event.data === "object" && !Array.isArray(event.data) - ? (event.data as Record) - : {}; - const originalDataKeys = Object.keys(originalData); - const preservedDataKeys = new Set(); - const baseData = { - truncated: true, - originalBytes: bytes, - limitBytes: TRAJECTORY_RUNTIME_EVENT_MAX_BYTES, - reason: "trajectory-event-size-limit", - }; - const buildTruncatedEvent = (includeDroppedFields: boolean): CodexTrajectoryEvent | undefined => { - const data: Record = { ...baseData }; - for (const key of TRAJECTORY_RUNTIME_OVERSIZE_PRESERVED_DATA_KEYS) { - if (preservedDataKeys.has(key)) { - data[key] = originalData[key]; - } - } - if (includeDroppedFields) { - const droppedFields = originalDataKeys.filter((key) => !preservedDataKeys.has(key)); - if (droppedFields.length > 0) { - data.droppedFields = droppedFields; - } - } - const truncatedEvent = { ...event, data }; - const truncated = JSON.stringify(truncatedEvent); - if (Buffer.byteLength(truncated, "utf8") <= TRAJECTORY_RUNTIME_EVENT_MAX_BYTES) { - return truncatedEvent as CodexTrajectoryEvent; - } - return undefined; - }; - - let best = buildTruncatedEvent(true) ?? buildTruncatedEvent(false); - if (!best) { - return undefined; - } - - for (const key of TRAJECTORY_RUNTIME_OVERSIZE_PRESERVED_DATA_KEYS) { - if (!Object.hasOwn(originalData, key)) { - continue; - } - preservedDataKeys.add(key); - const next = buildTruncatedEvent(true) ?? buildTruncatedEvent(false); - if (next) { - best = next; - continue; - } - preservedDataKeys.delete(key); - } - return best; -} - -/** Creates a trajectory recorder when trajectory capture is enabled for the environment. */ +/** Creates a trajectory recorder when the host exposes its capture capability. */ export function createCodexTrajectoryRecorder( params: CodexTrajectoryInit, ): CodexTrajectoryRecorder | null { - const env = params.env ?? process.env; - const enabled = parseTrajectoryEnabled(env); - if (!enabled) { - return null; - } - if (!params.trajectory) { return null; } const trajectory = params.trajectory; - let seq = 0; - const attribution = resolveCodexLocalRuntimeAttribution(params.attempt); return { - recordEvent: (type, data) => { - const event = boundedTrajectoryEvent({ - 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: attribution.provider, - modelId: params.attempt.modelId, - modelApi: attribution.api, - data: data ? sanitizeValue(data) : undefined, - }); - if (event) { - trajectory.recordEvent(event.type, event.data); - } - }, + recordEvent: trajectory.recordEvent, flush: trajectory.flush, }; } @@ -187,17 +78,6 @@ export function recordCodexTrajectoryCompletion( }); } -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 toTrajectoryToolDefinitions( tools: readonly CodexDynamicToolSpec[] | undefined, ): Array<{ name: string; description?: string; parameters?: unknown }> | undefined { @@ -214,55 +94,13 @@ function toTrajectoryToolDefinitions( { name, description: tool.description, - parameters: sanitizeValue(tool.inputSchema), + parameters: tool.inputSchema, }, ]; }) .toSorted((left, right) => left.name.localeCompare(right.name)); } -function sanitizeValue(value: unknown, depth = 0, key = ""): unknown { - // Trajectory exports may leave the live process, so redact credentials and - // private payloads before passing events to the SQLite host recorder. - 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 ? `${truncateUtf16Safe(redacted, 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 [keyLocal, child] of Object.entries(value).slice(0, 100)) { - next[keyLocal] = sanitizeValue(child, depth + 1, keyLocal); - } - 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="); -} - /** Converts arbitrary prompt errors into trajectory-safe text. */ export function normalizeCodexTrajectoryError(value: unknown): string | null { if (!value) { diff --git a/src/plugin-sdk/sqlite-runtime-testing.ts b/src/plugin-sdk/sqlite-runtime-testing.ts index 2e89904687bd..8df6e29923e3 100644 --- a/src/plugin-sdk/sqlite-runtime-testing.ts +++ b/src/plugin-sdk/sqlite-runtime-testing.ts @@ -19,6 +19,8 @@ export { loadSqliteTrajectoryRuntimeEvents, type SqliteTrajectoryRuntimeScope, } from "../trajectory/runtime-store.sqlite.js"; +export { createTrajectoryRuntimeRecorder as createTrajectoryRuntimeRecorderForTest } from "../trajectory/runtime.js"; +export { exportTrajectoryBundle as exportTrajectoryBundleForTest } from "../trajectory/export.js"; export { type TrajectoryEvent as SqliteTrajectoryRuntimeEventForTest } from "../trajectory/types.js"; export { closeOpenClawAgentDatabasesForTest, diff --git a/src/trajectory/runtime.test.ts b/src/trajectory/runtime.test.ts index 2ce1a23d3750..9cc74c9b12f6 100644 --- a/src/trajectory/runtime.test.ts +++ b/src/trajectory/runtime.test.ts @@ -391,9 +391,19 @@ describe("trajectory runtime", () => { const runtimeRecorder = expectTrajectoryRuntimeRecorder(recorder); runtimeRecorder.recordEvent("model.completed", { + threadId: "thread-compact", + turnId: "turn-compact", + timedOut: true, + yieldDetected: false, + aborted: true, + promptError: "terminal prompt error", usage: oversizedUsage, promptCache, stopReason: "length", + assistantTexts: Array.from( + { length: 12 }, + (_value, index) => `assistant-${index} ${"x".repeat(32_000)}`, + ), messagesSnapshot: [{ role: "user", content: "x".repeat(32_000) }], }); @@ -402,12 +412,19 @@ describe("trajectory runtime", () => { expect(parsed.data).toMatchObject({ truncated: true, reason: "trajectory-event-size-limit", + threadId: "thread-compact", + turnId: "turn-compact", + timedOut: true, + yieldDetected: false, + aborted: true, + promptError: "terminal prompt error", promptCache, stopReason: "length", }); expect(parsed.data.usage).toBeUndefined(); + expect(parsed.data.assistantTexts).toBeUndefined(); expect(parsed.data.droppedFields).toEqual( - expect.arrayContaining(["usage", "messagesSnapshot"]), + expect.arrayContaining(["usage", "assistantTexts", "messagesSnapshot"]), ); expect( Buffer.byteLength(expectDefined(writes[0], "writes[0] test invariant"), "utf8"), diff --git a/src/trajectory/runtime.ts b/src/trajectory/runtime.ts index db24bf558df4..48f9e7245e8f 100644 --- a/src/trajectory/runtime.ts +++ b/src/trajectory/runtime.ts @@ -61,7 +61,18 @@ const TRAJECTORY_RUNTIME_OVERSIZE_DROP_FIRST_DATA_KEYS = [ "messages", "systemPrompt", ] as const; -const OVERSIZE_PRESERVED_DATA_KEYS = ["stopReason", "usage", "promptCache", "prompt"] as const; +const OVERSIZE_PRESERVED_DATA_KEYS = [ + "threadId", + "turnId", + "timedOut", + "yieldDetected", + "aborted", + "promptError", + "stopReason", + "usage", + "promptCache", + "prompt", +] as const; type TrajectoryRuntimeWriterDiagnostics = QueuedFileWriterDiagnostics;