diff --git a/extensions/codex/src/app-server/config-contracts.ts b/extensions/codex/src/app-server/config-contracts.ts index edd923dfcaba..3c8d8b7a5e5b 100644 --- a/extensions/codex/src/app-server/config-contracts.ts +++ b/extensions/codex/src/app-server/config-contracts.ts @@ -29,14 +29,15 @@ export type OpenClawExecPolicy = OpenClawExecPolicyForCodexAppServer; export type ProviderAuthAliasConfig = NonNullable["config"]; export type CodexAppServerDefaultPolicy = { mode: CodexAppServerPolicyMode; - approvalPolicy?: CodexAppServerApprovalPolicy; + approvalPolicy?: CodexAppServerManagedApprovalPolicy; approvalsReviewer?: CodexAppServerApprovalsReviewer; sandbox?: CodexAppServerSandboxMode; dangerFullAccessAllowed?: boolean; }; export type CodexAppServerApprovalPolicy = "never" | "on-request"; +export type CodexAppServerManagedApprovalPolicy = Extract; export type CodexAppServerApprovalPolicySource = "config" | "env" | "requirements" | "implicit"; -export type CodexAppServerEffectiveApprovalPolicy = Exclude; +export type CodexAppServerEffectiveApprovalPolicy = CodexApprovalPolicy; export type CodexAppServerSandboxMode = "read-only" | "workspace-write" | "danger-full-access"; export type CodexAppServerApprovalsReviewer = "user" | "auto_review" | "guardian_subagent"; export type CodexAppServerCommandSource = "managed" | "resolved-managed" | "config" | "env"; diff --git a/extensions/codex/src/app-server/config-requirements.ts b/extensions/codex/src/app-server/config-requirements.ts index 504527c8df0b..411a635db4db 100644 --- a/extensions/codex/src/app-server/config-requirements.ts +++ b/extensions/codex/src/app-server/config-requirements.ts @@ -1,7 +1,7 @@ import { readFileSync } from "node:fs"; import type { - CodexAppServerApprovalPolicy, CodexAppServerApprovalsReviewer, + CodexAppServerManagedApprovalPolicy, CodexAppServerSandboxMode, OpenClawExecMode, } from "./config-contracts.js"; @@ -59,22 +59,14 @@ export function parseAllowedSandboxModesFromCodexRequirements( export function parseAllowedApprovalPoliciesFromCodexRequirements( content: string, -): Set | undefined { +): Set | undefined { const values = parseTopLevelRequirementsStringArray(content, "allowed_approval_policies"); if (values === undefined) { return undefined; } const normalizedPolicies = values .map((entry) => normalizeRequirementsApprovalPolicy(entry)) - .filter((entry): entry is CodexAppServerApprovalPolicy => entry !== undefined); - if ( - normalizedPolicies.length === 0 && - values.some((entry) => entry.trim().toLowerCase() === "untrusted") - ) { - throw new Error( - 'Codex requirements allowed_approval_policies only permits retired "untrusted"; replace it with "on-request".', - ); - } + .filter((entry): entry is CodexAppServerManagedApprovalPolicy => entry !== undefined); return normalizedPolicies.length > 0 ? new Set(normalizedPolicies) : undefined; } @@ -303,7 +295,7 @@ function globPatternMatches(value: string, pattern: string): boolean { function normalizeRequirementsApprovalPolicy( value: string, -): CodexAppServerApprovalPolicy | undefined { +): CodexAppServerManagedApprovalPolicy | undefined { const normalized = value.trim().toLowerCase(); // Codex still accepts this alias in persisted requirements, while its // app-server exposes only the canonical on-request value. @@ -311,7 +303,7 @@ function normalizeRequirementsApprovalPolicy( return "on-request"; } if (normalized === "untrusted") { - return undefined; + return normalized; } return resolveApprovalPolicy(normalized); } @@ -324,12 +316,15 @@ function normalizeRequirementsApprovalsReviewer( } export function selectGuardianApprovalPolicy( - allowedApprovalPolicies: Set | undefined, + allowedApprovalPolicies: Set | undefined, execModeRequiringPromptingApprovals?: Extract, -): CodexAppServerApprovalPolicy { +): CodexAppServerManagedApprovalPolicy { if (allowedApprovalPolicies === undefined || allowedApprovalPolicies.has("on-request")) { return "on-request"; } + if (allowedApprovalPolicies.has("untrusted")) { + return "untrusted"; + } if (execModeRequiringPromptingApprovals) { throw new Error( `tools.exec.mode=${execModeRequiringPromptingApprovals} requires Codex app-server prompting approvals`, diff --git a/extensions/codex/src/app-server/config-security.ts b/extensions/codex/src/app-server/config-security.ts index 00274d5988f4..359d02d093a4 100644 --- a/extensions/codex/src/app-server/config-security.ts +++ b/extensions/codex/src/app-server/config-security.ts @@ -138,6 +138,9 @@ function stableStringifyJson(value: JsonValue): string { export function withMcpElicitationsApprovalPolicy( policy: CodexAppServerEffectiveApprovalPolicy, ): CodexAppServerEffectiveApprovalPolicy { + if (policy === "untrusted") { + return policy; + } if (typeof policy !== "string") { return { granular: { @@ -294,7 +297,8 @@ export function resolveDefaultCodexAppServerPolicy(params: { const yoloSandboxAllowed = allowedSandboxModes === undefined || allowedSandboxModes.has("danger-full-access"); const yoloApprovalAllowed = - allowedApprovalPolicies === undefined || allowedApprovalPolicies.has("never"); + allowedApprovalPolicies === undefined || + (allowedApprovalPolicies.has("never") && !allowedApprovalPolicies.has("untrusted")); const yoloReviewerAllowed = allowedApprovalsReviewers === undefined || allowedApprovalsReviewers.has("user"); if (!params.forceGuardian && yoloSandboxAllowed && yoloApprovalAllowed && yoloReviewerAllowed) { diff --git a/extensions/codex/src/app-server/config.test.ts b/extensions/codex/src/app-server/config.test.ts index 87a0ac324688..33410aaeb1ec 100644 --- a/extensions/codex/src/app-server/config.test.ts +++ b/extensions/codex/src/app-server/config.test.ts @@ -31,6 +31,10 @@ function resolveRuntimeForTest(params: RuntimeOptionsParams = {}) { } describe("withMcpElicitationsApprovalPolicy", () => { + it("preserves managed per-command approvals that already allow MCP elicitation", () => { + expect(withMcpElicitationsApprovalPolicy("untrusted")).toBe("untrusted"); + }); + it("returns every field required by Codex granular approval policy", () => { expect(withMcpElicitationsApprovalPolicy("never")).toEqual({ granular: { @@ -1232,6 +1236,27 @@ describe("Codex app-server config", () => { }); }); + it.each([ + { policies: ["untrusted"], description: "only the managed internal policy" }, + { policies: ["untrusted", "never"], description: "managed and unrestricted policies" }, + ])("preserves $description without weakening Codex approvals", ({ policies }) => { + const runtime = resolveRuntimeForTest({ + pluginConfig: {}, + modelProvider: "openai", + requirementsToml: `allowed_approval_policies = [${policies + .map((policy) => `"${policy}"`) + .join(", ")}]\n`, + }); + + expectRuntimePolicy(runtime, { + approvalPolicy: "untrusted", + sandbox: "workspace-write", + approvalsReviewer: "auto_review", + }); + expect(runtime.approvalPolicySource).toBe("requirements"); + expect(withMcpElicitationsApprovalPolicy(runtime.approvalPolicy)).toBe("untrusted"); + }); + it("normalizes the deprecated requirements on-failure alias to on-request", () => { const runtime = resolveRuntimeForTest({ pluginConfig: {}, @@ -2452,23 +2477,11 @@ allowed_sandbox_modes = ["read-only", "workspace-write"] policies: ["never"], error: "tools.exec.mode=auto requires Codex app-server prompting approvals", }, - { - execMode: "auto", - policies: ["untrusted"], - error: - 'Codex requirements allowed_approval_policies only permits retired "untrusted"; replace it with "on-request".', - }, { execMode: "ask", policies: ["never"], error: "tools.exec.mode=ask requires Codex app-server prompting approvals", }, - { - execMode: "ask", - policies: ["untrusted"], - error: - 'Codex requirements allowed_approval_policies only permits retired "untrusted"; replace it with "on-request".', - }, ] as const)( "fails closed when normalized OpenClaw $execMode mode can only use $policies approvals", ({ execMode, policies, error }) => { @@ -2484,6 +2497,24 @@ allowed_sandbox_modes = ["read-only", "workspace-write"] }, ); + it.each([ + { execMode: "auto" as const, approvalsReviewer: "auto_review" }, + { execMode: "ask" as const, approvalsReviewer: "user" }, + ])("honors managed prompting approvals for OpenClaw $execMode mode", (expected) => { + const runtime = resolveRuntimeForTest({ + pluginConfig: {}, + execMode: expected.execMode, + modelProvider: "openai", + requirementsToml: 'allowed_approval_policies = ["untrusted", "never"]\n', + }); + + expectRuntimePolicy(runtime, { + approvalPolicy: "untrusted", + sandbox: "workspace-write", + approvalsReviewer: expected.approvalsReviewer, + }); + }); + it("keeps normalized OpenClaw full exec mode on default Codex yolo", () => { const runtime = resolveRuntimeForTest({ pluginConfig: {}, diff --git a/extensions/codex/src/app-server/event-projector-options.ts b/extensions/codex/src/app-server/event-projector-options.ts index f5a5d7cb27b0..5324301f6bd2 100644 --- a/extensions/codex/src/app-server/event-projector-options.ts +++ b/extensions/codex/src/app-server/event-projector-options.ts @@ -9,6 +9,7 @@ export type CodexAsyncDeliverySettlement = "settled" | "retry"; export type CodexAppServerEventProjectorOptions = { initialContextTokens?: number; nativePostToolUseRelayEnabled?: boolean; + asyncUserMessageAllowed?: boolean; onAsyncDelivery?: (delivery: { itemId: string; message: AssistantMessage; diff --git a/extensions/codex/src/app-server/event-projector.async-delivery.test.ts b/extensions/codex/src/app-server/event-projector.async-delivery.test.ts index 3668acd65fba..2f476e4eac18 100644 --- a/extensions/codex/src/app-server/event-projector.async-delivery.test.ts +++ b/extensions/codex/src/app-server/event-projector.async-delivery.test.ts @@ -16,6 +16,44 @@ import { codexTranscriptMirrorRuntime } from "./transcript-mirror.js"; registerCodexEventProjectorTestLifecycle(); describe("CodexAppServerEventProjector async delivery", () => { + it.each([ + { name: "disabled tools", disableTools: true }, + { name: "an empty tool allowlist", toolsAllow: [] }, + { name: "the ring-zero system tool", toolsAllow: ["openclaw"] }, + { name: "an allowlist without message delivery", toolsAllow: ["read"] }, + ])("does not expose native async messages through $name", async (restriction) => { + const params = await createParams(); + const onAsyncDelivery = vi.fn().mockResolvedValue("settled"); + const projector = await createProjector({ ...params, ...restriction }, { onAsyncDelivery }); + + await projector.handleNotification( + forCurrentTurn("item/completed", { + item: { + type: "agentMessage", + id: "unauthorized-async-update", + phase: "final_answer", + delivery: "async", + text: "This restricted update must not reach a user.", + }, + }), + ); + await projector.handleNotification( + turnCompleted([ + { + type: "agentMessage", + id: "authorized-final", + phase: "final_answer", + text: "Ordinary final reply.", + }, + ]), + ); + + expect(onAsyncDelivery).not.toHaveBeenCalled(); + expect( + JSON.stringify(projector.buildResult(buildEmptyToolTelemetry()).messagesSnapshot), + ).not.toContain("restricted update"); + }); + it("persists async delivery once without selecting it as the final answer", async () => { const onAgentEvent = vi.fn(); const onBlockReply = vi.fn(); @@ -195,7 +233,7 @@ describe("CodexAppServerEventProjector async delivery", () => { ]); }); - it("retries unsettled sessionless async delivery from the terminal snapshot", async () => { + it("retries unsettled sessionless async delivery when the real terminal summary contains only the final answer", async () => { const params = await createParams(); const onBlockReply = vi .fn() @@ -222,7 +260,6 @@ describe("CodexAppServerEventProjector async delivery", () => { text: "Retry this background update.", }; const completed = turnCompleted([ - asyncItem, { type: "agentMessage", id: "terminal-retry", diff --git a/extensions/codex/src/app-server/event-projector.ts b/extensions/codex/src/app-server/event-projector.ts index dbe36ded5573..a47f419ee342 100644 --- a/extensions/codex/src/app-server/event-projector.ts +++ b/extensions/codex/src/app-server/event-projector.ts @@ -66,6 +66,10 @@ export class CodexAppServerEventProjector { private readonly activeItemIds = new Set(); private readonly completedItemIds = new Set(); private readonly settledAsyncDeliveryItemIds = new Set(); + private readonly pendingAsyncDeliveries = new Map< + string, + Parameters>[0] + >(); private readonly activeCompactionItemIds = new Set(); private readonly terminalPresentationClearedItemIds = new Set(); private readonly nativeToolOutcomeOrdinals = new Map(); @@ -452,6 +456,18 @@ export class CodexAppServerEventProjector { this.activeItemIds.delete(itemId); this.completedItemIds.add(itemId); } + if ( + item?.type === "agentMessage" && + item.delivery === "async" && + !this.canDeliverAsyncUserMessage() + ) { + embeddedAgentLog.warn("blocked unauthorized codex async user message", { + itemId, + threadId: this.threadId, + turnId: this.turnId, + }); + return; + } const asyncMessage = this.assistantProjection.recordItemCompleted( item, itemId, @@ -530,6 +546,9 @@ export class CodexAppServerEventProjector { this.promptErrorSource = "prompt"; } const turnItems = turn.items ?? []; + // Upstream terminal summaries contain only the last assistant item. Keep + // earlier unsettled deliveries at their producer instead of inferring them. + const unsettledAsyncDeliveries = [...this.pendingAsyncDeliveries.values()]; // The final snapshot is authoritative when item notifications were omitted. // Only its last relevant tool may change the terminal presentation. for (let index = turnItems.length - 1; index >= 0; index -= 1) { @@ -546,6 +565,13 @@ export class CodexAppServerEventProjector { } } for (const item of turnItems) { + if ( + item.type === "agentMessage" && + item.delivery === "async" && + !this.canDeliverAsyncUserMessage() + ) { + continue; + } this.diagnostics.warnUnknownItemStatus(item); const asyncMessage = this.assistantProjection.recordSnapshotItem(item); if (asyncMessage) { @@ -562,6 +588,9 @@ export class CodexAppServerEventProjector { this.toolProgressProjection.emitToolResultSummary(item); this.toolProgressProjection.emitToolResultOutput(item); } + for (const delivery of unsettledAsyncDeliveries) { + await this.deliverAsyncMessage(delivery); + } this.assistantProjection.finalizeAnswerCandidate(turn); this.activeCompactionItemIds.clear(); await this.reasoningProjection.maybeEndReasoning(); @@ -580,9 +609,25 @@ export class CodexAppServerEventProjector { const settlement = await this.options.onAsyncDelivery?.(delivery); if (settlement === "settled") { this.settledAsyncDeliveryItemIds.add(delivery.itemId); + this.pendingAsyncDeliveries.delete(delivery.itemId); + } else if (settlement === "retry") { + this.pendingAsyncDeliveries.set(delivery.itemId, delivery); } } + private canDeliverAsyncUserMessage(): boolean { + if (this.params.disableTools === true) { + return false; + } + if (this.options.asyncUserMessageAllowed !== undefined) { + return this.options.asyncUserMessageAllowed; + } + return ( + this.params.toolsAllow === undefined || + this.params.toolsAllow.some((name) => name === "*" || name === "message") + ); + } + private async emitSnapshotOnlyNativeToolProgress(item: CodexThreadItem): Promise { if ( !shouldSynthesizeToolProgressForItem(item) || diff --git a/extensions/codex/src/app-server/run-attempt-active-turn.ts b/extensions/codex/src/app-server/run-attempt-active-turn.ts index 8cad7169aaf7..60e5b733abbf 100644 --- a/extensions/codex/src/app-server/run-attempt-active-turn.ts +++ b/extensions/codex/src/app-server/run-attempt-active-turn.ts @@ -103,6 +103,10 @@ export async function activateCodexAttemptTurn( nativePostToolUseRelayEnabled: resourceState.nativeHookRelay?.allowedEvents.includes("post_tool_use") === true && resourceState.nativeHookRelay.shouldRelayEvent("post_tool_use"), + asyncUserMessageAllowed: + params.disableTools !== true && + (params.toolsAllow === undefined || + toolBridge.availableTools.some((tool) => tool.name === "message")), onAsyncDelivery: async (delivery) => { return await codexTranscriptMirrorRuntime.deliverAsyncMessageBestEffort({ params: dynamicToolParams, @@ -189,6 +193,19 @@ export async function activateCodexAttemptTurn( for (const failure of pendingNativePreToolUseFailures.splice(0)) { activeProjector.recordNativeToolPreToolUseFailure(failure); } + const notifyUserMessagePersisted = createCodexAppServerUserMessagePersistenceNotifier(params); + // Buffered async items can persist immediately when the route opens. Commit + // their owning user admission first so durable history stays chronological. + await mirrorPromptAtTurnStartBestEffort({ + params, + agentId: sessionAgentId, + notifyUserMessagePersisted, + sessionKey: sandboxSessionKey, + cwd: effectiveCwd, + threadId: resourceState.thread.threadId, + turnId: activeTurnId, + upstreamUserText: turnState.codexTurnPromptText, + }); // The route buffers early events. Publish full turn context, then release in wire order. if (resourceState.turnRoute) { try { @@ -319,17 +336,6 @@ export async function activateCodexAttemptTurn( terminalState.terminalOutcomeFrozen = true; params.abortSignal?.removeEventListener("abort", abortFromUpstream); }; - const notifyUserMessagePersisted = createCodexAppServerUserMessagePersistenceNotifier(params); - void mirrorPromptAtTurnStartBestEffort({ - params, - agentId: sessionAgentId, - notifyUserMessagePersisted, - sessionKey: sandboxSessionKey, - cwd: effectiveCwd, - threadId: resourceState.thread.threadId, - turnId: activeTurnId, - upstreamUserText: turnState.codexTurnPromptText, - }); const abortListener = () => { if (state.timedOut) { void (async () => { diff --git a/extensions/codex/src/app-server/run-attempt.context-engine.test.ts b/extensions/codex/src/app-server/run-attempt.context-engine.test.ts index ec67596cb516..646836bbe94f 100644 --- a/extensions/codex/src/app-server/run-attempt.context-engine.test.ts +++ b/extensions/codex/src/app-server/run-attempt.context-engine.test.ts @@ -15,6 +15,7 @@ import { MESSAGE_TOOL_DELIVERY_HINTS } from "openclaw/plugin-sdk/message-tool-de import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtime"; import { registerSandboxBackend } from "openclaw/plugin-sdk/sandbox"; import { upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; +import { readSessionTranscriptEvents } from "openclaw/plugin-sdk/session-transcript-runtime"; import { formatSqliteSessionFileMarker } from "openclaw/plugin-sdk/sqlite-runtime-testing"; import { readStringValue } from "openclaw/plugin-sdk/string-coerce-runtime"; // Codex tests cover run attempt.context engine plugin behavior. @@ -1867,6 +1868,58 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => { }); }); + it("persists the admitted user prompt before an async item buffered during turn startup", async () => { + const workspaceDir = path.join(tempDir, "workspace-early-async"); + const params = await createSqliteParams(workspaceDir, "early-async-order"); + params.onBlockReply = vi.fn(); + const recorder = params.userTurnTranscriptRecorder; + if (!recorder) { + throw new Error("expected user turn transcript recorder"); + } + recorder.markRuntimePersistencePending = vi.fn(); + const harness = createStartedThreadHarness(async (method) => { + if (method === "turn/start") { + await harness.notify({ + method: "item/completed", + params: { + threadId: "thread-1", + turnId: "turn-1", + item: { + type: "agentMessage", + id: "startup-async", + phase: "final_answer", + delivery: "async", + text: "Working on the request.", + }, + }, + }); + } + return undefined; + }); + + const run = runCodexAppServerAttempt(params); + await harness.waitForMethod("turn/start"); + await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledOnce()); + await harness.completeTurn(); + await run; + + const sessionTarget = params.sessionTarget; + if (!sessionTarget?.sessionId || !sessionTarget.sessionKey) { + throw new Error("expected a complete session transcript target"); + } + const messages = ( + await readSessionTranscriptEvents({ + ...sessionTarget, + sessionId: sessionTarget.sessionId, + sessionKey: sessionTarget.sessionKey, + }) + ) + .map((event) => (event as { message?: { role?: string } }).message) + .filter((message) => message !== undefined); + expect(messages.slice(0, 2).map((message) => message.role)).toEqual(["user", "assistant"]); + expect(messages[1]).toMatchObject({ openclawAsyncDelivery: { itemId: "startup-async" } }); + }); + it("reloads mirrored history after bootstrap mutates the session transcript", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const workspaceDir = path.join(tempDir, "workspace"); diff --git a/extensions/codex/src/app-server/session-binding.test.ts b/extensions/codex/src/app-server/session-binding.test.ts index 628335238fe6..49eab53f7472 100644 --- a/extensions/codex/src/app-server/session-binding.test.ts +++ b/extensions/codex/src/app-server/session-binding.test.ts @@ -76,7 +76,7 @@ describe("Codex app-server binding store", () => { }); }); - it("does not normalize untrusted persisted binding policy into live runtime policy", () => { + it("preserves the effective managed approval policy in persisted thread bindings", () => { expect( readCodexAppServerThreadBinding({ threadId: "thread-untrusted-policy", @@ -87,6 +87,7 @@ describe("Codex app-server binding store", () => { ).toEqual({ threadId: "thread-untrusted-policy", cwd: "/repo", + approvalPolicy: "untrusted", sandbox: "workspace-write", }); }); diff --git a/extensions/codex/src/app-server/session-binding.ts b/extensions/codex/src/app-server/session-binding.ts index d4358ba43dbd..5076616a142b 100644 --- a/extensions/codex/src/app-server/session-binding.ts +++ b/extensions/codex/src/app-server/session-binding.ts @@ -227,7 +227,7 @@ const threadBindingSchema = z approvalPolicy: z .preprocess( (value) => (value === "on-failure" ? "on-request" : value), - z.enum(["never", "on-request"]).optional(), + z.enum(["never", "on-request", "untrusted"]).optional(), ) .catch(undefined), sandbox: z diff --git a/extensions/codex/src/app-server/session-permission-policy.test.ts b/extensions/codex/src/app-server/session-permission-policy.test.ts index c07e4cad4b01..84fc7a453805 100644 --- a/extensions/codex/src/app-server/session-permission-policy.test.ts +++ b/extensions/codex/src/app-server/session-permission-policy.test.ts @@ -98,6 +98,36 @@ describe("Codex session permission policy", () => { }); }); + it.each([ + { + mode: "guarded" as const, + policies: ["untrusted"], + approvalsReviewer: "user", + }, + { + mode: "workspace" as const, + policies: ["untrusted", "never"], + approvalsReviewer: "auto_review", + }, + ])("preserves managed prompting approval for a $mode session", (expected) => { + const resolved = applyCodexSessionPermissionPolicy({ + appServer: appServer(), + permissionMode: expected.mode, + sessionRoot: "/workspace/project", + pluginConfig, + canUseAutoReview: true, + requirementsToml: `allowed_approval_policies = [${expected.policies + .map((policy) => `"${policy}"`) + .join(", ")}]`, + }); + + expect(resolved).toMatchObject({ + sandbox: "workspace-write", + approvalPolicy: "untrusted", + approvalsReviewer: expected.approvalsReviewer, + }); + }); + it.each([ { mode: "full" as const, diff --git a/extensions/codex/src/app-server/session-permission-policy.ts b/extensions/codex/src/app-server/session-permission-policy.ts index 5b2f881d4932..af364979c430 100644 --- a/extensions/codex/src/app-server/session-permission-policy.ts +++ b/extensions/codex/src/app-server/session-permission-policy.ts @@ -2,8 +2,8 @@ import { hostname as readHostName } from "node:os"; import type { EmbeddedRunAttemptParamsV2 } from "openclaw/plugin-sdk/agent-harness-runtime"; import { isPathInside } from "openclaw/plugin-sdk/security-runtime"; import type { - CodexAppServerApprovalPolicy, CodexAppServerApprovalsReviewer, + CodexAppServerManagedApprovalPolicy, CodexAppServerRuntimeOptions, CodexAppServerSandboxMode, CodexPluginConfig, @@ -23,7 +23,7 @@ import { resolveCodexAppServerNetworkProxy } from "./config-security.js"; type SessionPermissionMode = NonNullable; type CodexSessionPermissionTuple = { - approvalPolicy: CodexAppServerApprovalPolicy; + approvalPolicy: CodexAppServerManagedApprovalPolicy; approvalsReviewer: CodexAppServerApprovalsReviewer; sandbox: CodexAppServerSandboxMode; }; @@ -61,7 +61,7 @@ function requirementsAllowTuple( tuple: CodexSessionPermissionTuple, allowed: { sandboxes: Set | undefined; - approvalPolicies: Set | undefined; + approvalPolicies: Set | undefined; reviewers: Set | undefined; }, ): boolean { diff --git a/extensions/codex/src/app-server/transcript-history-projection.ts b/extensions/codex/src/app-server/transcript-history-projection.ts index 20c231ab2381..b4ef7216786f 100644 --- a/extensions/codex/src/app-server/transcript-history-projection.ts +++ b/extensions/codex/src/app-server/transcript-history-projection.ts @@ -168,6 +168,9 @@ function projectCodexThreadHistory(params: { if (!text || !role) { continue; } + const phase = + item.phase === "commentary" || item.phase === "final_answer" ? item.phase : undefined; + const asyncDelivery = item.delivery === "async"; const message = role === "assistant" ? attachCodexMirrorIdentity( @@ -190,13 +193,13 @@ function projectCodexThreadHistory(params: { ...(turn.status === "failed" && turn.error?.message ? { errorMessage: turn.error.message } : {}), + ...(phase ? { phase } : {}), + ...(asyncDelivery && itemId ? { openclawAsyncDelivery: { itemId } } : {}), timestamp, } satisfies AssistantMessage, identity, ) : attachCodexMirrorIdentity({ role, content: text, timestamp } as AgentMessage, identity); - const phase = - item.phase === "commentary" || item.phase === "final_answer" ? item.phase : undefined; projected.push({ message, responseItem: { @@ -262,7 +265,9 @@ export function projectBoundedCodexThreadHistory(params: { .filter( ({ message }) => message.role !== "assistant" || - (message.stopReason !== "aborted" && message.stopReason !== "error"), + (message.stopReason !== "aborted" && + message.stopReason !== "error" && + !("openclawAsyncDelivery" in message)), ) .map(({ responseItem }) => responseItem), transcriptMessages: selected.map(({ message }) => message), @@ -280,8 +285,9 @@ export function projectBoundedCodexVisibleSessionHistory( } if ( entry.role === "assistant" && - "stopReason" in entry.message && - (entry.message.stopReason === "aborted" || entry.message.stopReason === "error") + (("stopReason" in entry.message && + (entry.message.stopReason === "aborted" || entry.message.stopReason === "error")) || + "openclawAsyncDelivery" in entry.message) ) { continue; } diff --git a/extensions/codex/src/app-server/transcript-mirror.test.ts b/extensions/codex/src/app-server/transcript-mirror.test.ts index 04b90925ba0d..ddfa5ae9fbf6 100644 --- a/extensions/codex/src/app-server/transcript-mirror.test.ts +++ b/extensions/codex/src/app-server/transcript-mirror.test.ts @@ -24,6 +24,7 @@ import { import { afterEach, describe, expect, it, vi } from "vitest"; import type { CodexThread } from "./protocol.js"; import { readCodexMirroredSessionHistoryMessages } from "./session-history.js"; +import { projectBoundedCodexVisibleSessionHistory } from "./transcript-history-projection.js"; import { buildCodexUserPromptMessage, codexTranscriptMirrorRuntime, @@ -651,6 +652,72 @@ describe("projectBoundedCodexThreadHistory", () => { expect(JSON.stringify(projection)).not.toContain("failed tail"); }); + it("preserves imported async and commentary ownership while keeping async messages out of model history", () => { + const importedThread = { + ...thread, + turns: [ + { + id: "turn-async-history", + status: "completed", + items: [ + { + id: "user-async-history", + type: "userMessage", + content: [{ type: "text", text: "Investigate this" }], + }, + { + id: "commentary-history", + type: "agentMessage", + text: "Checking the deployment.", + phase: "commentary", + }, + { + id: "async-history", + type: "agentMessage", + text: "Which environment should I use?", + phase: "final_answer", + delivery: "async", + }, + { + id: "final-history", + type: "agentMessage", + text: "Deployment complete.", + phase: "final_answer", + }, + ], + }, + ], + } as unknown as CodexThread; + + const projection = projectBoundedCodexThreadHistory({ + thread: importedThread, + throughTurnId: "turn-async-history", + importedAt: 1_800_000_000_000, + }); + + expect(projection.transcriptMessages).toHaveLength(4); + expect(projection.transcriptMessages[1]).toMatchObject({ phase: "commentary" }); + expect(projection.transcriptMessages[2]).toMatchObject({ + phase: "final_answer", + openclawAsyncDelivery: { itemId: "async-history" }, + }); + expect(JSON.stringify(projection.responseItems)).not.toContain( + "Which environment should I use?", + ); + expect(projection.responseItems).toHaveLength(3); + const visibleSessionHistory = projectBoundedCodexVisibleSessionHistory( + projection.transcriptMessages.map((message, index) => ({ + entryId: `entry-${index}`, + parentId: index === 0 ? null : `entry-${index - 1}`, + seq: index, + role: message.role, + message, + })), + ); + expect(JSON.stringify(visibleSessionHistory)).not.toContain("Which environment should I use?"); + expect(visibleSessionHistory).toHaveLength(3); + }); + it("accepts terminal boundaries", () => { for (const [status, stopReason] of [ ["completed", "stop"], diff --git a/extensions/codex/src/app-server/transcript-mirror.ts b/extensions/codex/src/app-server/transcript-mirror.ts index c3e04eb33077..e4a94bfd842e 100644 --- a/extensions/codex/src/app-server/transcript-mirror.ts +++ b/extensions/codex/src/app-server/transcript-mirror.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; import { + deliverAgentHarnessUserInputPrompt, embeddedAgentLog, formatErrorMessage, projectAgentHarnessTranscriptMessageForDisplay, @@ -578,7 +579,7 @@ async function deliverAsyncMessageBestEffort(params: { return "retry"; } try { - await params.params.onBlockReply({ text: params.text }, { deliveryIntentId }); + await deliverAsyncBlockReply(params.params.onBlockReply, params.text, deliveryIntentId); return "settled"; } catch (error) { embeddedAgentLog.warn("failed to deliver codex async agent message", { @@ -623,7 +624,7 @@ async function deliverAsyncMessageBestEffort(params: { ); if (params.params.onBlockReply && persistedText !== undefined) { try { - await params.params.onBlockReply({ text: persistedText }, { deliveryIntentId }); + await deliverAsyncBlockReply(params.params.onBlockReply, persistedText, deliveryIntentId); } catch (error) { embeddedAgentLog.warn("failed to deliver persisted codex async agent message", { error: formatErrorMessage(error), @@ -644,6 +645,20 @@ export const codexTranscriptMirrorRuntime = { mirrorBestEffort, }; +async function deliverAsyncBlockReply( + onBlockReply: NonNullable, + text: string, + deliveryIntentId: string, +): Promise { + // Harness-owned prompts already carry the host's canonical source-delivery + // authorization; an empty question list keeps the upstream message exact. + await deliverAgentHarnessUserInputPrompt( + { onBlockReply: (payload) => onBlockReply(payload, { deliveryIntentId }) }, + [], + { intro: text }, + ); +} + function resolveCodexMirrorTranscriptTarget(params: { agentId?: string; sessionId: string; diff --git a/extensions/codex/src/conversation-binding.ts b/extensions/codex/src/conversation-binding.ts index 756462743ef0..40b93643befe 100644 --- a/extensions/codex/src/conversation-binding.ts +++ b/extensions/codex/src/conversation-binding.ts @@ -46,6 +46,7 @@ import { isCodexAppServerIndeterminateRequestCancellationError, type CodexAppServerClient, } from "./app-server/client.js"; +import type { CodexAppServerManagedApprovalPolicy } from "./app-server/config-contracts.js"; import { canUseCodexModelBackedApprovalsReviewerForModel, codexSandboxPolicyForTurn, @@ -461,7 +462,7 @@ type CodexThreadBindingParams = { model?: string; modelProvider?: string; authProfileId?: string; - approvalPolicy?: CodexAppServerApprovalPolicy; + approvalPolicy?: CodexAppServerManagedApprovalPolicy; sandbox?: CodexAppServerSandboxMode; serviceTier?: CodexServiceTier; config?: CodexAppServerAuthProfileLookup["config"]; diff --git a/src/auto-reply/reply/dispatch-from-config.delivery-and-tts.test-utils.ts b/src/auto-reply/reply/dispatch-from-config.delivery-and-tts.test-utils.ts index bc2ad313e3af..355de62d4825 100644 --- a/src/auto-reply/reply/dispatch-from-config.delivery-and-tts.test-utils.ts +++ b/src/auto-reply/reply/dispatch-from-config.delivery-and-tts.test-utils.ts @@ -1952,6 +1952,36 @@ describe("dispatchReplyFromConfig", () => { }); }); + it("delivers independent durable updates immediately without mixing them into the final Telegram voice reply", async () => { + setNoAbort(); + installCaptionedVoiceTestPlugin("telegram"); + ttsMocks.state.synthesizeFinalAudio = true; + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ Provider: "telegram", Surface: "telegram" }); + const replyResolver = async ( + _ctx: MsgContext, + opts?: GetReplyOptions, + ): Promise => { + await opts?.onBlockReply?.( + { text: "Which environment should I use?" }, + { deliveryIntentId: "block-reply:v1:codex-app-server:thread-1:turn-1:question" }, + ); + expect(dispatcher.sendBlockReply).toHaveBeenCalledWith({ + text: "Which environment should I use?", + }); + await opts?.onBlockReply?.({ text: "The selected environment is ready." }); + return { text: "Deployment complete." }; + }; + + await dispatchReplyFromConfig({ ctx, cfg: emptyConfig, dispatcher, replyResolver }); + + expect(firstFinalReplyPayload(dispatcher)).toMatchObject({ + text: "The selected environment is ready.\nDeployment complete.", + mediaUrl: "https://example.com/tts-synth.opus", + audioAsVoice: true, + }); + }); + it("delivers deferred Telegram text when synthesis produces no audio", async () => { setNoAbort(); installCaptionedVoiceTestPlugin("telegram"); diff --git a/src/auto-reply/reply/dispatch-from-config.execute.ts b/src/auto-reply/reply/dispatch-from-config.execute.ts index 7f8097604c5a..d976eb516c8b 100644 --- a/src/auto-reply/reply/dispatch-from-config.execute.ts +++ b/src/auto-reply/reply/dispatch-from-config.execute.ts @@ -427,6 +427,10 @@ export async function executeDispatch(state: PrepareDispatchExecutionReadyState) } // Buffered commentary preceded this block; deliver it first. await flushPendingCommentaryProgress(); + const independentDurableBlock = context?.deliveryIntentId !== undefined; + if (independentDurableBlock && state.suppressAcpChildUserDelivery) { + return; + } if ( state.suppressDelivery && !shouldDeliverDespiteSourceReplySuppression(inputPayload, state) @@ -458,12 +462,12 @@ export async function executeDispatch(state: PrepareDispatchExecutionReadyState) // and must not be synthesised into the spoken reply. Display // lanes stay out too: they are presentation, never final text. const isStatusNotice = isReplyPayloadStatusNotice(payload); - if ( - payload.text && + const contributesToFinalReply = !isStatusNotice && + !independentDurableBlock && payload.isReasoning !== true && - payload.isCommentary !== true - ) { + payload.isCommentary !== true; + if (payload.text && contributesToFinalReply) { const joinsBufferedTtsDirective = cleanBlockTtsDirectiveText?.hasBufferedDirectiveText() === true; if (state.progressState.accumulatedBlockText.length > 0) { @@ -480,11 +484,7 @@ export async function executeDispatch(state: PrepareDispatchExecutionReadyState) state.progressState.blockCount++; } let visiblePayload = - payload.text && - cleanBlockTtsDirectiveText && - !isStatusNotice && - payload.isReasoning !== true && - payload.isCommentary !== true + payload.text && cleanBlockTtsDirectiveText && contributesToFinalReply ? (() => { const text = cleanBlockTtsDirectiveText.push(payload.text); return copyReplyPayloadMetadata(payload, { @@ -493,11 +493,7 @@ export async function executeDispatch(state: PrepareDispatchExecutionReadyState) }); })() : payload; - const deferThisBlock = - deferFinalTtsText && - !isStatusNotice && - payload.isReasoning !== true && - payload.isCommentary !== true; + const deferThisBlock = deferFinalTtsText && contributesToFinalReply; if (deferThisBlock) { const hasNonTextContent = Boolean( visiblePayload.mediaUrl || @@ -547,7 +543,10 @@ export async function executeDispatch(state: PrepareDispatchExecutionReadyState) if (isDispatchOperationAborted()) { return; } - if (context?.deliveryIntentId || shouldRouteToOriginating) { + if ( + shouldRouteToOriginating || + (independentDurableBlock && state.canRouteDurableBlockReply) + ) { const result = await sendPayloadAsync( normalizedPayload, context?.abortSignal, diff --git a/src/auto-reply/reply/dispatch-from-config.prepare-delivery.ts b/src/auto-reply/reply/dispatch-from-config.prepare-delivery.ts index 7650e6b1d4db..9c6f9f887466 100644 --- a/src/auto-reply/reply/dispatch-from-config.prepare-delivery.ts +++ b/src/auto-reply/reply/dispatch-from-config.prepare-delivery.ts @@ -84,6 +84,15 @@ export async function prepareDispatchDelivery(state: GatherDispatchRequestReadyS isRoutableChannel: routeReplyRuntime?.isRoutableChannel ?? (() => false), }); const routeReplyTo = replyRoute.to; + // Durable intent identifies an outbound write; it never authorizes a new + // destination or bypasses private-webchat and parent-owned-session fences. + const canRouteDurableBlockReply = Boolean( + !suppressAcpChildUserDelivery && + !isInternalWebchatTurn && + routeReplyChannel && + routeReplyTo && + routeReplyChannel === normalizedCurrentSurface, + ); const deliveryChannel = shouldRouteToOriginating ? routeReplyChannel : currentSurface; const replyContextAccountId = routeReplyChannel ? resolveReplyDeliveryAccountId(cfg, routeReplyChannel, replyRoute.accountId) @@ -133,10 +142,12 @@ export async function prepareDispatchDelivery(state: GatherDispatchRequestReadyS deliveryIntentId?: string; }, ) => { + const durableRouteAuthorized = + options?.deliveryIntentId !== undefined && canRouteDurableBlockReply; const runtime = - routeReplyRuntime ?? (options?.deliveryIntentId ? await loadRouteReplyRuntime() : undefined); + routeReplyRuntime ?? (durableRouteAuthorized ? await loadRouteReplyRuntime() : undefined); if ( - (!shouldRouteToOriginating && !options?.deliveryIntentId) || + (!shouldRouteToOriginating && !durableRouteAuthorized) || !routeReplyChannel || !routeReplyTo || !runtime @@ -281,6 +292,7 @@ export async function prepareDispatchDelivery(state: GatherDispatchRequestReadyS normalizedCurrentSurface, isInternalWebchatTurn, routeReplyChannel, + canRouteDurableBlockReply, shouldRouteToOriginating, shouldSuppressTyping, routeReplyTo, diff --git a/src/auto-reply/reply/dispatch-from-config.routing.test-utils.ts b/src/auto-reply/reply/dispatch-from-config.routing.test-utils.ts index 6295df0f6126..5b108f92fae2 100644 --- a/src/auto-reply/reply/dispatch-from-config.routing.test-utils.ts +++ b/src/auto-reply/reply/dispatch-from-config.routing.test-utils.ts @@ -303,6 +303,71 @@ describe("dispatchReplyFromConfig", () => { expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1); }); + it("never lets a durable block intent route a private webchat turn to an inherited external recipient", async () => { + setNoAbort(); + mocks.routeReply.mockClear(); + installThreadingTestPlugin({ id: "imessage" }); + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ + Provider: "webchat", + Surface: "webchat", + OriginatingChannel: "imessage", + OriginatingTo: "imessage:+15550001111", + }); + const replyResolver = async (_ctx: MsgContext, opts?: GetReplyOptions) => { + await requireBlockReplyHandler(opts?.onBlockReply)( + { text: "Private dashboard update" }, + { deliveryIntentId: "block-reply:v1:codex-app-server:thread-1:turn-1:private" }, + ); + return undefined; + }; + + await dispatchReplyFromConfig({ + ctx, + cfg: automaticDirectReplyConfig, + dispatcher, + replyResolver, + }); + + expect(mocks.routeReply).not.toHaveBeenCalled(); + expect(dispatcher.sendBlockReply).toHaveBeenCalledWith({ text: "Private dashboard update" }); + }); + + it("never lets a durable block intent deliver directly from a parent-owned background session", async () => { + setNoAbort(); + mocks.routeReply.mockClear(); + installThreadingTestPlugin({ id: "telegram" }); + sessionStoreMocks.currentEntry = { + sessionId: "background-child", + spawnedBy: "agent:main:parent", + acp: { backend: "codex" }, + }; + const dispatcher = createDispatcher(); + const replyResolver = async (_ctx: MsgContext, opts?: GetReplyOptions) => { + await requireBlockReplyHandler(opts?.onBlockReply)( + { text: "Private delegated progress" }, + { deliveryIntentId: "block-reply:v1:codex-app-server:thread-1:turn-1:child" }, + ); + return undefined; + }; + + await dispatchReplyFromConfig({ + ctx: buildTestCtx({ + Provider: "telegram", + Surface: "telegram", + SessionKey: "agent:main:background-child", + OriginatingChannel: "telegram", + OriginatingTo: "telegram:999", + }), + cfg: automaticDirectReplyConfig, + dispatcher, + replyResolver, + }); + + expect(mocks.routeReply).not.toHaveBeenCalled(); + expect(dispatcher.sendBlockReply).not.toHaveBeenCalled(); + }); + it("routes external origin replies for internal webchat turns when explicit delivery is set", async () => { setNoAbort(); mocks.routeReply.mockClear(); diff --git a/src/auto-reply/reply/dispatch-from-config.send-policy-routing.test-utils.ts b/src/auto-reply/reply/dispatch-from-config.send-policy-routing.test-utils.ts index b381536b822d..66ecde5aa9f3 100644 --- a/src/auto-reply/reply/dispatch-from-config.send-policy-routing.test-utils.ts +++ b/src/auto-reply/reply/dispatch-from-config.send-policy-routing.test-utils.ts @@ -215,6 +215,49 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () => expect(dispatcher.sendBlockReply).toHaveBeenCalledTimes(1); }); + it.each([ + { name: "permits", sendPolicy: "allow", delivered: true, context: {} }, + { name: "rejects", sendPolicy: "deny", delivered: false, context: {} }, + { + name: "rejects ambient room events for", + sendPolicy: "allow", + delivered: false, + context: { ChatType: "group", InboundEventKind: "room_event" } as const, + }, + ])( + "$name authorized durable harness updates in message-tool-only turns", + async ({ sendPolicy, delivered, context }) => { + setNoAbort(); + sessionStoreMocks.currentEntry = { sessionId: "s1", updatedAt: 0, sendPolicy }; + const dispatcher = createDispatcher(); + const payload = setReplyPayloadMetadata( + { text: "Background agent update." }, + { deliverDespiteSourceReplySuppression: true }, + ); + const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => { + await requireBlockReplyHandler(opts?.onBlockReply)(payload, { + deliveryIntentId: "block-reply:v1:codex-app-server:thread-1:turn-1:async-update", + }); + return []; + }); + + await dispatchReplyFromConfig({ + ctx: buildTestCtx({ + ChatType: "direct", + SessionKey: "test:async-harness-update", + ...context, + }), + cfg: emptyConfig, + dispatcher, + replyResolver, + replyOptions: { sourceReplyDeliveryMode: "message_tool_only" }, + }); + + expect(dispatcher.sendBlockReply).toHaveBeenCalledTimes(delivered ? 1 : 0); + expect(dispatcher.sendFinalReply).not.toHaveBeenCalled(); + }, + ); + it("keeps hook-cancelled marked blocks out of delivery and queued callbacks", async () => { setNoAbort(); sessionStoreMocks.currentEntry = { diff --git a/src/plugin-sdk/agent-harness-runtime.test.ts b/src/plugin-sdk/agent-harness-runtime.test.ts index a6840e2aa05f..622d8cdd803b 100644 --- a/src/plugin-sdk/agent-harness-runtime.test.ts +++ b/src/plugin-sdk/agent-harness-runtime.test.ts @@ -2,6 +2,7 @@ * Tests agent harness runtime helpers and task dispatch behavior. */ import { describe, expect, expectTypeOf, it, vi } from "vitest"; +import { getReplyPayloadMetadata } from "../auto-reply/reply-payload.js"; import { attachModelProviderRequestTransport, buildAgentHarnessUserInputAnswers, @@ -222,6 +223,20 @@ describe("agent harness runtime SDK facade", () => { }); describe("agent harness user input helpers", () => { + it("authorizes host-owned text-only harness updates without altering their visible payload", async () => { + const onBlockReply = vi.fn(); + + await deliverAgentHarnessUserInputPrompt({ onBlockReply }, [], { + intro: "Which environment should I use?", + }); + + const payload = onBlockReply.mock.calls[0]?.[0]; + expect(payload).toEqual({ text: "Which environment should I use?", presentation: undefined }); + expect(getReplyPayloadMetadata(payload)).toMatchObject({ + deliverDespiteSourceReplySuppression: true, + }); + }); + it("formats prompts and delivers through blocking replies first", async () => { const onBlockReply = vi.fn();