Compare commits

..

1 Commits

Author SHA1 Message Date
Peter Steinberger
d44260b927 feat: expose requester origin to tool policy hooks 2026-06-24 06:42:56 -07:00
107 changed files with 1700 additions and 2604 deletions

View File

@@ -1,2 +1,2 @@
ebb0ae07e4d6f6ea1faccba7604c9da71a5401b3aa2bc3618963e1e44a8dbcce plugin-sdk-api-baseline.json
9b7aee16d91c6a1b042a7d7e6f92a77b3e234337cc5fcf5a797de05fa9e9a02e plugin-sdk-api-baseline.jsonl
212b76ef72779add8f18be4848e143e61b6ae42a1c7daeefdc42d91e0a1152e9 plugin-sdk-api-baseline.json
976179e09e9e46a9b9259bd20ca1cafc8883c8e281a099a9aaa5fceab3c2983b plugin-sdk-api-baseline.jsonl

View File

@@ -528,25 +528,13 @@ 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` 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.
The Gateway watches `~/.openclaw/openclaw.json` and applies changes automatically - no manual restart needed for most settings.
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

View File

@@ -186,8 +186,12 @@ 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.toolKind`,
`ctx.toolInputKind`, and diagnostic `ctx.trace`
`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.
It can return:

View File

@@ -12,7 +12,11 @@ import {
type EmbeddedRunAttemptParams,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { buildApprovalResponse, handleCodexAppServerApprovalRequest } from "./approval-bridge.js";
import {
buildApprovalResponse,
handleCodexAppServerApprovalRequest as handleCodexAppServerApprovalRequestImpl,
} from "./approval-bridge.js";
import { buildCodexToolHookRunContext } from "./tool-hook-context.js";
vi.mock("openclaw/plugin-sdk/agent-harness-runtime", async (importOriginal) => ({
...(await importOriginal<typeof import("openclaw/plugin-sdk/agent-harness-runtime")>()),
@@ -42,6 +46,20 @@ 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}`);
@@ -243,6 +261,8 @@ describe("Codex app-server approval bridge", () => {
ctx: {
agentId: "main",
sessionKey: "agent:main:session-1",
messageProvider: "telegram",
channel: "telegram",
channelId: "chat-1",
},
});
@@ -1164,11 +1184,18 @@ describe("Codex app-server approval bridge", () => {
});
});
it("normalizes prefixed channel targets for OpenClaw tool policy context", async () => {
it("uses the caller-resolved hook context for approval fallback policy", async () => {
const params = createParams();
params.messageChannel = "telegram";
params.messageProvider = "telegram";
params.currentChannelId = "telegram:-100123";
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";
mockCallGatewayTool
.mockResolvedValueOnce({ id: "plugin:approval-prefixed", status: "accepted" })
.mockResolvedValueOnce({ id: "plugin:approval-prefixed", decision: "allow-once" });
@@ -1182,6 +1209,27 @@ 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",
});
@@ -1189,11 +1237,29 @@ 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("telegram:-100123");
expect(gatewayRequestPayload().turnSourceTo).toBe("discord:raw-target");
});
it("denies command approvals before prompting when OpenClaw tool policy blocks", async () => {

View File

@@ -8,7 +8,6 @@ import {
*/
import {
type AgentApprovalEventData,
buildAgentHookContextChannelFields,
formatApprovalDisplayPath,
hasNativeHookRelayInvocation,
invokeNativeHookRelay,
@@ -17,6 +16,7 @@ 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,6 +75,7 @@ export async function handleCodexAppServerApprovalRequest(params: {
method: string;
requestParams: JsonValue | undefined;
paramsForRun: EmbeddedRunAttemptParams;
toolHookContext: ToolHookRunContext;
threadId: string;
turnId: string;
nativeHookRelay?: Pick<
@@ -106,6 +107,7 @@ export async function handleCodexAppServerApprovalRequest(params: {
method: params.method,
requestParams,
paramsForRun: params.paramsForRun,
toolHookContext: params.toolHookContext,
context,
nativeHookRelay: params.nativeHookRelay,
signal: params.signal,
@@ -619,6 +621,7 @@ async function runOpenClawToolPolicyForApprovalRequest(params: {
method: string;
requestParams: JsonObject | undefined;
paramsForRun: EmbeddedRunAttemptParams;
toolHookContext: ToolHookRunContext;
context: ApprovalContext;
nativeHookRelay?: Pick<
NativeHookRelayRegistrationHandle,
@@ -652,13 +655,6 @@ 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,
@@ -666,13 +662,9 @@ async function runOpenClawToolPolicyForApprovalRequest(params: {
approvalMode: "request",
signal: params.signal,
ctx: {
...(params.paramsForRun.agentId ? { agentId: params.paramsForRun.agentId } : {}),
...params.toolHookContext,
...(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) {

View File

@@ -944,8 +944,16 @@ describe("Codex app-server dynamic tool build", () => {
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
params.disableTools = false;
params.currentChannelId = "D123";
params.messageChannel = "discord";
params.messageProvider = "discord-voice";
params.currentChannelId = "discord: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) => {
@@ -956,9 +964,19 @@ describe("Codex app-server dynamic tool build", () => {
await buildDynamicToolsForTest(params, workspaceDir, { sandbox: null as never });
expect(factoryOptions[0]).toMatchObject({
currentChannelId: "D123",
messageChannel: "discord",
messageProvider: "discord",
toolPolicyMessageProvider: "discord-voice",
currentChannelId: "discord: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 () => {

View File

@@ -125,7 +125,7 @@ export function resolveCodexAppServerHookChannelId(
messageChannel: params.messageChannel,
messageProvider: params.messageProvider,
currentChannelId: params.currentChannelId,
messageTo: params.messageTo,
messageTo: params.currentMessagingTarget ?? params.messageTo,
}).channelId;
}
@@ -239,6 +239,7 @@ 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,
@@ -249,6 +250,7 @@ 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,
@@ -290,6 +292,7 @@ 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,

View File

@@ -1846,6 +1846,17 @@ 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" },
},
},
});
@@ -1949,6 +1960,17 @@ 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" },
},
},
});
@@ -1975,6 +1997,17 @@ 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" } });
@@ -1997,6 +2030,17 @@ 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",
});
});

View File

@@ -32,6 +32,7 @@ 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";
@@ -53,13 +54,8 @@ import type {
JsonValue,
} from "./protocol.js";
type CodexDynamicToolHookContext = {
agentId?: string;
type CodexDynamicToolHookContext = ToolHookRunContext & {
config?: EmbeddedRunAttemptParams["config"];
sessionId?: string;
sessionKey?: string;
runId?: string;
channelId?: string;
currentChannelProvider?: string;
currentChannelId?: string;
currentMessagingTarget?: string;
@@ -70,7 +66,7 @@ type CodexDynamicToolHookContext = {
allocateToolOutcomeOrdinal?: EmbeddedRunAttemptParams["allocateToolOutcomeOrdinal"];
};
type CodexToolResultHookContext = Omit<CodexDynamicToolHookContext, "config">;
type CodexToolResultHookContext = ToolHookRunContext;
type ProjectedCodexDynamicTool = {
tool: AnyAgentTool;
@@ -310,11 +306,7 @@ export function createCodexDynamicToolBridge(params: {
void runAgentHarnessAfterToolCallHook({
toolName,
toolCallId: call.callId,
runId: toolResultHookContext.runId,
agentId: toolResultHookContext.agentId,
sessionId: toolResultHookContext.sessionId,
sessionKey: toolResultHookContext.sessionKey,
channelId: toolResultHookContext.channelId,
...toolResultHookContext,
startArgs: executedArgs,
result,
startedAt,
@@ -407,11 +399,7 @@ export function createCodexDynamicToolBridge(params: {
void runAgentHarnessAfterToolCallHook({
toolName,
toolCallId: call.callId,
runId: toolResultHookContext.runId,
agentId: toolResultHookContext.agentId,
sessionId: toolResultHookContext.sessionId,
sessionKey: toolResultHookContext.sessionKey,
channelId: toolResultHookContext.channelId,
...toolResultHookContext,
startArgs: executedArgs,
error: errorMessage,
startedAt,
@@ -702,13 +690,35 @@ function dedupeQuarantinedDynamicTools(
function toToolResultHookContext(
ctx: CodexDynamicToolHookContext | undefined,
): CodexToolResultHookContext {
const { agentId, sessionId, sessionKey, runId, channelId } = ctx ?? {};
const {
agentId,
sessionId,
sessionKey,
runId,
jobId,
trace,
trigger,
messageProvider,
channel,
chatId,
senderId,
channelId,
channelContext,
} = 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 }),
};
}

View File

@@ -2587,15 +2587,36 @@ describe("CodexAppServerEventProjector", () => {
});
});
it("emits after_tool_call observations for Codex-native tool item completions", async () => {
it("keeps resolved hook identity authoritative for Codex-native tool completions", async () => {
const afterToolCall = vi.fn();
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "after_tool_call", handler: afterToolCall }]),
);
const projector = await createProjector({
const projectorParams = {
...(await createParams()),
agentId: "main",
sessionKey: "agent:main:session-1",
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" },
},
},
});
await projector.handleNotification(
@@ -2652,6 +2673,17 @@ 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");
});

View File

@@ -18,6 +18,7 @@ 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";
@@ -65,6 +66,7 @@ export type CodexAppServerToolTelemetry = {
export type CodexAppServerEventProjectorOptions = {
nativePostToolUseRelayEnabled?: boolean;
toolHookContext?: ToolHookRunContext;
onNativeToolResultRecorded?: () => void | Promise<void>;
trajectoryRecorder?: CodexTrajectoryRecorder | null;
};
@@ -1374,6 +1376,9 @@ 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 } : {}),

View File

@@ -8,6 +8,7 @@ import {
type EmbeddedRunAttemptParams,
type NativeHookRelayEvent,
type NativeHookRelayRegistrationHandle,
type ToolHookRunContext,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import {
addTimerTimeoutGraceMs,
@@ -121,6 +122,7 @@ export function createCodexNativeHookRelay(params: {
config: EmbeddedRunAttemptParams["config"];
runId: string;
channelId?: string;
toolHookContext?: ToolHookRunContext;
attemptTimeoutMs: number;
startupTimeoutMs: number;
turnStartTimeoutMs: number;
@@ -146,6 +148,7 @@ 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,

View File

@@ -1,9 +1,6 @@
// 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,
@@ -609,6 +606,21 @@ 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(

View File

@@ -30,9 +30,7 @@ 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,
@@ -95,7 +93,15 @@ 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: {
@@ -135,6 +141,22 @@ 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,

View File

@@ -38,6 +38,7 @@ 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 {
@@ -248,6 +249,7 @@ import {
type CodexAppServerThreadLifecycleBinding,
type CodexContextEngineThreadBootstrapProjection,
} from "./thread-lifecycle.js";
import { buildCodexToolHookRunContext } from "./tool-hook-context.js";
import {
inferCodexDynamicToolMeta,
resolveCodexToolProgressDetailMode,
@@ -717,6 +719,14 @@ 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)) {
@@ -832,12 +842,8 @@ export async function runCodexAppServerAttempt(
}),
directToolNames: resolveCodexDynamicToolDirectNames(params),
hookContext: {
agentId: sessionAgentId,
...toolHookRunContext,
config: params.config,
sessionId: params.sessionId,
sessionKey: sandboxSessionKey,
runId: params.runId,
channelId: hookChannelId,
currentChannelProvider: resolveCodexMessageToolProvider(params),
currentChannelId: params.currentChannelId,
currentMessagingTarget: params.currentMessagingTarget,
@@ -1444,6 +1450,7 @@ export async function runCodexAppServerAttempt(
config: params.config,
runId: params.runId,
channelId: hookChannelId,
toolHookContext: toolHookRunContext,
attemptTimeoutMs: params.timeoutMs,
startupTimeoutMs,
turnStartTimeoutMs: params.timeoutMs,
@@ -2150,6 +2157,7 @@ export async function runCodexAppServerAttempt(
method: request.method,
params: request.params,
paramsForRun: params,
toolHookContext: toolHookRunContext,
threadId: thread.threadId,
turnId,
nativeHookRelay,
@@ -2761,6 +2769,7 @@ export async function runCodexAppServerAttempt(
nativePostToolUseRelayEnabled:
nativeHookRelay?.allowedEvents.includes("post_tool_use") === true &&
nativeHookRelay.shouldRelayEvent("post_tool_use"),
toolHookContext: toolHookRunContext,
trajectoryRecorder,
onNativeToolResultRecorded: maybeAnnounceFastModeAutoOff,
},
@@ -3430,6 +3439,7 @@ function handleApprovalRequest(params: {
method: string;
params: JsonValue | undefined;
paramsForRun: EmbeddedRunAttemptParams;
toolHookContext: ToolHookRunContext;
threadId: string;
turnId: string;
nativeHookRelay?: NativeHookRelayRegistrationHandle;
@@ -3443,6 +3453,7 @@ function handleApprovalRequest(params: {
method: params.method,
requestParams: params.params,
paramsForRun: params.paramsForRun,
toolHookContext: params.toolHookContext,
threadId: params.threadId,
turnId: params.turnId,
nativeHookRelay: params.nativeHookRelay,

View File

@@ -861,9 +861,12 @@ describe("runCodexAppServerSideQuestion", () => {
).toMatchObject({
agentId: "main",
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionKey: "agent:main:runtime-policy",
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");
@@ -889,6 +892,7 @@ describe("runCodexAppServerSideQuestion", () => {
runCodexAppServerSideQuestion(
sideParams({
sessionKey: "agent:main:session-1",
sandboxSessionKey: "agent:main:runtime-policy",
messageChannel: "discord",
messageProvider: "discord-voice",
currentChannelId: "discord:voice-room",
@@ -971,6 +975,7 @@ describe("runCodexAppServerSideQuestion", () => {
runCodexAppServerSideQuestion(
sideParams({
sessionKey: "agent:main:session-1",
sandboxSessionKey: "agent:main:runtime-policy",
messageChannel: "discord",
messageProvider: "discord-voice",
opts: { runId: "run-side-approval" },
@@ -988,6 +993,7 @@ describe("runCodexAppServerSideQuestion", () => {
threadId?: string;
turnId?: string;
paramsForRun?: { messageChannel?: string; messageProvider?: string };
toolHookContext?: { sessionKey?: string };
nativeHookRelay?: { relayId?: string; allowedEvents?: readonly string[] };
}
| undefined;
@@ -1007,6 +1013,9 @@ describe("runCodexAppServerSideQuestion", () => {
messageChannel: "discord",
messageProvider: "discord-voice",
},
toolHookContext: {
sessionKey: "agent:main:runtime-policy",
},
});
expect(approvalArgs?.nativeHookRelay).toMatchObject({
relayId: relayIdDuringFork,
@@ -1482,6 +1491,14 @@ 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) => {
@@ -1527,6 +1544,13 @@ 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" }],
@@ -1610,14 +1634,29 @@ describe("runCodexAppServerSideQuestion", () => {
expect(activeDiagnosticToolKeys(diagnosticEvents)).toEqual(new Set());
});
it("normalizes hook channel ids for side-thread dynamic tool requests", async () => {
it("preserves requester identity while normalizing side-thread hook channels", async () => {
const afterToolCall = vi.fn();
const beforeToolCall = vi.fn((...args: unknown[]) => {
const context = args[1] as { channelId?: string };
expect(context.channelId).toBe("voice-room");
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" },
},
});
return undefined;
});
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "before_tool_call", handler: beforeToolCall }]),
createMockPluginRegistry([
{ hookName: "before_tool_call", handler: beforeToolCall },
{ hookName: "after_tool_call", handler: afterToolCall },
]),
);
const client = createFakeClient();
client.request.mockImplementation(async (method: string) => {
@@ -1657,17 +1696,48 @@ 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({ hookChannelId: "voice-room" }),
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(
(mockCall(createOpenClawCodingToolsMock)[0] as { channelContext?: unknown }).channelContext,
).toBeUndefined();
expect(toolExecuteMock).toHaveBeenCalledTimes(1);
});

View File

@@ -1,6 +1,5 @@
// Codex plugin module implements side question behavior.
import {
buildAgentHookContextChannelFields,
embeddedAgentLog,
formatErrorMessage,
resolveAgentDir,
@@ -16,6 +15,7 @@ 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,6 +89,7 @@ import {
resolveCodexBindingModelProviderFallback,
resolveReasoningEffort,
} from "./thread-lifecycle.js";
import { buildCodexToolHookRunContext } from "./tool-hook-context.js";
import { filterToolsForVisionInputs } from "./vision-tools.js";
import {
resolveCodexWebSearchPlan,
@@ -206,9 +207,21 @@ 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: sideRunParams.sandboxSessionKey?.trim() || sideRunParams.sessionKey,
sessionKey: toolHookSessionKey,
sessionId: sideRunParams.sessionId,
surface: "/btw side-question mode",
});
@@ -287,6 +300,7 @@ export async function runCodexAppServerSideQuestion(
nativeToolSurfaceEnabled,
nativeProviderWebSearchSupport,
signal: runAbortController.signal,
toolHookContext: toolHookRunContext,
});
removeRequestHandler = client.addRequestHandler(async (request) => {
if (request.method === "account/chatgptAuthTokens/refresh") {
@@ -319,19 +333,20 @@ 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;
@@ -388,15 +403,11 @@ export async function runCodexAppServerSideQuestion(
events: nativeHookRelayEvents,
agentId: sessionAgentId,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
sessionKey: toolHookRunContext.sessionKey,
config: params.cfg,
runId: sideRunParams.runId,
channelId: buildAgentHookContextChannelFields({
sessionKey: params.sessionKey,
messageChannel: params.messageChannel,
messageProvider: params.messageProvider,
currentChannelId: params.currentChannelId,
}).channelId,
channelId: toolHookRunContext.channelId,
toolHookContext: toolHookRunContext,
requestTimeoutMs: appServer.requestTimeoutMs,
completionTimeoutMs: Math.max(
appServer.turnCompletionIdleTimeoutMs,
@@ -419,12 +430,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,
@@ -436,7 +447,7 @@ export async function runCodexAppServerSideQuestion(
cwd,
approvalPolicy,
approvalsReviewer: modelScopedAppServer.approvalsReviewer,
...(modelScopedAppServer.networkProxy ? {} : { sandbox }),
...(modelScopedAppServer.networkProxy ? {} : { sandbox }),
...(serviceTier ? { serviceTier } : {}),
config: threadConfig,
developerInstructions: SIDE_DEVELOPER_INSTRUCTIONS,
@@ -542,6 +553,7 @@ function registerCodexSideNativeHookRelay(params: {
config: EmbeddedRunAttemptParams["config"];
runId: string;
channelId?: string;
toolHookContext?: ToolHookRunContext;
requestTimeoutMs: number;
completionTimeoutMs: number;
signal: AbortSignal;
@@ -557,6 +569,7 @@ 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,
@@ -596,6 +609,7 @@ 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,
@@ -616,6 +630,8 @@ 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,
@@ -647,6 +663,7 @@ async function createCodexSideToolBridge(input: {
nativeToolSurfaceEnabled: boolean;
nativeProviderWebSearchSupport: CodexNativeWebSearchSupport;
signal: AbortSignal;
toolHookContext: ToolHookRunContext;
}): Promise<{ toolBridge: CodexDynamicToolBridge; webSearchPlan: CodexWebSearchPlan }> {
const runtimeModel =
input.params.runtimeModel ??
@@ -657,10 +674,7 @@ async function createCodexSideToolBridge(input: {
const createOpenClawCodingTools = (await import("openclaw/plugin-sdk/agent-harness"))
.createOpenClawCodingTools;
const sandboxSessionKey =
input.params.sandboxSessionKey?.trim() ||
input.params.sessionKey?.trim() ||
input.params.sessionId ||
input.sessionAgentId;
input.toolHookContext.sessionKey || input.params.sessionId || input.sessionAgentId;
const sandbox = await resolveSandboxContext({
config: input.params.cfg,
sessionKey: sandboxSessionKey,
@@ -696,6 +710,9 @@ 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,
@@ -715,6 +732,8 @@ 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 }
@@ -724,12 +743,7 @@ async function createCodexSideToolBridge(input: {
? { senderIsOwner: input.params.senderIsOwner }
: {}),
...(input.params.currentChannelId ? { currentChannelId: input.params.currentChannelId } : {}),
hookChannelId: buildAgentHookContextChannelFields({
sessionKey: input.params.sessionKey,
messageChannel: input.params.messageChannel,
messageProvider: input.params.messageProvider,
currentChannelId: input.params.currentChannelId,
}).channelId,
hookChannelId: input.toolHookContext.channelId,
sandbox,
emitBeforeToolCallDiagnostics: false,
modelHasVision: runtimeModel.input?.includes("image") ?? false,
@@ -757,25 +771,15 @@ 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: {
agentId: input.sessionAgentId,
...input.toolHookContext,
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,

View File

@@ -0,0 +1,41 @@
/** 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,
}),
};
}

View File

@@ -340,7 +340,22 @@ describe("runCopilotAttempt", () => {
return { sdkTools: [], sourceTools: [] };
});
await runCopilotAttempt(makeParams(), {
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, {
createToolBridge,
pool: makeFakePool(sdk),
});
@@ -387,7 +402,21 @@ describe("runCopilotAttempt", () => {
toolCallId: "tool-call-1",
toolName: "read",
}),
expect.objectContaining({ agentId: "agent-1", sessionId: "session-1" }),
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" },
},
}),
);
});
@@ -1287,6 +1316,32 @@ 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);

View File

@@ -9,6 +9,7 @@ import type {
} from "openclaw/plugin-sdk/agent-harness-runtime";
import {
buildAgentHookContextChannelFields,
buildAgentHookContextOriginFields,
detectAndLoadAgentHarnessPromptImages,
getModelProviderRequestTransport,
resolveAgentHarnessBeforePromptBuildResult,
@@ -409,6 +410,25 @@ 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);
@@ -626,7 +646,7 @@ export async function runCopilotAttempt(
allowModelTools: poolAcquire.provider.mode === "byok",
modelProvider: modelRef.provider,
modelId: modelRef.id,
agentId: readString(params.agentId) ?? "copilot",
agentId: sessionAgentId,
sessionId: readString(input.sessionId) ?? "copilot-session",
sessionKey: readString((input as { sessionKey?: unknown }).sessionKey),
agentDir: readString(input.agentDir),
@@ -652,11 +672,7 @@ export async function runCopilotAttempt(
runAgentHarnessAfterToolCallHook({
toolName,
toolCallId,
runId: input.runId,
agentId: sessionAgentId,
sessionId: input.sessionId,
sessionKey: sandboxSessionKey,
channelId: hookContext.channelId,
...toolHookRunContext,
startArgs: args,
...(result !== undefined ? { result } : {}),
...(error ? { error } : {}),

View File

@@ -1,11 +1,17 @@
// 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 & {
@@ -77,6 +83,7 @@ function runSdkTool(tool: SdkTool, args: unknown, invocation = makeInvocation())
}
afterEach(() => {
resetGlobalHookRunner();
vi.restoreAllMocks();
});
@@ -309,6 +316,79 @@ 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" }),
@@ -443,7 +523,13 @@ describe("createCopilotToolBridge", () => {
currentMessagingTarget: "user:U123",
currentThreadTs: "1700000000.000100",
currentMessageId: "M-1",
messageProvider: "slack",
messageChannel: "slack",
messageProvider: "slack-voice",
chatId: "chat-1",
channelContext: {
sender: { id: "sender-1", displayName: "Ada" },
chat: { id: "chat-1", kind: "channel" },
},
messageTo: "U-1",
messageThreadId: "1700000000.000100",
replyToMode: "first",
@@ -477,7 +563,13 @@ describe("createCopilotToolBridge", () => {
currentMessagingTarget: "user:U123",
currentThreadTs: "1700000000.000100",
currentMessageId: "M-1",
messageProvider: "slack",
messageChannel: "slack",
messageProvider: "slack-voice",
chatId: "chat-1",
hookChannelContext: {
sender: { id: "sender-1", displayName: "Ada" },
chat: { id: "chat-1", kind: "channel" },
},
messageTo: "U-1",
messageThreadId: "1700000000.000100",
replyToMode: "first",
@@ -485,6 +577,7 @@ describe("createCopilotToolBridge", () => {
forceMessageTool: true,
enableHeartbeatTool: true,
});
expect(opts.channelContext).toBeUndefined();
});
it("falls back messageProvider to attemptParams.messageChannel when messageProvider is absent (codex parity)", async () => {
@@ -502,6 +595,63 @@ 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;

View File

@@ -7,15 +7,19 @@ 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";
@@ -144,6 +148,7 @@ 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);
@@ -210,6 +215,7 @@ export async function createCopilotToolBridge(
},
toolSurfaceRuntime,
);
const toolHookContext = buildCopilotToolHookContext(toolOptions);
let sourceTools: unknown;
try {
@@ -231,9 +237,18 @@ export async function createCopilotToolBridge(
sourceTools as AnyAgentTool[],
toolSurfaceRuntime.runtimeToolAllowlist,
);
const compactedTools = toolSurfaceRuntime.compactTools(allowedSourceTools);
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 plannedTools = filterCopilotToolsForConstructionPlan(
compactedTools.tools,
hookedCompactedTools,
effectiveToolPlan.codingToolConstructionPlan,
{ preserveToolNames: toolSurfaceRuntime.runtimeToolAllowlist },
);
@@ -264,6 +279,51 @@ 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`.
@@ -350,7 +410,9 @@ 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,
@@ -360,6 +422,7 @@ function buildOpenClawCodingToolsOptions(
memberRoleIds: a.memberRoleIds,
spawnedBy: a.spawnedBy,
senderId: a.senderId,
hookChannelContext: a.channelContext,
senderName: a.senderName,
senderUsername: a.senderUsername,
senderE164: a.senderE164,
@@ -395,6 +458,7 @@ function buildOpenClawCodingToolsOptions(
workspaceDir,
}),
currentChannelId: a.currentChannelId,
chatId: a.chatId,
currentMessagingTarget: a.currentMessagingTarget,
currentThreadTs: a.currentThreadTs,
currentMessageId: a.currentMessageId,

View File

@@ -36,49 +36,21 @@ type DuckDuckGoResult = {
};
function decodeHtmlEntities(text: string): string {
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 === "&lt;") {
return "<";
}
if (normalized === "&gt;") {
return ">";
}
if (normalized === "&quot;") {
return '"';
}
if (normalized === "&apos;" || normalized === "&#39;" || normalized === "&#x27;") {
return "'";
}
if (normalized === "&#x2f;") {
return "/";
}
if (normalized === "&nbsp;") {
return " ";
}
if (normalized === "&ndash;") {
return "-";
}
if (normalized === "&mdash;") {
return "--";
}
if (normalized === "&hellip;") {
return "...";
}
if (normalized === "&amp;") {
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;
},
);
return text
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&#39;/g, "'")
.replace(/&#x27;/g, "'")
.replace(/&#x2F;/g, "/")
.replace(/&nbsp;/g, " ")
.replace(/&ndash;/g, "-")
.replace(/&mdash;/g, "--")
.replace(/&hellip;/g, "...")
.replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
.replace(/&#x([0-9a-f]+);/gi, (_, code) => String.fromCodePoint(Number.parseInt(code, 16)));
}
function stripHtml(html: string): string {

View File

@@ -186,17 +186,6 @@ describe("duckduckgo web search provider", () => {
);
});
it("does not double-decode escaped entities (decodes &amp; last)", () => {
// A result whose text literally shows "&lt;" arrives double-encoded as
// "&amp;lt;". Decoding &amp; first would re-decode it into "<", corrupting
// the snippet; &amp; must be decoded last.
expect(ddgClientTesting.decodeHtmlEntities("How to escape &amp;lt; in HTML")).toBe(
"How to escape &lt; in HTML",
);
expect(ddgClientTesting.decodeHtmlEntities("a&amp;#39;b")).toBe("a&#39;b");
expect(ddgClientTesting.decodeHtmlEntities("a&#x26;amp;b")).toBe("a&amp;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">

View File

@@ -29,30 +29,6 @@ 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",

View File

@@ -330,7 +330,7 @@ function isKanaOnlyToken(value: string): boolean {
);
}
function normalizeConceptToken(rawToken: string, fromGlossary = false): string | null {
function normalizeConceptToken(rawToken: string): string | null {
const normalized = normalizeLowercaseStringOrEmpty(
rawToken
.normalize("NFKC")
@@ -348,9 +348,7 @@ function normalizeConceptToken(rawToken: string, fromGlossary = false): string |
return null;
}
const script = classifyConceptTagScript(normalized);
// 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)) {
if (normalized.length < minimumTokenLengthForScript(script)) {
return null;
}
if (isKanaOnlyToken(normalized) && normalized.length < 3) {
@@ -362,43 +360,14 @@ function normalizeConceptToken(rawToken: string, fromGlossary = false): string |
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, wholeWord } of GLOSSARY_ENTRIES) {
const present = wholeWord
? includesStandaloneTerm(normalizedSource, entry)
: normalizedSource.includes(entry);
if (present) {
matches.push(entry);
for (const entry of PROTECTED_GLOSSARY) {
if (!normalizedSource.includes(entry)) {
continue;
}
matches.push(entry);
}
return matches;
}
@@ -416,13 +385,8 @@ function collectSegmentTokens(source: string): string[] {
return source.split(/[^\p{L}\p{N}]+/u).filter(Boolean);
}
function pushNormalizedTag(
tags: string[],
rawToken: string,
limit: number,
fromGlossary = false,
): void {
const normalized = normalizeConceptToken(rawToken, fromGlossary);
function pushNormalizedTag(tags: string[], rawToken: string, limit: number): void {
const normalized = normalizeConceptToken(rawToken);
if (!normalized || tags.includes(normalized)) {
return;
}
@@ -446,17 +410,14 @@ export function deriveConceptTags(params: {
}
const tags: string[] = [];
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;
}
for (const rawToken of [
...collectGlossaryMatches(source),
...collectCompoundTokens(source),
...collectSegmentTokens(source),
]) {
pushNormalizedTag(tags, rawToken, limit);
if (tags.length >= limit) {
break;
}
}
return tags;

View File

@@ -3189,9 +3189,7 @@ describe("short-term promotion", () => {
path: "memory/2026-04-03.md",
snippet: "Move backups to S3 Glacier and sync QMD router notes.",
}),
// "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"]);
).toStrictEqual(["backup", "backups", "glacier", "qmd", "router", "sync"]);
});
it("extracts multilingual concept tags across latin and cjk snippets", () => {

View File

@@ -37,15 +37,6 @@ describe("stripHtmlFromTeamsMessage", () => {
);
});
it("does not double-decode escaped entities (decodes &amp; last)", () => {
// Graph encodes literally-typed entity text by escaping its '&' to '&amp;'.
// Decoding '&amp;' first would re-decode the now-bare '&lt;'/'&gt;' into
// angle brackets, corrupting the user's literal text.
expect(stripHtmlFromTeamsMessage("The token is &amp;lt;APIKEY&amp;gt;")).toBe(
"The token is &lt;APIKEY&gt;",
);
});
it("normalizes multiple whitespace to single space", () => {
expect(stripHtmlFromTeamsMessage("hello world")).toBe("hello world");
});

View File

@@ -35,16 +35,14 @@ 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. &amp; must be decoded LAST to prevent
// double-decoding (e.g. &amp;lt; → &lt; not <), matching decodeHtmlEntities
// in inbound.ts.
// Decode common HTML entities.
text = text
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&");
.replace(/&nbsp;/g, " ");
// Normalize whitespace.
return text.replace(/\s+/g, " ").trim();
}

View File

@@ -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 bordered striped>"),
html: expect.stringContaining("<table>"),
}),
);
});

View File

@@ -1239,33 +1239,6 @@ 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({

View File

@@ -690,24 +690,6 @@ 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 });

View File

@@ -254,7 +254,7 @@ describe("markdownToTelegramHtml", () => {
`| ${Array.from({ length: columns }, (_, index) => String(index + 1)).join(" | ")} |`,
].join("\n");
expect(markdownToTelegramRichHtml(table(20))).toContain("<table bordered striped>");
expect(markdownToTelegramRichHtml(table(20))).toContain("<table>");
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,19 +295,6 @@ 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",

View File

@@ -346,8 +346,6 @@ 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,
@@ -974,25 +972,19 @@ function renderTelegramRichHtmlTable(table: MarkdownTableMeta): string {
}
const renderCellValue = (cell: MarkdownTableCell | undefined) =>
cell ? renderTelegramHtml(cell) : "";
const renderCell = (
tag: "td" | "th",
value: MarkdownTableCell | undefined,
align: TelegramTableAlignment | undefined,
) => {
const alignAttr = align ? ` align="${align}"` : "";
return `<${tag}${alignAttr}>${renderCellValue(value)}</${tag}>`;
};
const renderCell = (tag: "td" | "th", value: MarkdownTableCell | undefined) =>
`<${tag}>${renderCellValue(value)}</${tag}>`;
const head = table.headers.length
? `<thead><tr>${table.headerCells.map((cell, index) => renderCell("th", cell, table.aligns?.[index])).join("")}</tr></thead>`
? `<thead><tr>${table.headerCells.map((cell) => renderCell("th", cell)).join("")}</tr></thead>`
: "";
const bodyRows = table.rowCells
.map(
(row) =>
`<tr>${Array.from({ length: columnCount }, (_value, index) => renderCell("td", row[index], table.aligns?.[index])).join("")}</tr>`,
`<tr>${Array.from({ length: columnCount }, (_value, index) => renderCell("td", row[index])).join("")}</tr>`,
)
.join("");
const body = bodyRows ? `<tbody>${bodyRows}</tbody>` : "";
return `<table bordered striped>${head}${body}</table>\n\n`;
return `<table>${head}${body}</table>\n\n`;
}
function renderTelegramRichHtmlDocument(

View File

@@ -96,16 +96,6 @@ 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) {
@@ -174,11 +164,7 @@ export function buildTelegramRichMarkdown(
markdown: string,
options?: TelegramRichMessageOptions,
): TelegramInputRichMessage {
const richOptions = {
...options,
skipEntityDetection: shouldSkipTelegramRichEntityDetection(markdown, options),
};
return buildTelegramRichHtml(markdownToTelegramRichHtml(markdown, richOptions), richOptions);
return buildTelegramRichHtml(markdownToTelegramRichHtml(markdown, options), options);
}
export function buildTelegramRichHtml(
@@ -186,7 +172,7 @@ export function buildTelegramRichHtml(
options?: TelegramRichMessageOptions,
): TelegramInputRichMessage {
const safeHtml = prepareTelegramRichHtml(html);
return shouldSkipTelegramRichEntityDetection(safeHtml, options)
return options?.skipEntityDetection === true
? { html: safeHtml, skip_entity_detection: true }
: { html: safeHtml };
}
@@ -432,14 +418,13 @@ 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, markdownOptions));
prepareTelegramRichHtml(
markdownToTelegramRichHtml(chunk, {
tableMode: params.tableMode,
skipEntityDetection: params.skipEntityDetection,
}),
);
const htmlChunks =
params.textMode === "html"
? splitPreparedTelegramRichHtml({

View File

@@ -953,32 +953,7 @@ describe("sendMessageTelegram", () => {
expect(botRawApi.sendRichMessage).toHaveBeenCalledTimes(1);
const richMessage = botRawApi.sendRichMessage.mock.calls[0]?.[0]?.rich_message;
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:");
expect(richMessage?.html).toContain("<table>");
});
it.each([

View File

@@ -27,9 +27,6 @@ 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 },
);

View File

@@ -78,12 +78,9 @@ 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 = {
@@ -116,7 +113,6 @@ type TableCell = MarkdownTableCell;
type TableState = {
headers: TableCell[];
rows: TableCell[][];
aligns: (MarkdownTableAlignment | undefined)[];
currentRow: TableCell[];
currentCell: RenderTarget | null;
inHeader: boolean;
@@ -176,20 +172,6 @@ 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 };
}
@@ -450,7 +432,6 @@ function initTableState(): TableState {
return {
headers: [],
rows: [],
aligns: [],
currentRow: [],
currentCell: null,
inHeader: false,
@@ -536,15 +517,13 @@ function collectTableBlock(state: RenderState) {
}
const headerCells = state.table.headers.map(trimCell);
const rowCells = state.table.rows.map((row) => row.map(trimCell));
const table = {
state.collectedTables.push({
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(
@@ -895,10 +874,6 @@ 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":

View File

@@ -107,7 +107,6 @@ 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",
@@ -164,7 +163,6 @@ 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",

View File

@@ -33,6 +33,13 @@ 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>;
@@ -1200,6 +1207,143 @@ 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) => {

View File

@@ -37,6 +37,7 @@ 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";
@@ -50,8 +51,10 @@ 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 {
@@ -110,8 +113,15 @@ 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;
@@ -133,6 +143,24 @@ 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 =
@@ -211,6 +239,38 @@ 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");
@@ -1143,17 +1203,13 @@ export async function runBeforeToolCallHook(args: {
...(args.toolKind && { toolKind: args.toolKind }),
...(args.toolInputKind && { toolInputKind: args.toolInputKind }),
};
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 buildToolContext = (identity: typeof toolIdentity) =>
buildPluginHookToolContext({
toolName,
...identity,
...(args.toolCallId && { toolCallId: args.toolCallId }),
...(args.ctx ? { ctx: args.ctx } : {}),
});
const toolContext = buildToolContext(toolIdentity);
const trustedPolicyResult = shouldRunTrustedPolicies
? await runTrustedToolPolicies(

View File

@@ -201,7 +201,7 @@ describe("createOpenClawCodingTools", () => {
expect(names.has("tool_call")).toBe(false);
});
it("passes explicit hook channel ids to wrapped tool hooks", async () => {
it("passes requester origin context to wrapped tool hooks", async () => {
const beforeToolCall = vi.fn();
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "before_tool_call", handler: beforeToolCall }]),
@@ -210,15 +210,36 @@ describe("createOpenClawCodingTools", () => {
await fs.writeFile(path.join(tmpDir, "note.txt"), "hello");
const tools = createOpenClawCodingTools({
workspaceDir: tmpDir,
currentChannelId: "telegram:-100123",
hookChannelId: "-100123",
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" },
},
});
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({ channelId: "-100123" }),
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" },
},
}),
);
});

View File

@@ -19,6 +19,7 @@ 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";
@@ -470,8 +471,12 @@ 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). */
@@ -1187,7 +1192,8 @@ export function createOpenClawCodingTools(options?: {
);
options?.recordToolPrepStage?.("schema-normalization");
const turnSourceChannel = options?.messageChannel ?? options?.messageProvider;
const turnSourceTo = options?.currentMessagingTarget ?? options?.currentChannelId;
const turnSourceTo =
options?.currentMessagingTarget ?? options?.messageTo ?? options?.currentChannelId;
const hookContext = {
agentId,
...(options?.config ? { config: options.config } : {}),
@@ -1200,7 +1206,19 @@ export function createOpenClawCodingTools(options?: {
sessionKey: options?.sessionKey,
sessionId: options?.sessionId,
runId: options?.runId,
channelId: options?.hookChannelId ?? options?.currentChannelId,
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,
}),
...(turnSourceChannel ? { turnSourceChannel } : {}),
...(turnSourceTo ? { turnSourceTo } : {}),
...(options?.agentAccountId ? { turnSourceAccountId: options.agentAccountId } : {}),

View File

@@ -629,6 +629,11 @@ 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." });
@@ -653,6 +658,11 @@ describe("runBtwSideQuestion", () => {
senderName?: string;
senderUsername?: string;
senderE164?: string;
chatId?: string;
channelContext?: {
sender?: Record<string, unknown>;
chat?: Record<string, unknown>;
};
toolsAllow?: string[];
},
]
@@ -675,6 +685,11 @@ 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,

View File

@@ -18,6 +18,7 @@ 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";
@@ -388,6 +389,8 @@ type RunBtwSideQuestionParams = {
senderUsername?: string | null;
senderE164?: string | null;
senderIsOwner?: boolean;
chatId?: string;
channelContext?: PluginHookChannelContext;
currentChannelId?: string;
};

View File

@@ -52,6 +52,7 @@ 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";
@@ -1241,6 +1242,7 @@ export async function runEmbeddedAttempt(
toolConstructionPlan.constructTools ||
toolSearchControlsEnabledForRun ||
codeModeControlsEnabledForRun;
const toolPolicyMessageProvider = resolveAttemptToolPolicyMessageProvider(params);
let toolSearchCatalogExecutor: ToolSearchCatalogToolExecutor | undefined;
toolSearchCatalogRef =
toolSearchControlsEnabledForRun || codeModeControlsEnabledForRun
@@ -1261,7 +1263,7 @@ export async function runEmbeddedAttempt(
elevated: params.bashElevated,
},
sandbox,
messageProvider: resolveAttemptToolPolicyMessageProvider(params),
messageProvider: toolPolicyMessageProvider,
agentAccountId: params.agentAccountId,
messageTo: params.messageTo,
messageThreadId: params.messageThreadId,
@@ -1314,6 +1316,7 @@ export async function runEmbeddedAttempt(
}),
currentChannelId: params.currentChannelId,
currentMessagingTarget: params.currentMessagingTarget,
chatId: params.chatId,
currentThreadTs: params.currentThreadTs,
currentMessageId: params.currentMessageId,
currentInboundAudio: params.currentInboundAudio,
@@ -1661,7 +1664,19 @@ export async function runEmbeddedAttempt(
sessionKey: sandboxSessionKey,
sessionId: params.sessionId,
runId: params.runId,
channelId: params.currentChannelId,
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,
}),
trace: runTrace,
loopDetection: resolveToolLoopDetectionConfig({
cfg: params.config,
@@ -2421,14 +2436,9 @@ export async function runEmbeddedAttempt(
},
},
{
agentId: sessionAgentId,
sessionKey: sandboxSessionKey,
...catalogToolHookContext,
config: toolSearchRuntimeConfig,
sessionId: params.sessionId,
runId: params.runId,
loopDetection: clientToolLoopDetection,
onToolOutcome: params.onToolOutcome,
allocateToolOutcomeOrdinal: params.allocateToolOutcomeOrdinal,
},
)
: [];
@@ -3555,6 +3565,7 @@ export async function runEmbeddedAttempt(
messageChannel: runtimeChannel,
initialReplayState: params.initialReplayState,
hookRunner: getGlobalHookRunner() ?? undefined,
toolHookContext: catalogToolHookContext,
verboseLevel: params.verboseLevel,
reasoningMode: params.reasoningLevel ?? "off",
thinkingLevel: params.thinkLevel,

View File

@@ -34,6 +34,7 @@ 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,
@@ -1573,14 +1574,20 @@ export async function handleToolExecutionEnd(
durationMs,
};
void hookRunnerAfter
.runAfterToolCall(hookEvent, {
toolName,
agentId: ctx.params.agentId,
sessionKey: ctx.params.sessionKey,
sessionId: ctx.params.sessionId,
runId,
toolCallId,
})
.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,
},
}),
)
.catch((err: unknown) => {
ctx.log.warn(`after_tool_call hook failed: tool=${toolName} error=${String(err)}`);
});

View File

@@ -272,6 +272,7 @@ export type EmbeddedAgentSubscribeContext = {
type ToolHandlerParams = Pick<
SubscribeEmbeddedAgentSessionParams,
| "runId"
| "toolHookContext"
| "onBlockReplyFlush"
| "onAgentEvent"
| "onToolStreamBoundary"

View File

@@ -10,6 +10,7 @@ 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 {
@@ -35,6 +36,8 @@ 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;

View File

@@ -6,25 +6,26 @@
*/
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import { consumeAdjustedParamsForToolCall } from "../agent-tools.before-tool-call.js";
import {
buildPluginHookToolContext,
consumeAdjustedParamsForToolCall,
type ToolHookRunContext,
} 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: {
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> {
export async function runAgentHarnessAfterToolCallHook(
params: ToolHookRunContext & {
toolName: string;
toolCallId: 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 =
@@ -47,15 +48,11 @@ export async function runAgentHarnessAfterToolCallHook(params: {
...(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)}`);

View File

@@ -1637,6 +1637,19 @@ 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({
@@ -1674,7 +1687,17 @@ 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",
});
@@ -1695,6 +1718,19 @@ 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({
@@ -2243,6 +2279,19 @@ 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({
@@ -2273,7 +2322,17 @@ 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",
});

View File

@@ -37,6 +37,7 @@ 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";
@@ -104,6 +105,7 @@ export type NativeHookRelayRegistration = {
config?: OpenClawConfig;
runId: string;
channelId?: string;
toolHookContext?: ToolHookRunContext;
allowedEvents: readonly NativeHookRelayEvent[];
expiresAtMs: number;
signal?: AbortSignal;
@@ -131,6 +133,7 @@ export type RegisterNativeHookRelayParams = {
config?: OpenClawConfig;
runId: string;
channelId?: string;
toolHookContext?: ToolHookRunContext;
allowedEvents?: readonly NativeHookRelayEvent[];
ttlMs?: number;
command?: NativeHookRelayCommandOptions;
@@ -435,6 +438,7 @@ 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 } : {}),
@@ -1398,6 +1402,7 @@ 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 } : {}),
@@ -1447,6 +1452,7 @@ 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,

View File

@@ -65,6 +65,8 @@ 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;

View File

@@ -73,7 +73,6 @@ function cleanedLockForPath(lockPath: string): SessionLockInspection {
ageMs: 1_000,
stale: true,
staleReasons: ["dead-pid"],
removable: true,
removed: true,
};
}

View File

@@ -36,7 +36,6 @@ export type SessionLockInspection = {
ageMs: number | null;
stale: boolean;
staleReasons: string[];
removable: boolean;
removed: boolean;
};
@@ -859,15 +858,13 @@ 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 && removable) {
if (removeStale && (await shouldRemoveLockDuringCleanup(lockPath, lockInfo, staleMs, nowMs))) {
await fs.rm(lockPath, { force: true });
lockInfo.removed = true;
cleaned.push(lockInfo);

View File

@@ -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.js";
import { resolveAgentConfig } from "./agent-scope-config.js";
/** Resolves effective tool loop-detection config by overlaying agent settings on globals. */
export function resolveToolLoopDetectionConfig(params: {

View File

@@ -2167,7 +2167,6 @@ describe("cron tool", () => {
expect(params?.patch?.payload).toEqual({
kind: "agentTurn",
toolsAllow: ["read", "cron"],
toolsAllowIsDefault: true,
});
});
@@ -2203,7 +2202,6 @@ describe("cron tool", () => {
payload: {
kind: "agentTurn",
toolsAllow: ["read", "cron"],
toolsAllowIsDefault: true,
},
},
},
@@ -2280,51 +2278,6 @@ 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({
@@ -2357,7 +2310,6 @@ describe("cron tool", () => {
kind: "agentTurn",
message: "run later",
toolsAllow: ["read", "cron"],
toolsAllowIsDefault: true,
},
},
},

View File

@@ -466,7 +466,6 @@ function capCronAgentTurnToolsAllow(params: {
: params.defaultToolsAllow;
if (!Array.isArray(requestedRaw)) {
params.payload.toolsAllow = creatorToolNames;
params.payload.toolsAllowIsDefault = true;
return;
}
const requestedToolsAllow = normalizeCronToolsAllow(
@@ -474,12 +473,10 @@ 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({
@@ -493,7 +490,6 @@ function capCronAgentTurnToolsAllow(params: {
params.payload.toolsAllow = creatorToolNames.filter((toolName) =>
isToolAllowedByPolicyName(toolName, requestedPolicy),
);
delete params.payload.toolsAllowIsDefault;
}
function capCronAgentTurnJobToolsAllow(
@@ -553,12 +549,8 @@ 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) &&
existingPayload.toolsAllowIsDefault !== true
existingPayloadKind === "agentTurn" && isRecord(existingPayload)
? existingPayload.toolsAllow
: undefined,
});

View File

@@ -130,6 +130,12 @@ 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",
@@ -161,6 +167,11 @@ 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({

View File

@@ -1,4 +1,5 @@
/** 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";
@@ -57,6 +58,12 @@ 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,
@@ -109,6 +116,8 @@ 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 {

View File

@@ -155,7 +155,7 @@ describe("runDoctorLintCli", () => {
try {
const exitCode = await runDoctorLintCli(runtime, {
json: true,
onlyIds: ["core/doctor/not-a-check"],
onlyIds: ["core/doctor/session-locks"],
});
expect(exitCode).toBe(1);
@@ -167,7 +167,7 @@ describe("runDoctorLintCli", () => {
{
checkId: "core/doctor/lint-selection",
severity: "error",
path: "core/doctor/not-a-check",
path: "core/doctor/session-locks",
},
],
});

View File

@@ -13,12 +13,7 @@ vi.mock("../../packages/terminal-core/src/note.js", () => ({
note,
}));
import {
detectStaleSessionLocks,
noteSessionLockHealth,
sessionLockToHealthFinding,
sessionLockToRepairEffect,
} from "./doctor-session-locks.js";
import { noteSessionLockHealth } from "./doctor-session-locks.js";
async function expectPathMissing(targetPath: string): Promise<void> {
try {
@@ -110,154 +105,6 @@ 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 });

View File

@@ -9,19 +9,8 @@ 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";
@@ -51,57 +40,6 @@ 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;

View File

@@ -1,12 +1,7 @@
// 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 {
getContextEngineFactory,
getContextEngineRegistration,
registerContextEngine,
registerContextEngineForOwner,
} from "../../../context-engine/registry.js";
import { registerContextEngine } from "../../../context-engine/registry.js";
import type { ContextEngine, ContextEngineHostCapability } from "../../../context-engine/types.js";
import {
collectConfiguredContextEngineAgentRunHosts,
@@ -65,23 +60,6 @@ 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: {

View File

@@ -16,10 +16,7 @@ import {
type ContextEngineHostSupport,
} from "../../../context-engine/host-compat.js";
import { ensureContextEnginesInitialized } from "../../../context-engine/init.js";
import {
getContextEngineRegistration,
resolveContextEngine,
} from "../../../context-engine/registry.js";
import { getContextEngineFactory, 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";
@@ -257,7 +254,7 @@ async function resolveSelectedContextEngineInfo(params: {
}
ensureContextEnginesInitialized();
if (getContextEngineRegistration(engineId)?.lifecycle !== "runtime") {
if (!getContextEngineFactory(engineId)) {
try {
ensurePluginRegistryLoaded({
scope: "all",
@@ -266,7 +263,7 @@ async function resolveSelectedContextEngineInfo(params: {
onlyPluginIds: [engineId],
});
} catch (error) {
if (getContextEngineRegistration(engineId)?.lifecycle !== "runtime") {
if (!getContextEngineFactory(engineId)) {
const message = error instanceof Error ? error.message : String(error);
return {
warnings: [
@@ -275,7 +272,7 @@ async function resolveSelectedContextEngineInfo(params: {
};
}
}
if (getContextEngineRegistration(engineId)?.lifecycle !== "runtime") {
if (!getContextEngineFactory(engineId)) {
return {
warnings: [
`- plugins.slots.contextEngine: could not inspect context engine "${engineId}" host requirements because it is not registered.`,

View File

@@ -370,125 +370,6 @@ 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(

View File

@@ -11,7 +11,6 @@ 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";
@@ -129,36 +128,17 @@ type SessionRegistryMaintenanceSummary = {
stores: SessionRegistryMaintenanceStoreSummary[];
};
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 } {
function readRunningCronJobIds(): Set<string> {
try {
const cronStorePath = resolveCronJobsStorePath(getRuntimeConfig().cron?.store);
const runningJobs = loadCronJobsStoreSync(cronStorePath).jobs.filter(
(job) => typeof job.state?.runningAtMs === "number",
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()),
);
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 { ids: new Set(), count: 0 };
return new Set();
}
}
@@ -166,13 +146,13 @@ async function runSessionRegistryMaintenance(params: {
apply: boolean;
}): Promise<SessionRegistryMaintenanceSummary> {
const cfg = getRuntimeConfig();
const runningCronJobs = readRunningCronJobIds();
const runningCronJobIds = readRunningCronJobIds();
const stores: SessionRegistryMaintenanceStoreSummary[] = [];
for (const target of resolveAllAgentSessionStoreTargetsSync(cfg)) {
const result = await runSessionRegistryMaintenanceForStore({
apply: params.apply,
retentionMs: SESSION_REGISTRY_RETENTION_MS,
runningCronJobIds: runningCronJobs.ids,
runningCronJobIds,
storePath: target.storePath,
});
stores.push({
@@ -186,7 +166,7 @@ async function runSessionRegistryMaintenance(params: {
}
return {
retentionMs: SESSION_REGISTRY_RETENTION_MS,
runningCronJobs: runningCronJobs.count,
runningCronJobs: runningCronJobIds.size,
pruned: stores.reduce((total, store) => total + store.pruned, 0),
stores,
};

View File

@@ -1199,7 +1199,6 @@ 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) => {
@@ -1232,10 +1231,7 @@ function resolveConfigIncludesForRead(
resolvedPath,
rootRealDir,
ioFs: deps.fs,
onResolvedPath: (canonicalPath) => {
recordIncludeTarget(resolvedPath, canonicalPath);
includeFilePaths?.add(path.normalize(canonicalPath));
},
onResolvedPath: (canonicalPath) => recordIncludeTarget(resolvedPath, canonicalPath),
});
if (includeFileHashesForWrite) {
includeFileHashesForWrite[path.normalize(resolvedPath)] = hashConfigIncludeRaw(raw);
@@ -1311,13 +1307,11 @@ 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;
};
@@ -1874,7 +1868,6 @@ 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", () =>
@@ -1923,7 +1916,6 @@ export function createConfigIO(
deps,
includeFileHashesForWrite,
includeFileTargetsForWrite,
includeFilePaths,
),
);
} catch (err) {
@@ -2094,7 +2086,6 @@ export function createConfigIO(
envSnapshotForRestore: readResolution.envSnapshotForRestore,
includeFileHashesForWrite,
includeFileTargetsForWrite,
includeFilePaths: [...includeFilePaths].toSorted(),
pluginMetadataSnapshot: validationPluginMetadata.getSnapshot(),
},
{ observe: !callerRejectedSuspiciousRecovery },
@@ -2160,7 +2151,6 @@ export function createConfigIO(
});
return {
snapshot: result.snapshot,
...(result.snapshot.valid ? { includeFilePaths: result.includeFilePaths ?? [] } : {}),
...(result.pluginMetadataSnapshot
? { pluginMetadataSnapshot: result.pluginMetadataSnapshot }
: {}),

View File

@@ -1865,57 +1865,6 @@ 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");

View File

@@ -541,46 +541,6 @@ 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;
@@ -1433,37 +1393,6 @@ 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,
@@ -2586,53 +2515,6 @@ 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;

View File

@@ -1084,88 +1084,6 @@ 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
// ═══════════════════════════════════════════════════════════════════════════

View File

@@ -44,16 +44,9 @@ 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");
@@ -396,7 +389,13 @@ export type ContextEngineRuntimeQuarantine = {
};
type ContextEngineRegistryState = {
engines: Map<string, ContextEngineRegistration>;
engines: Map<
string,
{
factory: ContextEngineFactory;
owner: string;
}
>;
quarantinedEngines: Map<string, ContextEngineRuntimeQuarantine>;
};
@@ -513,7 +512,6 @@ 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 (
@@ -526,18 +524,11 @@ 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, lifecycle });
if (lifecycle === "runtime") {
clearContextEngineRuntimeQuarantine(id);
}
registry.set(id, { factory, owner: normalizedOwner });
clearContextEngineRuntimeQuarantine(id);
return { ok: true };
}
@@ -559,13 +550,7 @@ export function registerContextEngine(
* Return the factory for a registered engine, or undefined.
*/
export function getContextEngineFactory(id: string): ContextEngineFactory | undefined {
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);
return getContextEngineRegistryState().engines.get(id)?.factory;
}
/**
@@ -960,13 +945,6 @@ 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);

View File

@@ -161,19 +161,8 @@ function buildCronDeliveryTargetRuntimeContext(params: {
].join("\n");
}
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) === "*")
function resolveCliRuntimeToolsAllow(toolsAllow?: string[]): string[] | undefined {
return toolsAllow?.some((toolName) => normalizeToolName(toolName) === "*")
? undefined
: toolsAllow;
}
@@ -337,10 +326,7 @@ export function createCronPromptExecutor(params: {
messageChannel,
sourceReplyDeliveryMode,
requireExplicitMessageTarget: params.sourceDelivery.messageTool.requireExplicitTarget,
toolsAllow: resolveCliRuntimeToolsAllow(
params.agentPayload?.toolsAllow,
params.agentPayload?.toolsAllowIsDefault,
),
toolsAllow: resolveCliRuntimeToolsAllow(params.agentPayload?.toolsAllow),
abortSignal: params.abortSignal,
onExecutionStarted: params.onExecutionStarted,
onExecutionPhase: params.onExecutionPhase,

View File

@@ -825,41 +825,6 @@ 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());

View File

@@ -392,63 +392,6 @@ 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",
@@ -458,7 +401,6 @@ describe("applyJobPatch", () => {
kind: "agentTurn",
message: "do it",
toolsAllow: ["exec", "read"],
toolsAllowIsDefault: true,
};
applyJobPatch(job, {
@@ -472,7 +414,6 @@ describe("applyJobPatch", () => {
expect(job.payload.kind).toBe("agentTurn");
if (job.payload.kind === "agentTurn") {
expect(job.payload.toolsAllow).toBeUndefined();
expect(job.payload.toolsAllowIsDefault).toBeUndefined();
}
});
@@ -586,30 +527,6 @@ 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 },
{

View File

@@ -40,9 +40,6 @@ 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,
@@ -900,42 +897,6 @@ 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);
@@ -982,7 +943,7 @@ function mergeCronPayload(existing: CronPayload, patch: CronPayloadPatch): CronP
return buildPayloadFromPatch(patch);
}
const next: CronAgentTurnPayload = { ...existing };
const next: Extract<CronPayload, { kind: "agentTurn" }> = { ...existing };
if (typeof patch.message === "string") {
next.message = patch.message;
}
@@ -996,7 +957,11 @@ function mergeCronPayload(existing: CronPayload, patch: CronPayloadPatch): CronP
} else if (patch.fallbacks === null) {
delete next.fallbacks;
}
applyAgentTurnToolsAllowPatch(next, patch, existing);
if (Array.isArray(patch.toolsAllow)) {
next.toolsAllow = patch.toolsAllow;
} else if (patch.toolsAllow === null) {
delete next.toolsAllow;
}
if (typeof patch.thinking === "string") {
next.thinking = patch.thinking;
}
@@ -1040,18 +1005,17 @@ function buildPayloadFromPatch(patch: CronPayloadPatch): CronPayload {
throw new Error('cron.update payload.kind="agentTurn" requires message');
}
const next: CronAgentTurnPayload = {
return {
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(

View File

@@ -522,48 +522,6 @@ 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);

View File

@@ -75,7 +75,6 @@ export function bindPayloadColumns(
| "payload_thinking"
| "payload_timeout_seconds"
| "payload_tools_allow_json"
| "payload_tools_allow_is_default"
> {
if (payload.kind === "systemEvent") {
return {
@@ -89,7 +88,6 @@ 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") {
@@ -105,7 +103,6 @@ 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 {
@@ -119,9 +116,6 @@ 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,
};
}
@@ -150,10 +144,6 @@ 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,
@@ -165,7 +155,6 @@ export function payloadFromRow(row: CronJobRow): CronPayload | null {
...(externalContentSource ? { externalContentSource } : {}),
...(lightContext != null ? { lightContext } : {}),
...(toolsAllow ? { toolsAllow } : {}),
...(toolsAllow && toolsAllowIsDefault ? { toolsAllowIsDefault: true } : {}),
};
}
if (row.payload_kind === "command") {

View File

@@ -248,8 +248,6 @@ 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 = {

View File

@@ -748,28 +748,4 @@ 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",
}),
);
});
});

View File

@@ -13,11 +13,6 @@ import {
shellCompletionStatusToHealthFindings,
shellCompletionStatusToRepairEffects,
} from "../commands/doctor-completion.js";
import {
detectStaleSessionLocks,
sessionLockToHealthFinding,
sessionLockToRepairEffect,
} from "../commands/doctor-session-locks.js";
import {
disableUnavailableSkillsInConfig,
formatMissingSkillSummary,
@@ -36,7 +31,6 @@ 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,
@@ -48,7 +42,6 @@ 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;
@@ -736,33 +729,6 @@ 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",
@@ -1008,16 +974,13 @@ function createWorkspaceSuggestionsCheck(deps: CoreHealthCheckDeps): HealthCheck
};
}
function createConvertedWorkflowChecks(
deps: CoreHealthCheckDeps,
): readonly SplitHealthCheckInput[] {
function createConvertedWorkflowChecks(deps: CoreHealthCheckDeps): readonly HealthCheck[] {
return [
claudeCliCheck,
gatewayAuthCheck,
legacyStateCheck,
legacyWhatsAppCrontabCheck,
codexSessionRoutesCheck,
sessionLocksCheck,
shellCompletionCheck,
uiProtocolFreshnessCheck,
gatewayServicesExtraCheck,
@@ -1052,7 +1015,7 @@ export function resetCoreHealthChecksForTest(): void {
export function createCoreHealthChecks(
deps: CoreHealthCheckDeps = defaultCoreHealthCheckDeps,
): readonly SplitHealthCheckInput[] {
): readonly HealthCheck[] {
return [
gatewayConfigCheck,
...createConvertedWorkflowChecks(deps),
@@ -1063,4 +1026,4 @@ export function createCoreHealthChecks(
];
}
export const CORE_HEALTH_CHECKS: readonly SplitHealthCheckInput[] = createCoreHealthChecks();
export const CORE_HEALTH_CHECKS: readonly HealthCheck[] = createCoreHealthChecks();

View File

@@ -1292,7 +1292,6 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] {
createDoctorHealthContribution({
id: "doctor:session-locks",
label: "Session locks",
healthCheckIds: ["core/doctor/session-locks"],
run: runSessionLocksHealth,
}),
createDoctorHealthContribution({

View File

@@ -39,36 +39,6 @@ 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",

View File

@@ -37,9 +37,6 @@ 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;
}
@@ -81,10 +78,6 @@ 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 =

View File

@@ -3,19 +3,17 @@ import type {
HealthCheckInput,
HealthCheckRunResult,
RegisteredHealthCheck,
SplitHealthCheckInput,
} from "./health-check-runner-types.js";
import type { HealthRepairContext } from "./health-checks.js";
import type { HealthCheck, 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: SplitHealthCheckInput): RegisteredHealthCheck {
export function defineSplitHealthCheck(check: HealthCheck): 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:
@@ -75,7 +73,6 @@ 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);

View File

@@ -25,23 +25,18 @@ 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">, HealthCheckSelectionOptions {
export interface RunnableHealthCheck extends Pick<
HealthCheck,
"id" | "kind" | "description" | "source"
> {
run(ctx: HealthCheckRunContext, scope?: HealthCheckScope): Promise<HealthCheckRunResult>;
}
export type HealthCheckInput = SplitHealthCheckInput | RunnableHealthCheck;
export type HealthCheckInput = HealthCheck | RunnableHealthCheck;
/** Normalized check contract consumed by lint and repair runners. */
export interface RegisteredHealthCheck extends HealthCheck, HealthCheckSelectionOptions {
export interface RegisteredHealthCheck extends HealthCheck {
readonly sourceContract: "split" | "run";
run(ctx: HealthCheckRunContext, scope?: HealthCheckScope): Promise<HealthCheckRunResult>;
}

View File

@@ -18,7 +18,8 @@ import {
resolveMainSessionKey,
} from "../config/sessions/main-session.js";
import { resolveStorePath } from "../config/sessions/paths.js";
import { preserveTemporarySessionMapping } from "../config/sessions/session-accessor.js";
import { loadSessionStore, updateSessionStore } from "../config/sessions/store.js";
import type { SessionEntry } from "../config/sessions/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { formatErrorMessage } from "../infra/errors.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
@@ -32,6 +33,14 @@ 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";
@@ -92,6 +101,68 @@ 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;
@@ -122,49 +193,39 @@ export async function runBootOnce(params: {
const sessionKey = resolveBootSessionKey(mainSessionKey);
const message = buildBootPrompt(result.content ?? "");
const sessionId = generateBootSessionId();
const agentId = resolveAgentIdFromSessionKey(sessionKey);
const storePath = resolveStorePath(params.cfg.session?.store, { agentId });
const mappingSnapshot = snapshotSessionMapping({
cfg: params.cfg,
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,
});
// 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 mappingRestoreFailure = mappingPreservation.restoreFailure;
const mappingRestoreFailure = await restoreSessionMapping(mappingSnapshot);
if (mappingRestoreFailure) {
log.error(`boot: failed to restore session mapping: ${mappingRestoreFailure}`);
}

View File

@@ -1,6 +1,5 @@
// 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";
@@ -579,8 +578,8 @@ describe("resolveGatewayReloadSettings", () => {
});
});
type WatcherHandler = (value?: string | Error) => void;
type WatcherEvent = "add" | "change" | "unlink" | "error" | "ready";
type WatcherHandler = () => void;
type WatcherEvent = "add" | "change" | "unlink" | "error";
function createWatcherMock(effectiveUsePolling?: boolean) {
const handlers = new Map<WatcherEvent, WatcherHandler[]>();
@@ -593,9 +592,9 @@ function createWatcherMock(effectiveUsePolling?: boolean) {
handlers.set(event, existing);
return this;
},
emit(event: WatcherEvent, value?: string | Error) {
emit(event: WatcherEvent) {
for (const handler of handlers.get(event) ?? []) {
handler(value);
handler();
}
},
close: vi.fn(async () => {}),
@@ -660,30 +659,17 @@ function makeZeroDebounceHookWrite(persistedHash: string): ConfigWriteNotificati
}
function createReloaderHarness(
readSnapshot: () => Promise<
ConfigFileSnapshot | { snapshot: ConfigFileSnapshot; includeFilePaths?: readonly string[] }
>,
readSnapshot: () => Promise<ConfigFileSnapshot>,
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 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 watcher = createWatcherMock();
vi.spyOn(chokidar, "watch").mockReturnValue(watcher 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;
@@ -704,7 +690,6 @@ function createReloaderHarness(
initialConfig: { gateway: { reload: { debounceMs: 0 } } },
initialCompareConfig: options.initialCompareConfig,
initialInternalWriteHash: options.initialInternalWriteHash,
initialIncludeFilePaths: options.initialIncludeFilePaths,
readSnapshot,
promoteSnapshot: options.promoteSnapshot,
initialPluginInstallRecords: options.initialPluginInstallRecords ?? {},
@@ -717,8 +702,6 @@ function createReloaderHarness(
});
return {
watcher,
watchers,
watchSpy,
onHotReload,
onRestart,
log,
@@ -1016,9 +999,7 @@ describe("startGatewayConfigReloader", () => {
await harness.reloader.stop();
});
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");
it("does not promote external config edits when hot reload rejects them", async () => {
const acceptedSnapshot = makeSnapshot({
config: {
gateway: { reload: { debounceMs: 0 } },
@@ -1026,50 +1007,22 @@ 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()
.mockResolvedValueOnce({ snapshot: acceptedSnapshot, includeFilePaths: [nextInclude] })
.mockResolvedValueOnce({ snapshot: acceptedSnapshot, includeFilePaths: [nextInclude] })
.mockResolvedValueOnce({ snapshot: revisedSnapshot, includeFilePaths: [nextInclude] });
.fn<() => Promise<ConfigFileSnapshot>>()
.mockResolvedValueOnce(acceptedSnapshot);
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.runOnlyPendingTimersAsync();
await vi.runAllTimersAsync();
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();
});
@@ -1151,461 +1104,6 @@ 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>>()
@@ -2035,40 +1533,6 @@ 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>>()

View File

@@ -1,6 +1,5 @@
// 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";
@@ -103,36 +102,6 @@ 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: {
@@ -145,8 +114,7 @@ export function startGatewayConfigReloader(opts: {
initialConfig: OpenClawConfig;
initialCompareConfig?: OpenClawConfig;
initialInternalWriteHash?: string | null;
initialIncludeFilePaths?: readonly string[];
readSnapshot: () => Promise<ConfigReloadSnapshotReadResult>;
readSnapshot: () => Promise<ConfigFileSnapshot>;
onHotReload: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => Promise<void>;
onRestart: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => void | Promise<void>;
promoteSnapshot?: (snapshot: ConfigFileSnapshot, reason: string) => Promise<boolean>;
@@ -167,7 +135,6 @@ export function startGatewayConfigReloader(opts: {
let pending = false;
let running = false;
let stopped = false;
let pendingIncludeReload = false;
let restartQueued = false;
let missingConfigRetries = 0;
let pendingInProcessConfig: {
@@ -177,7 +144,6 @@ export function startGatewayConfigReloader(opts: {
afterWrite?: ConfigWriteNotification["afterWrite"];
} | null = null;
let lastAppliedWriteHash = opts.initialInternalWriteHash ?? null;
let currentApplyRejected = false;
let currentPluginInstallRecords =
opts.initialPluginInstallRecords ?? loadInstalledPluginIndexInstallRecordsSync();
const readPluginInstallRecords =
@@ -290,11 +256,7 @@ export function startGatewayConfigReloader(opts: {
currentPluginInstallRecords = nextPluginInstallRecords;
settings = resolveGatewayReloadSettings(nextConfig);
if (changedPaths.length === 0) {
if (currentApplyRejected) {
opts.log.warn("config reload skipped (previous apply failed; waiting for config change)");
return false;
}
return true;
return;
}
// Invalidate cached skills snapshots (persisted in sessions.json) whenever
@@ -311,21 +273,18 @@ 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})`);
currentApplyRejected = false;
return true;
return;
}
const plan = buildGatewayReloadPlan(changedPaths, {
noopPaths: pluginInstallTimestampNoopPaths,
forceChangedPaths: pluginInstallWholeRecordPaths,
});
if (isNoopReloadPlan(plan) && !followUp.requiresRestart) {
currentApplyRejected = false;
return true;
return;
}
if (settings.mode === "off") {
opts.log.info("config reload disabled (gateway.reload.mode=off)");
currentApplyRejected = false;
return true;
return;
}
if (followUp.requiresRestart) {
queueRestart(
@@ -336,13 +295,11 @@ export function startGatewayConfigReloader(opts: {
},
nextConfig,
);
currentApplyRejected = false;
return true;
return;
}
if (settings.mode === "restart") {
queueRestart(plan, nextConfig);
currentApplyRejected = false;
return true;
return;
}
if (plan.restartGateway) {
if (settings.mode === "hot") {
@@ -351,23 +308,13 @@ export function startGatewayConfigReloader(opts: {
", ",
)})`,
);
currentApplyRejected = false;
return true;
return;
}
queueRestart(plan, nextConfig);
currentApplyRejected = false;
return true;
return;
}
try {
await opts.onHotReload(plan, nextConfig);
currentApplyRejected = false;
return true;
} catch (err) {
currentApplyRejected = true;
opts.log.error(`config reload failed: ${String(err)}`);
return false;
}
await opts.onHotReload(plan, nextConfig);
};
const promoteAcceptedSnapshot = async (snapshot: ConfigFileSnapshot, reason: string) => {
@@ -381,26 +328,15 @@ export function startGatewayConfigReloader(opts: {
}
};
const promoteAcceptedInProcessWrite = async (
persistedHash: string,
acceptedCompareConfig: OpenClawConfig,
) => {
const promoteAcceptedInProcessWrite = async (persistedHash: string) => {
if (!opts.promoteSnapshot) {
return;
}
try {
const snapshotRead = unpackConfigReloadSnapshot(await opts.readSnapshot());
const snapshot = snapshotRead.snapshot;
if (
snapshot.hash !== persistedHash ||
!snapshot.valid ||
diffConfigPaths(acceptedCompareConfig, snapshot.sourceConfig).length > 0
) {
const snapshot = await opts.readSnapshot();
if (snapshot.hash !== persistedHash || !snapshot.valid) {
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)}`);
@@ -425,31 +361,20 @@ export function startGatewayConfigReloader(opts: {
const pendingWrite = pendingInProcessConfig;
pendingInProcessConfig = null;
missingConfigRetries = 0;
const applied = await applySnapshot(
await applySnapshot(
pendingWrite.config,
pendingWrite.compareConfig,
pendingWrite.afterWrite,
);
if (!applied) {
if (lastAppliedWriteHash === pendingWrite.persistedHash) {
lastAppliedWriteHash = null;
}
return;
}
await promoteAcceptedInProcessWrite(pendingWrite.persistedHash, pendingWrite.compareConfig);
await promoteAcceptedInProcessWrite(pendingWrite.persistedHash);
return;
}
const bypassRootWriteHashDedupe = pendingIncludeReload;
pendingIncludeReload = false;
const snapshotRead = unpackConfigReloadSnapshot(await opts.readSnapshot());
const snapshot = snapshotRead.snapshot;
const snapshot = await opts.readSnapshot();
if (lastAppliedWriteHash && typeof snapshot.hash === "string") {
if (!bypassRootWriteHashDedupe && snapshot.hash === lastAppliedWriteHash) {
if (snapshot.hash === lastAppliedWriteHash) {
return;
}
if (snapshot.hash !== lastAppliedWriteHash) {
lastAppliedWriteHash = null;
}
lastAppliedWriteHash = null;
}
if (handleMissingSnapshot(snapshot)) {
return;
@@ -458,13 +383,7 @@ export function startGatewayConfigReloader(opts: {
handleInvalidSnapshot(snapshot);
return;
}
const applied = await applySnapshot(snapshot.config, snapshot.sourceConfig);
if (!applied) {
return;
}
if (snapshotRead.includeFilePaths) {
replaceWatchedPaths(snapshotRead.includeFilePaths);
}
await applySnapshot(snapshot.config, snapshot.sourceConfig);
await promoteAcceptedSnapshot(snapshot, "valid-config");
} catch (err) {
opts.log.error(`config reload failed: ${String(err)}`);
@@ -473,20 +392,11 @@ export function startGatewayConfigReloader(opts: {
if (pending) {
pending = false;
schedule();
} else if (pendingIncludeReload) {
scheduleAfter(0);
}
}
};
const normalizedRootWatchPath = nodePath.normalize(opts.watchPath);
const scheduleFromWatcher = (changedPath?: string) => {
if (
typeof changedPath === "string" &&
nodePath.normalize(changedPath) !== normalizedRootWatchPath
) {
pendingIncludeReload = true;
}
const scheduleFromWatcher = () => {
schedule();
};
@@ -505,254 +415,35 @@ export function startGatewayConfigReloader(opts: {
scheduleAfter(0);
}) ?? (() => {});
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 watcher: ReturnType<typeof chokidar.watch> | null = null;
let watcherRecreateRetries = 0;
let watcherRecreateTimer: ReturnType<typeof setTimeout> | null = null;
let rootHotReloadDisabled = false;
let hotReloadStatus: GatewayHotReloadStatus = "active";
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 next = createWatcherInstance(
opts.watchPath,
resolveChokidarUsePolling(degradedToPolling),
);
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);
});
watcher = next;
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));
watcherUsesPolling = next.options.usePolling;
hotReloadStatus = "active";
};
const handleWatcherError = (source: ConfigWatcher, err: unknown) => {
const handleWatcherError = (source: typeof watcher, err: unknown) => {
// Ignore stale errors from a watcher we already replaced or stopped.
if (stopped || source !== watcher) {
return;
@@ -760,7 +451,7 @@ export function startGatewayConfigReloader(opts: {
const failedWatcherUsedPolling = watcherUsesPolling;
watcher = null;
watcherUsesPolling = false;
closeWatcher(source);
void source?.close().catch(() => {});
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
@@ -778,7 +469,7 @@ export function startGatewayConfigReloader(opts: {
return;
}
const mode = failedWatcherUsedPolling ? "polling mode" : "native mode";
rootHotReloadDisabled = true;
hotReloadStatus = "disabled";
opts.log.error(
`config hot-reload disabled: watcher failed after ${WATCHER_RECREATE_MAX_RETRIES} re-create attempts in ${mode}: ${String(err)}`,
);
@@ -799,18 +490,6 @@ 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 () => {
@@ -823,26 +502,11 @@ export function startGatewayConfigReloader(opts: {
clearTimeout(watcherRecreateTimer);
watcherRecreateTimer = null;
}
if (includeReplacementTimer) {
clearTimeout(includeReplacementTimer);
includeReplacementTimer = null;
}
unsubscribeFromWrites();
const rootWatcher = watcher;
const activeIncludes = activeIncludeGroup;
const stagedIncludes = pendingIncludeGroup;
const active = watcher;
watcher = null;
activeIncludeGroup = emptyIncludeGroup();
pendingIncludeGroup = null;
await Promise.all(
[
...(rootWatcher ? [rootWatcher] : []),
...activeIncludes.watchers,
...(stagedIncludes?.watchers ?? []),
].map(async (target) => await target.close().catch(() => {})),
);
await active?.close().catch(() => {});
},
hotReloadStatus: () =>
rootHotReloadDisabled || includeHotReloadDisabled ? "disabled" : "active",
hotReloadStatus: () => hotReloadStatus,
};
}

View File

@@ -47,6 +47,16 @@ 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;
};
@@ -1439,6 +1449,37 @@ 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" }],

View File

@@ -11,6 +11,7 @@ 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,
@@ -273,6 +274,12 @@ 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

View File

@@ -193,9 +193,8 @@ type ManagedGatewayConfigReloaderParams = Omit<
initialConfig: OpenClawConfig;
initialCompareConfig?: OpenClawConfig;
initialInternalWriteHash: string | null;
initialIncludeFilePaths?: readonly string[];
watchPath: string;
readSnapshot: typeof import("../config/config.js").readConfigFileSnapshotWithPluginMetadata;
readSnapshot: typeof import("../config/config.js").readConfigFileSnapshot;
promoteSnapshot: typeof import("../config/config.js").promoteConfigSnapshotToLastKnownGood;
subscribeToWrites: typeof import("../config/config.js").registerConfigWriteListener;
logReload: GatewayReloadLog & {
@@ -682,7 +681,6 @@ 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,

View File

@@ -99,7 +99,6 @@ function secretsPrepareTimelineAttributes(
export type GatewayStartupConfigSnapshotLoadResult = {
snapshot: ConfigFileSnapshot;
wroteConfig: boolean;
includeFilePaths?: readonly string[];
pluginMetadataSnapshot?: PluginMetadataSnapshot;
};
@@ -144,7 +143,6 @@ export async function loadGatewayStartupConfigSnapshot(params: {
return {
snapshot: configSnapshot,
wroteConfig,
...(snapshotRead.includeFilePaths ? { includeFilePaths: snapshotRead.includeFilePaths } : {}),
...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}),
};
}
@@ -155,7 +153,6 @@ export async function loadGatewayStartupConfigSnapshot(params: {
return {
snapshot: withRuntimeConfig(configSnapshot, autoEnable.config),
wroteConfig,
...(snapshotRead.includeFilePaths ? { includeFilePaths: snapshotRead.includeFilePaths } : {}),
...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}),
};
}

View File

@@ -17,7 +17,7 @@ import { isRestartEnabled } from "../config/commands.flags.js";
import {
getRuntimeConfig,
promoteConfigSnapshotToLastKnownGood,
readConfigFileSnapshotWithPluginMetadata,
readConfigFileSnapshot,
registerConfigWriteListener,
setRuntimeConfigSnapshot,
type ReadConfigFileSnapshotWithPluginMetadataResult,
@@ -638,7 +638,6 @@ 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);
@@ -695,15 +694,11 @@ 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 startupSnapshotRead = await startupTrace.measure("config.final-snapshot", () =>
readConfigFileSnapshotWithPluginMetadata(),
const startupSnapshot = await startupTrace.measure("config.final-snapshot", () =>
readConfigFileSnapshot(),
);
const startupSnapshot = startupSnapshotRead.snapshot;
startupInternalWriteHash = startupSnapshot.hash ?? null;
startupLastGoodSnapshot = startupSnapshot;
if (startupSnapshotRead.includeFilePaths) {
startupIncludeFilePaths = startupSnapshotRead.includeFilePaths;
}
}
setRuntimeConfigSnapshot(cfgAtStart, startupLastGoodSnapshot.sourceConfig);
const { prepareGatewayPluginBootstrap } = await loadStartupPluginsModule();
@@ -1732,9 +1727,8 @@ export async function startGatewayServer(
initialConfig: cfgAtStart,
initialCompareConfig: startupLastGoodSnapshot.sourceConfig,
initialInternalWriteHash: startupInternalWriteHash,
initialIncludeFilePaths: startupIncludeFilePaths,
watchPath: configSnapshot.path,
readSnapshot: readConfigFileSnapshotWithPluginMetadata,
readSnapshot: readConfigFileSnapshot,
promoteSnapshot: promoteConfigSnapshotToLastKnownGood,
subscribeToWrites: registerConfigWriteListener,
deps,

View File

@@ -684,6 +684,7 @@ 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",
},
@@ -696,6 +697,14 @@ 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 () => {

View File

@@ -13,6 +13,7 @@ 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";
@@ -257,6 +258,12 @@ 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,

View File

@@ -108,11 +108,16 @@ 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 } from "../plugins/hook-agent-context.js";
export {
buildAgentHookContextChannelFields,
buildAgentHookContextOriginFields,
} from "../plugins/hook-agent-context.js";
export { resolveToolLoopDetectionConfig } from "../agents/tool-loop-detection-config.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";

View File

@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
buildAgentHookContextChannelFields,
buildAgentHookContextIdentityFields,
buildAgentHookContextOriginFields,
resolveAgentHookChannelId,
} from "./hook-agent-context.js";
@@ -135,3 +136,63 @@ 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",
});
});
});

View File

@@ -38,18 +38,48 @@ 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 provider;
return inferredProviderChannel ?? provider;
}
const separatorIndex = messageChannel.indexOf(":");
if (separatorIndex === -1) {
if (inferredProviderChannel && normalizeKey(messageChannel) === normalizeKey(provider)) {
return inferredProviderChannel;
}
return messageChannel;
}
@@ -61,7 +91,7 @@ function resolveAgentHookChannel(params: {
TARGET_PREFIXES.has(normalizeKey(prefix)) ||
normalizeKey(prefix) === normalizeKey(provider)
) {
return provider;
return inferredProviderChannel ?? provider;
}
return prefix;
}
@@ -76,14 +106,19 @@ 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) ??
stripConversationPrefix(params.messageTo ?? undefined, provider, messageChannel);
stripConversationPrefix(
params.currentChannelId ?? undefined,
provider,
messageChannel,
channel,
) ?? stripConversationPrefix(params.messageTo ?? undefined, provider, messageChannel, channel);
if (metadataChannel && normalizeKey(metadataChannel) !== normalizeKey(provider)) {
return metadataChannel;
}
@@ -156,3 +191,38 @@ 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,
}),
};
}

View File

@@ -609,12 +609,22 @@ export type PluginHookReplyPayloadSendingResult = {
export type PluginHookToolKind = "code_mode_exec";
export type PluginHookToolInputKind = "javascript" | "typescript";
export type PluginHookToolContext = {
agentId?: string;
sessionKey?: string;
sessionId?: string;
runId?: string;
trace?: DiagnosticTraceContext;
export type PluginHookToolContext = Pick<
PluginHookAgentContext,
| "agentId"
| "sessionKey"
| "sessionId"
| "runId"
| "jobId"
| "trace"
| "trigger"
| "messageProvider"
| "channel"
| "chatId"
| "senderId"
| "channelId"
| "channelContext"
> & {
toolName: string;
/** Host-authoritative discriminator for tools that intentionally share names. */
toolKind?: PluginHookToolKind;
@@ -622,7 +632,6 @@ export type PluginHookToolContext = {
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