merge: sync origin/main

* origin/main:
  fix(agents): protect private memory in shared chats (#119198)
  perf(cli): skip absent gateway workspace dotenv (#119227)
  fix(agents): observe native provider prompt egress (#119219)
  fix(feishu): report bot identity retry failures (#102185)
  fix(memory): load configured providers for CLI search (#119186)
  fix(discord): count retained speech bytes
  test(discord): isolate exact speech overflow
  test(discord): cover exact speech overflow
  fix(discord): bound realtime exact speech
This commit is contained in:
Vincent Koc
2026-08-04 23:05:23 +08:00
60 changed files with 2434 additions and 200 deletions
@@ -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
@@ -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,
@@ -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,
@@ -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<typeof createClient>) =>
createManager(
{
groupPolicy: "open",
voice: {
enabled: true,
mode: "agent-proxy",
realtime: { provider: "openai" },
},
},
});
clientOverride,
);
const expectConnectedStatus = (
manager: InstanceType<typeof managerModule.DiscordVoiceManager>,
@@ -4114,6 +4117,88 @@ describe("DiscordVoiceManager", () => {
expectUserMessageIncludes("third answer");
});
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";
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);
const realtime = entry.realtime as unknown as {
enqueueExactSpeechMessage: (text: string) => void;
};
const connection = (entry as unknown as { connection: { destroy: ReturnType<typeof vi.fn> } })
.connection;
const bridgeParams = lastRealtimeBridgeParams();
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 {
enqueueExactSpeechMessage: (text: string) => void;
};
realtime.enqueueExactSpeechMessage(accepted);
expectUserMessageIncludes(accepted);
expect(manager.status()).toHaveLength(2);
realtime.enqueueExactSpeechMessage("overflow");
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");
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<typeof vi.fn> } })
.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" }] })
+7
View File
@@ -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 }),
});
+33
View File
@@ -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_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 = () => ({});
@@ -388,6 +390,7 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
mode: Exclude<DiscordVoiceMode, "stt-tts">;
bootstrapContextInstructions?: string;
getHumanParticipantCount?: () => number;
onTerminalError: (error: Error) => void;
runAgentTurn: (params: VoiceRealtimeAgentTurnParams) => Promise<string>;
},
) {
@@ -994,6 +997,36 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
if (this.stopped || !text.trim()) {
return;
}
const retainedMessages =
this.queuedExactSpeechMessages.length + (this.activeExactSpeechMessage ? 1 : 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 ||
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.
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} retainedBytes=${retainedBytes} incomingBytes=${incomingBytes}`,
),
);
return;
}
if (!this.bridgeReady || this.exactSpeechResponseActive || this.hasInterruptibleOutputAudio()) {
this.queuedExactSpeechMessages.push(text);
logger.info(
@@ -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<void>((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);
});
});
@@ -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)}`,
);
});
}
@@ -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"]);
});
});
@@ -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";
+2
View File
@@ -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";
+2
View File
@@ -136,6 +136,7 @@ const compatibility = {
"resolveOpenAIProjectedToolsStrictToolFlag",
"stripUnsupportedSchemaKeywords",
"projectRuntimeToolInputSchema",
"responsesPromptObserver",
],
types: [
"OpenAICompletionsOptions",
@@ -157,6 +158,7 @@ const compatibility = {
"OpenAICompletionsToolChoice",
"RuntimeToolInputSchemaJson",
"RuntimeToolInputSchemaProjection",
"ResponsesPromptObservation",
],
},
} as const;
@@ -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[];
@@ -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<typeof fetch>()
.mockResolvedValueOnce(new Response("overloaded", { status: 503 }))
@@ -89,13 +92,25 @@ describe("streamOpenAICodexResponses retry classification", () => {
return 0 as unknown as ReturnType<typeof setTimeout>;
});
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([
@@ -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<typeof createResponsesPromptEgressObserver>
>;
// ============================================================================
// 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<void> {
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(
@@ -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();
@@ -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;
@@ -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<ResponsesPromptObservation, "egress" | "payloadVariant">;
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,
});
};
}
@@ -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<Record<string, unknown>>,
}));
vi.mock("openai", () => {
const createClient = (client: "openai" | "azure") =>
class MockOpenAI {
responses = {
create: (request: Record<string, unknown>) => {
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<TApi extends Api = "openai-responses">(
overrides: Partial<Model<TApi>> = {},
): Model<TApi> {
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<TApi>;
}
function createContext(systemPrompt: string, overrides: Partial<Context> = {}): 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<string, unknown>;
}) {
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<string, unknown>) => ({
...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<Record<string, unknown>>)[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<Record<string, unknown>> = [];
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<string, unknown>);
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<string, unknown> | 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<string, unknown>;
return completedSseResponse();
}),
);
const options = {
apiKey: createJwt(),
transport: "sse" as const,
onPayload: async (body: unknown) => {
await Promise.resolve();
return { ...(body as Record<string, unknown>), 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",
]);
});
});
@@ -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<ReturnType<typeof createResponsesPromptEgressObserver>>;
}): Promise<{ stream: AsyncIterable<unknown>; 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();
@@ -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)"
+15 -1
View File
@@ -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);
+9 -2
View File
@@ -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)
);
});
}
+238 -55
View File
@@ -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<string> {
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", () => {
+42 -2
View File
@@ -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<WorkspaceBootstrapFile[]> {
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;
+78
View File
@@ -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.";
+3 -1
View File
@@ -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,
+2
View File
@@ -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;
@@ -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.
+2
View File
@@ -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<typeof runEmbeddedAgent>[0] = {
sessionId: params.sessionId,
sessionKey: params.sessionKey,
chatType: params.sessionEntry?.chatType,
sessionTarget: params.sessionTarget,
sandboxSessionKey: params.sessionKey,
agentId: params.sessionAgentId,
@@ -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.
@@ -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,
@@ -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,
@@ -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);
@@ -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<string, unknown>) => 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<Parameters<StreamFn>[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);
};
}
@@ -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,
@@ -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;
@@ -49,6 +49,7 @@ export async function prepareEmbeddedAttemptTransport(input: {
providerPromptState: {
state: ProviderPromptState;
effectiveContextTokenBudget: number;
recordEvent?: (type: string, data?: Record<string, unknown>) => void;
};
}) {
const attempt = input.attempt;
@@ -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<Record<string, unknown>> = [];
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();
}
@@ -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);
@@ -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]);
},
);
});
+119 -21
View File
@@ -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<boolean> | null = null;
// File content cache keyed by stable file identity to avoid stale reads.
const workspaceFileCache = new Map<string, { content: string; identity: string }>();
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<object, WorkspaceFileSourceIdentity>();
/**
* 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<Workspac
workspaceDir: resolvedDir,
});
if (loaded.ok) {
result.push({
const file: WorkspaceBootstrapFile = {
name: entry.name,
path: entry.filePath,
content: loaded.content,
missing: false,
});
};
setWorkspaceFileSourceIdentity(file, loaded.sourceIdentity);
result.push(file);
} else {
result.push({ name: entry.name, path: entry.filePath, missing: true });
}
@@ -1011,20 +1052,64 @@ const CRON_BOOTSTRAP_ALLOWLIST = new Set([
DEFAULT_USER_FILENAME,
]);
type BootstrapSessionContext = {
sessionKey?: string;
chatType?: ChatType;
workspaceDir?: string;
};
function resolveBootstrapSessionContext(
session?: string | BootstrapSessionContext,
): BootstrapSessionContext {
return typeof session === "string" ? { sessionKey: session } : (session ?? {});
}
function filterRootMemoryBootstrapFiles(
files: WorkspaceBootstrapFile[],
workspaceRoot?: string,
): WorkspaceBootstrapFile[] {
if (!workspaceRoot) {
return files.filter((file) => 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,
};
}
@@ -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,
@@ -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) => ({
@@ -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({
+3 -2
View File
@@ -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,
});
});
+6 -3
View File
@@ -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 },
+11 -10
View File
@@ -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"],
+9
View File
@@ -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"],
@@ -7,8 +7,6 @@ export const COLD_READ_COMMAND_PATHS: string[][] = [
["hooks", "list"],
["hooks", "info"],
["hooks", "check"],
["memory", "status"],
["memory", "search"],
["update", "--dry-run"],
];
+12 -5
View File
@@ -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<boolean>) | undefined;
@@ -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;
+88 -6
View File
@@ -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<typeof import("node:fs")>("node:fs");
return {
...actual,
existsSync: (target: Parameters<typeof actual.existsSync>[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"]);
+10 -4
View File
@@ -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 });
}
});
}
@@ -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"]);
});
});
@@ -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)}`);
}
+31 -1
View File
@@ -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"] }]);
+12
View File
@@ -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",
{
@@ -9,10 +9,16 @@ const mocks = vi.hoisted(() => ({
vi.fn<typeof import("../channel-plugin-ids.js").resolveChannelPluginIds>(),
resolveEffectivePluginIds:
vi.fn<typeof import("../effective-plugin-ids.js").resolveEffectivePluginIds>(),
collectConfiguredMemoryEmbeddingProviderIds:
vi.fn<
typeof import("../gateway-startup-plugin-ids.js").collectConfiguredMemoryEmbeddingProviderIds
>(),
applyPluginAutoEnable:
vi.fn<typeof import("../../config/plugin-auto-enable.js").applyPluginAutoEnable>(),
resolvePluginMetadataSnapshot:
vi.fn<typeof import("../plugin-metadata-snapshot.js").resolvePluginMetadataSnapshot>(),
isPluginMetadataSnapshotCompatible:
vi.fn<typeof import("../plugin-metadata-snapshot.js").isPluginMetadataSnapshotCompatible>(),
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<typeof mocks.collectConfiguredMemoryEmbeddingProviderIds>
) => mocks.collectConfiguredMemoryEmbeddingProviderIds(...args),
}));
vi.mock("../../config/plugin-auto-enable.js", () => ({
applyPluginAutoEnable: (...args: Parameters<typeof mocks.applyPluginAutoEnable>) =>
mocks.applyPluginAutoEnable(...args),
@@ -48,6 +60,9 @@ vi.mock("../plugin-metadata-snapshot.js", () => ({
resolvePluginMetadataSnapshot: (
...args: Parameters<typeof mocks.resolvePluginMetadataSnapshot>
) => mocks.resolvePluginMetadataSnapshot(...args),
isPluginMetadataSnapshotCompatible: (
...args: Parameters<typeof mocks.isPluginMetadataSnapshotCompatible>
) => 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<string, unknown> {
const options = mocks.loadOpenClawPlugins.mock.calls[0]?.[0];
if (!options) {
@@ -70,6 +107,8 @@ function requireLoadOptions(): Record<string, unknown> {
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([]);
});
});
+37 -3
View File
@@ -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<typeof resolvePluginRuntimeLoadContext>,
): string[] {
const configuredProviderIds = [
...collectConfiguredMemoryEmbeddingProviderIds(context.activationSourceConfig),
];
const pluginIds = new Set<string>();
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 }