mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -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" }] })
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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"],
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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)}`);
|
||||
}
|
||||
|
||||
@@ -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"] }]);
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user