mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-25 00:34:16 +08:00
Compare commits
17 Commits
codex/tool
...
codex/watc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cda85965bb | ||
|
|
cb13be375d | ||
|
|
acc2a0ee72 | ||
|
|
704fc35043 | ||
|
|
f1e38f2ed6 | ||
|
|
d2933bbdb9 | ||
|
|
2e124081af | ||
|
|
8150b76b6f | ||
|
|
77eb0fdbaa | ||
|
|
f0be8e7b6e | ||
|
|
80bd0003ce | ||
|
|
f3891e1335 | ||
|
|
bea3d292c7 | ||
|
|
17066f2d7c | ||
|
|
9aea104cc8 | ||
|
|
2aa9d67635 | ||
|
|
51eec3a757 |
@@ -1,2 +1,2 @@
|
||||
212b76ef72779add8f18be4848e143e61b6ae42a1c7daeefdc42d91e0a1152e9 plugin-sdk-api-baseline.json
|
||||
976179e09e9e46a9b9259bd20ca1cafc8883c8e281a099a9aaa5fceab3c2983b plugin-sdk-api-baseline.jsonl
|
||||
ebb0ae07e4d6f6ea1faccba7604c9da71a5401b3aa2bc3618963e1e44a8dbcce plugin-sdk-api-baseline.json
|
||||
9b7aee16d91c6a1b042a7d7e6f92a77b3e234337cc5fcf5a797de05fa9e9a02e plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -528,13 +528,25 @@ candidate contains redacted secret placeholders such as `***`.
|
||||
and re-checked, so a path that lexically lives in a config dir but whose
|
||||
real target escapes every allowed root is still rejected.
|
||||
- **Error handling**: clear errors for missing files, parse errors, circular includes, invalid path format, and excessive length
|
||||
- **Hot reload**: edits to regular include files successfully resolved by the
|
||||
last valid config are watched, including nested includes. Changing an
|
||||
authored `$include` target inside a watched file re-resolves the graph.
|
||||
Paths that were missing or invalid during the last successful resolution,
|
||||
and filesystem or symlink retargets that do not modify a watched file, are
|
||||
not discovered automatically; edit `openclaw.json` or restart the Gateway
|
||||
to resolve the graph again.
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## Config hot reload
|
||||
|
||||
The Gateway watches `~/.openclaw/openclaw.json` and applies changes automatically - no manual restart needed for most settings.
|
||||
The Gateway watches `~/.openclaw/openclaw.json` plus the canonical include files
|
||||
successfully resolved by the last valid config, and applies changes
|
||||
automatically - no manual restart needed for most settings. Invalid candidates
|
||||
keep the last valid watch set. Missing or invalid paths outside that set, plus
|
||||
filesystem or symlink retargets that do not modify a watched file, require an
|
||||
`openclaw.json` edit or a Gateway restart before they can be discovered.
|
||||
|
||||
Direct file edits are treated as untrusted until they validate. The watcher waits
|
||||
for editor temp-write/rename churn to settle, reads the final file, and rejects
|
||||
|
||||
@@ -186,12 +186,8 @@ file.
|
||||
- optional `event.runId`
|
||||
- optional `event.toolCallId`
|
||||
- context fields such as `ctx.agentId`, `ctx.sessionKey`, `ctx.sessionId`,
|
||||
`ctx.runId`, `ctx.jobId` (set on cron-driven runs), `ctx.trigger`,
|
||||
`ctx.toolKind`, `ctx.toolInputKind`, and diagnostic `ctx.trace`
|
||||
- for channel-originated calls, origin fields such as `ctx.channel`,
|
||||
`ctx.messageProvider`, `ctx.channelId`, `ctx.chatId`, `ctx.senderId`, and
|
||||
extensible `ctx.channelContext` sender/chat metadata. These use the same
|
||||
identity semantics described below for agent hook contexts.
|
||||
`ctx.runId`, `ctx.jobId` (set on cron-driven runs), `ctx.toolKind`,
|
||||
`ctx.toolInputKind`, and diagnostic `ctx.trace`
|
||||
|
||||
It can return:
|
||||
|
||||
|
||||
@@ -12,11 +12,7 @@ import {
|
||||
type EmbeddedRunAttemptParams,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildApprovalResponse,
|
||||
handleCodexAppServerApprovalRequest as handleCodexAppServerApprovalRequestImpl,
|
||||
} from "./approval-bridge.js";
|
||||
import { buildCodexToolHookRunContext } from "./tool-hook-context.js";
|
||||
import { buildApprovalResponse, handleCodexAppServerApprovalRequest } from "./approval-bridge.js";
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/agent-harness-runtime", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("openclaw/plugin-sdk/agent-harness-runtime")>()),
|
||||
@@ -46,20 +42,6 @@ const mockResolveNativeHookRelayDeferredToolApproval = vi.mocked(
|
||||
const mockReviewExecRequestWithConfiguredModel = vi.mocked(reviewExecRequestWithConfiguredModel);
|
||||
const mockRunBeforeToolCallHook = vi.mocked(runBeforeToolCallHook);
|
||||
|
||||
type ApprovalRequestParams = Parameters<typeof handleCodexAppServerApprovalRequestImpl>[0];
|
||||
|
||||
function handleCodexAppServerApprovalRequest(
|
||||
params: Omit<ApprovalRequestParams, "toolHookContext"> & {
|
||||
toolHookContext?: ApprovalRequestParams["toolHookContext"];
|
||||
},
|
||||
) {
|
||||
return handleCodexAppServerApprovalRequestImpl({
|
||||
...params,
|
||||
toolHookContext:
|
||||
params.toolHookContext ?? buildCodexToolHookRunContext({ attempt: params.paramsForRun }),
|
||||
});
|
||||
}
|
||||
|
||||
function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error(`Expected ${label}`);
|
||||
@@ -261,8 +243,6 @@ describe("Codex app-server approval bridge", () => {
|
||||
ctx: {
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:session-1",
|
||||
messageProvider: "telegram",
|
||||
channel: "telegram",
|
||||
channelId: "chat-1",
|
||||
},
|
||||
});
|
||||
@@ -1184,18 +1164,11 @@ describe("Codex app-server approval bridge", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the caller-resolved hook context for approval fallback policy", async () => {
|
||||
it("normalizes prefixed channel targets for OpenClaw tool policy context", async () => {
|
||||
const params = createParams();
|
||||
params.agentId = "raw-agent";
|
||||
params.sessionId = "raw-session";
|
||||
params.sessionKey = "agent:raw:session";
|
||||
params.runId = "raw-run";
|
||||
params.messageChannel = "discord";
|
||||
params.messageProvider = "discord";
|
||||
params.currentChannelId = "discord:raw-target";
|
||||
params.jobId = "raw-job";
|
||||
params.senderId = "raw-user";
|
||||
params.chatId = "raw-chat";
|
||||
params.messageChannel = "telegram";
|
||||
params.messageProvider = "telegram";
|
||||
params.currentChannelId = "telegram:-100123";
|
||||
mockCallGatewayTool
|
||||
.mockResolvedValueOnce({ id: "plugin:approval-prefixed", status: "accepted" })
|
||||
.mockResolvedValueOnce({ id: "plugin:approval-prefixed", decision: "allow-once" });
|
||||
@@ -1209,27 +1182,6 @@ describe("Codex app-server approval bridge", () => {
|
||||
command: "pnpm test extensions/codex/src/app-server",
|
||||
},
|
||||
paramsForRun: params,
|
||||
toolHookContext: {
|
||||
agentId: "resolved-agent",
|
||||
sessionId: "resolved-session",
|
||||
sessionKey: "agent:resolved:session",
|
||||
runId: "resolved-run",
|
||||
jobId: "resolved-job",
|
||||
trigger: "user",
|
||||
messageProvider: "telegram-voice",
|
||||
channel: "telegram",
|
||||
channelId: "-100123",
|
||||
chatId: "native-chat-1",
|
||||
senderId: "user-1",
|
||||
channelContext: {
|
||||
sender: {
|
||||
id: "user-1",
|
||||
displayName: "Ada",
|
||||
providerUserId: "provider-user-1",
|
||||
},
|
||||
chat: { id: "native-chat-1", providerThreadKey: "thread-key-1" },
|
||||
},
|
||||
},
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
});
|
||||
@@ -1237,29 +1189,11 @@ describe("Codex app-server approval bridge", () => {
|
||||
expect(mockRunBeforeToolCallHook).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ctx: expect.objectContaining({
|
||||
agentId: "resolved-agent",
|
||||
sessionId: "resolved-session",
|
||||
sessionKey: "agent:resolved:session",
|
||||
runId: "resolved-run",
|
||||
jobId: "resolved-job",
|
||||
trigger: "user",
|
||||
messageProvider: "telegram-voice",
|
||||
channel: "telegram",
|
||||
channelId: "-100123",
|
||||
chatId: "native-chat-1",
|
||||
senderId: "user-1",
|
||||
channelContext: {
|
||||
sender: {
|
||||
id: "user-1",
|
||||
displayName: "Ada",
|
||||
providerUserId: "provider-user-1",
|
||||
},
|
||||
chat: { id: "native-chat-1", providerThreadKey: "thread-key-1" },
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(gatewayRequestPayload().turnSourceTo).toBe("discord:raw-target");
|
||||
expect(gatewayRequestPayload().turnSourceTo).toBe("telegram:-100123");
|
||||
});
|
||||
|
||||
it("denies command approvals before prompting when OpenClaw tool policy blocks", async () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
*/
|
||||
import {
|
||||
type AgentApprovalEventData,
|
||||
buildAgentHookContextChannelFields,
|
||||
formatApprovalDisplayPath,
|
||||
hasNativeHookRelayInvocation,
|
||||
invokeNativeHookRelay,
|
||||
@@ -16,7 +17,6 @@ import {
|
||||
type NativeHookRelayProcessResponse,
|
||||
type NativeHookRelayRegistrationHandle,
|
||||
runBeforeToolCallHook,
|
||||
type ToolHookRunContext,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
|
||||
import { normalizeTrimmedStringList } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
@@ -75,7 +75,6 @@ export async function handleCodexAppServerApprovalRequest(params: {
|
||||
method: string;
|
||||
requestParams: JsonValue | undefined;
|
||||
paramsForRun: EmbeddedRunAttemptParams;
|
||||
toolHookContext: ToolHookRunContext;
|
||||
threadId: string;
|
||||
turnId: string;
|
||||
nativeHookRelay?: Pick<
|
||||
@@ -107,7 +106,6 @@ export async function handleCodexAppServerApprovalRequest(params: {
|
||||
method: params.method,
|
||||
requestParams,
|
||||
paramsForRun: params.paramsForRun,
|
||||
toolHookContext: params.toolHookContext,
|
||||
context,
|
||||
nativeHookRelay: params.nativeHookRelay,
|
||||
signal: params.signal,
|
||||
@@ -621,7 +619,6 @@ async function runOpenClawToolPolicyForApprovalRequest(params: {
|
||||
method: string;
|
||||
requestParams: JsonObject | undefined;
|
||||
paramsForRun: EmbeddedRunAttemptParams;
|
||||
toolHookContext: ToolHookRunContext;
|
||||
context: ApprovalContext;
|
||||
nativeHookRelay?: Pick<
|
||||
NativeHookRelayRegistrationHandle,
|
||||
@@ -655,6 +652,13 @@ async function runOpenClawToolPolicyForApprovalRequest(params: {
|
||||
if (nativeRelayOutcome?.handled) {
|
||||
return { outcome: "no-decision" };
|
||||
}
|
||||
const hookChannelId = buildAgentHookContextChannelFields({
|
||||
sessionKey: params.paramsForRun.sessionKey,
|
||||
messageChannel: params.paramsForRun.messageChannel,
|
||||
messageProvider: params.paramsForRun.messageProvider,
|
||||
currentChannelId: params.paramsForRun.currentChannelId,
|
||||
messageTo: params.paramsForRun.messageTo,
|
||||
}).channelId;
|
||||
const outcome = await runBeforeToolCallHook({
|
||||
toolName: policyRequest.toolName,
|
||||
params: policyRequest.params,
|
||||
@@ -662,9 +666,13 @@ async function runOpenClawToolPolicyForApprovalRequest(params: {
|
||||
approvalMode: "request",
|
||||
signal: params.signal,
|
||||
ctx: {
|
||||
...params.toolHookContext,
|
||||
...(params.paramsForRun.agentId ? { agentId: params.paramsForRun.agentId } : {}),
|
||||
...(params.paramsForRun.config ? { config: params.paramsForRun.config } : {}),
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(params.paramsForRun.sessionKey ? { sessionKey: params.paramsForRun.sessionKey } : {}),
|
||||
...(params.paramsForRun.sessionId ? { sessionId: params.paramsForRun.sessionId } : {}),
|
||||
...(params.paramsForRun.runId ? { runId: params.paramsForRun.runId } : {}),
|
||||
...(hookChannelId ? { channelId: hookChannelId } : {}),
|
||||
},
|
||||
});
|
||||
if (outcome.blocked) {
|
||||
|
||||
@@ -944,16 +944,8 @@ describe("Codex app-server dynamic tool build", () => {
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.disableTools = false;
|
||||
params.messageChannel = "discord";
|
||||
params.messageProvider = "discord-voice";
|
||||
params.currentChannelId = "discord:D123";
|
||||
params.currentChannelId = "D123";
|
||||
params.currentMessagingTarget = "user:U123";
|
||||
params.chatId = "chat-123";
|
||||
params.senderId = "user-123";
|
||||
params.channelContext = {
|
||||
sender: { id: "user-123" },
|
||||
chat: { id: "chat-123" },
|
||||
};
|
||||
params.runtimePlan = createCodexRuntimePlanFixture();
|
||||
const factoryOptions: unknown[] = [];
|
||||
setOpenClawCodingToolsFactoryForTests((options) => {
|
||||
@@ -964,19 +956,9 @@ describe("Codex app-server dynamic tool build", () => {
|
||||
await buildDynamicToolsForTest(params, workspaceDir, { sandbox: null as never });
|
||||
|
||||
expect(factoryOptions[0]).toMatchObject({
|
||||
messageChannel: "discord",
|
||||
messageProvider: "discord",
|
||||
toolPolicyMessageProvider: "discord-voice",
|
||||
currentChannelId: "discord:D123",
|
||||
currentChannelId: "D123",
|
||||
currentMessagingTarget: "user:U123",
|
||||
chatId: "chat-123",
|
||||
senderId: "user-123",
|
||||
hookChannelContext: {
|
||||
sender: { id: "user-123" },
|
||||
chat: { id: "chat-123" },
|
||||
},
|
||||
});
|
||||
expect((factoryOptions[0] as { channelContext?: unknown }).channelContext).toBeUndefined();
|
||||
});
|
||||
|
||||
it("passes the approval reviewer device into Codex dynamic tools", async () => {
|
||||
|
||||
@@ -125,7 +125,7 @@ export function resolveCodexAppServerHookChannelId(
|
||||
messageChannel: params.messageChannel,
|
||||
messageProvider: params.messageProvider,
|
||||
currentChannelId: params.currentChannelId,
|
||||
messageTo: params.currentMessagingTarget ?? params.messageTo,
|
||||
messageTo: params.messageTo,
|
||||
}).channelId;
|
||||
}
|
||||
|
||||
@@ -239,7 +239,6 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
|
||||
elevated: params.bashElevated,
|
||||
},
|
||||
sandbox: input.sandbox,
|
||||
messageChannel: params.messageChannel,
|
||||
messageProvider: resolveCodexMessageToolProvider(params),
|
||||
toolPolicyMessageProvider: params.messageProvider ?? params.messageChannel,
|
||||
agentAccountId: params.agentAccountId,
|
||||
@@ -250,7 +249,6 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
|
||||
groupSpace: params.groupSpace,
|
||||
spawnedBy: params.spawnedBy,
|
||||
senderId: params.senderId,
|
||||
hookChannelContext: params.channelContext,
|
||||
senderName: params.senderName,
|
||||
senderUsername: params.senderUsername,
|
||||
senderE164: params.senderE164,
|
||||
@@ -292,7 +290,6 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
|
||||
),
|
||||
suppressManagedWebSearch: false,
|
||||
currentChannelId: params.currentChannelId,
|
||||
chatId: params.chatId,
|
||||
currentMessagingTarget: params.currentMessagingTarget,
|
||||
hookChannelId: resolveCodexAppServerHookChannelId(params, input.sandboxSessionKey),
|
||||
currentThreadTs: params.currentThreadTs,
|
||||
|
||||
@@ -1846,17 +1846,6 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:agent-1:session-1",
|
||||
runId: "run-1",
|
||||
jobId: "job-1",
|
||||
trigger: "user",
|
||||
messageProvider: "discord-voice",
|
||||
channel: "discord",
|
||||
chatId: "channel-1",
|
||||
senderId: "user-1",
|
||||
channelId: "channel-1",
|
||||
channelContext: {
|
||||
sender: { id: "user-1", displayName: "Ada" },
|
||||
chat: { id: "channel-1" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1960,17 +1949,6 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:agent-1:session-1",
|
||||
runId: "run-1",
|
||||
jobId: "job-1",
|
||||
trigger: "user",
|
||||
messageProvider: "discord-voice",
|
||||
channel: "discord",
|
||||
chatId: "channel-1",
|
||||
senderId: "user-1",
|
||||
channelId: "channel-1",
|
||||
channelContext: {
|
||||
sender: { id: "user-1", displayName: "Ada" },
|
||||
chat: { id: "channel-1" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1997,17 +1975,6 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:agent-1:session-1",
|
||||
runId: "run-1",
|
||||
jobId: "job-1",
|
||||
trigger: "user",
|
||||
messageProvider: "discord-voice",
|
||||
channel: "discord",
|
||||
chatId: "channel-1",
|
||||
senderId: "user-1",
|
||||
channelId: "channel-1",
|
||||
channelContext: {
|
||||
sender: { id: "user-1", displayName: "Ada" },
|
||||
chat: { id: "channel-1" },
|
||||
},
|
||||
toolCallId: "call-1",
|
||||
});
|
||||
expectExecuteCall(execute, { callId: "call-1", args: { command: "pwd", mode: "safe" } });
|
||||
@@ -2030,17 +1997,6 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:agent-1:session-1",
|
||||
runId: "run-1",
|
||||
jobId: "job-1",
|
||||
trigger: "user",
|
||||
messageProvider: "discord-voice",
|
||||
channel: "discord",
|
||||
chatId: "channel-1",
|
||||
senderId: "user-1",
|
||||
channelId: "channel-1",
|
||||
channelContext: {
|
||||
sender: { id: "user-1", displayName: "Ada" },
|
||||
chat: { id: "channel-1" },
|
||||
},
|
||||
toolCallId: "call-1",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
type HeartbeatToolResponse,
|
||||
type MessagingToolSend,
|
||||
type MessagingToolSourceReplyPayload,
|
||||
type ToolHookRunContext,
|
||||
wrapToolWithBeforeToolCallHook,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { emitTrustedDiagnosticEvent } from "openclaw/plugin-sdk/diagnostic-runtime";
|
||||
@@ -54,8 +53,13 @@ import type {
|
||||
JsonValue,
|
||||
} from "./protocol.js";
|
||||
|
||||
type CodexDynamicToolHookContext = ToolHookRunContext & {
|
||||
type CodexDynamicToolHookContext = {
|
||||
agentId?: string;
|
||||
config?: EmbeddedRunAttemptParams["config"];
|
||||
sessionId?: string;
|
||||
sessionKey?: string;
|
||||
runId?: string;
|
||||
channelId?: string;
|
||||
currentChannelProvider?: string;
|
||||
currentChannelId?: string;
|
||||
currentMessagingTarget?: string;
|
||||
@@ -66,7 +70,7 @@ type CodexDynamicToolHookContext = ToolHookRunContext & {
|
||||
allocateToolOutcomeOrdinal?: EmbeddedRunAttemptParams["allocateToolOutcomeOrdinal"];
|
||||
};
|
||||
|
||||
type CodexToolResultHookContext = ToolHookRunContext;
|
||||
type CodexToolResultHookContext = Omit<CodexDynamicToolHookContext, "config">;
|
||||
|
||||
type ProjectedCodexDynamicTool = {
|
||||
tool: AnyAgentTool;
|
||||
@@ -306,7 +310,11 @@ export function createCodexDynamicToolBridge(params: {
|
||||
void runAgentHarnessAfterToolCallHook({
|
||||
toolName,
|
||||
toolCallId: call.callId,
|
||||
...toolResultHookContext,
|
||||
runId: toolResultHookContext.runId,
|
||||
agentId: toolResultHookContext.agentId,
|
||||
sessionId: toolResultHookContext.sessionId,
|
||||
sessionKey: toolResultHookContext.sessionKey,
|
||||
channelId: toolResultHookContext.channelId,
|
||||
startArgs: executedArgs,
|
||||
result,
|
||||
startedAt,
|
||||
@@ -399,7 +407,11 @@ export function createCodexDynamicToolBridge(params: {
|
||||
void runAgentHarnessAfterToolCallHook({
|
||||
toolName,
|
||||
toolCallId: call.callId,
|
||||
...toolResultHookContext,
|
||||
runId: toolResultHookContext.runId,
|
||||
agentId: toolResultHookContext.agentId,
|
||||
sessionId: toolResultHookContext.sessionId,
|
||||
sessionKey: toolResultHookContext.sessionKey,
|
||||
channelId: toolResultHookContext.channelId,
|
||||
startArgs: executedArgs,
|
||||
error: errorMessage,
|
||||
startedAt,
|
||||
@@ -690,35 +702,13 @@ function dedupeQuarantinedDynamicTools(
|
||||
function toToolResultHookContext(
|
||||
ctx: CodexDynamicToolHookContext | undefined,
|
||||
): CodexToolResultHookContext {
|
||||
const {
|
||||
agentId,
|
||||
sessionId,
|
||||
sessionKey,
|
||||
runId,
|
||||
jobId,
|
||||
trace,
|
||||
trigger,
|
||||
messageProvider,
|
||||
channel,
|
||||
chatId,
|
||||
senderId,
|
||||
channelId,
|
||||
channelContext,
|
||||
} = ctx ?? {};
|
||||
const { agentId, sessionId, sessionKey, runId, channelId } = ctx ?? {};
|
||||
return {
|
||||
...(agentId && { agentId }),
|
||||
...(sessionId && { sessionId }),
|
||||
...(sessionKey && { sessionKey }),
|
||||
...(runId && { runId }),
|
||||
...(jobId && { jobId }),
|
||||
...(trace && { trace }),
|
||||
...(trigger && { trigger }),
|
||||
...(messageProvider && { messageProvider }),
|
||||
...(channel && { channel }),
|
||||
...(chatId && { chatId }),
|
||||
...(senderId && { senderId }),
|
||||
...(channelId && { channelId }),
|
||||
...(channelContext && { channelContext }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2587,36 +2587,15 @@ describe("CodexAppServerEventProjector", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps resolved hook identity authoritative for Codex-native tool completions", async () => {
|
||||
it("emits after_tool_call observations for Codex-native tool item completions", async () => {
|
||||
const afterToolCall = vi.fn();
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "after_tool_call", handler: afterToolCall }]),
|
||||
);
|
||||
const projectorParams = {
|
||||
const projector = await createProjector({
|
||||
...(await createParams()),
|
||||
agentId: "raw-agent",
|
||||
sessionId: "raw-session",
|
||||
sessionKey: "agent:raw:session-1",
|
||||
runId: "raw-run",
|
||||
};
|
||||
const projector = await createProjector(projectorParams, {
|
||||
toolHookContext: {
|
||||
agentId: "main",
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
runId: "run-1",
|
||||
jobId: "job-1",
|
||||
trigger: "user",
|
||||
messageProvider: "discord-voice",
|
||||
channel: "discord",
|
||||
chatId: "channel-1",
|
||||
senderId: "user-1",
|
||||
channelId: "channel-1",
|
||||
channelContext: {
|
||||
sender: { id: "user-1" },
|
||||
chat: { id: "channel-1" },
|
||||
},
|
||||
},
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:session-1",
|
||||
});
|
||||
|
||||
await projector.handleNotification(
|
||||
@@ -2673,17 +2652,6 @@ describe("CodexAppServerEventProjector", () => {
|
||||
expect(context.sessionId).toBe("session-1");
|
||||
expect(context.sessionKey).toBe("agent:main:session-1");
|
||||
expect(context.runId).toBe("run-1");
|
||||
expect(context.jobId).toBe("job-1");
|
||||
expect(context.trigger).toBe("user");
|
||||
expect(context.messageProvider).toBe("discord-voice");
|
||||
expect(context.channel).toBe("discord");
|
||||
expect(context.chatId).toBe("channel-1");
|
||||
expect(context.senderId).toBe("user-1");
|
||||
expect(context.channelId).toBe("channel-1");
|
||||
expect(context.channelContext).toEqual({
|
||||
sender: { id: "user-1" },
|
||||
chat: { id: "channel-1" },
|
||||
});
|
||||
expect(context.toolName).toBe("bash");
|
||||
expect(context.toolCallId).toBe("cmd-observed");
|
||||
});
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
type HeartbeatToolResponse,
|
||||
type MessagingToolSend,
|
||||
type MessagingToolSourceReplyPayload,
|
||||
type ToolHookRunContext,
|
||||
type ToolProgressDetailMode,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { emitTrustedDiagnosticEvent } from "openclaw/plugin-sdk/diagnostic-runtime";
|
||||
@@ -66,7 +65,6 @@ export type CodexAppServerToolTelemetry = {
|
||||
|
||||
export type CodexAppServerEventProjectorOptions = {
|
||||
nativePostToolUseRelayEnabled?: boolean;
|
||||
toolHookContext?: ToolHookRunContext;
|
||||
onNativeToolResultRecorded?: () => void | Promise<void>;
|
||||
trajectoryRecorder?: CodexTrajectoryRecorder | null;
|
||||
};
|
||||
@@ -1376,9 +1374,6 @@ export class CodexAppServerEventProjector {
|
||||
agentId: this.params.agentId,
|
||||
sessionId: this.params.sessionId,
|
||||
sessionKey: this.params.sessionKey,
|
||||
// The attempt boundary resolves aliases and sandbox session identity once.
|
||||
// Keep that canonical snapshot authoritative over optional raw projector params.
|
||||
...this.options.toolHookContext,
|
||||
startArgs: itemToolArgs(item) ?? {},
|
||||
...(result !== undefined ? { result } : {}),
|
||||
...(error ? { error } : {}),
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
type EmbeddedRunAttemptParams,
|
||||
type NativeHookRelayEvent,
|
||||
type NativeHookRelayRegistrationHandle,
|
||||
type ToolHookRunContext,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
addTimerTimeoutGraceMs,
|
||||
@@ -122,7 +121,6 @@ export function createCodexNativeHookRelay(params: {
|
||||
config: EmbeddedRunAttemptParams["config"];
|
||||
runId: string;
|
||||
channelId?: string;
|
||||
toolHookContext?: ToolHookRunContext;
|
||||
attemptTimeoutMs: number;
|
||||
startupTimeoutMs: number;
|
||||
turnStartTimeoutMs: number;
|
||||
@@ -148,7 +146,6 @@ export function createCodexNativeHookRelay(params: {
|
||||
...(params.config ? { config: params.config } : {}),
|
||||
runId: params.runId,
|
||||
...(params.channelId ? { channelId: params.channelId } : {}),
|
||||
...(params.toolHookContext ? { toolHookContext: params.toolHookContext } : {}),
|
||||
allowedEvents: params.events,
|
||||
ttlMs: resolveCodexNativeHookRelayTtlMs({
|
||||
explicitTtlMs: params.options?.ttlMs,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// Codex tests cover run attemptynamic tools plugin behavior.
|
||||
import path from "node:path";
|
||||
import { onAgentEvent, type AgentEventPayload } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
onAgentEvent,
|
||||
type AgentEventPayload,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
emitTrustedDiagnosticEvent,
|
||||
onInternalDiagnosticEvent,
|
||||
@@ -606,21 +609,6 @@ describe("runCodexAppServerAttempt dynamic tools", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("prefers the current messaging target for hook channel fallback", () => {
|
||||
const params = createParams(
|
||||
path.join(tempDir, "session.jsonl"),
|
||||
path.join(tempDir, "workspace"),
|
||||
);
|
||||
params.messageChannel = "telegram";
|
||||
params.messageProvider = "telegram";
|
||||
params.messageTo = "telegram:stale-target";
|
||||
params.currentMessagingTarget = "telegram:current-target";
|
||||
|
||||
expect(testing.resolveCodexAppServerHookChannelId(params, "agent:main:session-1")).toBe(
|
||||
"current-target",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes normalized channel context to app-server dynamic tool result hooks", async () => {
|
||||
const afterToolCall = vi.fn();
|
||||
initializeGlobalHookRunner(
|
||||
|
||||
@@ -30,7 +30,9 @@ const DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT = JSON.stringify({
|
||||
web_search: "disabled",
|
||||
});
|
||||
|
||||
function writeCodexAppServerBinding(...args: Parameters<typeof writeRawCodexAppServerBinding>) {
|
||||
function writeCodexAppServerBinding(
|
||||
...args: Parameters<typeof writeRawCodexAppServerBinding>
|
||||
) {
|
||||
const [sessionFile, binding, lookup] = args;
|
||||
return writeRawCodexAppServerBinding(
|
||||
sessionFile,
|
||||
@@ -93,15 +95,7 @@ describe("runCodexAppServerAttempt native hook relay", () => {
|
||||
const harness = createStartedThreadHarness();
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.messageChannel = "discord";
|
||||
params.messageProvider = "discord-voice";
|
||||
params.currentChannelId = "channel:target";
|
||||
params.trigger = "user";
|
||||
params.senderId = "user-1";
|
||||
params.chatId = "native-target";
|
||||
params.channelContext = {
|
||||
sender: { id: "user-1", providerUserId: "discord-user-1" },
|
||||
chat: { id: "native-target", guildId: "guild-1" },
|
||||
};
|
||||
|
||||
const run = runCodexAppServerAttempt(params, {
|
||||
nativeHookRelay: {
|
||||
@@ -141,22 +135,6 @@ describe("runCodexAppServerAttempt native hook relay", () => {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
autoApprove: true,
|
||||
toolHookContext: {
|
||||
agentId: "main",
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
runId: "run-1",
|
||||
trigger: "user",
|
||||
messageProvider: "discord-voice",
|
||||
channel: "discord",
|
||||
channelId: "target",
|
||||
chatId: "native-target",
|
||||
senderId: "user-1",
|
||||
channelContext: {
|
||||
sender: { id: "user-1", providerUserId: "discord-user-1" },
|
||||
chat: { id: "native-target", guildId: "guild-1" },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(approvalArgs?.nativeHookRelay).toMatchObject({
|
||||
relayId,
|
||||
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
type EmbeddedRunAttemptResult,
|
||||
type NativeHookRelayEvent,
|
||||
type NativeHookRelayRegistrationHandle,
|
||||
type ToolHookRunContext,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { resolveAgentDir } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import {
|
||||
@@ -249,7 +248,6 @@ import {
|
||||
type CodexAppServerThreadLifecycleBinding,
|
||||
type CodexContextEngineThreadBootstrapProjection,
|
||||
} from "./thread-lifecycle.js";
|
||||
import { buildCodexToolHookRunContext } from "./tool-hook-context.js";
|
||||
import {
|
||||
inferCodexDynamicToolMeta,
|
||||
resolveCodexToolProgressDetailMode,
|
||||
@@ -719,14 +717,6 @@ export async function runCodexAppServerAttempt(
|
||||
});
|
||||
}
|
||||
const hookChannelId = resolveCodexAppServerHookChannelId(params, sandboxSessionKey);
|
||||
const toolHookRunContext = buildCodexToolHookRunContext({
|
||||
attempt: params,
|
||||
agentId: sessionAgentId,
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: sandboxSessionKey,
|
||||
runId: params.runId,
|
||||
channelId: hookChannelId,
|
||||
});
|
||||
preDynamicStartupStages.mark("context-engine-support");
|
||||
const preDynamicSummary = preDynamicStartupStages.snapshot();
|
||||
if (shouldWarnCodexDynamicToolBuildStageSummary(preDynamicSummary)) {
|
||||
@@ -842,8 +832,12 @@ export async function runCodexAppServerAttempt(
|
||||
}),
|
||||
directToolNames: resolveCodexDynamicToolDirectNames(params),
|
||||
hookContext: {
|
||||
...toolHookRunContext,
|
||||
agentId: sessionAgentId,
|
||||
config: params.config,
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: sandboxSessionKey,
|
||||
runId: params.runId,
|
||||
channelId: hookChannelId,
|
||||
currentChannelProvider: resolveCodexMessageToolProvider(params),
|
||||
currentChannelId: params.currentChannelId,
|
||||
currentMessagingTarget: params.currentMessagingTarget,
|
||||
@@ -1450,7 +1444,6 @@ export async function runCodexAppServerAttempt(
|
||||
config: params.config,
|
||||
runId: params.runId,
|
||||
channelId: hookChannelId,
|
||||
toolHookContext: toolHookRunContext,
|
||||
attemptTimeoutMs: params.timeoutMs,
|
||||
startupTimeoutMs,
|
||||
turnStartTimeoutMs: params.timeoutMs,
|
||||
@@ -2157,7 +2150,6 @@ export async function runCodexAppServerAttempt(
|
||||
method: request.method,
|
||||
params: request.params,
|
||||
paramsForRun: params,
|
||||
toolHookContext: toolHookRunContext,
|
||||
threadId: thread.threadId,
|
||||
turnId,
|
||||
nativeHookRelay,
|
||||
@@ -2769,7 +2761,6 @@ export async function runCodexAppServerAttempt(
|
||||
nativePostToolUseRelayEnabled:
|
||||
nativeHookRelay?.allowedEvents.includes("post_tool_use") === true &&
|
||||
nativeHookRelay.shouldRelayEvent("post_tool_use"),
|
||||
toolHookContext: toolHookRunContext,
|
||||
trajectoryRecorder,
|
||||
onNativeToolResultRecorded: maybeAnnounceFastModeAutoOff,
|
||||
},
|
||||
@@ -3439,7 +3430,6 @@ function handleApprovalRequest(params: {
|
||||
method: string;
|
||||
params: JsonValue | undefined;
|
||||
paramsForRun: EmbeddedRunAttemptParams;
|
||||
toolHookContext: ToolHookRunContext;
|
||||
threadId: string;
|
||||
turnId: string;
|
||||
nativeHookRelay?: NativeHookRelayRegistrationHandle;
|
||||
@@ -3453,7 +3443,6 @@ function handleApprovalRequest(params: {
|
||||
method: params.method,
|
||||
requestParams: params.params,
|
||||
paramsForRun: params.paramsForRun,
|
||||
toolHookContext: params.toolHookContext,
|
||||
threadId: params.threadId,
|
||||
turnId: params.turnId,
|
||||
nativeHookRelay: params.nativeHookRelay,
|
||||
|
||||
@@ -861,12 +861,9 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
).toMatchObject({
|
||||
agentId: "main",
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:runtime-policy",
|
||||
sessionKey: "agent:main:session-1",
|
||||
runId: "run-side-1",
|
||||
channelId: "voice-room",
|
||||
toolHookContext: {
|
||||
sessionKey: "agent:main:runtime-policy",
|
||||
},
|
||||
allowedEvents: ["pre_tool_use", "post_tool_use", "before_agent_finalize"],
|
||||
});
|
||||
return threadResult("side-thread");
|
||||
@@ -892,7 +889,6 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
runCodexAppServerSideQuestion(
|
||||
sideParams({
|
||||
sessionKey: "agent:main:session-1",
|
||||
sandboxSessionKey: "agent:main:runtime-policy",
|
||||
messageChannel: "discord",
|
||||
messageProvider: "discord-voice",
|
||||
currentChannelId: "discord:voice-room",
|
||||
@@ -975,7 +971,6 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
runCodexAppServerSideQuestion(
|
||||
sideParams({
|
||||
sessionKey: "agent:main:session-1",
|
||||
sandboxSessionKey: "agent:main:runtime-policy",
|
||||
messageChannel: "discord",
|
||||
messageProvider: "discord-voice",
|
||||
opts: { runId: "run-side-approval" },
|
||||
@@ -993,7 +988,6 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
threadId?: string;
|
||||
turnId?: string;
|
||||
paramsForRun?: { messageChannel?: string; messageProvider?: string };
|
||||
toolHookContext?: { sessionKey?: string };
|
||||
nativeHookRelay?: { relayId?: string; allowedEvents?: readonly string[] };
|
||||
}
|
||||
| undefined;
|
||||
@@ -1013,9 +1007,6 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
messageChannel: "discord",
|
||||
messageProvider: "discord-voice",
|
||||
},
|
||||
toolHookContext: {
|
||||
sessionKey: "agent:main:runtime-policy",
|
||||
},
|
||||
});
|
||||
expect(approvalArgs?.nativeHookRelay).toMatchObject({
|
||||
relayId: relayIdDuringFork,
|
||||
@@ -1491,14 +1482,6 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
});
|
||||
|
||||
it("bridges side-thread dynamic tool requests to OpenClaw tools", async () => {
|
||||
const beforeToolCall = vi.fn();
|
||||
const afterToolCall = vi.fn();
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([
|
||||
{ hookName: "before_tool_call", handler: beforeToolCall },
|
||||
{ hookName: "after_tool_call", handler: afterToolCall },
|
||||
]),
|
||||
);
|
||||
const client = createFakeClient();
|
||||
let toolResponse: unknown;
|
||||
client.request.mockImplementation(async (method: string) => {
|
||||
@@ -1544,13 +1527,6 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
expect(toolArguments).toEqual({ topic: "AGENTS.md" });
|
||||
expect(toolSignal).toBeInstanceOf(AbortSignal);
|
||||
expect(toolOptions).toBeUndefined();
|
||||
expect(beforeToolCall).toHaveBeenCalledTimes(1);
|
||||
expect(mockCall(beforeToolCall)[1]).toMatchObject({ sessionKey: "session-1" });
|
||||
await vi.waitFor(() => expect(afterToolCall).toHaveBeenCalledTimes(1));
|
||||
expect(mockCall(afterToolCall)[1]).toMatchObject({ sessionKey: "session-1" });
|
||||
expect(createOpenClawCodingToolsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sessionKey: "session-1" }),
|
||||
);
|
||||
expect(toolResponse).toEqual({
|
||||
success: true,
|
||||
contentItems: [{ type: "inputText", text: "tool output" }],
|
||||
@@ -1634,29 +1610,14 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
expect(activeDiagnosticToolKeys(diagnosticEvents)).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("preserves requester identity while normalizing side-thread hook channels", async () => {
|
||||
const afterToolCall = vi.fn();
|
||||
it("normalizes hook channel ids for side-thread dynamic tool requests", async () => {
|
||||
const beforeToolCall = vi.fn((...args: unknown[]) => {
|
||||
const context = args[1] as Record<string, unknown>;
|
||||
expect(context).toMatchObject({
|
||||
sessionKey: "agent:main:runtime-policy",
|
||||
messageProvider: "discord-voice",
|
||||
channel: "discord",
|
||||
channelId: "voice-room",
|
||||
chatId: "native-voice-chat",
|
||||
senderId: "sender-1",
|
||||
channelContext: {
|
||||
sender: { id: "sender-1", providerUserId: "discord-user-1" },
|
||||
chat: { id: "native-voice-chat", guildId: "guild-1" },
|
||||
},
|
||||
});
|
||||
const context = args[1] as { channelId?: string };
|
||||
expect(context.channelId).toBe("voice-room");
|
||||
return undefined;
|
||||
});
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([
|
||||
{ hookName: "before_tool_call", handler: beforeToolCall },
|
||||
{ hookName: "after_tool_call", handler: afterToolCall },
|
||||
]),
|
||||
createMockPluginRegistry([{ hookName: "before_tool_call", handler: beforeToolCall }]),
|
||||
);
|
||||
const client = createFakeClient();
|
||||
client.request.mockImplementation(async (method: string) => {
|
||||
@@ -1696,48 +1657,17 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
await expect(
|
||||
runCodexAppServerSideQuestion(
|
||||
sideParams({
|
||||
sessionKey: "agent:main:conversation",
|
||||
sandboxSessionKey: "agent:main:runtime-policy",
|
||||
messageChannel: "discord",
|
||||
messageProvider: "discord-voice",
|
||||
currentChannelId: "discord:voice-room",
|
||||
chatId: "native-voice-chat",
|
||||
senderId: "sender-1",
|
||||
channelContext: {
|
||||
sender: { id: "sender-1", providerUserId: "discord-user-1" },
|
||||
chat: { id: "native-voice-chat", guildId: "guild-1" },
|
||||
},
|
||||
}),
|
||||
),
|
||||
).resolves.toEqual({ text: "Tool answer." });
|
||||
|
||||
expect(beforeToolCall).toHaveBeenCalledTimes(1);
|
||||
await vi.waitFor(() => expect(afterToolCall).toHaveBeenCalledTimes(1));
|
||||
expect(mockCall(afterToolCall)[1]).toMatchObject({
|
||||
sessionKey: "agent:main:runtime-policy",
|
||||
messageProvider: "discord-voice",
|
||||
channel: "discord",
|
||||
channelId: "voice-room",
|
||||
chatId: "native-voice-chat",
|
||||
});
|
||||
expect(createOpenClawCodingToolsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionKey: "agent:main:runtime-policy",
|
||||
runSessionKey: "agent:main:conversation",
|
||||
messageChannel: "discord",
|
||||
messageProvider: "discord",
|
||||
toolPolicyMessageProvider: "discord-voice",
|
||||
hookChannelId: "voice-room",
|
||||
chatId: "native-voice-chat",
|
||||
hookChannelContext: {
|
||||
sender: { id: "sender-1", providerUserId: "discord-user-1" },
|
||||
chat: { id: "native-voice-chat", guildId: "guild-1" },
|
||||
},
|
||||
}),
|
||||
expect.objectContaining({ hookChannelId: "voice-room" }),
|
||||
);
|
||||
expect(
|
||||
(mockCall(createOpenClawCodingToolsMock)[0] as { channelContext?: unknown }).channelContext,
|
||||
).toBeUndefined();
|
||||
expect(toolExecuteMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Codex plugin module implements side question behavior.
|
||||
import {
|
||||
buildAgentHookContextChannelFields,
|
||||
embeddedAgentLog,
|
||||
formatErrorMessage,
|
||||
resolveAgentDir,
|
||||
@@ -15,7 +16,6 @@ import {
|
||||
type EmbeddedRunAttemptParams,
|
||||
type NativeHookRelayEvent,
|
||||
type NativeHookRelayRegistrationHandle,
|
||||
type ToolHookRunContext,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { loadExecApprovals } from "openclaw/plugin-sdk/exec-approvals-runtime";
|
||||
import { resolveCodexAppServerForModelProvider } from "./app-server-policy.js";
|
||||
@@ -89,7 +89,6 @@ import {
|
||||
resolveCodexBindingModelProviderFallback,
|
||||
resolveReasoningEffort,
|
||||
} from "./thread-lifecycle.js";
|
||||
import { buildCodexToolHookRunContext } from "./tool-hook-context.js";
|
||||
import { filterToolsForVisionInputs } from "./vision-tools.js";
|
||||
import {
|
||||
resolveCodexWebSearchPlan,
|
||||
@@ -207,21 +206,9 @@ export async function runCodexAppServerSideQuestion(
|
||||
});
|
||||
const cwd = binding.cwd || params.workspaceDir || process.cwd();
|
||||
const sideRunParams = buildSideRunAttemptParams(params, { cwd, authProfileId });
|
||||
const toolHookSessionKey =
|
||||
sideRunParams.sandboxSessionKey?.trim() ||
|
||||
sideRunParams.sessionKey?.trim() ||
|
||||
sideRunParams.sessionId ||
|
||||
sessionAgentId;
|
||||
const toolHookRunContext = buildCodexToolHookRunContext({
|
||||
attempt: sideRunParams,
|
||||
agentId: sessionAgentId,
|
||||
sessionId: sideRunParams.sessionId,
|
||||
sessionKey: toolHookSessionKey,
|
||||
runId: sideRunParams.runId,
|
||||
});
|
||||
const nativeExecutionBlock = resolveCodexNativeExecutionBlock({
|
||||
config: sideRunParams.config,
|
||||
sessionKey: toolHookSessionKey,
|
||||
sessionKey: sideRunParams.sandboxSessionKey?.trim() || sideRunParams.sessionKey,
|
||||
sessionId: sideRunParams.sessionId,
|
||||
surface: "/btw side-question mode",
|
||||
});
|
||||
@@ -300,7 +287,6 @@ export async function runCodexAppServerSideQuestion(
|
||||
nativeToolSurfaceEnabled,
|
||||
nativeProviderWebSearchSupport,
|
||||
signal: runAbortController.signal,
|
||||
toolHookContext: toolHookRunContext,
|
||||
});
|
||||
removeRequestHandler = client.addRequestHandler(async (request) => {
|
||||
if (request.method === "account/chatgptAuthTokens/refresh") {
|
||||
@@ -333,20 +319,19 @@ export async function runCodexAppServerSideQuestion(
|
||||
method: request.method,
|
||||
requestParams: request.params,
|
||||
paramsForRun: sideRunParams,
|
||||
toolHookContext: toolHookRunContext,
|
||||
threadId: childThreadId,
|
||||
turnId,
|
||||
nativeHookRelay,
|
||||
execPolicy,
|
||||
execReviewerAgentId: sessionAgentId,
|
||||
internalExecAutoReview: modelScopedAppServer.approvalsReviewer === "user",
|
||||
autoApprove: shouldAutoApproveCodexAppServerApprovals({
|
||||
approvalPolicy,
|
||||
networkProxy: modelScopedAppServer.networkProxy,
|
||||
sandbox,
|
||||
}),
|
||||
signal: runAbortController.signal,
|
||||
});
|
||||
execPolicy,
|
||||
execReviewerAgentId: sessionAgentId,
|
||||
internalExecAutoReview: modelScopedAppServer.approvalsReviewer === "user",
|
||||
autoApprove: shouldAutoApproveCodexAppServerApprovals({
|
||||
approvalPolicy,
|
||||
networkProxy: modelScopedAppServer.networkProxy,
|
||||
sandbox,
|
||||
}),
|
||||
signal: runAbortController.signal,
|
||||
});
|
||||
}
|
||||
if (request.method !== "item/tool/call") {
|
||||
return undefined;
|
||||
@@ -403,11 +388,15 @@ export async function runCodexAppServerSideQuestion(
|
||||
events: nativeHookRelayEvents,
|
||||
agentId: sessionAgentId,
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: toolHookRunContext.sessionKey,
|
||||
sessionKey: params.sessionKey,
|
||||
config: params.cfg,
|
||||
runId: sideRunParams.runId,
|
||||
channelId: toolHookRunContext.channelId,
|
||||
toolHookContext: toolHookRunContext,
|
||||
channelId: buildAgentHookContextChannelFields({
|
||||
sessionKey: params.sessionKey,
|
||||
messageChannel: params.messageChannel,
|
||||
messageProvider: params.messageProvider,
|
||||
currentChannelId: params.currentChannelId,
|
||||
}).channelId,
|
||||
requestTimeoutMs: appServer.requestTimeoutMs,
|
||||
completionTimeoutMs: Math.max(
|
||||
appServer.turnCompletionIdleTimeoutMs,
|
||||
@@ -430,12 +419,12 @@ export async function runCodexAppServerSideQuestion(
|
||||
nativeCodeModeEnabled: nativeToolSurfaceEnabled,
|
||||
nativeCodeModeOnlyEnabled: appServer.codeModeOnly,
|
||||
});
|
||||
const threadConfig =
|
||||
mergeCodexThreadConfigs(
|
||||
nativeHookRelayConfig,
|
||||
runtimeThreadConfig,
|
||||
modelScopedAppServer.networkProxy?.configPatch,
|
||||
) ?? runtimeThreadConfig;
|
||||
const threadConfig =
|
||||
mergeCodexThreadConfigs(
|
||||
nativeHookRelayConfig,
|
||||
runtimeThreadConfig,
|
||||
modelScopedAppServer.networkProxy?.configPatch,
|
||||
) ?? runtimeThreadConfig;
|
||||
const forkResponse = assertCodexThreadForkResponse(
|
||||
await forkCodexSideThread(
|
||||
client,
|
||||
@@ -447,7 +436,7 @@ export async function runCodexAppServerSideQuestion(
|
||||
cwd,
|
||||
approvalPolicy,
|
||||
approvalsReviewer: modelScopedAppServer.approvalsReviewer,
|
||||
...(modelScopedAppServer.networkProxy ? {} : { sandbox }),
|
||||
...(modelScopedAppServer.networkProxy ? {} : { sandbox }),
|
||||
...(serviceTier ? { serviceTier } : {}),
|
||||
config: threadConfig,
|
||||
developerInstructions: SIDE_DEVELOPER_INSTRUCTIONS,
|
||||
@@ -553,7 +542,6 @@ function registerCodexSideNativeHookRelay(params: {
|
||||
config: EmbeddedRunAttemptParams["config"];
|
||||
runId: string;
|
||||
channelId?: string;
|
||||
toolHookContext?: ToolHookRunContext;
|
||||
requestTimeoutMs: number;
|
||||
completionTimeoutMs: number;
|
||||
signal: AbortSignal;
|
||||
@@ -569,7 +557,6 @@ function registerCodexSideNativeHookRelay(params: {
|
||||
...(params.config ? { config: params.config } : {}),
|
||||
runId: params.runId,
|
||||
...(params.channelId ? { channelId: params.channelId } : {}),
|
||||
...(params.toolHookContext ? { toolHookContext: params.toolHookContext } : {}),
|
||||
allowedEvents: params.events,
|
||||
ttlMs: resolveCodexSideNativeHookRelayTtlMs({
|
||||
explicitTtlMs: params.options.ttlMs,
|
||||
@@ -609,7 +596,6 @@ function buildSideRunAttemptParams(
|
||||
provider: params.provider,
|
||||
modelId: params.model,
|
||||
model: params.runtimeModel ?? ({ id: params.model, provider: params.provider } as never),
|
||||
trigger: "user" as const,
|
||||
sessionId: params.sessionId,
|
||||
sessionFile: params.sessionFile,
|
||||
sessionKey: params.sessionKey,
|
||||
@@ -630,8 +616,6 @@ function buildSideRunAttemptParams(
|
||||
...(params.senderUsername !== undefined ? { senderUsername: params.senderUsername } : {}),
|
||||
...(params.senderE164 !== undefined ? { senderE164: params.senderE164 } : {}),
|
||||
...(params.senderIsOwner !== undefined ? { senderIsOwner: params.senderIsOwner } : {}),
|
||||
...(params.chatId ? { chatId: params.chatId } : {}),
|
||||
...(params.channelContext ? { channelContext: params.channelContext } : {}),
|
||||
...(params.currentChannelId ? { currentChannelId: params.currentChannelId } : {}),
|
||||
...(params.toolsAllow ? { toolsAllow: params.toolsAllow } : {}),
|
||||
workspaceDir: options.cwd,
|
||||
@@ -663,7 +647,6 @@ async function createCodexSideToolBridge(input: {
|
||||
nativeToolSurfaceEnabled: boolean;
|
||||
nativeProviderWebSearchSupport: CodexNativeWebSearchSupport;
|
||||
signal: AbortSignal;
|
||||
toolHookContext: ToolHookRunContext;
|
||||
}): Promise<{ toolBridge: CodexDynamicToolBridge; webSearchPlan: CodexWebSearchPlan }> {
|
||||
const runtimeModel =
|
||||
input.params.runtimeModel ??
|
||||
@@ -674,7 +657,10 @@ async function createCodexSideToolBridge(input: {
|
||||
const createOpenClawCodingTools = (await import("openclaw/plugin-sdk/agent-harness"))
|
||||
.createOpenClawCodingTools;
|
||||
const sandboxSessionKey =
|
||||
input.toolHookContext.sessionKey || input.params.sessionId || input.sessionAgentId;
|
||||
input.params.sandboxSessionKey?.trim() ||
|
||||
input.params.sessionKey?.trim() ||
|
||||
input.params.sessionId ||
|
||||
input.sessionAgentId;
|
||||
const sandbox = await resolveSandboxContext({
|
||||
config: input.params.cfg,
|
||||
sessionKey: sandboxSessionKey,
|
||||
@@ -710,9 +696,6 @@ async function createCodexSideToolBridge(input: {
|
||||
workspaceDir: input.cwd,
|
||||
}),
|
||||
suppressManagedWebSearch: false,
|
||||
trigger: input.toolHookContext.trigger,
|
||||
jobId: input.toolHookContext.jobId,
|
||||
messageChannel: input.params.messageChannel,
|
||||
...(input.params.messageProvider || input.params.messageChannel
|
||||
? {
|
||||
messageProvider: messageToolProvider,
|
||||
@@ -732,8 +715,6 @@ async function createCodexSideToolBridge(input: {
|
||||
...(input.params.memberRoleIds ? { memberRoleIds: input.params.memberRoleIds } : {}),
|
||||
...(input.params.spawnedBy !== undefined ? { spawnedBy: input.params.spawnedBy } : {}),
|
||||
...(input.params.senderId !== undefined ? { senderId: input.params.senderId } : {}),
|
||||
chatId: input.toolHookContext.chatId,
|
||||
hookChannelContext: input.toolHookContext.channelContext,
|
||||
...(input.params.senderName !== undefined ? { senderName: input.params.senderName } : {}),
|
||||
...(input.params.senderUsername !== undefined
|
||||
? { senderUsername: input.params.senderUsername }
|
||||
@@ -743,7 +724,12 @@ async function createCodexSideToolBridge(input: {
|
||||
? { senderIsOwner: input.params.senderIsOwner }
|
||||
: {}),
|
||||
...(input.params.currentChannelId ? { currentChannelId: input.params.currentChannelId } : {}),
|
||||
hookChannelId: input.toolHookContext.channelId,
|
||||
hookChannelId: buildAgentHookContextChannelFields({
|
||||
sessionKey: input.params.sessionKey,
|
||||
messageChannel: input.params.messageChannel,
|
||||
messageProvider: input.params.messageProvider,
|
||||
currentChannelId: input.params.currentChannelId,
|
||||
}).channelId,
|
||||
sandbox,
|
||||
emitBeforeToolCallDiagnostics: false,
|
||||
modelHasVision: runtimeModel.input?.includes("image") ?? false,
|
||||
@@ -771,15 +757,25 @@ async function createCodexSideToolBridge(input: {
|
||||
})
|
||||
: requestedWebSearchPlan;
|
||||
const exposedTools = tools.filter((tool) => tool.name !== "web_search");
|
||||
const hookChannelFields = buildAgentHookContextChannelFields({
|
||||
sessionKey: input.params.sessionKey,
|
||||
messageChannel: input.params.messageChannel,
|
||||
messageProvider: input.params.messageProvider,
|
||||
currentChannelId: input.params.currentChannelId,
|
||||
});
|
||||
return {
|
||||
toolBridge: createCodexDynamicToolBridge({
|
||||
tools: exposedTools,
|
||||
signal: input.signal,
|
||||
loading: resolveCodexDynamicToolsLoading(input.pluginConfig),
|
||||
hookContext: {
|
||||
...input.toolHookContext,
|
||||
agentId: input.sessionAgentId,
|
||||
config: input.params.cfg,
|
||||
sessionId: input.params.sessionId,
|
||||
sessionKey: input.params.sessionKey,
|
||||
runId: input.params.opts?.runId ?? `codex-btw:${input.params.sessionId}`,
|
||||
currentChannelProvider: messageToolProvider,
|
||||
...hookChannelFields,
|
||||
},
|
||||
}),
|
||||
webSearchPlan,
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
/** Builds one canonical requester-origin snapshot for Codex tool hook paths. */
|
||||
import {
|
||||
buildAgentHookContextOriginFields,
|
||||
type EmbeddedRunAttemptParams,
|
||||
type ToolHookRunContext,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
|
||||
/** Build the plain run metadata shared by Codex before/after tool hook owners. */
|
||||
export function buildCodexToolHookRunContext(params: {
|
||||
attempt: EmbeddedRunAttemptParams;
|
||||
agentId?: string;
|
||||
sessionId?: string;
|
||||
sessionKey?: string;
|
||||
runId?: string;
|
||||
channelId?: string;
|
||||
}): ToolHookRunContext {
|
||||
const attempt = params.attempt;
|
||||
const agentId = params.agentId ?? attempt.agentId;
|
||||
const sessionKey = params.sessionKey ?? attempt.sessionKey;
|
||||
const sessionId = params.sessionId ?? attempt.sessionId;
|
||||
const runId = params.runId ?? attempt.runId;
|
||||
return {
|
||||
...(agentId ? { agentId } : {}),
|
||||
...(sessionKey ? { sessionKey } : {}),
|
||||
...(sessionId ? { sessionId } : {}),
|
||||
...(runId ? { runId } : {}),
|
||||
...(attempt.jobId ? { jobId: attempt.jobId } : {}),
|
||||
...(attempt.trigger ? { trigger: attempt.trigger } : {}),
|
||||
...buildAgentHookContextOriginFields({
|
||||
sessionKey,
|
||||
messageChannel: attempt.messageChannel,
|
||||
messageProvider: attempt.messageProvider ?? attempt.messageChannel,
|
||||
currentChannelId: params.channelId ?? attempt.currentChannelId,
|
||||
messageTo: attempt.currentMessagingTarget ?? attempt.messageTo,
|
||||
trigger: attempt.trigger,
|
||||
senderId: attempt.senderId,
|
||||
chatId: attempt.chatId,
|
||||
channelContext: attempt.channelContext,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -340,22 +340,7 @@ describe("runCopilotAttempt", () => {
|
||||
return { sdkTools: [], sourceTools: [] };
|
||||
});
|
||||
|
||||
const params = makeParams();
|
||||
Object.assign(params, {
|
||||
jobId: "job-1",
|
||||
trigger: "user",
|
||||
messageChannel: "slack",
|
||||
messageProvider: "slack-voice",
|
||||
currentChannelId: "C123",
|
||||
chatId: "C123",
|
||||
senderId: "U123",
|
||||
channelContext: {
|
||||
sender: { id: "U123", displayName: "Ada" },
|
||||
chat: { id: "C123" },
|
||||
},
|
||||
});
|
||||
|
||||
await runCopilotAttempt(params, {
|
||||
await runCopilotAttempt(makeParams(), {
|
||||
createToolBridge,
|
||||
pool: makeFakePool(sdk),
|
||||
});
|
||||
@@ -402,21 +387,7 @@ describe("runCopilotAttempt", () => {
|
||||
toolCallId: "tool-call-1",
|
||||
toolName: "read",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
agentId: "agent-1",
|
||||
sessionId: "session-1",
|
||||
jobId: "job-1",
|
||||
trigger: "user",
|
||||
messageProvider: "slack-voice",
|
||||
channel: "slack",
|
||||
chatId: "C123",
|
||||
senderId: "U123",
|
||||
channelId: "C123",
|
||||
channelContext: {
|
||||
sender: { id: "U123", displayName: "Ada" },
|
||||
chat: { id: "C123" },
|
||||
},
|
||||
}),
|
||||
expect.objectContaining({ agentId: "agent-1", sessionId: "session-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1316,32 +1287,6 @@ describe("runCopilotAttempt", () => {
|
||||
).toBe(sdkTools);
|
||||
});
|
||||
|
||||
it("passes the session-resolved agent id to the tool bridge", async () => {
|
||||
const sdk = makeFakeSdk();
|
||||
const pool = makeFakePool(sdk);
|
||||
const createToolBridge = vi.fn(async () => ({ sdkTools: [], sourceTools: [] }));
|
||||
|
||||
await runCopilotAttempt(
|
||||
makeParams({
|
||||
agentId: undefined,
|
||||
sessionKey: "agent:beta:main",
|
||||
config: {
|
||||
agents: {
|
||||
list: [{ id: "main" }, { id: "beta" }],
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
{ createToolBridge, pool },
|
||||
);
|
||||
|
||||
expect(createToolBridge).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentId: "beta",
|
||||
sessionKey: "agent:beta:main",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("F6: sessionRef is populated after createSession so the tool bridge's onYield can abort the live SDK session", async () => {
|
||||
const sdk = makeFakeSdk();
|
||||
const pool = makeFakePool(sdk);
|
||||
|
||||
@@ -9,7 +9,6 @@ import type {
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
buildAgentHookContextChannelFields,
|
||||
buildAgentHookContextOriginFields,
|
||||
detectAndLoadAgentHarnessPromptImages,
|
||||
getModelProviderRequestTransport,
|
||||
resolveAgentHarnessBeforePromptBuildResult,
|
||||
@@ -410,25 +409,6 @@ export async function runCopilotAttempt(
|
||||
...hookContextWindowFields,
|
||||
...buildAgentHookContextChannelFields(input),
|
||||
};
|
||||
const toolHookRunContext = {
|
||||
runId: input.runId,
|
||||
jobId: input.jobId,
|
||||
agentId: sessionAgentId,
|
||||
sessionKey: sandboxSessionKey,
|
||||
sessionId: input.sessionId,
|
||||
trigger: input.trigger,
|
||||
...buildAgentHookContextOriginFields({
|
||||
sessionKey: sandboxSessionKey,
|
||||
messageChannel: input.messageChannel,
|
||||
messageProvider: input.messageProvider ?? input.messageChannel,
|
||||
currentChannelId: input.currentChannelId,
|
||||
messageTo: input.currentMessagingTarget ?? input.messageTo,
|
||||
trigger: input.trigger,
|
||||
senderId: input.senderId,
|
||||
chatId: input.chatId,
|
||||
channelContext: input.channelContext,
|
||||
}),
|
||||
};
|
||||
const finishAttempt = (result: AgentHarnessAttemptResult) =>
|
||||
finalizeCopilotAttempt(input, result, hookContext, attemptStartedAt, now);
|
||||
|
||||
@@ -646,7 +626,7 @@ export async function runCopilotAttempt(
|
||||
allowModelTools: poolAcquire.provider.mode === "byok",
|
||||
modelProvider: modelRef.provider,
|
||||
modelId: modelRef.id,
|
||||
agentId: sessionAgentId,
|
||||
agentId: readString(params.agentId) ?? "copilot",
|
||||
sessionId: readString(input.sessionId) ?? "copilot-session",
|
||||
sessionKey: readString((input as { sessionKey?: unknown }).sessionKey),
|
||||
agentDir: readString(input.agentDir),
|
||||
@@ -672,7 +652,11 @@ export async function runCopilotAttempt(
|
||||
runAgentHarnessAfterToolCallHook({
|
||||
toolName,
|
||||
toolCallId,
|
||||
...toolHookRunContext,
|
||||
runId: input.runId,
|
||||
agentId: sessionAgentId,
|
||||
sessionId: input.sessionId,
|
||||
sessionKey: sandboxSessionKey,
|
||||
channelId: hookContext.channelId,
|
||||
startArgs: args,
|
||||
...(result !== undefined ? { result } : {}),
|
||||
...(error ? { error } : {}),
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
// Copilot tests cover tool bridge plugin behavior.
|
||||
import type { Tool as SdkTool, ToolInvocation, ToolResultObject } from "@github/copilot-sdk";
|
||||
import type { AnyAgentTool, SandboxContext } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
initializeGlobalHookRunner,
|
||||
resetGlobalHookRunner,
|
||||
} from "openclaw/plugin-sdk/hook-runtime";
|
||||
import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createCopilotToolBridge,
|
||||
convertOpenClawToolToSdkTool,
|
||||
supportsModelTools,
|
||||
testing,
|
||||
} from "./tool-bridge.js";
|
||||
|
||||
type FakeTool = AnyAgentTool & {
|
||||
@@ -83,7 +77,6 @@ function runSdkTool(tool: SdkTool, args: unknown, invocation = makeInvocation())
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
resetGlobalHookRunner();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -316,79 +309,6 @@ describe("createCopilotToolBridge", () => {
|
||||
expect(result.sdkTools.map((tool) => tool.name)).toEqual(["exec", "wait"]);
|
||||
});
|
||||
|
||||
it("runs requester-aware policy before code-mode exec controls", async () => {
|
||||
const beforeToolCall = vi.fn(() => ({
|
||||
block: true,
|
||||
blockReason: "blocked before code-mode execution",
|
||||
}));
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "before_tool_call", handler: beforeToolCall }]),
|
||||
);
|
||||
const createOpenClawCodingTools = vi.fn(async () => [makeTool({ name: "read" })]);
|
||||
|
||||
const result = await createCopilotToolBridge({
|
||||
agentId: "agent-1",
|
||||
attemptParams: {
|
||||
config: { tools: { codeMode: true } },
|
||||
runId: "run-code-mode",
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:main",
|
||||
jobId: "job-1",
|
||||
trigger: "user",
|
||||
messageChannel: "slack",
|
||||
messageProvider: "slack-voice",
|
||||
currentChannelId: "slack:C123",
|
||||
senderId: "U123",
|
||||
channelContext: { sender: { id: "U123", displayName: "Ada" } },
|
||||
} as never,
|
||||
createOpenClawCodingTools,
|
||||
modelId: "gpt-4o",
|
||||
modelProvider: "github-copilot",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
const exec = result.sdkTools.find((tool) => tool.name === "exec");
|
||||
if (!exec) {
|
||||
throw new Error("missing code-mode exec control");
|
||||
}
|
||||
|
||||
await runSdkTool(
|
||||
exec,
|
||||
{ code: "return 1;" },
|
||||
makeInvocation({ toolCallId: "code-call-1", toolName: "exec" }),
|
||||
);
|
||||
|
||||
expect(beforeToolCall).toHaveBeenCalledTimes(1);
|
||||
expect(beforeToolCall).toHaveBeenCalledWith(
|
||||
{
|
||||
toolName: "exec",
|
||||
params: { code: "return 1;", command: "return 1;" },
|
||||
toolKind: "code_mode_exec",
|
||||
toolInputKind: "javascript",
|
||||
runId: "run-code-mode",
|
||||
toolCallId: "code-call-1",
|
||||
},
|
||||
{
|
||||
toolName: "exec",
|
||||
toolKind: "code_mode_exec",
|
||||
toolInputKind: "javascript",
|
||||
agentId: "agent-1",
|
||||
sessionKey: "agent:main:main",
|
||||
sessionId: "session-1",
|
||||
runId: "run-code-mode",
|
||||
jobId: "job-1",
|
||||
trigger: "user",
|
||||
messageProvider: "slack-voice",
|
||||
channel: "slack",
|
||||
senderId: "U123",
|
||||
toolCallId: "code-call-1",
|
||||
channelId: "C123",
|
||||
channelContext: {
|
||||
sender: { id: "U123", displayName: "Ada" },
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps code-mode controls visible when a narrow allowlist is active", async () => {
|
||||
const createOpenClawCodingTools = vi.fn(async () => [
|
||||
makeTool({ name: "fake_hidden" }),
|
||||
@@ -523,13 +443,7 @@ describe("createCopilotToolBridge", () => {
|
||||
currentMessagingTarget: "user:U123",
|
||||
currentThreadTs: "1700000000.000100",
|
||||
currentMessageId: "M-1",
|
||||
messageChannel: "slack",
|
||||
messageProvider: "slack-voice",
|
||||
chatId: "chat-1",
|
||||
channelContext: {
|
||||
sender: { id: "sender-1", displayName: "Ada" },
|
||||
chat: { id: "chat-1", kind: "channel" },
|
||||
},
|
||||
messageProvider: "slack",
|
||||
messageTo: "U-1",
|
||||
messageThreadId: "1700000000.000100",
|
||||
replyToMode: "first",
|
||||
@@ -563,13 +477,7 @@ describe("createCopilotToolBridge", () => {
|
||||
currentMessagingTarget: "user:U123",
|
||||
currentThreadTs: "1700000000.000100",
|
||||
currentMessageId: "M-1",
|
||||
messageChannel: "slack",
|
||||
messageProvider: "slack-voice",
|
||||
chatId: "chat-1",
|
||||
hookChannelContext: {
|
||||
sender: { id: "sender-1", displayName: "Ada" },
|
||||
chat: { id: "chat-1", kind: "channel" },
|
||||
},
|
||||
messageProvider: "slack",
|
||||
messageTo: "U-1",
|
||||
messageThreadId: "1700000000.000100",
|
||||
replyToMode: "first",
|
||||
@@ -577,7 +485,6 @@ describe("createCopilotToolBridge", () => {
|
||||
forceMessageTool: true,
|
||||
enableHeartbeatTool: true,
|
||||
});
|
||||
expect(opts.channelContext).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back messageProvider to attemptParams.messageChannel when messageProvider is absent (codex parity)", async () => {
|
||||
@@ -595,63 +502,6 @@ describe("createCopilotToolBridge", () => {
|
||||
expect(getOpts().messageProvider).toBe("telegram");
|
||||
});
|
||||
|
||||
it("uses messageTo when currentMessagingTarget is absent in tool hook routing", () => {
|
||||
const context = testing.buildCopilotToolHookContext({
|
||||
agentId: "agent-1",
|
||||
messageChannel: "slack",
|
||||
messageProvider: "slack",
|
||||
messageTo: "user:U-only",
|
||||
trigger: "user",
|
||||
});
|
||||
|
||||
expect(context).toMatchObject({
|
||||
channel: "slack",
|
||||
messageProvider: "slack",
|
||||
channelId: "U-only",
|
||||
turnSourceChannel: "slack",
|
||||
turnSourceTo: "user:U-only",
|
||||
});
|
||||
expect(context.chatId).toBeUndefined();
|
||||
expect(context.channelContext).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves per-agent loop detection overrides for generated code-mode controls", () => {
|
||||
const context = testing.buildCopilotToolHookContext({
|
||||
agentId: "agent-1",
|
||||
config: {
|
||||
tools: {
|
||||
loopDetection: {
|
||||
enabled: true,
|
||||
warningThreshold: 7,
|
||||
detectors: { genericRepeat: true },
|
||||
postCompactionGuard: { windowSize: 4 },
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "agent-1",
|
||||
tools: {
|
||||
loopDetection: {
|
||||
enabled: false,
|
||||
detectors: { pingPong: false },
|
||||
postCompactionGuard: { windowSize: 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(context.loopDetection).toEqual({
|
||||
enabled: false,
|
||||
warningThreshold: 7,
|
||||
detectors: { genericRepeat: true, pingPong: false },
|
||||
postCompactionGuard: { windowSize: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards authProfileStore, runId, config, and run hooks (onToolOutcome) from attemptParams", async () => {
|
||||
const { createOpenClawCodingTools, getOpts } = captureCall();
|
||||
const authProfileStore = { kind: "fake-store" } as never;
|
||||
|
||||
@@ -7,19 +7,15 @@ import type {
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
applyEmbeddedAttemptToolsAllow,
|
||||
buildAgentHookContextOriginFields,
|
||||
buildEmbeddedAttemptToolRunContext,
|
||||
extractToolErrorMessage,
|
||||
getPluginToolMeta,
|
||||
isSubagentSessionKey,
|
||||
isToolWrappedWithBeforeToolCallHook,
|
||||
isToolResultError,
|
||||
resolveAttemptSpawnWorkspaceDir,
|
||||
resolveEmbeddedAttemptToolConstructionPlan,
|
||||
resolveModelAuthMode,
|
||||
resolveToolLoopDetectionConfig,
|
||||
sanitizeToolResult,
|
||||
wrapToolWithBeforeToolCallHook,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { createAgentHarnessToolSurfaceRuntime } from "openclaw/plugin-sdk/agent-harness-tool-runtime";
|
||||
|
||||
@@ -148,7 +144,6 @@ export interface CopilotToolBridge {
|
||||
export const SUPPORTED_TOOL_PROVIDERS: ReadonlySet<string> = new Set(["github-copilot"]);
|
||||
const BASE_COPILOT_CODING_TOOL_NAMES = new Set(["edit", "read", "write"]);
|
||||
const SHELL_COPILOT_CODING_TOOL_NAMES = new Set(["apply_patch", "exec", "process"]);
|
||||
const CODE_MODE_CONTROL_TOOL_NAMES = new Set(["exec", "wait"]);
|
||||
|
||||
export function supportsModelTools(modelProvider: string): boolean {
|
||||
return SUPPORTED_TOOL_PROVIDERS.has(modelProvider);
|
||||
@@ -215,7 +210,6 @@ export async function createCopilotToolBridge(
|
||||
},
|
||||
toolSurfaceRuntime,
|
||||
);
|
||||
const toolHookContext = buildCopilotToolHookContext(toolOptions);
|
||||
|
||||
let sourceTools: unknown;
|
||||
try {
|
||||
@@ -237,18 +231,9 @@ export async function createCopilotToolBridge(
|
||||
sourceTools as AnyAgentTool[],
|
||||
toolSurfaceRuntime.runtimeToolAllowlist,
|
||||
);
|
||||
const compactedTools = toolSurfaceRuntime.compactTools(allowedSourceTools, {
|
||||
hookContext: toolHookContext,
|
||||
});
|
||||
const hookedCompactedTools = compactedTools.tools.map((tool) =>
|
||||
!toolSurfaceRuntime.codeModeControlsEnabled ||
|
||||
!CODE_MODE_CONTROL_TOOL_NAMES.has(tool.name) ||
|
||||
isToolWrappedWithBeforeToolCallHook(tool)
|
||||
? tool
|
||||
: wrapToolWithBeforeToolCallHook(tool, toolHookContext),
|
||||
);
|
||||
const compactedTools = toolSurfaceRuntime.compactTools(allowedSourceTools);
|
||||
const plannedTools = filterCopilotToolsForConstructionPlan(
|
||||
hookedCompactedTools,
|
||||
compactedTools.tools,
|
||||
effectiveToolPlan.codingToolConstructionPlan,
|
||||
{ preserveToolNames: toolSurfaceRuntime.runtimeToolAllowlist },
|
||||
);
|
||||
@@ -279,51 +264,6 @@ export async function createCopilotToolBridge(
|
||||
};
|
||||
}
|
||||
|
||||
function buildCopilotToolHookContext(toolOptions: OpenClawCodingToolsOptions) {
|
||||
const turnSourceChannel = toolOptions.messageChannel ?? toolOptions.messageProvider;
|
||||
const messageTo = toolOptions.currentMessagingTarget ?? toolOptions.messageTo;
|
||||
const turnSourceTo = messageTo ?? toolOptions.currentChannelId;
|
||||
return {
|
||||
agentId: toolOptions.agentId,
|
||||
config: toolOptions.config,
|
||||
cwd: toolOptions.cwd,
|
||||
workspaceDir: toolOptions.workspaceDir,
|
||||
sessionKey: toolOptions.sessionKey,
|
||||
sessionId: toolOptions.sessionId,
|
||||
runId: toolOptions.runId,
|
||||
jobId: toolOptions.jobId,
|
||||
trace: toolOptions.trace,
|
||||
trigger: toolOptions.trigger,
|
||||
...buildAgentHookContextOriginFields({
|
||||
sessionKey: toolOptions.sessionKey,
|
||||
messageChannel: toolOptions.messageChannel,
|
||||
messageProvider: toolOptions.toolPolicyMessageProvider ?? toolOptions.messageProvider,
|
||||
currentChannelId: toolOptions.hookChannelId ?? toolOptions.currentChannelId,
|
||||
messageTo,
|
||||
trigger: toolOptions.trigger,
|
||||
senderId: toolOptions.senderId,
|
||||
chatId: toolOptions.chatId,
|
||||
channelContext: toolOptions.hookChannelContext ?? toolOptions.channelContext,
|
||||
}),
|
||||
...(turnSourceChannel ? { turnSourceChannel } : {}),
|
||||
...(turnSourceTo ? { turnSourceTo } : {}),
|
||||
...(toolOptions.agentAccountId ? { turnSourceAccountId: toolOptions.agentAccountId } : {}),
|
||||
...(toolOptions.currentThreadTs ? { turnSourceThreadId: toolOptions.currentThreadTs } : {}),
|
||||
loopDetection: resolveToolLoopDetectionConfig({
|
||||
cfg: toolOptions.config,
|
||||
agentId: toolOptions.agentId,
|
||||
}),
|
||||
onToolOutcome: toolOptions.onToolOutcome,
|
||||
allocateToolOutcomeOrdinal: toolOptions.allocateToolOutcomeOrdinal,
|
||||
};
|
||||
}
|
||||
|
||||
/** Test-only access to requester-context construction. */
|
||||
export const testing = {
|
||||
buildCopilotToolHookContext: (toolOptions: unknown): Record<string, unknown> =>
|
||||
buildCopilotToolHookContext(toolOptions as OpenClawCodingToolsOptions),
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the full `createOpenClawCodingTools` options bag mirroring the
|
||||
* PI in-tree call at `src/agents/pi-embedded-runner/run/attempt.ts:1029-1117`.
|
||||
@@ -410,9 +350,7 @@ function buildOpenClawCodingToolsOptions(
|
||||
...a.execOverrides,
|
||||
elevated: a.bashElevated,
|
||||
},
|
||||
messageChannel: a.messageChannel,
|
||||
messageProvider: a.messageProvider ?? a.messageChannel,
|
||||
toolPolicyMessageProvider: a.messageProvider ?? a.messageChannel,
|
||||
agentAccountId: a.agentAccountId,
|
||||
messageTo: a.messageTo,
|
||||
messageThreadId: a.messageThreadId,
|
||||
@@ -422,7 +360,6 @@ function buildOpenClawCodingToolsOptions(
|
||||
memberRoleIds: a.memberRoleIds,
|
||||
spawnedBy: a.spawnedBy,
|
||||
senderId: a.senderId,
|
||||
hookChannelContext: a.channelContext,
|
||||
senderName: a.senderName,
|
||||
senderUsername: a.senderUsername,
|
||||
senderE164: a.senderE164,
|
||||
@@ -458,7 +395,6 @@ function buildOpenClawCodingToolsOptions(
|
||||
workspaceDir,
|
||||
}),
|
||||
currentChannelId: a.currentChannelId,
|
||||
chatId: a.chatId,
|
||||
currentMessagingTarget: a.currentMessagingTarget,
|
||||
currentThreadTs: a.currentThreadTs,
|
||||
currentMessageId: a.currentMessageId,
|
||||
|
||||
@@ -36,21 +36,49 @@ type DuckDuckGoResult = {
|
||||
};
|
||||
|
||||
function decodeHtmlEntities(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/g, "'")
|
||||
.replace(///g, "/")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/–/g, "-")
|
||||
.replace(/—/g, "--")
|
||||
.replace(/…/g, "...")
|
||||
.replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
|
||||
.replace(/&#x([0-9a-f]+);/gi, (_, code) => String.fromCodePoint(Number.parseInt(code, 16)));
|
||||
return text.replace(
|
||||
/&(?:lt|gt|quot|apos|#39|#x27|#x2F|nbsp|ndash|mdash|hellip|amp|#\d+|#x[0-9a-f]+);/gi,
|
||||
(entity) => {
|
||||
const normalized = entity.toLowerCase();
|
||||
if (normalized === "<") {
|
||||
return "<";
|
||||
}
|
||||
if (normalized === ">") {
|
||||
return ">";
|
||||
}
|
||||
if (normalized === """) {
|
||||
return '"';
|
||||
}
|
||||
if (normalized === "'" || normalized === "'" || normalized === "'") {
|
||||
return "'";
|
||||
}
|
||||
if (normalized === "/") {
|
||||
return "/";
|
||||
}
|
||||
if (normalized === " ") {
|
||||
return " ";
|
||||
}
|
||||
if (normalized === "–") {
|
||||
return "-";
|
||||
}
|
||||
if (normalized === "—") {
|
||||
return "--";
|
||||
}
|
||||
if (normalized === "…") {
|
||||
return "...";
|
||||
}
|
||||
if (normalized === "&") {
|
||||
return "&";
|
||||
}
|
||||
if (normalized.startsWith("&#x")) {
|
||||
return String.fromCodePoint(Number.parseInt(normalized.slice(3, -1), 16));
|
||||
}
|
||||
if (normalized.startsWith("&#")) {
|
||||
return String.fromCodePoint(Number.parseInt(normalized.slice(2, -1), 10));
|
||||
}
|
||||
return entity;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
|
||||
@@ -186,6 +186,17 @@ describe("duckduckgo web search provider", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not double-decode escaped entities (decodes & last)", () => {
|
||||
// A result whose text literally shows "<" arrives double-encoded as
|
||||
// "&lt;". Decoding & first would re-decode it into "<", corrupting
|
||||
// the snippet; & must be decoded last.
|
||||
expect(ddgClientTesting.decodeHtmlEntities("How to escape &lt; in HTML")).toBe(
|
||||
"How to escape < in HTML",
|
||||
);
|
||||
expect(ddgClientTesting.decodeHtmlEntities("a&#39;b")).toBe("a'b");
|
||||
expect(ddgClientTesting.decodeHtmlEntities("a&amp;b")).toBe("a&b");
|
||||
});
|
||||
|
||||
it("parses results when href appears before class", () => {
|
||||
const html = `
|
||||
<a href="https://duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com" class="result__a">
|
||||
|
||||
@@ -29,6 +29,30 @@ describe("concept vocabulary", () => {
|
||||
expect(tags).not.toContain("2026-04-04.md");
|
||||
});
|
||||
|
||||
it("preserves short protected-glossary terms past the latin minimum-length gate", () => {
|
||||
const tags = deriveConceptTags({
|
||||
path: "memory/2026-04-04.md",
|
||||
snippet: "Store the session in kv and back up to s3 nightly.",
|
||||
});
|
||||
|
||||
// "kv" and "s3" are 2-char latin glossary entries that the generic min-length-3 gate would drop.
|
||||
expect(tags).toContain("kv");
|
||||
expect(tags).toContain("s3");
|
||||
});
|
||||
|
||||
it("does not surface short glossary terms that only appear inside longer words", () => {
|
||||
const tags = deriveConceptTags({
|
||||
path: "memory/2026-04-04.md",
|
||||
snippet: "Played the mkv recording and tuned the css3 layout.",
|
||||
});
|
||||
|
||||
// "kv"/"s3" are substrings of "mkv"/"css3"; whole-word matching must not emit them as tags.
|
||||
expect(tags).not.toContain("kv");
|
||||
expect(tags).not.toContain("s3");
|
||||
expect(tags).toContain("mkv");
|
||||
expect(tags).toContain("css3");
|
||||
});
|
||||
|
||||
it("extracts protected and segmented CJK concept tags", () => {
|
||||
const tags = deriveConceptTags({
|
||||
path: "memory/2026-04-04.md",
|
||||
|
||||
@@ -330,7 +330,7 @@ function isKanaOnlyToken(value: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeConceptToken(rawToken: string): string | null {
|
||||
function normalizeConceptToken(rawToken: string, fromGlossary = false): string | null {
|
||||
const normalized = normalizeLowercaseStringOrEmpty(
|
||||
rawToken
|
||||
.normalize("NFKC")
|
||||
@@ -348,7 +348,9 @@ function normalizeConceptToken(rawToken: string): string | null {
|
||||
return null;
|
||||
}
|
||||
const script = classifyConceptTagScript(normalized);
|
||||
if (normalized.length < minimumTokenLengthForScript(script)) {
|
||||
// Glossary entries are an explicit allowlist of short technical terms (e.g. "kv", "s3"); they
|
||||
// bypass the per-script minimum length that would otherwise discard them.
|
||||
if (!fromGlossary && normalized.length < minimumTokenLengthForScript(script)) {
|
||||
return null;
|
||||
}
|
||||
if (isKanaOnlyToken(normalized) && normalized.length < 3) {
|
||||
@@ -360,14 +362,43 @@ function normalizeConceptToken(rawToken: string): string | null {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// Only entries shorter than their script's minimum token length rely on the glossary bypass, and
|
||||
// only those need whole-word matching so they don't fire inside longer words ("kv" in "mkv"). Longer
|
||||
// entries keep substring containment (the shipped behavior, e.g. "backup" tagging inside "backups").
|
||||
// Precomputed so derive() does not reclassify on every call.
|
||||
const GLOSSARY_ENTRIES = PROTECTED_GLOSSARY.map((entry) => ({
|
||||
entry,
|
||||
wholeWord: entry.length < minimumTokenLengthForScript(classifyConceptTagScript(entry)),
|
||||
}));
|
||||
|
||||
function isAlphanumericAt(source: string, index: number): boolean {
|
||||
const ch = source[index];
|
||||
return ch !== undefined && LETTER_OR_NUMBER_RE.test(ch);
|
||||
}
|
||||
|
||||
// True when `entry` occurs as a delimiter-bounded token, not inside a longer word. Keeps short
|
||||
// glossary entries like "kv"/"s3" from firing inside "mkv"/"css3" once they bypass the length gate.
|
||||
function includesStandaloneTerm(source: string, entry: string): boolean {
|
||||
let from = source.indexOf(entry);
|
||||
while (from !== -1) {
|
||||
if (!isAlphanumericAt(source, from - 1) && !isAlphanumericAt(source, from + entry.length)) {
|
||||
return true;
|
||||
}
|
||||
from = source.indexOf(entry, from + 1);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function collectGlossaryMatches(source: string): string[] {
|
||||
const normalizedSource = normalizeLowercaseStringOrEmpty(source.normalize("NFKC"));
|
||||
const matches: string[] = [];
|
||||
for (const entry of PROTECTED_GLOSSARY) {
|
||||
if (!normalizedSource.includes(entry)) {
|
||||
continue;
|
||||
for (const { entry, wholeWord } of GLOSSARY_ENTRIES) {
|
||||
const present = wholeWord
|
||||
? includesStandaloneTerm(normalizedSource, entry)
|
||||
: normalizedSource.includes(entry);
|
||||
if (present) {
|
||||
matches.push(entry);
|
||||
}
|
||||
matches.push(entry);
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
@@ -385,8 +416,13 @@ function collectSegmentTokens(source: string): string[] {
|
||||
return source.split(/[^\p{L}\p{N}]+/u).filter(Boolean);
|
||||
}
|
||||
|
||||
function pushNormalizedTag(tags: string[], rawToken: string, limit: number): void {
|
||||
const normalized = normalizeConceptToken(rawToken);
|
||||
function pushNormalizedTag(
|
||||
tags: string[],
|
||||
rawToken: string,
|
||||
limit: number,
|
||||
fromGlossary = false,
|
||||
): void {
|
||||
const normalized = normalizeConceptToken(rawToken, fromGlossary);
|
||||
if (!normalized || tags.includes(normalized)) {
|
||||
return;
|
||||
}
|
||||
@@ -410,14 +446,17 @@ export function deriveConceptTags(params: {
|
||||
}
|
||||
|
||||
const tags: string[] = [];
|
||||
for (const rawToken of [
|
||||
...collectGlossaryMatches(source),
|
||||
...collectCompoundTokens(source),
|
||||
...collectSegmentTokens(source),
|
||||
]) {
|
||||
pushNormalizedTag(tags, rawToken, limit);
|
||||
if (tags.length >= limit) {
|
||||
break;
|
||||
const tokenSources: Array<{ tokens: string[]; fromGlossary: boolean }> = [
|
||||
{ tokens: collectGlossaryMatches(source), fromGlossary: true },
|
||||
{ tokens: collectCompoundTokens(source), fromGlossary: false },
|
||||
{ tokens: collectSegmentTokens(source), fromGlossary: false },
|
||||
];
|
||||
for (const { tokens, fromGlossary } of tokenSources) {
|
||||
for (const rawToken of tokens) {
|
||||
pushNormalizedTag(tags, rawToken, limit, fromGlossary);
|
||||
if (tags.length >= limit) {
|
||||
return tags;
|
||||
}
|
||||
}
|
||||
}
|
||||
return tags;
|
||||
|
||||
@@ -3189,7 +3189,9 @@ describe("short-term promotion", () => {
|
||||
path: "memory/2026-04-03.md",
|
||||
snippet: "Move backups to S3 Glacier and sync QMD router notes.",
|
||||
}),
|
||||
).toStrictEqual(["backup", "backups", "glacier", "qmd", "router", "sync"]);
|
||||
// "s3" is a protected-glossary term; it now surfaces as a standalone token past the
|
||||
// per-script min-length gate (the longer terms still match as substrings).
|
||||
).toStrictEqual(["backup", "backups", "glacier", "qmd", "router", "s3", "sync"]);
|
||||
});
|
||||
|
||||
it("extracts multilingual concept tags across latin and cjk snippets", () => {
|
||||
|
||||
@@ -37,6 +37,15 @@ describe("stripHtmlFromTeamsMessage", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not double-decode escaped entities (decodes & last)", () => {
|
||||
// Graph encodes literally-typed entity text by escaping its '&' to '&'.
|
||||
// Decoding '&' first would re-decode the now-bare '<'/'>' into
|
||||
// angle brackets, corrupting the user's literal text.
|
||||
expect(stripHtmlFromTeamsMessage("The token is &lt;APIKEY&gt;")).toBe(
|
||||
"The token is <APIKEY>",
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes multiple whitespace to single space", () => {
|
||||
expect(stripHtmlFromTeamsMessage("hello world")).toBe("hello world");
|
||||
});
|
||||
|
||||
@@ -35,14 +35,16 @@ export function stripHtmlFromTeamsMessage(html: string): string {
|
||||
let text = html.replace(/<at[^>]*>(.*?)<\/at>/gi, "@$1");
|
||||
// Strip remaining HTML tags.
|
||||
text = text.replace(/<[^>]*>/g, " ");
|
||||
// Decode common HTML entities.
|
||||
// Decode common HTML entities. & must be decoded LAST to prevent
|
||||
// double-decoding (e.g. &lt; → < not <), matching decodeHtmlEntities
|
||||
// in inbound.ts.
|
||||
text = text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /g, " ");
|
||||
.replace(/ /g, " ")
|
||||
.replace(/&/g, "&");
|
||||
// Normalize whitespace.
|
||||
return text.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
@@ -712,7 +712,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
|
||||
const preview = renderText?.("| A | B |\n| --- | --- |\n| 1 | 2 |");
|
||||
expect(preview?.richMessage).toEqual(
|
||||
expect.objectContaining({
|
||||
html: expect.stringContaining("<table>"),
|
||||
html: expect.stringContaining("<table bordered striped>"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1239,6 +1239,33 @@ describe("deliverReplies", () => {
|
||||
expect(mockCallArg(sendRichMessage, 1, 0)).not.toHaveProperty("reply_to_message_id");
|
||||
});
|
||||
|
||||
it("skips rich entity detection for reply text with provider-prefixed email addresses", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 11,
|
||||
chat: { id: "123" },
|
||||
});
|
||||
const bot = createBot({ sendMessage });
|
||||
const oauthProfileText =
|
||||
"OAuth profile: openai:keshavbotagent@gmail.com (keshavbotagent@gmail.com)";
|
||||
|
||||
await deliverWith({
|
||||
replies: [{ text: oauthProfileText }],
|
||||
runtime,
|
||||
bot,
|
||||
richMessages: true,
|
||||
});
|
||||
|
||||
const raw = bot.api.raw as unknown as {
|
||||
sendRichMessage: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
const richMessage = raw.sendRichMessage.mock.calls[0]?.[0]?.rich_message;
|
||||
expect(richMessage).toEqual({
|
||||
html: oauthProfileText,
|
||||
skip_entity_detection: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses legacy reply id when selected reply target differs from quote source", async () => {
|
||||
const runtime = createRuntime();
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
|
||||
@@ -690,6 +690,24 @@ describe("createTelegramDraftStream", () => {
|
||||
expect(api.editMessageText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips rich entity detection for draft text with provider-prefixed email addresses", async () => {
|
||||
const api = createMockDraftApi();
|
||||
const stream = createDraftStream(api, { richMessages: true });
|
||||
const oauthProfileText =
|
||||
"OAuth profile: openai:keshavbotagent@gmail.com (keshavbotagent@gmail.com)";
|
||||
|
||||
stream.update(oauthProfileText);
|
||||
await stream.flush();
|
||||
|
||||
expect(api.raw.sendRichMessage).toHaveBeenCalledWith({
|
||||
chat_id: 123,
|
||||
rich_message: {
|
||||
html: oauthProfileText,
|
||||
skip_entity_detection: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps rich preview html out of plain preview gating", async () => {
|
||||
const api = createMockDraftApi();
|
||||
const stream = createDraftStream(api, { richMessages: true, minInitialChars: 10 });
|
||||
|
||||
@@ -254,7 +254,7 @@ describe("markdownToTelegramHtml", () => {
|
||||
`| ${Array.from({ length: columns }, (_, index) => String(index + 1)).join(" | ")} |`,
|
||||
].join("\n");
|
||||
|
||||
expect(markdownToTelegramRichHtml(table(20))).toContain("<table>");
|
||||
expect(markdownToTelegramRichHtml(table(20))).toContain("<table bordered striped>");
|
||||
expect(markdownToTelegramRichHtml(table(21))).toContain("<pre><code>");
|
||||
expect(markdownToTelegramRichHtml(table(2), { tableMode: "code" })).toContain("<pre><code>");
|
||||
expect(markdownToTelegramRichHtml(table(2), { tableMode: "code" })).not.toContain("<table>");
|
||||
@@ -295,6 +295,19 @@ describe("markdownToTelegramHtml", () => {
|
||||
expect(html).toContain('<td><a href="https://example.com">docs</a></td>');
|
||||
});
|
||||
|
||||
it("preserves markdown table column alignment in rich tables", () => {
|
||||
const html = markdownToTelegramRichHtml(
|
||||
"| Feature | Status | Count |\n| :--- | :---: | ---: |\n| Rich tables | Fixed | 2 |",
|
||||
);
|
||||
|
||||
expect(html).toContain('<th align="left">Feature</th>');
|
||||
expect(html).toContain('<th align="center">Status</th>');
|
||||
expect(html).toContain('<th align="right">Count</th>');
|
||||
expect(html).toContain('<td align="left">Rich tables</td>');
|
||||
expect(html).toContain('<td align="center">Fixed</td>');
|
||||
expect(html).toContain('<td align="right">2</td>');
|
||||
});
|
||||
|
||||
it("does not auto-linkify bare URLs when entity detection is skipped", () => {
|
||||
expect(markdownToTelegramRichHtml("https://example.com", { skipEntityDetection: true })).toBe(
|
||||
"https://example.com",
|
||||
|
||||
@@ -346,6 +346,8 @@ type TelegramHtmlTagSupport = {
|
||||
attrPatterns: ReadonlyMap<string, RegExp>;
|
||||
};
|
||||
|
||||
type TelegramTableAlignment = NonNullable<MarkdownTableMeta["aligns"]>[number];
|
||||
|
||||
const TELEGRAM_LEGACY_HTML_TAG_SUPPORT: TelegramHtmlTagSupport = {
|
||||
simpleTags: TELEGRAM_SIMPLE_HTML_TAGS,
|
||||
attrPatterns: TELEGRAM_ATTR_HTML_TAG_PATTERNS,
|
||||
@@ -972,19 +974,25 @@ function renderTelegramRichHtmlTable(table: MarkdownTableMeta): string {
|
||||
}
|
||||
const renderCellValue = (cell: MarkdownTableCell | undefined) =>
|
||||
cell ? renderTelegramHtml(cell) : "";
|
||||
const renderCell = (tag: "td" | "th", value: MarkdownTableCell | undefined) =>
|
||||
`<${tag}>${renderCellValue(value)}</${tag}>`;
|
||||
const renderCell = (
|
||||
tag: "td" | "th",
|
||||
value: MarkdownTableCell | undefined,
|
||||
align: TelegramTableAlignment | undefined,
|
||||
) => {
|
||||
const alignAttr = align ? ` align="${align}"` : "";
|
||||
return `<${tag}${alignAttr}>${renderCellValue(value)}</${tag}>`;
|
||||
};
|
||||
const head = table.headers.length
|
||||
? `<thead><tr>${table.headerCells.map((cell) => renderCell("th", cell)).join("")}</tr></thead>`
|
||||
? `<thead><tr>${table.headerCells.map((cell, index) => renderCell("th", cell, table.aligns?.[index])).join("")}</tr></thead>`
|
||||
: "";
|
||||
const bodyRows = table.rowCells
|
||||
.map(
|
||||
(row) =>
|
||||
`<tr>${Array.from({ length: columnCount }, (_value, index) => renderCell("td", row[index])).join("")}</tr>`,
|
||||
`<tr>${Array.from({ length: columnCount }, (_value, index) => renderCell("td", row[index], table.aligns?.[index])).join("")}</tr>`,
|
||||
)
|
||||
.join("");
|
||||
const body = bodyRows ? `<tbody>${bodyRows}</tbody>` : "";
|
||||
return `<table>${head}${body}</table>\n\n`;
|
||||
return `<table bordered striped>${head}${body}</table>\n\n`;
|
||||
}
|
||||
|
||||
function renderTelegramRichHtmlDocument(
|
||||
|
||||
@@ -96,6 +96,16 @@ type TelegramApiWithRichRaw = Bot["api"] & {
|
||||
raw?: TelegramRichRawApi;
|
||||
};
|
||||
|
||||
const TELEGRAM_RICH_EMAIL_TOKEN_RE =
|
||||
/[A-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?(?:\.[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?)+/iu;
|
||||
|
||||
function shouldSkipTelegramRichEntityDetection(
|
||||
text: string,
|
||||
options?: Pick<TelegramRichMessageOptions, "skipEntityDetection">,
|
||||
): boolean {
|
||||
return options?.skipEntityDetection === true || TELEGRAM_RICH_EMAIL_TOKEN_RE.test(text);
|
||||
}
|
||||
|
||||
export function getTelegramRichRawApi(api: Bot["api"]): TelegramRichRawApi {
|
||||
const raw = (api as TelegramApiWithRichRaw).raw;
|
||||
if (raw) {
|
||||
@@ -164,7 +174,11 @@ export function buildTelegramRichMarkdown(
|
||||
markdown: string,
|
||||
options?: TelegramRichMessageOptions,
|
||||
): TelegramInputRichMessage {
|
||||
return buildTelegramRichHtml(markdownToTelegramRichHtml(markdown, options), options);
|
||||
const richOptions = {
|
||||
...options,
|
||||
skipEntityDetection: shouldSkipTelegramRichEntityDetection(markdown, options),
|
||||
};
|
||||
return buildTelegramRichHtml(markdownToTelegramRichHtml(markdown, richOptions), richOptions);
|
||||
}
|
||||
|
||||
export function buildTelegramRichHtml(
|
||||
@@ -172,7 +186,7 @@ export function buildTelegramRichHtml(
|
||||
options?: TelegramRichMessageOptions,
|
||||
): TelegramInputRichMessage {
|
||||
const safeHtml = prepareTelegramRichHtml(html);
|
||||
return options?.skipEntityDetection === true
|
||||
return shouldSkipTelegramRichEntityDetection(safeHtml, options)
|
||||
? { html: safeHtml, skip_entity_detection: true }
|
||||
: { html: safeHtml };
|
||||
}
|
||||
@@ -418,13 +432,14 @@ export function splitTelegramRichMessageTextChunks(params: {
|
||||
tableMode?: MarkdownTableMode;
|
||||
skipEntityDetection?: boolean;
|
||||
}): TelegramRichTextChunk[] {
|
||||
const markdownOptions = {
|
||||
tableMode: params.tableMode,
|
||||
skipEntityDetection: shouldSkipTelegramRichEntityDetection(params.text, {
|
||||
skipEntityDetection: params.skipEntityDetection,
|
||||
}),
|
||||
};
|
||||
const renderMarkdownChunk = (chunk: string) =>
|
||||
prepareTelegramRichHtml(
|
||||
markdownToTelegramRichHtml(chunk, {
|
||||
tableMode: params.tableMode,
|
||||
skipEntityDetection: params.skipEntityDetection,
|
||||
}),
|
||||
);
|
||||
prepareTelegramRichHtml(markdownToTelegramRichHtml(chunk, markdownOptions));
|
||||
const htmlChunks =
|
||||
params.textMode === "html"
|
||||
? splitPreparedTelegramRichHtml({
|
||||
|
||||
@@ -953,7 +953,32 @@ describe("sendMessageTelegram", () => {
|
||||
|
||||
expect(botRawApi.sendRichMessage).toHaveBeenCalledTimes(1);
|
||||
const richMessage = botRawApi.sendRichMessage.mock.calls[0]?.[0]?.rich_message;
|
||||
expect(richMessage?.html).toContain("<table>");
|
||||
expect(richMessage?.html).toContain("<table bordered striped>");
|
||||
});
|
||||
|
||||
it("skips rich entity detection for provider-prefixed email text", async () => {
|
||||
botApi.sendMessage.mockResolvedValue({ message_id: 45, chat: { id: "123" } });
|
||||
const oauthProfileText =
|
||||
"OAuth profile: openai:keshavbotagent@gmail.com (keshavbotagent@gmail.com)";
|
||||
|
||||
await sendMessageTelegram("123", oauthProfileText, {
|
||||
cfg: {
|
||||
channels: {
|
||||
telegram: {
|
||||
richMessages: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
token: "tok",
|
||||
});
|
||||
|
||||
expect(botRawApi.sendRichMessage).toHaveBeenCalledTimes(1);
|
||||
const richMessage = botRawApi.sendRichMessage.mock.calls[0]?.[0]?.rich_message;
|
||||
expect(richMessage).toEqual({
|
||||
html: oauthProfileText,
|
||||
skip_entity_detection: true,
|
||||
});
|
||||
expect(richMessage?.html).not.toContain("mailto:");
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -27,6 +27,9 @@ function cronAgentTurnPayloadSchema(params: {
|
||||
allowUnsafeExternalContent: Type.Optional(Type.Boolean()),
|
||||
lightContext: Type.Optional(Type.Boolean()),
|
||||
toolsAllow: Type.Optional(params.toolsAllow),
|
||||
// Server-managed marker for auto-stamped defaults; persisted so CLI cron
|
||||
// runs can drop only the cap that was never user-explicit.
|
||||
toolsAllowIsDefault: Type.Optional(Type.Boolean()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
@@ -78,9 +78,12 @@ function createStyleSpan(params: MarkdownStyleSpan): MarkdownStyleSpan {
|
||||
return span;
|
||||
}
|
||||
|
||||
type MarkdownTableAlignment = "left" | "center" | "right";
|
||||
|
||||
export type MarkdownTableData = {
|
||||
headers: string[];
|
||||
rows: string[][];
|
||||
aligns?: (MarkdownTableAlignment | undefined)[];
|
||||
};
|
||||
|
||||
export type MarkdownTableCell = {
|
||||
@@ -113,6 +116,7 @@ type TableCell = MarkdownTableCell;
|
||||
type TableState = {
|
||||
headers: TableCell[];
|
||||
rows: TableCell[][];
|
||||
aligns: (MarkdownTableAlignment | undefined)[];
|
||||
currentRow: TableCell[];
|
||||
currentCell: RenderTarget | null;
|
||||
inHeader: boolean;
|
||||
@@ -172,6 +176,20 @@ function getAttr(token: MarkdownToken, name: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function markdownTableAlignmentFromToken(token: MarkdownToken): MarkdownTableAlignment | undefined {
|
||||
const value = getAttr(token, "style") ?? "";
|
||||
if (/text-align\s*:\s*left/i.test(value)) {
|
||||
return "left";
|
||||
}
|
||||
if (/text-align\s*:\s*center/i.test(value)) {
|
||||
return "center";
|
||||
}
|
||||
if (/text-align\s*:\s*right/i.test(value)) {
|
||||
return "right";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function createTextToken(base: MarkdownToken, content: string): MarkdownToken {
|
||||
return { ...base, type: "text", content, children: undefined };
|
||||
}
|
||||
@@ -432,6 +450,7 @@ function initTableState(): TableState {
|
||||
return {
|
||||
headers: [],
|
||||
rows: [],
|
||||
aligns: [],
|
||||
currentRow: [],
|
||||
currentCell: null,
|
||||
inHeader: false,
|
||||
@@ -517,13 +536,15 @@ function collectTableBlock(state: RenderState) {
|
||||
}
|
||||
const headerCells = state.table.headers.map(trimCell);
|
||||
const rowCells = state.table.rows.map((row) => row.map(trimCell));
|
||||
state.collectedTables.push({
|
||||
const table = {
|
||||
headers: headerCells.map((cell) => cell.text),
|
||||
rows: rowCells.map((row) => row.map((cell) => cell.text)),
|
||||
headerCells,
|
||||
rowCells,
|
||||
placeholderOffset: state.text.length,
|
||||
});
|
||||
...(state.table.aligns.some(Boolean) ? { aligns: [...state.table.aligns] } : {}),
|
||||
};
|
||||
state.collectedTables.push(table);
|
||||
}
|
||||
|
||||
function appendTableBulletValue(
|
||||
@@ -874,6 +895,10 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void {
|
||||
case "td_open":
|
||||
if (state.table) {
|
||||
state.table.currentCell = initRenderTarget();
|
||||
if (token.type === "th_open" && state.table.inHeader) {
|
||||
state.table.aligns[state.table.currentRow.length] =
|
||||
markdownTableAlignmentFromToken(token);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "th_close":
|
||||
|
||||
@@ -107,6 +107,7 @@ export const migratedSessionAccessorFiles = new Set([
|
||||
"src/gateway/sessions-history-http.ts",
|
||||
"src/gateway/session-utils.ts",
|
||||
"src/gateway/managed-image-attachments.ts",
|
||||
"src/gateway/boot.ts",
|
||||
"src/gateway/server-methods/artifacts.ts",
|
||||
"src/gateway/server-methods/chat.ts",
|
||||
"src/gateway/sessions-resolve.ts",
|
||||
@@ -163,6 +164,7 @@ export const migratedSessionAccessorWriteFiles = new Set([
|
||||
"src/auto-reply/reply/session-usage.ts",
|
||||
"src/commands/tasks.ts",
|
||||
"src/config/sessions/cleanup-service.ts",
|
||||
"src/gateway/boot.ts",
|
||||
"src/gateway/server-node-events.ts",
|
||||
"src/gateway/session-compaction-checkpoints.ts",
|
||||
"src/plugins/host-hook-cleanup.ts",
|
||||
|
||||
@@ -33,13 +33,6 @@ import { markCodeModeControlTool } from "./code-mode-control-tools.js";
|
||||
import { CODE_MODE_EXEC_TOOL_NAME, createCodeModeTools } from "./code-mode.js";
|
||||
import { splitSdkTools } from "./embedded-agent-runner.js";
|
||||
import type { ExtensionContext } from "./sessions/index.js";
|
||||
import {
|
||||
addClientToolsToToolSearchCatalog,
|
||||
applyToolSearchCatalog,
|
||||
clearToolSearchCatalog,
|
||||
createToolSearchTools,
|
||||
TOOL_CALL_RAW_TOOL_NAME,
|
||||
} from "./tool-search.js";
|
||||
import { setToolTerminalPresentation } from "./tool-terminal-presentation.js";
|
||||
|
||||
type BeforeToolCallHandlerMock = ReturnType<typeof vi.fn>;
|
||||
@@ -1207,143 +1200,6 @@ describe("before_tool_call hook integration for client tools", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves requester origin context on adapted client tools", async () => {
|
||||
const beforeToolCallHook = installBeforeToolCallHook();
|
||||
const [tool] = toClientToolDefinitions(
|
||||
[
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "client_tool",
|
||||
description: "Client tool",
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
},
|
||||
],
|
||||
undefined,
|
||||
{
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:client",
|
||||
sessionId: "session-client",
|
||||
runId: "run-client",
|
||||
jobId: "job-client",
|
||||
trigger: "user",
|
||||
messageProvider: "slack-voice",
|
||||
channel: "slack",
|
||||
chatId: "C123",
|
||||
senderId: "U123",
|
||||
channelId: "C123",
|
||||
channelContext: {
|
||||
sender: { id: "U123", displayName: "Ada" },
|
||||
chat: { id: "C123" },
|
||||
},
|
||||
},
|
||||
);
|
||||
const extensionContext = {} as Parameters<typeof tool.execute>[4];
|
||||
|
||||
await tool.execute("client-call-context", {}, undefined, undefined, extensionContext);
|
||||
|
||||
expect(beforeToolCallHook).toHaveBeenCalledWith(
|
||||
{
|
||||
toolName: "client_tool",
|
||||
params: {},
|
||||
runId: "run-client",
|
||||
toolCallId: "client-call-context",
|
||||
},
|
||||
{
|
||||
toolName: "client_tool",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:client",
|
||||
sessionId: "session-client",
|
||||
runId: "run-client",
|
||||
jobId: "job-client",
|
||||
trigger: "user",
|
||||
messageProvider: "slack-voice",
|
||||
channel: "slack",
|
||||
chatId: "C123",
|
||||
senderId: "U123",
|
||||
toolCallId: "client-call-context",
|
||||
channelId: "C123",
|
||||
channelContext: {
|
||||
sender: { id: "U123", displayName: "Ada" },
|
||||
chat: { id: "C123" },
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves requester origin context when a client tool is cataloged", async () => {
|
||||
const beforeToolCallHook = installBeforeToolCallHook();
|
||||
const onClientToolCall = vi.fn();
|
||||
const sessionId = "session-client-catalog";
|
||||
const config = { tools: { toolSearch: { enabled: true, mode: "tools" } } } as never;
|
||||
const [clientTool] = toClientToolDefinitions(
|
||||
[
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "client_tool",
|
||||
description: "Client tool",
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
},
|
||||
],
|
||||
onClientToolCall,
|
||||
{
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:client",
|
||||
sessionId,
|
||||
runId: "run-client-catalog",
|
||||
jobId: "job-client",
|
||||
trigger: "user",
|
||||
messageProvider: "slack-voice",
|
||||
channel: "slack",
|
||||
chatId: "C123",
|
||||
senderId: "U123",
|
||||
channelId: "C123",
|
||||
channelContext: {
|
||||
sender: { id: "U123", displayName: "Ada" },
|
||||
chat: { id: "C123" },
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!clientTool) {
|
||||
throw new Error("missing client tool definition");
|
||||
}
|
||||
const controls = createToolSearchTools({ config, sessionId });
|
||||
applyToolSearchCatalog({ tools: controls, config, sessionId });
|
||||
addClientToolsToToolSearchCatalog({ tools: [clientTool], config, sessionId });
|
||||
const toolCall = controls.find((tool) => tool.name === TOOL_CALL_RAW_TOOL_NAME);
|
||||
if (!toolCall) {
|
||||
throw new Error("missing tool_call control");
|
||||
}
|
||||
|
||||
try {
|
||||
await toolCall.execute("catalog-parent", {
|
||||
id: "client:client:client_tool",
|
||||
args: { value: "cataloged" },
|
||||
});
|
||||
|
||||
expect(beforeToolCallHook).toHaveBeenCalledTimes(1);
|
||||
expect(beforeToolCallHook.mock.calls[0]?.[1]).toMatchObject({
|
||||
jobId: "job-client",
|
||||
trigger: "user",
|
||||
messageProvider: "slack-voice",
|
||||
channel: "slack",
|
||||
chatId: "C123",
|
||||
senderId: "U123",
|
||||
channelId: "C123",
|
||||
channelContext: {
|
||||
sender: { id: "U123", displayName: "Ada" },
|
||||
chat: { id: "C123" },
|
||||
},
|
||||
});
|
||||
expect(onClientToolCall).toHaveBeenCalledWith("client_tool", { value: "cataloged" });
|
||||
} finally {
|
||||
clearToolSearchCatalog({ sessionId });
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves client tool source order when hooks resolve out of order", async () => {
|
||||
let releaseFirstHook: (() => void) | undefined;
|
||||
const firstHookGate = new Promise<void>((resolve) => {
|
||||
|
||||
@@ -37,7 +37,6 @@ import {
|
||||
import type { SessionState } from "../logging/diagnostic-session-state.js";
|
||||
import { redactToolDetail } from "../logging/redact.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { buildAgentHookContextIdentityFields } from "../plugins/hook-agent-context.js";
|
||||
import { getGlobalHookRunnerRegistry } from "../plugins/hook-runner-global-state.js";
|
||||
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
|
||||
import { deriveToolParams } from "../plugins/host-tool-param-parsers.js";
|
||||
@@ -51,10 +50,8 @@ import {
|
||||
PluginApprovalResolutions,
|
||||
type PluginApprovalResolution,
|
||||
type PluginHookBeforeToolCallResult,
|
||||
type PluginHookChannelContext,
|
||||
type PluginHookToolInputKind,
|
||||
type PluginHookToolKind,
|
||||
type PluginHookToolContext,
|
||||
} from "../plugins/types.js";
|
||||
import { createLazyRuntimeSurface } from "../shared/lazy-runtime.js";
|
||||
import {
|
||||
@@ -113,15 +110,8 @@ export type HookContext = {
|
||||
/** Ephemeral session UUID — regenerated on /new and /reset. */
|
||||
sessionId?: string;
|
||||
runId?: string;
|
||||
jobId?: string;
|
||||
trace?: DiagnosticTraceContext;
|
||||
trigger?: string;
|
||||
messageProvider?: string;
|
||||
channel?: string;
|
||||
chatId?: string;
|
||||
senderId?: string;
|
||||
channelId?: string;
|
||||
channelContext?: PluginHookChannelContext;
|
||||
/** Originating channel for approval delivery routing; mirrors exec approval turn-source fields. */
|
||||
turnSourceChannel?: string;
|
||||
turnSourceTo?: string;
|
||||
@@ -143,24 +133,6 @@ export type HookContext = {
|
||||
};
|
||||
};
|
||||
|
||||
/** Plain run-owned metadata that can safely be projected onto tool hook contexts. */
|
||||
export type ToolHookRunContext = Pick<
|
||||
HookContext,
|
||||
| "agentId"
|
||||
| "sessionKey"
|
||||
| "sessionId"
|
||||
| "runId"
|
||||
| "jobId"
|
||||
| "trace"
|
||||
| "trigger"
|
||||
| "messageProvider"
|
||||
| "channel"
|
||||
| "chatId"
|
||||
| "senderId"
|
||||
| "channelId"
|
||||
| "channelContext"
|
||||
>;
|
||||
|
||||
type HookBlockedKind = "veto" | "failure";
|
||||
type HookBlockedReason = "plugin-before-tool-call" | "plugin-approval" | "tool-loop";
|
||||
type HookOutcome =
|
||||
@@ -239,38 +211,6 @@ export function hasBeforeToolCallPolicy(): boolean {
|
||||
return state.hasBeforeToolCallHook || state.trustedToolPolicies.length > 0;
|
||||
}
|
||||
|
||||
/** Project the internal tool runtime context onto the public plugin hook contract. */
|
||||
export function buildPluginHookToolContext(args: {
|
||||
toolName: string;
|
||||
toolKind?: PluginHookToolKind;
|
||||
toolInputKind?: PluginHookToolInputKind;
|
||||
toolCallId?: string;
|
||||
ctx?: ToolHookRunContext;
|
||||
}): PluginHookToolContext {
|
||||
return {
|
||||
toolName: args.toolName,
|
||||
...(args.toolKind && { toolKind: args.toolKind }),
|
||||
...(args.toolInputKind && { toolInputKind: args.toolInputKind }),
|
||||
...(args.ctx?.agentId && { agentId: args.ctx.agentId }),
|
||||
...(args.ctx?.sessionKey && { sessionKey: args.ctx.sessionKey }),
|
||||
...(args.ctx?.sessionId && { sessionId: args.ctx.sessionId }),
|
||||
...(args.ctx?.runId && { runId: args.ctx.runId }),
|
||||
...(args.ctx?.jobId && { jobId: args.ctx.jobId }),
|
||||
...(args.ctx?.trace && { trace: freezeDiagnosticTraceContext(args.ctx.trace) }),
|
||||
...(args.ctx?.trigger && { trigger: args.ctx.trigger }),
|
||||
...(args.ctx?.messageProvider && { messageProvider: args.ctx.messageProvider }),
|
||||
...(args.ctx?.channel && { channel: args.ctx.channel }),
|
||||
...(args.toolCallId && { toolCallId: args.toolCallId }),
|
||||
...(args.ctx?.channelId && { channelId: args.ctx.channelId }),
|
||||
...buildAgentHookContextIdentityFields({
|
||||
trigger: args.ctx?.trigger,
|
||||
senderId: args.ctx?.senderId,
|
||||
chatId: args.ctx?.chatId,
|
||||
channelContext: args.ctx?.channelContext,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const log = createSubsystemLogger("agents/tools");
|
||||
const BEFORE_TOOL_CALL_WRAPPED = Symbol("beforeToolCallWrapped");
|
||||
const BEFORE_TOOL_CALL_DIAGNOSTIC_OPTIONS = Symbol("beforeToolCallDiagnosticOptions");
|
||||
@@ -1203,13 +1143,17 @@ export async function runBeforeToolCallHook(args: {
|
||||
...(args.toolKind && { toolKind: args.toolKind }),
|
||||
...(args.toolInputKind && { toolInputKind: args.toolInputKind }),
|
||||
};
|
||||
const buildToolContext = (identity: typeof toolIdentity) =>
|
||||
buildPluginHookToolContext({
|
||||
toolName,
|
||||
...identity,
|
||||
...(args.toolCallId && { toolCallId: args.toolCallId }),
|
||||
...(args.ctx ? { ctx: args.ctx } : {}),
|
||||
});
|
||||
const buildToolContext = (identity: typeof toolIdentity) => ({
|
||||
toolName,
|
||||
...identity,
|
||||
...(args.ctx?.agentId && { agentId: args.ctx.agentId }),
|
||||
...(args.ctx?.sessionKey && { sessionKey: args.ctx.sessionKey }),
|
||||
...(args.ctx?.sessionId && { sessionId: args.ctx.sessionId }),
|
||||
...(args.ctx?.runId && { runId: args.ctx.runId }),
|
||||
...(args.ctx?.trace && { trace: freezeDiagnosticTraceContext(args.ctx.trace) }),
|
||||
...(args.toolCallId && { toolCallId: args.toolCallId }),
|
||||
...(args.ctx?.channelId && { channelId: args.ctx.channelId }),
|
||||
});
|
||||
const toolContext = buildToolContext(toolIdentity);
|
||||
const trustedPolicyResult = shouldRunTrustedPolicies
|
||||
? await runTrustedToolPolicies(
|
||||
|
||||
@@ -201,7 +201,7 @@ describe("createOpenClawCodingTools", () => {
|
||||
expect(names.has("tool_call")).toBe(false);
|
||||
});
|
||||
|
||||
it("passes requester origin context to wrapped tool hooks", async () => {
|
||||
it("passes explicit hook channel ids to wrapped tool hooks", async () => {
|
||||
const beforeToolCall = vi.fn();
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "before_tool_call", handler: beforeToolCall }]),
|
||||
@@ -210,36 +210,15 @@ describe("createOpenClawCodingTools", () => {
|
||||
await fs.writeFile(path.join(tmpDir, "note.txt"), "hello");
|
||||
const tools = createOpenClawCodingTools({
|
||||
workspaceDir: tmpDir,
|
||||
messageChannel: "telegram",
|
||||
messageProvider: "telegram",
|
||||
toolPolicyMessageProvider: "telegram-voice",
|
||||
messageTo: "telegram:-100123",
|
||||
senderId: "speaker-1",
|
||||
trigger: "user",
|
||||
jobId: "job-1",
|
||||
hookChannelContext: {
|
||||
sender: { id: "transport-speaker" },
|
||||
chat: { id: "transport-chat" },
|
||||
},
|
||||
currentChannelId: "telegram:-100123",
|
||||
hookChannelId: "-100123",
|
||||
});
|
||||
const readTool = requireTool(tools, "read");
|
||||
await requireToolExecute(readTool)("tool-hook-channel", { path: "note.txt" });
|
||||
|
||||
expect(beforeToolCall).toHaveBeenCalledTimes(1);
|
||||
expect(beforeToolCall.mock.calls[0]?.[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
channel: "telegram",
|
||||
messageProvider: "telegram-voice",
|
||||
channelId: "-100123",
|
||||
chatId: "transport-chat",
|
||||
senderId: "speaker-1",
|
||||
trigger: "user",
|
||||
jobId: "job-1",
|
||||
channelContext: {
|
||||
sender: { id: "speaker-1" },
|
||||
chat: { id: "transport-chat" },
|
||||
},
|
||||
}),
|
||||
expect.objectContaining({ channelId: "-100123" }),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ import { resolveEventSessionRoutingPolicy } from "../infra/event-session-routing
|
||||
import { applyExecPolicyLayer } from "../infra/exec-policy.js";
|
||||
import { resolveMergedSafeBinProfileFixtures } from "../infra/exec-safe-bin-runtime-policy.js";
|
||||
import { logWarn } from "../logger.js";
|
||||
import { buildAgentHookContextOriginFields } from "../plugins/hook-agent-context.js";
|
||||
import type { PluginHookChannelContext } from "../plugins/hook-types.js";
|
||||
import { getPluginToolMeta } from "../plugins/tools.js";
|
||||
import { createLazyImportLoader } from "../shared/lazy-promise.js";
|
||||
@@ -471,12 +470,8 @@ export function createOpenClawCodingTools(options?: {
|
||||
currentMessagingTarget?: string;
|
||||
/** Normalized conversation id exposed to tool hooks. Defaults to currentChannelId. */
|
||||
hookChannelId?: string;
|
||||
/** Transport-native conversation id exposed to tool hooks when available. */
|
||||
chatId?: string;
|
||||
/** Channel-owned sender/chat metadata exposed to subprocess environments. */
|
||||
channelContext?: PluginHookChannelContext;
|
||||
/** Channel-owned sender/chat metadata exposed only to plugin tool hooks. */
|
||||
hookChannelContext?: PluginHookChannelContext;
|
||||
/** Current thread timestamp for auto-threading (Slack). */
|
||||
currentThreadTs?: string;
|
||||
/** Current inbound message id for action fallbacks (e.g. Telegram react). */
|
||||
@@ -1192,8 +1187,7 @@ export function createOpenClawCodingTools(options?: {
|
||||
);
|
||||
options?.recordToolPrepStage?.("schema-normalization");
|
||||
const turnSourceChannel = options?.messageChannel ?? options?.messageProvider;
|
||||
const turnSourceTo =
|
||||
options?.currentMessagingTarget ?? options?.messageTo ?? options?.currentChannelId;
|
||||
const turnSourceTo = options?.currentMessagingTarget ?? options?.currentChannelId;
|
||||
const hookContext = {
|
||||
agentId,
|
||||
...(options?.config ? { config: options.config } : {}),
|
||||
@@ -1206,19 +1200,7 @@ export function createOpenClawCodingTools(options?: {
|
||||
sessionKey: options?.sessionKey,
|
||||
sessionId: options?.sessionId,
|
||||
runId: options?.runId,
|
||||
jobId: options?.jobId,
|
||||
trigger: options?.trigger,
|
||||
...buildAgentHookContextOriginFields({
|
||||
sessionKey: options?.sessionKey,
|
||||
messageChannel: options?.messageChannel,
|
||||
messageProvider: options?.toolPolicyMessageProvider ?? options?.messageProvider,
|
||||
currentChannelId: options?.hookChannelId ?? options?.currentChannelId,
|
||||
messageTo: options?.currentMessagingTarget ?? options?.messageTo,
|
||||
trigger: options?.trigger,
|
||||
senderId: options?.senderId,
|
||||
chatId: options?.chatId,
|
||||
channelContext: options?.hookChannelContext ?? options?.channelContext,
|
||||
}),
|
||||
channelId: options?.hookChannelId ?? options?.currentChannelId,
|
||||
...(turnSourceChannel ? { turnSourceChannel } : {}),
|
||||
...(turnSourceTo ? { turnSourceTo } : {}),
|
||||
...(options?.agentAccountId ? { turnSourceAccountId: options.agentAccountId } : {}),
|
||||
|
||||
@@ -629,11 +629,6 @@ describe("runBtwSideQuestion", () => {
|
||||
senderName: "Rosita",
|
||||
senderUsername: "rosita",
|
||||
senderE164: "+15550001",
|
||||
chatId: "native-chat-1",
|
||||
channelContext: {
|
||||
sender: { id: "sender-1", providerUserId: "provider-user-1" },
|
||||
chat: { id: "native-chat-1", providerThreadKey: "thread-key-1" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({ text: "Codex side answer." });
|
||||
@@ -658,11 +653,6 @@ describe("runBtwSideQuestion", () => {
|
||||
senderName?: string;
|
||||
senderUsername?: string;
|
||||
senderE164?: string;
|
||||
chatId?: string;
|
||||
channelContext?: {
|
||||
sender?: Record<string, unknown>;
|
||||
chat?: Record<string, unknown>;
|
||||
};
|
||||
toolsAllow?: string[];
|
||||
},
|
||||
]
|
||||
@@ -685,11 +675,6 @@ describe("runBtwSideQuestion", () => {
|
||||
senderName: "Rosita",
|
||||
senderUsername: "rosita",
|
||||
senderE164: "+15550001",
|
||||
chatId: "native-chat-1",
|
||||
channelContext: {
|
||||
sender: { id: "sender-1", providerUserId: "provider-user-1" },
|
||||
chat: { id: "native-chat-1", providerThreadKey: "thread-key-1" },
|
||||
},
|
||||
});
|
||||
expect(
|
||||
(mockArg(codexSideQuestionMock, 0, 0) as { sessionFile?: string }).sessionFile,
|
||||
|
||||
@@ -18,7 +18,6 @@ import type {
|
||||
Model,
|
||||
TextContent,
|
||||
} from "../llm/types.js";
|
||||
import type { PluginHookChannelContext } from "../plugins/hook-types.js";
|
||||
import { prepareProviderRuntimeAuth } from "../plugins/provider-runtime.js";
|
||||
import { discoverAuthStorage, discoverModels } from "./agent-model-discovery.js";
|
||||
import { resolveAgentWorkspaceDir, resolveSessionAgentId } from "./agent-scope.js";
|
||||
@@ -389,8 +388,6 @@ type RunBtwSideQuestionParams = {
|
||||
senderUsername?: string | null;
|
||||
senderE164?: string | null;
|
||||
senderIsOwner?: boolean;
|
||||
chatId?: string;
|
||||
channelContext?: PluginHookChannelContext;
|
||||
currentChannelId?: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -52,7 +52,6 @@ import { getCurrentPluginMetadataSnapshot } from "../../../plugins/current-plugi
|
||||
import {
|
||||
buildAgentHookContextChannelFields,
|
||||
buildAgentHookContextIdentityFields,
|
||||
buildAgentHookContextOriginFields,
|
||||
} from "../../../plugins/hook-agent-context.js";
|
||||
import { resolveBlockMessage } from "../../../plugins/hook-decision-types.js";
|
||||
import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js";
|
||||
@@ -1242,7 +1241,6 @@ export async function runEmbeddedAttempt(
|
||||
toolConstructionPlan.constructTools ||
|
||||
toolSearchControlsEnabledForRun ||
|
||||
codeModeControlsEnabledForRun;
|
||||
const toolPolicyMessageProvider = resolveAttemptToolPolicyMessageProvider(params);
|
||||
let toolSearchCatalogExecutor: ToolSearchCatalogToolExecutor | undefined;
|
||||
toolSearchCatalogRef =
|
||||
toolSearchControlsEnabledForRun || codeModeControlsEnabledForRun
|
||||
@@ -1263,7 +1261,7 @@ export async function runEmbeddedAttempt(
|
||||
elevated: params.bashElevated,
|
||||
},
|
||||
sandbox,
|
||||
messageProvider: toolPolicyMessageProvider,
|
||||
messageProvider: resolveAttemptToolPolicyMessageProvider(params),
|
||||
agentAccountId: params.agentAccountId,
|
||||
messageTo: params.messageTo,
|
||||
messageThreadId: params.messageThreadId,
|
||||
@@ -1316,7 +1314,6 @@ export async function runEmbeddedAttempt(
|
||||
}),
|
||||
currentChannelId: params.currentChannelId,
|
||||
currentMessagingTarget: params.currentMessagingTarget,
|
||||
chatId: params.chatId,
|
||||
currentThreadTs: params.currentThreadTs,
|
||||
currentMessageId: params.currentMessageId,
|
||||
currentInboundAudio: params.currentInboundAudio,
|
||||
@@ -1664,19 +1661,7 @@ export async function runEmbeddedAttempt(
|
||||
sessionKey: sandboxSessionKey,
|
||||
sessionId: params.sessionId,
|
||||
runId: params.runId,
|
||||
jobId: params.jobId,
|
||||
trigger: params.trigger,
|
||||
...buildAgentHookContextOriginFields({
|
||||
sessionKey: sandboxSessionKey,
|
||||
messageChannel: params.messageChannel,
|
||||
messageProvider: toolPolicyMessageProvider,
|
||||
currentChannelId: params.currentChannelId,
|
||||
messageTo: params.currentMessagingTarget ?? params.messageTo,
|
||||
trigger: params.trigger,
|
||||
senderId: params.senderId,
|
||||
chatId: params.chatId,
|
||||
channelContext: params.channelContext,
|
||||
}),
|
||||
channelId: params.currentChannelId,
|
||||
trace: runTrace,
|
||||
loopDetection: resolveToolLoopDetectionConfig({
|
||||
cfg: params.config,
|
||||
@@ -2436,9 +2421,14 @@ export async function runEmbeddedAttempt(
|
||||
},
|
||||
},
|
||||
{
|
||||
...catalogToolHookContext,
|
||||
agentId: sessionAgentId,
|
||||
sessionKey: sandboxSessionKey,
|
||||
config: toolSearchRuntimeConfig,
|
||||
sessionId: params.sessionId,
|
||||
runId: params.runId,
|
||||
loopDetection: clientToolLoopDetection,
|
||||
onToolOutcome: params.onToolOutcome,
|
||||
allocateToolOutcomeOrdinal: params.allocateToolOutcomeOrdinal,
|
||||
},
|
||||
)
|
||||
: [];
|
||||
@@ -3565,7 +3555,6 @@ export async function runEmbeddedAttempt(
|
||||
messageChannel: runtimeChannel,
|
||||
initialReplayState: params.initialReplayState,
|
||||
hookRunner: getGlobalHookRunner() ?? undefined,
|
||||
toolHookContext: catalogToolHookContext,
|
||||
verboseLevel: params.verboseLevel,
|
||||
reasoningMode: params.reasoningLevel ?? "off",
|
||||
thinkingLevel: params.thinkLevel,
|
||||
|
||||
@@ -34,7 +34,6 @@ import type { PluginHookAfterToolCallEvent } from "../plugins/types.js";
|
||||
import { createLazyImportLoader } from "../shared/lazy-promise.js";
|
||||
import { truncateUtf16Safe } from "../utils.js";
|
||||
import { normalizeAcceptedSessionSpawnResult } from "./accepted-session-spawn.js";
|
||||
import { buildPluginHookToolContext } from "./agent-tools.before-tool-call.js";
|
||||
import {
|
||||
consumeAdjustedParamsForToolCall,
|
||||
consumePreExecutionBlockedToolCall,
|
||||
@@ -1574,20 +1573,14 @@ export async function handleToolExecutionEnd(
|
||||
durationMs,
|
||||
};
|
||||
void hookRunnerAfter
|
||||
.runAfterToolCall(
|
||||
hookEvent,
|
||||
buildPluginHookToolContext({
|
||||
toolName,
|
||||
toolCallId,
|
||||
ctx: {
|
||||
...ctx.params.toolHookContext,
|
||||
...(ctx.params.agentId ? { agentId: ctx.params.agentId } : {}),
|
||||
...(ctx.params.sessionKey ? { sessionKey: ctx.params.sessionKey } : {}),
|
||||
...(ctx.params.sessionId ? { sessionId: ctx.params.sessionId } : {}),
|
||||
runId,
|
||||
},
|
||||
}),
|
||||
)
|
||||
.runAfterToolCall(hookEvent, {
|
||||
toolName,
|
||||
agentId: ctx.params.agentId,
|
||||
sessionKey: ctx.params.sessionKey,
|
||||
sessionId: ctx.params.sessionId,
|
||||
runId,
|
||||
toolCallId,
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
ctx.log.warn(`after_tool_call hook failed: tool=${toolName} error=${String(err)}`);
|
||||
});
|
||||
|
||||
@@ -272,7 +272,6 @@ export type EmbeddedAgentSubscribeContext = {
|
||||
type ToolHandlerParams = Pick<
|
||||
SubscribeEmbeddedAgentSessionParams,
|
||||
| "runId"
|
||||
| "toolHookContext"
|
||||
| "onBlockReplyFlush"
|
||||
| "onAgentEvent"
|
||||
| "onToolStreamBoundary"
|
||||
|
||||
@@ -10,7 +10,6 @@ import type { ReplyPayload } from "../auto-reply/reply-payload.js";
|
||||
import type { ReasoningLevel, ThinkLevel, VerboseLevel } from "../auto-reply/thinking.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { HookRunner } from "../plugins/hooks.js";
|
||||
import type { ToolHookRunContext } from "./agent-tools.before-tool-call.js";
|
||||
import type { BlockReplyPayload } from "./embedded-agent-payloads.js";
|
||||
import type { EmbeddedRunReplayState } from "./embedded-agent-runner/replay-state.js";
|
||||
import type {
|
||||
@@ -36,8 +35,6 @@ export type SubscribeEmbeddedAgentSessionParams = {
|
||||
messageChannel?: string;
|
||||
initialReplayState?: EmbeddedRunReplayState;
|
||||
hookRunner?: HookRunner;
|
||||
/** Internal run context projected onto before/after tool hook contracts. */
|
||||
toolHookContext?: ToolHookRunContext;
|
||||
verboseLevel?: VerboseLevel;
|
||||
reasoningMode?: ReasoningLevel;
|
||||
thinkingLevel?: ThinkLevel;
|
||||
|
||||
@@ -6,26 +6,25 @@
|
||||
*/
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
|
||||
import {
|
||||
buildPluginHookToolContext,
|
||||
consumeAdjustedParamsForToolCall,
|
||||
type ToolHookRunContext,
|
||||
} from "../agent-tools.before-tool-call.js";
|
||||
import { consumeAdjustedParamsForToolCall } from "../agent-tools.before-tool-call.js";
|
||||
import type { AgentMessage } from "../runtime/index.js";
|
||||
|
||||
const log = createSubsystemLogger("agents/harness");
|
||||
|
||||
/** Runs best-effort after-tool-call hooks for a completed tool invocation. */
|
||||
export async function runAgentHarnessAfterToolCallHook(
|
||||
params: ToolHookRunContext & {
|
||||
toolName: string;
|
||||
toolCallId: string;
|
||||
startArgs: Record<string, unknown>;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
startedAt?: number;
|
||||
},
|
||||
): Promise<void> {
|
||||
export async function runAgentHarnessAfterToolCallHook(params: {
|
||||
toolName: string;
|
||||
toolCallId: string;
|
||||
runId?: string;
|
||||
agentId?: string;
|
||||
sessionId?: string;
|
||||
sessionKey?: string;
|
||||
channelId?: string;
|
||||
startArgs: Record<string, unknown>;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
startedAt?: number;
|
||||
}): Promise<void> {
|
||||
const adjustedArgs = consumeAdjustedParamsForToolCall(params.toolCallId, params.runId);
|
||||
// Hooks should see adjusted tool params when before_tool_call rewrote them.
|
||||
const resolvedArgs =
|
||||
@@ -48,11 +47,15 @@ export async function runAgentHarnessAfterToolCallHook(
|
||||
...(params.error ? { error: params.error } : {}),
|
||||
...(params.startedAt != null ? { durationMs: Date.now() - params.startedAt } : {}),
|
||||
},
|
||||
buildPluginHookToolContext({
|
||||
{
|
||||
toolName: params.toolName,
|
||||
...(params.agentId ? { agentId: params.agentId } : {}),
|
||||
...(params.sessionId ? { sessionId: params.sessionId } : {}),
|
||||
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
|
||||
...(params.runId ? { runId: params.runId } : {}),
|
||||
...(params.channelId ? { channelId: params.channelId } : {}),
|
||||
toolCallId: params.toolCallId,
|
||||
ctx: params,
|
||||
}),
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
log.warn(`after_tool_call hook failed: tool=${params.toolName} error=${String(error)}`);
|
||||
|
||||
@@ -1637,19 +1637,6 @@ describe("native hook relay registry", () => {
|
||||
sessionKey: "agent:main:session-1",
|
||||
runId: "run-1",
|
||||
channelId: "telegram",
|
||||
toolHookContext: {
|
||||
jobId: "job-1",
|
||||
trigger: "user",
|
||||
messageProvider: "telegram-voice",
|
||||
channel: "telegram",
|
||||
chatId: "chat-1",
|
||||
senderId: "user-1",
|
||||
channelId: "telegram",
|
||||
channelContext: {
|
||||
sender: { id: "user-1" },
|
||||
chat: { id: "chat-1" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const response = await invokeNativeHookRelay({
|
||||
@@ -1687,17 +1674,7 @@ describe("native hook relay registry", () => {
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
runId: "run-1",
|
||||
jobId: "job-1",
|
||||
trigger: "user",
|
||||
messageProvider: "telegram-voice",
|
||||
channel: "telegram",
|
||||
chatId: "chat-1",
|
||||
senderId: "user-1",
|
||||
channelId: "telegram",
|
||||
channelContext: {
|
||||
sender: { id: "user-1" },
|
||||
chat: { id: "chat-1" },
|
||||
},
|
||||
toolName: "exec",
|
||||
toolCallId: "native-call-1",
|
||||
});
|
||||
@@ -1718,19 +1695,6 @@ describe("native hook relay registry", () => {
|
||||
sessionKey: "agent:main:session-1",
|
||||
runId: "run-1",
|
||||
channelId: "telegram",
|
||||
toolHookContext: {
|
||||
jobId: "job-1",
|
||||
trigger: "user",
|
||||
messageProvider: "telegram-voice",
|
||||
channel: "telegram",
|
||||
chatId: "chat-1",
|
||||
senderId: "user-1",
|
||||
channelId: "telegram",
|
||||
channelContext: {
|
||||
sender: { id: "user-1" },
|
||||
chat: { id: "chat-1" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const response = await invokeNativeHookRelay({
|
||||
@@ -2279,19 +2243,6 @@ describe("native hook relay registry", () => {
|
||||
sessionKey: "agent:main:session-1",
|
||||
runId: "run-1",
|
||||
channelId: "telegram",
|
||||
toolHookContext: {
|
||||
jobId: "job-1",
|
||||
trigger: "user",
|
||||
messageProvider: "telegram-voice",
|
||||
channel: "telegram",
|
||||
chatId: "chat-1",
|
||||
senderId: "user-1",
|
||||
channelId: "telegram",
|
||||
channelContext: {
|
||||
sender: { id: "user-1" },
|
||||
chat: { id: "chat-1" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const response = await invokeNativeHookRelay({
|
||||
@@ -2322,17 +2273,7 @@ describe("native hook relay registry", () => {
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
runId: "run-1",
|
||||
jobId: "job-1",
|
||||
trigger: "user",
|
||||
messageProvider: "telegram-voice",
|
||||
channel: "telegram",
|
||||
chatId: "chat-1",
|
||||
senderId: "user-1",
|
||||
channelId: "telegram",
|
||||
channelContext: {
|
||||
sender: { id: "user-1" },
|
||||
chat: { id: "chat-1" },
|
||||
},
|
||||
toolName: "exec",
|
||||
toolCallId: "native-call-1",
|
||||
});
|
||||
|
||||
@@ -37,7 +37,6 @@ import {
|
||||
requestDeferredPluginToolApproval,
|
||||
runBeforeToolCallHook,
|
||||
type DeferredPluginToolApproval,
|
||||
type ToolHookRunContext,
|
||||
} from "../agent-tools.before-tool-call.js";
|
||||
import { stableStringify } from "../stable-stringify.js";
|
||||
import { resolveToolLoopDetectionConfig } from "../tool-loop-detection-config.js";
|
||||
@@ -105,7 +104,6 @@ export type NativeHookRelayRegistration = {
|
||||
config?: OpenClawConfig;
|
||||
runId: string;
|
||||
channelId?: string;
|
||||
toolHookContext?: ToolHookRunContext;
|
||||
allowedEvents: readonly NativeHookRelayEvent[];
|
||||
expiresAtMs: number;
|
||||
signal?: AbortSignal;
|
||||
@@ -133,7 +131,6 @@ export type RegisterNativeHookRelayParams = {
|
||||
config?: OpenClawConfig;
|
||||
runId: string;
|
||||
channelId?: string;
|
||||
toolHookContext?: ToolHookRunContext;
|
||||
allowedEvents?: readonly NativeHookRelayEvent[];
|
||||
ttlMs?: number;
|
||||
command?: NativeHookRelayCommandOptions;
|
||||
@@ -438,7 +435,6 @@ export function registerNativeHookRelay(
|
||||
...(params.config ? { config: params.config } : {}),
|
||||
runId: params.runId,
|
||||
...(params.channelId ? { channelId: params.channelId } : {}),
|
||||
...(params.toolHookContext ? { toolHookContext: params.toolHookContext } : {}),
|
||||
allowedEvents,
|
||||
expiresAtMs,
|
||||
...(params.signal ? { signal: params.signal } : {}),
|
||||
@@ -1402,7 +1398,6 @@ async function runNativeHookRelayPreToolUse(params: {
|
||||
...(approvalMode === "report" ? { approvalMode: "defer" } : {}),
|
||||
signal: params.registration.signal,
|
||||
ctx: {
|
||||
...params.registration.toolHookContext,
|
||||
...(params.registration.agentId ? { agentId: params.registration.agentId } : {}),
|
||||
sessionId: params.registration.sessionId,
|
||||
...(params.registration.sessionKey ? { sessionKey: params.registration.sessionKey } : {}),
|
||||
@@ -1452,7 +1447,6 @@ async function runNativeHookRelayPostToolUse(params: {
|
||||
await runAgentHarnessAfterToolCallHook({
|
||||
toolName,
|
||||
toolCallId,
|
||||
...params.registration.toolHookContext,
|
||||
runId: params.registration.runId,
|
||||
...(params.registration.agentId ? { agentId: params.registration.agentId } : {}),
|
||||
sessionId: params.registration.sessionId,
|
||||
|
||||
@@ -65,8 +65,6 @@ export type AgentHarnessSideQuestionParams = {
|
||||
senderUsername?: string | null;
|
||||
senderE164?: string | null;
|
||||
senderIsOwner?: boolean;
|
||||
chatId?: string;
|
||||
channelContext?: import("../../plugins/hook-types.js").PluginHookChannelContext;
|
||||
currentChannelId?: string;
|
||||
toolsAllow?: string[];
|
||||
authProfileId?: string;
|
||||
|
||||
@@ -73,6 +73,7 @@ function cleanedLockForPath(lockPath: string): SessionLockInspection {
|
||||
ageMs: 1_000,
|
||||
stale: true,
|
||||
staleReasons: ["dead-pid"],
|
||||
removable: true,
|
||||
removed: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ export type SessionLockInspection = {
|
||||
ageMs: number | null;
|
||||
stale: boolean;
|
||||
staleReasons: string[];
|
||||
removable: boolean;
|
||||
removed: boolean;
|
||||
};
|
||||
|
||||
@@ -858,13 +859,15 @@ export async function cleanStaleLockFiles(params: {
|
||||
reclaimLockWithoutStarttime: false,
|
||||
readOwnerProcessArgs: ownerProcessArgsReader,
|
||||
});
|
||||
const removable = await shouldRemoveLockDuringCleanup(lockPath, inspected, staleMs, nowMs);
|
||||
const lockInfo: SessionLockInspection = {
|
||||
lockPath,
|
||||
...inspected,
|
||||
removable,
|
||||
removed: false,
|
||||
};
|
||||
|
||||
if (removeStale && (await shouldRemoveLockDuringCleanup(lockPath, lockInfo, staleMs, nowMs))) {
|
||||
if (removeStale && removable) {
|
||||
await fs.rm(lockPath, { force: true });
|
||||
lockInfo.removed = true;
|
||||
cleaned.push(lockInfo);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { ToolLoopDetectionConfig } from "../config/types.tools.js";
|
||||
import { resolveAgentConfig } from "./agent-scope-config.js";
|
||||
import { resolveAgentConfig } from "./agent-scope.js";
|
||||
|
||||
/** Resolves effective tool loop-detection config by overlaying agent settings on globals. */
|
||||
export function resolveToolLoopDetectionConfig(params: {
|
||||
|
||||
@@ -2167,6 +2167,7 @@ describe("cron tool", () => {
|
||||
expect(params?.patch?.payload).toEqual({
|
||||
kind: "agentTurn",
|
||||
toolsAllow: ["read", "cron"],
|
||||
toolsAllowIsDefault: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2202,6 +2203,7 @@ describe("cron tool", () => {
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
toolsAllow: ["read", "cron"],
|
||||
toolsAllowIsDefault: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -2278,6 +2280,51 @@ describe("cron tool", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the default toolsAllow flag across an update that omits toolsAllow", async () => {
|
||||
// Regression guard: a routine update (here, toggling enabled) of an
|
||||
// agentTurn job whose cap was an auto-stamped default must keep
|
||||
// toolsAllowIsDefault set. Otherwise the run-time CLI drop (which keys off
|
||||
// the flag) stops applying and the job fails closed again after a restart —
|
||||
// re-breaking the exact #91499 regression this change fixes.
|
||||
callGatewayMock
|
||||
.mockResolvedValueOnce({
|
||||
id: "job-13",
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: "hi",
|
||||
toolsAllow: ["read", "cron"],
|
||||
toolsAllowIsDefault: true,
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const tool = createTestCronTool({
|
||||
agentSessionKey: "agent:main:telegram:group:restricted-room",
|
||||
creatorToolAllowlist: ["read", "cron"],
|
||||
});
|
||||
await tool.execute("call-update-preserve-default-flag", {
|
||||
action: "update",
|
||||
id: "job-13",
|
||||
patch: { enabled: false },
|
||||
});
|
||||
|
||||
expect(callGatewayMock).toHaveBeenCalledTimes(2);
|
||||
expect(readGatewayCall(1)).toEqual({
|
||||
method: "cron.update",
|
||||
params: {
|
||||
id: "job-13",
|
||||
patch: {
|
||||
enabled: false,
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
toolsAllow: ["read", "cron"],
|
||||
toolsAllowIsDefault: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("adds the creator tool surface when converting an existing job to agentTurn", async () => {
|
||||
callGatewayMock
|
||||
.mockResolvedValueOnce({
|
||||
@@ -2310,6 +2357,7 @@ describe("cron tool", () => {
|
||||
kind: "agentTurn",
|
||||
message: "run later",
|
||||
toolsAllow: ["read", "cron"],
|
||||
toolsAllowIsDefault: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -466,6 +466,7 @@ function capCronAgentTurnToolsAllow(params: {
|
||||
: params.defaultToolsAllow;
|
||||
if (!Array.isArray(requestedRaw)) {
|
||||
params.payload.toolsAllow = creatorToolNames;
|
||||
params.payload.toolsAllowIsDefault = true;
|
||||
return;
|
||||
}
|
||||
const requestedToolsAllow = normalizeCronToolsAllow(
|
||||
@@ -473,10 +474,12 @@ function capCronAgentTurnToolsAllow(params: {
|
||||
);
|
||||
if (requestedToolsAllow.length === 0) {
|
||||
params.payload.toolsAllow = [];
|
||||
delete params.payload.toolsAllowIsDefault;
|
||||
return;
|
||||
}
|
||||
if (requestedToolsAllow.includes("*")) {
|
||||
params.payload.toolsAllow = creatorToolNames;
|
||||
params.payload.toolsAllowIsDefault = true;
|
||||
return;
|
||||
}
|
||||
const pluginGroups = buildPluginToolGroups({
|
||||
@@ -490,6 +493,7 @@ function capCronAgentTurnToolsAllow(params: {
|
||||
params.payload.toolsAllow = creatorToolNames.filter((toolName) =>
|
||||
isToolAllowedByPolicyName(toolName, requestedPolicy),
|
||||
);
|
||||
delete params.payload.toolsAllowIsDefault;
|
||||
}
|
||||
|
||||
function capCronAgentTurnJobToolsAllow(
|
||||
@@ -549,8 +553,12 @@ async function capCronAgentTurnUpdatePatchToolsAllow(params: {
|
||||
capCronAgentTurnToolsAllow({
|
||||
payload: nextPayload,
|
||||
creatorToolAllowlist: params.creatorToolAllowlist,
|
||||
// Flagged defaults are re-derived so normal updates do not turn them into
|
||||
// explicit restrictions or lose the marker needed after restart.
|
||||
defaultToolsAllow:
|
||||
existingPayloadKind === "agentTurn" && isRecord(existingPayload)
|
||||
existingPayloadKind === "agentTurn" &&
|
||||
isRecord(existingPayload) &&
|
||||
existingPayload.toolsAllowIsDefault !== true
|
||||
? existingPayload.toolsAllow
|
||||
: undefined,
|
||||
});
|
||||
|
||||
@@ -130,12 +130,6 @@ describe("handleBtwCommand", () => {
|
||||
params.ctx.SenderUsername = "rosita";
|
||||
params.ctx.SenderE164 = "+15550001";
|
||||
params.ctx.MessageThreadId = "thread-1";
|
||||
params.ctx.NativeChannelId = "native-chat-1";
|
||||
params.ctx.ChatId = "legacy-chat";
|
||||
params.ctx.ChannelContext = {
|
||||
sender: { id: "sender-1", providerUserId: "provider-user-1" },
|
||||
chat: { id: "native-chat-1", providerThreadKey: "thread-key-1" },
|
||||
};
|
||||
params.agentDir = "/tmp/agent";
|
||||
params.sessionEntry = {
|
||||
sessionId: "session-1",
|
||||
@@ -167,11 +161,6 @@ describe("handleBtwCommand", () => {
|
||||
senderUsername: "rosita",
|
||||
senderE164: "+15550001",
|
||||
senderIsOwner: true,
|
||||
chatId: "native-chat-1",
|
||||
channelContext: {
|
||||
sender: { id: "sender-1", providerUserId: "provider-user-1" },
|
||||
chat: { id: "native-chat-1", providerThreadKey: "thread-key-1" },
|
||||
},
|
||||
});
|
||||
expect(String(runnerArgs.agentDir)).toContain("/agents/main/agent");
|
||||
expect(result).toEqual({
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
/** Handles /btw side-question commands against the active session context. */
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { resolveAgentDir, resolveSessionAgentId } from "../../agents/agent-scope.js";
|
||||
import { runBtwSideQuestion } from "../../agents/btw.js";
|
||||
import { resolveGroupSessionKey } from "../../config/sessions/group.js";
|
||||
@@ -58,12 +57,6 @@ export const handleBtwCommand: CommandHandler = async (params, allowTextCommands
|
||||
await params.typing?.startTypingLoop();
|
||||
const currentChannelId =
|
||||
params.ctx.OriginatingTo?.trim() || params.command.to || params.command.channelId;
|
||||
const chatId =
|
||||
normalizeOptionalString(params.ctx.NativeChannelId) ??
|
||||
normalizeOptionalString(params.ctx.ChatId) ??
|
||||
normalizeOptionalString(params.rootCtx?.NativeChannelId) ??
|
||||
normalizeOptionalString(params.rootCtx?.ChatId);
|
||||
const channelContext = params.ctx.ChannelContext ?? params.rootCtx?.ChannelContext;
|
||||
const groupId = resolveGroupSessionKey(params.ctx)?.id ?? targetSessionEntry.groupId;
|
||||
const reply = await runBtwSideQuestion({
|
||||
cfg: params.cfg,
|
||||
@@ -116,8 +109,6 @@ export const handleBtwCommand: CommandHandler = async (params, allowTextCommands
|
||||
...(params.ctx.SenderUsername ? { senderUsername: params.ctx.SenderUsername } : {}),
|
||||
...(params.ctx.SenderE164 ? { senderE164: params.ctx.SenderE164 } : {}),
|
||||
senderIsOwner: params.command.senderIsOwner,
|
||||
...(chatId ? { chatId } : {}),
|
||||
...(channelContext ? { channelContext } : {}),
|
||||
...(currentChannelId ? { currentChannelId } : {}),
|
||||
});
|
||||
return {
|
||||
|
||||
@@ -155,7 +155,7 @@ describe("runDoctorLintCli", () => {
|
||||
try {
|
||||
const exitCode = await runDoctorLintCli(runtime, {
|
||||
json: true,
|
||||
onlyIds: ["core/doctor/session-locks"],
|
||||
onlyIds: ["core/doctor/not-a-check"],
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
@@ -167,7 +167,7 @@ describe("runDoctorLintCli", () => {
|
||||
{
|
||||
checkId: "core/doctor/lint-selection",
|
||||
severity: "error",
|
||||
path: "core/doctor/session-locks",
|
||||
path: "core/doctor/not-a-check",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -13,7 +13,12 @@ vi.mock("../../packages/terminal-core/src/note.js", () => ({
|
||||
note,
|
||||
}));
|
||||
|
||||
import { noteSessionLockHealth } from "./doctor-session-locks.js";
|
||||
import {
|
||||
detectStaleSessionLocks,
|
||||
noteSessionLockHealth,
|
||||
sessionLockToHealthFinding,
|
||||
sessionLockToRepairEffect,
|
||||
} from "./doctor-session-locks.js";
|
||||
|
||||
async function expectPathMissing(targetPath: string): Promise<void> {
|
||||
try {
|
||||
@@ -105,6 +110,154 @@ describe("noteSessionLockHealth", () => {
|
||||
await expect(fs.access(freshLock)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("detects stale locks without removing them for structured lint", async () => {
|
||||
const sessionsDir = state.sessionsDir();
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
|
||||
const staleLock = path.join(sessionsDir, "stale.jsonl.lock");
|
||||
const freshLock = path.join(sessionsDir, "fresh.jsonl.lock");
|
||||
|
||||
await fs.writeFile(
|
||||
staleLock,
|
||||
JSON.stringify({ pid: -1, createdAt: new Date(Date.now() - 120_000).toISOString() }),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
freshLock,
|
||||
JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const locks = await detectStaleSessionLocks({
|
||||
staleMs: 30_000,
|
||||
readOwnerProcessArgs: () => ["node", "/opt/openclaw/openclaw.mjs", "doctor"],
|
||||
});
|
||||
|
||||
expect(locks).toHaveLength(1);
|
||||
expect(locks[0]?.lockPath).toBe(staleLock);
|
||||
await expect(fs.access(staleLock)).resolves.toBeUndefined();
|
||||
await expect(fs.access(freshLock)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("maps stale locks to structured findings and dry-run effects", async () => {
|
||||
const sessionsDir = state.sessionsDir();
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
const lockPath = path.join(sessionsDir, "stale.jsonl.lock");
|
||||
await fs.writeFile(
|
||||
lockPath,
|
||||
JSON.stringify({ pid: -1, createdAt: new Date(Date.now() - 120_000).toISOString() }),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const [lock] = await detectStaleSessionLocks({
|
||||
staleMs: 30_000,
|
||||
readOwnerProcessArgs: () => ["node", "/opt/openclaw/openclaw.mjs", "doctor"],
|
||||
});
|
||||
if (!lock) {
|
||||
throw new Error("expected stale session lock");
|
||||
}
|
||||
|
||||
expect(sessionLockToHealthFinding(lock)).toEqual(
|
||||
expect.objectContaining({
|
||||
checkId: "core/doctor/session-locks",
|
||||
severity: "warning",
|
||||
path: lockPath,
|
||||
}),
|
||||
);
|
||||
expect(sessionLockToRepairEffect(lock)).toEqual({
|
||||
kind: "state",
|
||||
action: "would-remove-stale-session-lock",
|
||||
target: lockPath,
|
||||
dryRunSafe: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves fresh malformed stale locks in dry-run repair effects", async () => {
|
||||
const sessionsDir = state.sessionsDir();
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
|
||||
const malformedLock = path.join(sessionsDir, "malformed.jsonl.lock");
|
||||
await fs.writeFile(malformedLock, "{}", "utf8");
|
||||
|
||||
const [lock] = await detectStaleSessionLocks({
|
||||
staleMs: 30_000,
|
||||
readOwnerProcessArgs: () => ["node", "/opt/openclaw/openclaw.mjs", "doctor"],
|
||||
});
|
||||
if (!lock) {
|
||||
throw new Error("expected stale session lock");
|
||||
}
|
||||
|
||||
expect(lock.staleReasons).toEqual(["missing-pid", "invalid-createdAt"]);
|
||||
expect(lock.removable).toBe(false);
|
||||
expect(sessionLockToHealthFinding(lock).fixHint).toContain("after the cleanup grace period");
|
||||
expect(sessionLockToRepairEffect(lock)).toEqual({
|
||||
kind: "state",
|
||||
action: "would-preserve-mtime-gated-stale-session-lock",
|
||||
target: malformedLock,
|
||||
dryRunSafe: false,
|
||||
});
|
||||
await expect(fs.access(malformedLock)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses the supplied env to choose the structured lint state dir", async () => {
|
||||
const other = await createOpenClawTestState({
|
||||
layout: "state-only",
|
||||
prefix: "openclaw-doctor-locks-other-",
|
||||
applyEnv: false,
|
||||
});
|
||||
try {
|
||||
await fs.mkdir(other.sessionsDir(), { recursive: true });
|
||||
const lockPath = path.join(other.sessionsDir(), "other-stale.jsonl.lock");
|
||||
await fs.writeFile(
|
||||
lockPath,
|
||||
JSON.stringify({ pid: -1, createdAt: new Date(Date.now() - 120_000).toISOString() }),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const locks = await detectStaleSessionLocks({
|
||||
env: other.env,
|
||||
staleMs: 30_000,
|
||||
readOwnerProcessArgs: () => ["node", "/opt/openclaw/openclaw.mjs", "doctor"],
|
||||
});
|
||||
|
||||
expect(locks.map((lock) => lock.lockPath)).toEqual([lockPath]);
|
||||
} finally {
|
||||
await other.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves report-only live OpenClaw locks in dry-run repair effects", async () => {
|
||||
const sessionsDir = state.sessionsDir();
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
|
||||
const reportOnlyLock = path.join(sessionsDir, "report-only.jsonl.lock");
|
||||
await fs.writeFile(
|
||||
reportOnlyLock,
|
||||
JSON.stringify({ pid: process.pid, createdAt: new Date(Date.now() - 45_000).toISOString() }),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const [lock] = await detectStaleSessionLocks({
|
||||
staleMs: 30_000,
|
||||
readOwnerProcessArgs: () => ["node", "/opt/openclaw/openclaw.mjs", "doctor"],
|
||||
});
|
||||
if (!lock) {
|
||||
throw new Error("expected stale session lock");
|
||||
}
|
||||
|
||||
expect(lock.staleReasons).toEqual(["too-old"]);
|
||||
expect(sessionLockToHealthFinding(lock).fixHint).toBe(
|
||||
"OpenClaw is preserving this live owned lock; inspect the owning process if it appears stuck.",
|
||||
);
|
||||
expect(sessionLockToRepairEffect(lock)).toEqual({
|
||||
kind: "state",
|
||||
action: "would-preserve-report-only-stale-session-lock",
|
||||
target: reportOnlyLock,
|
||||
dryRunSafe: false,
|
||||
});
|
||||
await expect(fs.access(reportOnlyLock)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses configured stale threshold without removing live OpenClaw lock files", async () => {
|
||||
const sessionsDir = state.sessionsDir();
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
|
||||
@@ -9,8 +9,19 @@ import {
|
||||
type SessionWriteLockAcquireTimeoutConfig,
|
||||
} from "../agents/session-write-lock.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import type { HealthFinding, HealthRepairEffect } from "../flows/health-checks.js";
|
||||
import { shortenHomePath } from "../utils.js";
|
||||
|
||||
const SESSION_LOCKS_CHECK_ID = "core/doctor/session-locks";
|
||||
const REPORT_ONLY_STALE_LOCK_REASONS = new Set(["too-old", "hold-exceeded"]);
|
||||
|
||||
function isReportOnlyStaleLock(lock: SessionLockInspection): boolean {
|
||||
return (
|
||||
lock.staleReasons.length > 0 &&
|
||||
lock.staleReasons.every((reason) => REPORT_ONLY_STALE_LOCK_REASONS.has(reason))
|
||||
);
|
||||
}
|
||||
|
||||
function formatAge(ageMs: number | null): string {
|
||||
if (ageMs === null) {
|
||||
return "unknown";
|
||||
@@ -40,6 +51,57 @@ function formatLockLine(lock: SessionLockInspection): string {
|
||||
return `- ${shortenHomePath(lock.lockPath)} ${pidStatus} ${ageStatus} ${staleStatus}${removedStatus}`;
|
||||
}
|
||||
|
||||
export async function detectStaleSessionLocks(params?: {
|
||||
config?: SessionWriteLockAcquireTimeoutConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
staleMs?: number;
|
||||
readOwnerProcessArgs?: SessionLockOwnerProcessArgsReader;
|
||||
}): Promise<readonly SessionLockInspection[]> {
|
||||
const staleMs = params?.staleMs ?? resolveSessionWriteLockStaleMs(params?.config, params?.env);
|
||||
const env = params?.env ?? process.env;
|
||||
const sessionDirs = await resolveAgentSessionDirs(resolveStateDir(env));
|
||||
const staleLocks: SessionLockInspection[] = [];
|
||||
for (const sessionsDir of sessionDirs) {
|
||||
const result = await cleanStaleLockFiles({
|
||||
sessionsDir,
|
||||
staleMs,
|
||||
removeStale: false,
|
||||
readOwnerProcessArgs: params?.readOwnerProcessArgs,
|
||||
});
|
||||
staleLocks.push(...result.locks.filter((lock) => lock.stale));
|
||||
}
|
||||
return staleLocks.toSorted((a, b) => a.lockPath.localeCompare(b.lockPath));
|
||||
}
|
||||
|
||||
export function sessionLockToHealthFinding(lock: SessionLockInspection): HealthFinding {
|
||||
const fixHint = lock.removable
|
||||
? 'Run "openclaw doctor --fix" to remove this stale lock file automatically.'
|
||||
: isReportOnlyStaleLock(lock)
|
||||
? "OpenClaw is preserving this live owned lock; inspect the owning process if it appears stuck."
|
||||
: 'Run "openclaw doctor --fix" after the cleanup grace period if this stale lock remains.';
|
||||
return {
|
||||
checkId: SESSION_LOCKS_CHECK_ID,
|
||||
severity: "warning",
|
||||
message: `Stale session lock file: ${shortenHomePath(lock.lockPath)} (${lock.staleReasons.join(", ") || "unknown"})`,
|
||||
path: lock.lockPath,
|
||||
fixHint,
|
||||
};
|
||||
}
|
||||
|
||||
export function sessionLockToRepairEffect(lock: SessionLockInspection): HealthRepairEffect {
|
||||
const action = lock.removable
|
||||
? "would-remove-stale-session-lock"
|
||||
: isReportOnlyStaleLock(lock)
|
||||
? "would-preserve-report-only-stale-session-lock"
|
||||
: "would-preserve-mtime-gated-stale-session-lock";
|
||||
return {
|
||||
kind: "state",
|
||||
action,
|
||||
target: lock.lockPath,
|
||||
dryRunSafe: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** Reports session write locks and removes stale locks when doctor repair is enabled. */
|
||||
export async function noteSessionLockHealth(params?: {
|
||||
shouldRepair?: boolean;
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
// Context engine host compatibility tests cover doctor warnings for host/context mismatches.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
|
||||
import { registerContextEngine } from "../../../context-engine/registry.js";
|
||||
import {
|
||||
getContextEngineFactory,
|
||||
getContextEngineRegistration,
|
||||
registerContextEngine,
|
||||
registerContextEngineForOwner,
|
||||
} from "../../../context-engine/registry.js";
|
||||
import type { ContextEngine, ContextEngineHostCapability } from "../../../context-engine/types.js";
|
||||
import {
|
||||
collectConfiguredContextEngineAgentRunHosts,
|
||||
@@ -60,6 +65,23 @@ function configWithEngine(engineId: string, cfg: OpenClawConfig = {}): OpenClawC
|
||||
}
|
||||
|
||||
describe("doctor context-engine host compatibility", () => {
|
||||
it("distinguishes read-only discovery registrations from runtime entries", () => {
|
||||
const id = uniqueEngineId();
|
||||
const factory = () => {
|
||||
throw new Error("discovery-only");
|
||||
};
|
||||
const result = registerContextEngineForOwner(id, factory, `doctor-test-owner-${id}`, {
|
||||
lifecycle: "readOnlyDiscovery",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(getContextEngineRegistration(id)).toMatchObject({
|
||||
factory,
|
||||
lifecycle: "readOnlyDiscovery",
|
||||
});
|
||||
expect(getContextEngineFactory(id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("collects native Codex and OpenClaw as compatible agent-run hosts", () => {
|
||||
const hosts = collectConfiguredContextEngineAgentRunHosts({
|
||||
cfg: {
|
||||
|
||||
@@ -16,7 +16,10 @@ import {
|
||||
type ContextEngineHostSupport,
|
||||
} from "../../../context-engine/host-compat.js";
|
||||
import { ensureContextEnginesInitialized } from "../../../context-engine/init.js";
|
||||
import { getContextEngineFactory, resolveContextEngine } from "../../../context-engine/registry.js";
|
||||
import {
|
||||
getContextEngineRegistration,
|
||||
resolveContextEngine,
|
||||
} from "../../../context-engine/registry.js";
|
||||
import type { ContextEngineInfo } from "../../../context-engine/types.js";
|
||||
import { ensurePluginRegistryLoaded } from "../../../plugins/runtime/runtime-registry-loader.js";
|
||||
import { defaultSlotIdForKey } from "../../../plugins/slots.js";
|
||||
@@ -254,7 +257,7 @@ async function resolveSelectedContextEngineInfo(params: {
|
||||
}
|
||||
|
||||
ensureContextEnginesInitialized();
|
||||
if (!getContextEngineFactory(engineId)) {
|
||||
if (getContextEngineRegistration(engineId)?.lifecycle !== "runtime") {
|
||||
try {
|
||||
ensurePluginRegistryLoaded({
|
||||
scope: "all",
|
||||
@@ -263,7 +266,7 @@ async function resolveSelectedContextEngineInfo(params: {
|
||||
onlyPluginIds: [engineId],
|
||||
});
|
||||
} catch (error) {
|
||||
if (!getContextEngineFactory(engineId)) {
|
||||
if (getContextEngineRegistration(engineId)?.lifecycle !== "runtime") {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
warnings: [
|
||||
@@ -272,7 +275,7 @@ async function resolveSelectedContextEngineInfo(params: {
|
||||
};
|
||||
}
|
||||
}
|
||||
if (!getContextEngineFactory(engineId)) {
|
||||
if (getContextEngineRegistration(engineId)?.lifecycle !== "runtime") {
|
||||
return {
|
||||
warnings: [
|
||||
`- plugins.slots.contextEngine: could not inspect context engine "${engineId}" host requirements because it is not registered.`,
|
||||
|
||||
@@ -370,6 +370,125 @@ describe("tasks commands", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves both cron-run session key shapes for a running non-slug job id", async () => {
|
||||
await withTaskCommandStateDir(async (state) => {
|
||||
const now = Date.now();
|
||||
const old = now - 8 * 24 * 60 * 60_000;
|
||||
await saveCronStore(state.statePath("cron", "jobs.json"), {
|
||||
version: 1,
|
||||
jobs: [
|
||||
{
|
||||
id: "Daily Report",
|
||||
name: "Daily Report",
|
||||
enabled: true,
|
||||
schedule: { kind: "every", everyMs: 60_000 },
|
||||
sessionTarget: "isolated",
|
||||
sessionKey: "cron:daily-report",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "agentTurn", message: "ping" },
|
||||
delivery: { mode: "none" },
|
||||
createdAtMs: now,
|
||||
updatedAtMs: now,
|
||||
state: { runningAtMs: now - 5_000 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const sessionsDir = state.sessionsDir("main");
|
||||
const storePath = path.join(sessionsDir, "sessions.json");
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
// A running job can be retargeted after its session is created, so maintenance must preserve
|
||||
// both the raw and slugged historical shapes.
|
||||
const slugKey = "agent:main:cron:daily-report:run:old-run";
|
||||
const rawKey = "agent:main:cron:daily report:run:old-run";
|
||||
const retiredKey = "agent:main:cron:retired-job:run:old-run";
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
[slugKey]: { sessionId: "slug-run", updatedAt: old },
|
||||
[rawKey]: { sessionId: "raw-run", updatedAt: old },
|
||||
[retiredKey]: { sessionId: "retired-run", updatedAt: old },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const runtime = createRuntime();
|
||||
await tasksMaintenanceCommand({ json: true, apply: true }, runtime);
|
||||
|
||||
const payload = readFirstJsonLog(runtime) as {
|
||||
maintenance: { sessions: { runningCronJobs: number } };
|
||||
};
|
||||
expect(payload.maintenance.sessions.runningCronJobs).toBe(1);
|
||||
const updated = JSON.parse(await fs.readFile(storePath, "utf8")) as Record<string, unknown>;
|
||||
expect(updated[slugKey]).toBeDefined();
|
||||
expect(updated[rawKey]).toBeDefined();
|
||||
expect(updated[retiredKey]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves a running cron session with an explicit session key", async () => {
|
||||
await withTaskCommandStateDir(async (state) => {
|
||||
const now = Date.now();
|
||||
const old = now - 8 * 24 * 60 * 60_000;
|
||||
await saveCronStore(state.statePath("cron", "jobs.json"), {
|
||||
version: 1,
|
||||
jobs: [
|
||||
{
|
||||
id: "job-uuid",
|
||||
name: "Daily monitor",
|
||||
enabled: true,
|
||||
schedule: { kind: "every", everyMs: 60_000 },
|
||||
sessionTarget: "isolated",
|
||||
sessionKey: "cron:daily-monitor",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "agentTurn", message: "ping" },
|
||||
delivery: { mode: "none" },
|
||||
createdAtMs: now,
|
||||
updatedAtMs: now,
|
||||
state: { runningAtMs: now - 5_000 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const sessionsDir = state.sessionsDir("main");
|
||||
const storePath = path.join(sessionsDir, "sessions.json");
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:main:cron:daily-monitor:run:old-run": {
|
||||
sessionId: "explicit-run",
|
||||
updatedAt: old,
|
||||
},
|
||||
"agent:main:cron:job-uuid:run:old-run": {
|
||||
sessionId: "job-id-run",
|
||||
updatedAt: old,
|
||||
},
|
||||
"agent:main:cron:retired-job:run:old-run": {
|
||||
sessionId: "retired-run",
|
||||
updatedAt: old,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const runtime = createRuntime();
|
||||
await tasksMaintenanceCommand({ json: true, apply: true }, runtime);
|
||||
|
||||
const updated = JSON.parse(await fs.readFile(storePath, "utf8")) as Record<string, unknown>;
|
||||
expect(updated["agent:main:cron:daily-monitor:run:old-run"]).toBeDefined();
|
||||
expect(updated["agent:main:cron:retired-job:run:old-run"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not build JSON-only diagnostics for text maintenance output", async () => {
|
||||
await withTaskCommandStateDir(async () => {
|
||||
const diagnosticsSpy = vi.spyOn(
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
resolveAllAgentSessionStoreTargetsSync,
|
||||
runSessionRegistryMaintenanceForStore,
|
||||
} from "../config/sessions.js";
|
||||
import { normalizeCronLaneSegment } from "../cron/service/task-runs.js";
|
||||
import { loadCronJobsStoreSync, resolveCronJobsStorePath } from "../cron/store.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { getTaskById, updateTaskNotifyPolicyById } from "../tasks/runtime-internal.js";
|
||||
@@ -128,17 +129,36 @@ type SessionRegistryMaintenanceSummary = {
|
||||
stores: SessionRegistryMaintenanceStoreSummary[];
|
||||
};
|
||||
|
||||
function readRunningCronJobIds(): Set<string> {
|
||||
function resolveExplicitCronSessionSegment(sessionKey: string | undefined): string | undefined {
|
||||
const match = /^(?:agent:[^:]+:)?cron:([^:]+)$/u.exec(sessionKey?.trim() ?? "");
|
||||
return match?.[1]?.toLowerCase();
|
||||
}
|
||||
|
||||
function readRunningCronJobIds(): { ids: Set<string>; count: number } {
|
||||
try {
|
||||
const cronStorePath = resolveCronJobsStorePath(getRuntimeConfig().cron?.store);
|
||||
return new Set(
|
||||
loadCronJobsStoreSync(cronStorePath)
|
||||
.jobs.filter((job) => typeof job.state?.runningAtMs === "number")
|
||||
// Cron session keys are matched case-insensitively against job ids.
|
||||
.map((job) => job.id.toLowerCase()),
|
||||
const runningJobs = loadCronJobsStoreSync(cronStorePath).jobs.filter(
|
||||
(job) => typeof job.state?.runningAtMs === "number",
|
||||
);
|
||||
return {
|
||||
// A running job may have been retargeted after its session was created. Keep both historical
|
||||
// shapes; the registry has no producer metadata, so retaining an ambiguous alias is safer
|
||||
// than pruning a live transcript.
|
||||
ids: new Set(
|
||||
runningJobs.flatMap((job) => [
|
||||
job.id.toLowerCase(),
|
||||
normalizeCronLaneSegment(job.id, "job"),
|
||||
...(job.sessionTarget !== "main" && job.sessionKey
|
||||
? [resolveExplicitCronSessionSegment(job.sessionKey)].filter(
|
||||
(segment): segment is string => segment !== undefined,
|
||||
)
|
||||
: []),
|
||||
]),
|
||||
),
|
||||
count: runningJobs.length,
|
||||
};
|
||||
} catch {
|
||||
return new Set();
|
||||
return { ids: new Set(), count: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,13 +166,13 @@ async function runSessionRegistryMaintenance(params: {
|
||||
apply: boolean;
|
||||
}): Promise<SessionRegistryMaintenanceSummary> {
|
||||
const cfg = getRuntimeConfig();
|
||||
const runningCronJobIds = readRunningCronJobIds();
|
||||
const runningCronJobs = readRunningCronJobIds();
|
||||
const stores: SessionRegistryMaintenanceStoreSummary[] = [];
|
||||
for (const target of resolveAllAgentSessionStoreTargetsSync(cfg)) {
|
||||
const result = await runSessionRegistryMaintenanceForStore({
|
||||
apply: params.apply,
|
||||
retentionMs: SESSION_REGISTRY_RETENTION_MS,
|
||||
runningCronJobIds,
|
||||
runningCronJobIds: runningCronJobs.ids,
|
||||
storePath: target.storePath,
|
||||
});
|
||||
stores.push({
|
||||
@@ -166,7 +186,7 @@ async function runSessionRegistryMaintenance(params: {
|
||||
}
|
||||
return {
|
||||
retentionMs: SESSION_REGISTRY_RETENTION_MS,
|
||||
runningCronJobs: runningCronJobIds.size,
|
||||
runningCronJobs: runningCronJobs.count,
|
||||
pruned: stores.reduce((total, store) => total + store.pruned, 0),
|
||||
stores,
|
||||
};
|
||||
|
||||
@@ -1199,6 +1199,7 @@ function resolveConfigIncludesForRead(
|
||||
deps: Required<ConfigIoDeps>,
|
||||
includeFileHashesForWrite?: Record<string, string>,
|
||||
includeFileTargetsForWrite?: Record<string, string>,
|
||||
includeFilePaths?: Set<string>,
|
||||
): unknown {
|
||||
const allowedRoots = resolveIncludeRoots(deps.env, deps.homedir);
|
||||
const recordIncludeTarget = (resolvedPath: string, canonicalPath?: string) => {
|
||||
@@ -1231,7 +1232,10 @@ function resolveConfigIncludesForRead(
|
||||
resolvedPath,
|
||||
rootRealDir,
|
||||
ioFs: deps.fs,
|
||||
onResolvedPath: (canonicalPath) => recordIncludeTarget(resolvedPath, canonicalPath),
|
||||
onResolvedPath: (canonicalPath) => {
|
||||
recordIncludeTarget(resolvedPath, canonicalPath);
|
||||
includeFilePaths?.add(path.normalize(canonicalPath));
|
||||
},
|
||||
});
|
||||
if (includeFileHashesForWrite) {
|
||||
includeFileHashesForWrite[path.normalize(resolvedPath)] = hashConfigIncludeRaw(raw);
|
||||
@@ -1307,11 +1311,13 @@ type ReadConfigFileSnapshotInternalResult = {
|
||||
envSnapshotForRestore?: Record<string, string | undefined>;
|
||||
includeFileHashesForWrite?: Record<string, string>;
|
||||
includeFileTargetsForWrite?: Record<string, string>;
|
||||
includeFilePaths?: readonly string[];
|
||||
pluginMetadataSnapshot?: PluginMetadataSnapshot;
|
||||
};
|
||||
|
||||
export type ReadConfigFileSnapshotWithPluginMetadataResult = {
|
||||
snapshot: ConfigFileSnapshot;
|
||||
includeFilePaths?: readonly string[];
|
||||
pluginMetadataSnapshot?: PluginMetadataSnapshot;
|
||||
};
|
||||
|
||||
@@ -1868,6 +1874,7 @@ export function createConfigIO(
|
||||
let fallbackEnvSnapshotForRestore: Record<string, string | undefined> | undefined;
|
||||
const includeFileHashesForWrite: Record<string, string> = {};
|
||||
const includeFileTargetsForWrite: Record<string, string> = {};
|
||||
const includeFilePaths = new Set<string>();
|
||||
|
||||
try {
|
||||
const raw = await deps.measure("config.snapshot.read.file", () =>
|
||||
@@ -1916,6 +1923,7 @@ export function createConfigIO(
|
||||
deps,
|
||||
includeFileHashesForWrite,
|
||||
includeFileTargetsForWrite,
|
||||
includeFilePaths,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
@@ -2086,6 +2094,7 @@ export function createConfigIO(
|
||||
envSnapshotForRestore: readResolution.envSnapshotForRestore,
|
||||
includeFileHashesForWrite,
|
||||
includeFileTargetsForWrite,
|
||||
includeFilePaths: [...includeFilePaths].toSorted(),
|
||||
pluginMetadataSnapshot: validationPluginMetadata.getSnapshot(),
|
||||
},
|
||||
{ observe: !callerRejectedSuspiciousRecovery },
|
||||
@@ -2151,6 +2160,7 @@ export function createConfigIO(
|
||||
});
|
||||
return {
|
||||
snapshot: result.snapshot,
|
||||
...(result.snapshot.valid ? { includeFilePaths: result.includeFilePaths ?? [] } : {}),
|
||||
...(result.pluginMetadataSnapshot
|
||||
? { pluginMetadataSnapshot: result.pluginMetadataSnapshot }
|
||||
: {}),
|
||||
|
||||
@@ -1865,6 +1865,57 @@ describe("config io write", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"exposes only canonical valid include paths through the metadata wrapper",
|
||||
async () => {
|
||||
await withSuiteHome(async (home) => {
|
||||
const configDir = path.join(home, ".openclaw");
|
||||
const configPath = path.join(configDir, "openclaw.json");
|
||||
const fragmentsDir = path.join(configDir, "fragments");
|
||||
const aliasDir = path.join(configDir, "alias");
|
||||
const defaultsPath = path.join(fragmentsDir, "defaults.json5");
|
||||
const nestedPath = path.join(fragmentsDir, "nested.json5");
|
||||
await fs.mkdir(fragmentsDir, { recursive: true });
|
||||
await fs.symlink(fragmentsDir, aliasDir, "dir");
|
||||
await fs.writeFile(nestedPath, '{ workspace: "~/.openclaw/workspace" }\n', "utf-8");
|
||||
await fs.writeFile(
|
||||
defaultsPath,
|
||||
'{ $include: "./nested.json5", maxConcurrent: 1 }\n',
|
||||
"utf-8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
'{ agents: { defaults: { $include: "./alias/defaults.json5" } } }\n',
|
||||
"utf-8",
|
||||
);
|
||||
const io = createConfigIO({
|
||||
env: { OPENCLAW_TEST_FAST: "1" } as NodeJS.ProcessEnv,
|
||||
homedir: () => home,
|
||||
logger: silentLogger,
|
||||
});
|
||||
|
||||
const valid = await io.readConfigFileSnapshotWithPluginMetadata();
|
||||
|
||||
expect(valid.snapshot.valid).toBe(true);
|
||||
expect(valid.includeFilePaths).toEqual(
|
||||
[await fs.realpath(defaultsPath), await fs.realpath(nestedPath)].toSorted(),
|
||||
);
|
||||
expect(valid.includeFilePaths).not.toContain(path.join(aliasDir, "defaults.json5"));
|
||||
expect(valid.snapshot).not.toHaveProperty("includeFilePaths");
|
||||
|
||||
await fs.writeFile(nestedPath, "{ malformed", "utf-8");
|
||||
const invalid = await io.readConfigFileSnapshotWithPluginMetadata();
|
||||
expect(invalid.snapshot.valid).toBe(false);
|
||||
expect(invalid).not.toHaveProperty("includeFilePaths");
|
||||
|
||||
await fs.rm(nestedPath);
|
||||
const missing = await io.readConfigFileSnapshotWithPluginMetadata();
|
||||
expect(missing.snapshot.valid).toBe(false);
|
||||
expect(missing).not.toHaveProperty("includeFilePaths");
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("repairs invalid root-authored siblings without flattening included config", async () => {
|
||||
await withSuiteHome(async (home) => {
|
||||
const configPath = path.join(home, ".openclaw", "openclaw.json");
|
||||
|
||||
@@ -541,6 +541,46 @@ export type RestoreSessionFromCompactionCheckpointParams = {
|
||||
storePath: string;
|
||||
};
|
||||
|
||||
export type TemporarySessionMappingPreservationResult<T> = {
|
||||
/** Result returned by the operation while the temporary mapping may exist. */
|
||||
result: T;
|
||||
/** Snapshot failure; callers may continue when temporary cleanup is best-effort. */
|
||||
snapshotFailure?: string;
|
||||
/** Restore/delete failure for the original temporary mapping state. */
|
||||
restoreFailure?: string;
|
||||
};
|
||||
|
||||
type TemporarySessionMappingSnapshot =
|
||||
| {
|
||||
canRestore: false;
|
||||
sessionKey: string;
|
||||
snapshotFailure: string;
|
||||
storePath: string;
|
||||
}
|
||||
| {
|
||||
canRestore: true;
|
||||
hadEntry: false;
|
||||
sessionKey: string;
|
||||
storePath: string;
|
||||
}
|
||||
| {
|
||||
canRestore: true;
|
||||
entry: SessionEntry;
|
||||
hadEntry: true;
|
||||
sessionKey: string;
|
||||
storePath: string;
|
||||
};
|
||||
|
||||
type TemporarySessionMappingOperationResult<T> =
|
||||
| {
|
||||
ok: true;
|
||||
result: T;
|
||||
}
|
||||
| {
|
||||
error: unknown;
|
||||
ok: false;
|
||||
};
|
||||
|
||||
export type SessionEntryCreateWithTranscriptContext = {
|
||||
/** Current entry under the requested key before creation, if any. */
|
||||
existingEntry?: SessionEntry;
|
||||
@@ -1393,6 +1433,37 @@ export async function applyRestartRecoveryLifecycle<T>(params: {
|
||||
return writerResult.result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs an operation while preserving one temporary session mapping.
|
||||
* The storage backend snapshots exactly the named key before the operation and
|
||||
* restores that entry, or deletes it when it did not previously exist, after
|
||||
* the operation finishes. SQLite backends can implement the same named
|
||||
* preservation lifecycle without exposing mutable store access to callers.
|
||||
*/
|
||||
export async function preserveTemporarySessionMapping<T>(
|
||||
scope: SessionAccessScope,
|
||||
operation: () => Promise<T> | T,
|
||||
): Promise<TemporarySessionMappingPreservationResult<T>> {
|
||||
const snapshot = snapshotTemporarySessionMapping(scope);
|
||||
let operationResult: TemporarySessionMappingOperationResult<T>;
|
||||
try {
|
||||
operationResult = { ok: true, result: await operation() };
|
||||
} catch (err) {
|
||||
operationResult = { error: err, ok: false };
|
||||
}
|
||||
|
||||
const restoreFailure = await restoreTemporarySessionMapping(snapshot);
|
||||
if (!operationResult.ok) {
|
||||
throw operationResult.error;
|
||||
}
|
||||
|
||||
return {
|
||||
result: operationResult.result,
|
||||
...(snapshot.canRestore ? {} : { snapshotFailure: snapshot.snapshotFailure }),
|
||||
...(restoreFailure ? { restoreFailure } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Removes entries and orphan transcript artifacts owned by a named session lifecycle. */
|
||||
export async function cleanupSessionLifecycleArtifacts(
|
||||
params: SessionLifecycleArtifactCleanupParams,
|
||||
@@ -2515,6 +2586,53 @@ function createFallbackSessionEntry(patch: Partial<SessionEntry>): SessionEntry
|
||||
};
|
||||
}
|
||||
|
||||
function snapshotTemporarySessionMapping(
|
||||
scope: SessionAccessScope,
|
||||
): TemporarySessionMappingSnapshot {
|
||||
const storePath = resolveAccessStorePath(scope);
|
||||
try {
|
||||
const store = loadSessionStore(storePath, { skipCache: true });
|
||||
const entry = store[scope.sessionKey];
|
||||
return {
|
||||
canRestore: true,
|
||||
...(entry ? { entry: structuredClone(entry), hadEntry: true } : { hadEntry: false }),
|
||||
sessionKey: scope.sessionKey,
|
||||
storePath,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
canRestore: false,
|
||||
sessionKey: scope.sessionKey,
|
||||
snapshotFailure: formatErrorMessage(err),
|
||||
storePath,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreTemporarySessionMapping(
|
||||
snapshot: TemporarySessionMappingSnapshot,
|
||||
): Promise<string | undefined> {
|
||||
if (!snapshot.canRestore) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
await updateSessionStore(
|
||||
snapshot.storePath,
|
||||
(store) => {
|
||||
if (snapshot.hadEntry) {
|
||||
store[snapshot.sessionKey] = structuredClone(snapshot.entry);
|
||||
return;
|
||||
}
|
||||
delete store[snapshot.sessionKey];
|
||||
},
|
||||
{ activeSessionKey: snapshot.sessionKey },
|
||||
);
|
||||
return undefined;
|
||||
} catch (err) {
|
||||
return formatErrorMessage(err);
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupPreviousResetTranscripts(params: {
|
||||
agentId: string;
|
||||
previousEntry: SessionEntry;
|
||||
|
||||
@@ -1084,6 +1084,88 @@ describe("Factory context passing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Read-only plugin discovery registrations", () => {
|
||||
beforeEach(() => {
|
||||
registerLegacyContextEngine();
|
||||
clearContextEngineRuntimeQuarantine();
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("does not construct or quarantine read-only discovery context-engine factories", async () => {
|
||||
const engineId = uniqueEngineId("lossless-readonly");
|
||||
const owner = "plugin:lossless-claw";
|
||||
let readOnlyFactoryCalls = 0;
|
||||
let runtimeFactoryCalls = 0;
|
||||
|
||||
registerContextEngineForOwner(
|
||||
engineId,
|
||||
() => {
|
||||
readOnlyFactoryCalls += 1;
|
||||
throw new Error("Engine initialization is disabled during read-only plugin registration");
|
||||
},
|
||||
owner,
|
||||
{ allowSameOwnerRefresh: true, lifecycle: "readOnlyDiscovery" },
|
||||
);
|
||||
|
||||
const discoveryFallback = await resolveContextEngine(configWithSlot(engineId));
|
||||
|
||||
expect(discoveryFallback.info.id).toBe("legacy");
|
||||
expect(readOnlyFactoryCalls).toBe(0);
|
||||
expect(listContextEngineQuarantines().some((entry) => entry.engineId === engineId)).toBe(false);
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
`[context-engine] Context engine "${engineId}" owner=${owner} is registered for read-only discovery only; falling back to default engine "legacy" without quarantine until runtime activation registers it.`,
|
||||
);
|
||||
|
||||
registerContextEngineForOwner(
|
||||
engineId,
|
||||
() => {
|
||||
runtimeFactoryCalls += 1;
|
||||
return {
|
||||
info: { id: "lossless-claw", name: "Lossless Claw" },
|
||||
async ingest() {
|
||||
return { ingested: true };
|
||||
},
|
||||
async assemble({ messages }: { messages: AgentMessage[] }) {
|
||||
return { messages, estimatedTokens: 0 };
|
||||
},
|
||||
async compact() {
|
||||
return { ok: true, compacted: false };
|
||||
},
|
||||
} satisfies ContextEngine;
|
||||
},
|
||||
owner,
|
||||
{ allowSameOwnerRefresh: true, lifecycle: "runtime" },
|
||||
);
|
||||
|
||||
const runtimeEngine = await resolveContextEngine(configWithSlot(engineId));
|
||||
|
||||
expect(runtimeEngine.info.id).toBe("lossless-claw");
|
||||
expect(readOnlyFactoryCalls).toBe(0);
|
||||
expect(runtimeFactoryCalls).toBe(1);
|
||||
expect(listContextEngineQuarantines().some((entry) => entry.engineId === engineId)).toBe(false);
|
||||
|
||||
registerContextEngineForOwner(
|
||||
engineId,
|
||||
() => {
|
||||
readOnlyFactoryCalls += 1;
|
||||
throw new Error("read-only discovery should not replace runtime registration");
|
||||
},
|
||||
owner,
|
||||
{ allowSameOwnerRefresh: true, lifecycle: "readOnlyDiscovery" },
|
||||
);
|
||||
|
||||
const stillRuntimeEngine = await resolveContextEngine(configWithSlot(engineId));
|
||||
|
||||
expect(stillRuntimeEngine.info.id).toBe("lossless-claw");
|
||||
expect(readOnlyFactoryCalls).toBe(0);
|
||||
expect(runtimeFactoryCalls).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 4. Invalid engine fallback
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -44,9 +44,16 @@ export type ContextEngineFactory = (
|
||||
ctx: ContextEngineFactoryContext,
|
||||
) => ContextEngine | Promise<ContextEngine>;
|
||||
export type ContextEngineRegistrationResult = { ok: true } | { ok: false; existingOwner: string };
|
||||
export type ContextEngineRegistrationLifecycle = "runtime" | "readOnlyDiscovery";
|
||||
export type ContextEngineRegistration = {
|
||||
factory: ContextEngineFactory;
|
||||
owner: string;
|
||||
lifecycle: ContextEngineRegistrationLifecycle;
|
||||
};
|
||||
|
||||
type RegisterContextEngineForOwnerOptions = {
|
||||
allowSameOwnerRefresh?: boolean;
|
||||
lifecycle?: ContextEngineRegistrationLifecycle;
|
||||
};
|
||||
|
||||
const LEGACY_SESSION_KEY_COMPAT = Symbol.for("openclaw.contextEngine.sessionKeyCompat");
|
||||
@@ -389,13 +396,7 @@ export type ContextEngineRuntimeQuarantine = {
|
||||
};
|
||||
|
||||
type ContextEngineRegistryState = {
|
||||
engines: Map<
|
||||
string,
|
||||
{
|
||||
factory: ContextEngineFactory;
|
||||
owner: string;
|
||||
}
|
||||
>;
|
||||
engines: Map<string, ContextEngineRegistration>;
|
||||
quarantinedEngines: Map<string, ContextEngineRuntimeQuarantine>;
|
||||
};
|
||||
|
||||
@@ -512,6 +513,7 @@ export function registerContextEngineForOwner(
|
||||
opts?: RegisterContextEngineForOwnerOptions,
|
||||
): ContextEngineRegistrationResult {
|
||||
const normalizedOwner = requireContextEngineOwner(owner);
|
||||
const lifecycle = opts?.lifecycle ?? "runtime";
|
||||
const registry = getContextEngineRegistryState().engines;
|
||||
const existing = registry.get(id);
|
||||
if (
|
||||
@@ -524,11 +526,18 @@ export function registerContextEngineForOwner(
|
||||
if (existing && existing.owner !== normalizedOwner) {
|
||||
return { ok: false, existingOwner: existing.owner };
|
||||
}
|
||||
if (existing?.lifecycle === "runtime" && lifecycle === "readOnlyDiscovery") {
|
||||
// Read-only discovery may re-run after live activation. It can collect metadata, but it must
|
||||
// not replace the runtime-safe factory with a closure that captured a read-only plugin mode.
|
||||
return { ok: true };
|
||||
}
|
||||
if (existing && opts?.allowSameOwnerRefresh !== true) {
|
||||
return { ok: false, existingOwner: existing.owner };
|
||||
}
|
||||
registry.set(id, { factory, owner: normalizedOwner });
|
||||
clearContextEngineRuntimeQuarantine(id);
|
||||
registry.set(id, { factory, owner: normalizedOwner, lifecycle });
|
||||
if (lifecycle === "runtime") {
|
||||
clearContextEngineRuntimeQuarantine(id);
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@@ -550,7 +559,13 @@ export function registerContextEngine(
|
||||
* Return the factory for a registered engine, or undefined.
|
||||
*/
|
||||
export function getContextEngineFactory(id: string): ContextEngineFactory | undefined {
|
||||
return getContextEngineRegistryState().engines.get(id)?.factory;
|
||||
const registration = getContextEngineRegistration(id);
|
||||
return registration?.lifecycle === "runtime" ? registration.factory : undefined;
|
||||
}
|
||||
|
||||
/** Returns registration metadata so callers can distinguish discovery snapshots from runtime entries. */
|
||||
export function getContextEngineRegistration(id: string): ContextEngineRegistration | undefined {
|
||||
return getContextEngineRegistryState().engines.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -945,6 +960,13 @@ export async function resolveContextEngine(
|
||||
return resolveDefaultContextEngine(defaultEngineId, factoryCtx);
|
||||
}
|
||||
|
||||
if (!isDefaultEngine && entry.lifecycle === "readOnlyDiscovery") {
|
||||
console.warn(
|
||||
`[context-engine] Context engine "${engineId}" owner=${entry.owner} is registered for read-only discovery only; falling back to default engine "${defaultEngineId}" without quarantine until runtime activation registers it.`,
|
||||
);
|
||||
return resolveDefaultContextEngine(defaultEngineId, factoryCtx);
|
||||
}
|
||||
|
||||
let engine: ContextEngine;
|
||||
try {
|
||||
engine = await entry.factory(factoryCtx);
|
||||
|
||||
@@ -161,8 +161,19 @@ function buildCronDeliveryTargetRuntimeContext(params: {
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function resolveCliRuntimeToolsAllow(toolsAllow?: string[]): string[] | undefined {
|
||||
return toolsAllow?.some((toolName) => normalizeToolName(toolName) === "*")
|
||||
function resolveCliRuntimeToolsAllow(
|
||||
toolsAllow?: string[],
|
||||
toolsAllowIsDefault?: boolean,
|
||||
): string[] | undefined {
|
||||
if (toolsAllow === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
// CLI runners reject runtime toolsAllow. Drop only the auto-stamped default;
|
||||
// explicit per-cron restrictions stay fail-closed in prepareCliRunContext.
|
||||
if (toolsAllowIsDefault) {
|
||||
return undefined;
|
||||
}
|
||||
return toolsAllow.some((toolName) => normalizeToolName(toolName) === "*")
|
||||
? undefined
|
||||
: toolsAllow;
|
||||
}
|
||||
@@ -326,7 +337,10 @@ export function createCronPromptExecutor(params: {
|
||||
messageChannel,
|
||||
sourceReplyDeliveryMode,
|
||||
requireExplicitMessageTarget: params.sourceDelivery.messageTool.requireExplicitTarget,
|
||||
toolsAllow: resolveCliRuntimeToolsAllow(params.agentPayload?.toolsAllow),
|
||||
toolsAllow: resolveCliRuntimeToolsAllow(
|
||||
params.agentPayload?.toolsAllow,
|
||||
params.agentPayload?.toolsAllowIsDefault,
|
||||
),
|
||||
abortSignal: params.abortSignal,
|
||||
onExecutionStarted: params.onExecutionStarted,
|
||||
onExecutionPhase: params.onExecutionPhase,
|
||||
|
||||
@@ -825,6 +825,41 @@ describe("runCronIsolatedAgentTurn message tool policy", () => {
|
||||
expect(cliRun.prompt).toContain("Message delivery destination metadata");
|
||||
});
|
||||
|
||||
it("drops the auto-applied default toolsAllow cap for CLI-backed runs instead of failing", async () => {
|
||||
// A CLI backend cannot enforce a runtime toolsAllow, so the auto-applied
|
||||
// creator-surface cap (#91499, flagged toolsAllowIsDefault) is dropped at
|
||||
// run time rather than handed to the CLI runner — which would otherwise
|
||||
// reject the run. An explicit user restriction (no flag) is still
|
||||
// propagated; see the "restricted toolsAllow" case above.
|
||||
mockRunCronFallbackPassthrough();
|
||||
resolveCronDeliveryPlanMock.mockReturnValue(makeAnnounceDeliveryPlan());
|
||||
isCliProviderMock.mockReturnValue(true);
|
||||
runCliAgentMock.mockResolvedValue({
|
||||
payloads: [{ text: "done" }],
|
||||
meta: { agentMeta: { usage: { input: 10, output: 20 } } },
|
||||
});
|
||||
|
||||
await runCronIsolatedAgentTurn({
|
||||
...makeParams(),
|
||||
job: makeMessageToolPolicyJob(
|
||||
{ mode: "announce", channel: "messagechat", to: "123" },
|
||||
{
|
||||
kind: "agentTurn",
|
||||
message: "send a message",
|
||||
toolsAllow: ["read", "cron"],
|
||||
toolsAllowIsDefault: true,
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
const cliRun = expectRecordFields(
|
||||
getMockCallArg(runCliAgentMock, 0, 0, "CLI run"),
|
||||
{},
|
||||
"CLI run params",
|
||||
);
|
||||
expect(cliRun.toolsAllow).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps automatic exec completion notifications when announce delivery is active", async () => {
|
||||
mockRunCronFallbackPassthrough();
|
||||
resolveCronDeliveryPlanMock.mockReturnValue(makeAnnounceDeliveryPlan());
|
||||
|
||||
@@ -392,6 +392,63 @@ describe("applyJobPatch", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("clears the default toolsAllow flag when editing to an explicit restriction", () => {
|
||||
const job = createIsolatedAgentTurnJob("job-tools-explicit", {
|
||||
mode: "announce",
|
||||
channel: "telegram",
|
||||
});
|
||||
job.payload = {
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
toolsAllow: ["exec", "read"],
|
||||
toolsAllowIsDefault: true,
|
||||
};
|
||||
|
||||
applyJobPatch(job, {
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
toolsAllow: ["read"],
|
||||
toolsAllowIsDefault: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(job.payload.kind).toBe("agentTurn");
|
||||
if (job.payload.kind === "agentTurn") {
|
||||
expect(job.payload.toolsAllow).toEqual(["read"]);
|
||||
expect(job.payload.toolsAllowIsDefault).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves the default toolsAllow flag when a full payload edit keeps the default list", () => {
|
||||
const job = createIsolatedAgentTurnJob("job-tools-default-edit", {
|
||||
mode: "announce",
|
||||
channel: "telegram",
|
||||
});
|
||||
job.payload = {
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
toolsAllow: ["exec", "read"],
|
||||
toolsAllowIsDefault: true,
|
||||
};
|
||||
|
||||
applyJobPatch(job, {
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: "do it later",
|
||||
toolsAllow: ["exec", "read"],
|
||||
toolsAllowIsDefault: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(job.payload.kind).toBe("agentTurn");
|
||||
if (job.payload.kind === "agentTurn") {
|
||||
expect(job.payload.message).toBe("do it later");
|
||||
expect(job.payload.toolsAllow).toEqual(["exec", "read"]);
|
||||
expect(job.payload.toolsAllowIsDefault).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("clears agentTurn payload.toolsAllow when patch requests null", () => {
|
||||
const job = createIsolatedAgentTurnJob("job-tools-clear", {
|
||||
mode: "announce",
|
||||
@@ -401,6 +458,7 @@ describe("applyJobPatch", () => {
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
toolsAllow: ["exec", "read"],
|
||||
toolsAllowIsDefault: true,
|
||||
};
|
||||
|
||||
applyJobPatch(job, {
|
||||
@@ -414,6 +472,7 @@ describe("applyJobPatch", () => {
|
||||
expect(job.payload.kind).toBe("agentTurn");
|
||||
if (job.payload.kind === "agentTurn") {
|
||||
expect(job.payload.toolsAllow).toBeUndefined();
|
||||
expect(job.payload.toolsAllowIsDefault).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -527,6 +586,30 @@ describe("applyJobPatch", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("carries payload.toolsAllow default flag when replacing payload kind via patch", () => {
|
||||
const job = createIsolatedAgentTurnJob("job-tools-default-switch", {
|
||||
mode: "announce",
|
||||
channel: "telegram",
|
||||
});
|
||||
job.payload = { kind: "systemEvent", text: "ping" };
|
||||
|
||||
applyJobPatch(job, {
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
toolsAllow: ["exec", "read"],
|
||||
toolsAllowIsDefault: true,
|
||||
},
|
||||
});
|
||||
|
||||
const payload = job.payload as CronJob["payload"];
|
||||
expect(payload.kind).toBe("agentTurn");
|
||||
if (payload.kind === "agentTurn") {
|
||||
expect(payload.toolsAllow).toEqual(["exec", "read"]);
|
||||
expect(payload.toolsAllowIsDefault).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "no delivery update", patch: { enabled: true } satisfies CronJobPatch },
|
||||
{
|
||||
|
||||
@@ -40,6 +40,9 @@ const STUCK_RUN_MS = 2 * 60 * 60 * 1000;
|
||||
const STAGGER_OFFSET_CACHE_MAX = 4096;
|
||||
const staggerOffsetCache = new Map<string, number>();
|
||||
|
||||
type CronAgentTurnPayload = Extract<CronPayload, { kind: "agentTurn" }>;
|
||||
type CronAgentTurnPayloadPatch = Extract<CronPayloadPatch, { kind: "agentTurn" }>;
|
||||
|
||||
/** Default retry delays applied after consecutive cron execution errors. */
|
||||
export const DEFAULT_ERROR_BACKOFF_SCHEDULE_MS = [
|
||||
30_000,
|
||||
@@ -897,6 +900,42 @@ export function applyJobPatch(
|
||||
}
|
||||
}
|
||||
|
||||
function applyAgentTurnToolsAllowPatch(
|
||||
payload: CronAgentTurnPayload,
|
||||
patch: CronAgentTurnPayloadPatch,
|
||||
existing?: CronAgentTurnPayload,
|
||||
): void {
|
||||
if (Array.isArray(patch.toolsAllow)) {
|
||||
payload.toolsAllow = patch.toolsAllow;
|
||||
// Same-kind edits keep the marker only when the default list is unchanged;
|
||||
// kind replacements carry the cron-tool-stamped marker into persistence.
|
||||
if (
|
||||
patch.toolsAllowIsDefault === true &&
|
||||
(!existing || (existing.toolsAllowIsDefault === true && toolsAllowEqual(existing, patch)))
|
||||
) {
|
||||
payload.toolsAllowIsDefault = true;
|
||||
} else {
|
||||
delete payload.toolsAllowIsDefault;
|
||||
}
|
||||
} else if (patch.toolsAllow === null) {
|
||||
delete payload.toolsAllow;
|
||||
delete payload.toolsAllowIsDefault;
|
||||
}
|
||||
}
|
||||
|
||||
function toolsAllowEqual(
|
||||
left: Pick<CronAgentTurnPayload, "toolsAllow">,
|
||||
right: Pick<CronAgentTurnPayloadPatch, "toolsAllow">,
|
||||
): boolean {
|
||||
const rightToolsAllow = right.toolsAllow;
|
||||
return (
|
||||
Array.isArray(left.toolsAllow) &&
|
||||
Array.isArray(rightToolsAllow) &&
|
||||
left.toolsAllow.length === rightToolsAllow.length &&
|
||||
left.toolsAllow.every((toolName, index) => toolName === rightToolsAllow[index])
|
||||
);
|
||||
}
|
||||
|
||||
function mergeCronPayload(existing: CronPayload, patch: CronPayloadPatch): CronPayload {
|
||||
if (patch.kind !== existing.kind) {
|
||||
return buildPayloadFromPatch(patch);
|
||||
@@ -943,7 +982,7 @@ function mergeCronPayload(existing: CronPayload, patch: CronPayloadPatch): CronP
|
||||
return buildPayloadFromPatch(patch);
|
||||
}
|
||||
|
||||
const next: Extract<CronPayload, { kind: "agentTurn" }> = { ...existing };
|
||||
const next: CronAgentTurnPayload = { ...existing };
|
||||
if (typeof patch.message === "string") {
|
||||
next.message = patch.message;
|
||||
}
|
||||
@@ -957,11 +996,7 @@ function mergeCronPayload(existing: CronPayload, patch: CronPayloadPatch): CronP
|
||||
} else if (patch.fallbacks === null) {
|
||||
delete next.fallbacks;
|
||||
}
|
||||
if (Array.isArray(patch.toolsAllow)) {
|
||||
next.toolsAllow = patch.toolsAllow;
|
||||
} else if (patch.toolsAllow === null) {
|
||||
delete next.toolsAllow;
|
||||
}
|
||||
applyAgentTurnToolsAllowPatch(next, patch, existing);
|
||||
if (typeof patch.thinking === "string") {
|
||||
next.thinking = patch.thinking;
|
||||
}
|
||||
@@ -1005,17 +1040,18 @@ function buildPayloadFromPatch(patch: CronPayloadPatch): CronPayload {
|
||||
throw new Error('cron.update payload.kind="agentTurn" requires message');
|
||||
}
|
||||
|
||||
return {
|
||||
const next: CronAgentTurnPayload = {
|
||||
kind: "agentTurn",
|
||||
message: patch.message,
|
||||
model: typeof patch.model === "string" ? patch.model : undefined,
|
||||
fallbacks: Array.isArray(patch.fallbacks) ? patch.fallbacks : undefined,
|
||||
toolsAllow: Array.isArray(patch.toolsAllow) ? patch.toolsAllow : undefined,
|
||||
thinking: patch.thinking,
|
||||
timeoutSeconds: patch.timeoutSeconds,
|
||||
lightContext: patch.lightContext,
|
||||
allowUnsafeExternalContent: patch.allowUnsafeExternalContent,
|
||||
};
|
||||
applyAgentTurnToolsAllowPatch(next, patch);
|
||||
return next;
|
||||
}
|
||||
|
||||
function mergeCronDelivery(
|
||||
|
||||
@@ -522,6 +522,48 @@ describe("cron store", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("round-trips the toolsAllow default-cap flag through SQLite", async () => {
|
||||
// The flag must survive a gateway restart: without it, a CLI-resolved run
|
||||
// would re-hit the prepare.ts toolsAllow rejection after reload (#91499).
|
||||
const store = await makeStorePath();
|
||||
const payload = makeStore("tools-allow-default-job", true);
|
||||
payload.jobs[0].sessionTarget = "isolated";
|
||||
payload.jobs[0].payload = {
|
||||
kind: "agentTurn",
|
||||
message: "scheduled continuation",
|
||||
toolsAllow: ["read", "cron"],
|
||||
toolsAllowIsDefault: true,
|
||||
};
|
||||
|
||||
await saveCronStore(store.storePath, payload);
|
||||
|
||||
expect((await loadCronStore(store.storePath)).jobs[0]?.payload).toMatchObject({
|
||||
kind: "agentTurn",
|
||||
toolsAllow: ["read", "cron"],
|
||||
toolsAllowIsDefault: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not persist a default-cap flag for an explicit toolsAllow restriction", async () => {
|
||||
// An explicit user restriction is fail-closed: it carries no flag, so a CLI
|
||||
// run still surfaces the prepare.ts rejection rather than silently dropping
|
||||
// the requested policy.
|
||||
const store = await makeStorePath();
|
||||
const payload = makeStore("tools-allow-explicit-job", true);
|
||||
payload.jobs[0].sessionTarget = "isolated";
|
||||
payload.jobs[0].payload = {
|
||||
kind: "agentTurn",
|
||||
message: "scheduled continuation",
|
||||
toolsAllow: ["read"],
|
||||
};
|
||||
|
||||
await saveCronStore(store.storePath, payload);
|
||||
|
||||
const reloaded = (await loadCronStore(store.storePath)).jobs[0]?.payload;
|
||||
expect(reloaded).toMatchObject({ kind: "agentTurn", toolsAllow: ["read"] });
|
||||
expect(reloaded && "toolsAllowIsDefault" in reloaded).toBe(false);
|
||||
});
|
||||
|
||||
it("round-trips command payloads through SQLite", async () => {
|
||||
const store = await makeStorePath();
|
||||
const payload = makeStore("command-job", true);
|
||||
|
||||
@@ -75,6 +75,7 @@ export function bindPayloadColumns(
|
||||
| "payload_thinking"
|
||||
| "payload_timeout_seconds"
|
||||
| "payload_tools_allow_json"
|
||||
| "payload_tools_allow_is_default"
|
||||
> {
|
||||
if (payload.kind === "systemEvent") {
|
||||
return {
|
||||
@@ -88,6 +89,7 @@ export function bindPayloadColumns(
|
||||
payload_external_content_source_json: null,
|
||||
payload_light_context: null,
|
||||
payload_tools_allow_json: null,
|
||||
payload_tools_allow_is_default: null,
|
||||
};
|
||||
}
|
||||
if (payload.kind === "command") {
|
||||
@@ -103,6 +105,7 @@ export function bindPayloadColumns(
|
||||
payload_external_content_source_json: null,
|
||||
payload_light_context: null,
|
||||
payload_tools_allow_json: null,
|
||||
payload_tools_allow_is_default: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -116,6 +119,9 @@ export function bindPayloadColumns(
|
||||
payload_external_content_source_json: serializeJson(payload.externalContentSource),
|
||||
payload_light_context: booleanToInteger(payload.lightContext),
|
||||
payload_tools_allow_json: serializeJson(payload.toolsAllow),
|
||||
payload_tools_allow_is_default: payload.toolsAllow
|
||||
? booleanToInteger(payload.toolsAllowIsDefault)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -144,6 +150,10 @@ export function payloadFromRow(row: CronJobRow): CronPayload | null {
|
||||
const toolsAllow = row.payload_tools_allow_json
|
||||
? parseJsonArray(row.payload_tools_allow_json)
|
||||
: undefined;
|
||||
const toolsAllowIsDefault =
|
||||
row.payload_tools_allow_is_default != null
|
||||
? integerToBoolean(row.payload_tools_allow_is_default)
|
||||
: undefined;
|
||||
return {
|
||||
kind: "agentTurn",
|
||||
message: row.payload_message,
|
||||
@@ -155,6 +165,7 @@ export function payloadFromRow(row: CronJobRow): CronPayload | null {
|
||||
...(externalContentSource ? { externalContentSource } : {}),
|
||||
...(lightContext != null ? { lightContext } : {}),
|
||||
...(toolsAllow ? { toolsAllow } : {}),
|
||||
...(toolsAllow && toolsAllowIsDefault ? { toolsAllowIsDefault: true } : {}),
|
||||
};
|
||||
}
|
||||
if (row.payload_kind === "command") {
|
||||
|
||||
@@ -248,6 +248,8 @@ type CronAgentTurnPayloadFields = {
|
||||
lightContext?: boolean;
|
||||
/** Optional tool allow-list; when set, only these tools are sent to the model. */
|
||||
toolsAllow?: string[];
|
||||
/** Server-managed marker for auto-stamped defaults; explicit restrictions omit it. */
|
||||
toolsAllowIsDefault?: boolean;
|
||||
};
|
||||
|
||||
type CronAgentTurnPayload = {
|
||||
|
||||
@@ -748,4 +748,28 @@ describe("CORE_HEALTH_CHECKS", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("registers stale session locks as a legacy-owned structured check", async () => {
|
||||
const check = getCheck(createCoreHealthChecks(createDeps()), "core/doctor/session-locks");
|
||||
|
||||
if (typeof check.repair !== "function") {
|
||||
throw new Error("expected session lock check repair");
|
||||
}
|
||||
await expect(
|
||||
check.repair(
|
||||
{
|
||||
mode: "fix",
|
||||
runtime,
|
||||
cfg: {},
|
||||
cwd: "/tmp/openclaw-test-workspace",
|
||||
},
|
||||
[],
|
||||
),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
status: "skipped",
|
||||
reason: "legacy doctor session lock contribution owns cleanup",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,11 @@ import {
|
||||
shellCompletionStatusToHealthFindings,
|
||||
shellCompletionStatusToRepairEffects,
|
||||
} from "../commands/doctor-completion.js";
|
||||
import {
|
||||
detectStaleSessionLocks,
|
||||
sessionLockToHealthFinding,
|
||||
sessionLockToRepairEffect,
|
||||
} from "../commands/doctor-session-locks.js";
|
||||
import {
|
||||
disableUnavailableSkillsInConfig,
|
||||
formatMissingSkillSummary,
|
||||
@@ -31,6 +36,7 @@ import { resolveGatewayAuth } from "../gateway/auth.js";
|
||||
import { getSkippedExecRefStaticError } from "../secrets/exec-resolution-policy.js";
|
||||
import type { SkillStatusEntry } from "../skills/discovery/status.js";
|
||||
import { registerHealthCheck } from "./health-check-registry.js";
|
||||
import type { SplitHealthCheckInput } from "./health-check-runner-types.js";
|
||||
import type {
|
||||
HealthCheck,
|
||||
HealthCheckContext,
|
||||
@@ -42,6 +48,7 @@ const BROWSER_CLAWD_PROFILE_RESIDUE_CHECK_ID = "core/doctor/browser-clawd-profil
|
||||
const CODEX_SESSION_ROUTES_CHECK_ID = "core/doctor/codex-session-routes";
|
||||
const FINAL_CONFIG_VALIDATION_CHECK_ID = "core/doctor/final-config-validation";
|
||||
const GATEWAY_SERVICES_EXTRA_CHECK_ID = "core/doctor/gateway-services/extra";
|
||||
const SESSION_LOCKS_CHECK_ID = "core/doctor/session-locks";
|
||||
|
||||
type CoreHealthCheckContext = HealthCheckContext & {
|
||||
readonly deep?: boolean;
|
||||
@@ -729,6 +736,33 @@ const gatewayPlatformNotesCheck: HealthCheck = {
|
||||
},
|
||||
};
|
||||
|
||||
const sessionLocksCheck: SplitHealthCheckInput = {
|
||||
id: SESSION_LOCKS_CHECK_ID,
|
||||
kind: "core",
|
||||
description: "Stale session lock files are represented as structured findings.",
|
||||
source: "doctor",
|
||||
defaultEnabled: false,
|
||||
async detect(ctx) {
|
||||
return (await detectStaleSessionLocks({ config: ctx.cfg, env: process.env })).map(
|
||||
sessionLockToHealthFinding,
|
||||
);
|
||||
},
|
||||
async repair(ctx) {
|
||||
const effects = (await detectStaleSessionLocks({ config: ctx.cfg, env: process.env })).map(
|
||||
sessionLockToRepairEffect,
|
||||
);
|
||||
if (ctx.dryRun === true) {
|
||||
return { status: "repaired", changes: [], effects };
|
||||
}
|
||||
return {
|
||||
status: "skipped",
|
||||
reason: "legacy doctor session lock contribution owns cleanup",
|
||||
changes: [],
|
||||
effects,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const browserCheck: HealthCheck = {
|
||||
id: "core/doctor/browser",
|
||||
kind: "core",
|
||||
@@ -974,13 +1008,16 @@ function createWorkspaceSuggestionsCheck(deps: CoreHealthCheckDeps): HealthCheck
|
||||
};
|
||||
}
|
||||
|
||||
function createConvertedWorkflowChecks(deps: CoreHealthCheckDeps): readonly HealthCheck[] {
|
||||
function createConvertedWorkflowChecks(
|
||||
deps: CoreHealthCheckDeps,
|
||||
): readonly SplitHealthCheckInput[] {
|
||||
return [
|
||||
claudeCliCheck,
|
||||
gatewayAuthCheck,
|
||||
legacyStateCheck,
|
||||
legacyWhatsAppCrontabCheck,
|
||||
codexSessionRoutesCheck,
|
||||
sessionLocksCheck,
|
||||
shellCompletionCheck,
|
||||
uiProtocolFreshnessCheck,
|
||||
gatewayServicesExtraCheck,
|
||||
@@ -1015,7 +1052,7 @@ export function resetCoreHealthChecksForTest(): void {
|
||||
|
||||
export function createCoreHealthChecks(
|
||||
deps: CoreHealthCheckDeps = defaultCoreHealthCheckDeps,
|
||||
): readonly HealthCheck[] {
|
||||
): readonly SplitHealthCheckInput[] {
|
||||
return [
|
||||
gatewayConfigCheck,
|
||||
...createConvertedWorkflowChecks(deps),
|
||||
@@ -1026,4 +1063,4 @@ export function createCoreHealthChecks(
|
||||
];
|
||||
}
|
||||
|
||||
export const CORE_HEALTH_CHECKS: readonly HealthCheck[] = createCoreHealthChecks();
|
||||
export const CORE_HEALTH_CHECKS: readonly SplitHealthCheckInput[] = createCoreHealthChecks();
|
||||
|
||||
@@ -1292,6 +1292,7 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] {
|
||||
createDoctorHealthContribution({
|
||||
id: "doctor:session-locks",
|
||||
label: "Session locks",
|
||||
healthCheckIds: ["core/doctor/session-locks"],
|
||||
run: runSessionLocksHealth,
|
||||
}),
|
||||
createDoctorHealthContribution({
|
||||
|
||||
@@ -39,6 +39,36 @@ describe("runDoctorLintChecks", () => {
|
||||
expect(result.findings.map((finding) => finding.checkId)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("skips default-disabled checks unless explicitly selected", async () => {
|
||||
const defaultDisabled = normalizeHealthCheck({
|
||||
...check("targeted", async () => [
|
||||
{ checkId: "targeted", severity: "warning" as const, message: "warn" },
|
||||
]),
|
||||
defaultEnabled: false,
|
||||
});
|
||||
|
||||
await expect(
|
||||
runDoctorLintChecks(ctx, {
|
||||
checks: [defaultDisabled],
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
checksRun: 0,
|
||||
checksSkipped: 1,
|
||||
findings: [],
|
||||
});
|
||||
|
||||
await expect(
|
||||
runDoctorLintChecks(ctx, {
|
||||
checks: [defaultDisabled],
|
||||
onlyIds: ["targeted"],
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
checksRun: 1,
|
||||
checksSkipped: 0,
|
||||
findings: [expect.objectContaining({ checkId: "targeted" })],
|
||||
});
|
||||
});
|
||||
|
||||
it("supports single-run checks in lint mode", async () => {
|
||||
const runnable: RunnableHealthCheck = {
|
||||
id: "run-check",
|
||||
|
||||
@@ -37,6 +37,9 @@ export async function runDoctorLintChecks(
|
||||
if (only.size > 0 && !only.has(c.id)) {
|
||||
return false;
|
||||
}
|
||||
if (only.size === 0 && isDefaultDisabled(c)) {
|
||||
return false;
|
||||
}
|
||||
if (skip.has(c.id)) {
|
||||
return false;
|
||||
}
|
||||
@@ -78,6 +81,10 @@ export async function runDoctorLintChecks(
|
||||
};
|
||||
}
|
||||
|
||||
function isDefaultDisabled(check: HealthCheck): boolean {
|
||||
return "defaultEnabled" in check && check.defaultEnabled === false;
|
||||
}
|
||||
|
||||
// Stable ordering keeps CLI output and tests deterministic across registry order changes.
|
||||
function compareFindings(a: HealthFinding, b: HealthFinding): number {
|
||||
const sevDelta =
|
||||
|
||||
@@ -3,17 +3,19 @@ import type {
|
||||
HealthCheckInput,
|
||||
HealthCheckRunResult,
|
||||
RegisteredHealthCheck,
|
||||
SplitHealthCheckInput,
|
||||
} from "./health-check-runner-types.js";
|
||||
import type { HealthCheck, HealthRepairContext } from "./health-checks.js";
|
||||
import type { HealthRepairContext } from "./health-checks.js";
|
||||
|
||||
// Adapts legacy split detect/repair checks and newer runnable checks to one runner contract.
|
||||
/** Wraps a detect/repair health check in the runnable health-check contract. */
|
||||
export function defineSplitHealthCheck(check: HealthCheck): RegisteredHealthCheck {
|
||||
export function defineSplitHealthCheck(check: SplitHealthCheckInput): RegisteredHealthCheck {
|
||||
return {
|
||||
id: check.id,
|
||||
kind: check.kind,
|
||||
description: check.description,
|
||||
source: check.source,
|
||||
defaultEnabled: check.defaultEnabled,
|
||||
sourceContract: "split",
|
||||
detect: (ctx, scope) => check.detect(ctx, scope),
|
||||
repair:
|
||||
@@ -73,6 +75,7 @@ export function normalizeHealthCheck(check: HealthCheckInput): RegisteredHealthC
|
||||
kind: check.kind,
|
||||
description: check.description,
|
||||
source: check.source,
|
||||
defaultEnabled: check.defaultEnabled,
|
||||
sourceContract: "run",
|
||||
async detect(ctx, scope) {
|
||||
const result = await check.run({ ...ctx, repair: false }, scope);
|
||||
|
||||
@@ -25,18 +25,23 @@ export interface HealthCheckRunResult extends Omit<HealthRepairResult, "changes"
|
||||
readonly effects?: readonly HealthRepairEffect[];
|
||||
}
|
||||
|
||||
/** Internal runner selection metadata. This is intentionally not part of the public SDK type. */
|
||||
export interface HealthCheckSelectionOptions {
|
||||
readonly defaultEnabled?: boolean;
|
||||
}
|
||||
|
||||
export type SplitHealthCheckInput = HealthCheck & HealthCheckSelectionOptions;
|
||||
|
||||
/** Health-check implementation that owns its own detect/repair orchestration. */
|
||||
export interface RunnableHealthCheck extends Pick<
|
||||
HealthCheck,
|
||||
"id" | "kind" | "description" | "source"
|
||||
> {
|
||||
export interface RunnableHealthCheck
|
||||
extends Pick<HealthCheck, "id" | "kind" | "description" | "source">, HealthCheckSelectionOptions {
|
||||
run(ctx: HealthCheckRunContext, scope?: HealthCheckScope): Promise<HealthCheckRunResult>;
|
||||
}
|
||||
|
||||
export type HealthCheckInput = HealthCheck | RunnableHealthCheck;
|
||||
export type HealthCheckInput = SplitHealthCheckInput | RunnableHealthCheck;
|
||||
|
||||
/** Normalized check contract consumed by lint and repair runners. */
|
||||
export interface RegisteredHealthCheck extends HealthCheck {
|
||||
export interface RegisteredHealthCheck extends HealthCheck, HealthCheckSelectionOptions {
|
||||
readonly sourceContract: "split" | "run";
|
||||
run(ctx: HealthCheckRunContext, scope?: HealthCheckScope): Promise<HealthCheckRunResult>;
|
||||
}
|
||||
|
||||
@@ -18,8 +18,7 @@ import {
|
||||
resolveMainSessionKey,
|
||||
} from "../config/sessions/main-session.js";
|
||||
import { resolveStorePath } from "../config/sessions/paths.js";
|
||||
import { loadSessionStore, updateSessionStore } from "../config/sessions/store.js";
|
||||
import type { SessionEntry } from "../config/sessions/types.js";
|
||||
import { preserveTemporarySessionMapping } from "../config/sessions/session-accessor.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
@@ -33,14 +32,6 @@ function generateBootSessionId(): string {
|
||||
return `boot-${ts}-${suffix}`;
|
||||
}
|
||||
|
||||
type SessionMappingSnapshot = {
|
||||
storePath: string;
|
||||
sessionKey: string;
|
||||
canRestore: boolean;
|
||||
hadEntry: boolean;
|
||||
entry?: SessionEntry;
|
||||
};
|
||||
|
||||
const log = createSubsystemLogger("gateway/boot");
|
||||
const BOOT_FILENAME = "BOOT.md";
|
||||
|
||||
@@ -101,68 +92,6 @@ async function loadBootFile(
|
||||
}
|
||||
}
|
||||
|
||||
function snapshotSessionMapping(params: {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
}): SessionMappingSnapshot {
|
||||
const agentId = resolveAgentIdFromSessionKey(params.sessionKey);
|
||||
const storePath = resolveStorePath(params.cfg.session?.store, { agentId });
|
||||
try {
|
||||
const store = loadSessionStore(storePath, { skipCache: true });
|
||||
const entry = store[params.sessionKey];
|
||||
if (!entry) {
|
||||
return {
|
||||
storePath,
|
||||
sessionKey: params.sessionKey,
|
||||
canRestore: true,
|
||||
hadEntry: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
storePath,
|
||||
sessionKey: params.sessionKey,
|
||||
canRestore: true,
|
||||
hadEntry: true,
|
||||
entry: structuredClone(entry),
|
||||
};
|
||||
} catch (err) {
|
||||
log.debug("boot: could not snapshot session mapping", {
|
||||
sessionKey: params.sessionKey,
|
||||
error: String(err),
|
||||
});
|
||||
return {
|
||||
storePath,
|
||||
sessionKey: params.sessionKey,
|
||||
canRestore: false,
|
||||
hadEntry: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreSessionMapping(
|
||||
snapshot: SessionMappingSnapshot,
|
||||
): Promise<string | undefined> {
|
||||
if (!snapshot.canRestore) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
await updateSessionStore(
|
||||
snapshot.storePath,
|
||||
(store) => {
|
||||
if (snapshot.hadEntry && snapshot.entry) {
|
||||
store[snapshot.sessionKey] = snapshot.entry;
|
||||
return;
|
||||
}
|
||||
delete store[snapshot.sessionKey];
|
||||
},
|
||||
{ activeSessionKey: snapshot.sessionKey },
|
||||
);
|
||||
return undefined;
|
||||
} catch (err) {
|
||||
return formatErrorMessage(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runBootOnce(params: {
|
||||
cfg: OpenClawConfig;
|
||||
deps: CliDeps;
|
||||
@@ -193,39 +122,49 @@ export async function runBootOnce(params: {
|
||||
const sessionKey = resolveBootSessionKey(mainSessionKey);
|
||||
const message = buildBootPrompt(result.content ?? "");
|
||||
const sessionId = generateBootSessionId();
|
||||
const mappingSnapshot = snapshotSessionMapping({
|
||||
cfg: params.cfg,
|
||||
sessionKey,
|
||||
});
|
||||
const agentId = resolveAgentIdFromSessionKey(sessionKey);
|
||||
const storePath = resolveStorePath(params.cfg.session?.store, { agentId });
|
||||
|
||||
// Register the boot prompt for the message-tool echo guard so the
|
||||
// tool layer can drop fallback-model echoes that copy substantial
|
||||
// BOOT.md content without preserving the wrapper markers above.
|
||||
// Always cleared in finally so a failed run does not leave a stale
|
||||
// entry that mis-fires on an unrelated subsequent run reusing the
|
||||
// same session key. Refs #53732.
|
||||
setBootEchoContextForSession(sessionKey, message);
|
||||
let agentFailure: string | undefined;
|
||||
try {
|
||||
await agentCommand(
|
||||
{
|
||||
message,
|
||||
sessionKey,
|
||||
sessionId,
|
||||
deliver: false,
|
||||
suppressPromptPersistence: true,
|
||||
},
|
||||
bootRuntime,
|
||||
params.deps,
|
||||
);
|
||||
} catch (err) {
|
||||
agentFailure = formatErrorMessage(err);
|
||||
log.error(`boot: agent run failed: ${agentFailure}`);
|
||||
} finally {
|
||||
clearBootEchoContextForSession(sessionKey);
|
||||
const mappingPreservation = await preserveTemporarySessionMapping(
|
||||
{ storePath, sessionKey },
|
||||
async () => {
|
||||
// Register the boot prompt for the message-tool echo guard so the
|
||||
// tool layer can drop fallback-model echoes that copy substantial
|
||||
// BOOT.md content without preserving the wrapper markers above.
|
||||
// Always cleared in finally so a failed run does not leave a stale
|
||||
// entry that mis-fires on an unrelated subsequent run reusing the
|
||||
// same session key. Refs #53732.
|
||||
setBootEchoContextForSession(sessionKey, message);
|
||||
try {
|
||||
await agentCommand(
|
||||
{
|
||||
message,
|
||||
sessionKey,
|
||||
sessionId,
|
||||
deliver: false,
|
||||
suppressPromptPersistence: true,
|
||||
},
|
||||
bootRuntime,
|
||||
params.deps,
|
||||
);
|
||||
return undefined;
|
||||
} catch (err) {
|
||||
const failure = formatErrorMessage(err);
|
||||
log.error(`boot: agent run failed: ${failure}`);
|
||||
return failure;
|
||||
} finally {
|
||||
clearBootEchoContextForSession(sessionKey);
|
||||
}
|
||||
},
|
||||
);
|
||||
const agentFailure = mappingPreservation.result;
|
||||
if (mappingPreservation.snapshotFailure) {
|
||||
log.debug("boot: could not snapshot session mapping", {
|
||||
sessionKey,
|
||||
error: mappingPreservation.snapshotFailure,
|
||||
});
|
||||
}
|
||||
|
||||
const mappingRestoreFailure = await restoreSessionMapping(mappingSnapshot);
|
||||
const mappingRestoreFailure = mappingPreservation.restoreFailure;
|
||||
if (mappingRestoreFailure) {
|
||||
log.error(`boot: failed to restore session mapping: ${mappingRestoreFailure}`);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Gateway config reload tests cover changed-path detection, reload planning,
|
||||
// plugin registry refresh, skill snapshot invalidation, and watcher behavior.
|
||||
import nodePath from "node:path";
|
||||
import chokidar from "chokidar";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { listChannelPlugins } from "../channels/plugins/index.js";
|
||||
@@ -578,8 +579,8 @@ describe("resolveGatewayReloadSettings", () => {
|
||||
});
|
||||
});
|
||||
|
||||
type WatcherHandler = () => void;
|
||||
type WatcherEvent = "add" | "change" | "unlink" | "error";
|
||||
type WatcherHandler = (value?: string | Error) => void;
|
||||
type WatcherEvent = "add" | "change" | "unlink" | "error" | "ready";
|
||||
|
||||
function createWatcherMock(effectiveUsePolling?: boolean) {
|
||||
const handlers = new Map<WatcherEvent, WatcherHandler[]>();
|
||||
@@ -592,9 +593,9 @@ function createWatcherMock(effectiveUsePolling?: boolean) {
|
||||
handlers.set(event, existing);
|
||||
return this;
|
||||
},
|
||||
emit(event: WatcherEvent) {
|
||||
emit(event: WatcherEvent, value?: string | Error) {
|
||||
for (const handler of handlers.get(event) ?? []) {
|
||||
handler();
|
||||
handler(value);
|
||||
}
|
||||
},
|
||||
close: vi.fn(async () => {}),
|
||||
@@ -659,17 +660,30 @@ function makeZeroDebounceHookWrite(persistedHash: string): ConfigWriteNotificati
|
||||
}
|
||||
|
||||
function createReloaderHarness(
|
||||
readSnapshot: () => Promise<ConfigFileSnapshot>,
|
||||
readSnapshot: () => Promise<
|
||||
ConfigFileSnapshot | { snapshot: ConfigFileSnapshot; includeFilePaths?: readonly string[] }
|
||||
>,
|
||||
options: {
|
||||
initialCompareConfig?: OpenClawConfig;
|
||||
initialInternalWriteHash?: string | null;
|
||||
initialIncludeFilePaths?: readonly string[];
|
||||
promoteSnapshot?: (snapshot: ConfigFileSnapshot, reason: string) => Promise<boolean>;
|
||||
initialPluginInstallRecords?: Record<string, PluginInstallRecord>;
|
||||
readPluginInstallRecords?: () => Promise<Record<string, PluginInstallRecord>>;
|
||||
watchers?: ReturnType<typeof createWatcherMock>[];
|
||||
} = {},
|
||||
) {
|
||||
const watcher = createWatcherMock();
|
||||
vi.spyOn(chokidar, "watch").mockReturnValue(watcher as unknown as never);
|
||||
const watchers = options.watchers ?? [createWatcherMock()];
|
||||
const watcher = watchers[0] ?? createWatcherMock();
|
||||
let watcherIndex = 0;
|
||||
const watchSpy = vi.spyOn(chokidar, "watch").mockImplementation((_path, watchOptions) => {
|
||||
const next = watchers[watcherIndex++];
|
||||
if (!next) {
|
||||
throw new Error("missing watcher mock");
|
||||
}
|
||||
next.options.usePolling = next.effectiveUsePolling ?? Boolean(watchOptions?.usePolling);
|
||||
return next as unknown as never;
|
||||
});
|
||||
const onHotReload = vi.fn(async (_plan: GatewayReloadPlan, _nextConfig: OpenClawConfig) => {});
|
||||
const onRestart = vi.fn((_plan: GatewayReloadPlan, _nextConfig: OpenClawConfig) => {});
|
||||
let writeListener: ((event: ConfigWriteNotification) => void) | null = null;
|
||||
@@ -690,6 +704,7 @@ function createReloaderHarness(
|
||||
initialConfig: { gateway: { reload: { debounceMs: 0 } } },
|
||||
initialCompareConfig: options.initialCompareConfig,
|
||||
initialInternalWriteHash: options.initialInternalWriteHash,
|
||||
initialIncludeFilePaths: options.initialIncludeFilePaths,
|
||||
readSnapshot,
|
||||
promoteSnapshot: options.promoteSnapshot,
|
||||
initialPluginInstallRecords: options.initialPluginInstallRecords ?? {},
|
||||
@@ -702,6 +717,8 @@ function createReloaderHarness(
|
||||
});
|
||||
return {
|
||||
watcher,
|
||||
watchers,
|
||||
watchSpy,
|
||||
onHotReload,
|
||||
onRestart,
|
||||
log,
|
||||
@@ -999,7 +1016,9 @@ describe("startGatewayConfigReloader", () => {
|
||||
await harness.reloader.stop();
|
||||
});
|
||||
|
||||
it("does not promote external config edits when hot reload rejects them", async () => {
|
||||
it("does not replay a rejected graph and accepts a later content change", async () => {
|
||||
const oldInclude = nodePath.normalize("/tmp/includes/old.json5");
|
||||
const nextInclude = nodePath.normalize("/tmp/includes/next.json5");
|
||||
const acceptedSnapshot = makeSnapshot({
|
||||
config: {
|
||||
gateway: { reload: { debounceMs: 0 } },
|
||||
@@ -1007,22 +1026,50 @@ describe("startGatewayConfigReloader", () => {
|
||||
},
|
||||
hash: "external-rejected-1",
|
||||
});
|
||||
const revisedSnapshot = makeSnapshot({
|
||||
config: {
|
||||
gateway: { reload: { debounceMs: 0 } },
|
||||
hooks: { enabled: false },
|
||||
},
|
||||
hash: "external-revised-2",
|
||||
});
|
||||
const readSnapshot = vi
|
||||
.fn<() => Promise<ConfigFileSnapshot>>()
|
||||
.mockResolvedValueOnce(acceptedSnapshot);
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ snapshot: acceptedSnapshot, includeFilePaths: [nextInclude] })
|
||||
.mockResolvedValueOnce({ snapshot: acceptedSnapshot, includeFilePaths: [nextInclude] })
|
||||
.mockResolvedValueOnce({ snapshot: revisedSnapshot, includeFilePaths: [nextInclude] });
|
||||
const promoteSnapshot = vi.fn(async (_snapshot: ConfigFileSnapshot, _reason: string) => true);
|
||||
const watchers = [createWatcherMock(), createWatcherMock(), createWatcherMock()];
|
||||
const { watcher, onHotReload, log, reloader } = createReloaderHarness(readSnapshot, {
|
||||
initialIncludeFilePaths: [oldInclude],
|
||||
promoteSnapshot,
|
||||
watchers,
|
||||
});
|
||||
onHotReload.mockRejectedValueOnce(new Error("reload refused"));
|
||||
|
||||
watcher.emit("change");
|
||||
await vi.runAllTimersAsync();
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(onHotReload).toHaveBeenCalledTimes(1);
|
||||
expect(promoteSnapshot).not.toHaveBeenCalled();
|
||||
expect(log.error).toHaveBeenCalledWith("config reload failed: Error: reload refused");
|
||||
|
||||
watcher.emit("change");
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(onHotReload).toHaveBeenCalledTimes(1);
|
||||
expect(promoteSnapshot).not.toHaveBeenCalled();
|
||||
expect(log.warn).toHaveBeenCalledWith(
|
||||
"config reload skipped (previous apply failed; waiting for config change)",
|
||||
);
|
||||
|
||||
watcher.emit("change");
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(onHotReload).toHaveBeenCalledTimes(2);
|
||||
expect(promoteSnapshot).toHaveBeenCalledTimes(1);
|
||||
expect(promoteSnapshot).toHaveBeenCalledWith(revisedSnapshot, "valid-config");
|
||||
|
||||
await reloader.stop();
|
||||
});
|
||||
|
||||
@@ -1104,6 +1151,461 @@ describe("startGatewayConfigReloader", () => {
|
||||
await harness.reloader.stop();
|
||||
});
|
||||
|
||||
it("retains a queued include reconciliation when an in-process hot reload throws", async () => {
|
||||
const includePath = nodePath.normalize("/tmp/includes/active.json5");
|
||||
const acceptedSnapshot = makeZeroDebounceHookSnapshot("internal-reconcile-1");
|
||||
const readSnapshot = vi.fn().mockResolvedValueOnce({
|
||||
snapshot: acceptedSnapshot,
|
||||
includeFilePaths: [includePath],
|
||||
});
|
||||
const watchers = [createWatcherMock(), createWatcherMock()];
|
||||
const harness = createReloaderHarness(readSnapshot, {
|
||||
initialIncludeFilePaths: [includePath],
|
||||
watchers,
|
||||
});
|
||||
harness.onHotReload.mockRejectedValueOnce(new Error("reload refused"));
|
||||
|
||||
harness.emitWrite(makeZeroDebounceHookWrite("internal-reconcile-1"));
|
||||
watchers[1]?.emit("ready");
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(harness.log.error).toHaveBeenCalledWith("config reload failed: Error: reload refused");
|
||||
expect(readSnapshot).toHaveBeenCalledTimes(1);
|
||||
expect(harness.onHotReload).toHaveBeenCalledTimes(1);
|
||||
expect(harness.log.warn).toHaveBeenCalledWith(
|
||||
"config reload skipped (previous apply failed; waiting for config change)",
|
||||
);
|
||||
|
||||
await harness.reloader.stop();
|
||||
});
|
||||
|
||||
it("watches nested startup includes and does not apply root hash dedupe to include edits", async () => {
|
||||
const includePaths = [
|
||||
nodePath.normalize("/tmp/includes/outer.json5"),
|
||||
nodePath.normalize("/tmp/includes/nested.json5"),
|
||||
];
|
||||
const readSnapshot = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
snapshot: makeZeroDebounceHookSnapshot("internal-include-1"),
|
||||
includeFilePaths: includePaths,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
snapshot: makeSnapshot({
|
||||
sourceConfig: {
|
||||
gateway: { reload: { debounceMs: 0 } },
|
||||
hooks: { enabled: false },
|
||||
},
|
||||
runtimeConfig: {
|
||||
gateway: { reload: { debounceMs: 0 } },
|
||||
hooks: { enabled: false },
|
||||
},
|
||||
config: {
|
||||
gateway: { reload: { debounceMs: 0 } },
|
||||
hooks: { enabled: false },
|
||||
},
|
||||
hash: "internal-include-1",
|
||||
}),
|
||||
includeFilePaths: includePaths,
|
||||
});
|
||||
const watchers = [createWatcherMock(), createWatcherMock(), createWatcherMock()];
|
||||
const harness = createReloaderHarness(readSnapshot, {
|
||||
initialIncludeFilePaths: includePaths,
|
||||
promoteSnapshot: vi.fn(async () => true),
|
||||
watchers,
|
||||
});
|
||||
|
||||
expect(harness.watchSpy.mock.calls.map((call) => call[0])).toEqual([
|
||||
"/tmp/openclaw.json",
|
||||
nodePath.normalize("/tmp/includes/nested.json5"),
|
||||
nodePath.normalize("/tmp/includes/outer.json5"),
|
||||
]);
|
||||
|
||||
harness.emitWrite(makeZeroDebounceHookWrite("internal-include-1"));
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
expect(harness.onHotReload).toHaveBeenCalledTimes(1);
|
||||
|
||||
watchers[2]?.emit("change", nodePath.normalize("/tmp/includes/outer.json5"));
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(readSnapshot).toHaveBeenCalledTimes(2);
|
||||
expect(harness.onHotReload).toHaveBeenCalledTimes(2);
|
||||
await harness.reloader.stop();
|
||||
});
|
||||
|
||||
it("clears a stale root write hash when an include-triggered read sees different root bytes", async () => {
|
||||
const includePath = nodePath.normalize("/tmp/includes/active.json5");
|
||||
const readSnapshot = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
snapshot: makeZeroDebounceHookSnapshot("external-root-2"),
|
||||
includeFilePaths: [includePath],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
snapshot: makeSnapshot({
|
||||
sourceConfig: { gateway: { reload: { debounceMs: 0 } }, hooks: { enabled: false } },
|
||||
runtimeConfig: { gateway: { reload: { debounceMs: 0 } }, hooks: { enabled: false } },
|
||||
config: { gateway: { reload: { debounceMs: 0 } }, hooks: { enabled: false } },
|
||||
hash: "internal-root-1",
|
||||
}),
|
||||
includeFilePaths: [includePath],
|
||||
});
|
||||
const watchers = [createWatcherMock(), createWatcherMock()];
|
||||
const harness = createReloaderHarness(readSnapshot, {
|
||||
initialIncludeFilePaths: [includePath],
|
||||
initialInternalWriteHash: "internal-root-1",
|
||||
watchers,
|
||||
});
|
||||
|
||||
watchers[1]?.emit("change", includePath);
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
expect(harness.onHotReload).toHaveBeenCalledTimes(1);
|
||||
|
||||
watchers[0]?.emit("change", nodePath.normalize("/tmp/openclaw.json"));
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
expect(readSnapshot).toHaveBeenCalledTimes(2);
|
||||
expect(harness.onHotReload).toHaveBeenCalledTimes(2);
|
||||
|
||||
await harness.reloader.stop();
|
||||
});
|
||||
|
||||
it("retries a failed include watcher handoff while the prior set stays active", async () => {
|
||||
const rootPath = nodePath.normalize("/tmp/openclaw.json");
|
||||
const oldInclude = nodePath.normalize("/tmp/includes/old.json5");
|
||||
const nextInclude = nodePath.normalize("/tmp/includes/next.json5");
|
||||
const nextSnapshot = {
|
||||
snapshot: makeZeroDebounceHookSnapshot("graph-retry-1"),
|
||||
includeFilePaths: [nextInclude],
|
||||
};
|
||||
const readSnapshot = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(nextSnapshot)
|
||||
.mockResolvedValueOnce(nextSnapshot);
|
||||
const watchers = [
|
||||
createWatcherMock(),
|
||||
createWatcherMock(),
|
||||
createWatcherMock(),
|
||||
createWatcherMock(),
|
||||
];
|
||||
const [rootWatcher, oldWatcher, failedCandidate, retryCandidate] = watchers;
|
||||
if (!rootWatcher || !oldWatcher || !failedCandidate || !retryCandidate) {
|
||||
throw new Error("expected watcher mocks");
|
||||
}
|
||||
const harness = createReloaderHarness(readSnapshot, {
|
||||
initialIncludeFilePaths: [oldInclude],
|
||||
watchers,
|
||||
});
|
||||
|
||||
rootWatcher.emit("change", rootPath);
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
failedCandidate.emit("error", new Error("ENOSPC"));
|
||||
failedCandidate.emit("ready");
|
||||
|
||||
expect(oldWatcher.close).not.toHaveBeenCalled();
|
||||
expect(rootWatcher.close).not.toHaveBeenCalled();
|
||||
expect(harness.watchSpy).toHaveBeenCalledTimes(3);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(harness.watchSpy).toHaveBeenCalledTimes(4);
|
||||
retryCandidate.emit("ready");
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(oldWatcher.close).toHaveBeenCalledTimes(1);
|
||||
expect(rootWatcher.close).not.toHaveBeenCalled();
|
||||
expect(readSnapshot).toHaveBeenCalledTimes(2);
|
||||
expect(harness.log.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("retrying replacement (attempt 1/3 in 500ms)"),
|
||||
);
|
||||
|
||||
await harness.reloader.stop();
|
||||
});
|
||||
|
||||
it("uses the include watcher's effective polling mode when retries are exhausted", async () => {
|
||||
const originalVitest = process.env.VITEST;
|
||||
const originalChokidarPolling = process.env.CHOKIDAR_USEPOLLING;
|
||||
delete process.env.VITEST;
|
||||
delete process.env.CHOKIDAR_USEPOLLING;
|
||||
let harness: ReloaderHarness | undefined;
|
||||
try {
|
||||
const oldInclude = nodePath.normalize("/tmp/includes/old.json5");
|
||||
const nextInclude = nodePath.normalize("/tmp/includes/next.json5");
|
||||
const watchers = [
|
||||
createWatcherMock(false),
|
||||
createWatcherMock(false),
|
||||
createWatcherMock(true),
|
||||
createWatcherMock(true),
|
||||
createWatcherMock(true),
|
||||
createWatcherMock(true),
|
||||
];
|
||||
harness = createReloaderHarness(
|
||||
vi.fn().mockResolvedValueOnce({
|
||||
snapshot: makeZeroDebounceHookSnapshot("effective-polling"),
|
||||
includeFilePaths: [nextInclude],
|
||||
}),
|
||||
{ initialIncludeFilePaths: [oldInclude], watchers },
|
||||
);
|
||||
|
||||
watchers[0]?.emit("change", nodePath.normalize("/tmp/openclaw.json"));
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
for (const [index, delay] of [
|
||||
[2, 500],
|
||||
[3, 2000],
|
||||
[4, 5000],
|
||||
] as const) {
|
||||
watchers[index]?.emit("error", new Error("polling failed"));
|
||||
await vi.advanceTimersByTimeAsync(delay);
|
||||
}
|
||||
watchers[5]?.emit("error", new Error("polling failed"));
|
||||
|
||||
expect(harness.reloader.hotReloadStatus()).toBe("disabled");
|
||||
expect(harness.log.error).toHaveBeenCalledWith(expect.stringContaining("in polling mode"));
|
||||
expect(harness.log.warn).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("degrading to polling mode"),
|
||||
);
|
||||
} finally {
|
||||
if (originalVitest === undefined) {
|
||||
delete process.env.VITEST;
|
||||
} else {
|
||||
process.env.VITEST = originalVitest;
|
||||
}
|
||||
if (originalChokidarPolling === undefined) {
|
||||
delete process.env.CHOKIDAR_USEPOLLING;
|
||||
} else {
|
||||
process.env.CHOKIDAR_USEPOLLING = originalChokidarPolling;
|
||||
}
|
||||
await harness?.reloader.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("reconciles once the initial include watcher set is ready", async () => {
|
||||
const includePath = nodePath.normalize("/tmp/includes/startup.json5");
|
||||
const readSnapshot = vi.fn().mockResolvedValueOnce({
|
||||
snapshot: makeZeroDebounceHookSnapshot("startup-include-ready"),
|
||||
includeFilePaths: [includePath],
|
||||
});
|
||||
const watchers = [createWatcherMock(), createWatcherMock()];
|
||||
const harness = createReloaderHarness(readSnapshot, {
|
||||
initialIncludeFilePaths: [includePath],
|
||||
watchers,
|
||||
});
|
||||
|
||||
watchers[1]?.emit("ready");
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(readSnapshot).toHaveBeenCalledTimes(1);
|
||||
await harness.reloader.stop();
|
||||
});
|
||||
|
||||
it("reconciles a retained initial watcher after a graph change reverts before ready", async () => {
|
||||
const rootPath = nodePath.normalize("/tmp/openclaw.json");
|
||||
const initialInclude = nodePath.normalize("/tmp/includes/initial.json5");
|
||||
const transientInclude = nodePath.normalize("/tmp/includes/transient.json5");
|
||||
const initialSnapshot = {
|
||||
snapshot: makeZeroDebounceHookSnapshot("initial-graph"),
|
||||
includeFilePaths: [initialInclude],
|
||||
};
|
||||
const readSnapshot = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
snapshot: makeZeroDebounceHookSnapshot("transient-graph"),
|
||||
includeFilePaths: [transientInclude],
|
||||
})
|
||||
.mockResolvedValueOnce(initialSnapshot)
|
||||
.mockResolvedValueOnce(initialSnapshot);
|
||||
const watchers = [createWatcherMock(), createWatcherMock(), createWatcherMock()];
|
||||
const [rootWatcher, initialWatcher, transientCandidate] = watchers;
|
||||
if (!rootWatcher || !initialWatcher || !transientCandidate) {
|
||||
throw new Error("expected watcher mocks");
|
||||
}
|
||||
const harness = createReloaderHarness(readSnapshot, {
|
||||
initialIncludeFilePaths: [initialInclude],
|
||||
watchers,
|
||||
});
|
||||
|
||||
rootWatcher.emit("change", rootPath);
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
rootWatcher.emit("change", rootPath);
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(transientCandidate.close).toHaveBeenCalledTimes(1);
|
||||
initialWatcher.emit("ready");
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
expect(readSnapshot).toHaveBeenCalledTimes(3);
|
||||
|
||||
await harness.reloader.stop();
|
||||
});
|
||||
|
||||
it("invalidates an active include watcher that errors during a newer graph handoff", async () => {
|
||||
const rootPath = nodePath.normalize("/tmp/openclaw.json");
|
||||
const oldInclude = nodePath.normalize("/tmp/includes/old.json5");
|
||||
const nextInclude = nodePath.normalize("/tmp/includes/next.json5");
|
||||
const nextSnapshot = {
|
||||
snapshot: makeZeroDebounceHookSnapshot("graph-active-error"),
|
||||
includeFilePaths: [nextInclude],
|
||||
};
|
||||
const readSnapshot = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(nextSnapshot)
|
||||
.mockResolvedValueOnce(nextSnapshot);
|
||||
const watchers = [createWatcherMock(), createWatcherMock(), createWatcherMock()];
|
||||
const [rootWatcher, oldWatcher, candidateWatcher] = watchers;
|
||||
if (!rootWatcher || !oldWatcher || !candidateWatcher) {
|
||||
throw new Error("expected watcher mocks");
|
||||
}
|
||||
const harness = createReloaderHarness(readSnapshot, {
|
||||
initialIncludeFilePaths: [oldInclude],
|
||||
watchers,
|
||||
});
|
||||
|
||||
rootWatcher.emit("change", rootPath);
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
oldWatcher.emit("error", new Error("active failed"));
|
||||
|
||||
expect(oldWatcher.close).toHaveBeenCalledTimes(1);
|
||||
expect(rootWatcher.close).not.toHaveBeenCalled();
|
||||
|
||||
candidateWatcher.emit("ready");
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
expect(readSnapshot).toHaveBeenCalledTimes(2);
|
||||
|
||||
await harness.reloader.stop();
|
||||
});
|
||||
|
||||
it("atomically swaps changed include graphs after ready and reconciles without watcher leaks", async () => {
|
||||
const rootPath = nodePath.normalize("/tmp/openclaw.json");
|
||||
const oldInclude = nodePath.normalize("/tmp/includes/old.json5");
|
||||
const nextInclude = nodePath.normalize("/tmp/includes/next.json5");
|
||||
const finalInclude = nodePath.normalize("/tmp/includes/final.json5");
|
||||
const firstConfig: OpenClawConfig = {
|
||||
gateway: { reload: { debounceMs: 0 } },
|
||||
hooks: { enabled: true },
|
||||
};
|
||||
const finalConfig: OpenClawConfig = {
|
||||
gateway: { reload: { debounceMs: 0 } },
|
||||
hooks: { enabled: false },
|
||||
};
|
||||
const readSnapshot = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
snapshot: makeSnapshot({
|
||||
sourceConfig: firstConfig,
|
||||
runtimeConfig: firstConfig,
|
||||
config: firstConfig,
|
||||
hash: "graph-1",
|
||||
}),
|
||||
includeFilePaths: [nextInclude],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
snapshot: makeSnapshot({
|
||||
sourceConfig: firstConfig,
|
||||
runtimeConfig: firstConfig,
|
||||
config: firstConfig,
|
||||
hash: "graph-1",
|
||||
}),
|
||||
includeFilePaths: [nextInclude],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
snapshot: makeSnapshot({
|
||||
sourceConfig: finalConfig,
|
||||
runtimeConfig: finalConfig,
|
||||
config: finalConfig,
|
||||
hash: "graph-2",
|
||||
}),
|
||||
includeFilePaths: [finalInclude],
|
||||
});
|
||||
const watchers = [
|
||||
createWatcherMock(),
|
||||
createWatcherMock(),
|
||||
createWatcherMock(),
|
||||
createWatcherMock(),
|
||||
];
|
||||
const harness = createReloaderHarness(readSnapshot, {
|
||||
initialIncludeFilePaths: [oldInclude],
|
||||
watchers,
|
||||
});
|
||||
const [rootWatcher, initialIncludeWatcher, replacementWatcher, pendingFinalWatcher] = watchers;
|
||||
if (!rootWatcher || !initialIncludeWatcher || !replacementWatcher || !pendingFinalWatcher) {
|
||||
throw new Error("expected watcher mocks");
|
||||
}
|
||||
|
||||
rootWatcher.emit("change", rootPath);
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(harness.watchSpy.mock.calls[2]?.[0]).toBe(nextInclude);
|
||||
expect(rootWatcher.close).not.toHaveBeenCalled();
|
||||
expect(initialIncludeWatcher.close).not.toHaveBeenCalled();
|
||||
replacementWatcher.emit("change", nextInclude);
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
expect(readSnapshot).toHaveBeenCalledTimes(1);
|
||||
|
||||
replacementWatcher.emit("ready");
|
||||
expect(initialIncludeWatcher.close).toHaveBeenCalledTimes(1);
|
||||
expect(rootWatcher.close).not.toHaveBeenCalled();
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
expect(readSnapshot).toHaveBeenCalledTimes(2);
|
||||
expect(harness.onHotReload).toHaveBeenCalledTimes(1);
|
||||
|
||||
initialIncludeWatcher.emit("change", oldInclude);
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
expect(readSnapshot).toHaveBeenCalledTimes(2);
|
||||
|
||||
rootWatcher.emit("change", rootPath);
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
expect(harness.watchSpy.mock.calls[3]?.[0]).toBe(finalInclude);
|
||||
expect(harness.onHotReload).toHaveBeenCalledTimes(2);
|
||||
|
||||
await harness.reloader.stop();
|
||||
expect(rootWatcher.close).toHaveBeenCalledTimes(1);
|
||||
expect(initialIncludeWatcher.close).toHaveBeenCalledTimes(1);
|
||||
expect(replacementWatcher.close).toHaveBeenCalledTimes(1);
|
||||
expect(pendingFinalWatcher.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps the last valid include watch set when a candidate snapshot is invalid", async () => {
|
||||
const rootPath = nodePath.normalize("/tmp/openclaw.json");
|
||||
const acceptedInclude = nodePath.normalize("/tmp/includes/accepted.json5");
|
||||
const rejectedInclude = nodePath.normalize("/tmp/includes/rejected.json5");
|
||||
const nextConfig: OpenClawConfig = {
|
||||
gateway: { reload: { debounceMs: 0 } },
|
||||
hooks: { enabled: true },
|
||||
};
|
||||
const readSnapshot = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
snapshot: makeSnapshot({
|
||||
valid: false,
|
||||
issues: [{ path: "hooks.enabled", message: "Expected boolean" }],
|
||||
hash: "invalid-graph",
|
||||
}),
|
||||
includeFilePaths: [rejectedInclude],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
snapshot: makeSnapshot({
|
||||
sourceConfig: nextConfig,
|
||||
runtimeConfig: nextConfig,
|
||||
config: nextConfig,
|
||||
hash: "accepted-graph",
|
||||
}),
|
||||
includeFilePaths: [acceptedInclude],
|
||||
});
|
||||
const harness = createReloaderHarness(readSnapshot, {
|
||||
initialIncludeFilePaths: [acceptedInclude],
|
||||
watchers: [createWatcherMock(), createWatcherMock()],
|
||||
});
|
||||
|
||||
harness.watcher.emit("change", rootPath);
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
expect(harness.watchSpy).toHaveBeenCalledTimes(2);
|
||||
|
||||
harness.watchers[1]?.emit("change", acceptedInclude);
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
expect(harness.watchSpy).toHaveBeenCalledTimes(2);
|
||||
expect(harness.onHotReload).toHaveBeenCalledTimes(1);
|
||||
|
||||
await harness.reloader.stop();
|
||||
});
|
||||
|
||||
it("honors in-process write intent to skip reload", async () => {
|
||||
const readSnapshot = vi
|
||||
.fn<() => Promise<ConfigFileSnapshot>>()
|
||||
@@ -1533,6 +2035,40 @@ describe("startGatewayConfigReloader", () => {
|
||||
await harness.reloader.stop();
|
||||
});
|
||||
|
||||
it("skips in-process promotion when includes change under the same root hash", async () => {
|
||||
const oldInclude = nodePath.normalize("/tmp/includes/old.json5");
|
||||
const nextInclude = nodePath.normalize("/tmp/includes/next.json5");
|
||||
const changedByInclude: OpenClawConfig = {
|
||||
gateway: { reload: { debounceMs: 0 } },
|
||||
hooks: { enabled: false },
|
||||
};
|
||||
const readSnapshot = vi.fn().mockResolvedValueOnce({
|
||||
snapshot: makeSnapshot({
|
||||
sourceConfig: changedByInclude,
|
||||
runtimeConfig: changedByInclude,
|
||||
config: changedByInclude,
|
||||
hash: "internal-1",
|
||||
}),
|
||||
includeFilePaths: [nextInclude],
|
||||
});
|
||||
const promoteSnapshot = vi.fn(async () => true);
|
||||
const harness = createReloaderHarness(readSnapshot, {
|
||||
initialIncludeFilePaths: [oldInclude],
|
||||
promoteSnapshot,
|
||||
watchers: [createWatcherMock(), createWatcherMock()],
|
||||
});
|
||||
|
||||
harness.emitWrite(makeZeroDebounceHookWrite("internal-1"));
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(harness.onHotReload).toHaveBeenCalledTimes(1);
|
||||
expect(readSnapshot).toHaveBeenCalledTimes(1);
|
||||
expect(promoteSnapshot).not.toHaveBeenCalled();
|
||||
expect(harness.watchSpy).toHaveBeenCalledTimes(2);
|
||||
|
||||
await harness.reloader.stop();
|
||||
});
|
||||
|
||||
it("dedupes the first watcher reread for startup internal writes", async () => {
|
||||
const readSnapshot = vi
|
||||
.fn<() => Promise<ConfigFileSnapshot>>()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Gateway config hot-reload watcher.
|
||||
// Diffs config/plugin install snapshots and dispatches hot reload or restart plans.
|
||||
import nodePath from "node:path";
|
||||
import chokidar from "chokidar";
|
||||
import type { ConfigWriteNotification } from "../config/io.js";
|
||||
import { formatConfigIssueLines } from "../config/issue-format.js";
|
||||
@@ -102,6 +103,36 @@ type GatewayConfigReloader = {
|
||||
|
||||
type PluginInstallRecords = Record<string, PluginInstallRecord>;
|
||||
|
||||
type ConfigReloadSnapshotReadResult =
|
||||
| ConfigFileSnapshot
|
||||
| {
|
||||
snapshot: ConfigFileSnapshot;
|
||||
includeFilePaths?: readonly string[];
|
||||
};
|
||||
|
||||
function unpackConfigReloadSnapshot(result: ConfigReloadSnapshotReadResult): {
|
||||
snapshot: ConfigFileSnapshot;
|
||||
includeFilePaths?: readonly string[];
|
||||
} {
|
||||
return "snapshot" in result ? result : { snapshot: result };
|
||||
}
|
||||
|
||||
function normalizeIncludeWatcherPaths(
|
||||
rootPath: string,
|
||||
includeFilePaths: readonly string[] = [],
|
||||
): string[] {
|
||||
const normalizedRoot = nodePath.normalize(rootPath);
|
||||
const includes = new Set(
|
||||
includeFilePaths.map((includePath) => nodePath.normalize(includePath)).filter(Boolean),
|
||||
);
|
||||
includes.delete(normalizedRoot);
|
||||
return [...includes].toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function watcherPathsEqual(left: readonly string[], right: readonly string[]): boolean {
|
||||
return left.length === right.length && left.every((entry, index) => entry === right[index]);
|
||||
}
|
||||
|
||||
function asPluginInstallConfig(records: PluginInstallRecords): OpenClawConfig {
|
||||
return {
|
||||
plugins: {
|
||||
@@ -114,7 +145,8 @@ export function startGatewayConfigReloader(opts: {
|
||||
initialConfig: OpenClawConfig;
|
||||
initialCompareConfig?: OpenClawConfig;
|
||||
initialInternalWriteHash?: string | null;
|
||||
readSnapshot: () => Promise<ConfigFileSnapshot>;
|
||||
initialIncludeFilePaths?: readonly string[];
|
||||
readSnapshot: () => Promise<ConfigReloadSnapshotReadResult>;
|
||||
onHotReload: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => Promise<void>;
|
||||
onRestart: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => void | Promise<void>;
|
||||
promoteSnapshot?: (snapshot: ConfigFileSnapshot, reason: string) => Promise<boolean>;
|
||||
@@ -135,6 +167,7 @@ export function startGatewayConfigReloader(opts: {
|
||||
let pending = false;
|
||||
let running = false;
|
||||
let stopped = false;
|
||||
let pendingIncludeReload = false;
|
||||
let restartQueued = false;
|
||||
let missingConfigRetries = 0;
|
||||
let pendingInProcessConfig: {
|
||||
@@ -144,6 +177,7 @@ export function startGatewayConfigReloader(opts: {
|
||||
afterWrite?: ConfigWriteNotification["afterWrite"];
|
||||
} | null = null;
|
||||
let lastAppliedWriteHash = opts.initialInternalWriteHash ?? null;
|
||||
let currentApplyRejected = false;
|
||||
let currentPluginInstallRecords =
|
||||
opts.initialPluginInstallRecords ?? loadInstalledPluginIndexInstallRecordsSync();
|
||||
const readPluginInstallRecords =
|
||||
@@ -256,7 +290,11 @@ export function startGatewayConfigReloader(opts: {
|
||||
currentPluginInstallRecords = nextPluginInstallRecords;
|
||||
settings = resolveGatewayReloadSettings(nextConfig);
|
||||
if (changedPaths.length === 0) {
|
||||
return;
|
||||
if (currentApplyRejected) {
|
||||
opts.log.warn("config reload skipped (previous apply failed; waiting for config change)");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Invalidate cached skills snapshots (persisted in sessions.json) whenever
|
||||
@@ -273,18 +311,21 @@ export function startGatewayConfigReloader(opts: {
|
||||
opts.log.info(`config change detected; evaluating reload (${changedPaths.join(", ")})`);
|
||||
if (followUp.mode === "none") {
|
||||
opts.log.info(`config reload skipped by writer intent (${followUp.reason})`);
|
||||
return;
|
||||
currentApplyRejected = false;
|
||||
return true;
|
||||
}
|
||||
const plan = buildGatewayReloadPlan(changedPaths, {
|
||||
noopPaths: pluginInstallTimestampNoopPaths,
|
||||
forceChangedPaths: pluginInstallWholeRecordPaths,
|
||||
});
|
||||
if (isNoopReloadPlan(plan) && !followUp.requiresRestart) {
|
||||
return;
|
||||
currentApplyRejected = false;
|
||||
return true;
|
||||
}
|
||||
if (settings.mode === "off") {
|
||||
opts.log.info("config reload disabled (gateway.reload.mode=off)");
|
||||
return;
|
||||
currentApplyRejected = false;
|
||||
return true;
|
||||
}
|
||||
if (followUp.requiresRestart) {
|
||||
queueRestart(
|
||||
@@ -295,11 +336,13 @@ export function startGatewayConfigReloader(opts: {
|
||||
},
|
||||
nextConfig,
|
||||
);
|
||||
return;
|
||||
currentApplyRejected = false;
|
||||
return true;
|
||||
}
|
||||
if (settings.mode === "restart") {
|
||||
queueRestart(plan, nextConfig);
|
||||
return;
|
||||
currentApplyRejected = false;
|
||||
return true;
|
||||
}
|
||||
if (plan.restartGateway) {
|
||||
if (settings.mode === "hot") {
|
||||
@@ -308,13 +351,23 @@ export function startGatewayConfigReloader(opts: {
|
||||
", ",
|
||||
)})`,
|
||||
);
|
||||
return;
|
||||
currentApplyRejected = false;
|
||||
return true;
|
||||
}
|
||||
queueRestart(plan, nextConfig);
|
||||
return;
|
||||
currentApplyRejected = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
await opts.onHotReload(plan, nextConfig);
|
||||
try {
|
||||
await opts.onHotReload(plan, nextConfig);
|
||||
currentApplyRejected = false;
|
||||
return true;
|
||||
} catch (err) {
|
||||
currentApplyRejected = true;
|
||||
opts.log.error(`config reload failed: ${String(err)}`);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const promoteAcceptedSnapshot = async (snapshot: ConfigFileSnapshot, reason: string) => {
|
||||
@@ -328,15 +381,26 @@ export function startGatewayConfigReloader(opts: {
|
||||
}
|
||||
};
|
||||
|
||||
const promoteAcceptedInProcessWrite = async (persistedHash: string) => {
|
||||
const promoteAcceptedInProcessWrite = async (
|
||||
persistedHash: string,
|
||||
acceptedCompareConfig: OpenClawConfig,
|
||||
) => {
|
||||
if (!opts.promoteSnapshot) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const snapshot = await opts.readSnapshot();
|
||||
if (snapshot.hash !== persistedHash || !snapshot.valid) {
|
||||
const snapshotRead = unpackConfigReloadSnapshot(await opts.readSnapshot());
|
||||
const snapshot = snapshotRead.snapshot;
|
||||
if (
|
||||
snapshot.hash !== persistedHash ||
|
||||
!snapshot.valid ||
|
||||
diffConfigPaths(acceptedCompareConfig, snapshot.sourceConfig).length > 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (snapshotRead.includeFilePaths) {
|
||||
replaceWatchedPaths(snapshotRead.includeFilePaths);
|
||||
}
|
||||
await promoteAcceptedSnapshot(snapshot, "in-process-write");
|
||||
} catch (err) {
|
||||
opts.log.warn(`config reload in-process last-known-good promotion failed: ${String(err)}`);
|
||||
@@ -361,20 +425,31 @@ export function startGatewayConfigReloader(opts: {
|
||||
const pendingWrite = pendingInProcessConfig;
|
||||
pendingInProcessConfig = null;
|
||||
missingConfigRetries = 0;
|
||||
await applySnapshot(
|
||||
const applied = await applySnapshot(
|
||||
pendingWrite.config,
|
||||
pendingWrite.compareConfig,
|
||||
pendingWrite.afterWrite,
|
||||
);
|
||||
await promoteAcceptedInProcessWrite(pendingWrite.persistedHash);
|
||||
return;
|
||||
}
|
||||
const snapshot = await opts.readSnapshot();
|
||||
if (lastAppliedWriteHash && typeof snapshot.hash === "string") {
|
||||
if (snapshot.hash === lastAppliedWriteHash) {
|
||||
if (!applied) {
|
||||
if (lastAppliedWriteHash === pendingWrite.persistedHash) {
|
||||
lastAppliedWriteHash = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
lastAppliedWriteHash = null;
|
||||
await promoteAcceptedInProcessWrite(pendingWrite.persistedHash, pendingWrite.compareConfig);
|
||||
return;
|
||||
}
|
||||
const bypassRootWriteHashDedupe = pendingIncludeReload;
|
||||
pendingIncludeReload = false;
|
||||
const snapshotRead = unpackConfigReloadSnapshot(await opts.readSnapshot());
|
||||
const snapshot = snapshotRead.snapshot;
|
||||
if (lastAppliedWriteHash && typeof snapshot.hash === "string") {
|
||||
if (!bypassRootWriteHashDedupe && snapshot.hash === lastAppliedWriteHash) {
|
||||
return;
|
||||
}
|
||||
if (snapshot.hash !== lastAppliedWriteHash) {
|
||||
lastAppliedWriteHash = null;
|
||||
}
|
||||
}
|
||||
if (handleMissingSnapshot(snapshot)) {
|
||||
return;
|
||||
@@ -383,7 +458,13 @@ export function startGatewayConfigReloader(opts: {
|
||||
handleInvalidSnapshot(snapshot);
|
||||
return;
|
||||
}
|
||||
await applySnapshot(snapshot.config, snapshot.sourceConfig);
|
||||
const applied = await applySnapshot(snapshot.config, snapshot.sourceConfig);
|
||||
if (!applied) {
|
||||
return;
|
||||
}
|
||||
if (snapshotRead.includeFilePaths) {
|
||||
replaceWatchedPaths(snapshotRead.includeFilePaths);
|
||||
}
|
||||
await promoteAcceptedSnapshot(snapshot, "valid-config");
|
||||
} catch (err) {
|
||||
opts.log.error(`config reload failed: ${String(err)}`);
|
||||
@@ -392,11 +473,20 @@ export function startGatewayConfigReloader(opts: {
|
||||
if (pending) {
|
||||
pending = false;
|
||||
schedule();
|
||||
} else if (pendingIncludeReload) {
|
||||
scheduleAfter(0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleFromWatcher = () => {
|
||||
const normalizedRootWatchPath = nodePath.normalize(opts.watchPath);
|
||||
const scheduleFromWatcher = (changedPath?: string) => {
|
||||
if (
|
||||
typeof changedPath === "string" &&
|
||||
nodePath.normalize(changedPath) !== normalizedRootWatchPath
|
||||
) {
|
||||
pendingIncludeReload = true;
|
||||
}
|
||||
schedule();
|
||||
};
|
||||
|
||||
@@ -415,35 +505,254 @@ export function startGatewayConfigReloader(opts: {
|
||||
scheduleAfter(0);
|
||||
}) ?? (() => {});
|
||||
|
||||
let watcher: ReturnType<typeof chokidar.watch> | null = null;
|
||||
type ConfigWatcher = ReturnType<typeof chokidar.watch>;
|
||||
type IncludeWatcherGroup = {
|
||||
paths: string[];
|
||||
watchers: ConfigWatcher[];
|
||||
ready: Set<ConfigWatcher>;
|
||||
usePolling: boolean;
|
||||
};
|
||||
const emptyIncludeGroup = (paths: string[] = []): IncludeWatcherGroup => ({
|
||||
paths,
|
||||
watchers: [],
|
||||
ready: new Set(),
|
||||
usePolling: false,
|
||||
});
|
||||
|
||||
let watcher: ConfigWatcher | null = null;
|
||||
let watcherRecreateRetries = 0;
|
||||
let watcherRecreateTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let hotReloadStatus: GatewayHotReloadStatus = "active";
|
||||
let rootHotReloadDisabled = false;
|
||||
let degradedToPolling = false;
|
||||
let watcherUsesPolling = false;
|
||||
|
||||
const initialIncludePaths = normalizeIncludeWatcherPaths(
|
||||
opts.watchPath,
|
||||
opts.initialIncludeFilePaths,
|
||||
);
|
||||
let activeIncludeGroup = emptyIncludeGroup(initialIncludePaths);
|
||||
let pendingIncludeGroup: IncludeWatcherGroup | null = null;
|
||||
let desiredIncludePaths = initialIncludePaths;
|
||||
let includeGeneration = 0;
|
||||
let includeReplacementRetries = 0;
|
||||
let includeReplacementTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let includeHotReloadDisabled = false;
|
||||
let includeDegradedToPolling = false;
|
||||
|
||||
const closeWatcher = (target: ConfigWatcher | null) => {
|
||||
void target?.close().catch(() => {});
|
||||
};
|
||||
|
||||
const closeIncludeGroup = (group: IncludeWatcherGroup | null) => {
|
||||
for (const target of group?.watchers ?? []) {
|
||||
closeWatcher(target);
|
||||
}
|
||||
};
|
||||
|
||||
const createWatcherInstance = (watchPath: string, usePolling: boolean): ConfigWatcher =>
|
||||
chokidar.watch(watchPath, {
|
||||
ignoreInitial: true,
|
||||
awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 },
|
||||
usePolling,
|
||||
});
|
||||
|
||||
const activateIncludeGroup = (group: IncludeWatcherGroup) => {
|
||||
if (stopped || group !== pendingIncludeGroup) {
|
||||
return;
|
||||
}
|
||||
const previous = activeIncludeGroup;
|
||||
activeIncludeGroup = group;
|
||||
pendingIncludeGroup = null;
|
||||
includeReplacementRetries = 0;
|
||||
includeHotReloadDisabled = false;
|
||||
closeIncludeGroup(previous);
|
||||
|
||||
// Re-read once after the handoff so edits during candidate startup are
|
||||
// reconciled without opening a gap between the old and new exact sets.
|
||||
pendingIncludeReload = true;
|
||||
schedule();
|
||||
};
|
||||
|
||||
const scheduleIncludeReplacementRetry = (
|
||||
generation: number,
|
||||
failedWithPolling: boolean,
|
||||
err: unknown,
|
||||
) => {
|
||||
if (stopped || generation !== includeGeneration) {
|
||||
return;
|
||||
}
|
||||
if (includeReplacementRetries >= WATCHER_RECREATE_MAX_RETRIES) {
|
||||
if (!failedWithPolling && resolveChokidarUsePolling(true)) {
|
||||
includeDegradedToPolling = true;
|
||||
includeReplacementRetries = 0;
|
||||
opts.log.warn(
|
||||
`config include watcher native retries exhausted; degrading to polling mode: ${String(err)}`,
|
||||
);
|
||||
includeReplacementTimer = setTimeout(() => {
|
||||
includeReplacementTimer = null;
|
||||
stageIncludeReplacement(generation);
|
||||
}, WATCHER_RECREATE_BACKOFF_MS[0] ?? 500);
|
||||
return;
|
||||
}
|
||||
const mode = failedWithPolling ? "polling mode" : "native mode";
|
||||
includeHotReloadDisabled = true;
|
||||
opts.log.error(
|
||||
`config include hot-reload disabled: watcher failed after ${WATCHER_RECREATE_MAX_RETRIES} re-create attempts in ${mode}; keeping prior paths: ${String(err)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const backoff =
|
||||
WATCHER_RECREATE_BACKOFF_MS[includeReplacementRetries] ??
|
||||
WATCHER_RECREATE_BACKOFF_MS[WATCHER_RECREATE_BACKOFF_MS.length - 1] ??
|
||||
0;
|
||||
includeReplacementRetries += 1;
|
||||
opts.log.warn(
|
||||
`config include watcher error; retrying replacement (attempt ${includeReplacementRetries}/${WATCHER_RECREATE_MAX_RETRIES} in ${backoff}ms): ${String(err)}`,
|
||||
);
|
||||
includeReplacementTimer = setTimeout(() => {
|
||||
includeReplacementTimer = null;
|
||||
stageIncludeReplacement(generation);
|
||||
}, backoff);
|
||||
};
|
||||
|
||||
const createIncludeGroup = (paths: string[], generation: number): IncludeWatcherGroup => {
|
||||
const usePolling = resolveChokidarUsePolling(includeDegradedToPolling);
|
||||
const group: IncludeWatcherGroup = {
|
||||
paths,
|
||||
watchers: [],
|
||||
ready: new Set(),
|
||||
usePolling: false,
|
||||
};
|
||||
try {
|
||||
for (const includePath of paths) {
|
||||
const next = createWatcherInstance(includePath, usePolling);
|
||||
group.watchers.push(next);
|
||||
group.usePolling ||= Boolean(next.options.usePolling);
|
||||
const scheduleIfActive = (changedPath: string) => {
|
||||
if (group === activeIncludeGroup) {
|
||||
scheduleFromWatcher(changedPath);
|
||||
}
|
||||
};
|
||||
next.on("add", scheduleIfActive);
|
||||
next.on("change", scheduleIfActive);
|
||||
next.on("unlink", scheduleIfActive);
|
||||
next.on("ready", () => {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
group.ready.add(next);
|
||||
if (group.ready.size !== group.watchers.length) {
|
||||
return;
|
||||
}
|
||||
if (group === pendingIncludeGroup) {
|
||||
if (generation !== includeGeneration) {
|
||||
return;
|
||||
}
|
||||
activateIncludeGroup(group);
|
||||
} else if (group === activeIncludeGroup) {
|
||||
pendingIncludeReload = true;
|
||||
schedule();
|
||||
}
|
||||
});
|
||||
next.on("error", (err) => {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
if (group === pendingIncludeGroup) {
|
||||
if (generation !== includeGeneration) {
|
||||
return;
|
||||
}
|
||||
pendingIncludeGroup = null;
|
||||
closeIncludeGroup(group);
|
||||
scheduleIncludeReplacementRetry(generation, group.usePolling, err);
|
||||
return;
|
||||
}
|
||||
if (group === activeIncludeGroup) {
|
||||
activeIncludeGroup = emptyIncludeGroup();
|
||||
closeIncludeGroup(group);
|
||||
if (!pendingIncludeGroup && !includeReplacementTimer) {
|
||||
scheduleIncludeReplacementRetry(includeGeneration, group.usePolling, err);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
return group;
|
||||
} catch (err) {
|
||||
closeIncludeGroup(group);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
function stageIncludeReplacement(generation: number) {
|
||||
if (
|
||||
stopped ||
|
||||
generation !== includeGeneration ||
|
||||
pendingIncludeGroup ||
|
||||
watcherPathsEqual(desiredIncludePaths, activeIncludeGroup.paths)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (desiredIncludePaths.length === 0) {
|
||||
pendingIncludeGroup = emptyIncludeGroup();
|
||||
activateIncludeGroup(pendingIncludeGroup);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
pendingIncludeGroup = createIncludeGroup([...desiredIncludePaths], generation);
|
||||
} catch (err) {
|
||||
scheduleIncludeReplacementRetry(
|
||||
generation,
|
||||
resolveChokidarUsePolling(includeDegradedToPolling),
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const replaceWatchedPaths = (includeFilePaths: readonly string[]) => {
|
||||
const nextPaths = normalizeIncludeWatcherPaths(opts.watchPath, includeFilePaths);
|
||||
if (watcherPathsEqual(nextPaths, desiredIncludePaths)) {
|
||||
return;
|
||||
}
|
||||
includeGeneration += 1;
|
||||
desiredIncludePaths = nextPaths;
|
||||
includeReplacementRetries = 0;
|
||||
if (includeReplacementTimer) {
|
||||
clearTimeout(includeReplacementTimer);
|
||||
includeReplacementTimer = null;
|
||||
}
|
||||
const stagedGroup = pendingIncludeGroup;
|
||||
pendingIncludeGroup = null;
|
||||
closeIncludeGroup(stagedGroup);
|
||||
if (watcherPathsEqual(nextPaths, activeIncludeGroup.paths)) {
|
||||
includeHotReloadDisabled = false;
|
||||
return;
|
||||
}
|
||||
stageIncludeReplacement(includeGeneration);
|
||||
};
|
||||
|
||||
const createWatcher = () => {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
const usePolling = resolveChokidarUsePolling(degradedToPolling);
|
||||
const next = chokidar.watch(opts.watchPath, {
|
||||
ignoreInitial: true,
|
||||
awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 },
|
||||
usePolling,
|
||||
});
|
||||
next.on("add", scheduleFromWatcher);
|
||||
next.on("change", scheduleFromWatcher);
|
||||
next.on("unlink", scheduleFromWatcher);
|
||||
next.on("error", (err) => {
|
||||
handleWatcherError(next, err);
|
||||
});
|
||||
const next = createWatcherInstance(
|
||||
opts.watchPath,
|
||||
resolveChokidarUsePolling(degradedToPolling),
|
||||
);
|
||||
watcher = next;
|
||||
watcherUsesPolling = next.options.usePolling;
|
||||
hotReloadStatus = "active";
|
||||
watcherUsesPolling = Boolean(next.options.usePolling);
|
||||
rootHotReloadDisabled = false;
|
||||
const scheduleIfActive = (changedPath: string) => {
|
||||
if (next === watcher) {
|
||||
scheduleFromWatcher(changedPath);
|
||||
}
|
||||
};
|
||||
next.on("add", scheduleIfActive);
|
||||
next.on("change", scheduleIfActive);
|
||||
next.on("unlink", scheduleIfActive);
|
||||
next.on("error", (err) => handleWatcherError(next, err));
|
||||
};
|
||||
|
||||
const handleWatcherError = (source: typeof watcher, err: unknown) => {
|
||||
const handleWatcherError = (source: ConfigWatcher, err: unknown) => {
|
||||
// Ignore stale errors from a watcher we already replaced or stopped.
|
||||
if (stopped || source !== watcher) {
|
||||
return;
|
||||
@@ -451,7 +760,7 @@ export function startGatewayConfigReloader(opts: {
|
||||
const failedWatcherUsedPolling = watcherUsesPolling;
|
||||
watcher = null;
|
||||
watcherUsesPolling = false;
|
||||
void source?.close().catch(() => {});
|
||||
closeWatcher(source);
|
||||
if (watcherRecreateRetries >= WATCHER_RECREATE_MAX_RETRIES) {
|
||||
// All native (inotify/kqueue) retries exhausted — fall back to polling
|
||||
// mode so config hot-reload survives on hosts where inotify resources
|
||||
@@ -469,7 +778,7 @@ export function startGatewayConfigReloader(opts: {
|
||||
return;
|
||||
}
|
||||
const mode = failedWatcherUsedPolling ? "polling mode" : "native mode";
|
||||
hotReloadStatus = "disabled";
|
||||
rootHotReloadDisabled = true;
|
||||
opts.log.error(
|
||||
`config hot-reload disabled: watcher failed after ${WATCHER_RECREATE_MAX_RETRIES} re-create attempts in ${mode}: ${String(err)}`,
|
||||
);
|
||||
@@ -490,6 +799,18 @@ export function startGatewayConfigReloader(opts: {
|
||||
};
|
||||
|
||||
createWatcher();
|
||||
if (initialIncludePaths.length > 0) {
|
||||
try {
|
||||
activeIncludeGroup = createIncludeGroup(initialIncludePaths, includeGeneration);
|
||||
} catch (err) {
|
||||
activeIncludeGroup = emptyIncludeGroup();
|
||||
scheduleIncludeReplacementRetry(
|
||||
includeGeneration,
|
||||
resolveChokidarUsePolling(includeDegradedToPolling),
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
stop: async () => {
|
||||
@@ -502,11 +823,26 @@ export function startGatewayConfigReloader(opts: {
|
||||
clearTimeout(watcherRecreateTimer);
|
||||
watcherRecreateTimer = null;
|
||||
}
|
||||
if (includeReplacementTimer) {
|
||||
clearTimeout(includeReplacementTimer);
|
||||
includeReplacementTimer = null;
|
||||
}
|
||||
unsubscribeFromWrites();
|
||||
const active = watcher;
|
||||
const rootWatcher = watcher;
|
||||
const activeIncludes = activeIncludeGroup;
|
||||
const stagedIncludes = pendingIncludeGroup;
|
||||
watcher = null;
|
||||
await active?.close().catch(() => {});
|
||||
activeIncludeGroup = emptyIncludeGroup();
|
||||
pendingIncludeGroup = null;
|
||||
await Promise.all(
|
||||
[
|
||||
...(rootWatcher ? [rootWatcher] : []),
|
||||
...activeIncludes.watchers,
|
||||
...(stagedIncludes?.watchers ?? []),
|
||||
].map(async (target) => await target.close().catch(() => {})),
|
||||
);
|
||||
},
|
||||
hotReloadStatus: () => hotReloadStatus,
|
||||
hotReloadStatus: () =>
|
||||
rootHotReloadDisabled || includeHotReloadDisabled ? "disabled" : "active",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -47,16 +47,6 @@ type BeforeToolCallHookInput = {
|
||||
agentId?: string;
|
||||
config?: unknown;
|
||||
sessionKey?: string;
|
||||
sessionId?: string;
|
||||
messageProvider?: string;
|
||||
channel?: string;
|
||||
channelId?: string;
|
||||
chatId?: string;
|
||||
senderId?: string;
|
||||
channelContext?: {
|
||||
sender?: { id?: string };
|
||||
chat?: { id?: string };
|
||||
};
|
||||
};
|
||||
signal?: unknown;
|
||||
};
|
||||
@@ -1449,37 +1439,6 @@ describe("mcp loopback server", () => {
|
||||
expectMcpResultText(payload, "blocked by hook", true);
|
||||
});
|
||||
|
||||
it("passes canonical prefixed channel origin into loopback before-tool hooks", async () => {
|
||||
const execute = vi.fn<MockGatewayTool["execute"]>(async () => ({
|
||||
content: [{ type: "text", text: "EXECUTED" }],
|
||||
}));
|
||||
mockScopedTools([makeMessageTool({ execute })]);
|
||||
const { runtime } = await startLoopbackServerForTest();
|
||||
|
||||
const response = await sendLoopbackToolCall({
|
||||
token: runtime.ownerToken,
|
||||
name: "message",
|
||||
args: { body: "hello" },
|
||||
headers: {
|
||||
"x-session-key": "agent:main:main",
|
||||
"x-openclaw-session-id": "session-origin-1",
|
||||
"x-openclaw-message-channel": "telegram",
|
||||
"x-openclaw-current-channel-id": "telegram:chat123",
|
||||
},
|
||||
});
|
||||
|
||||
expectMcpResultText(await readOkMcpPayload(response), "EXECUTED", false);
|
||||
expect(getBeforeToolCallHookInput(0).ctx).toEqual({
|
||||
agentId: "main",
|
||||
config: { session: { mainKey: "main" } },
|
||||
sessionKey: "agent:main:main",
|
||||
sessionId: "session-origin-1",
|
||||
messageProvider: "telegram",
|
||||
channel: "telegram",
|
||||
channelId: "chat123",
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards the request abort signal to loopback tool execution", async () => {
|
||||
const execute = vi.fn<MockGatewayTool["execute"]>(async () => ({
|
||||
content: [{ type: "text", text: "EXECUTED" }],
|
||||
|
||||
@@ -11,7 +11,6 @@ import { getRuntimeConfig } from "../config/io.js";
|
||||
import { isTruthyEnvValue } from "../infra/env.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { logDebug, logWarn } from "../logger.js";
|
||||
import { buildAgentHookContextOriginFields } from "../plugins/hook-agent-context.js";
|
||||
import { handleMcpJsonRpc } from "./mcp-http.handlers.js";
|
||||
import {
|
||||
clearActiveMcpLoopbackRuntimeByOwnerToken,
|
||||
@@ -274,12 +273,6 @@ export async function startMcpLoopbackServer(port = 0): Promise<{
|
||||
agentId: scopedTools.agentId,
|
||||
config: cfg,
|
||||
sessionKey: requestContext.sessionKey,
|
||||
...(requestContext.sessionId ? { sessionId: requestContext.sessionId } : {}),
|
||||
...buildAgentHookContextOriginFields({
|
||||
sessionKey: requestContext.sessionKey,
|
||||
messageProvider: requestContext.messageProvider,
|
||||
currentChannelId: requestContext.currentChannelId,
|
||||
}),
|
||||
},
|
||||
signal: requestAbort.signal,
|
||||
onToolCallPrepared: cliCaptureHandle
|
||||
|
||||
@@ -193,8 +193,9 @@ type ManagedGatewayConfigReloaderParams = Omit<
|
||||
initialConfig: OpenClawConfig;
|
||||
initialCompareConfig?: OpenClawConfig;
|
||||
initialInternalWriteHash: string | null;
|
||||
initialIncludeFilePaths?: readonly string[];
|
||||
watchPath: string;
|
||||
readSnapshot: typeof import("../config/config.js").readConfigFileSnapshot;
|
||||
readSnapshot: typeof import("../config/config.js").readConfigFileSnapshotWithPluginMetadata;
|
||||
promoteSnapshot: typeof import("../config/config.js").promoteConfigSnapshotToLastKnownGood;
|
||||
subscribeToWrites: typeof import("../config/config.js").registerConfigWriteListener;
|
||||
logReload: GatewayReloadLog & {
|
||||
@@ -681,6 +682,7 @@ export function startManagedGatewayConfigReloader(params: ManagedGatewayConfigRe
|
||||
initialConfig: params.initialConfig,
|
||||
initialCompareConfig: params.initialCompareConfig,
|
||||
initialInternalWriteHash: params.initialInternalWriteHash,
|
||||
initialIncludeFilePaths: params.initialIncludeFilePaths,
|
||||
readSnapshot: params.readSnapshot,
|
||||
promoteSnapshot: async (snapshot, _reason) => await params.promoteSnapshot(snapshot),
|
||||
subscribeToWrites: params.subscribeToWrites,
|
||||
|
||||
@@ -99,6 +99,7 @@ function secretsPrepareTimelineAttributes(
|
||||
export type GatewayStartupConfigSnapshotLoadResult = {
|
||||
snapshot: ConfigFileSnapshot;
|
||||
wroteConfig: boolean;
|
||||
includeFilePaths?: readonly string[];
|
||||
pluginMetadataSnapshot?: PluginMetadataSnapshot;
|
||||
};
|
||||
|
||||
@@ -143,6 +144,7 @@ export async function loadGatewayStartupConfigSnapshot(params: {
|
||||
return {
|
||||
snapshot: configSnapshot,
|
||||
wroteConfig,
|
||||
...(snapshotRead.includeFilePaths ? { includeFilePaths: snapshotRead.includeFilePaths } : {}),
|
||||
...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}),
|
||||
};
|
||||
}
|
||||
@@ -153,6 +155,7 @@ export async function loadGatewayStartupConfigSnapshot(params: {
|
||||
return {
|
||||
snapshot: withRuntimeConfig(configSnapshot, autoEnable.config),
|
||||
wroteConfig,
|
||||
...(snapshotRead.includeFilePaths ? { includeFilePaths: snapshotRead.includeFilePaths } : {}),
|
||||
...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import { isRestartEnabled } from "../config/commands.flags.js";
|
||||
import {
|
||||
getRuntimeConfig,
|
||||
promoteConfigSnapshotToLastKnownGood,
|
||||
readConfigFileSnapshot,
|
||||
readConfigFileSnapshotWithPluginMetadata,
|
||||
registerConfigWriteListener,
|
||||
setRuntimeConfigSnapshot,
|
||||
type ReadConfigFileSnapshotWithPluginMetadataResult,
|
||||
@@ -638,6 +638,7 @@ export async function startGatewayServer(
|
||||
let cfgAtStart: OpenClawConfig;
|
||||
let startupInternalWriteHash: string | null = null;
|
||||
let startupLastGoodSnapshot = configSnapshot;
|
||||
let startupIncludeFilePaths = startupConfigLoad.includeFilePaths;
|
||||
const startupActivationSourceConfig = configSnapshot.sourceConfig;
|
||||
const startupRuntimeConfig = applyConfigOverrides(configSnapshot.config);
|
||||
startupTrace.setConfig(startupRuntimeConfig);
|
||||
@@ -694,11 +695,15 @@ export async function startGatewayServer(
|
||||
// Keep the old startup-write suppression path intact for compatibility with
|
||||
// callers that may still report a write, but startup itself no longer mutates config.
|
||||
if (startupConfigLoad.wroteConfig || authBootstrap.persistedGeneratedToken) {
|
||||
const startupSnapshot = await startupTrace.measure("config.final-snapshot", () =>
|
||||
readConfigFileSnapshot(),
|
||||
const startupSnapshotRead = await startupTrace.measure("config.final-snapshot", () =>
|
||||
readConfigFileSnapshotWithPluginMetadata(),
|
||||
);
|
||||
const startupSnapshot = startupSnapshotRead.snapshot;
|
||||
startupInternalWriteHash = startupSnapshot.hash ?? null;
|
||||
startupLastGoodSnapshot = startupSnapshot;
|
||||
if (startupSnapshotRead.includeFilePaths) {
|
||||
startupIncludeFilePaths = startupSnapshotRead.includeFilePaths;
|
||||
}
|
||||
}
|
||||
setRuntimeConfigSnapshot(cfgAtStart, startupLastGoodSnapshot.sourceConfig);
|
||||
const { prepareGatewayPluginBootstrap } = await loadStartupPluginsModule();
|
||||
@@ -1727,8 +1732,9 @@ export async function startGatewayServer(
|
||||
initialConfig: cfgAtStart,
|
||||
initialCompareConfig: startupLastGoodSnapshot.sourceConfig,
|
||||
initialInternalWriteHash: startupInternalWriteHash,
|
||||
initialIncludeFilePaths: startupIncludeFilePaths,
|
||||
watchPath: configSnapshot.path,
|
||||
readSnapshot: readConfigFileSnapshot,
|
||||
readSnapshot: readConfigFileSnapshotWithPluginMetadata,
|
||||
promoteSnapshot: promoteConfigSnapshotToLastKnownGood,
|
||||
subscribeToWrites: registerConfigWriteListener,
|
||||
deps,
|
||||
|
||||
@@ -684,7 +684,6 @@ describe("POST /tools/invoke", () => {
|
||||
port: sharedPort,
|
||||
headers: {
|
||||
...gatewayAuthHeaders(),
|
||||
"x-openclaw-message-channel": "telegram",
|
||||
"x-openclaw-message-to": "channel:24514",
|
||||
"x-openclaw-thread-id": "thread-24514",
|
||||
},
|
||||
@@ -697,14 +696,6 @@ describe("POST /tools/invoke", () => {
|
||||
agentTo: "channel:24514",
|
||||
agentThreadId: "thread-24514",
|
||||
});
|
||||
expect(firstHookCallArg().ctx).toMatchObject({
|
||||
messageProvider: "telegram",
|
||||
channel: "telegram",
|
||||
channelId: "24514",
|
||||
});
|
||||
expect(firstHookCallArg().ctx?.senderId).toBeUndefined();
|
||||
expect(firstHookCallArg().ctx?.chatId).toBeUndefined();
|
||||
expect(firstHookCallArg().ctx?.channelContext).toBeUndefined();
|
||||
});
|
||||
|
||||
it("propagates owner-only HTTP denies into spawned session inheritance", async () => {
|
||||
|
||||
@@ -13,7 +13,6 @@ import { resolveMainSessionKey } from "../config/sessions.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { logWarn } from "../logger.js";
|
||||
import { isTestDefaultMemorySlotDisabled } from "../plugins/config-state.js";
|
||||
import { buildAgentHookContextOriginFields } from "../plugins/hook-agent-context.js";
|
||||
import { defaultSlotIdForKey } from "../plugins/slots.js";
|
||||
import { getPluginToolMeta } from "../plugins/tools.js";
|
||||
import { canonicalizeSessionKeyForAgent } from "./session-store-key.js";
|
||||
@@ -258,12 +257,6 @@ export async function invokeGatewayTool(params: {
|
||||
agentId,
|
||||
config: params.cfg,
|
||||
sessionKey,
|
||||
...buildAgentHookContextOriginFields({
|
||||
sessionKey,
|
||||
messageChannel: params.messageChannel,
|
||||
messageProvider: params.messageChannel,
|
||||
messageTo: params.agentTo,
|
||||
}),
|
||||
loopDetection: resolveToolLoopDetectionConfig({ cfg: params.cfg, agentId }),
|
||||
},
|
||||
approvalMode: params.approvalMode,
|
||||
|
||||
@@ -108,16 +108,11 @@ export type {
|
||||
NativeHookRelayProvider,
|
||||
NativeHookRelayRegistrationHandle,
|
||||
} from "../agents/harness/native-hook-relay.js";
|
||||
export type { ToolHookRunContext } from "../agents/agent-tools.before-tool-call.js";
|
||||
|
||||
export { VERSION as OPENCLAW_VERSION } from "../version.js";
|
||||
export { formatErrorMessage } from "../infra/errors.js";
|
||||
export { formatApprovalDisplayPath } from "../infra/approval-display-paths.js";
|
||||
export {
|
||||
buildAgentHookContextChannelFields,
|
||||
buildAgentHookContextOriginFields,
|
||||
} from "../plugins/hook-agent-context.js";
|
||||
export { resolveToolLoopDetectionConfig } from "../agents/tool-loop-detection-config.js";
|
||||
export { buildAgentHookContextChannelFields } from "../plugins/hook-agent-context.js";
|
||||
export { emitAgentEvent, onAgentEvent, resetAgentEventsForTest } from "../infra/agent-events.js";
|
||||
export { runAgentCleanupStep } from "../agents/run-cleanup-timeout.js";
|
||||
export { log as embeddedAgentLog } from "../agents/embedded-agent-runner/logger.js";
|
||||
|
||||
@@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildAgentHookContextChannelFields,
|
||||
buildAgentHookContextIdentityFields,
|
||||
buildAgentHookContextOriginFields,
|
||||
resolveAgentHookChannelId,
|
||||
} from "./hook-agent-context.js";
|
||||
|
||||
@@ -136,63 +135,3 @@ describe("buildAgentHookContextIdentityFields", () => {
|
||||
).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAgentHookContextOriginFields", () => {
|
||||
it("infers a canonical channel while preserving native chat identity", () => {
|
||||
expect(
|
||||
buildAgentHookContextOriginFields({
|
||||
sessionKey: "agent:main:main",
|
||||
messageProvider: "discord-voice",
|
||||
currentChannelId: "discord:1472750640760623226",
|
||||
trigger: "user",
|
||||
channelContext: {
|
||||
sender: { id: "user-123" },
|
||||
chat: { id: "native-chat-1" },
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
channel: "discord",
|
||||
messageProvider: "discord-voice",
|
||||
channelId: "1472750640760623226",
|
||||
chatId: "native-chat-1",
|
||||
senderId: "user-123",
|
||||
channelContext: {
|
||||
sender: { id: "user-123" },
|
||||
chat: { id: "native-chat-1" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not infer native chat identity from a routing target", () => {
|
||||
expect(
|
||||
buildAgentHookContextOriginFields({
|
||||
messageChannel: "telegram",
|
||||
messageProvider: "telegram",
|
||||
currentChannelId: "telegram:-100123:topic:7",
|
||||
trigger: "user",
|
||||
}),
|
||||
).toEqual({
|
||||
channel: "telegram",
|
||||
messageProvider: "telegram",
|
||||
channelId: "-100123:topic:7",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps routing fields but omits requester identity for system triggers", () => {
|
||||
expect(
|
||||
buildAgentHookContextOriginFields({
|
||||
messageChannel: "telegram",
|
||||
messageProvider: "telegram",
|
||||
currentChannelId: "telegram:-100123",
|
||||
trigger: "cron",
|
||||
senderId: "stale-user",
|
||||
chatId: "stale-chat",
|
||||
channelContext: { sender: { id: "stale-user" }, chat: { id: "stale-chat" } },
|
||||
}),
|
||||
).toEqual({
|
||||
channel: "telegram",
|
||||
messageProvider: "telegram",
|
||||
channelId: "-100123",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,48 +38,18 @@ function stripConversationPrefix(
|
||||
return text;
|
||||
}
|
||||
|
||||
function inferNarrowProviderChannel(params: {
|
||||
messageProvider?: string | null;
|
||||
currentChannelId?: string | null;
|
||||
messageTo?: string | null;
|
||||
}): string | undefined {
|
||||
const providerKey = normalizeKey(normalizeOptionalString(params.messageProvider));
|
||||
if (!providerKey) {
|
||||
return undefined;
|
||||
}
|
||||
for (const value of [params.currentChannelId, params.messageTo]) {
|
||||
const text = normalizeOptionalString(value);
|
||||
const separatorIndex = text?.indexOf(":") ?? -1;
|
||||
if (!text || separatorIndex <= 0) {
|
||||
continue;
|
||||
}
|
||||
const prefix = normalizeOptionalString(text.slice(0, separatorIndex));
|
||||
const prefixKey = normalizeKey(prefix);
|
||||
if (prefix && providerKey.startsWith(`${prefixKey}-`)) {
|
||||
return prefix;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveAgentHookChannel(params: {
|
||||
messageChannel?: string | null;
|
||||
messageProvider?: string | null;
|
||||
currentChannelId?: string | null;
|
||||
messageTo?: string | null;
|
||||
}): string | undefined {
|
||||
const messageChannel = normalizeOptionalString(params.messageChannel);
|
||||
const provider = normalizeOptionalString(params.messageProvider);
|
||||
const inferredProviderChannel = inferNarrowProviderChannel(params);
|
||||
if (!messageChannel) {
|
||||
return inferredProviderChannel ?? provider;
|
||||
return provider;
|
||||
}
|
||||
|
||||
const separatorIndex = messageChannel.indexOf(":");
|
||||
if (separatorIndex === -1) {
|
||||
if (inferredProviderChannel && normalizeKey(messageChannel) === normalizeKey(provider)) {
|
||||
return inferredProviderChannel;
|
||||
}
|
||||
return messageChannel;
|
||||
}
|
||||
|
||||
@@ -91,7 +61,7 @@ function resolveAgentHookChannel(params: {
|
||||
TARGET_PREFIXES.has(normalizeKey(prefix)) ||
|
||||
normalizeKey(prefix) === normalizeKey(provider)
|
||||
) {
|
||||
return inferredProviderChannel ?? provider;
|
||||
return provider;
|
||||
}
|
||||
return prefix;
|
||||
}
|
||||
@@ -106,19 +76,14 @@ export function resolveAgentHookChannelId(params: {
|
||||
}): string | undefined {
|
||||
const provider = normalizeOptionalString(params.messageProvider);
|
||||
const messageChannel = normalizeOptionalString(params.messageChannel);
|
||||
const channel = resolveAgentHookChannel(params);
|
||||
const parsed = parseRawSessionConversationRef(params.sessionKey);
|
||||
if (parsed?.rawId) {
|
||||
return parsed.rawId;
|
||||
}
|
||||
|
||||
const metadataChannel =
|
||||
stripConversationPrefix(
|
||||
params.currentChannelId ?? undefined,
|
||||
provider,
|
||||
messageChannel,
|
||||
channel,
|
||||
) ?? stripConversationPrefix(params.messageTo ?? undefined, provider, messageChannel, channel);
|
||||
stripConversationPrefix(params.currentChannelId ?? undefined, provider, messageChannel) ??
|
||||
stripConversationPrefix(params.messageTo ?? undefined, provider, messageChannel);
|
||||
if (metadataChannel && normalizeKey(metadataChannel) !== normalizeKey(provider)) {
|
||||
return metadataChannel;
|
||||
}
|
||||
@@ -191,38 +156,3 @@ export function buildAgentHookContextIdentityFields(params: {
|
||||
...(channelContext ? { channelContext } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Builds canonical channel and requester fields shared by agent and tool hooks. */
|
||||
export function buildAgentHookContextOriginFields(params: {
|
||||
sessionKey?: string | null;
|
||||
messageChannel?: string | null;
|
||||
messageProvider?: string | null;
|
||||
currentChannelId?: string | null;
|
||||
messageTo?: string | null;
|
||||
trigger?: string | null;
|
||||
senderId?: string | null;
|
||||
chatId?: string | null;
|
||||
channelContext?: PluginHookChannelContext;
|
||||
}): Pick<
|
||||
PluginHookAgentContext,
|
||||
"channel" | "messageProvider" | "channelId" | "chatId" | "senderId" | "channelContext"
|
||||
> {
|
||||
const channelFields = buildAgentHookContextChannelFields({
|
||||
sessionKey: params.sessionKey,
|
||||
messageChannel: params.messageChannel,
|
||||
messageProvider: params.messageProvider,
|
||||
currentChannelId: params.currentChannelId,
|
||||
messageTo: params.messageTo,
|
||||
});
|
||||
return {
|
||||
...(channelFields.channel ? { channel: channelFields.channel } : {}),
|
||||
...(channelFields.messageProvider ? { messageProvider: channelFields.messageProvider } : {}),
|
||||
...(channelFields.channelId ? { channelId: channelFields.channelId } : {}),
|
||||
...buildAgentHookContextIdentityFields({
|
||||
trigger: params.trigger,
|
||||
senderId: params.senderId ?? params.channelContext?.sender?.id,
|
||||
chatId: params.chatId ?? params.channelContext?.chat?.id,
|
||||
channelContext: params.channelContext,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -609,22 +609,12 @@ export type PluginHookReplyPayloadSendingResult = {
|
||||
export type PluginHookToolKind = "code_mode_exec";
|
||||
export type PluginHookToolInputKind = "javascript" | "typescript";
|
||||
|
||||
export type PluginHookToolContext = Pick<
|
||||
PluginHookAgentContext,
|
||||
| "agentId"
|
||||
| "sessionKey"
|
||||
| "sessionId"
|
||||
| "runId"
|
||||
| "jobId"
|
||||
| "trace"
|
||||
| "trigger"
|
||||
| "messageProvider"
|
||||
| "channel"
|
||||
| "chatId"
|
||||
| "senderId"
|
||||
| "channelId"
|
||||
| "channelContext"
|
||||
> & {
|
||||
export type PluginHookToolContext = {
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
sessionId?: string;
|
||||
runId?: string;
|
||||
trace?: DiagnosticTraceContext;
|
||||
toolName: string;
|
||||
/** Host-authoritative discriminator for tools that intentionally share names. */
|
||||
toolKind?: PluginHookToolKind;
|
||||
@@ -632,6 +622,7 @@ export type PluginHookToolContext = Pick<
|
||||
toolInputKind?: PluginHookToolInputKind;
|
||||
toolCallId?: string;
|
||||
getSessionExtension?: (namespace: string) => PluginJsonValue | undefined;
|
||||
channelId?: string;
|
||||
};
|
||||
|
||||
export type PluginHookBeforeToolCallEvent = {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user