From 00f3fb24693dd9f813344b5d580cf857339bf300 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 4 Aug 2026 21:29:29 +0800 Subject: [PATCH 1/9] fix(discord): bound realtime exact speech --- extensions/discord/src/voice/manager.ts | 7 ++++++ extensions/discord/src/voice/realtime.ts | 30 ++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/extensions/discord/src/voice/manager.ts b/extensions/discord/src/voice/manager.ts index 8082bfcc5747..5085869aab14 100644 --- a/extensions/discord/src/voice/manager.ts +++ b/extensions/discord/src/voice/manager.ts @@ -746,6 +746,7 @@ export class DiscordVoiceManager { receiveRecovery: createVoiceReceiveRecoveryState(), isStopped: () => stopped, stop: () => { + clearSessionIfCurrent(); stopEntry(entry, { destroyConnection: true, reason: `stop guild ${guildId} channel ${channelId}`, @@ -884,6 +885,12 @@ export class DiscordVoiceManager { entry, getHumanParticipantCount: () => this.membership.countHumanParticipants(entry, this.botUserId), mode: voiceMode, + onTerminalError: (error) => { + logger.error( + `discord voice: realtime session failed terminally guild=${entry.guildId} channel=${entry.channelId}: ${formatErrorMessage(error)}`, + ); + entry.stop(); + }, runAgentTurn: ({ context, message, toolsAllow, userId }) => this.runDiscordRealtimeAgentTurn({ context, entry, message, toolsAllow, userId }), }); diff --git a/extensions/discord/src/voice/realtime.ts b/extensions/discord/src/voice/realtime.ts index 67ff7ba01a16..851b63508093 100644 --- a/extensions/discord/src/voice/realtime.ts +++ b/extensions/discord/src/voice/realtime.ts @@ -89,6 +89,8 @@ const DISCORD_REALTIME_FORCED_CONSULT_FALLBACK_DELAY_MS = 200; const DISCORD_REALTIME_DUPLICATE_ERROR_SUPPRESS_MS = 60_000; const DISCORD_REALTIME_CONTROL_SPEECH_DEDUPE_MS = 5_000; const DISCORD_REALTIME_OUTPUT_PLAYBACK_WATCHDOG_MARGIN_MS = 1_500; +const DISCORD_REALTIME_MAX_RETAINED_EXACT_SPEECH_MESSAGES = 32; +const DISCORD_REALTIME_MAX_RETAINED_EXACT_SPEECH_CHARS = 32 * 1024; const DISCORD_REALTIME_CANCELLATION_RACE_DETAIL = "Cancellation failed: no active response found"; const DISCORD_REALTIME_WAKE_ACKS = ["Yeah.", "Mm-hmm.", "Got it.", "One sec."]; const discordRealtimeTalkPayload = () => ({}); @@ -388,6 +390,7 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession { mode: Exclude; bootstrapContextInstructions?: string; getHumanParticipantCount?: () => number; + onTerminalError: (error: Error) => void; runAgentTurn: (params: VoiceRealtimeAgentTurnParams) => Promise; }, ) { @@ -994,6 +997,33 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession { if (this.stopped || !text.trim()) { return; } + const retainedMessages = + this.queuedExactSpeechMessages.length + (this.activeExactSpeechMessage ? 1 : 0); + const retainedChars = + this.queuedExactSpeechMessages.reduce((total, message) => total + message.length, 0) + + (this.activeExactSpeechMessage?.length ?? 0); + if ( + retainedMessages >= DISCORD_REALTIME_MAX_RETAINED_EXACT_SPEECH_MESSAGES || + retainedChars + text.length > DISCORD_REALTIME_MAX_RETAINED_EXACT_SPEECH_CHARS + ) { + // Completed speech cannot be silently dropped. Overflow terminally retires + // this session before late provider or playback events can drain stale work. + this.stopped = true; + this.bridgeReady = false; + this.outputBackpressure = undefined; + this.talkback.close(); + this.queuedExactSpeechMessages = []; + this.exactSpeechResponseActive = false; + this.exactSpeechAudioStarted = false; + this.activeExactSpeechMessage = undefined; + this.clearOutputAudio("exact-speech-overflow"); + this.params.onTerminalError( + new Error( + `Discord realtime exact speech overflow: retained=${retainedMessages} retainedChars=${retainedChars} incomingChars=${text.length}`, + ), + ); + return; + } if (!this.bridgeReady || this.exactSpeechResponseActive || this.hasInterruptibleOutputAudio()) { this.queuedExactSpeechMessages.push(text); logger.info( From 47f8c4febb79adc193eefdfe75c9ad8a970364a0 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 4 Aug 2026 21:29:30 +0800 Subject: [PATCH 2/9] test(discord): cover exact speech overflow --- .../discord/src/voice/manager.e2e.test.ts | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/extensions/discord/src/voice/manager.e2e.test.ts b/extensions/discord/src/voice/manager.e2e.test.ts index 95b6498548d8..9ec411b399d2 100644 --- a/extensions/discord/src/voice/manager.e2e.test.ts +++ b/extensions/discord/src/voice/manager.e2e.test.ts @@ -4114,6 +4114,66 @@ describe("DiscordVoiceManager", () => { expectUserMessageIncludes("third answer"); }); + it("terminates realtime voice when retained exact speech exceeds the character budget", async () => { + const manager = createAgentProxyManager(); + + await manager.join({ guildId: "g1", channelId: "1001" }); + const entry = getSessionEntry(manager); + const realtime = entry.realtime as unknown as { + enqueueExactSpeechMessage: (text: string) => void; + }; + const connection = (entry as unknown as { connection: { destroy: ReturnType } }) + .connection; + const bridgeParams = lastRealtimeBridgeParams(); + const accepted = "a".repeat(32 * 1024); + + realtime.enqueueExactSpeechMessage(accepted); + expectUserMessageIncludes(accepted); + expect(manager.status()).toHaveLength(1); + + realtime.enqueueExactSpeechMessage("overflow"); + + expect(manager.status()).toStrictEqual([]); + expect(connection.destroy).toHaveBeenCalledOnce(); + expect(realtimeSessionMock.close).toHaveBeenCalledOnce(); + expectUserMessageNotIncludes("overflow"); + + bridgeParams.onReady?.(); + bridgeParams.onEvent?.({ direction: "server", type: "response.done" }); + realtime.enqueueExactSpeechMessage("late"); + entry.stop(); + + expect(connection.destroy).toHaveBeenCalledOnce(); + expect(realtimeSessionMock.close).toHaveBeenCalledOnce(); + expectUserMessageNotIncludes("late"); + }); + + it("terminates realtime voice when retained exact speech exceeds the message budget", async () => { + const manager = createAgentProxyManager(); + + await manager.join({ guildId: "g1", channelId: "1001" }); + const entry = getSessionEntry(manager); + const realtime = entry.realtime as unknown as { + enqueueExactSpeechMessage: (text: string) => void; + }; + const connection = (entry as unknown as { connection: { destroy: ReturnType } }) + .connection; + + for (let index = 0; index < 32; index += 1) { + realtime.enqueueExactSpeechMessage(`answer-${index}`); + } + + expect(manager.status()).toHaveLength(1); + expect(realtimeSessionMock.sendUserMessage).toHaveBeenCalledOnce(); + + realtime.enqueueExactSpeechMessage("answer-overflow"); + + expect(manager.status()).toStrictEqual([]); + expect(connection.destroy).toHaveBeenCalledOnce(); + expect(realtimeSessionMock.close).toHaveBeenCalledOnce(); + expectUserMessageNotIncludes("answer-overflow"); + }); + it("does not interrupt active exact speech for a later forced agent-proxy consult", async () => { agentCommandMock .mockResolvedValueOnce({ payloads: [{ text: "first answer" }] }) From 193317fd68c9d3d84854733815b5656a7a97acac Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 4 Aug 2026 22:08:37 +0800 Subject: [PATCH 3/9] test(discord): isolate exact speech overflow --- .../discord/src/voice/manager.e2e.test.ts | 45 ++++++++++++++----- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/extensions/discord/src/voice/manager.e2e.test.ts b/extensions/discord/src/voice/manager.e2e.test.ts index 9ec411b399d2..56fd0bd85256 100644 --- a/extensions/discord/src/voice/manager.e2e.test.ts +++ b/extensions/discord/src/voice/manager.e2e.test.ts @@ -433,15 +433,18 @@ describe("DiscordVoiceManager", () => { runtime: createRuntime(), }); - const createAgentProxyManager = () => - createManager({ - groupPolicy: "open", - voice: { - enabled: true, - mode: "agent-proxy", - realtime: { provider: "openai" }, + const createAgentProxyManager = (clientOverride?: ReturnType) => + createManager( + { + groupPolicy: "open", + voice: { + enabled: true, + mode: "agent-proxy", + realtime: { provider: "openai" }, + }, }, - }); + clientOverride, + ); const expectConnectedStatus = ( manager: InstanceType, @@ -4115,7 +4118,17 @@ describe("DiscordVoiceManager", () => { }); it("terminates realtime voice when retained exact speech exceeds the character budget", async () => { - const manager = createAgentProxyManager(); + const client = createClient(); + client.fetchChannel.mockImplementation(async (channelId: string) => { + const guildId = channelId === "2001" ? "g2" : "g1"; + return { + id: channelId, + guildId, + guild: { id: guildId, name: guildId }, + type: ChannelType.GuildVoice, + }; + }); + const manager = createAgentProxyManager(client); await manager.join({ guildId: "g1", channelId: "1001" }); const entry = getSessionEntry(manager); @@ -4127,17 +4140,27 @@ describe("DiscordVoiceManager", () => { const bridgeParams = lastRealtimeBridgeParams(); const accepted = "a".repeat(32 * 1024); + await manager.join({ guildId: "g2", channelId: "2001" }); + const siblingRealtime = getSessionEntry(manager, "g2").realtime as unknown as { + enqueueExactSpeechMessage: (text: string) => void; + }; + realtime.enqueueExactSpeechMessage(accepted); expectUserMessageIncludes(accepted); - expect(manager.status()).toHaveLength(1); + expect(manager.status()).toHaveLength(2); realtime.enqueueExactSpeechMessage("overflow"); - expect(manager.status()).toStrictEqual([]); + expect(manager.status()).toEqual([ + expect.objectContaining({ guildId: "g2", channelId: "2001" }), + ]); expect(connection.destroy).toHaveBeenCalledOnce(); expect(realtimeSessionMock.close).toHaveBeenCalledOnce(); expectUserMessageNotIncludes("overflow"); + siblingRealtime.enqueueExactSpeechMessage("sibling remains usable"); + expectUserMessageIncludes("sibling remains usable"); + bridgeParams.onReady?.(); bridgeParams.onEvent?.({ direction: "server", type: "response.done" }); realtime.enqueueExactSpeechMessage("late"); From c238564d2707be5527e83b112025443401e7e2fa Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 4 Aug 2026 22:27:43 +0800 Subject: [PATCH 4/9] fix(discord): count retained speech bytes --- extensions/discord/src/voice/manager.e2e.test.ts | 6 ++++-- extensions/discord/src/voice/realtime.ts | 15 +++++++++------ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/extensions/discord/src/voice/manager.e2e.test.ts b/extensions/discord/src/voice/manager.e2e.test.ts index 56fd0bd85256..16dc4ab27011 100644 --- a/extensions/discord/src/voice/manager.e2e.test.ts +++ b/extensions/discord/src/voice/manager.e2e.test.ts @@ -4117,7 +4117,7 @@ describe("DiscordVoiceManager", () => { expectUserMessageIncludes("third answer"); }); - it("terminates realtime voice when retained exact speech exceeds the character budget", async () => { + it("terminates realtime voice when retained Unicode speech exceeds the byte budget", async () => { const client = createClient(); client.fetchChannel.mockImplementation(async (channelId: string) => { const guildId = channelId === "2001" ? "g2" : "g1"; @@ -4138,7 +4138,9 @@ describe("DiscordVoiceManager", () => { const connection = (entry as unknown as { connection: { destroy: ReturnType } }) .connection; const bridgeParams = lastRealtimeBridgeParams(); - const accepted = "a".repeat(32 * 1024); + const accepted = "😀".repeat(8 * 1024); + expect(accepted.length).toBe(16 * 1024); + expect(Buffer.byteLength(accepted, "utf8")).toBe(32 * 1024); await manager.join({ guildId: "g2", channelId: "2001" }); const siblingRealtime = getSessionEntry(manager, "g2").realtime as unknown as { diff --git a/extensions/discord/src/voice/realtime.ts b/extensions/discord/src/voice/realtime.ts index 851b63508093..ed18f91ef12d 100644 --- a/extensions/discord/src/voice/realtime.ts +++ b/extensions/discord/src/voice/realtime.ts @@ -90,7 +90,7 @@ const DISCORD_REALTIME_DUPLICATE_ERROR_SUPPRESS_MS = 60_000; const DISCORD_REALTIME_CONTROL_SPEECH_DEDUPE_MS = 5_000; const DISCORD_REALTIME_OUTPUT_PLAYBACK_WATCHDOG_MARGIN_MS = 1_500; const DISCORD_REALTIME_MAX_RETAINED_EXACT_SPEECH_MESSAGES = 32; -const DISCORD_REALTIME_MAX_RETAINED_EXACT_SPEECH_CHARS = 32 * 1024; +const DISCORD_REALTIME_MAX_RETAINED_EXACT_SPEECH_BYTES = 32 * 1024; const DISCORD_REALTIME_CANCELLATION_RACE_DETAIL = "Cancellation failed: no active response found"; const DISCORD_REALTIME_WAKE_ACKS = ["Yeah.", "Mm-hmm.", "Got it.", "One sec."]; const discordRealtimeTalkPayload = () => ({}); @@ -999,12 +999,15 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession { } const retainedMessages = this.queuedExactSpeechMessages.length + (this.activeExactSpeechMessage ? 1 : 0); - const retainedChars = - this.queuedExactSpeechMessages.reduce((total, message) => total + message.length, 0) + - (this.activeExactSpeechMessage?.length ?? 0); + const retainedBytes = + this.queuedExactSpeechMessages.reduce( + (total, message) => total + Buffer.byteLength(message, "utf8"), + 0, + ) + Buffer.byteLength(this.activeExactSpeechMessage ?? "", "utf8"); + const incomingBytes = Buffer.byteLength(text, "utf8"); if ( retainedMessages >= DISCORD_REALTIME_MAX_RETAINED_EXACT_SPEECH_MESSAGES || - retainedChars + text.length > DISCORD_REALTIME_MAX_RETAINED_EXACT_SPEECH_CHARS + retainedBytes + incomingBytes > DISCORD_REALTIME_MAX_RETAINED_EXACT_SPEECH_BYTES ) { // Completed speech cannot be silently dropped. Overflow terminally retires // this session before late provider or playback events can drain stale work. @@ -1019,7 +1022,7 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession { this.clearOutputAudio("exact-speech-overflow"); this.params.onTerminalError( new Error( - `Discord realtime exact speech overflow: retained=${retainedMessages} retainedChars=${retainedChars} incomingChars=${text.length}`, + `Discord realtime exact speech overflow: retained=${retainedMessages} retainedBytes=${retainedBytes} incomingBytes=${incomingBytes}`, ), ); return; From 1aedd8f37e0bd40e2a71f50824896e24f24b21d4 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 4 Aug 2026 22:31:10 +0800 Subject: [PATCH 5/9] fix(memory): load configured providers for CLI search (#119186) * fix(memory): activate configured providers in CLI commands * fix(memory): resolve configured embedding provider owners * test(memory): keep metadata mock unset by default * fix(memory): preserve plugin allowlist policy * test(memory): classify plugin-owned root command --- src/cli/command-bootstrap.test.ts | 5 +- src/cli/command-catalog.ts | 9 +- src/cli/command-path-policy.test.ts | 21 +-- src/cli/command-startup-policy.test.ts | 9 ++ src/cli/program/preaction.test-helpers.ts | 2 - src/cli/program/preaction.ts | 17 +- .../program/root-command-descriptions.test.ts | 1 + src/plugins/activation-context.test.ts | 32 +++- src/plugins/channel-plugin-ids.test.ts | 12 ++ .../runtime/runtime-registry-loader.test.ts | 149 ++++++++++++++++++ .../runtime/runtime-registry-loader.ts | 40 ++++- 11 files changed, 271 insertions(+), 26 deletions(-) diff --git a/src/cli/command-bootstrap.test.ts b/src/cli/command-bootstrap.test.ts index cd7e97c62163..1551aa7d2cfa 100644 --- a/src/cli/command-bootstrap.test.ts +++ b/src/cli/command-bootstrap.test.ts @@ -68,15 +68,16 @@ describe("ensureCliCommandBootstrap", () => { it("skips config guard without skipping plugin loading", async () => { await ensureCliCommandBootstrap({ runtime: {} as never, - commandPath: ["status"], + commandPath: ["memory", "search"], suppressDoctorStdout: true, skipConfigGuard: true, loadPlugins: true, + pluginRegistry: { scope: "memory" }, }); expect(ensureConfigReadyMock).not.toHaveBeenCalled(); expect(ensureCliPluginRegistryLoadedMock).toHaveBeenCalledWith({ - scope: "channels", + scope: "memory", routeLogsToStderr: true, }); }); diff --git a/src/cli/command-catalog.ts b/src/cli/command-catalog.ts index 98d11b87886b..dd7a4498a83e 100644 --- a/src/cli/command-catalog.ts +++ b/src/cli/command-catalog.ts @@ -10,7 +10,7 @@ type CliConfigGuardMode = "run" | "skip" | "when-suppressed"; type CliConfigGuardPolicy = | CliConfigGuardMode | ((ctx: { argv: string[]; commandPath: string[] }) => CliConfigGuardMode); -export type CliPluginRegistryScope = "all" | "channels" | "configured-channels"; +export type CliPluginRegistryScope = "all" | "channels" | "configured-channels" | "memory"; export type CliPluginRegistryPolicy = { scope: CliPluginRegistryScope; }; @@ -607,10 +607,14 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [ exact: true, policy: { configGuard: "skip", loadPlugins: "never" }, }, + { + commandPath: ["memory"], + policy: { loadPlugins: "always", pluginRegistry: { scope: "memory" } }, + }, { commandPath: ["memory", "search"], exact: true, - policy: { configGuard: "skip", loadPlugins: "never" }, + policy: { configGuard: "skip" }, }, { commandPath: ["memory", "status"], @@ -618,7 +622,6 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [ policy: { configGuard: ({ argv }) => hasFlag(argv, "--index") || hasFlag(argv, "--fix") ? "run" : "skip", - loadPlugins: "never", }, }, { commandPath: ["skills", "update"], exact: true }, diff --git a/src/cli/command-path-policy.test.ts b/src/cli/command-path-policy.test.ts index dc3402060dd4..09c58583dba4 100644 --- a/src/cli/command-path-policy.test.ts +++ b/src/cli/command-path-policy.test.ts @@ -377,18 +377,19 @@ describe("command-path-policy", () => { networkProxy: "bypass", }); } - for (const commandPath of [ - ["skills", "search"], - ["memory", "search"], - ]) { - expectResolvedPolicy(commandPath, { - configGuard: "skip", - loadPlugins: "never", - }); - } + expectResolvedPolicy(["skills", "search"], { + configGuard: "skip", + loadPlugins: "never", + }); + expectResolvedPolicy(["memory", "search"], { + configGuard: "skip", + loadPlugins: "always", + pluginRegistry: { scope: "memory" }, + }); const memoryStatusPolicy = resolveCliCommandPathPolicy(["memory", "status"]); expectConfigGuardResolver(memoryStatusPolicy); - expect(memoryStatusPolicy.loadPlugins).toBe("never"); + expect(memoryStatusPolicy.loadPlugins).toBe("always"); + expect(memoryStatusPolicy.pluginRegistry).toEqual({ scope: "memory" }); expect( memoryStatusPolicy.configGuard({ argv: ["node", "openclaw", "memory", "status"], diff --git a/src/cli/command-startup-policy.test.ts b/src/cli/command-startup-policy.test.ts index 11fac9e3ced8..37777ae326d6 100644 --- a/src/cli/command-startup-policy.test.ts +++ b/src/cli/command-startup-policy.test.ts @@ -185,6 +185,15 @@ describe("command-startup-policy", () => { }); it("matches plugin preload policy", () => { + for (const commandPath of [ + ["memory", "index"], + ["memory", "search"], + ["memory", "status"], + ]) { + const policy = resolvePolicy({ commandPath }); + expect(policy.loadPlugins, commandPath.join(" ")).toBe(true); + expect(policy.pluginRegistry, commandPath.join(" ")).toEqual({ scope: "memory" }); + } expect( resolvePolicy({ commandPath: ["status"], diff --git a/src/cli/program/preaction.test-helpers.ts b/src/cli/program/preaction.test-helpers.ts index 83c35e0749c7..175853ff8beb 100644 --- a/src/cli/program/preaction.test-helpers.ts +++ b/src/cli/program/preaction.test-helpers.ts @@ -7,8 +7,6 @@ export const COLD_READ_COMMAND_PATHS: string[][] = [ ["hooks", "list"], ["hooks", "info"], ["hooks", "check"], - ["memory", "status"], - ["memory", "search"], ["update", "--dry-run"], ]; diff --git a/src/cli/program/preaction.ts b/src/cli/program/preaction.ts index a9b02d2503b3..3bda0f1737dc 100644 --- a/src/cli/program/preaction.ts +++ b/src/cli/program/preaction.ts @@ -137,11 +137,18 @@ export function registerPreActionHooks(program: Command, programVersion: string) if (!verbose) { process.env.NODE_NO_WARNINGS ??= "1"; } - if ( - startupPolicy.skipConfigGuard || - isGuidedConfigAction(actionCommand) || - isGuidedConfigCommandPath(commandPath) - ) { + if (isGuidedConfigAction(actionCommand) || isGuidedConfigCommandPath(commandPath)) { + return; + } + if (startupPolicy.skipConfigGuard) { + // Config validation and plugin activation are independent startup policies. + // A cold config read must not suppress a plugin runtime explicitly required by the command. + await ensureCliExecutionBootstrap({ + runtime: defaultRuntime, + commandPath, + startupPolicy, + skipConfigGuard: true, + }); return; } let beforeStateMigrations: ((snapshot?: ConfigFileSnapshot) => Promise) | undefined; diff --git a/src/cli/program/root-command-descriptions.test.ts b/src/cli/program/root-command-descriptions.test.ts index 9a5ccce77936..a0a61ae68375 100644 --- a/src/cli/program/root-command-descriptions.test.ts +++ b/src/cli/program/root-command-descriptions.test.ts @@ -17,6 +17,7 @@ const RESERVED_CATALOG_ROOTS = { } as const; const PLUGIN_CATALOG_PATHS = { + memory: "registered and covered by the memory-core plugin", "memory search": "registered and covered by the memory-core plugin", "memory status": "registered and covered by the memory-core plugin", } as const; diff --git a/src/plugins/activation-context.test.ts b/src/plugins/activation-context.test.ts index ffa50a1a26c7..f4022a84136f 100644 --- a/src/plugins/activation-context.test.ts +++ b/src/plugins/activation-context.test.ts @@ -21,13 +21,43 @@ vi.mock("../config/plugin-auto-enable.js", () => ({ applyPluginAutoEnable: applyPluginAutoEnableMock, })); -import { resolveBundledPluginCompatibleActivationInputs } from "./activation-context.js"; +import { + resolveBundledPluginCompatibleActivationInputs, + withActivatedPluginIds, +} from "./activation-context.js"; afterEach(() => { clearCurrentPluginMetadataSnapshot(); applyPluginAutoEnableMock.mockClear(); }); +describe("withActivatedPluginIds", () => { + it("keeps omitted plugin ids outside restrictive allowlists", () => { + expect( + withActivatedPluginIds({ + config: { + plugins: { + allow: ["memory-core"], + deny: ["blocked"], + entries: { + disabled: { enabled: false }, + }, + }, + }, + pluginIds: ["openai", "blocked", "disabled"], + }), + ).toEqual({ + plugins: { + allow: ["memory-core"], + deny: ["blocked"], + entries: { + disabled: { enabled: false }, + }, + }, + }); + }); +}); + describe("resolveBundledPluginCompatibleActivationInputs", () => { it("passes the current manifest registry into activation auto-enable", () => { const manifestRegistry = makeRegistry([{ id: "openai", channels: [], providers: ["openai"] }]); diff --git a/src/plugins/channel-plugin-ids.test.ts b/src/plugins/channel-plugin-ids.test.ts index 3f859653b6db..dd07de0a580d 100644 --- a/src/plugins/channel-plugin-ids.test.ts +++ b/src/plugins/channel-plugin-ids.test.ts @@ -775,6 +775,18 @@ describe("resolveGatewayStartupPluginIdsFromRegistry", () => { } as OpenClawConfig, ["browser", "openai", "memory-core"], ], + [ + "keeps configured memory embedding providers behind restrictive allowlists", + { + channels: {}, + memory: { search: { provider: "openai" } }, + plugins: { + allow: ["memory-core"], + slots: { memory: "memory-core" }, + }, + } as OpenClawConfig, + ["memory-core"], + ], [ "includes the owning plugin for a configured memory embedding fallback at startup", { diff --git a/src/plugins/runtime/runtime-registry-loader.test.ts b/src/plugins/runtime/runtime-registry-loader.test.ts index 6e85b7e52d32..c4ecca89baa2 100644 --- a/src/plugins/runtime/runtime-registry-loader.test.ts +++ b/src/plugins/runtime/runtime-registry-loader.test.ts @@ -9,10 +9,16 @@ const mocks = vi.hoisted(() => ({ vi.fn(), resolveEffectivePluginIds: vi.fn(), + collectConfiguredMemoryEmbeddingProviderIds: + vi.fn< + typeof import("../gateway-startup-plugin-ids.js").collectConfiguredMemoryEmbeddingProviderIds + >(), applyPluginAutoEnable: vi.fn(), resolvePluginMetadataSnapshot: vi.fn(), + isPluginMetadataSnapshotCompatible: + vi.fn(), resolveAgentWorkspaceDir: vi.fn< typeof import("../../agents/agent-scope.js").resolveAgentWorkspaceDir >(() => "/resolved-workspace"), @@ -39,6 +45,12 @@ vi.mock("../effective-plugin-ids.js", () => ({ mocks.resolveEffectivePluginIds(...args), })); +vi.mock("../gateway-startup-plugin-ids.js", () => ({ + collectConfiguredMemoryEmbeddingProviderIds: ( + ...args: Parameters + ) => mocks.collectConfiguredMemoryEmbeddingProviderIds(...args), +})); + vi.mock("../../config/plugin-auto-enable.js", () => ({ applyPluginAutoEnable: (...args: Parameters) => mocks.applyPluginAutoEnable(...args), @@ -48,6 +60,9 @@ vi.mock("../plugin-metadata-snapshot.js", () => ({ resolvePluginMetadataSnapshot: ( ...args: Parameters ) => mocks.resolvePluginMetadataSnapshot(...args), + isPluginMetadataSnapshotCompatible: ( + ...args: Parameters + ) => mocks.isPluginMetadataSnapshotCompatible(...args), })); vi.mock("../../agents/agent-scope.js", () => ({ @@ -59,6 +74,28 @@ vi.mock("../../agents/agent-scope.js", () => ({ import { ensurePluginRegistryLoaded } from "./runtime-registry-loader.js"; +function useMemoryProviderOwner(params: { + adapterId: string; + contract: "embeddingProviders" | "memoryEmbeddingProviders"; + pluginId: string; +}): void { + mocks.resolvePluginMetadataSnapshot.mockReturnValue({ + policyHash: "test", + index: { + installRecords: {}, + plugins: [ + { + pluginId: params.pluginId, + contributions: { + contracts: { [params.contract]: [params.adapterId] }, + }, + }, + ], + }, + manifestRegistry: { plugins: [], diagnostics: [] }, + } as never); +} + function requireLoadOptions(): Record { const options = mocks.loadOpenClawPlugins.mock.calls[0]?.[0]; if (!options) { @@ -70,6 +107,8 @@ function requireLoadOptions(): Record { describe("ensurePluginRegistryLoaded", () => { beforeEach(() => { vi.clearAllMocks(); + mocks.resolvePluginMetadataSnapshot.mockReset(); + mocks.isPluginMetadataSnapshotCompatible.mockReturnValue(true); mocks.applyPluginAutoEnable.mockImplementation((params) => ({ config: params.config ?? {}, changes: [], @@ -121,4 +160,114 @@ describe("ensurePluginRegistryLoaded", () => { }), ); }); + + it("loads only the selected memory backend and embedding provider owners", () => { + const config = { + memory: { search: { provider: "openai" } }, + plugins: { + allow: ["acpx", "memory-core"], + slots: { memory: "memory-core" }, + entries: { unrelated: { enabled: true } }, + }, + }; + mocks.collectConfiguredMemoryEmbeddingProviderIds.mockReturnValue(new Set(["openai"])); + + ensurePluginRegistryLoaded({ scope: "memory", config }); + + expect(mocks.collectConfiguredMemoryEmbeddingProviderIds).toHaveBeenCalledWith(config); + expect(requireLoadOptions()).toEqual( + expect.objectContaining({ + config, + activationSourceConfig: config, + onlyPluginIds: ["memory-core", "openai"], + throwOnLoadError: true, + }), + ); + }); + + it.each([ + { + adapterId: "gemini", + contract: "memoryEmbeddingProviders" as const, + pluginId: "google", + }, + { + adapterId: "local", + contract: "embeddingProviders" as const, + pluginId: "llama-cpp", + }, + ])("loads the $pluginId owner for the $adapterId memory adapter", (provider) => { + const config = { + memory: { search: { provider: provider.adapterId } }, + plugins: { slots: { memory: "memory-core" } }, + }; + mocks.collectConfiguredMemoryEmbeddingProviderIds.mockReturnValue( + new Set([provider.adapterId]), + ); + useMemoryProviderOwner(provider); + + ensurePluginRegistryLoaded({ scope: "memory", config }); + + expect(requireLoadOptions().onlyPluginIds).toEqual( + [provider.pluginId, "memory-core"].toSorted(), + ); + }); + + it("keeps a denied memory provider owner denied", () => { + const config = { + memory: { search: { provider: "gemini" } }, + plugins: { + allow: ["memory-core"], + deny: ["google"], + slots: { memory: "memory-core" }, + }, + }; + mocks.collectConfiguredMemoryEmbeddingProviderIds.mockReturnValue(new Set(["gemini"])); + useMemoryProviderOwner({ + adapterId: "gemini", + contract: "memoryEmbeddingProviders", + pluginId: "google", + }); + + ensurePluginRegistryLoaded({ scope: "memory", config }); + + const options = requireLoadOptions(); + expect(options.onlyPluginIds).toEqual(["google", "memory-core"]); + expect(options.config).toEqual(config); + expect(options.activationSourceConfig).toEqual(config); + }); + + it("keeps an explicitly disabled memory provider owner disabled", () => { + const config = { + memory: { search: { provider: "local" } }, + plugins: { + entries: { "llama-cpp": { enabled: false } }, + slots: { memory: "memory-core" }, + }, + }; + mocks.collectConfiguredMemoryEmbeddingProviderIds.mockReturnValue(new Set(["local"])); + useMemoryProviderOwner({ + adapterId: "local", + contract: "embeddingProviders", + pluginId: "llama-cpp", + }); + + ensurePluginRegistryLoaded({ scope: "memory", config }); + + const options = requireLoadOptions(); + expect(options.onlyPluginIds).toEqual(["llama-cpp", "memory-core"]); + expect(options.config).toEqual(config); + expect(options.activationSourceConfig).toEqual(config); + }); + + it("keeps an empty memory scope empty when no backend is selected", () => { + mocks.collectConfiguredMemoryEmbeddingProviderIds.mockReturnValue(new Set()); + + ensurePluginRegistryLoaded({ + scope: "memory", + config: { plugins: { slots: { memory: "none" } } }, + }); + + expect(requireLoadOptions().onlyPluginIds).toEqual([]); + }); }); diff --git a/src/plugins/runtime/runtime-registry-loader.ts b/src/plugins/runtime/runtime-registry-loader.ts index 0c573143e48e..4bd560f9e6a1 100644 --- a/src/plugins/runtime/runtime-registry-loader.ts +++ b/src/plugins/runtime/runtime-registry-loader.ts @@ -5,7 +5,10 @@ import { resolveChannelPluginIds, resolveConfiguredChannelPluginIds, } from "../channel-plugin-ids.js"; +import { normalizePluginsConfig } from "../config-state.js"; import { resolveEffectivePluginIds } from "../effective-plugin-ids.js"; +import { collectConfiguredMemoryEmbeddingProviderIds } from "../gateway-startup-plugin-ids.js"; +import { createInstalledPluginIndexScopeLookup } from "../installed-plugin-index-scope-lookup.js"; import { loadOpenClawPlugins } from "../loader.js"; import { hasNonEmptyPluginIdScope } from "../plugin-scope.js"; import { @@ -13,7 +16,30 @@ import { resolvePluginRuntimeLoadContext, } from "./load-context.js"; -export type PluginRegistryScope = "configured-channels" | "channels" | "all"; +export type PluginRegistryScope = "configured-channels" | "channels" | "memory" | "all"; + +function resolveMemoryPluginIds( + context: ReturnType, +): string[] { + const configuredProviderIds = [ + ...collectConfiguredMemoryEmbeddingProviderIds(context.activationSourceConfig), + ]; + const pluginIds = new Set(); + if (context.metadataSnapshot) { + createInstalledPluginIndexScopeLookup( + context.metadataSnapshot.index, + ).addProviderContributionOwners(pluginIds, configuredProviderIds); + } else { + for (const providerId of configuredProviderIds) { + pluginIds.add(providerId); + } + } + const memoryPluginId = normalizePluginsConfig(context.config.plugins).slots.memory?.trim(); + if (memoryPluginId) { + pluginIds.add(memoryPluginId); + } + return [...pluginIds].toSorted(); +} function resolveScopePluginIds(params: { scope: PluginRegistryScope; @@ -34,6 +60,11 @@ function resolveScopePluginIds(params: { env: params.context.env, }); } + if (params.scope === "memory") { + // Memory CLI commands must use the same backend and embedding adapters as + // Gateway, without activating unrelated explicitly enabled plugins. + return resolveMemoryPluginIds(params.context); + } return resolveEffectivePluginIds({ config: params.context.rawConfig, workspaceDir: params.context.workspaceDir, @@ -56,8 +87,10 @@ export function ensurePluginRegistryLoaded(options?: { ? (withActivatedPluginIds({ config: context.config, pluginIds }) ?? context.config) : context.config; const activationSourceConfig = activateConfigured - ? (withActivatedPluginIds({ config: context.activationSourceConfig, pluginIds }) ?? - context.activationSourceConfig) + ? (withActivatedPluginIds({ + config: context.activationSourceConfig, + pluginIds, + }) ?? context.activationSourceConfig) : context.activationSourceConfig; loadOpenClawPlugins( buildPluginRuntimeLoadOptionsFromValues( @@ -65,6 +98,7 @@ export function ensurePluginRegistryLoaded(options?: { { throwOnLoadError: true, ...(scope === "configured-channels" || + scope === "memory" || scope === "all" || hasNonEmptyPluginIdScope(pluginIds) ? { onlyPluginIds: pluginIds } From e79f358b02dfd0f51b159f622691795547370c25 Mon Sep 17 00:00:00 2001 From: zhangLei99586 Date: Tue, 4 Aug 2026 22:45:28 +0800 Subject: [PATCH 6/9] fix(feishu): report bot identity retry failures (#102185) Co-authored-by: Vincent Koc Co-authored-by: zhangLei99586 <294081397+zhangLei99586@users.noreply.github.com> --- .../monitor.bot-identity.rejection.test.ts | 96 +++++++++++++++++++ extensions/feishu/src/monitor.bot-identity.ts | 6 +- 2 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 extensions/feishu/src/monitor.bot-identity.rejection.test.ts diff --git a/extensions/feishu/src/monitor.bot-identity.rejection.test.ts b/extensions/feishu/src/monitor.bot-identity.rejection.test.ts new file mode 100644 index 000000000000..a99da05ca4b1 --- /dev/null +++ b/extensions/feishu/src/monitor.bot-identity.rejection.test.ts @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { RuntimeEnv } from "../runtime-api.js"; +import { startBotIdentityRecovery } from "./monitor.bot-identity.js"; + +const fetchBotIdentityForMonitorMock = vi.hoisted(() => vi.fn()); +const setFeishuBotIdentityStateMock = vi.hoisted(() => vi.fn()); + +vi.mock("./monitor.startup.js", () => ({ + fetchBotIdentityForMonitor: fetchBotIdentityForMonitorMock, +})); + +vi.mock("./monitor.state.js", () => ({ + setFeishuBotIdentityState: setFeishuBotIdentityStateMock, +})); + +beforeEach(() => { + vi.useFakeTimers(); + fetchBotIdentityForMonitorMock.mockReset(); + setFeishuBotIdentityStateMock.mockReset(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("Feishu bot identity retry failures", () => { + it("reports a rejected background retry without leaking an unhandled rejection", async () => { + fetchBotIdentityForMonitorMock.mockRejectedValueOnce(new Error("probe exploded")); + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + } satisfies RuntimeEnv; + const unhandled: unknown[] = []; + const onUnhandledRejection = (reason: unknown) => { + unhandled.push(reason); + }; + process.on("unhandledRejection", onUnhandledRejection); + + try { + startBotIdentityRecovery({ + account: { + accountId: "person-2", + appId: "cli_person_2", + appSecret: "secret_person_2", // pragma: allowlist secret + } as never, + accountId: "person-2", + runtime, + }); + + await vi.advanceTimersByTimeAsync(60_000); + const nextTurn = new Promise((resolve) => { + setImmediate(resolve); + }); + await vi.advanceTimersByTimeAsync(0); + await nextTurn; + + expect(fetchBotIdentityForMonitorMock).toHaveBeenCalledTimes(1); + expect(runtime.error).toHaveBeenCalledTimes(1); + expect(runtime.error).toHaveBeenCalledWith( + "feishu[person-2]: bot identity background retry failed unexpectedly: Error: probe exploded", + ); + expect(setFeishuBotIdentityStateMock).not.toHaveBeenCalled(); + expect(unhandled).toStrictEqual([]); + } finally { + process.off("unhandledRejection", onUnhandledRejection); + } + }); + + it("stops an aborted retry without probing or reporting an error", async () => { + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + } satisfies RuntimeEnv; + const controller = new AbortController(); + + startBotIdentityRecovery({ + account: { + accountId: "person-2", + appId: "cli_person_2", + appSecret: "secret_person_2", // pragma: allowlist secret + } as never, + accountId: "person-2", + runtime, + abortSignal: controller.signal, + }); + controller.abort(); + await vi.advanceTimersByTimeAsync(0); + + expect(fetchBotIdentityForMonitorMock).not.toHaveBeenCalled(); + expect(runtime.error).not.toHaveBeenCalled(); + expect(setFeishuBotIdentityStateMock).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/extensions/feishu/src/monitor.bot-identity.ts b/extensions/feishu/src/monitor.bot-identity.ts index 807fbe1988bc..c8cc27758511 100644 --- a/extensions/feishu/src/monitor.bot-identity.ts +++ b/extensions/feishu/src/monitor.bot-identity.ts @@ -88,5 +88,9 @@ export function startBotIdentityRecovery(params: { ); } - void retryBotIdentityProbe(account, accountId, runtime, abortSignal); + void retryBotIdentityProbe(account, accountId, runtime, abortSignal).catch((err: unknown) => { + (runtime?.error ?? console.error)( + `feishu[${accountId}]: bot identity background retry failed unexpectedly: ${String(err)}`, + ); + }); } From a54bec799cdc45bbee49290040230fb3824c93c2 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 4 Aug 2026 22:46:07 +0800 Subject: [PATCH 7/9] fix(agents): observe native provider prompt egress (#119219) * fix(agents): observe final provider prompt egress * fix(agents): observe native provider prompt egress * test(agents): type native prompt observer models --- .../src/scenario-catalog.no-meta.test.ts | 14 + .../qa-lab/src/scenario-flow-runner.test.ts | 118 ++++ packages/ai/src/internal/openai.ts | 2 + packages/ai/src/package.e2e.test.ts | 2 + .../openai-chatgpt-responses.cache.test.ts | 21 +- .../openai-chatgpt-responses.retry.test.ts | 21 +- .../src/providers/openai-chatgpt-responses.ts | 29 +- .../src/transports/openai-responses-client.ts | 9 +- .../transports/openai-responses-contracts.ts | 26 + ...enai-responses-prompt-observer-internal.ts | 60 ++ .../openai-responses-prompt-observer.test.ts | 543 ++++++++++++++++++ .../openai-responses-replay-internal.ts | 10 + ...n-profile-artifact-followthrough-live.yaml | 60 +- .../provider-prompt-state.test.ts | 36 ++ .../provider-prompt-state.ts | 55 +- .../run/attempt-session-runtime-prepare.ts | 1 + .../run/attempt-stream-transport.ts | 1 + .../stream-resolution.test.ts | 39 +- 18 files changed, 988 insertions(+), 59 deletions(-) create mode 100644 extensions/qa-lab/src/scenario-catalog.no-meta.test.ts create mode 100644 packages/ai/src/transports/openai-responses-prompt-observer-internal.ts create mode 100644 packages/ai/src/transports/openai-responses-prompt-observer.test.ts diff --git a/extensions/qa-lab/src/scenario-catalog.no-meta.test.ts b/extensions/qa-lab/src/scenario-catalog.no-meta.test.ts new file mode 100644 index 000000000000..9b27c573f14d --- /dev/null +++ b/extensions/qa-lab/src/scenario-catalog.no-meta.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { readQaScenarioPack } from "./scenario-catalog.js"; + +describe("no-meta QA catalog", () => { + it("keeps context visibility proof on one primary scenario", () => { + const primaryOwnerIds = readQaScenarioPack() + .scenarios.filter((scenario) => + scenario.coverage?.primary.includes("session-memory.context-visibility-no-meta-leak"), + ) + .map((scenario) => scenario.id); + + expect(primaryOwnerIds).toStrictEqual(["instruction-profile-artifact-followthrough-live"]); + }); +}); diff --git a/extensions/qa-lab/src/scenario-flow-runner.test.ts b/extensions/qa-lab/src/scenario-flow-runner.test.ts index 8badb9dc7b95..d70d58e77c2e 100644 --- a/extensions/qa-lab/src/scenario-flow-runner.test.ts +++ b/extensions/qa-lab/src/scenario-flow-runner.test.ts @@ -69,6 +69,66 @@ async function runWebchatTranscriptWait( }); } +function readCurrentRunProviderPromptEvidenceFlow(trajectoryEvents: unknown[]): QaScenarioFlow { + const scenario = readQaScenarioById("instruction-profile-artifact-followthrough-live"); + const actions = scenario.execution.flow?.steps[0]?.actions; + if (!actions) { + throw new Error("instruction profile scenario has no actions"); + } + const evidenceIndex = actions.findIndex( + (action) => + typeof action === "object" && + action !== null && + "set" in action && + action.set === "providerPromptEvidence", + ); + const assertionIndex = actions.findIndex( + (action, index) => + index > evidenceIndex && + typeof action === "object" && + action !== null && + "assert" in action && + JSON.stringify(action).includes("current-run provider prompt evidence mismatch"), + ); + if (evidenceIndex < 0 || assertionIndex < 0) { + throw new Error("instruction profile scenario has no provider prompt evidence assertion"); + } + const instructionContents = scenario.execution.config?.instructionContents; + const instructionChars = + typeof instructionContents === "string" ? instructionContents.trimEnd().length : 0; + return { + steps: [ + { + name: "proves current-run provider prompt evidence", + actions: [ + { set: "turn", value: { started: { runId: "current-run" } } }, + { + set: "instructionProfileReport", + value: { + missing: false, + truncated: false, + rawChars: instructionChars, + injectedChars: instructionChars, + }, + }, + { set: "trajectoryEvents", value: trajectoryEvents }, + ...actions + .slice(evidenceIndex, assertionIndex + 1) + .filter( + (action) => + !( + typeof action === "object" && + action !== null && + "call" in action && + action.call === "fs.rm" + ), + ), + ], + }, + ], + }; +} + const planningEvidenceCoverageIds = new Set(["runtime.no-meta-leak", "workspace.planning"]); type PlanningEvidenceScenario = QaSeedScenarioWithSource & { @@ -237,6 +297,64 @@ const planningEvidenceFixtures = readQaScenarioPack() .map(createPlanningEvidenceFixture); describe("scenario-flow-runner", () => { + it("ignores stale provider prompt mismatches when the current run matches", async () => { + const currentObservation = { + egress: "responses-sdk", + payloadVariant: "initial", + promptSource: "input.developer", + expectedChars: 4096, + observedChars: 4096, + matchesAssembledPrompt: true, + }; + const result = await runLoadedScenarioFlow("instruction-profile-artifact-followthrough-live", { + flow: readCurrentRunProviderPromptEvidenceFlow([ + { + type: "provider.prompt.observed", + runId: "stale-run", + data: { + ...currentObservation, + promptSource: "missing", + observedChars: 0, + matchesAssembledPrompt: false, + }, + }, + { type: "provider.prompt.observed", runId: "current-run", data: currentObservation }, + ]), + }); + + expect(result.status).toBe("pass"); + }); + + it("excludes marker-bearing diagnostic trajectory context from bounded no-leak evidence", async () => { + const marker = "INSTRUCTION-PROFILE-CONTEXT-MARKER-A6E29D4B"; + const trajectoryEvents = [ + { + type: "context.compiled", + runId: "current-run", + data: { systemPrompt: `diagnostic support context ${marker}` }, + }, + { + type: "provider.prompt.observed", + runId: "current-run", + data: { + egress: "native-codex-websocket", + payloadVariant: "initial", + promptSource: "instructions", + expectedChars: 4096, + observedChars: 4096, + matchesAssembledPrompt: true, + }, + }, + ]; + + expect(JSON.stringify(trajectoryEvents)).toContain(marker); + const result = await runLoadedScenarioFlow("instruction-profile-artifact-followthrough-live", { + flow: readCurrentRunProviderPromptEvidenceFlow(trajectoryEvents), + }); + + expect(result.status).toBe("pass"); + }); + it("keeps live goal followthrough inside the active-goal context limit", async () => { const state = createQaBusState(); const artifactFile = "goal-continuance-live-00000000.txt"; diff --git a/packages/ai/src/internal/openai.ts b/packages/ai/src/internal/openai.ts index 274403268011..bdeefb320b17 100644 --- a/packages/ai/src/internal/openai.ts +++ b/packages/ai/src/internal/openai.ts @@ -16,3 +16,5 @@ export * from "../providers/openai-tool-schema-compat.js"; export * from "../providers/openai-tool-schema.js"; export * from "../providers/schema-keyword-strip.js"; export * from "../providers/tool-schema-json-projection.js"; +export { responsesPromptObserver } from "../transports/openai-responses-contracts.js"; +export type { ResponsesPromptObservation } from "../transports/openai-responses-contracts.js"; diff --git a/packages/ai/src/package.e2e.test.ts b/packages/ai/src/package.e2e.test.ts index e1ad5cf28b86..97e129f4675a 100644 --- a/packages/ai/src/package.e2e.test.ts +++ b/packages/ai/src/package.e2e.test.ts @@ -136,6 +136,7 @@ const compatibility = { "resolveOpenAIProjectedToolsStrictToolFlag", "stripUnsupportedSchemaKeywords", "projectRuntimeToolInputSchema", + "responsesPromptObserver", ], types: [ "OpenAICompletionsOptions", @@ -157,6 +158,7 @@ const compatibility = { "OpenAICompletionsToolChoice", "RuntimeToolInputSchemaJson", "RuntimeToolInputSchemaProjection", + "ResponsesPromptObservation", ], }, } as const; diff --git a/packages/ai/src/providers/openai-chatgpt-responses.cache.test.ts b/packages/ai/src/providers/openai-chatgpt-responses.cache.test.ts index b87d81adeece..84b0ee0a9982 100644 --- a/packages/ai/src/providers/openai-chatgpt-responses.cache.test.ts +++ b/packages/ai/src/providers/openai-chatgpt-responses.cache.test.ts @@ -5,6 +5,7 @@ import { zstdDecompressSync } from "node:zlib"; import { afterEach, describe, expect, it, vi } from "vitest"; import { WebSocket, WebSocketServer } from "ws"; import { configureAiTransportHost } from "../host.js"; +import { responsesPromptObserver, type ResponsesPromptObservation } from "../internal/openai.js"; import { cleanupSessionResources } from "../session-resources.js"; import { MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE } from "../transports/transport-utils.js"; import type { Context, Model } from "../types.js"; @@ -315,6 +316,9 @@ describe("ChatGPT Responses cached transport", () => { }); it("does not prepare SSE requests or serialize full bodies for cached websocket turns", async () => { + const prompt = "PRIVATE-CACHED-WEBSOCKET-PROMPT"; + const observations: ResponsesPromptObservation[] = []; + const order: string[] = []; const sentPayloads: string[] = []; class CachedWebSocket extends EventTarget { @@ -326,6 +330,7 @@ describe("ChatGPT Responses cached transport", () => { } send(payload: string): void { + order.push("send"); sentPayloads.push(payload); queueMicrotask(() => { this.dispatchEvent( @@ -352,11 +357,20 @@ describe("ChatGPT Responses cached transport", () => { sessionId: "cached-hot-path", transport: "websocket-cached" as const, }; + responsesPromptObserver.set(options, (observation) => { + order.push("observe"); + observations.push(observation); + }); - const first = await streamOpenAICodexResponses(model, context, options).result(); + const first = await streamOpenAICodexResponses( + model, + { ...context, systemPrompt: prompt }, + options, + ).result(); const second = await streamOpenAICodexResponses( model, { + systemPrompt: prompt, messages: [...context.messages, { role: "user", content: "follow-up", timestamp: 2 }], }, options, @@ -368,6 +382,11 @@ describe("ChatGPT Responses cached transport", () => { expect(headerSet).not.toHaveBeenCalledWith("accept", "text/event-stream"); expect(headerSet).not.toHaveBeenCalledWith("content-type", "application/json"); expect(sentPayloads).toHaveLength(2); + expect(order).toEqual(["observe", "send", "observe", "send"]); + expect(observations).toHaveLength(2); + expect(observations.every((entry) => entry.egress === "native-codex-websocket")).toBe(true); + expect(observations.every((entry) => entry.matchesAssembledPrompt)).toBe(true); + expect(JSON.stringify(observations)).not.toContain(prompt); const continuation = JSON.parse(sentPayloads[1] as string) as { input?: unknown[]; diff --git a/packages/ai/src/providers/openai-chatgpt-responses.retry.test.ts b/packages/ai/src/providers/openai-chatgpt-responses.retry.test.ts index 31c4bf227b82..5a3256e4ff25 100644 --- a/packages/ai/src/providers/openai-chatgpt-responses.retry.test.ts +++ b/packages/ai/src/providers/openai-chatgpt-responses.retry.test.ts @@ -1,6 +1,7 @@ // Covers which ChatGPT Responses failures the SSE transport retries. import { afterEach, describe, expect, it, vi } from "vitest"; import { configureAiTransportHost } from "../host.js"; +import { responsesPromptObserver, type ResponsesPromptObservation } from "../internal/openai.js"; import type { Context, Model } from "../types.js"; import { closeOpenAICodexWebSocketSessions, @@ -73,6 +74,8 @@ describe("streamOpenAICodexResponses retry classification", () => { ); it("still retries retryable ChatGPT responses", async () => { + const prompt = "PRIVATE-NATIVE-SSE-RETRY-PROMPT"; + const observations: ResponsesPromptObservation[] = []; const fetchMock = vi .fn() .mockResolvedValueOnce(new Response("overloaded", { status: 503 })) @@ -89,13 +92,25 @@ describe("streamOpenAICodexResponses retry classification", () => { return 0 as unknown as ReturnType; }); - const result = await streamOpenAICodexResponses(model, context, { + const options = { apiKey: jwt, - transport: "sse", - }).result(); + transport: "sse" as const, + }; + responsesPromptObserver.set(options, (observation) => observations.push(observation)); + + const result = await streamOpenAICodexResponses( + model, + { ...context, systemPrompt: prompt }, + options, + ).result(); expect(result.stopReason).toBe("error"); expect(fetchMock).toHaveBeenCalledTimes(2); + expect(observations).toHaveLength(2); + expect(observations.every((entry) => entry.egress === "native-codex-sse")).toBe(true); + expect(observations.every((entry) => entry.payloadVariant === "initial")).toBe(true); + expect(observations.every((entry) => entry.matchesAssembledPrompt)).toBe(true); + expect(JSON.stringify(observations)).not.toContain(prompt); }); it.each([ diff --git a/packages/ai/src/providers/openai-chatgpt-responses.ts b/packages/ai/src/providers/openai-chatgpt-responses.ts index 70593c206c45..3897d972c36d 100644 --- a/packages/ai/src/providers/openai-chatgpt-responses.ts +++ b/packages/ai/src/providers/openai-chatgpt-responses.ts @@ -36,6 +36,8 @@ import { getAiTransportHost, resolveAiTransportHeaderSentinels } from "../host.j import { parseRetryAfterHttpDateMs } from "../internal/retry-after.js"; import { sleepWithAbort } from "../internal/retry-sleep.js"; import { registerSessionResourceCleanup } from "../session-resources.js"; +import { responsesPromptObserver } from "../transports/openai-responses-contracts.js"; +import { createResponsesPromptEgressObserver } from "../transports/openai-responses-prompt-observer-internal.js"; import { processResponsesStream, ResponsesStreamFailure, @@ -138,6 +140,10 @@ interface RequestBody { [key: string]: unknown; } +type ObserveResponsesPromptEgress = NonNullable< + ReturnType +>; + // ============================================================================ // Retry Helpers // ============================================================================ @@ -284,6 +290,10 @@ export const streamOpenAICodexResponses: StreamFunction< if (nextBody !== undefined) { body = nextBody as RequestBody; } + const observePromptEgress = createResponsesPromptEgressObserver( + options, + context.systemPrompt, + ); // NOTE: when options.sessionId is absent, this falls back to a fresh random id // per request, which forfeits session-affinity routing on the WS transport (the // backend routes by session_id/x-client-request-id). Left as-is for this fix; @@ -324,6 +334,7 @@ export const streamOpenAICodexResponses: StreamFunction< }, requestOptions, firstEventAbort.abort, + observePromptEgress, ); if (activeSignal?.aborted) { @@ -395,6 +406,10 @@ export const streamOpenAICodexResponses: StreamFunction< let attemptResponse: Response; let errorText: string; try { + observePromptEgress?.(body, { + egress: "native-codex-sse", + payloadVariant: "initial", + }); attemptResponse = await fetch(resolveCodexUrl(model.baseUrl), { method: "POST", headers: sseHeaders, @@ -516,11 +531,12 @@ export const streamSimpleOpenAICodexResponses: StreamFunction< throw new Error(`No API key for provider: ${model.provider}`); } - const base = buildBaseOptions(model, options, apiKey); - return streamOpenAICodexResponses(model, context, { - ...base, + const resolvedOptions = { + ...buildBaseOptions(model, options, apiKey), reasoningEffort: resolveResponsesReasoningEffort(model, options?.reasoning), - } satisfies OpenAICodexResponsesOptions); + } satisfies OpenAICodexResponsesOptions; + responsesPromptObserver.copy(options, resolvedOptions); + return streamOpenAICodexResponses(model, context, resolvedOptions); }; // ============================================================================ @@ -1461,6 +1477,7 @@ async function processWebSocketStream( onStart: () => void, options?: OpenAICodexResponsesOptions, abortFirstEventStream?: (reason: Error) => void, + observePromptEgress?: ObserveResponsesPromptEgress, ): Promise { const { socket, entry, release } = await acquireWebSocket( url, @@ -1480,6 +1497,10 @@ async function processWebSocketStream( if (options?.signal?.aborted) { throw transportAbortError(options.signal); } + observePromptEgress?.(requestBody, { + egress: "native-codex-websocket", + payloadVariant: "initial", + }); socket.send(JSON.stringify({ type: "response.create", ...requestBody })); await processResponsesStream( startWebSocketOutputOnFirstEvent( diff --git a/packages/ai/src/transports/openai-responses-client.ts b/packages/ai/src/transports/openai-responses-client.ts index 728013d2a134..0698a0dd8e06 100644 --- a/packages/ai/src/transports/openai-responses-client.ts +++ b/packages/ai/src/transports/openai-responses-client.ts @@ -30,6 +30,7 @@ import { buildOpenAIResponsesParams, sanitizeOpenAICodexResponsesParams, } from "./openai-responses-params-internal.js"; +import { createResponsesPromptEgressObserver } from "./openai-responses-prompt-observer-internal.js"; import { buildOpenAIResponsesReasoningReplayMetadata, createResponsesStreamWithEncryptedContentRetry, @@ -193,6 +194,10 @@ function createResponsesTransportExecutor(config: ResponsesTransportExecutorOpti enforceCodeModeResponsesToolSurface(params, visibleToolNames); assertCodeModeResponsesToolSurface(params, visibleToolNames); } + const observePrompt = createResponsesPromptEgressObserver( + responsesOptions, + context.systemPrompt, + ); const requestStartedAt = Date.now(); firstEventAbort = createFirstStreamEventAbortController(options?.signal); const requestOptions = buildOpenAISdkRequestOptions(model, firstEventAbort.signal, { @@ -211,6 +216,7 @@ function createResponsesTransportExecutor(config: ResponsesTransportExecutorOpti request: params, requestOptions, model, + observePrompt, }); await options?.onResponse?.( { status: response.status, headers: headersToRecord(response.headers) }, @@ -290,7 +296,8 @@ export function createAzureOpenAIResponsesTransportStreamFn(): StreamFn { resolveAzureDeploymentName(model), metadata, ), - createResponseStream: async ({ client, request, requestOptions }) => { + createResponseStream: async ({ client, request, requestOptions, observePrompt }) => { + observePrompt?.(request, { egress: "responses-sdk", payloadVariant: "initial" }); const { data, response } = await client.responses .create(request as never, requestOptions) .withResponse(); diff --git a/packages/ai/src/transports/openai-responses-contracts.ts b/packages/ai/src/transports/openai-responses-contracts.ts index 55c251a93a4c..d157a046a2a9 100644 --- a/packages/ai/src/transports/openai-responses-contracts.ts +++ b/packages/ai/src/transports/openai-responses-contracts.ts @@ -46,6 +46,32 @@ export type OpenAIResponsesOptions = BaseOpenAIStreamOptions & { toolChoice?: ResponseCreateParamsStreaming["tool_choice"]; }; +const PROMPT_OBSERVER = Symbol("openaiResponsesPromptObserver"); +export type ResponsesPromptObservation = { + egress: "responses-sdk" | "native-codex-websocket" | "native-codex-sse"; + payloadVariant: "initial" | "encrypted-content-retry"; + promptSource: "instructions" | "input.developer" | "input.system" | "missing"; + expectedChars: number; + observedChars: number; + matchesAssembledPrompt: boolean; +}; +type ResponsesPromptObserver = (observation: ResponsesPromptObservation) => void; + +export const responsesPromptObserver = { + set(options: object, observer: ResponsesPromptObserver): void { + Reflect.set(options, PROMPT_OBSERVER, observer); + }, + get(options: object) { + return Reflect.get(options, PROMPT_OBSERVER) as ResponsesPromptObserver | undefined; + }, + copy(source: object | undefined, target: object): void { + const observer = source && responsesPromptObserver.get(source); + if (observer) { + responsesPromptObserver.set(target, observer); + } + }, +}; + export type OpenAIResponsesReplayContext = { provider: string; api: Api; diff --git a/packages/ai/src/transports/openai-responses-prompt-observer-internal.ts b/packages/ai/src/transports/openai-responses-prompt-observer-internal.ts new file mode 100644 index 000000000000..ca611eaae492 --- /dev/null +++ b/packages/ai/src/transports/openai-responses-prompt-observer-internal.ts @@ -0,0 +1,60 @@ +import type { EasyInputMessage } from "openai/resources/responses/responses.js"; +import { stripSystemPromptCacheBoundary } from "../utils/system-prompt-cache-boundary.js"; +import { + responsesPromptObserver, + type ResponsesPromptObservation, +} from "./openai-responses-contracts.js"; +import { sanitizeTransportPayloadText } from "./transport-stream-shared.js"; + +type ResponsesPromptRequest = { instructions?: unknown; input?: unknown }; +type ResponsesPromptMetadata = Pick; + +function readFinalResponsesPrompt( + request: ResponsesPromptRequest, +): [ResponsesPromptObservation["promptSource"], string] { + if (typeof request.instructions === "string") { + return ["instructions", request.instructions] as const; + } + const input = Array.isArray(request.input) ? request.input : []; + const message = input.find((item) => { + const role = (item as EasyInputMessage).role; + return role === "developer" || role === "system"; + }) as EasyInputMessage | undefined; + if (!message) { + return ["missing", ""] as const; + } + const content = message.content; + const observedPrompt = + typeof content === "string" + ? content + : Array.isArray(content) + ? content.flatMap((part) => (part.type === "input_text" ? [part.text] : [])).join("") + : ""; + return [ + message.role === "developer" ? "input.developer" : "input.system", + observedPrompt, + ] as const; +} + +export function createResponsesPromptEgressObserver( + options: object | undefined, + assembledPrompt: string | undefined, +) { + const observer = options ? responsesPromptObserver.get(options) : undefined; + if (!observer) { + return undefined; + } + const expectedPrompt = sanitizeTransportPayloadText( + stripSystemPromptCacheBoundary(assembledPrompt ?? ""), + ); + return (request: ResponsesPromptRequest, metadata: ResponsesPromptMetadata) => { + const [promptSource, observedPrompt] = readFinalResponsesPrompt(request); + observer({ + ...metadata, + promptSource, + expectedChars: expectedPrompt.length, + observedChars: observedPrompt.length, + matchesAssembledPrompt: promptSource !== "missing" && observedPrompt === expectedPrompt, + }); + }; +} diff --git a/packages/ai/src/transports/openai-responses-prompt-observer.test.ts b/packages/ai/src/transports/openai-responses-prompt-observer.test.ts new file mode 100644 index 000000000000..d0db9c2e4d4f --- /dev/null +++ b/packages/ai/src/transports/openai-responses-prompt-observer.test.ts @@ -0,0 +1,543 @@ +import { zstdDecompressSync } from "node:zlib"; +import type { Api, Context, Model } from "@openclaw/llm-core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { configureAiTransportHost, getAiTransportHost } from "../host.js"; +import { responsesPromptObserver, type ResponsesPromptObservation } from "../internal/openai.js"; +import { + closeOpenAICodexWebSocketSessions, + resetOpenAICodexWebSocketStateForTest, + streamOpenAICodexResponses, + streamSimpleOpenAICodexResponses, +} from "../providers/openai-chatgpt-responses.js"; +import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "../utils/system-prompt-cache-boundary.js"; + +const sdkState = vi.hoisted(() => ({ + clients: [] as Array<"openai" | "azure">, + errors: [] as Error[], + order: [] as string[], + requests: [] as Array>, +})); + +vi.mock("openai", () => { + const createClient = (client: "openai" | "azure") => + class MockOpenAI { + responses = { + create: (request: Record) => { + sdkState.clients.push(client); + sdkState.order.push(`${client}.create`); + sdkState.requests.push(request); + const error = sdkState.errors.shift() ?? new Error("stop after request"); + return { + withResponse: async () => { + throw error; + }, + }; + }, + }; + }; + return { default: createClient("openai"), AzureOpenAI: createClient("azure") }; +}); + +import { + createAzureOpenAIResponsesTransportStreamFn, + createOpenAIResponsesTransportStreamFn, +} from "./openai-responses-client.js"; + +const initialHost = getAiTransportHost(); + +function createModel( + overrides: Partial> = {}, +): Model { + return { + id: "gpt-5.4", + name: "GPT-5.4", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 8192, + ...overrides, + } as Model; +} + +function createContext(systemPrompt: string, overrides: Partial = {}): Context { + return { + systemPrompt, + messages: [{ role: "user", content: "hello", timestamp: 1 }], + tools: [], + ...overrides, + } as Context; +} + +function createJwt(): string { + const encode = (value: object) => Buffer.from(JSON.stringify(value)).toString("base64url"); + return `${encode({ alg: "none", typ: "JWT" })}.${encode({ + "https://api.openai.com/auth": { chatgpt_account_id: "acct-1" }, + })}.signature`; +} + +function completedSseResponse(responseId = "resp_test"): Response { + return new Response( + `data: ${JSON.stringify({ + type: "response.completed", + response: { + id: responseId, + status: "completed", + output: [], + usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8 }, + }, + })}\n\n`, + { status: 200, headers: { "content-type": "text/event-stream" } }, + ); +} + +async function runObservedRequest(params: { + context: Context; + model?: Model; + azure?: boolean; + errors?: Error[]; + options?: Record; +}) { + const observations: ResponsesPromptObservation[] = []; + const options = { apiKey: "test-key", ...params.options }; + const requestStart = sdkState.requests.length; + const orderStart = sdkState.order.length; + sdkState.errors = params.errors ?? [new Error("stop after request")]; + responsesPromptObserver.set(options, (observation) => { + sdkState.order.push("observe"); + observations.push(observation); + }); + const streamFn = params.azure + ? createAzureOpenAIResponsesTransportStreamFn() + : createOpenAIResponsesTransportStreamFn(); + const stream = await Promise.resolve( + streamFn(params.model ?? createModel(), params.context, options as never), + ); + expect((await stream.result()).stopReason).toBe("error"); + return { + observations, + order: sdkState.order.slice(orderStart), + requests: sdkState.requests.slice(requestStart), + }; +} + +beforeEach(() => { + sdkState.clients = []; + sdkState.errors = []; + sdkState.order = []; + sdkState.requests = []; + configureAiTransportHost(initialHost); +}); + +afterEach(() => { + closeOpenAICodexWebSocketSessions(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + resetOpenAICodexWebSocketStateForTest(); + configureAiTransportHost(initialHost); +}); + +describe("OpenAI Responses provider prompt observer", () => { + it.each([ + { reasoning: true, promptSource: "input.developer" }, + { reasoning: false, promptSource: "input.system" }, + ] as const)("observes the final $promptSource prompt", async ({ reasoning, promptSource }) => { + const prompt = `PRIVATE-${promptSource}-PROMPT`; + const run = await runObservedRequest({ + context: createContext(prompt), + model: createModel({ reasoning }), + }); + + expect(run.observations).toEqual([ + { + egress: "responses-sdk", + payloadVariant: "initial", + promptSource, + expectedChars: prompt.length, + observedChars: prompt.length, + matchesAssembledPrompt: true, + }, + ]); + expect(JSON.stringify(run.observations)).not.toContain(prompt); + }); + + it("observes Azure Responses egress", async () => { + const prompt = "PRIVATE-AZURE-PROMPT"; + const run = await runObservedRequest({ + azure: true, + context: createContext(prompt), + model: createModel({ + api: "azure-openai-responses", + provider: "azure-openai-responses", + baseUrl: "https://example.openai.azure.com", + }), + }); + + expect(sdkState.clients).toEqual(["azure"]); + expect(run.order).toEqual(["observe", "azure.create"]); + expect(run.observations[0]).toMatchObject({ + egress: "responses-sdk", + payloadVariant: "initial", + promptSource: "input.developer", + matchesAssembledPrompt: true, + }); + }); + + it("observes the async replacement immediately before final transformed egress", async () => { + const prompt = "PRIVATE-FINAL-TRANSFORMED-PROMPT"; + const tool = (name: string) => ({ + name, + description: name, + parameters: { type: "object", properties: {} }, + }); + configureAiTransportHost({ + ...initialHost, + plugin: { + ...initialHost.plugin, + resolveTransportTurnState: () => ({ metadata: { host: "added" } }), + }, + }); + const run = await runObservedRequest({ + context: createContext(prompt, { tools: [tool("exec"), tool("wait")] as never }), + options: { + openclawCodeModeToolSurface: true, + onPayload: async () => { + await Promise.resolve(); + return { + model: "gpt-5.4", + stream: true, + metadata: { caller: "kept" }, + input: [ + { type: "message", role: "developer", content: prompt }, + { + type: "message", + role: "user", + content: [{ type: "input_image", image_url: "data:image/png;base64,invalid!" }], + }, + ], + tools: [tool("exec"), tool("wait"), tool("rogue")], + }; + }, + }, + }); + + expect(run.order).toEqual(["observe", "openai.create"]); + expect(run.observations[0]?.matchesAssembledPrompt).toBe(true); + expect(run.requests[0]?.metadata).toEqual({ caller: "kept", host: "added" }); + expect(run.requests[0]?.tools).toEqual([tool("exec"), tool("wait")]); + expect(JSON.stringify(run.requests[0]?.input)).toContain("omitted image payload"); + }); + + it("observes initial and encrypted-content retry application attempts", async () => { + const prompt = "PRIVATE-REPLAY-PROMPT"; + const invalidEncryptedContent = Object.assign(new Error("invalid encrypted content"), { + code: "invalid_encrypted_content", + }); + const run = await runObservedRequest({ + context: createContext(prompt), + errors: [invalidEncryptedContent, new Error("stop after retry")], + options: { + onPayload: (request: Record) => ({ + ...request, + input: [ + ...((request.input as unknown[]) ?? []), + { type: "reasoning", encrypted_content: "opaque", summary: [] }, + ], + }), + }, + }); + + expect(run.order).toEqual(["observe", "openai.create", "observe", "openai.create"]); + expect(run.observations.map((entry) => entry.payloadVariant)).toEqual([ + "initial", + "encrypted-content-retry", + ]); + expect(run.observations.every((entry) => entry.egress === "responses-sdk")).toBe(true); + expect(run.observations.every((entry) => entry.matchesAssembledPrompt)).toBe(true); + expect(JSON.stringify(run.requests[0])).toContain("encrypted_content"); + expect(JSON.stringify(run.requests[1])).not.toContain("encrypted_content"); + }); + + it("uses cache-boundary and surrogate normalization as the expected prompt owner", async () => { + const systemPrompt = `stable${SYSTEM_PROMPT_CACHE_BOUNDARY}dynamic\ud800`; + const normalizedPrompt = "stable\ndynamic"; + const run = await runObservedRequest({ context: createContext(systemPrompt) }); + + expect(run.observations[0]).toMatchObject({ + expectedChars: normalizedPrompt.length, + observedChars: normalizedPrompt.length, + matchesAssembledPrompt: true, + }); + const request = run.requests[0]; + if (!request) { + throw new Error("missing captured request"); + } + expect((request.input as Array>)[0]).toMatchObject({ + content: [{ type: "input_text", text: normalizedPrompt }], + }); + }); + + it("reports missing and same-length mutated prompts without retaining content", async () => { + const missingPrompt = "PRIVATE-MISSING-PROMPT"; + const missing = await runObservedRequest({ + context: createContext(missingPrompt), + options: { + onPayload: () => ({ + model: "gpt-5.4", + stream: true, + input: [{ type: "message", role: "user", content: "hello" }], + }), + }, + }); + const mismatch = await runObservedRequest({ + context: createContext("trusted"), + options: { + onPayload: () => ({ + model: "gpt-5.4", + stream: true, + input: [{ type: "message", role: "developer", content: "altered" }], + }), + }, + }); + + expect(missing.observations[0]).toMatchObject({ + promptSource: "missing", + observedChars: 0, + matchesAssembledPrompt: false, + }); + expect(mismatch.observations[0]).toMatchObject({ + promptSource: "input.developer", + expectedChars: 7, + observedChars: 7, + matchesAssembledPrompt: false, + }); + expect(JSON.stringify([...missing.observations, ...mismatch.observations])).not.toContain( + missingPrompt, + ); + }); + + it("observes each native WebSocket connection-limit dispatch before send", async () => { + const prompt = "PRIVATE-NATIVE-WEBSOCKET-PROMPT"; + const observations: ResponsesPromptObservation[] = []; + const order: string[] = []; + const sentRequests: Array> = []; + let connections = 0; + class ConnectionLimitWebSocket extends EventTarget { + private readonly limitReached = connections++ === 0; + + constructor() { + super(); + queueMicrotask(() => this.dispatchEvent(new Event("open"))); + } + + send(payload: string): void { + order.push("send"); + sentRequests.push(JSON.parse(payload) as Record); + const event = this.limitReached + ? { type: "error", error: { code: "websocket_connection_limit_reached" } } + : { + type: "response.completed", + response: { + id: "resp_ws", + status: "completed", + output: [], + usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8 }, + }, + }; + queueMicrotask(() => { + this.dispatchEvent(Object.assign(new Event("message"), { data: JSON.stringify(event) })); + }); + } + + close(): void {} + } + vi.stubGlobal("WebSocket", ConnectionLimitWebSocket); + vi.stubGlobal("fetch", vi.fn()); + const options = { apiKey: createJwt(), transport: "websocket" as const }; + responsesPromptObserver.set(options, (observation) => { + order.push("observe"); + observations.push(observation); + }); + + const result = await streamOpenAICodexResponses( + createModel({ + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.test/backend-api", + }), + createContext(prompt), + options, + ).result(); + + expect(result.stopReason).toBe("stop"); + expect(connections).toBe(2); + expect(order).toEqual(["observe", "send", "observe", "send"]); + expect(sentRequests.map((request) => request.instructions)).toEqual([prompt, prompt]); + expect(observations).toEqual([ + { + egress: "native-codex-websocket", + payloadVariant: "initial", + promptSource: "instructions", + expectedChars: prompt.length, + observedChars: prompt.length, + matchesAssembledPrompt: true, + }, + { + egress: "native-codex-websocket", + payloadVariant: "initial", + promptSource: "instructions", + expectedChars: prompt.length, + observedChars: prompt.length, + matchesAssembledPrompt: true, + }, + ]); + expect(JSON.stringify(observations)).not.toContain(prompt); + }); + + it("forwards the private observer through simple options to final native SSE egress", async () => { + const prompt = "PRIVATE-NATIVE-SSE-PROMPT"; + const observations: ResponsesPromptObservation[] = []; + const order: string[] = []; + let sentRequest: Record | undefined; + vi.stubGlobal( + "fetch", + vi.fn(async (_input, init) => { + order.push("fetch"); + const body = + typeof init?.body === "string" + ? init.body + : zstdDecompressSync(init?.body as Uint8Array).toString("utf8"); + sentRequest = JSON.parse(body) as Record; + return completedSseResponse(); + }), + ); + const options = { + apiKey: createJwt(), + transport: "sse" as const, + onPayload: async (body: unknown) => { + await Promise.resolve(); + return { ...(body as Record), finalTransform: true }; + }, + }; + responsesPromptObserver.set(options, (observation) => { + order.push("observe"); + observations.push(observation); + }); + + const result = await streamSimpleOpenAICodexResponses( + createModel({ + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.test/backend-api", + }), + createContext(prompt), + options, + ).result(); + + expect(result.stopReason).toBe("stop"); + expect(order).toEqual(["observe", "fetch"]); + expect(sentRequest).toMatchObject({ instructions: prompt, finalTransform: true }); + expect(observations).toEqual([ + { + egress: "native-codex-sse", + payloadVariant: "initial", + promptSource: "instructions", + expectedChars: prompt.length, + observedChars: prompt.length, + matchesAssembledPrompt: true, + }, + ]); + expect(JSON.stringify(observations)).not.toContain(prompt); + }); + + it("observes only SSE when automatic WebSocket fallback happens before send", async () => { + const prompt = "PRIVATE-PRE-SEND-FALLBACK-PROMPT"; + const observations: ResponsesPromptObservation[] = []; + class FailingWebSocket { + constructor() { + throw new Error("websocket connect failed"); + } + send(): void {} + close(): void {} + addEventListener(): void {} + removeEventListener(): void {} + } + vi.stubGlobal("WebSocket", FailingWebSocket); + vi.stubGlobal( + "fetch", + vi.fn(async () => completedSseResponse()), + ); + const options = { apiKey: createJwt(), transport: "auto" as const }; + responsesPromptObserver.set(options, (observation) => observations.push(observation)); + + const result = await streamOpenAICodexResponses( + createModel({ + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.test/backend-api", + }), + createContext(prompt), + options, + ).result(); + + expect(result.stopReason).toBe("stop"); + expect(observations.map((entry) => entry.egress)).toEqual(["native-codex-sse"]); + }); + + it("observes WebSocket then SSE when fallback happens after send", async () => { + const prompt = "PRIVATE-POST-SEND-FALLBACK-PROMPT"; + const observations: ResponsesPromptObservation[] = []; + const order: string[] = []; + class SendThenFailWebSocket extends EventTarget { + constructor() { + super(); + queueMicrotask(() => this.dispatchEvent(new Event("open"))); + } + + send(): void { + order.push("send"); + queueMicrotask(() => + this.dispatchEvent( + Object.assign(new Event("error"), { message: "connection dropped after send" }), + ), + ); + } + + close(): void {} + } + vi.stubGlobal("WebSocket", SendThenFailWebSocket); + vi.stubGlobal( + "fetch", + vi.fn(async () => { + order.push("fetch"); + return completedSseResponse(); + }), + ); + const options = { apiKey: createJwt(), transport: "auto" as const }; + responsesPromptObserver.set(options, (observation) => { + order.push(`observe:${observation.egress}`); + observations.push(observation); + }); + + const result = await streamOpenAICodexResponses( + createModel({ + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.test/backend-api", + }), + createContext(prompt), + options, + ).result(); + + expect(result.stopReason).toBe("stop"); + expect(order).toEqual([ + "observe:native-codex-websocket", + "send", + "observe:native-codex-sse", + "fetch", + ]); + expect(observations.map((entry) => entry.egress)).toEqual([ + "native-codex-websocket", + "native-codex-sse", + ]); + }); +}); diff --git a/packages/ai/src/transports/openai-responses-replay-internal.ts b/packages/ai/src/transports/openai-responses-replay-internal.ts index 1b2d6e707869..0e055dfb2b88 100644 --- a/packages/ai/src/transports/openai-responses-replay-internal.ts +++ b/packages/ai/src/transports/openai-responses-replay-internal.ts @@ -26,6 +26,7 @@ import { type ReplayableResponseOutputMessage, type ReplayableResponseReasoningItem, } from "./openai-responses-contracts.js"; +import type { createResponsesPromptEgressObserver } from "./openai-responses-prompt-observer-internal.js"; import { resolveReplayableResponsesMessageId } from "./openai-responses-replay.js"; import { log } from "./openai-transport-shared.js"; import { @@ -221,8 +222,13 @@ export async function createResponsesStreamWithEncryptedContentRetry(params: { request: OpenAIResponsesRequestParams; requestOptions: unknown; model: Model; + observePrompt?: NonNullable>; }): Promise<{ stream: AsyncIterable; response: Response }> { try { + params.observePrompt?.(params.request, { + egress: "responses-sdk", + payloadVariant: "initial", + }); const { data, response } = await params.client.responses .create(params.request as never, params.requestOptions as never) .withResponse(); @@ -236,6 +242,10 @@ export async function createResponsesStreamWithEncryptedContentRetry(params: { `[responses] retrying without encrypted reasoning content provider=${params.model.provider} ` + `api=${params.model.api} model=${params.model.id}`, ); + params.observePrompt?.(retryRequest, { + egress: "responses-sdk", + payloadVariant: "encrypted-content-retry", + }); const { data, response } = await params.client.responses .create(retryRequest as never, params.requestOptions as never) .withResponse(); diff --git a/qa/scenarios/agents/instruction-profile-artifact-followthrough-live.yaml b/qa/scenarios/agents/instruction-profile-artifact-followthrough-live.yaml index 4cf7165f1486..5a6fab48de93 100644 --- a/qa/scenarios/agents/instruction-profile-artifact-followthrough-live.yaml +++ b/qa/scenarios/agents/instruction-profile-artifact-followthrough-live.yaml @@ -6,10 +6,14 @@ scenario: coverage: primary: - session-memory.instruction-profile-artifacts + - session-memory.context-visibility-no-meta-leak objective: Verify a live model follows canonical AGENTS.md and SOUL.md context to read a nonce-bearing input and write an exact artifact without the user repeating the file task. successCriteria: - The user prompt does not mention reading, files, paths, nonces, or content. - The live transcript records a successful input read and exact artifact write with matched non-error results. + - The assembled context includes the full synthetic profile marker and every current-run provider dispatch preserves it exactly. + - The marker is absent from terminal replies, outbound messages, user-visible history/transcript text, artifact bytes, and bounded provider prompt observations. + - Diagnostic trajectory support bundles intentionally retain compiled prompts and are excluded from the marker-absence guarantee. - The artifact bytes exactly match the instruction-profile contract. - No visible plan or permission reply precedes the tool work, and exactly one terminal reply follows it. docsRefs: @@ -24,7 +28,7 @@ scenario: providerMode: live-frontier retryCount: 0 channel: qa-channel - summary: Use live transcript evidence to prove canonical workspace instructions cause one exact read/write artifact chain before the sole terminal reply. + summary: Use live user-visible evidence and bounded provider observations to prove canonical workspace instructions cause one exact read/write artifact chain before the sole terminal reply; diagnostic trajectory prompt retention is out of scope. config: requiredProviderMode: live-frontier instructionFile: AGENTS.md @@ -32,6 +36,7 @@ scenario: inputFile: INSTRUCTION_PROFILE_INPUT.txt artifactFile: instruction-profile-proof.txt nonce: INSTRUCTION-PROFILE-NONCE-8C4F31A7 + contextMarker: INSTRUCTION-PROFILE-CONTEXT-MARKER-A6E29D4B finalReply: WROTE instruction-profile-proof.txt prompt: Complete the startup contract now. forbiddenPromptNeedles: @@ -52,6 +57,8 @@ scenario: instructionContents: |- # Instruction profile artifact contract + Profile metadata: INSTRUCTION-PROFILE-CONTEXT-MARKER-A6E29D4B + For the next user request, act before explaining: 1. Use the read tool on INSTRUCTION_PROFILE_INPUT.txt. @@ -107,7 +114,7 @@ flow: value: expr: "normalizeLowercaseStringOrEmpty(config.prompt)" - assert: - expr: "config.forbiddenPromptNeedles.every((needle) => !normalizedPrompt.includes(normalizeLowercaseStringOrEmpty(needle)))" + expr: "config.forbiddenPromptNeedles.every((needle) => !normalizedPrompt.includes(normalizeLowercaseStringOrEmpty(needle))) && !config.prompt.includes(config.contextMarker)" message: expr: "`user prompt repeated hidden instruction-profile inputs: ${config.prompt}`" - set: sessionKey @@ -117,6 +124,7 @@ flow: value: expr: "state.getSnapshot().messages.length" - call: runAgentPrompt + saveAs: turn args: - ref: env - sessionKey: @@ -149,9 +157,43 @@ flow: - ref: env - ref: sessionKey - assert: - expr: "transcript.assistantToolCallCounts.read === 1 && transcript.completedToolCallCounts.read === 1 && transcript.successfulToolCallCounts.read === 1 && transcript.assistantToolCallCounts.write === 1 && transcript.completedToolCallCounts.write === 1 && transcript.successfulToolCallCounts.write === 1 && String(transcript.finalText ?? '').trim() === config.finalReply" + expr: "transcript.assistantToolCallCounts.read === 1 && transcript.completedToolCallCounts.read === 1 && transcript.successfulToolCallCounts.read === 1 && transcript.assistantToolCallCounts.write === 1 && transcript.completedToolCallCounts.write === 1 && transcript.successfulToolCallCounts.write === 1 && String(transcript.finalText ?? '').trim() === config.finalReply && !String(transcript.finalText ?? '').includes(config.contextMarker)" message: expr: "`live transcript did not persist one successful read/write chain and final: ${JSON.stringify(transcript)}`" + - call: runQaCli + saveAs: trajectoryExport + args: + - ref: env + - - sessions + - export-trajectory + - --session-key + - ref: sessionKey + - --output + - expr: "`context-no-meta-${randomUUID().slice(0, 8)}`" + - --json + - json: true + timeoutMs: 30000 + - set: promptsCapture + value: + expr: "JSON.parse(await fs.readFile(path.join(trajectoryExport.outputDir, 'prompts.json'), 'utf8'))" + - set: instructionProfileReport + value: + expr: "promptsCapture.systemPromptReport?.injectedWorkspaceFiles?.find((entry) => path.basename(String(entry.path ?? '')) === config.instructionFile)" + - set: trajectoryEvents + value: + expr: "(await fs.readFile(path.join(trajectoryExport.outputDir, 'events.jsonl'), 'utf8')).split(/\\r?\\n/u).filter((line) => line.trim()).map((line) => JSON.parse(line))" + - set: providerPromptEvidence + value: + expr: "trajectoryEvents.filter((event) => event.type === 'provider.prompt.observed' && event.runId === turn.started.runId).map((event) => event.data)" + - call: fs.rm + args: + - expr: trajectoryExport.outputDir + - recursive: true + force: true + - assert: + expr: "config.instructionContents.includes(config.contextMarker) && instructionProfileReport?.missing === false && instructionProfileReport?.truncated === false && instructionProfileReport?.rawChars === config.instructionContents.trimEnd().length && instructionProfileReport?.injectedChars === config.instructionContents.trimEnd().length && providerPromptEvidence.length > 0 && providerPromptEvidence.every((entry) => ['responses-sdk', 'native-codex-websocket', 'native-codex-sse'].includes(entry?.egress) && ['initial', 'encrypted-content-retry'].includes(entry?.payloadVariant) && ['instructions', 'input.developer', 'input.system'].includes(entry?.promptSource) && Number.isInteger(entry?.expectedChars) && entry.expectedChars > 0 && entry.observedChars === entry.expectedChars && entry.matchesAssembledPrompt === true && Object.keys(entry).toSorted().join(',') === 'egress,expectedChars,matchesAssembledPrompt,observedChars,payloadVariant,promptSource') && !JSON.stringify(providerPromptEvidence).includes(config.contextMarker)" + message: + expr: "`current-run provider prompt evidence mismatch: ${JSON.stringify({ runId: turn.started.runId, instructionProfileReport, providerPromptEvidence })}`" - call: waitForCondition saveAs: historyEvidence args: @@ -181,15 +223,17 @@ flow: const writeResult = writeCall && results.find((result) => result.messageIndex > writeCall.messageIndex && result.callId === writeCall.id && result.name === 'write' && result.isError === false); const hasToolItem = (message) => message?.role === 'toolResult' || (Array.isArray(message?.content) && message.content.some((item) => ['toolCall', 'toolUse', 'tool_use', 'toolResult', 'tool_result'].includes(item?.type))); const textOfMessage = (message) => textOf(message?.content) || String(message?.text ?? ''); + const historyMarkerLeak = JSON.stringify(history).includes(config.contextMarker); const visibleAssistant = messages.flatMap((message, messageIndex) => message?.role === 'assistant' && message.phase !== 'commentary' && !message.openclawMessageToolMirror && !message.openclawDeliveryMirror && !(message.provider === 'openclaw' && ['delivery-mirror', 'gateway-injected'].includes(message.model)) && !hasToolItem(message) && textOfMessage(message).trim() ? [{ messageIndex, text: textOfMessage(message).trim() }] : []); + const visibleMarkerLeak = visibleAssistant.some((message) => message.text.includes(config.contextMarker)); const firstCallIndex = calls.length > 0 ? Math.min(...calls.map((call) => call.messageIndex)) : -1; const visibleBeforeTools = visibleAssistant.filter((message) => firstCallIndex >= 0 && message.messageIndex < firstCallIndex); const forbiddenVisible = visibleBeforeTools.some((message) => [...config.permissionNeedles, ...config.planNeedles].some((needle) => normalizeLowercaseStringOrEmpty(message.text).includes(normalizeLowercaseStringOrEmpty(needle)))); const final = visibleAssistant.find((message) => writeResult && message.messageIndex > writeResult.messageIndex && message.text === config.finalReply); - return readCall && readResult && writeCall && writeResult && visibleBeforeTools.length === 0 && !forbiddenVisible && visibleAssistant.length === 1 && final - ? { history, readCall, readResult, writeCall, writeResult, visibleBeforeTools, visibleAssistant, final } + return readCall && readResult && writeCall && writeResult && visibleBeforeTools.length === 0 && !forbiddenVisible && !historyMarkerLeak && !visibleMarkerLeak && visibleAssistant.length === 1 && final + ? { history, readCall, readResult, writeCall, writeResult, visibleBeforeTools, visibleAssistant, historyMarkerLeak, visibleMarkerLeak, final } : undefined; })() - expr: liveTurnTimeoutMs(env, 30000) @@ -198,11 +242,11 @@ flow: value: expr: "state.getSnapshot().messages.slice(outboundStartIndex).filter((message) => message.direction === 'outbound' && message.conversation.id === 'qa-operator' && !message.deleted)" - assert: - expr: "outboundMessages.length === 1 && String(outboundMessages[0]?.text ?? '').trim() === config.finalReply" + expr: "outboundMessages.length === 1 && String(outboundMessages[0]?.text ?? '').trim() === config.finalReply && !JSON.stringify(outboundMessages).includes(config.contextMarker)" message: expr: "`instruction-profile flow emitted an early plan/permission reply or multiple finals: ${JSON.stringify(outboundMessages)}`" - assert: - expr: "artifact === config.nonce && Buffer.byteLength(artifact, 'utf8') === Buffer.byteLength(config.nonce, 'utf8')" + expr: "artifact === config.nonce && !artifact.includes(config.contextMarker) && Buffer.byteLength(artifact, 'utf8') === Buffer.byteLength(config.nonce, 'utf8')" message: expr: "`artifact bytes differed from the instructed nonce: ${JSON.stringify(artifact)}`" - detailsExpr: "JSON.stringify({ verdict: 'PASS', scenario: 'instruction-profile-artifact-followthrough-live', provider: env.providerMode, sessionKey, promptRepeatedHiddenTerms: config.forbiddenPromptNeedles.filter((needle) => normalizedPrompt.includes(normalizeLowercaseStringOrEmpty(needle))), read: { callId: historyEvidence.readCall.id, resultCallId: historyEvidence.readResult.callId, path: historyEvidence.readCall.args.path, noncePresent: historyEvidence.readResult.text.includes(config.nonce) }, write: { callId: historyEvidence.writeCall.id, resultCallId: historyEvidence.writeResult.callId, path: historyEvidence.writeCall.args.path, exactArgs: historyEvidence.writeCall.args.content === config.nonce }, artifactBytes: Buffer.byteLength(artifact, 'utf8'), visibleBeforeTools: historyEvidence.visibleBeforeTools.length, terminalReplies: historyEvidence.visibleAssistant.length, outboundFinals: outboundMessages.length }, null, 2)" + detailsExpr: "JSON.stringify({ verdict: 'PASS', scenario: 'instruction-profile-artifact-followthrough-live', provider: env.providerMode, sessionKey, runId: turn.started.runId, diagnosticTrajectoryPromptRetentionExcluded: true, promptRepeatedHiddenTerms: config.forbiddenPromptNeedles.filter((needle) => normalizedPrompt.includes(normalizeLowercaseStringOrEmpty(needle))), providerContextMarker: instructionProfileReport?.missing === false && instructionProfileReport?.truncated === false && instructionProfileReport?.injectedChars === config.instructionContents.trimEnd().length, providerPromptObservations: providerPromptEvidence, read: { callId: historyEvidence.readCall.id, resultCallId: historyEvidence.readResult.callId, path: historyEvidence.readCall.args.path, noncePresent: historyEvidence.readResult.text.includes(config.nonce) }, write: { callId: historyEvidence.writeCall.id, resultCallId: historyEvidence.writeResult.callId, path: historyEvidence.writeCall.args.path, exactArgs: historyEvidence.writeCall.args.content === config.nonce }, artifactBytes: Buffer.byteLength(artifact, 'utf8'), visibleBeforeTools: historyEvidence.visibleBeforeTools.length, terminalReplies: historyEvidence.visibleAssistant.length, historyMarkerLeak: historyEvidence.historyMarkerLeak, visibleMarkerLeak: historyEvidence.visibleMarkerLeak, outboundFinals: outboundMessages.length }, null, 2)" diff --git a/src/agents/embedded-agent-runner/provider-prompt-state.test.ts b/src/agents/embedded-agent-runner/provider-prompt-state.test.ts index b0de53e41a10..0585cfc62c1e 100644 --- a/src/agents/embedded-agent-runner/provider-prompt-state.test.ts +++ b/src/agents/embedded-agent-runner/provider-prompt-state.test.ts @@ -1,3 +1,4 @@ +import { responsesPromptObserver } from "@openclaw/ai/internal/openai"; import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; import { createAssistantMessageEventStream, @@ -66,6 +67,41 @@ describe("provider prompt state", () => { } }); + it("records only bounded private observer evidence", async () => { + const runId = "provider-evidence"; + const marker = "PRIVATE-PROVIDER-PROMPT-MARKER"; + const state = getProviderPromptState(runId); + const recordEvent = vi.fn(); + const observation = { + egress: "responses-sdk", + payloadVariant: "initial", + promptSource: "input.developer", + expectedChars: marker.length, + observedChars: marker.length, + matchesAssembledPrompt: true, + } as const; + const wrapped = wrapStreamFnWithProviderPromptState({ + streamFn: async (_model, _context, options) => { + if (!options) { + throw new Error("missing stream options"); + } + await options.onPayload?.({ input: marker }, model); + responsesPromptObserver.get(options)?.(observation); + return createResultStream("stop"); + }, + state, + effectiveContextTokenBudget: 128_000, + recordEvent, + }); + + const result = await wrapped(model, { systemPrompt: marker, messages: [], tools: [] }); + await result.result(); + + expect(recordEvent).toHaveBeenCalledWith("provider.prompt.observed", observation); + expect(JSON.stringify({ calls: recordEvent.mock.calls, state })).not.toContain(marker); + clearProviderPromptState(runId); + }); + it("observes the final replacement body and blocks its rejected replay before network send", async () => { const runId = "replacement-body"; const state = getProviderPromptState(runId); diff --git a/src/agents/embedded-agent-runner/provider-prompt-state.ts b/src/agents/embedded-agent-runner/provider-prompt-state.ts index a3780c1ecef5..490a321d64d1 100644 --- a/src/agents/embedded-agent-runner/provider-prompt-state.ts +++ b/src/agents/embedded-agent-runner/provider-prompt-state.ts @@ -1,5 +1,6 @@ import { Buffer } from "node:buffer"; import crypto from "node:crypto"; +import { responsesPromptObserver } from "@openclaw/ai/internal/openai"; import { stableStringify } from "@openclaw/normalization-core"; import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; import type { Model } from "openclaw/plugin-sdk/llm"; @@ -32,23 +33,13 @@ class ProviderPromptRetryNoProgressError extends Error { } } -function digest(serialized: string): string { - return crypto.createHash("sha256").update(serialized).digest("hex"); -} - -function createProviderPromptState(): ProviderPromptState { - return {}; -} +const digest = (serialized: string) => crypto.createHash("sha256").update(serialized).digest("hex"); /** Returns run-local retry state; restarts and new run ids intentionally have no baseline. */ export function getProviderPromptState(runId: string): ProviderPromptState { - const existing = providerPromptStates.get(runId); - if (existing) { - return existing; - } - const created = createProviderPromptState(); - providerPromptStates.set(runId, created); - return created; + const state = providerPromptStates.get(runId) ?? {}; + providerPromptStates.set(runId, state); + return state; } export function clearProviderPromptState(runId: string): void { @@ -82,27 +73,11 @@ function assertProviderPromptRetryProgress( candidate: ProviderPromptSnapshot, ): void { const rejected = state.lastRejected; - if (!rejected || rejected.scopeDigest !== candidate.scopeDigest) { - return; - } - if (rejected.digest === candidate.digest) { + if (rejected?.scopeDigest === candidate.scopeDigest && rejected.digest === candidate.digest) { throw new ProviderPromptRetryNoProgressError(candidate.byteWeight); } } -function beginProviderPromptAttempt(state: ProviderPromptState): void { - // A transport that does not implement onPayload must not leave a stale body - // eligible to be marked as the current provider rejection. - state.lastAttempt = undefined; -} - -function recordProviderPromptAttempt( - state: ProviderPromptState, - snapshot: ProviderPromptSnapshot, -): void { - state.lastAttempt = snapshot; -} - export function markLastProviderPromptContextRejected( state: ProviderPromptState, ): ProviderPromptSnapshot | undefined { @@ -113,16 +88,17 @@ export function markLastProviderPromptContextRejected( return attempted; } -/** Observes the request body after every provider wrapper and caller payload hook. */ +/** Hashes the post-onPayload body for context-retry admission. */ export function wrapStreamFnWithProviderPromptState(params: { streamFn: StreamFn; state: ProviderPromptState; effectiveContextTokenBudget: number; + recordEvent?: (type: string, data?: Record) => void; }): StreamFn { return async (model, context, options) => { - beginProviderPromptAttempt(params.state); + params.state.lastAttempt = undefined; // Custom transports must not leave a stale candidate. const originalOnPayload = options?.onPayload; - const stream = await params.streamFn(model, context, { + const observedOptions: NonNullable[2]> = { ...options, onPayload: async (payload, payloadModel) => { const replacement = await originalOnPayload?.(payload, payloadModel); @@ -133,10 +109,15 @@ export function wrapStreamFnWithProviderPromptState(params: { effectiveContextTokenBudget: params.effectiveContextTokenBudget, }); assertProviderPromptRetryProgress(params.state, snapshot); - recordProviderPromptAttempt(params.state, snapshot); + params.state.lastAttempt = snapshot; return finalPayload; }, - }); - return stream; + }; + if (params.recordEvent) { + responsesPromptObserver.set(observedOptions, (observation) => + params.recordEvent?.("provider.prompt.observed", { ...observation }), + ); + } + return params.streamFn(model, context, observedOptions); }; } diff --git a/src/agents/embedded-agent-runner/run/attempt-session-runtime-prepare.ts b/src/agents/embedded-agent-runner/run/attempt-session-runtime-prepare.ts index 5d4999d16e17..0914d57ee88b 100644 --- a/src/agents/embedded-agent-runner/run/attempt-session-runtime-prepare.ts +++ b/src/agents/embedded-agent-runner/run/attempt-session-runtime-prepare.ts @@ -239,6 +239,7 @@ export async function prepareEmbeddedAttemptSessionRuntime(input: { 1, Math.floor(attempt.contextTokenBudget ?? attempt.model.contextWindow), ), + ...(trajectoryRecorder ? { recordEvent: trajectoryRecorder.recordEvent } : {}), }, }); promptCacheRetentionRef.current = transport.effectivePromptCacheRetention; diff --git a/src/agents/embedded-agent-runner/run/attempt-stream-transport.ts b/src/agents/embedded-agent-runner/run/attempt-stream-transport.ts index 859a91cff8cd..a16906549c73 100644 --- a/src/agents/embedded-agent-runner/run/attempt-stream-transport.ts +++ b/src/agents/embedded-agent-runner/run/attempt-stream-transport.ts @@ -49,6 +49,7 @@ export async function prepareEmbeddedAttemptTransport(input: { providerPromptState: { state: ProviderPromptState; effectiveContextTokenBudget: number; + recordEvent?: (type: string, data?: Record) => void; }; }) { const attempt = input.attempt; diff --git a/src/agents/embedded-agent-runner/stream-resolution.test.ts b/src/agents/embedded-agent-runner/stream-resolution.test.ts index b52cd2ecbd3c..1f549f2334e5 100644 --- a/src/agents/embedded-agent-runner/stream-resolution.test.ts +++ b/src/agents/embedded-agent-runner/stream-resolution.test.ts @@ -10,6 +10,7 @@ import { bindStreamLlmRuntime } from "../../llm/model-runtime-binding.js"; import { streamSimple } from "../../llm/stream.js"; import type { Model } from "../../llm/types.js"; import { mintSecretSentinel } from "../../secrets/sentinel.js"; +import { wrapStreamFnWithProviderPromptState } from "./provider-prompt-state.js"; import { describeEmbeddedAgentStreamStrategy as describeEmbeddedAgentStreamStrategyImpl, resolveEmbeddedAgentApiKey, @@ -286,6 +287,7 @@ describe("resolveEmbeddedAgentStreamFn", () => { }); it("keeps real lifecycle-owned Codex sessions on authenticated WebSocket transport", async () => { + const prompt = "PRIVATE-EMBEDDED-NATIVE-CODEX-PROMPT"; const tokenHeader = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString( "base64url", ); @@ -298,6 +300,7 @@ describe("resolveEmbeddedAgentStreamFn", () => { }); const handshakes: Array<{ url: string; headers: Headers }> = []; const sentRequests: Array> = []; + const recordEvent = vi.fn(); let rejectNextConnection = false; const fetchSpy = vi.fn(() => { throw new Error("explicit WebSocket transport must not issue an HTTP request"); @@ -374,10 +377,19 @@ describe("resolveEmbeddedAgentStreamFn", () => { sessionId: "session-websocket", resolvedApiKey: protectedAccessToken, }); + const observedEmbeddedStreamFn = wrapStreamFnWithProviderPromptState({ + streamFn: embeddedStreamFn, + state: {}, + effectiveContextTokenBudget: 128_000, + recordEvent, + }); expect(boundaryStreamFactory.mock.calls.slice(initialBoundaryCalls)).toEqual([]); - const stream = await embeddedStreamFn( + const stream = await observedEmbeddedStreamFn( model, - { messages: [{ role: "user", content: "hello", timestamp: 1 }] }, + { + systemPrompt: prompt, + messages: [{ role: "user", content: "hello", timestamp: 1 }], + }, { transport: "websocket" }, ); const result = await stream.result(); @@ -393,13 +405,29 @@ describe("resolveEmbeddedAgentStreamFn", () => { expect(handshakes[0]?.headers.get("session_id")).toBe("session-websocket"); expect(handshakes[0]?.headers.get("x-client-request-id")).toBe("session-websocket"); expect(sentRequests).toEqual([ - expect.objectContaining({ type: "response.create", model: "gpt-5.5" }), + expect.objectContaining({ + type: "response.create", + model: "gpt-5.5", + instructions: prompt, + }), ]); + expect(recordEvent).toHaveBeenCalledWith("provider.prompt.observed", { + egress: "native-codex-websocket", + payloadVariant: "initial", + promptSource: "instructions", + expectedChars: prompt.length, + observedChars: prompt.length, + matchesAssembledPrompt: true, + }); + expect(JSON.stringify(recordEvent.mock.calls)).not.toContain(prompt); rejectNextConnection = true; - const rejectedStream = await embeddedStreamFn( + const rejectedStream = await observedEmbeddedStreamFn( model, - { messages: [{ role: "user", content: "retry", timestamp: 2 }] }, + { + systemPrompt: prompt, + messages: [{ role: "user", content: "retry", timestamp: 2 }], + }, { transport: "websocket", sessionId: "session-websocket-rejected" }, ); const rejectedResult = await rejectedStream.result(); @@ -409,6 +437,7 @@ describe("resolveEmbeddedAgentStreamFn", () => { expect(resolveSessionAuth).toHaveBeenCalledTimes(2); expect(fetchSpy).not.toHaveBeenCalled(); expect(boundaryStreamFactory.mock.calls.slice(initialBoundaryCalls)).toEqual([]); + expect(recordEvent).toHaveBeenCalledTimes(1); } finally { vi.unstubAllGlobals(); } From 29f31996082db8d9c9e6c06d5ad2aa8f9a81dbf0 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 4 Aug 2026 22:54:15 +0800 Subject: [PATCH 8/9] perf(cli): skip absent gateway workspace dotenv (#119227) * perf(cli): skip absent gateway workspace dotenv * test(cli): cover gateway dotenv import boundary * test(cli): prove gateway dotenv stays lazy --- src/cli/run-main.exit.test.ts | 94 ++++++++++++++++++++++++++++++++--- src/cli/run-main.ts | 14 ++++-- 2 files changed, 98 insertions(+), 10 deletions(-) diff --git a/src/cli/run-main.exit.test.ts b/src/cli/run-main.exit.test.ts index 2e7d6644ad27..04fa0d562952 100644 --- a/src/cli/run-main.exit.test.ts +++ b/src/cli/run-main.exit.test.ts @@ -5,7 +5,7 @@ import path from "node:path"; import process from "node:process"; import { expectDefined } from "@openclaw/normalization-core"; import { CommanderError } from "commander"; -import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { GATEWAY_SERVICE_RUNTIME_PID_ENV } from "../daemon/constants.js"; import { loggingState } from "../logging/state.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; @@ -14,9 +14,13 @@ import type { LocalOnboardingState } from "../state/local-onboarding-state.js"; import { captureEnv, withEnvAsync } from "../test-utils/env.js"; import { getGatewayRunRuntimeHooks } from "./gateway-cli/runtime-hooks.js"; import type { RootHelpRenderOptions } from "./program/root-help.js"; -import { runCli, shouldStartProxyForCli } from "./run-main.js"; import { registerSignalExitBarrier } from "./signal-exit-barrier.js"; +type RunMainModule = typeof import("./run-main.js"); + +let runCli: RunMainModule["runCli"]; +let shouldStartProxyForCli: RunMainModule["shouldStartProxyForCli"]; + type ConfigSnapshotStub = { exists: boolean; hash?: string; @@ -38,6 +42,13 @@ type ConfigSnapshotReadOptionsStub = { const tryRouteCliMock = vi.hoisted(() => vi.fn()); const loadDotEnvMock = vi.hoisted(() => vi.fn()); +const dotenvModuleImportState = vi.hoisted(() => ({ count: 0 })); +const existsSyncOverride = vi.hoisted( + () => + ({ value: undefined }) as { + value: ((target: string) => boolean) | undefined; + }, +); const normalizeEnvMock = vi.hoisted(() => vi.fn()); const pinConfigDirMock = vi.hoisted(() => vi.fn()); const pinRuntimePathsMock = vi.hoisted(() => vi.fn()); @@ -230,9 +241,23 @@ vi.mock("./container-target.js", () => ({ parseCliContainerArgs: (argv: string[]) => ({ ok: true, container: null, argv }), })); -vi.mock("./dotenv.js", () => ({ - loadCliDotEnv: loadDotEnvMock, -})); +vi.mock("node:fs", async () => { + const actual = await vi.importActual("node:fs"); + return { + ...actual, + existsSync: (target: Parameters[0]) => + typeof target === "string" && existsSyncOverride.value + ? existsSyncOverride.value(target) + : actual.existsSync(target), + }; +}); + +vi.mock("./dotenv.js", () => { + dotenvModuleImportState.count += 1; + return { + loadCliDotEnv: loadDotEnvMock, + }; +}); vi.mock("./one-shot-exit.js", () => ({ flushExitAfterOneShotOutput: flushExitAfterOneShotOutputMock, @@ -472,6 +497,14 @@ async function expectNonInteractiveBareCliError( } describe("runCli exit behavior", () => { + beforeAll(async () => { + expect(dotenvModuleImportState.count).toBe(0); + const runMainModule = await import("./run-main.js"); + expect(dotenvModuleImportState.count).toBe(0); + runCli = runMainModule.runCli; + shouldStartProxyForCli = runMainModule.shouldStartProxyForCli; + }); + afterAll(() => { serviceEnvSnapshot.restore(); }); @@ -485,6 +518,7 @@ describe("runCli exit behavior", () => { delete process.env.OPENCLAW_GATEWAY_TOKEN; delete process.env.OPENCLAW_GATEWAY_PASSWORD; delete process.env[GATEWAY_SERVICE_RUNTIME_PID_ENV]; + existsSyncOverride.value = undefined; vi.clearAllMocks(); readConfigFileSnapshotMock.mockResolvedValue({ exists: true, @@ -529,6 +563,32 @@ describe("runCli exit behavior", () => { loggingState.forceConsoleToStderr = false; }); + it("does not import dotenv for gateway forms without a workspace file", async () => { + existsSyncOverride.value = () => false; + expect(dotenvModuleImportState.count).toBe(0); + + await runCli(["node", "openclaw", "gateway"]); + await runCli(["node", "openclaw", "gateway", "run"]); + tryRouteCliMock.mockResolvedValueOnce(false); + buildProgramMock.mockReturnValueOnce({ + commands: [{ name: () => "gateway", aliases: () => [] }], + parseAsync: commanderParseAsyncMock, + }); + await runCli(["node", "openclaw", "--log-level", "debug", "gateway", "run"]); + + expect(dotenvModuleImportState.count).toBe(0); + expect(loadDotEnvMock).not.toHaveBeenCalled(); + expect(buildProgramMock).toHaveBeenCalledTimes(1); + expect(commanderParseAsyncMock).toHaveBeenLastCalledWith([ + "node", + "openclaw", + "--log-level", + "debug", + "gateway", + "run", + ]); + }); + it("does not force process.exit after successful routed command", async () => { tryRouteCliMock.mockResolvedValueOnce(true); const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { @@ -2448,18 +2508,28 @@ describe("runCli exit behavior", () => { }); it.each([ + ["bare gateway fast path", ["node", "openclaw", "gateway"]], ["fast path", ["node", "openclaw", "gateway", "run"]], [ "full Commander path with root options", ["node", "openclaw", "--log-level", "debug", "gateway", "run"], ], ])("loads trusted dotenv and isolates %s gateway proxy config reads", async (_name, argv) => { + existsSyncOverride.value = (target) => target === path.join(process.cwd(), ".env"); if (_name === "full Commander path with root options") { - tryRouteCliMock.mockResolvedValueOnce(true); + tryRouteCliMock.mockResolvedValueOnce(false); + buildProgramMock.mockReturnValueOnce({ + commands: [{ name: () => "gateway", aliases: () => [] }], + parseAsync: commanderParseAsyncMock, + }); } await runCli(argv); expect(loadDotEnvMock).toHaveBeenCalledWith({ loadGlobalEnv: false, quiet: true }); + if (_name === "full Commander path with root options") { + expect(buildProgramMock).toHaveBeenCalledTimes(1); + expect(commanderParseAsyncMock).toHaveBeenLastCalledWith(argv); + } expect(loadConfigMock).toHaveBeenCalledWith({ isolateEnv: true, observe: false, @@ -2468,6 +2538,18 @@ describe("runCli exit behavior", () => { expect(startProxyMock).toHaveBeenCalledWith(undefined); }); + it("keeps state dotenv loading for non-gateway commands", async () => { + const stateDir = path.join(os.tmpdir(), "openclaw-run-main-state"); + existsSyncOverride.value = (target) => target === path.join(stateDir, ".env"); + tryRouteCliMock.mockResolvedValueOnce(true); + + await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, () => + runCli(["node", "openclaw", "status"]), + ); + + expect(loadDotEnvMock).toHaveBeenCalledWith({ loadGlobalEnv: true, quiet: true }); + }); + it("keeps agent exec outside the CLI dotenv loader", async () => { buildProgramMock.mockReturnValueOnce({ commands: [], parseAsync: vi.fn() }); await runCli(["node", "openclaw", "agent", "exec", "test prompt"]); diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index 1eff67cf8ed1..4590e381bc13 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -719,12 +719,15 @@ export function resolveMissingPluginCommandMessage( ); } -function shouldLoadCliDotEnv(env: NodeJS.ProcessEnv = process.env): boolean { +function shouldLoadCliDotEnv( + loadGlobalEnv: boolean, + env: NodeJS.ProcessEnv = process.env, +): boolean { const cwd = tryProcessCwd(); if (cwd && existsSync(path.join(cwd, ".env"))) { return true; } - return existsSync(path.join(resolveStateDir(env), ".env")); + return loadGlobalEnv && existsSync(path.join(resolveStateDir(env), ".env")); } function isAgentExecInvocation(commandPath: string[]): boolean { @@ -1155,6 +1158,9 @@ async function runCliWithPreparedOutputMode( const normalizedInvocation = resolveCliArgvInvocation(normalizedArgv); const isHelpOrVersionInvocation = normalizedInvocation.hasHelpOrVersion; const isGatewayRunInvocation = isGatewayRunInvocationArgv(normalizedArgv); + // Gateway pre-bootstrap owns state/config dotenv selection. This phase only + // needs the workspace file, so avoid importing the loader when it is absent. + const loadGlobalEnv = !isGatewayRunInvocation; startupTrace.mark("argv"); // Enforce the minimum supported runtime before gateway selection can read or recover config. @@ -1163,7 +1169,7 @@ async function runCliWithPreparedOutputMode( if ( !isHelpOrVersionInvocation && !isAgentExecInvocation(normalizedInvocation.commandPath) && - (isGatewayRunInvocation || shouldLoadCliDotEnv()) + shouldLoadCliDotEnv(loadGlobalEnv) ) { await startupTrace.measure("dotenv", async () => { if (isRemoteAgentDispatchInvocation(normalizedArgv, normalizedInvocation.primary)) { @@ -1171,7 +1177,7 @@ async function runCliWithPreparedOutputMode( await loadGatewayDispatchCliDotEnv({ quiet: true }); } else { const { loadCliDotEnv } = await import("./dotenv.js"); - loadCliDotEnv({ loadGlobalEnv: !isGatewayRunInvocation, quiet: true }); + loadCliDotEnv({ loadGlobalEnv, quiet: true }); } }); } From d06fc25a0c580bb1854fd890e0d849270fbec487 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 4 Aug 2026 23:01:38 +0800 Subject: [PATCH 9/9] fix(agents): protect private memory in shared chats (#119198) * fix(agents): protect private memory in shared chats Fixes #84466 Co-authored-by: Peter Steinberger * fix(agents): propagate shared chat type across runtimes * fix(agents): enforce bootstrap privacy after hooks * test(agents): cover final bootstrap filtering * fix(agents): filter private memory before allowlists * fix(agents): preserve bootstrap source identity * refactor(agents): simplify bootstrap source identity * fix(agents): keep bootstrap source identity private * fix(agents): unify CLI runtime chat type --------- Co-authored-by: Peter Steinberger --- .../.generated/plugin-sdk-api-baseline.sha256 | 2 +- .../codex/src/app-server/attempt-context.ts | 1 + extensions/copilot/src/workspace-bootstrap.ts | 1 + src/agents/bootstrap-cache.test.ts | 16 +- src/agents/bootstrap-cache.ts | 11 +- src/agents/bootstrap-files.test.ts | 293 ++++++++++++++---- src/agents/bootstrap-files.ts | 44 ++- src/agents/cli-runner/prepare.test.ts | 78 +++++ src/agents/cli-runner/prepare.ts | 4 +- src/agents/cli-runner/types.ts | 2 + .../command/attempt-execution.cli.test.ts | 38 +++ src/agents/command/attempt-execution.ts | 2 + .../cli-backend-dispatch.test.ts | 17 + .../cli-backend-dispatch.ts | 1 + .../prepared-compaction-runtime.ts | 1 + .../run/attempt-bootstrap-prepare.ts | 2 + src/agents/workspace.bootstrap-cache.test.ts | 28 ++ .../workspace.bootstrap-privacy.test.ts | 129 ++++++++ src/agents/workspace.ts | 140 +++++++-- .../reply/agent-runner-cli-candidate.ts | 5 + .../agent-runner-execution-runtime.test.ts | 80 +++++ .../reply/commands-system-prompt.ts | 1 + .../bootstrap-extra-files/handler.test.ts | 29 +- .../bundled/bootstrap-extra-files/handler.ts | 14 +- 24 files changed, 843 insertions(+), 96 deletions(-) create mode 100644 src/agents/workspace.bootstrap-privacy.test.ts diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index ca690fa3bb15..6885611456c0 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -4,7 +4,7 @@ cbf4e2c3088f8886a7c9ea91325a66e0f0846cea21f0b2891f36399b4811306c module/account 8e985f345f21a1c9a2b0e94304aaaad6a326bec1c1ce3b26027d2862804a366e module/account-resolution e5e67ddf3cab38fcbf9220bc3160715897e2709d9a9ff6ff36f1ecc9453c2367 module/agent-config-primitives 74daa746deb548379d3f0d6eac3c4d082df1034c4360cc03bf51fee0f10a2e4d module/agent-harness -95a907e1c33305b9473be64cc8d723e1a12b909eda86b15b94cee91879fb6a89 module/agent-harness-runtime +7d5db072119e8bff37bcb0086e833b047ef11ba4d713ffc9041dfb6ae6e22d70 module/agent-harness-runtime 5168648cd946abad8a92822889f13ceacc87ed502314a66190d0b1eb8ebe76ea module/agent-media-payload 6ee8bb70cd7b8a5a976ee84cd0c6e632dbfa5e4a047f616cdc00fa5b27879b27 module/agent-runtime 56b6d5fb6af3d95af1200065aca2e7d4f59e5fa59740505fe6ff433077ef6646 module/allow-from diff --git a/extensions/codex/src/app-server/attempt-context.ts b/extensions/codex/src/app-server/attempt-context.ts index 5a8da806b0d7..37d5dca1e110 100644 --- a/extensions/codex/src/app-server/attempt-context.ts +++ b/extensions/codex/src/app-server/attempt-context.ts @@ -182,6 +182,7 @@ export async function buildCodexWorkspaceBootstrapContext(params: { config: params.params.config, sessionKey: params.sessionKey, sessionId: params.params.sessionId, + chatType: params.params.chatType, agentId: params.params.agentId ?? params.sessionAgentId, warn: (message) => embeddedAgentLog.warn(message), contextMode: params.params.bootstrapContextMode, diff --git a/extensions/copilot/src/workspace-bootstrap.ts b/extensions/copilot/src/workspace-bootstrap.ts index 1c9f347ac5ef..aa119dd667ae 100644 --- a/extensions/copilot/src/workspace-bootstrap.ts +++ b/extensions/copilot/src/workspace-bootstrap.ts @@ -89,6 +89,7 @@ export async function resolveCopilotWorkspaceBootstrapContext(params: { config: attempt.config, sessionKey: readNonEmptyString((attempt as { sessionKey?: unknown }).sessionKey), sessionId: readNonEmptyString(attempt.sessionId), + chatType: attempt.chatType, agentId: readNonEmptyString(attempt.agentId), warn: params.warn, contextMode: attempt.bootstrapContextMode, diff --git a/src/agents/bootstrap-cache.test.ts b/src/agents/bootstrap-cache.test.ts index 09f4e557d105..e65a51086b83 100644 --- a/src/agents/bootstrap-cache.test.ts +++ b/src/agents/bootstrap-cache.test.ts @@ -8,10 +8,11 @@ import type { WorkspaceBootstrapFile } from "./workspace.js"; vi.mock("./workspace.js", () => ({ loadWorkspaceBootstrapFiles: vi.fn(), + workspaceFileSourceIdentitiesMatch: vi.fn(() => true), })); import { clearBootstrapSnapshot, getOrLoadBootstrapFiles } from "./bootstrap-cache.js"; -import { loadWorkspaceBootstrapFiles } from "./workspace.js"; +import { loadWorkspaceBootstrapFiles, workspaceFileSourceIdentitiesMatch } from "./workspace.js"; let workspaceDir = ""; @@ -34,6 +35,7 @@ describe("getOrLoadBootstrapFiles", () => { beforeEach(() => { mockLoad().mockResolvedValue(files); + vi.mocked(workspaceFileSourceIdentitiesMatch).mockReturnValue(true); }); afterEach(() => { @@ -75,6 +77,18 @@ describe("getOrLoadBootstrapFiles", () => { expect(mockLoad()).toHaveBeenCalledTimes(2); }); + it("replaces cached result when loader source identity changes", async () => { + const refreshedFiles = [makeFile("AGENTS.md", "# Agent"), makeFile("SOUL.md", "# Soul")]; + mockLoad().mockResolvedValueOnce(files).mockResolvedValueOnce(refreshedFiles); + vi.mocked(workspaceFileSourceIdentitiesMatch).mockReturnValue(false); + + const first = await getOrLoadBootstrapFiles({ workspaceDir, sessionKey: "session-1" }); + const result = await getOrLoadBootstrapFiles({ workspaceDir, sessionKey: "session-1" }); + + expect(first).toBe(files); + expect(result).toBe(refreshedFiles); + }); + it("different session keys get independent caches", async () => { const files2 = [makeFile("AGENTS.md", "# Agent v2")]; mockLoad().mockResolvedValueOnce(files).mockResolvedValueOnce(files2); diff --git a/src/agents/bootstrap-cache.ts b/src/agents/bootstrap-cache.ts index a6b24e62e6c4..a6378ec37e44 100644 --- a/src/agents/bootstrap-cache.ts +++ b/src/agents/bootstrap-cache.ts @@ -4,7 +4,11 @@ * become visible to long-lived agent sessions. */ import { pruneMapToMaxSize } from "../infra/map-size.js"; -import { loadWorkspaceBootstrapFiles, type WorkspaceBootstrapFile } from "./workspace.js"; +import { + loadWorkspaceBootstrapFiles, + type WorkspaceBootstrapFile, + workspaceFileSourceIdentitiesMatch, +} from "./workspace.js"; type BootstrapSnapshot = { workspaceDir: string; @@ -29,7 +33,10 @@ function bootstrapFilesEqual( file.name === updated.name && file.path === updated.path && file.content === updated.content && - file.missing === updated.missing + file.missing === updated.missing && + // Equal bytes at a replaced inode or symlink target must carry the newly + // opened source identity instead of reusing stale file objects. + workspaceFileSourceIdentitiesMatch(file, updated) ); }); } diff --git a/src/agents/bootstrap-files.test.ts b/src/agents/bootstrap-files.test.ts index 3b3e1904fb89..ba1093c38af7 100644 --- a/src/agents/bootstrap-files.test.ts +++ b/src/agents/bootstrap-files.test.ts @@ -32,7 +32,11 @@ import { import { SessionManager } from "./sessions/session-manager.js"; import { resetLegacyWorkspaceStateCheckForTest } from "./workspace-legacy-state.test-support.js"; import { mergeWorkspaceSetupState } from "./workspace-state-store.js"; -import type { WorkspaceBootstrapFile } from "./workspace.js"; +import { + DEFAULT_MEMORY_FILENAME, + loadExtraBootstrapFilesWithDiagnostics, + type WorkspaceBootstrapFile, +} from "./workspace.js"; let testState: OpenClawTestState | undefined; @@ -103,6 +107,53 @@ function registerDuplicateBootstrapFileHook() { }); } +function registerNamedBootstrapFileHook( + relativePath = "MEMORY.md", + name: WorkspaceBootstrapFile["name"] = "MEMORY.md", +) { + registerInternalHook("agent:bootstrap", (event) => { + const context = event.context as AgentBootstrapHookContext; + context.bootstrapFiles = [ + ...context.bootstrapFiles, + { + name, + path: path.join(context.workspaceDir, relativePath), + content: "hook memory", + missing: false, + }, + ]; + }); +} + +function registerLoadedBootstrapFilesHook( + relativePaths: string[], + name?: WorkspaceBootstrapFile["name"], +) { + registerInternalHook("agent:bootstrap", async (event) => { + const context = event.context as AgentBootstrapHookContext; + const { files } = await loadExtraBootstrapFilesWithDiagnostics( + context.workspaceDir, + relativePaths, + ); + if (name) { + for (const file of files) { + file.name = name; + } + } + context.bootstrapFiles = [...context.bootstrapFiles, ...files]; + }); +} + +async function createDirectoryAlias(params: { + workspaceDir: string; + targetDir: string; + aliasName: string; +}): Promise { + const aliasDir = path.join(params.workspaceDir, params.aliasName); + await fs.symlink(params.targetDir, aliasDir, process.platform === "win32" ? "junction" : "dir"); + return aliasDir; +} + function registerBootstrapFileHook(relativePath = "BOOTSTRAP.md") { registerInternalHook("agent:bootstrap", (event) => { const context = event.context as AgentBootstrapHookContext; @@ -330,66 +381,198 @@ describe("resolveBootstrapFilesForRun", () => { expect(files.map((file) => file.path)).not.toContain(path.join(workspaceDir, "BOOTSTRAP.md")); }); - it("keeps subagent sessions to AGENTS.md", async () => { - const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-subagent-"); - await Promise.all( - [ - ["AGENTS.md", "project rules"], - ["SOUL.md", "persona"], - ["IDENTITY.md", "identity"], - ["USER.md", "user profile"], - ["MEMORY.md", "memory"], - ["HEARTBEAT.md", "heartbeat"], - ["BOOTSTRAP.md", "setup"], - ].map(([fileName, content]) => - fs.writeFile( - path.join(workspaceDir, expectDefined(fileName, "fileName test invariant")), - expectDefined(content, "content test invariant"), - "utf8", - ), - ), - ); + it("keeps MEMORY.md for direct sessions", async () => { + const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-direct-"); + await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), "private memory", "utf8"); const files = await resolveBootstrapFilesForRun({ workspaceDir, + sessionKey: "agent:main:discord:direct:user-1", + }); + + expect(files.map((file) => file.name)).toContain("MEMORY.md"); + }); + + it.each(["group", "channel"] as const)( + "drops MEMORY.md for an opaque session with authoritative %s chat type", + async (chatType) => { + const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-shared-"); + await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), "private memory", "utf8"); + + const files = await resolveBootstrapFilesForRun({ + workspaceDir, + sessionKey: "agent:main:opaque:binding", + chatType, + }); + + expect(files.map((file) => file.name)).not.toContain("MEMORY.md"); + }, + ); + + it.each(["direct", "group", "channel"] as const)( + "applies root-memory source privacy while keeping unrelated aliases for %s chats", + async (chatType) => { + const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-shared-alias-"); + const nestedDir = path.join(workspaceDir, "packages", "core"); + await fs.mkdir(nestedDir, { recursive: true }); + await fs.writeFile( + path.join(workspaceDir, DEFAULT_MEMORY_FILENAME), + "private memory", + "utf8", + ); + await fs.writeFile(path.join(nestedDir, DEFAULT_MEMORY_FILENAME), "nested memory", "utf8"); + const rootAliasDir = await createDirectoryAlias({ + workspaceDir, + targetDir: workspaceDir, + aliasName: "root-memory-alias", + }); + const nestedAliasDir = await createDirectoryAlias({ + workspaceDir, + targetDir: nestedDir, + aliasName: "nested-memory-alias", + }); + const rootAliasPath = path.join(rootAliasDir, DEFAULT_MEMORY_FILENAME); + const nestedAliasPath = path.join(nestedAliasDir, DEFAULT_MEMORY_FILENAME); + registerLoadedBootstrapFilesHook([ + path.relative(workspaceDir, rootAliasPath), + path.relative(workspaceDir, nestedAliasPath), + ]); + + const files = await resolveBootstrapFilesForRun({ + workspaceDir, + sessionKey: "agent:main:opaque:binding", + chatType, + }); + + if (chatType === "direct") { + expect(files.map((file) => file.path)).toContain(rootAliasPath); + } else { + expect(files.map((file) => file.path)).not.toContain(rootAliasPath); + } + expect(files.map((file) => file.path)).toContain(nestedAliasPath); + }, + ); + + it("does not let hooks re-add MEMORY.md to shared sessions", async () => { + registerNamedBootstrapFileHook(); + const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-hook-shared-"); + await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), "private memory", "utf8"); + + const files = await resolveBootstrapFilesForRun({ + workspaceDir, + sessionKey: "agent:main:slack:channel:c1", + }); + + expect(files.map((file) => file.name)).not.toContain("MEMORY.md"); + }); + + it("does not let hooks relabel and re-add root MEMORY.md to shared sessions", async () => { + registerNamedBootstrapFileHook("MEMORY.md", "SOUL.md"); + const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-hook-shared-alias-"); + const rootMemoryPath = path.join(workspaceDir, "MEMORY.md"); + await fs.writeFile(rootMemoryPath, "private memory", "utf8"); + + const files = await resolveBootstrapFilesForRun({ + workspaceDir, + sessionKey: "agent:main:slack:channel:c1", + }); + + expect(files.map((file) => file.path)).not.toContain(rootMemoryPath); + }); + + it("keeps hook-added nested MEMORY.md in shared sessions", async () => { + registerNamedBootstrapFileHook(path.join("packages", "core", "MEMORY.md")); + const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-hook-nested-memory-"); + + const files = await resolveBootstrapFilesForRun({ + workspaceDir, + sessionKey: "agent:main:slack:channel:c1", + }); + + expect(files.map((file) => path.relative(workspaceDir, file.path))).toContain( + path.join("packages", "core", "MEMORY.md"), + ); + }); + + it("keeps missing hook records without source identity when policy allows them", async () => { + const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-missing-hook-record-"); + await fs.writeFile(path.join(workspaceDir, DEFAULT_MEMORY_FILENAME), "private memory", "utf8"); + registerInternalHook("agent:bootstrap", (event) => { + const context = event.context as AgentBootstrapHookContext; + context.bootstrapFiles = [ + ...context.bootstrapFiles, + { + name: "SOUL.md", + path: path.join(context.workspaceDir, "generated", "SOUL.md"), + missing: true, + }, + ]; + }); + + const files = await resolveBootstrapFilesForRun({ + workspaceDir, + sessionKey: "agent:main:opaque:binding", + chatType: "channel", + }); + + expect(files).toContainEqual({ + name: "SOUL.md", + path: path.join(workspaceDir, "generated", "SOUL.md"), + missing: true, + }); + }); + + it.each([ + { + mode: "subagent", sessionKey: "agent:main:subagent:worker", - }); - - expect(files.map((file) => file.name)).toStrictEqual(["AGENTS.md"]); - }); - - it("keeps cron sessions on their existing minimal bootstrap files", async () => { - const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-cron-"); - await Promise.all( - [ - ["AGENTS.md", "project rules"], - ["SOUL.md", "persona"], - ["IDENTITY.md", "identity"], - ["USER.md", "user profile"], - ["MEMORY.md", "memory"], - ["HEARTBEAT.md", "heartbeat"], - ["BOOTSTRAP.md", "setup"], - ].map(([fileName, content]) => - fs.writeFile( - path.join(workspaceDir, expectDefined(fileName, "fileName test invariant")), - expectDefined(content, "content test invariant"), - "utf8", - ), - ), - ); - - const files = await resolveBootstrapFilesForRun({ - workspaceDir, + relabeledName: "AGENTS.md", + expectedNames: ["AGENTS.md"], + }, + { + mode: "cron", sessionKey: "agent:main:cron:daily:run:run-1", - }); + relabeledName: "SOUL.md", + expectedNames: ["AGENTS.md", "SOUL.md", "IDENTITY.md", "USER.md"], + }, + ] as const)( + "rejects loader aliases to root memory relabeled under the $mode allowlist", + async ({ sessionKey, relabeledName, expectedNames }) => { + const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-restricted-"); + const rootMemoryPath = path.join(workspaceDir, DEFAULT_MEMORY_FILENAME); + const aliasDir = await createDirectoryAlias({ + workspaceDir, + targetDir: workspaceDir, + aliasName: "root-memory-alias", + }); + registerLoadedBootstrapFilesHook( + [path.relative(workspaceDir, path.join(aliasDir, DEFAULT_MEMORY_FILENAME))], + relabeledName, + ); + await Promise.all( + [ + ["AGENTS.md", "project rules"], + ["SOUL.md", "persona"], + ["IDENTITY.md", "identity"], + ["USER.md", "user profile"], + ["MEMORY.md", "memory"], + ["HEARTBEAT.md", "heartbeat"], + ["BOOTSTRAP.md", "setup"], + ].map(([fileName, content]) => + fs.writeFile( + path.join(workspaceDir, expectDefined(fileName, "fileName test invariant")), + expectDefined(content, "content test invariant"), + "utf8", + ), + ), + ); - expect(files.map((file) => file.name)).toStrictEqual([ - "AGENTS.md", - "SOUL.md", - "IDENTITY.md", - "USER.md", - ]); - }); + const files = await resolveBootstrapFilesForRun({ workspaceDir, sessionKey }); + + expect(files.map((file) => file.name)).toStrictEqual(expectedNames); + expect(files.map((file) => file.path)).not.toContain(rootMemoryPath); + }, + ); }); describe("resolveBootstrapContextForRun", () => { diff --git a/src/agents/bootstrap-files.ts b/src/agents/bootstrap-files.ts index 8a8b6c467541..12e97c2653d9 100644 --- a/src/agents/bootstrap-files.ts +++ b/src/agents/bootstrap-files.ts @@ -4,6 +4,7 @@ */ import path from "node:path"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import type { ChatType } from "../channels/chat-type.js"; import { readRecentSessionTranscriptActiveEvents } from "../config/sessions/session-accessor.js"; import type { AgentContextInjection } from "../config/types.agent-defaults.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -21,10 +22,12 @@ import { import type { AgentRunSessionTarget } from "./run-session-target.js"; import { DEFAULT_BOOTSTRAP_FILENAME, + DEFAULT_MEMORY_FILENAME, filterBootstrapFilesForSession, isWorkspaceSetupCompleted, loadWorkspaceBootstrapFiles, type WorkspaceBootstrapFile, + workspaceFilesShareSourceIdentity, } from "./workspace.js"; export type BootstrapContextMode = "full" | "lightweight"; @@ -194,18 +197,43 @@ async function isWorkspaceSetupCompletedForContext(workspaceDir: string): Promis } } +function filterBootstrapFilesAfterHooks(params: { + files: WorkspaceBootstrapFile[]; + session: { + sessionKey?: string; + chatType?: ChatType; + workspaceDir: string; + }; + protectedRootMemoryFile?: WorkspaceBootstrapFile; +}): WorkspaceBootstrapFile[] { + const sessionFiltered = filterBootstrapFilesForSession(params.files, params.session); + const rootMemoryFile = params.protectedRootMemoryFile; + if (!rootMemoryFile) { + return sessionFiltered; + } + // Hooks can relabel or alias loader-produced records. Reapply lexical/session + // policy first, then enforce the root-memory source captured by the pinned open. + return sessionFiltered.filter((file) => !workspaceFilesShareSourceIdentity(file, rootMemoryFile)); +} + /** Resolves hook-adjusted, session-filtered bootstrap files for a run. */ export async function resolveBootstrapFilesForRun(params: { workspaceDir: string; config?: OpenClawConfig; sessionKey?: string; sessionId?: string; + chatType?: ChatType; agentId?: string; warn?: (message: string) => void; contextMode?: BootstrapContextMode; runKind?: BootstrapContextRunKind; }): Promise { const sessionKey = params.sessionKey ?? params.sessionId; + const session = { + sessionKey, + chatType: params.chatType, + workspaceDir: params.workspaceDir, + }; const workspaceSetupCompleted = await isWorkspaceSetupCompletedForContext(params.workspaceDir); const rawFiles = params.sessionKey ? await getOrLoadBootstrapFiles({ @@ -213,9 +241,16 @@ export async function resolveBootstrapFilesForRun(params: { sessionKey: params.sessionKey, }) : await loadWorkspaceBootstrapFiles(params.workspaceDir); + const rootMemoryFile = rawFiles.find( + (file) => file.name === DEFAULT_MEMORY_FILENAME && !file.missing, + ); + const protectedRootMemoryFile = + rootMemoryFile && filterBootstrapFilesForSession([rootMemoryFile], session).length === 0 + ? rootMemoryFile + : undefined; const bootstrapFiles = applyContextModeFilter({ files: filterCompletedWorkspaceBootstrapFile( - filterBootstrapFilesForSession(rawFiles, sessionKey), + filterBootstrapFilesForSession(rawFiles, session), workspaceSetupCompleted, params.workspaceDir, ), @@ -232,7 +267,11 @@ export async function resolveBootstrapFilesForRun(params: { agentId: params.agentId, }); const filteredUpdated = filterCompletedWorkspaceBootstrapFile( - updated, + filterBootstrapFilesAfterHooks({ + files: updated, + session, + protectedRootMemoryFile, + }), workspaceSetupCompleted, params.workspaceDir, ); @@ -245,6 +284,7 @@ export async function resolveBootstrapContextForRun(params: { config?: OpenClawConfig; sessionKey?: string; sessionId?: string; + chatType?: ChatType; agentId?: string; warn?: (message: string) => void; contextMode?: BootstrapContextMode; diff --git a/src/agents/cli-runner/prepare.test.ts b/src/agents/cli-runner/prepare.test.ts index 40c26d3b794f..f7f1c2c7d5fe 100644 --- a/src/agents/cli-runner/prepare.test.ts +++ b/src/agents/cli-runner/prepare.test.ts @@ -146,6 +146,32 @@ function createCliBackendConfig(params: TestCliBackendParams = {}): OpenClawConf return {}; } +const SHARED_CHAT_MESSAGE_TOOL_ETIQUETTE = + "- Group/channel: stale/joke/light ack/low-value chatter => reaction or silence. Needed reply => `message(action=send)`; final text private."; + +function createBundledMessageToolConfig(): OpenClawConfig { + setCliRunnerPrepareTestDeps({ + getActiveMcpLoopbackRuntime: vi.fn(() => ({ + port: 31783, + ownerToken: "loopback-owner-token", + nonOwnerToken: "loopback-non-owner-token", + })), + resolveMcpLoopbackScopedTools: vi.fn(() => ({ + agentId: "main", + tools: [ + { + name: "message", + label: "Message", + description: "Send a message", + parameters: { type: "object", properties: {} }, + execute: vi.fn(), + }, + ], + })), + }); + return createCliBackendConfig({ bundleMcp: true }); +} + function setCliBackendForPrepareTest( params: { authEpochMode?: CliBackendPlugin["authEpochMode"]; @@ -2191,6 +2217,58 @@ describe("prepareCliRunContext", () => { expect(context.systemPrompt).not.toContain("Telegram rich OFF"); }); + it.each(["group", "channel"] as const)( + "uses explicit %s chat type for bundled message-tool etiquette with an opaque session key", + async (chatType) => { + const context = await fixture.prepare({ + config: createBundledMessageToolConfig(), + sessionKey: "agent:main:opaque:binding", + chatType, + sourceReplyDeliveryMode: "message_tool_only", + }); + + expect(context.systemPrompt).toContain(SHARED_CHAT_MESSAGE_TOOL_ETIQUETTE); + }, + ); + + it.each([ + { + name: "prefers current-turn metadata", + chatType: "group" as const, + sessionEntryChatType: "direct" as const, + expectedChatType: "group" as const, + }, + { + name: "falls back to stored session metadata", + chatType: undefined, + sessionEntryChatType: "channel" as const, + expectedChatType: "channel" as const, + }, + ])("$name for bootstrap and prompt preparation", async (testCase) => { + const resolveBootstrapContextForRun = vi.fn(async () => ({ + bootstrapFiles: [], + contextFiles: [], + })); + setCliRunnerPrepareTestDeps({ resolveBootstrapContextForRun }); + + const context = await fixture.prepare({ + config: createBundledMessageToolConfig(), + sessionKey: "agent:main:opaque:binding", + chatType: testCase.chatType, + sessionEntry: { + sessionId: "stored-session", + updatedAt: 0, + chatType: testCase.sessionEntryChatType, + }, + sourceReplyDeliveryMode: "message_tool_only", + }); + + expect(resolveBootstrapContextForRun).toHaveBeenCalledWith( + expect.objectContaining({ chatType: testCase.expectedChatType }), + ); + expect(context.systemPrompt).toContain(SHARED_CHAT_MESSAGE_TOOL_ETIQUETTE); + }); + it("ignores volatile prompt text when static prompt text matches", async () => { const { dir } = fixture.session; const staticPrompt = "## Direct Context\nYou are in a Telegram direct conversation."; diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index 0aacd025c685..2e2fbf509b2d 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -413,6 +413,7 @@ export async function prepareCliRunContext( const started = Date.now(); const executionMode = params.executionMode ?? "agent"; const isSideQuestion = executionMode === "side-question"; + const runtimeChatType = params.chatType ?? params.sessionEntry?.chatType; const workspaceResolution = resolveRunWorkspaceDir({ workspaceDir: params.workspaceDir, sessionKey: params.sessionKey, @@ -831,6 +832,7 @@ export async function prepareCliRunContext( config: params.config, sessionKey: params.sessionKey, sessionId: params.sessionId, + chatType: runtimeChatType, agentId: sessionAgentId, contextMode: params.bootstrapContextMode, runKind: params.bootstrapContextRunKind, @@ -1420,7 +1422,7 @@ export async function prepareCliRunContext( requireExplicitMessageTarget: bindingRequireExplicitMessageTarget, silentReplyPromptMode: params.silentReplyPromptMode, runtimeChannel, - runtimeChatType: params.sessionEntry?.chatType, + runtimeChatType, runtimeCapabilities, ownerNumbers: params.ownerNumbers, heartbeatPrompt, diff --git a/src/agents/cli-runner/types.ts b/src/agents/cli-runner/types.ts index a38bb41bf5dc..d05d2a9271c9 100644 --- a/src/agents/cli-runner/types.ts +++ b/src/agents/cli-runner/types.ts @@ -12,6 +12,7 @@ import type { import type { ReplyOperation } from "../../auto-reply/reply/reply-run-registry.js"; import type { ThinkLevel } from "../../auto-reply/thinking.js"; import type { FastMode } from "../../auto-reply/thinking.shared.js"; +import type { ChatType } from "../../channels/chat-type.js"; import type { InboundEventKind } from "../../channels/inbound-event/kind.js"; import type { CliSessionBinding, @@ -63,6 +64,7 @@ export type RunCliAgentParams = { sessionManager?: SessionManager; sessionId: string; sessionKey?: string; + chatType?: ChatType; sessionTarget?: SessionTranscriptRuntimeTarget; /** Session identity used only for sandbox and tool-policy resolution. */ runtimePolicySessionKey?: string; diff --git a/src/agents/command/attempt-execution.cli.test.ts b/src/agents/command/attempt-execution.cli.test.ts index 10fd2d639e50..779626ec45c2 100644 --- a/src/agents/command/attempt-execution.cli.test.ts +++ b/src/agents/command/attempt-execution.cli.test.ts @@ -758,6 +758,19 @@ describe("CLI attempt execution", () => { expect(onExecutionStarted).toHaveBeenCalledTimes(1); }); + it("forwards authoritative channel type to embedded runs with opaque session keys", async () => { + const embedded = await runOpenClawEmbeddedAttemptForTest({ + runId: "embedded-opaque-channel", + sessionKey: "agent:main:opaque:binding", + sessionEntry: { chatType: "channel" }, + }); + + expect(embedded).toMatchObject({ + sessionKey: "agent:main:opaque:binding", + chatType: "channel", + }); + }); + async function runClaudeCliAttempt(params: { sessionKey: string; sessionEntry: SessionEntry; @@ -824,6 +837,31 @@ describe("CLI attempt execution", () => { expect(firstRunCliAgentArg().onExecutionStarted).toBe(onExecutionStarted); }); + it("forwards authoritative group type to CLI runs with opaque session keys", async () => { + const sessionKey = "agent:main:opaque:binding"; + const sessionEntry: SessionEntry = { + sessionId: "session-cli-opaque-group", + updatedAt: Date.now(), + chatType: "group", + }; + const sessionStore = { [sessionKey]: sessionEntry }; + await writeSessionStoreSeed(sessionStore); + runCliAgentMock.mockResolvedValueOnce(makeCliResult("shared")); + + await runClaudeCliAttempt({ + sessionKey, + sessionEntry, + sessionStore, + body: "shared", + runId: "run-cli-opaque-group", + }); + + expect(firstRunCliAgentArg()).toMatchObject({ + sessionKey, + chatType: "group", + }); + }); + async function writeClaudeCliAssistantTranscript(cliSessionId: string) { // Claude stores resumable sessions under a workspace-derived project dir, // so stale-session tests must create the same on-disk shape. diff --git a/src/agents/command/attempt-execution.ts b/src/agents/command/attempt-execution.ts index 336e3aa30f3c..d7050109b76a 100644 --- a/src/agents/command/attempt-execution.ts +++ b/src/agents/command/attempt-execution.ts @@ -936,6 +936,7 @@ export function runAgentAttempt(params: { sessionId: params.sessionId, sessionKey: params.sessionKey, sessionEntry: params.sessionEntry, + chatType: params.sessionEntry?.chatType, agentId: params.sessionAgentId, trigger: "user", sessionFile: params.sessionFile, @@ -1135,6 +1136,7 @@ export function runAgentAttempt(params: { const embeddedRunParams: Parameters[0] = { sessionId: params.sessionId, sessionKey: params.sessionKey, + chatType: params.sessionEntry?.chatType, sessionTarget: params.sessionTarget, sandboxSessionKey: params.sessionKey, agentId: params.sessionAgentId, diff --git a/src/agents/embedded-agent-runner/cli-backend-dispatch.test.ts b/src/agents/embedded-agent-runner/cli-backend-dispatch.test.ts index 3e7c5bcf5e12..30fb5c937391 100644 --- a/src/agents/embedded-agent-runner/cli-backend-dispatch.test.ts +++ b/src/agents/embedded-agent-runner/cli-backend-dispatch.test.ts @@ -362,6 +362,23 @@ describe("runEmbeddedAgentViaCliBackendIfEligible execution", () => { expect(cliParams).not.toHaveProperty("toolsAllow"); }); + it.each(["group", "channel"] as const)( + "forwards authoritative %s type through embedded-to-CLI dispatch for opaque keys", + async (chatType) => { + await runEmbeddedAgentViaCliBackendIfEligible( + baseRunParams({ + sessionKey: "agent:main:opaque:binding", + chatType, + }), + ); + + expect(runCliAgent.mock.calls[0]?.[0]).toMatchObject({ + sessionKey: "agent:main:opaque:binding", + chatType, + }); + }, + ); + // Fail-closed tool policy: only a non-empty named allowlist is expressible // on the CLI surface. Every other embedded tool state keeps the passthrough // so no closed state silently widens. diff --git a/src/agents/embedded-agent-runner/cli-backend-dispatch.ts b/src/agents/embedded-agent-runner/cli-backend-dispatch.ts index 4d1e2d9dc216..a66ed799211d 100644 --- a/src/agents/embedded-agent-runner/cli-backend-dispatch.ts +++ b/src/agents/embedded-agent-runner/cli-backend-dispatch.ts @@ -194,6 +194,7 @@ async function runEmbeddedAgentViaCliBackend( const result = await runCliAgent({ sessionId: params.sessionId, sessionKey: params.sessionKey, + chatType: params.chatType, agentId: params.agentId, trigger: params.trigger, sessionFile: dispatch.sessionFile, diff --git a/src/agents/embedded-agent-runner/prepared-compaction-runtime.ts b/src/agents/embedded-agent-runner/prepared-compaction-runtime.ts index fe166af66e2a..c75c51491823 100644 --- a/src/agents/embedded-agent-runner/prepared-compaction-runtime.ts +++ b/src/agents/embedded-agent-runner/prepared-compaction-runtime.ts @@ -190,6 +190,7 @@ export async function buildPreparedCompactionRuntime(prepared: DirectCompactionP config: params.config, sessionKey: params.sessionKey, sessionId: params.sessionId, + chatType: params.chatType, agentId: effectiveSkillAgentId, warn: makeBootstrapWarn({ sessionLabel, diff --git a/src/agents/embedded-agent-runner/run/attempt-bootstrap-prepare.ts b/src/agents/embedded-agent-runner/run/attempt-bootstrap-prepare.ts index 894fe99350ed..971baad43200 100644 --- a/src/agents/embedded-agent-runner/run/attempt-bootstrap-prepare.ts +++ b/src/agents/embedded-agent-runner/run/attempt-bootstrap-prepare.ts @@ -79,6 +79,7 @@ export async function prepareEmbeddedAttemptBootstrap(params: { config: attempt.config, sessionKey: attempt.sessionKey, sessionId: attempt.sessionId, + chatType: attempt.chatType, agentId: params.sessionAgentId, warn: bootstrapWarn, contextMode: attempt.bootstrapContextMode, @@ -108,6 +109,7 @@ export async function prepareEmbeddedAttemptBootstrap(params: { config: attempt.config, sessionKey: attempt.sessionKey, sessionId: attempt.sessionId, + chatType: attempt.chatType, agentId: params.sessionAgentId, warn: bootstrapWarn, contextMode: attempt.bootstrapContextMode, diff --git a/src/agents/workspace.bootstrap-cache.test.ts b/src/agents/workspace.bootstrap-cache.test.ts index 31d76f79bcb6..372b75792b85 100644 --- a/src/agents/workspace.bootstrap-cache.test.ts +++ b/src/agents/workspace.bootstrap-cache.test.ts @@ -138,6 +138,34 @@ describe("workspace bootstrap file caching", () => { expectAgentsContent(agentsFile2, content2); }); + it("replaces a session snapshot when inode changes with identical bytes", async () => { + if (process.platform === "win32") { + return; + } + const content = "# stable-content"; + const filePath = path.join(workspaceDir, DEFAULT_AGENTS_FILENAME); + const tempPath = path.join(workspaceDir, ".AGENTS.replacement"); + const sessionKey = "agent:main:identity-refresh"; + + await writeWorkspaceFile({ + dir: workspaceDir, + name: DEFAULT_AGENTS_FILENAME, + content, + }); + const originalStat = await fs.stat(filePath); + const agentsFile1 = await loadSessionAgentsFile(workspaceDir, sessionKey); + expectAgentsContent(agentsFile1, content); + + await fs.writeFile(tempPath, content, "utf-8"); + await fs.utimes(tempPath, originalStat.atime, originalStat.mtime); + await fs.rename(tempPath, filePath); + await fs.utimes(filePath, originalStat.atime, originalStat.mtime); + + const agentsFile2 = await loadSessionAgentsFile(workspaceDir, sessionKey); + expectAgentsContent(agentsFile2, content); + expect(agentsFile2).not.toBe(agentsFile1); + }); + it("handles file deletion gracefully", async () => { const content = "# Some content"; const filePath = path.join(workspaceDir, DEFAULT_AGENTS_FILENAME); diff --git a/src/agents/workspace.bootstrap-privacy.test.ts b/src/agents/workspace.bootstrap-privacy.test.ts new file mode 100644 index 000000000000..05ce980ed3e2 --- /dev/null +++ b/src/agents/workspace.bootstrap-privacy.test.ts @@ -0,0 +1,129 @@ +/** Tests workspace bootstrap privacy policy and loader source provenance. */ +import fs from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { makeTempWorkspace } from "../test-helpers/workspace.js"; +import { + DEFAULT_MEMORY_FILENAME, + filterBootstrapFilesForSession, + loadExtraBootstrapFilesWithDiagnostics, + loadWorkspaceBootstrapFiles, + type WorkspaceBootstrapFile, + workspaceFilesShareSourceIdentity, +} from "./workspace.js"; + +const mockFiles: WorkspaceBootstrapFile[] = [ + { name: "AGENTS.md", path: "/w/AGENTS.md", content: "", missing: false }, + { name: "SOUL.md", path: "/w/SOUL.md", content: "", missing: false }, + { name: "IDENTITY.md", path: "/w/IDENTITY.md", content: "", missing: false }, + { name: "USER.md", path: "/w/USER.md", content: "", missing: false }, + { name: "BOOTSTRAP.md", path: "/w/BOOTSTRAP.md", content: "", missing: false }, + { name: "MEMORY.md", path: "/w/MEMORY.md", content: "", missing: false }, +]; + +describe("workspace bootstrap source identity", () => { + it("carries canonical source identity through extra-file conversion", async () => { + const tempDir = await makeTempWorkspace("openclaw-workspace-source-identity-"); + const nestedDir = path.join(tempDir, "packages", "core"); + const rootAliasDir = path.join(tempDir, "root-memory-alias"); + const nestedAliasDir = path.join(tempDir, "nested-memory-alias"); + await fs.mkdir(nestedDir, { recursive: true }); + await fs.writeFile(path.join(tempDir, DEFAULT_MEMORY_FILENAME), "root memory", "utf8"); + await fs.writeFile(path.join(nestedDir, DEFAULT_MEMORY_FILENAME), "nested memory", "utf8"); + await fs.symlink(tempDir, rootAliasDir, process.platform === "win32" ? "junction" : "dir"); + await fs.symlink(nestedDir, nestedAliasDir, process.platform === "win32" ? "junction" : "dir"); + + const rootMemory = (await loadWorkspaceBootstrapFiles(tempDir)).find( + (file) => file.name === DEFAULT_MEMORY_FILENAME, + ); + const { files: aliases } = await loadExtraBootstrapFilesWithDiagnostics(tempDir, [ + path.relative(tempDir, path.join(rootAliasDir, DEFAULT_MEMORY_FILENAME)), + path.relative(tempDir, path.join(nestedAliasDir, DEFAULT_MEMORY_FILENAME)), + ]); + const rootAlias = aliases.find((file) => file.path.startsWith(rootAliasDir)); + const nestedAlias = aliases.find((file) => file.path.startsWith(nestedAliasDir)); + + expect(rootMemory).toBeDefined(); + expect(rootAlias).toBeDefined(); + expect(nestedAlias).toBeDefined(); + expect(workspaceFilesShareSourceIdentity(rootMemory!, rootAlias!)).toBe(true); + expect(workspaceFilesShareSourceIdentity(rootMemory!, nestedAlias!)).toBe(false); + }); +}); + +describe("filterBootstrapFilesForSession privacy", () => { + it.each(["agent:default:discord:direct:user-1", "agent:default:telegram:dm:123456"])( + "keeps MEMORY.md for direct sessions (%s)", + (sessionKey) => { + expect(filterBootstrapFilesForSession(mockFiles, sessionKey)).toStrictEqual(mockFiles); + }, + ); + + it.each([ + "agent:default:discord:channel:c1", + "agent:default:telegram:group:-1001234567890:topic:99", + ])("drops only MEMORY.md for shared sessions (%s)", (sessionKey) => { + const result = filterBootstrapFilesForSession(mockFiles, sessionKey); + expect(result).toStrictEqual(mockFiles.filter((file) => file.name !== "MEMORY.md")); + }); + + it("prefers authoritative chat type over the session-key fallback", () => { + const shared = filterBootstrapFilesForSession(mockFiles, { + sessionKey: "agent:default:opaque:binding", + chatType: "group", + }); + const direct = filterBootstrapFilesForSession(mockFiles, { + sessionKey: "agent:default:discord:channel:c1", + chatType: "direct", + }); + + expect(shared).toStrictEqual(mockFiles.filter((file) => file.name !== "MEMORY.md")); + expect(direct).toStrictEqual(mockFiles); + }); + + it("drops root memory path aliases while preserving nested memory in shared sessions", () => { + const rootMemoryAlias: WorkspaceBootstrapFile = { + name: "SOUL.md", + path: "/w/private/../MEMORY.md", + content: "", + missing: false, + }; + const nestedMemory: WorkspaceBootstrapFile = { + name: "MEMORY.md", + path: "/w/packages/core/MEMORY.md", + content: "", + missing: false, + }; + + const result = filterBootstrapFilesForSession([rootMemoryAlias, nestedMemory], { + sessionKey: "agent:default:opaque:binding", + chatType: "channel", + workspaceDir: "/w", + }); + + expect(result).toStrictEqual([nestedMemory]); + }); + + it.each([ + ["subagent", "agent:default:subagent:task-1", "AGENTS.md"], + ["cron", "agent:default:cron:daily-check", "SOUL.md"], + ] as const)( + "drops root memory path aliases before the %s allowlist", + (_mode, sessionKey, name) => { + const allowedFile = mockFiles.find((file) => file.name === name)!; + const rootMemoryAlias: WorkspaceBootstrapFile = { + name, + path: "/w/MEMORY.md", + content: "", + missing: false, + }; + + const result = filterBootstrapFilesForSession([allowedFile, rootMemoryAlias], { + sessionKey, + workspaceDir: "/w", + }); + + expect(result).toStrictEqual([allowedFile]); + }, + ); +}); diff --git a/src/agents/workspace.ts b/src/agents/workspace.ts index c1590dfa150f..50e2a2a4d423 100644 --- a/src/agents/workspace.ts +++ b/src/agents/workspace.ts @@ -9,7 +9,9 @@ import fs from "node:fs/promises"; import path from "node:path"; import { Minimatch } from "minimatch"; import { extractFrontmatterBlock } from "../../packages/markdown-core/src/frontmatter.js"; +import type { ChatType } from "../channels/chat-type.js"; import { openRootFile } from "../infra/boundary-file-read.js"; +import { sameFileIdentity, type FileIdentityStat } from "../infra/fs-safe-advanced.js"; import { pathExists } from "../infra/fs-safe.js"; import { isPathInside } from "../infra/path-guards.js"; import { retryAsync } from "../infra/retry.js"; @@ -19,6 +21,7 @@ import { } from "../memory/root-memory-files.js"; import { runCommandWithTimeout } from "../process/exec.js"; import { isCronSessionKey, isSubagentSessionKey } from "../routing/session-key.js"; +import { deriveSessionChatTypeFromKey } from "../sessions/session-chat-type-shared.js"; import { resolveUserPath } from "../utils.js"; import { MAX_WORKSPACE_BOOTSTRAP_FILE_BYTES, @@ -77,18 +80,53 @@ let gitAvailabilityPromise: Promise | null = null; // File content cache keyed by stable file identity to avoid stale reads. const workspaceFileCache = new Map(); +type WorkspaceFileSourceIdentity = readonly [ + canonicalPath: string, + stat: FileIdentityStat, + exactIdentity: string, +]; +// Loader-owned records retain the pinned-open identity through final session filtering. +const workspaceFileSourceIdentities = new WeakMap(); /** * Read workspace files via boundary-safe open and cache by inode/dev/size/mtime identity. */ type WorkspaceGuardedReadResult = - | { ok: true; content: string } + | { ok: true; content: string; sourceIdentity: WorkspaceFileSourceIdentity } | { ok: false; reason: "path" | "validation" | "io"; error?: unknown }; function workspaceFileIdentity(stat: syncFs.Stats, canonicalPath: string): string { return `${canonicalPath}|${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}`; } +function setWorkspaceFileSourceIdentity( + file: object, + sourceIdentity: WorkspaceFileSourceIdentity, +): void { + workspaceFileSourceIdentities.set(file, sourceIdentity); +} + +function getWorkspaceFileSourceIdentity(file: object): WorkspaceFileSourceIdentity | undefined { + return workspaceFileSourceIdentities.get(file); +} + +export function workspaceFileSourceIdentitiesMatch(left: object, right: object): boolean { + const leftIdentity = getWorkspaceFileSourceIdentity(left); + const rightIdentity = getWorkspaceFileSourceIdentity(right); + return leftIdentity?.[2] === rightIdentity?.[2]; +} + +export function workspaceFilesShareSourceIdentity(left: object, right: object): boolean { + const leftIdentity = getWorkspaceFileSourceIdentity(left); + const rightIdentity = getWorkspaceFileSourceIdentity(right); + if (!leftIdentity || !rightIdentity) { + return false; + } + return ( + leftIdentity[0] === rightIdentity[0] || sameFileIdentity(leftIdentity[1], rightIdentity[1]) + ); +} + async function readWorkspaceFileWithGuards(params: { filePath: string; workspaceDir: string; @@ -120,16 +158,17 @@ async function readWorkspaceFileWithGuards(params: { } const identity = workspaceFileIdentity(opened.stat, opened.path); + const sourceIdentity = [opened.path, opened.stat, identity] as const; const cached = workspaceFileCache.get(params.filePath); if (cached && cached.identity === identity) { syncFs.closeSync(opened.fd); - return { ok: true, content: cached.content }; + return { ok: true, content: cached.content, sourceIdentity }; } try { const content = await readWorkspaceBootstrapFile(opened.fd); workspaceFileCache.set(params.filePath, { content, identity }); - return { ok: true, content }; + return { ok: true, content, sourceIdentity }; } finally { syncFs.closeSync(opened.fd); } @@ -989,12 +1028,14 @@ export async function loadWorkspaceBootstrapFiles(dir: string): Promise file.name !== DEFAULT_MEMORY_FILENAME); + } + const resolvedWorkspaceRoot = resolveUserPath(workspaceRoot); + const rootMemoryPath = path.join(resolvedWorkspaceRoot, DEFAULT_MEMORY_FILENAME); + return files.filter((file) => { + if (typeof file.path !== "string") { + return true; + } + const filePath = file.path.trim(); + if (!filePath) { + return true; + } + const resolvedPath = path.isAbsolute(filePath) + ? path.resolve(filePath) + : filePath.startsWith("~") + ? resolveUserPath(filePath) + : path.resolve(resolvedWorkspaceRoot, filePath); + return resolvedPath !== rootMemoryPath; + }); +} + export function filterBootstrapFilesForSession( files: WorkspaceBootstrapFile[], - sessionKey?: string, + session?: string | BootstrapSessionContext, ): WorkspaceBootstrapFile[] { - if (!sessionKey) { - return files; + const { sessionKey, chatType, workspaceDir } = resolveBootstrapSessionContext(session); + const isSubagent = isSubagentSessionKey(sessionKey); + const isCron = isCronSessionKey(sessionKey); + const effectiveChatType = chatType ?? deriveSessionChatTypeFromKey(sessionKey); + const isNonPrivate = + isSubagent || isCron || effectiveChatType === "group" || effectiveChatType === "channel"; + const privacyFilteredFiles = isNonPrivate + ? filterRootMemoryBootstrapFiles(files, workspaceDir) + : files; + if (isSubagent) { + return privacyFilteredFiles.filter((file) => SUBAGENT_BOOTSTRAP_ALLOWLIST.has(file.name)); } - if (isSubagentSessionKey(sessionKey)) { - return files.filter((file) => SUBAGENT_BOOTSTRAP_ALLOWLIST.has(file.name)); + if (isCron) { + return privacyFilteredFiles.filter((file) => CRON_BOOTSTRAP_ALLOWLIST.has(file.name)); } - if (isCronSessionKey(sessionKey)) { - return files.filter((file) => CRON_BOOTSTRAP_ALLOWLIST.has(file.name)); - } - return files; + return privacyFilteredFiles; } function hasGlobPattern(pattern: string): boolean { @@ -1207,7 +1292,13 @@ export async function loadWorkspacePatternFilesWithDiagnostics( workspaceDir: resolvedDir, }); if (loaded.ok) { - files.push({ name: baseName, path: filePath, content: loaded.content }); + const file: WorkspacePatternFile = { + name: baseName, + path: filePath, + content: loaded.content, + }; + setWorkspaceFileSourceIdentity(file, loaded.sourceIdentity); + files.push(file); continue; } @@ -1244,12 +1335,19 @@ export async function loadExtraBootstrapFilesWithDiagnostics( acceptedBasenames: VALID_BOOTSTRAP_NAMES, }); return { - files: loaded.files.map((file) => ({ - name: file.name as WorkspaceBootstrapFileName, - path: file.path, - content: file.content, - missing: false, - })), + files: loaded.files.map((file) => { + const bootstrapFile: WorkspaceBootstrapFile = { + name: file.name as WorkspaceBootstrapFileName, + path: file.path, + content: file.content, + missing: false, + }; + const sourceIdentity = getWorkspaceFileSourceIdentity(file); + if (sourceIdentity) { + setWorkspaceFileSourceIdentity(bootstrapFile, sourceIdentity); + } + return bootstrapFile; + }), diagnostics: loaded.diagnostics, }; } diff --git a/src/auto-reply/reply/agent-runner-cli-candidate.ts b/src/auto-reply/reply/agent-runner-cli-candidate.ts index b090d84d169e..73a601bdcf10 100644 --- a/src/auto-reply/reply/agent-runner-cli-candidate.ts +++ b/src/auto-reply/reply/agent-runner-cli-candidate.ts @@ -12,6 +12,7 @@ import { resolveAgentRunErrorLifecycleFields, } from "../../agents/run-termination.js"; import { withLocalSessionPlacementTurnAdmission } from "../../agents/session-placement-admission.js"; +import { normalizeChatType } from "../../channels/chat-type.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { getGeneratedMediaTaskIdsForSessionKey, @@ -322,6 +323,10 @@ export async function runCliFallbackCandidate(params: { runParams: { sessionId: turn.followupRun.run.sessionId, sessionKey: turn.sessionKey, + chatType: + normalizeChatType(turn.followupRun.originatingChatType) ?? + normalizeChatType(turn.sessionCtx.ChatType) ?? + params.candidateRun.chatType, runtimePolicySessionKey: turn.followupRun.run.runtimePolicySessionKey ?? turn.runtimePolicySessionKey, agentId: turn.followupRun.run.agentId, diff --git a/src/auto-reply/reply/agent-runner-execution-runtime.test.ts b/src/auto-reply/reply/agent-runner-execution-runtime.test.ts index 259a6a725276..d394b0b0a615 100644 --- a/src/auto-reply/reply/agent-runner-execution-runtime.test.ts +++ b/src/auto-reply/reply/agent-runner-execution-runtime.test.ts @@ -17,6 +17,86 @@ import type { FallbackRunnerParams } from "./agent-runner-execution.test-support const state = setupAgentRunnerExecutionTestState(); describe("executeAgentTurn: runtime selection", () => { + it.each(["group", "channel"] as const)( + "forwards authoritative %s type through CLI fallback for opaque session keys", + async (chatType) => { + state.isCliProviderMock.mockReturnValue(true); + state.runWithModelFallbackMock.mockImplementationOnce( + async (params: FallbackRunnerParams) => ({ + result: await params.run("codex-cli", "gpt-5.4"), + provider: "codex-cli", + model: "gpt-5.4", + attempts: [], + }), + ); + state.runCliAgentMock.mockResolvedValueOnce({ + payloads: [{ text: "final" }], + meta: {}, + }); + + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const followupRun = createFollowupRun(); + followupRun.run.provider = "codex-cli"; + followupRun.run.model = "gpt-5.4"; + followupRun.run.sessionKey = "agent:main:opaque:binding"; + followupRun.run.chatType = chatType; + + await executeAgentTurn({ + ...createMinimalRunAgentTurnParams({ + followupRun, + sessionCtx: { + Provider: "discord", + MessageSid: "msg", + } as unknown as TemplateContext, + }), + sessionKey: "agent:main:opaque:binding", + }); + + expectMockCallArgFields(state.runCliAgentMock, 0, "CLI run params", { + sessionKey: "agent:main:opaque:binding", + chatType, + }); + }, + ); + + it("prefers normalized current shared context over stale queued direct metadata", async () => { + state.isCliProviderMock.mockReturnValue(true); + state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({ + result: await params.run("codex-cli", "gpt-5.4"), + provider: "codex-cli", + model: "gpt-5.4", + attempts: [], + })); + state.runCliAgentMock.mockResolvedValueOnce({ + payloads: [{ text: "final" }], + meta: {}, + }); + + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const followupRun = createFollowupRun(); + followupRun.run.provider = "codex-cli"; + followupRun.run.model = "gpt-5.4"; + followupRun.run.sessionKey = "agent:main:opaque:binding"; + followupRun.run.chatType = "direct"; + + await executeAgentTurn({ + ...createMinimalRunAgentTurnParams({ + followupRun, + sessionCtx: { + Provider: "discord", + ChatType: "Channel", + MessageSid: "msg", + } as unknown as TemplateContext, + }), + sessionKey: "agent:main:opaque:binding", + }); + + expectMockCallArgFields(state.runCliAgentMock, 0, "CLI run params", { + sessionKey: "agent:main:opaque:binding", + chatType: "channel", + }); + }); + it("resolves CLI messageProvider from the live session surface when no origin channel is set", async () => { state.isCliProviderMock.mockReturnValue(true); state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({ diff --git a/src/auto-reply/reply/commands-system-prompt.ts b/src/auto-reply/reply/commands-system-prompt.ts index 5acedc3dc372..60645d0ea20d 100644 --- a/src/auto-reply/reply/commands-system-prompt.ts +++ b/src/auto-reply/reply/commands-system-prompt.ts @@ -172,6 +172,7 @@ export async function resolveCommandsSystemPromptBundle( config: params.cfg, sessionKey: params.sessionKey, sessionId: targetSessionEntry?.sessionId, + chatType: targetSessionEntry?.chatType, agentId: sessionAgentId, }); const toolPolicySessionKey = resolveRuntimePolicySessionKey({ diff --git a/src/hooks/bundled/bootstrap-extra-files/handler.test.ts b/src/hooks/bundled/bootstrap-extra-files/handler.test.ts index bf8380655f44..2af375d65549 100644 --- a/src/hooks/bundled/bootstrap-extra-files/handler.test.ts +++ b/src/hooks/bundled/bootstrap-extra-files/handler.test.ts @@ -76,11 +76,34 @@ describe("bootstrap-extra-files hook", () => { ); }); - it("re-applies subagent bootstrap allowlist after extras are added", async () => { + it("appends configured nested memory without applying session policy", async () => { + const tempDir = await makeTempWorkspace("openclaw-bootstrap-extra-memory-"); + const extraDir = path.join(tempDir, "packages", "core"); + const sessionKey = "agent:main:slack:channel:c1"; + await fs.mkdir(extraDir, { recursive: true }); + await fs.writeFile(path.join(extraDir, "MEMORY.md"), "nested memory", "utf-8"); + + const cfg = createBootstrapExtraConfig(["packages/*/MEMORY.md"]); + const context = await createBootstrapContext({ + workspaceDir: tempDir, + cfg, + sessionKey, + rootFiles: [{ name: "MEMORY.md", content: "private root memory" }], + }); + + const event = createHookEvent("agent", "bootstrap", sessionKey, context); + await handler(event); + + const relativePaths = context.bootstrapFiles.map((file) => path.relative(tempDir, file.path)); + expect(relativePaths).toContain("MEMORY.md"); + expect(relativePaths).toContain(path.join("packages", "core", "MEMORY.md")); + }); + + it("leaves subagent allowlist enforcement to the final resolver", async () => { const tempDir = await makeTempWorkspace("openclaw-bootstrap-extra-subagent-"); const extraDir = path.join(tempDir, "packages", "persona"); await fs.mkdir(extraDir, { recursive: true }); - await fs.writeFile(path.join(extraDir, "SOUL.md"), "evil", "utf-8"); + await fs.writeFile(path.join(extraDir, "SOUL.md"), "extra persona", "utf-8"); const cfg = createBootstrapExtraConfig(["packages/*/SOUL.md"]); const context = await createBootstrapContext({ @@ -92,6 +115,6 @@ describe("bootstrap-extra-files hook", () => { const event = createHookEvent("agent", "bootstrap", "agent:main:subagent:abc", context); await handler(event); - expect(context.bootstrapFiles.map((f) => f.name).toSorted()).toEqual(["AGENTS.md"]); + expect(context.bootstrapFiles.map((f) => f.name).toSorted()).toEqual(["AGENTS.md", "SOUL.md"]); }); }); diff --git a/src/hooks/bundled/bootstrap-extra-files/handler.ts b/src/hooks/bundled/bootstrap-extra-files/handler.ts index 7f46e8063728..68bb3daeea4b 100644 --- a/src/hooks/bundled/bootstrap-extra-files/handler.ts +++ b/src/hooks/bundled/bootstrap-extra-files/handler.ts @@ -1,9 +1,6 @@ // Bootstrap extra files hook injects configured extra files into startup context. import { normalizeTrimmedStringList } from "@openclaw/normalization-core/string-normalization"; -import { - filterBootstrapFilesForSession, - loadExtraBootstrapFilesWithDiagnostics, -} from "../../../agents/workspace.js"; +import { loadExtraBootstrapFilesWithDiagnostics } from "../../../agents/workspace.js"; import { createSubsystemLogger } from "../../../logging/subsystem.js"; import { resolveHookConfig } from "../../config.js"; import { isAgentBootstrapEvent, type HookHandler } from "../../hooks.js"; @@ -58,12 +55,9 @@ const bootstrapExtraFilesHook: HookHandler = async (event) => { if (extras.length === 0) { return; } - // Re-run session filtering after append so extra files obey the same - // per-session include rules as the original bootstrap files. - context.bootstrapFiles = filterBootstrapFilesForSession( - [...context.bootstrapFiles, ...extras], - context.sessionKey, - ); + // The final bootstrap resolver owns session policy after every hook has run, + // using the authoritative chat type and loader provenance in one place. + context.bootstrapFiles = [...context.bootstrapFiles, ...extras]; } catch (err) { log.warn(`failed: ${String(err)}`); }