Compare commits

..

17 Commits

Author SHA1 Message Date
Peter Steinberger
cda85965bb fix(config): reload changes from included files 2026-06-24 07:23:18 -07:00
ly-wang19
cb13be375d fix(tasks): preserve both cron-run session key shapes during maintenance (#96352)
* fix(tasks): preserve both cron-run session key shapes during maintenance

Session-registry maintenance keeps running cron jobs' session rows, but
readRunningCronJobIds built the preserve-set with job.id.toLowerCase() only.
Cron-run session keys carry two job-segment shapes: main-session runs use the
slugified segment (normalizeCronLaneSegment, e.g. "daily-report") while
default-isolated runs use the raw lowercased id ("daily report", built from
cron:${job.id} via toAgentStoreSessionKey, which lowercases but does not
slugify). The lowercase-only matcher preserved isolated runs but pruned
main-session runs of any non-slug job id (e.g. "Daily Report") as stale.

Preserve both shapes (raw lowercased id and slugified segment). This is
strictly more-preserving, so no live running cron session is dropped. Adds a
regression test seeding both a slug main-session run and a raw isolated run for
a non-slug job id, asserting both survive while a non-running job's run is still
pruned.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(tasks): match cron session keys to target shape

* fix(tasks): preserve active cron aliases across retargeting

* fix(tasks): retain explicit cron session aliases

---------

Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-24 22:10:49 +08:00
Josh Lehman
acc2a0ee72 refactor: route boot session mapping through accessor (#96225) 2026-06-24 06:54:19 -07:00
Gio Della-Libera
704fc35043 Doctor: expose session lock findings (#84366)
Merged via squash.

Prepared head SHA: 93192bb7ab
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Reviewed-by: @giodl73-repo
2026-06-24 06:53:01 -07:00
Ayaan Zaidi
f1e38f2ed6 fix(telegram): narrow rich table alignment surface 2026-06-24 06:41:38 -07:00
zhang-guiping
d2933bbdb9 fix(telegram): refresh rich table SDK budget 2026-06-24 06:41:38 -07:00
张贵萍0668001030
2e124081af fix(telegram): preserve rich table styling 2026-06-24 06:41:38 -07:00
张贵萍0668001030
8150b76b6f fix(telegram): preserve rich table styling 2026-06-24 06:41:38 -07:00
张贵萍0668001030
77eb0fdbaa fix(telegram): preserve rich table styling 2026-06-24 06:41:38 -07:00
ly-wang19
f0be8e7b6e fix(duckduckgo): decode &amp; last in decodeHtmlEntities to avoid double-decoding (#96348)
* fix(duckduckgo): decode &amp; last in decodeHtmlEntities to avoid double-decoding

decodeHtmlEntities decoded &amp; FIRST, so result text that literally contains
an entity (e.g. a page title 'How to escape &lt; in HTML', which DuckDuckGo
returns double-encoded as '&amp;lt;') was re-decoded into markup: '&amp;lt;'
became '<' instead of the literal '&lt;', corrupting the titles, snippets, and
URLs the web-search tool returns to the model.

Reorder so &amp; is decoded last, matching the established convention elsewhere
in the codebase (msteams/inbound.ts, openai-transport-stream.ts,
launchd-plist.ts, doctor-session-snapshots.ts all decode &amp; last).
Behavior-preserving for all singly-encoded input.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(duckduckgo): decode html entities in one pass

---------

Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-24 21:35:27 +08:00
ly-wang19
80bd0003ce fix(msteams): decode &amp; last in stripHtmlFromTeamsMessage to avoid double-decoding (#96342)
stripHtmlFromTeamsMessage decoded &amp; FIRST, so literal entity text the
user typed (which Microsoft Graph returns double-encoded, e.g. &amp;lt;) got
re-decoded into markup: "The token is &amp;lt;APIKEY&amp;gt;" became
"The token is <APIKEY>" instead of the correct "The token is &lt;APIKEY&gt;".

Reorder so &amp; is decoded last, mirroring the documented ordering in
decodeHtmlEntities (inbound.ts), whose comment already states it 'must be last
to prevent double-decoding (e.g. &amp;lt; -> &lt; not <)'. Behavior-preserving
for all singly-encoded input; the existing entity test is unchanged.

Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 21:34:40 +08:00
snowzlmbot
f3891e1335 fix(context-engine): avoid quarantining read-only discovery factories (#96357)
* fix(context-engine): ignore read-only discovery factories

* fix(context-engine): keep discovery registrations out of runtime probes

---------

Co-authored-by: snowzlmbot <snowzlmbot@users.noreply.github.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-24 21:33:49 +08:00
ly-wang19
bea3d292c7 fix(memory-core): keep short protected-glossary terms past the min-length gate (#96304)
PROTECTED_GLOSSARY exists to preserve short technical terms that generic
filtering would discard, but every glossary match still flowed through
normalizeConceptToken's per-script minimum-length gate. The 2-char latin
entries "kv" and "s3" were therefore never emitted as concept tags despite
being on the protect-list. Thread a fromGlossary flag so glossary matches
bypass only that length check; all other gates still apply.

Because that bypass lets short entries through, a bare substring match would
also surface them from inside longer words ("kv" in "mkv", "s3" in "css3").
Match ONLY the short entries (those below their script's min length) as
delimiter-bounded whole tokens; longer entries keep substring containment, so
the shipped behavior of "backup" tagging inside "backups" is preserved. CJK
entries (no word delimiters) always use substring matching. Positive
(standalone kv/s3) and negative (mkv/css3 substrings) regression tests cover
both directions, and the short-term-promotion stable-tags assertion gains "s3".

Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 21:32:58 +08:00
Ayaan Zaidi
17066f2d7c fix(cron): preserve default toolsAllow markers safely 2026-06-24 06:26:52 -07:00
Cameron Beeley
9aea104cc8 fix(cron): stop stamping an unenforceable default toolsAllow cap on CLI runs
#91499 auto-stamps the creator's tool surface as a default toolsAllow cap
on agentTurn cron payloads whenever the creating session is tool-restricted
(a narrowing allow-policy or an explicit deny). CLI backends cannot enforce
a runtime toolsAllow — cli-runner/prepare.ts rejects any defined allow-list
— so every scheduled agentTurn that resolves to a CLI backend (e.g.
claude-cli) fails to start. This silently broke per-thread scheduled
continuations on CLI backends.

A CLI backend is not a runtime tool-policy boundary: it runs with its own
configured tool set, as the operator, on the local machine, and refuses a
runtime allow-list outright. An inherited default cap is therefore
unenforceable on a CLI backend. Decide at run time, where the backend is
known:

- Flag the default. capCronAgentTurnToolsAllow stamps toolsAllowIsDefault
  when it fills in the creator surface because the cron requested nothing
  (or a bare "*"). An explicit narrowing or empty allow-list is a real
  per-cron restriction and carries no flag.
- Drop only the default, only on CLI. The run-executor drops a flagged
  default in the CLI branch and lets the run proceed. An explicit per-cron
  restriction (no flag) is deliberately passed through, so prepare.ts still
  fails it closed and surfaces that the requested policy needs an embedded
  runtime. Embedded runs are untouched and keep the full cap enforced.
- Persist the flag. New nullable cron_jobs.payload_tools_allow_is_default
  column (additive ensureColumn migration + codec read/write) so the
  decision survives a gateway restart, plus toolsAllowIsDefault on the
  gateway-protocol agentTurn payload schema — the stamped payload is
  otherwise rejected by the contract's additionalProperties:false.
- Preserve the flag across updates. A no-toolsAllow update (reschedule,
  prompt edit) no longer carries the stored default forward as a literal
  value — that routed it through the explicit-narrowing branch, stripped the
  flag, and re-broke the job on CLI after the next restart. The default is
  re-derived (flag intact); an explicit restriction is still carried forward
  unflagged.

Net policy: on CLI only the unenforceable inherited default is relaxed;
explicit per-cron restrictions still fail closed; embedded backends are
unchanged.

Tests: run-executor drops the flagged default but propagates an explicit
restriction on CLI; cron-tool stamps/clears the flag across create and
update and preserves it across a no-toolsAllow update; store round-trips the
flag (and its absence) through SQLite.

Not covered: agentTurn crons created during the regression window carry a
flagless toolsAllow and remain fail-closed on CLI until recreated or updated
with an explicit toolsAllow.
2026-06-24 06:26:52 -07:00
Ayaan Zaidi
2aa9d67635 refactor(telegram): simplify rich email entity detection 2026-06-24 06:23:08 -07:00
Kelaw - Keshav's Agent
51eec3a757 fix(telegram): skip rich entity detection for oauth emails 2026-06-24 06:23:08 -07:00
107 changed files with 2607 additions and 1703 deletions

View File

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

View File

@@ -528,13 +528,25 @@ candidate contains redacted secret placeholders such as `***`.
and re-checked, so a path that lexically lives in a config dir but whose
real target escapes every allowed root is still rejected.
- **Error handling**: clear errors for missing files, parse errors, circular includes, invalid path format, and excessive length
- **Hot reload**: edits to regular include files successfully resolved by the
last valid config are watched, including nested includes. Changing an
authored `$include` target inside a watched file re-resolves the graph.
Paths that were missing or invalid during the last successful resolution,
and filesystem or symlink retargets that do not modify a watched file, are
not discovered automatically; edit `openclaw.json` or restart the Gateway
to resolve the graph again.
</Accordion>
</AccordionGroup>
## Config hot reload
The Gateway watches `~/.openclaw/openclaw.json` and applies changes automatically - no manual restart needed for most settings.
The Gateway watches `~/.openclaw/openclaw.json` plus the canonical include files
successfully resolved by the last valid config, and applies changes
automatically - no manual restart needed for most settings. Invalid candidates
keep the last valid watch set. Missing or invalid paths outside that set, plus
filesystem or symlink retargets that do not modify a watched file, require an
`openclaw.json` edit or a Gateway restart before they can be discovered.
Direct file edits are treated as untrusted until they validate. The watcher waits
for editor temp-write/rename churn to settle, reads the final file, and rejects

View File

@@ -186,12 +186,8 @@ file.
- optional `event.runId`
- optional `event.toolCallId`
- context fields such as `ctx.agentId`, `ctx.sessionKey`, `ctx.sessionId`,
`ctx.runId`, `ctx.jobId` (set on cron-driven runs), `ctx.trigger`,
`ctx.toolKind`, `ctx.toolInputKind`, and diagnostic `ctx.trace`
- for channel-originated calls, origin fields such as `ctx.channel`,
`ctx.messageProvider`, `ctx.channelId`, `ctx.chatId`, `ctx.senderId`, and
extensible `ctx.channelContext` sender/chat metadata. These use the same
identity semantics described below for agent hook contexts.
`ctx.runId`, `ctx.jobId` (set on cron-driven runs), `ctx.toolKind`,
`ctx.toolInputKind`, and diagnostic `ctx.trace`
It can return:

View File

@@ -12,11 +12,7 @@ import {
type EmbeddedRunAttemptParams,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
buildApprovalResponse,
handleCodexAppServerApprovalRequest as handleCodexAppServerApprovalRequestImpl,
} from "./approval-bridge.js";
import { buildCodexToolHookRunContext } from "./tool-hook-context.js";
import { buildApprovalResponse, handleCodexAppServerApprovalRequest } from "./approval-bridge.js";
vi.mock("openclaw/plugin-sdk/agent-harness-runtime", async (importOriginal) => ({
...(await importOriginal<typeof import("openclaw/plugin-sdk/agent-harness-runtime")>()),
@@ -46,20 +42,6 @@ const mockResolveNativeHookRelayDeferredToolApproval = vi.mocked(
const mockReviewExecRequestWithConfiguredModel = vi.mocked(reviewExecRequestWithConfiguredModel);
const mockRunBeforeToolCallHook = vi.mocked(runBeforeToolCallHook);
type ApprovalRequestParams = Parameters<typeof handleCodexAppServerApprovalRequestImpl>[0];
function handleCodexAppServerApprovalRequest(
params: Omit<ApprovalRequestParams, "toolHookContext"> & {
toolHookContext?: ApprovalRequestParams["toolHookContext"];
},
) {
return handleCodexAppServerApprovalRequestImpl({
...params,
toolHookContext:
params.toolHookContext ?? buildCodexToolHookRunContext({ attempt: params.paramsForRun }),
});
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`Expected ${label}`);
@@ -261,8 +243,6 @@ describe("Codex app-server approval bridge", () => {
ctx: {
agentId: "main",
sessionKey: "agent:main:session-1",
messageProvider: "telegram",
channel: "telegram",
channelId: "chat-1",
},
});
@@ -1184,18 +1164,11 @@ describe("Codex app-server approval bridge", () => {
});
});
it("uses the caller-resolved hook context for approval fallback policy", async () => {
it("normalizes prefixed channel targets for OpenClaw tool policy context", async () => {
const params = createParams();
params.agentId = "raw-agent";
params.sessionId = "raw-session";
params.sessionKey = "agent:raw:session";
params.runId = "raw-run";
params.messageChannel = "discord";
params.messageProvider = "discord";
params.currentChannelId = "discord:raw-target";
params.jobId = "raw-job";
params.senderId = "raw-user";
params.chatId = "raw-chat";
params.messageChannel = "telegram";
params.messageProvider = "telegram";
params.currentChannelId = "telegram:-100123";
mockCallGatewayTool
.mockResolvedValueOnce({ id: "plugin:approval-prefixed", status: "accepted" })
.mockResolvedValueOnce({ id: "plugin:approval-prefixed", decision: "allow-once" });
@@ -1209,27 +1182,6 @@ describe("Codex app-server approval bridge", () => {
command: "pnpm test extensions/codex/src/app-server",
},
paramsForRun: params,
toolHookContext: {
agentId: "resolved-agent",
sessionId: "resolved-session",
sessionKey: "agent:resolved:session",
runId: "resolved-run",
jobId: "resolved-job",
trigger: "user",
messageProvider: "telegram-voice",
channel: "telegram",
channelId: "-100123",
chatId: "native-chat-1",
senderId: "user-1",
channelContext: {
sender: {
id: "user-1",
displayName: "Ada",
providerUserId: "provider-user-1",
},
chat: { id: "native-chat-1", providerThreadKey: "thread-key-1" },
},
},
threadId: "thread-1",
turnId: "turn-1",
});
@@ -1237,29 +1189,11 @@ describe("Codex app-server approval bridge", () => {
expect(mockRunBeforeToolCallHook).toHaveBeenCalledWith(
expect.objectContaining({
ctx: expect.objectContaining({
agentId: "resolved-agent",
sessionId: "resolved-session",
sessionKey: "agent:resolved:session",
runId: "resolved-run",
jobId: "resolved-job",
trigger: "user",
messageProvider: "telegram-voice",
channel: "telegram",
channelId: "-100123",
chatId: "native-chat-1",
senderId: "user-1",
channelContext: {
sender: {
id: "user-1",
displayName: "Ada",
providerUserId: "provider-user-1",
},
chat: { id: "native-chat-1", providerThreadKey: "thread-key-1" },
},
}),
}),
);
expect(gatewayRequestPayload().turnSourceTo).toBe("discord:raw-target");
expect(gatewayRequestPayload().turnSourceTo).toBe("telegram:-100123");
});
it("denies command approvals before prompting when OpenClaw tool policy blocks", async () => {

View File

@@ -8,6 +8,7 @@ import {
*/
import {
type AgentApprovalEventData,
buildAgentHookContextChannelFields,
formatApprovalDisplayPath,
hasNativeHookRelayInvocation,
invokeNativeHookRelay,
@@ -16,7 +17,6 @@ import {
type NativeHookRelayProcessResponse,
type NativeHookRelayRegistrationHandle,
runBeforeToolCallHook,
type ToolHookRunContext,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
import { normalizeTrimmedStringList } from "openclaw/plugin-sdk/string-coerce-runtime";
@@ -75,7 +75,6 @@ export async function handleCodexAppServerApprovalRequest(params: {
method: string;
requestParams: JsonValue | undefined;
paramsForRun: EmbeddedRunAttemptParams;
toolHookContext: ToolHookRunContext;
threadId: string;
turnId: string;
nativeHookRelay?: Pick<
@@ -107,7 +106,6 @@ export async function handleCodexAppServerApprovalRequest(params: {
method: params.method,
requestParams,
paramsForRun: params.paramsForRun,
toolHookContext: params.toolHookContext,
context,
nativeHookRelay: params.nativeHookRelay,
signal: params.signal,
@@ -621,7 +619,6 @@ async function runOpenClawToolPolicyForApprovalRequest(params: {
method: string;
requestParams: JsonObject | undefined;
paramsForRun: EmbeddedRunAttemptParams;
toolHookContext: ToolHookRunContext;
context: ApprovalContext;
nativeHookRelay?: Pick<
NativeHookRelayRegistrationHandle,
@@ -655,6 +652,13 @@ async function runOpenClawToolPolicyForApprovalRequest(params: {
if (nativeRelayOutcome?.handled) {
return { outcome: "no-decision" };
}
const hookChannelId = buildAgentHookContextChannelFields({
sessionKey: params.paramsForRun.sessionKey,
messageChannel: params.paramsForRun.messageChannel,
messageProvider: params.paramsForRun.messageProvider,
currentChannelId: params.paramsForRun.currentChannelId,
messageTo: params.paramsForRun.messageTo,
}).channelId;
const outcome = await runBeforeToolCallHook({
toolName: policyRequest.toolName,
params: policyRequest.params,
@@ -662,9 +666,13 @@ async function runOpenClawToolPolicyForApprovalRequest(params: {
approvalMode: "request",
signal: params.signal,
ctx: {
...params.toolHookContext,
...(params.paramsForRun.agentId ? { agentId: params.paramsForRun.agentId } : {}),
...(params.paramsForRun.config ? { config: params.paramsForRun.config } : {}),
...(cwd ? { cwd } : {}),
...(params.paramsForRun.sessionKey ? { sessionKey: params.paramsForRun.sessionKey } : {}),
...(params.paramsForRun.sessionId ? { sessionId: params.paramsForRun.sessionId } : {}),
...(params.paramsForRun.runId ? { runId: params.paramsForRun.runId } : {}),
...(hookChannelId ? { channelId: hookChannelId } : {}),
},
});
if (outcome.blocked) {

View File

@@ -944,16 +944,8 @@ describe("Codex app-server dynamic tool build", () => {
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
params.disableTools = false;
params.messageChannel = "discord";
params.messageProvider = "discord-voice";
params.currentChannelId = "discord:D123";
params.currentChannelId = "D123";
params.currentMessagingTarget = "user:U123";
params.chatId = "chat-123";
params.senderId = "user-123";
params.channelContext = {
sender: { id: "user-123" },
chat: { id: "chat-123" },
};
params.runtimePlan = createCodexRuntimePlanFixture();
const factoryOptions: unknown[] = [];
setOpenClawCodingToolsFactoryForTests((options) => {
@@ -964,19 +956,9 @@ describe("Codex app-server dynamic tool build", () => {
await buildDynamicToolsForTest(params, workspaceDir, { sandbox: null as never });
expect(factoryOptions[0]).toMatchObject({
messageChannel: "discord",
messageProvider: "discord",
toolPolicyMessageProvider: "discord-voice",
currentChannelId: "discord:D123",
currentChannelId: "D123",
currentMessagingTarget: "user:U123",
chatId: "chat-123",
senderId: "user-123",
hookChannelContext: {
sender: { id: "user-123" },
chat: { id: "chat-123" },
},
});
expect((factoryOptions[0] as { channelContext?: unknown }).channelContext).toBeUndefined();
});
it("passes the approval reviewer device into Codex dynamic tools", async () => {

View File

@@ -125,7 +125,7 @@ export function resolveCodexAppServerHookChannelId(
messageChannel: params.messageChannel,
messageProvider: params.messageProvider,
currentChannelId: params.currentChannelId,
messageTo: params.currentMessagingTarget ?? params.messageTo,
messageTo: params.messageTo,
}).channelId;
}
@@ -239,7 +239,6 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
elevated: params.bashElevated,
},
sandbox: input.sandbox,
messageChannel: params.messageChannel,
messageProvider: resolveCodexMessageToolProvider(params),
toolPolicyMessageProvider: params.messageProvider ?? params.messageChannel,
agentAccountId: params.agentAccountId,
@@ -250,7 +249,6 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
groupSpace: params.groupSpace,
spawnedBy: params.spawnedBy,
senderId: params.senderId,
hookChannelContext: params.channelContext,
senderName: params.senderName,
senderUsername: params.senderUsername,
senderE164: params.senderE164,
@@ -292,7 +290,6 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
),
suppressManagedWebSearch: false,
currentChannelId: params.currentChannelId,
chatId: params.chatId,
currentMessagingTarget: params.currentMessagingTarget,
hookChannelId: resolveCodexAppServerHookChannelId(params, input.sandboxSessionKey),
currentThreadTs: params.currentThreadTs,

View File

@@ -1846,17 +1846,6 @@ describe("createCodexDynamicToolBridge", () => {
sessionId: "session-1",
sessionKey: "agent:agent-1:session-1",
runId: "run-1",
jobId: "job-1",
trigger: "user",
messageProvider: "discord-voice",
channel: "discord",
chatId: "channel-1",
senderId: "user-1",
channelId: "channel-1",
channelContext: {
sender: { id: "user-1", displayName: "Ada" },
chat: { id: "channel-1" },
},
},
});
@@ -1960,17 +1949,6 @@ describe("createCodexDynamicToolBridge", () => {
sessionId: "session-1",
sessionKey: "agent:agent-1:session-1",
runId: "run-1",
jobId: "job-1",
trigger: "user",
messageProvider: "discord-voice",
channel: "discord",
chatId: "channel-1",
senderId: "user-1",
channelId: "channel-1",
channelContext: {
sender: { id: "user-1", displayName: "Ada" },
chat: { id: "channel-1" },
},
},
});
@@ -1997,17 +1975,6 @@ describe("createCodexDynamicToolBridge", () => {
sessionId: "session-1",
sessionKey: "agent:agent-1:session-1",
runId: "run-1",
jobId: "job-1",
trigger: "user",
messageProvider: "discord-voice",
channel: "discord",
chatId: "channel-1",
senderId: "user-1",
channelId: "channel-1",
channelContext: {
sender: { id: "user-1", displayName: "Ada" },
chat: { id: "channel-1" },
},
toolCallId: "call-1",
});
expectExecuteCall(execute, { callId: "call-1", args: { command: "pwd", mode: "safe" } });
@@ -2030,17 +1997,6 @@ describe("createCodexDynamicToolBridge", () => {
sessionId: "session-1",
sessionKey: "agent:agent-1:session-1",
runId: "run-1",
jobId: "job-1",
trigger: "user",
messageProvider: "discord-voice",
channel: "discord",
chatId: "channel-1",
senderId: "user-1",
channelId: "channel-1",
channelContext: {
sender: { id: "user-1", displayName: "Ada" },
chat: { id: "channel-1" },
},
toolCallId: "call-1",
});
});

View File

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

View File

@@ -2587,36 +2587,15 @@ describe("CodexAppServerEventProjector", () => {
});
});
it("keeps resolved hook identity authoritative for Codex-native tool completions", async () => {
it("emits after_tool_call observations for Codex-native tool item completions", async () => {
const afterToolCall = vi.fn();
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "after_tool_call", handler: afterToolCall }]),
);
const projectorParams = {
const projector = await createProjector({
...(await createParams()),
agentId: "raw-agent",
sessionId: "raw-session",
sessionKey: "agent:raw:session-1",
runId: "raw-run",
};
const projector = await createProjector(projectorParams, {
toolHookContext: {
agentId: "main",
sessionId: "session-1",
sessionKey: "agent:main:session-1",
runId: "run-1",
jobId: "job-1",
trigger: "user",
messageProvider: "discord-voice",
channel: "discord",
chatId: "channel-1",
senderId: "user-1",
channelId: "channel-1",
channelContext: {
sender: { id: "user-1" },
chat: { id: "channel-1" },
},
},
agentId: "main",
sessionKey: "agent:main:session-1",
});
await projector.handleNotification(
@@ -2673,17 +2652,6 @@ describe("CodexAppServerEventProjector", () => {
expect(context.sessionId).toBe("session-1");
expect(context.sessionKey).toBe("agent:main:session-1");
expect(context.runId).toBe("run-1");
expect(context.jobId).toBe("job-1");
expect(context.trigger).toBe("user");
expect(context.messageProvider).toBe("discord-voice");
expect(context.channel).toBe("discord");
expect(context.chatId).toBe("channel-1");
expect(context.senderId).toBe("user-1");
expect(context.channelId).toBe("channel-1");
expect(context.channelContext).toEqual({
sender: { id: "user-1" },
chat: { id: "channel-1" },
});
expect(context.toolName).toBe("bash");
expect(context.toolCallId).toBe("cmd-observed");
});

View File

@@ -18,7 +18,6 @@ import {
type HeartbeatToolResponse,
type MessagingToolSend,
type MessagingToolSourceReplyPayload,
type ToolHookRunContext,
type ToolProgressDetailMode,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { emitTrustedDiagnosticEvent } from "openclaw/plugin-sdk/diagnostic-runtime";
@@ -66,7 +65,6 @@ export type CodexAppServerToolTelemetry = {
export type CodexAppServerEventProjectorOptions = {
nativePostToolUseRelayEnabled?: boolean;
toolHookContext?: ToolHookRunContext;
onNativeToolResultRecorded?: () => void | Promise<void>;
trajectoryRecorder?: CodexTrajectoryRecorder | null;
};
@@ -1376,9 +1374,6 @@ export class CodexAppServerEventProjector {
agentId: this.params.agentId,
sessionId: this.params.sessionId,
sessionKey: this.params.sessionKey,
// The attempt boundary resolves aliases and sandbox session identity once.
// Keep that canonical snapshot authoritative over optional raw projector params.
...this.options.toolHookContext,
startArgs: itemToolArgs(item) ?? {},
...(result !== undefined ? { result } : {}),
...(error ? { error } : {}),

View File

@@ -8,7 +8,6 @@ import {
type EmbeddedRunAttemptParams,
type NativeHookRelayEvent,
type NativeHookRelayRegistrationHandle,
type ToolHookRunContext,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import {
addTimerTimeoutGraceMs,
@@ -122,7 +121,6 @@ export function createCodexNativeHookRelay(params: {
config: EmbeddedRunAttemptParams["config"];
runId: string;
channelId?: string;
toolHookContext?: ToolHookRunContext;
attemptTimeoutMs: number;
startupTimeoutMs: number;
turnStartTimeoutMs: number;
@@ -148,7 +146,6 @@ export function createCodexNativeHookRelay(params: {
...(params.config ? { config: params.config } : {}),
runId: params.runId,
...(params.channelId ? { channelId: params.channelId } : {}),
...(params.toolHookContext ? { toolHookContext: params.toolHookContext } : {}),
allowedEvents: params.events,
ttlMs: resolveCodexNativeHookRelayTtlMs({
explicitTtlMs: params.options?.ttlMs,

View File

@@ -1,6 +1,9 @@
// Codex tests cover run attemptynamic tools plugin behavior.
import path from "node:path";
import { onAgentEvent, type AgentEventPayload } from "openclaw/plugin-sdk/agent-harness-runtime";
import {
onAgentEvent,
type AgentEventPayload,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import {
emitTrustedDiagnosticEvent,
onInternalDiagnosticEvent,
@@ -606,21 +609,6 @@ describe("runCodexAppServerAttempt dynamic tools", () => {
}
});
it("prefers the current messaging target for hook channel fallback", () => {
const params = createParams(
path.join(tempDir, "session.jsonl"),
path.join(tempDir, "workspace"),
);
params.messageChannel = "telegram";
params.messageProvider = "telegram";
params.messageTo = "telegram:stale-target";
params.currentMessagingTarget = "telegram:current-target";
expect(testing.resolveCodexAppServerHookChannelId(params, "agent:main:session-1")).toBe(
"current-target",
);
});
it("passes normalized channel context to app-server dynamic tool result hooks", async () => {
const afterToolCall = vi.fn();
initializeGlobalHookRunner(

View File

@@ -30,7 +30,9 @@ const DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT = JSON.stringify({
web_search: "disabled",
});
function writeCodexAppServerBinding(...args: Parameters<typeof writeRawCodexAppServerBinding>) {
function writeCodexAppServerBinding(
...args: Parameters<typeof writeRawCodexAppServerBinding>
) {
const [sessionFile, binding, lookup] = args;
return writeRawCodexAppServerBinding(
sessionFile,
@@ -93,15 +95,7 @@ describe("runCodexAppServerAttempt native hook relay", () => {
const harness = createStartedThreadHarness();
const params = createParams(sessionFile, workspaceDir);
params.messageChannel = "discord";
params.messageProvider = "discord-voice";
params.currentChannelId = "channel:target";
params.trigger = "user";
params.senderId = "user-1";
params.chatId = "native-target";
params.channelContext = {
sender: { id: "user-1", providerUserId: "discord-user-1" },
chat: { id: "native-target", guildId: "guild-1" },
};
const run = runCodexAppServerAttempt(params, {
nativeHookRelay: {
@@ -141,22 +135,6 @@ describe("runCodexAppServerAttempt native hook relay", () => {
threadId: "thread-1",
turnId: "turn-1",
autoApprove: true,
toolHookContext: {
agentId: "main",
sessionId: "session-1",
sessionKey: "agent:main:session-1",
runId: "run-1",
trigger: "user",
messageProvider: "discord-voice",
channel: "discord",
channelId: "target",
chatId: "native-target",
senderId: "user-1",
channelContext: {
sender: { id: "user-1", providerUserId: "discord-user-1" },
chat: { id: "native-target", guildId: "guild-1" },
},
},
});
expect(approvalArgs?.nativeHookRelay).toMatchObject({
relayId,

View File

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

View File

@@ -861,12 +861,9 @@ describe("runCodexAppServerSideQuestion", () => {
).toMatchObject({
agentId: "main",
sessionId: "session-1",
sessionKey: "agent:main:runtime-policy",
sessionKey: "agent:main:session-1",
runId: "run-side-1",
channelId: "voice-room",
toolHookContext: {
sessionKey: "agent:main:runtime-policy",
},
allowedEvents: ["pre_tool_use", "post_tool_use", "before_agent_finalize"],
});
return threadResult("side-thread");
@@ -892,7 +889,6 @@ describe("runCodexAppServerSideQuestion", () => {
runCodexAppServerSideQuestion(
sideParams({
sessionKey: "agent:main:session-1",
sandboxSessionKey: "agent:main:runtime-policy",
messageChannel: "discord",
messageProvider: "discord-voice",
currentChannelId: "discord:voice-room",
@@ -975,7 +971,6 @@ describe("runCodexAppServerSideQuestion", () => {
runCodexAppServerSideQuestion(
sideParams({
sessionKey: "agent:main:session-1",
sandboxSessionKey: "agent:main:runtime-policy",
messageChannel: "discord",
messageProvider: "discord-voice",
opts: { runId: "run-side-approval" },
@@ -993,7 +988,6 @@ describe("runCodexAppServerSideQuestion", () => {
threadId?: string;
turnId?: string;
paramsForRun?: { messageChannel?: string; messageProvider?: string };
toolHookContext?: { sessionKey?: string };
nativeHookRelay?: { relayId?: string; allowedEvents?: readonly string[] };
}
| undefined;
@@ -1013,9 +1007,6 @@ describe("runCodexAppServerSideQuestion", () => {
messageChannel: "discord",
messageProvider: "discord-voice",
},
toolHookContext: {
sessionKey: "agent:main:runtime-policy",
},
});
expect(approvalArgs?.nativeHookRelay).toMatchObject({
relayId: relayIdDuringFork,
@@ -1491,14 +1482,6 @@ describe("runCodexAppServerSideQuestion", () => {
});
it("bridges side-thread dynamic tool requests to OpenClaw tools", async () => {
const beforeToolCall = vi.fn();
const afterToolCall = vi.fn();
initializeGlobalHookRunner(
createMockPluginRegistry([
{ hookName: "before_tool_call", handler: beforeToolCall },
{ hookName: "after_tool_call", handler: afterToolCall },
]),
);
const client = createFakeClient();
let toolResponse: unknown;
client.request.mockImplementation(async (method: string) => {
@@ -1544,13 +1527,6 @@ describe("runCodexAppServerSideQuestion", () => {
expect(toolArguments).toEqual({ topic: "AGENTS.md" });
expect(toolSignal).toBeInstanceOf(AbortSignal);
expect(toolOptions).toBeUndefined();
expect(beforeToolCall).toHaveBeenCalledTimes(1);
expect(mockCall(beforeToolCall)[1]).toMatchObject({ sessionKey: "session-1" });
await vi.waitFor(() => expect(afterToolCall).toHaveBeenCalledTimes(1));
expect(mockCall(afterToolCall)[1]).toMatchObject({ sessionKey: "session-1" });
expect(createOpenClawCodingToolsMock).toHaveBeenCalledWith(
expect.objectContaining({ sessionKey: "session-1" }),
);
expect(toolResponse).toEqual({
success: true,
contentItems: [{ type: "inputText", text: "tool output" }],
@@ -1634,29 +1610,14 @@ describe("runCodexAppServerSideQuestion", () => {
expect(activeDiagnosticToolKeys(diagnosticEvents)).toEqual(new Set());
});
it("preserves requester identity while normalizing side-thread hook channels", async () => {
const afterToolCall = vi.fn();
it("normalizes hook channel ids for side-thread dynamic tool requests", async () => {
const beforeToolCall = vi.fn((...args: unknown[]) => {
const context = args[1] as Record<string, unknown>;
expect(context).toMatchObject({
sessionKey: "agent:main:runtime-policy",
messageProvider: "discord-voice",
channel: "discord",
channelId: "voice-room",
chatId: "native-voice-chat",
senderId: "sender-1",
channelContext: {
sender: { id: "sender-1", providerUserId: "discord-user-1" },
chat: { id: "native-voice-chat", guildId: "guild-1" },
},
});
const context = args[1] as { channelId?: string };
expect(context.channelId).toBe("voice-room");
return undefined;
});
initializeGlobalHookRunner(
createMockPluginRegistry([
{ hookName: "before_tool_call", handler: beforeToolCall },
{ hookName: "after_tool_call", handler: afterToolCall },
]),
createMockPluginRegistry([{ hookName: "before_tool_call", handler: beforeToolCall }]),
);
const client = createFakeClient();
client.request.mockImplementation(async (method: string) => {
@@ -1696,48 +1657,17 @@ describe("runCodexAppServerSideQuestion", () => {
await expect(
runCodexAppServerSideQuestion(
sideParams({
sessionKey: "agent:main:conversation",
sandboxSessionKey: "agent:main:runtime-policy",
messageChannel: "discord",
messageProvider: "discord-voice",
currentChannelId: "discord:voice-room",
chatId: "native-voice-chat",
senderId: "sender-1",
channelContext: {
sender: { id: "sender-1", providerUserId: "discord-user-1" },
chat: { id: "native-voice-chat", guildId: "guild-1" },
},
}),
),
).resolves.toEqual({ text: "Tool answer." });
expect(beforeToolCall).toHaveBeenCalledTimes(1);
await vi.waitFor(() => expect(afterToolCall).toHaveBeenCalledTimes(1));
expect(mockCall(afterToolCall)[1]).toMatchObject({
sessionKey: "agent:main:runtime-policy",
messageProvider: "discord-voice",
channel: "discord",
channelId: "voice-room",
chatId: "native-voice-chat",
});
expect(createOpenClawCodingToolsMock).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: "agent:main:runtime-policy",
runSessionKey: "agent:main:conversation",
messageChannel: "discord",
messageProvider: "discord",
toolPolicyMessageProvider: "discord-voice",
hookChannelId: "voice-room",
chatId: "native-voice-chat",
hookChannelContext: {
sender: { id: "sender-1", providerUserId: "discord-user-1" },
chat: { id: "native-voice-chat", guildId: "guild-1" },
},
}),
expect.objectContaining({ hookChannelId: "voice-room" }),
);
expect(
(mockCall(createOpenClawCodingToolsMock)[0] as { channelContext?: unknown }).channelContext,
).toBeUndefined();
expect(toolExecuteMock).toHaveBeenCalledTimes(1);
});

View File

@@ -1,5 +1,6 @@
// Codex plugin module implements side question behavior.
import {
buildAgentHookContextChannelFields,
embeddedAgentLog,
formatErrorMessage,
resolveAgentDir,
@@ -15,7 +16,6 @@ import {
type EmbeddedRunAttemptParams,
type NativeHookRelayEvent,
type NativeHookRelayRegistrationHandle,
type ToolHookRunContext,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { loadExecApprovals } from "openclaw/plugin-sdk/exec-approvals-runtime";
import { resolveCodexAppServerForModelProvider } from "./app-server-policy.js";
@@ -89,7 +89,6 @@ import {
resolveCodexBindingModelProviderFallback,
resolveReasoningEffort,
} from "./thread-lifecycle.js";
import { buildCodexToolHookRunContext } from "./tool-hook-context.js";
import { filterToolsForVisionInputs } from "./vision-tools.js";
import {
resolveCodexWebSearchPlan,
@@ -207,21 +206,9 @@ export async function runCodexAppServerSideQuestion(
});
const cwd = binding.cwd || params.workspaceDir || process.cwd();
const sideRunParams = buildSideRunAttemptParams(params, { cwd, authProfileId });
const toolHookSessionKey =
sideRunParams.sandboxSessionKey?.trim() ||
sideRunParams.sessionKey?.trim() ||
sideRunParams.sessionId ||
sessionAgentId;
const toolHookRunContext = buildCodexToolHookRunContext({
attempt: sideRunParams,
agentId: sessionAgentId,
sessionId: sideRunParams.sessionId,
sessionKey: toolHookSessionKey,
runId: sideRunParams.runId,
});
const nativeExecutionBlock = resolveCodexNativeExecutionBlock({
config: sideRunParams.config,
sessionKey: toolHookSessionKey,
sessionKey: sideRunParams.sandboxSessionKey?.trim() || sideRunParams.sessionKey,
sessionId: sideRunParams.sessionId,
surface: "/btw side-question mode",
});
@@ -300,7 +287,6 @@ export async function runCodexAppServerSideQuestion(
nativeToolSurfaceEnabled,
nativeProviderWebSearchSupport,
signal: runAbortController.signal,
toolHookContext: toolHookRunContext,
});
removeRequestHandler = client.addRequestHandler(async (request) => {
if (request.method === "account/chatgptAuthTokens/refresh") {
@@ -333,20 +319,19 @@ export async function runCodexAppServerSideQuestion(
method: request.method,
requestParams: request.params,
paramsForRun: sideRunParams,
toolHookContext: toolHookRunContext,
threadId: childThreadId,
turnId,
nativeHookRelay,
execPolicy,
execReviewerAgentId: sessionAgentId,
internalExecAutoReview: modelScopedAppServer.approvalsReviewer === "user",
autoApprove: shouldAutoApproveCodexAppServerApprovals({
approvalPolicy,
networkProxy: modelScopedAppServer.networkProxy,
sandbox,
}),
signal: runAbortController.signal,
});
execPolicy,
execReviewerAgentId: sessionAgentId,
internalExecAutoReview: modelScopedAppServer.approvalsReviewer === "user",
autoApprove: shouldAutoApproveCodexAppServerApprovals({
approvalPolicy,
networkProxy: modelScopedAppServer.networkProxy,
sandbox,
}),
signal: runAbortController.signal,
});
}
if (request.method !== "item/tool/call") {
return undefined;
@@ -403,11 +388,15 @@ export async function runCodexAppServerSideQuestion(
events: nativeHookRelayEvents,
agentId: sessionAgentId,
sessionId: params.sessionId,
sessionKey: toolHookRunContext.sessionKey,
sessionKey: params.sessionKey,
config: params.cfg,
runId: sideRunParams.runId,
channelId: toolHookRunContext.channelId,
toolHookContext: toolHookRunContext,
channelId: buildAgentHookContextChannelFields({
sessionKey: params.sessionKey,
messageChannel: params.messageChannel,
messageProvider: params.messageProvider,
currentChannelId: params.currentChannelId,
}).channelId,
requestTimeoutMs: appServer.requestTimeoutMs,
completionTimeoutMs: Math.max(
appServer.turnCompletionIdleTimeoutMs,
@@ -430,12 +419,12 @@ export async function runCodexAppServerSideQuestion(
nativeCodeModeEnabled: nativeToolSurfaceEnabled,
nativeCodeModeOnlyEnabled: appServer.codeModeOnly,
});
const threadConfig =
mergeCodexThreadConfigs(
nativeHookRelayConfig,
runtimeThreadConfig,
modelScopedAppServer.networkProxy?.configPatch,
) ?? runtimeThreadConfig;
const threadConfig =
mergeCodexThreadConfigs(
nativeHookRelayConfig,
runtimeThreadConfig,
modelScopedAppServer.networkProxy?.configPatch,
) ?? runtimeThreadConfig;
const forkResponse = assertCodexThreadForkResponse(
await forkCodexSideThread(
client,
@@ -447,7 +436,7 @@ export async function runCodexAppServerSideQuestion(
cwd,
approvalPolicy,
approvalsReviewer: modelScopedAppServer.approvalsReviewer,
...(modelScopedAppServer.networkProxy ? {} : { sandbox }),
...(modelScopedAppServer.networkProxy ? {} : { sandbox }),
...(serviceTier ? { serviceTier } : {}),
config: threadConfig,
developerInstructions: SIDE_DEVELOPER_INSTRUCTIONS,
@@ -553,7 +542,6 @@ function registerCodexSideNativeHookRelay(params: {
config: EmbeddedRunAttemptParams["config"];
runId: string;
channelId?: string;
toolHookContext?: ToolHookRunContext;
requestTimeoutMs: number;
completionTimeoutMs: number;
signal: AbortSignal;
@@ -569,7 +557,6 @@ function registerCodexSideNativeHookRelay(params: {
...(params.config ? { config: params.config } : {}),
runId: params.runId,
...(params.channelId ? { channelId: params.channelId } : {}),
...(params.toolHookContext ? { toolHookContext: params.toolHookContext } : {}),
allowedEvents: params.events,
ttlMs: resolveCodexSideNativeHookRelayTtlMs({
explicitTtlMs: params.options.ttlMs,
@@ -609,7 +596,6 @@ function buildSideRunAttemptParams(
provider: params.provider,
modelId: params.model,
model: params.runtimeModel ?? ({ id: params.model, provider: params.provider } as never),
trigger: "user" as const,
sessionId: params.sessionId,
sessionFile: params.sessionFile,
sessionKey: params.sessionKey,
@@ -630,8 +616,6 @@ function buildSideRunAttemptParams(
...(params.senderUsername !== undefined ? { senderUsername: params.senderUsername } : {}),
...(params.senderE164 !== undefined ? { senderE164: params.senderE164 } : {}),
...(params.senderIsOwner !== undefined ? { senderIsOwner: params.senderIsOwner } : {}),
...(params.chatId ? { chatId: params.chatId } : {}),
...(params.channelContext ? { channelContext: params.channelContext } : {}),
...(params.currentChannelId ? { currentChannelId: params.currentChannelId } : {}),
...(params.toolsAllow ? { toolsAllow: params.toolsAllow } : {}),
workspaceDir: options.cwd,
@@ -663,7 +647,6 @@ async function createCodexSideToolBridge(input: {
nativeToolSurfaceEnabled: boolean;
nativeProviderWebSearchSupport: CodexNativeWebSearchSupport;
signal: AbortSignal;
toolHookContext: ToolHookRunContext;
}): Promise<{ toolBridge: CodexDynamicToolBridge; webSearchPlan: CodexWebSearchPlan }> {
const runtimeModel =
input.params.runtimeModel ??
@@ -674,7 +657,10 @@ async function createCodexSideToolBridge(input: {
const createOpenClawCodingTools = (await import("openclaw/plugin-sdk/agent-harness"))
.createOpenClawCodingTools;
const sandboxSessionKey =
input.toolHookContext.sessionKey || input.params.sessionId || input.sessionAgentId;
input.params.sandboxSessionKey?.trim() ||
input.params.sessionKey?.trim() ||
input.params.sessionId ||
input.sessionAgentId;
const sandbox = await resolveSandboxContext({
config: input.params.cfg,
sessionKey: sandboxSessionKey,
@@ -710,9 +696,6 @@ async function createCodexSideToolBridge(input: {
workspaceDir: input.cwd,
}),
suppressManagedWebSearch: false,
trigger: input.toolHookContext.trigger,
jobId: input.toolHookContext.jobId,
messageChannel: input.params.messageChannel,
...(input.params.messageProvider || input.params.messageChannel
? {
messageProvider: messageToolProvider,
@@ -732,8 +715,6 @@ async function createCodexSideToolBridge(input: {
...(input.params.memberRoleIds ? { memberRoleIds: input.params.memberRoleIds } : {}),
...(input.params.spawnedBy !== undefined ? { spawnedBy: input.params.spawnedBy } : {}),
...(input.params.senderId !== undefined ? { senderId: input.params.senderId } : {}),
chatId: input.toolHookContext.chatId,
hookChannelContext: input.toolHookContext.channelContext,
...(input.params.senderName !== undefined ? { senderName: input.params.senderName } : {}),
...(input.params.senderUsername !== undefined
? { senderUsername: input.params.senderUsername }
@@ -743,7 +724,12 @@ async function createCodexSideToolBridge(input: {
? { senderIsOwner: input.params.senderIsOwner }
: {}),
...(input.params.currentChannelId ? { currentChannelId: input.params.currentChannelId } : {}),
hookChannelId: input.toolHookContext.channelId,
hookChannelId: buildAgentHookContextChannelFields({
sessionKey: input.params.sessionKey,
messageChannel: input.params.messageChannel,
messageProvider: input.params.messageProvider,
currentChannelId: input.params.currentChannelId,
}).channelId,
sandbox,
emitBeforeToolCallDiagnostics: false,
modelHasVision: runtimeModel.input?.includes("image") ?? false,
@@ -771,15 +757,25 @@ async function createCodexSideToolBridge(input: {
})
: requestedWebSearchPlan;
const exposedTools = tools.filter((tool) => tool.name !== "web_search");
const hookChannelFields = buildAgentHookContextChannelFields({
sessionKey: input.params.sessionKey,
messageChannel: input.params.messageChannel,
messageProvider: input.params.messageProvider,
currentChannelId: input.params.currentChannelId,
});
return {
toolBridge: createCodexDynamicToolBridge({
tools: exposedTools,
signal: input.signal,
loading: resolveCodexDynamicToolsLoading(input.pluginConfig),
hookContext: {
...input.toolHookContext,
agentId: input.sessionAgentId,
config: input.params.cfg,
sessionId: input.params.sessionId,
sessionKey: input.params.sessionKey,
runId: input.params.opts?.runId ?? `codex-btw:${input.params.sessionId}`,
currentChannelProvider: messageToolProvider,
...hookChannelFields,
},
}),
webSearchPlan,

View File

@@ -1,41 +0,0 @@
/** Builds one canonical requester-origin snapshot for Codex tool hook paths. */
import {
buildAgentHookContextOriginFields,
type EmbeddedRunAttemptParams,
type ToolHookRunContext,
} from "openclaw/plugin-sdk/agent-harness-runtime";
/** Build the plain run metadata shared by Codex before/after tool hook owners. */
export function buildCodexToolHookRunContext(params: {
attempt: EmbeddedRunAttemptParams;
agentId?: string;
sessionId?: string;
sessionKey?: string;
runId?: string;
channelId?: string;
}): ToolHookRunContext {
const attempt = params.attempt;
const agentId = params.agentId ?? attempt.agentId;
const sessionKey = params.sessionKey ?? attempt.sessionKey;
const sessionId = params.sessionId ?? attempt.sessionId;
const runId = params.runId ?? attempt.runId;
return {
...(agentId ? { agentId } : {}),
...(sessionKey ? { sessionKey } : {}),
...(sessionId ? { sessionId } : {}),
...(runId ? { runId } : {}),
...(attempt.jobId ? { jobId: attempt.jobId } : {}),
...(attempt.trigger ? { trigger: attempt.trigger } : {}),
...buildAgentHookContextOriginFields({
sessionKey,
messageChannel: attempt.messageChannel,
messageProvider: attempt.messageProvider ?? attempt.messageChannel,
currentChannelId: params.channelId ?? attempt.currentChannelId,
messageTo: attempt.currentMessagingTarget ?? attempt.messageTo,
trigger: attempt.trigger,
senderId: attempt.senderId,
chatId: attempt.chatId,
channelContext: attempt.channelContext,
}),
};
}

View File

@@ -340,22 +340,7 @@ describe("runCopilotAttempt", () => {
return { sdkTools: [], sourceTools: [] };
});
const params = makeParams();
Object.assign(params, {
jobId: "job-1",
trigger: "user",
messageChannel: "slack",
messageProvider: "slack-voice",
currentChannelId: "C123",
chatId: "C123",
senderId: "U123",
channelContext: {
sender: { id: "U123", displayName: "Ada" },
chat: { id: "C123" },
},
});
await runCopilotAttempt(params, {
await runCopilotAttempt(makeParams(), {
createToolBridge,
pool: makeFakePool(sdk),
});
@@ -402,21 +387,7 @@ describe("runCopilotAttempt", () => {
toolCallId: "tool-call-1",
toolName: "read",
}),
expect.objectContaining({
agentId: "agent-1",
sessionId: "session-1",
jobId: "job-1",
trigger: "user",
messageProvider: "slack-voice",
channel: "slack",
chatId: "C123",
senderId: "U123",
channelId: "C123",
channelContext: {
sender: { id: "U123", displayName: "Ada" },
chat: { id: "C123" },
},
}),
expect.objectContaining({ agentId: "agent-1", sessionId: "session-1" }),
);
});
@@ -1316,32 +1287,6 @@ describe("runCopilotAttempt", () => {
).toBe(sdkTools);
});
it("passes the session-resolved agent id to the tool bridge", async () => {
const sdk = makeFakeSdk();
const pool = makeFakePool(sdk);
const createToolBridge = vi.fn(async () => ({ sdkTools: [], sourceTools: [] }));
await runCopilotAttempt(
makeParams({
agentId: undefined,
sessionKey: "agent:beta:main",
config: {
agents: {
list: [{ id: "main" }, { id: "beta" }],
},
} as never,
}),
{ createToolBridge, pool },
);
expect(createToolBridge).toHaveBeenCalledWith(
expect.objectContaining({
agentId: "beta",
sessionKey: "agent:beta:main",
}),
);
});
it("F6: sessionRef is populated after createSession so the tool bridge's onYield can abort the live SDK session", async () => {
const sdk = makeFakeSdk();
const pool = makeFakePool(sdk);

View File

@@ -9,7 +9,6 @@ import type {
} from "openclaw/plugin-sdk/agent-harness-runtime";
import {
buildAgentHookContextChannelFields,
buildAgentHookContextOriginFields,
detectAndLoadAgentHarnessPromptImages,
getModelProviderRequestTransport,
resolveAgentHarnessBeforePromptBuildResult,
@@ -410,25 +409,6 @@ export async function runCopilotAttempt(
...hookContextWindowFields,
...buildAgentHookContextChannelFields(input),
};
const toolHookRunContext = {
runId: input.runId,
jobId: input.jobId,
agentId: sessionAgentId,
sessionKey: sandboxSessionKey,
sessionId: input.sessionId,
trigger: input.trigger,
...buildAgentHookContextOriginFields({
sessionKey: sandboxSessionKey,
messageChannel: input.messageChannel,
messageProvider: input.messageProvider ?? input.messageChannel,
currentChannelId: input.currentChannelId,
messageTo: input.currentMessagingTarget ?? input.messageTo,
trigger: input.trigger,
senderId: input.senderId,
chatId: input.chatId,
channelContext: input.channelContext,
}),
};
const finishAttempt = (result: AgentHarnessAttemptResult) =>
finalizeCopilotAttempt(input, result, hookContext, attemptStartedAt, now);
@@ -646,7 +626,7 @@ export async function runCopilotAttempt(
allowModelTools: poolAcquire.provider.mode === "byok",
modelProvider: modelRef.provider,
modelId: modelRef.id,
agentId: sessionAgentId,
agentId: readString(params.agentId) ?? "copilot",
sessionId: readString(input.sessionId) ?? "copilot-session",
sessionKey: readString((input as { sessionKey?: unknown }).sessionKey),
agentDir: readString(input.agentDir),
@@ -672,7 +652,11 @@ export async function runCopilotAttempt(
runAgentHarnessAfterToolCallHook({
toolName,
toolCallId,
...toolHookRunContext,
runId: input.runId,
agentId: sessionAgentId,
sessionId: input.sessionId,
sessionKey: sandboxSessionKey,
channelId: hookContext.channelId,
startArgs: args,
...(result !== undefined ? { result } : {}),
...(error ? { error } : {}),

View File

@@ -1,17 +1,11 @@
// Copilot tests cover tool bridge plugin behavior.
import type { Tool as SdkTool, ToolInvocation, ToolResultObject } from "@github/copilot-sdk";
import type { AnyAgentTool, SandboxContext } from "openclaw/plugin-sdk/agent-harness-runtime";
import {
initializeGlobalHookRunner,
resetGlobalHookRunner,
} from "openclaw/plugin-sdk/hook-runtime";
import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createCopilotToolBridge,
convertOpenClawToolToSdkTool,
supportsModelTools,
testing,
} from "./tool-bridge.js";
type FakeTool = AnyAgentTool & {
@@ -83,7 +77,6 @@ function runSdkTool(tool: SdkTool, args: unknown, invocation = makeInvocation())
}
afterEach(() => {
resetGlobalHookRunner();
vi.restoreAllMocks();
});
@@ -316,79 +309,6 @@ describe("createCopilotToolBridge", () => {
expect(result.sdkTools.map((tool) => tool.name)).toEqual(["exec", "wait"]);
});
it("runs requester-aware policy before code-mode exec controls", async () => {
const beforeToolCall = vi.fn(() => ({
block: true,
blockReason: "blocked before code-mode execution",
}));
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "before_tool_call", handler: beforeToolCall }]),
);
const createOpenClawCodingTools = vi.fn(async () => [makeTool({ name: "read" })]);
const result = await createCopilotToolBridge({
agentId: "agent-1",
attemptParams: {
config: { tools: { codeMode: true } },
runId: "run-code-mode",
sessionId: "session-1",
sessionKey: "agent:main:main",
jobId: "job-1",
trigger: "user",
messageChannel: "slack",
messageProvider: "slack-voice",
currentChannelId: "slack:C123",
senderId: "U123",
channelContext: { sender: { id: "U123", displayName: "Ada" } },
} as never,
createOpenClawCodingTools,
modelId: "gpt-4o",
modelProvider: "github-copilot",
sessionId: "session-1",
});
const exec = result.sdkTools.find((tool) => tool.name === "exec");
if (!exec) {
throw new Error("missing code-mode exec control");
}
await runSdkTool(
exec,
{ code: "return 1;" },
makeInvocation({ toolCallId: "code-call-1", toolName: "exec" }),
);
expect(beforeToolCall).toHaveBeenCalledTimes(1);
expect(beforeToolCall).toHaveBeenCalledWith(
{
toolName: "exec",
params: { code: "return 1;", command: "return 1;" },
toolKind: "code_mode_exec",
toolInputKind: "javascript",
runId: "run-code-mode",
toolCallId: "code-call-1",
},
{
toolName: "exec",
toolKind: "code_mode_exec",
toolInputKind: "javascript",
agentId: "agent-1",
sessionKey: "agent:main:main",
sessionId: "session-1",
runId: "run-code-mode",
jobId: "job-1",
trigger: "user",
messageProvider: "slack-voice",
channel: "slack",
senderId: "U123",
toolCallId: "code-call-1",
channelId: "C123",
channelContext: {
sender: { id: "U123", displayName: "Ada" },
},
},
);
});
it("keeps code-mode controls visible when a narrow allowlist is active", async () => {
const createOpenClawCodingTools = vi.fn(async () => [
makeTool({ name: "fake_hidden" }),
@@ -523,13 +443,7 @@ describe("createCopilotToolBridge", () => {
currentMessagingTarget: "user:U123",
currentThreadTs: "1700000000.000100",
currentMessageId: "M-1",
messageChannel: "slack",
messageProvider: "slack-voice",
chatId: "chat-1",
channelContext: {
sender: { id: "sender-1", displayName: "Ada" },
chat: { id: "chat-1", kind: "channel" },
},
messageProvider: "slack",
messageTo: "U-1",
messageThreadId: "1700000000.000100",
replyToMode: "first",
@@ -563,13 +477,7 @@ describe("createCopilotToolBridge", () => {
currentMessagingTarget: "user:U123",
currentThreadTs: "1700000000.000100",
currentMessageId: "M-1",
messageChannel: "slack",
messageProvider: "slack-voice",
chatId: "chat-1",
hookChannelContext: {
sender: { id: "sender-1", displayName: "Ada" },
chat: { id: "chat-1", kind: "channel" },
},
messageProvider: "slack",
messageTo: "U-1",
messageThreadId: "1700000000.000100",
replyToMode: "first",
@@ -577,7 +485,6 @@ describe("createCopilotToolBridge", () => {
forceMessageTool: true,
enableHeartbeatTool: true,
});
expect(opts.channelContext).toBeUndefined();
});
it("falls back messageProvider to attemptParams.messageChannel when messageProvider is absent (codex parity)", async () => {
@@ -595,63 +502,6 @@ describe("createCopilotToolBridge", () => {
expect(getOpts().messageProvider).toBe("telegram");
});
it("uses messageTo when currentMessagingTarget is absent in tool hook routing", () => {
const context = testing.buildCopilotToolHookContext({
agentId: "agent-1",
messageChannel: "slack",
messageProvider: "slack",
messageTo: "user:U-only",
trigger: "user",
});
expect(context).toMatchObject({
channel: "slack",
messageProvider: "slack",
channelId: "U-only",
turnSourceChannel: "slack",
turnSourceTo: "user:U-only",
});
expect(context.chatId).toBeUndefined();
expect(context.channelContext).toBeUndefined();
});
it("resolves per-agent loop detection overrides for generated code-mode controls", () => {
const context = testing.buildCopilotToolHookContext({
agentId: "agent-1",
config: {
tools: {
loopDetection: {
enabled: true,
warningThreshold: 7,
detectors: { genericRepeat: true },
postCompactionGuard: { windowSize: 4 },
},
},
agents: {
list: [
{
id: "agent-1",
tools: {
loopDetection: {
enabled: false,
detectors: { pingPong: false },
postCompactionGuard: { windowSize: 2 },
},
},
},
],
},
},
});
expect(context.loopDetection).toEqual({
enabled: false,
warningThreshold: 7,
detectors: { genericRepeat: true, pingPong: false },
postCompactionGuard: { windowSize: 2 },
});
});
it("forwards authProfileStore, runId, config, and run hooks (onToolOutcome) from attemptParams", async () => {
const { createOpenClawCodingTools, getOpts } = captureCall();
const authProfileStore = { kind: "fake-store" } as never;

View File

@@ -7,19 +7,15 @@ import type {
} from "openclaw/plugin-sdk/agent-harness-runtime";
import {
applyEmbeddedAttemptToolsAllow,
buildAgentHookContextOriginFields,
buildEmbeddedAttemptToolRunContext,
extractToolErrorMessage,
getPluginToolMeta,
isSubagentSessionKey,
isToolWrappedWithBeforeToolCallHook,
isToolResultError,
resolveAttemptSpawnWorkspaceDir,
resolveEmbeddedAttemptToolConstructionPlan,
resolveModelAuthMode,
resolveToolLoopDetectionConfig,
sanitizeToolResult,
wrapToolWithBeforeToolCallHook,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { createAgentHarnessToolSurfaceRuntime } from "openclaw/plugin-sdk/agent-harness-tool-runtime";
@@ -148,7 +144,6 @@ export interface CopilotToolBridge {
export const SUPPORTED_TOOL_PROVIDERS: ReadonlySet<string> = new Set(["github-copilot"]);
const BASE_COPILOT_CODING_TOOL_NAMES = new Set(["edit", "read", "write"]);
const SHELL_COPILOT_CODING_TOOL_NAMES = new Set(["apply_patch", "exec", "process"]);
const CODE_MODE_CONTROL_TOOL_NAMES = new Set(["exec", "wait"]);
export function supportsModelTools(modelProvider: string): boolean {
return SUPPORTED_TOOL_PROVIDERS.has(modelProvider);
@@ -215,7 +210,6 @@ export async function createCopilotToolBridge(
},
toolSurfaceRuntime,
);
const toolHookContext = buildCopilotToolHookContext(toolOptions);
let sourceTools: unknown;
try {
@@ -237,18 +231,9 @@ export async function createCopilotToolBridge(
sourceTools as AnyAgentTool[],
toolSurfaceRuntime.runtimeToolAllowlist,
);
const compactedTools = toolSurfaceRuntime.compactTools(allowedSourceTools, {
hookContext: toolHookContext,
});
const hookedCompactedTools = compactedTools.tools.map((tool) =>
!toolSurfaceRuntime.codeModeControlsEnabled ||
!CODE_MODE_CONTROL_TOOL_NAMES.has(tool.name) ||
isToolWrappedWithBeforeToolCallHook(tool)
? tool
: wrapToolWithBeforeToolCallHook(tool, toolHookContext),
);
const compactedTools = toolSurfaceRuntime.compactTools(allowedSourceTools);
const plannedTools = filterCopilotToolsForConstructionPlan(
hookedCompactedTools,
compactedTools.tools,
effectiveToolPlan.codingToolConstructionPlan,
{ preserveToolNames: toolSurfaceRuntime.runtimeToolAllowlist },
);
@@ -279,51 +264,6 @@ export async function createCopilotToolBridge(
};
}
function buildCopilotToolHookContext(toolOptions: OpenClawCodingToolsOptions) {
const turnSourceChannel = toolOptions.messageChannel ?? toolOptions.messageProvider;
const messageTo = toolOptions.currentMessagingTarget ?? toolOptions.messageTo;
const turnSourceTo = messageTo ?? toolOptions.currentChannelId;
return {
agentId: toolOptions.agentId,
config: toolOptions.config,
cwd: toolOptions.cwd,
workspaceDir: toolOptions.workspaceDir,
sessionKey: toolOptions.sessionKey,
sessionId: toolOptions.sessionId,
runId: toolOptions.runId,
jobId: toolOptions.jobId,
trace: toolOptions.trace,
trigger: toolOptions.trigger,
...buildAgentHookContextOriginFields({
sessionKey: toolOptions.sessionKey,
messageChannel: toolOptions.messageChannel,
messageProvider: toolOptions.toolPolicyMessageProvider ?? toolOptions.messageProvider,
currentChannelId: toolOptions.hookChannelId ?? toolOptions.currentChannelId,
messageTo,
trigger: toolOptions.trigger,
senderId: toolOptions.senderId,
chatId: toolOptions.chatId,
channelContext: toolOptions.hookChannelContext ?? toolOptions.channelContext,
}),
...(turnSourceChannel ? { turnSourceChannel } : {}),
...(turnSourceTo ? { turnSourceTo } : {}),
...(toolOptions.agentAccountId ? { turnSourceAccountId: toolOptions.agentAccountId } : {}),
...(toolOptions.currentThreadTs ? { turnSourceThreadId: toolOptions.currentThreadTs } : {}),
loopDetection: resolveToolLoopDetectionConfig({
cfg: toolOptions.config,
agentId: toolOptions.agentId,
}),
onToolOutcome: toolOptions.onToolOutcome,
allocateToolOutcomeOrdinal: toolOptions.allocateToolOutcomeOrdinal,
};
}
/** Test-only access to requester-context construction. */
export const testing = {
buildCopilotToolHookContext: (toolOptions: unknown): Record<string, unknown> =>
buildCopilotToolHookContext(toolOptions as OpenClawCodingToolsOptions),
};
/**
* Builds the full `createOpenClawCodingTools` options bag mirroring the
* PI in-tree call at `src/agents/pi-embedded-runner/run/attempt.ts:1029-1117`.
@@ -410,9 +350,7 @@ function buildOpenClawCodingToolsOptions(
...a.execOverrides,
elevated: a.bashElevated,
},
messageChannel: a.messageChannel,
messageProvider: a.messageProvider ?? a.messageChannel,
toolPolicyMessageProvider: a.messageProvider ?? a.messageChannel,
agentAccountId: a.agentAccountId,
messageTo: a.messageTo,
messageThreadId: a.messageThreadId,
@@ -422,7 +360,6 @@ function buildOpenClawCodingToolsOptions(
memberRoleIds: a.memberRoleIds,
spawnedBy: a.spawnedBy,
senderId: a.senderId,
hookChannelContext: a.channelContext,
senderName: a.senderName,
senderUsername: a.senderUsername,
senderE164: a.senderE164,
@@ -458,7 +395,6 @@ function buildOpenClawCodingToolsOptions(
workspaceDir,
}),
currentChannelId: a.currentChannelId,
chatId: a.chatId,
currentMessagingTarget: a.currentMessagingTarget,
currentThreadTs: a.currentThreadTs,
currentMessageId: a.currentMessageId,

View File

@@ -36,21 +36,49 @@ type DuckDuckGoResult = {
};
function decodeHtmlEntities(text: string): string {
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)));
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;
},
);
}
function stripHtml(html: string): string {

View File

@@ -186,6 +186,17 @@ 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,6 +29,30 @@ describe("concept vocabulary", () => {
expect(tags).not.toContain("2026-04-04.md");
});
it("preserves short protected-glossary terms past the latin minimum-length gate", () => {
const tags = deriveConceptTags({
path: "memory/2026-04-04.md",
snippet: "Store the session in kv and back up to s3 nightly.",
});
// "kv" and "s3" are 2-char latin glossary entries that the generic min-length-3 gate would drop.
expect(tags).toContain("kv");
expect(tags).toContain("s3");
});
it("does not surface short glossary terms that only appear inside longer words", () => {
const tags = deriveConceptTags({
path: "memory/2026-04-04.md",
snippet: "Played the mkv recording and tuned the css3 layout.",
});
// "kv"/"s3" are substrings of "mkv"/"css3"; whole-word matching must not emit them as tags.
expect(tags).not.toContain("kv");
expect(tags).not.toContain("s3");
expect(tags).toContain("mkv");
expect(tags).toContain("css3");
});
it("extracts protected and segmented CJK concept tags", () => {
const tags = deriveConceptTags({
path: "memory/2026-04-04.md",

View File

@@ -330,7 +330,7 @@ function isKanaOnlyToken(value: string): boolean {
);
}
function normalizeConceptToken(rawToken: string): string | null {
function normalizeConceptToken(rawToken: string, fromGlossary = false): string | null {
const normalized = normalizeLowercaseStringOrEmpty(
rawToken
.normalize("NFKC")
@@ -348,7 +348,9 @@ function normalizeConceptToken(rawToken: string): string | null {
return null;
}
const script = classifyConceptTagScript(normalized);
if (normalized.length < minimumTokenLengthForScript(script)) {
// Glossary entries are an explicit allowlist of short technical terms (e.g. "kv", "s3"); they
// bypass the per-script minimum length that would otherwise discard them.
if (!fromGlossary && normalized.length < minimumTokenLengthForScript(script)) {
return null;
}
if (isKanaOnlyToken(normalized) && normalized.length < 3) {
@@ -360,14 +362,43 @@ function normalizeConceptToken(rawToken: string): string | null {
return normalized;
}
// Only entries shorter than their script's minimum token length rely on the glossary bypass, and
// only those need whole-word matching so they don't fire inside longer words ("kv" in "mkv"). Longer
// entries keep substring containment (the shipped behavior, e.g. "backup" tagging inside "backups").
// Precomputed so derive() does not reclassify on every call.
const GLOSSARY_ENTRIES = PROTECTED_GLOSSARY.map((entry) => ({
entry,
wholeWord: entry.length < minimumTokenLengthForScript(classifyConceptTagScript(entry)),
}));
function isAlphanumericAt(source: string, index: number): boolean {
const ch = source[index];
return ch !== undefined && LETTER_OR_NUMBER_RE.test(ch);
}
// True when `entry` occurs as a delimiter-bounded token, not inside a longer word. Keeps short
// glossary entries like "kv"/"s3" from firing inside "mkv"/"css3" once they bypass the length gate.
function includesStandaloneTerm(source: string, entry: string): boolean {
let from = source.indexOf(entry);
while (from !== -1) {
if (!isAlphanumericAt(source, from - 1) && !isAlphanumericAt(source, from + entry.length)) {
return true;
}
from = source.indexOf(entry, from + 1);
}
return false;
}
function collectGlossaryMatches(source: string): string[] {
const normalizedSource = normalizeLowercaseStringOrEmpty(source.normalize("NFKC"));
const matches: string[] = [];
for (const entry of PROTECTED_GLOSSARY) {
if (!normalizedSource.includes(entry)) {
continue;
for (const { entry, wholeWord } of GLOSSARY_ENTRIES) {
const present = wholeWord
? includesStandaloneTerm(normalizedSource, entry)
: normalizedSource.includes(entry);
if (present) {
matches.push(entry);
}
matches.push(entry);
}
return matches;
}
@@ -385,8 +416,13 @@ function collectSegmentTokens(source: string): string[] {
return source.split(/[^\p{L}\p{N}]+/u).filter(Boolean);
}
function pushNormalizedTag(tags: string[], rawToken: string, limit: number): void {
const normalized = normalizeConceptToken(rawToken);
function pushNormalizedTag(
tags: string[],
rawToken: string,
limit: number,
fromGlossary = false,
): void {
const normalized = normalizeConceptToken(rawToken, fromGlossary);
if (!normalized || tags.includes(normalized)) {
return;
}
@@ -410,14 +446,17 @@ export function deriveConceptTags(params: {
}
const tags: string[] = [];
for (const rawToken of [
...collectGlossaryMatches(source),
...collectCompoundTokens(source),
...collectSegmentTokens(source),
]) {
pushNormalizedTag(tags, rawToken, limit);
if (tags.length >= limit) {
break;
const tokenSources: Array<{ tokens: string[]; fromGlossary: boolean }> = [
{ tokens: collectGlossaryMatches(source), fromGlossary: true },
{ tokens: collectCompoundTokens(source), fromGlossary: false },
{ tokens: collectSegmentTokens(source), fromGlossary: false },
];
for (const { tokens, fromGlossary } of tokenSources) {
for (const rawToken of tokens) {
pushNormalizedTag(tags, rawToken, limit, fromGlossary);
if (tags.length >= limit) {
return tags;
}
}
}
return tags;

View File

@@ -3189,7 +3189,9 @@ describe("short-term promotion", () => {
path: "memory/2026-04-03.md",
snippet: "Move backups to S3 Glacier and sync QMD router notes.",
}),
).toStrictEqual(["backup", "backups", "glacier", "qmd", "router", "sync"]);
// "s3" is a protected-glossary term; it now surfaces as a standalone token past the
// per-script min-length gate (the longer terms still match as substrings).
).toStrictEqual(["backup", "backups", "glacier", "qmd", "router", "s3", "sync"]);
});
it("extracts multilingual concept tags across latin and cjk snippets", () => {

View File

@@ -37,6 +37,15 @@ 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,14 +35,16 @@ export function stripHtmlFromTeamsMessage(html: string): string {
let text = html.replace(/<at[^>]*>(.*?)<\/at>/gi, "@$1");
// Strip remaining HTML tags.
text = text.replace(/<[^>]*>/g, " ");
// Decode common HTML entities.
// Decode common HTML entities. &amp; must be decoded LAST to prevent
// double-decoding (e.g. &amp;lt; → &lt; not <), matching decodeHtmlEntities
// in inbound.ts.
text = text
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, " ");
.replace(/&nbsp;/g, " ")
.replace(/&amp;/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>"),
html: expect.stringContaining("<table bordered striped>"),
}),
);
});

View File

@@ -1239,6 +1239,33 @@ describe("deliverReplies", () => {
expect(mockCallArg(sendRichMessage, 1, 0)).not.toHaveProperty("reply_to_message_id");
});
it("skips rich entity detection for reply text with provider-prefixed email addresses", async () => {
const runtime = createRuntime();
const sendMessage = vi.fn().mockResolvedValue({
message_id: 11,
chat: { id: "123" },
});
const bot = createBot({ sendMessage });
const oauthProfileText =
"OAuth profile: openai:keshavbotagent@gmail.com (keshavbotagent@gmail.com)";
await deliverWith({
replies: [{ text: oauthProfileText }],
runtime,
bot,
richMessages: true,
});
const raw = bot.api.raw as unknown as {
sendRichMessage: ReturnType<typeof vi.fn>;
};
const richMessage = raw.sendRichMessage.mock.calls[0]?.[0]?.rich_message;
expect(richMessage).toEqual({
html: oauthProfileText,
skip_entity_detection: true,
});
});
it("uses legacy reply id when selected reply target differs from quote source", async () => {
const runtime = createRuntime();
const sendMessage = vi.fn().mockResolvedValue({

View File

@@ -690,6 +690,24 @@ describe("createTelegramDraftStream", () => {
expect(api.editMessageText).not.toHaveBeenCalled();
});
it("skips rich entity detection for draft text with provider-prefixed email addresses", async () => {
const api = createMockDraftApi();
const stream = createDraftStream(api, { richMessages: true });
const oauthProfileText =
"OAuth profile: openai:keshavbotagent@gmail.com (keshavbotagent@gmail.com)";
stream.update(oauthProfileText);
await stream.flush();
expect(api.raw.sendRichMessage).toHaveBeenCalledWith({
chat_id: 123,
rich_message: {
html: oauthProfileText,
skip_entity_detection: true,
},
});
});
it("keeps rich preview html out of plain preview gating", async () => {
const api = createMockDraftApi();
const stream = createDraftStream(api, { richMessages: true, minInitialChars: 10 });

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>");
expect(markdownToTelegramRichHtml(table(20))).toContain("<table bordered striped>");
expect(markdownToTelegramRichHtml(table(21))).toContain("<pre><code>");
expect(markdownToTelegramRichHtml(table(2), { tableMode: "code" })).toContain("<pre><code>");
expect(markdownToTelegramRichHtml(table(2), { tableMode: "code" })).not.toContain("<table>");
@@ -295,6 +295,19 @@ describe("markdownToTelegramHtml", () => {
expect(html).toContain('<td><a href="https://example.com">docs</a></td>');
});
it("preserves markdown table column alignment in rich tables", () => {
const html = markdownToTelegramRichHtml(
"| Feature | Status | Count |\n| :--- | :---: | ---: |\n| Rich tables | Fixed | 2 |",
);
expect(html).toContain('<th align="left">Feature</th>');
expect(html).toContain('<th align="center">Status</th>');
expect(html).toContain('<th align="right">Count</th>');
expect(html).toContain('<td align="left">Rich tables</td>');
expect(html).toContain('<td align="center">Fixed</td>');
expect(html).toContain('<td align="right">2</td>');
});
it("does not auto-linkify bare URLs when entity detection is skipped", () => {
expect(markdownToTelegramRichHtml("https://example.com", { skipEntityDetection: true })).toBe(
"https://example.com",

View File

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

View File

@@ -96,6 +96,16 @@ type TelegramApiWithRichRaw = Bot["api"] & {
raw?: TelegramRichRawApi;
};
const TELEGRAM_RICH_EMAIL_TOKEN_RE =
/[A-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?(?:\.[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?)+/iu;
function shouldSkipTelegramRichEntityDetection(
text: string,
options?: Pick<TelegramRichMessageOptions, "skipEntityDetection">,
): boolean {
return options?.skipEntityDetection === true || TELEGRAM_RICH_EMAIL_TOKEN_RE.test(text);
}
export function getTelegramRichRawApi(api: Bot["api"]): TelegramRichRawApi {
const raw = (api as TelegramApiWithRichRaw).raw;
if (raw) {
@@ -164,7 +174,11 @@ export function buildTelegramRichMarkdown(
markdown: string,
options?: TelegramRichMessageOptions,
): TelegramInputRichMessage {
return buildTelegramRichHtml(markdownToTelegramRichHtml(markdown, options), options);
const richOptions = {
...options,
skipEntityDetection: shouldSkipTelegramRichEntityDetection(markdown, options),
};
return buildTelegramRichHtml(markdownToTelegramRichHtml(markdown, richOptions), richOptions);
}
export function buildTelegramRichHtml(
@@ -172,7 +186,7 @@ export function buildTelegramRichHtml(
options?: TelegramRichMessageOptions,
): TelegramInputRichMessage {
const safeHtml = prepareTelegramRichHtml(html);
return options?.skipEntityDetection === true
return shouldSkipTelegramRichEntityDetection(safeHtml, options)
? { html: safeHtml, skip_entity_detection: true }
: { html: safeHtml };
}
@@ -418,13 +432,14 @@ export function splitTelegramRichMessageTextChunks(params: {
tableMode?: MarkdownTableMode;
skipEntityDetection?: boolean;
}): TelegramRichTextChunk[] {
const markdownOptions = {
tableMode: params.tableMode,
skipEntityDetection: shouldSkipTelegramRichEntityDetection(params.text, {
skipEntityDetection: params.skipEntityDetection,
}),
};
const renderMarkdownChunk = (chunk: string) =>
prepareTelegramRichHtml(
markdownToTelegramRichHtml(chunk, {
tableMode: params.tableMode,
skipEntityDetection: params.skipEntityDetection,
}),
);
prepareTelegramRichHtml(markdownToTelegramRichHtml(chunk, markdownOptions));
const htmlChunks =
params.textMode === "html"
? splitPreparedTelegramRichHtml({

View File

@@ -953,7 +953,32 @@ describe("sendMessageTelegram", () => {
expect(botRawApi.sendRichMessage).toHaveBeenCalledTimes(1);
const richMessage = botRawApi.sendRichMessage.mock.calls[0]?.[0]?.rich_message;
expect(richMessage?.html).toContain("<table>");
expect(richMessage?.html).toContain("<table bordered striped>");
});
it("skips rich entity detection for provider-prefixed email text", async () => {
botApi.sendMessage.mockResolvedValue({ message_id: 45, chat: { id: "123" } });
const oauthProfileText =
"OAuth profile: openai:keshavbotagent@gmail.com (keshavbotagent@gmail.com)";
await sendMessageTelegram("123", oauthProfileText, {
cfg: {
channels: {
telegram: {
richMessages: true,
},
},
},
token: "tok",
});
expect(botRawApi.sendRichMessage).toHaveBeenCalledTimes(1);
const richMessage = botRawApi.sendRichMessage.mock.calls[0]?.[0]?.rich_message;
expect(richMessage).toEqual({
html: oauthProfileText,
skip_entity_detection: true,
});
expect(richMessage?.html).not.toContain("mailto:");
});
it.each([

View File

@@ -27,6 +27,9 @@ function cronAgentTurnPayloadSchema(params: {
allowUnsafeExternalContent: Type.Optional(Type.Boolean()),
lightContext: Type.Optional(Type.Boolean()),
toolsAllow: Type.Optional(params.toolsAllow),
// Server-managed marker for auto-stamped defaults; persisted so CLI cron
// runs can drop only the cap that was never user-explicit.
toolsAllowIsDefault: Type.Optional(Type.Boolean()),
},
{ additionalProperties: false },
);

View File

@@ -78,9 +78,12 @@ function createStyleSpan(params: MarkdownStyleSpan): MarkdownStyleSpan {
return span;
}
type MarkdownTableAlignment = "left" | "center" | "right";
export type MarkdownTableData = {
headers: string[];
rows: string[][];
aligns?: (MarkdownTableAlignment | undefined)[];
};
export type MarkdownTableCell = {
@@ -113,6 +116,7 @@ type TableCell = MarkdownTableCell;
type TableState = {
headers: TableCell[];
rows: TableCell[][];
aligns: (MarkdownTableAlignment | undefined)[];
currentRow: TableCell[];
currentCell: RenderTarget | null;
inHeader: boolean;
@@ -172,6 +176,20 @@ function getAttr(token: MarkdownToken, name: string): string | null {
return null;
}
function markdownTableAlignmentFromToken(token: MarkdownToken): MarkdownTableAlignment | undefined {
const value = getAttr(token, "style") ?? "";
if (/text-align\s*:\s*left/i.test(value)) {
return "left";
}
if (/text-align\s*:\s*center/i.test(value)) {
return "center";
}
if (/text-align\s*:\s*right/i.test(value)) {
return "right";
}
return undefined;
}
function createTextToken(base: MarkdownToken, content: string): MarkdownToken {
return { ...base, type: "text", content, children: undefined };
}
@@ -432,6 +450,7 @@ function initTableState(): TableState {
return {
headers: [],
rows: [],
aligns: [],
currentRow: [],
currentCell: null,
inHeader: false,
@@ -517,13 +536,15 @@ function collectTableBlock(state: RenderState) {
}
const headerCells = state.table.headers.map(trimCell);
const rowCells = state.table.rows.map((row) => row.map(trimCell));
state.collectedTables.push({
const table = {
headers: headerCells.map((cell) => cell.text),
rows: rowCells.map((row) => row.map((cell) => cell.text)),
headerCells,
rowCells,
placeholderOffset: state.text.length,
});
...(state.table.aligns.some(Boolean) ? { aligns: [...state.table.aligns] } : {}),
};
state.collectedTables.push(table);
}
function appendTableBulletValue(
@@ -874,6 +895,10 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void {
case "td_open":
if (state.table) {
state.table.currentCell = initRenderTarget();
if (token.type === "th_open" && state.table.inHeader) {
state.table.aligns[state.table.currentRow.length] =
markdownTableAlignmentFromToken(token);
}
}
break;
case "th_close":

View File

@@ -107,6 +107,7 @@ export const migratedSessionAccessorFiles = new Set([
"src/gateway/sessions-history-http.ts",
"src/gateway/session-utils.ts",
"src/gateway/managed-image-attachments.ts",
"src/gateway/boot.ts",
"src/gateway/server-methods/artifacts.ts",
"src/gateway/server-methods/chat.ts",
"src/gateway/sessions-resolve.ts",
@@ -163,6 +164,7 @@ export const migratedSessionAccessorWriteFiles = new Set([
"src/auto-reply/reply/session-usage.ts",
"src/commands/tasks.ts",
"src/config/sessions/cleanup-service.ts",
"src/gateway/boot.ts",
"src/gateway/server-node-events.ts",
"src/gateway/session-compaction-checkpoints.ts",
"src/plugins/host-hook-cleanup.ts",

View File

@@ -33,13 +33,6 @@ import { markCodeModeControlTool } from "./code-mode-control-tools.js";
import { CODE_MODE_EXEC_TOOL_NAME, createCodeModeTools } from "./code-mode.js";
import { splitSdkTools } from "./embedded-agent-runner.js";
import type { ExtensionContext } from "./sessions/index.js";
import {
addClientToolsToToolSearchCatalog,
applyToolSearchCatalog,
clearToolSearchCatalog,
createToolSearchTools,
TOOL_CALL_RAW_TOOL_NAME,
} from "./tool-search.js";
import { setToolTerminalPresentation } from "./tool-terminal-presentation.js";
type BeforeToolCallHandlerMock = ReturnType<typeof vi.fn>;
@@ -1207,143 +1200,6 @@ describe("before_tool_call hook integration for client tools", () => {
});
});
it("preserves requester origin context on adapted client tools", async () => {
const beforeToolCallHook = installBeforeToolCallHook();
const [tool] = toClientToolDefinitions(
[
{
type: "function",
function: {
name: "client_tool",
description: "Client tool",
parameters: { type: "object", properties: {} },
},
},
],
undefined,
{
agentId: "main",
sessionKey: "agent:main:client",
sessionId: "session-client",
runId: "run-client",
jobId: "job-client",
trigger: "user",
messageProvider: "slack-voice",
channel: "slack",
chatId: "C123",
senderId: "U123",
channelId: "C123",
channelContext: {
sender: { id: "U123", displayName: "Ada" },
chat: { id: "C123" },
},
},
);
const extensionContext = {} as Parameters<typeof tool.execute>[4];
await tool.execute("client-call-context", {}, undefined, undefined, extensionContext);
expect(beforeToolCallHook).toHaveBeenCalledWith(
{
toolName: "client_tool",
params: {},
runId: "run-client",
toolCallId: "client-call-context",
},
{
toolName: "client_tool",
agentId: "main",
sessionKey: "agent:main:client",
sessionId: "session-client",
runId: "run-client",
jobId: "job-client",
trigger: "user",
messageProvider: "slack-voice",
channel: "slack",
chatId: "C123",
senderId: "U123",
toolCallId: "client-call-context",
channelId: "C123",
channelContext: {
sender: { id: "U123", displayName: "Ada" },
chat: { id: "C123" },
},
},
);
});
it("preserves requester origin context when a client tool is cataloged", async () => {
const beforeToolCallHook = installBeforeToolCallHook();
const onClientToolCall = vi.fn();
const sessionId = "session-client-catalog";
const config = { tools: { toolSearch: { enabled: true, mode: "tools" } } } as never;
const [clientTool] = toClientToolDefinitions(
[
{
type: "function",
function: {
name: "client_tool",
description: "Client tool",
parameters: { type: "object", properties: {} },
},
},
],
onClientToolCall,
{
agentId: "main",
sessionKey: "agent:main:client",
sessionId,
runId: "run-client-catalog",
jobId: "job-client",
trigger: "user",
messageProvider: "slack-voice",
channel: "slack",
chatId: "C123",
senderId: "U123",
channelId: "C123",
channelContext: {
sender: { id: "U123", displayName: "Ada" },
chat: { id: "C123" },
},
},
);
if (!clientTool) {
throw new Error("missing client tool definition");
}
const controls = createToolSearchTools({ config, sessionId });
applyToolSearchCatalog({ tools: controls, config, sessionId });
addClientToolsToToolSearchCatalog({ tools: [clientTool], config, sessionId });
const toolCall = controls.find((tool) => tool.name === TOOL_CALL_RAW_TOOL_NAME);
if (!toolCall) {
throw new Error("missing tool_call control");
}
try {
await toolCall.execute("catalog-parent", {
id: "client:client:client_tool",
args: { value: "cataloged" },
});
expect(beforeToolCallHook).toHaveBeenCalledTimes(1);
expect(beforeToolCallHook.mock.calls[0]?.[1]).toMatchObject({
jobId: "job-client",
trigger: "user",
messageProvider: "slack-voice",
channel: "slack",
chatId: "C123",
senderId: "U123",
channelId: "C123",
channelContext: {
sender: { id: "U123", displayName: "Ada" },
chat: { id: "C123" },
},
});
expect(onClientToolCall).toHaveBeenCalledWith("client_tool", { value: "cataloged" });
} finally {
clearToolSearchCatalog({ sessionId });
}
});
it("preserves client tool source order when hooks resolve out of order", async () => {
let releaseFirstHook: (() => void) | undefined;
const firstHookGate = new Promise<void>((resolve) => {

View File

@@ -37,7 +37,6 @@ import {
import type { SessionState } from "../logging/diagnostic-session-state.js";
import { redactToolDetail } from "../logging/redact.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { buildAgentHookContextIdentityFields } from "../plugins/hook-agent-context.js";
import { getGlobalHookRunnerRegistry } from "../plugins/hook-runner-global-state.js";
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
import { deriveToolParams } from "../plugins/host-tool-param-parsers.js";
@@ -51,10 +50,8 @@ import {
PluginApprovalResolutions,
type PluginApprovalResolution,
type PluginHookBeforeToolCallResult,
type PluginHookChannelContext,
type PluginHookToolInputKind,
type PluginHookToolKind,
type PluginHookToolContext,
} from "../plugins/types.js";
import { createLazyRuntimeSurface } from "../shared/lazy-runtime.js";
import {
@@ -113,15 +110,8 @@ export type HookContext = {
/** Ephemeral session UUID — regenerated on /new and /reset. */
sessionId?: string;
runId?: string;
jobId?: string;
trace?: DiagnosticTraceContext;
trigger?: string;
messageProvider?: string;
channel?: string;
chatId?: string;
senderId?: string;
channelId?: string;
channelContext?: PluginHookChannelContext;
/** Originating channel for approval delivery routing; mirrors exec approval turn-source fields. */
turnSourceChannel?: string;
turnSourceTo?: string;
@@ -143,24 +133,6 @@ export type HookContext = {
};
};
/** Plain run-owned metadata that can safely be projected onto tool hook contexts. */
export type ToolHookRunContext = Pick<
HookContext,
| "agentId"
| "sessionKey"
| "sessionId"
| "runId"
| "jobId"
| "trace"
| "trigger"
| "messageProvider"
| "channel"
| "chatId"
| "senderId"
| "channelId"
| "channelContext"
>;
type HookBlockedKind = "veto" | "failure";
type HookBlockedReason = "plugin-before-tool-call" | "plugin-approval" | "tool-loop";
type HookOutcome =
@@ -239,38 +211,6 @@ export function hasBeforeToolCallPolicy(): boolean {
return state.hasBeforeToolCallHook || state.trustedToolPolicies.length > 0;
}
/** Project the internal tool runtime context onto the public plugin hook contract. */
export function buildPluginHookToolContext(args: {
toolName: string;
toolKind?: PluginHookToolKind;
toolInputKind?: PluginHookToolInputKind;
toolCallId?: string;
ctx?: ToolHookRunContext;
}): PluginHookToolContext {
return {
toolName: args.toolName,
...(args.toolKind && { toolKind: args.toolKind }),
...(args.toolInputKind && { toolInputKind: args.toolInputKind }),
...(args.ctx?.agentId && { agentId: args.ctx.agentId }),
...(args.ctx?.sessionKey && { sessionKey: args.ctx.sessionKey }),
...(args.ctx?.sessionId && { sessionId: args.ctx.sessionId }),
...(args.ctx?.runId && { runId: args.ctx.runId }),
...(args.ctx?.jobId && { jobId: args.ctx.jobId }),
...(args.ctx?.trace && { trace: freezeDiagnosticTraceContext(args.ctx.trace) }),
...(args.ctx?.trigger && { trigger: args.ctx.trigger }),
...(args.ctx?.messageProvider && { messageProvider: args.ctx.messageProvider }),
...(args.ctx?.channel && { channel: args.ctx.channel }),
...(args.toolCallId && { toolCallId: args.toolCallId }),
...(args.ctx?.channelId && { channelId: args.ctx.channelId }),
...buildAgentHookContextIdentityFields({
trigger: args.ctx?.trigger,
senderId: args.ctx?.senderId,
chatId: args.ctx?.chatId,
channelContext: args.ctx?.channelContext,
}),
};
}
const log = createSubsystemLogger("agents/tools");
const BEFORE_TOOL_CALL_WRAPPED = Symbol("beforeToolCallWrapped");
const BEFORE_TOOL_CALL_DIAGNOSTIC_OPTIONS = Symbol("beforeToolCallDiagnosticOptions");
@@ -1203,13 +1143,17 @@ export async function runBeforeToolCallHook(args: {
...(args.toolKind && { toolKind: args.toolKind }),
...(args.toolInputKind && { toolInputKind: args.toolInputKind }),
};
const buildToolContext = (identity: typeof toolIdentity) =>
buildPluginHookToolContext({
toolName,
...identity,
...(args.toolCallId && { toolCallId: args.toolCallId }),
...(args.ctx ? { ctx: args.ctx } : {}),
});
const buildToolContext = (identity: typeof toolIdentity) => ({
toolName,
...identity,
...(args.ctx?.agentId && { agentId: args.ctx.agentId }),
...(args.ctx?.sessionKey && { sessionKey: args.ctx.sessionKey }),
...(args.ctx?.sessionId && { sessionId: args.ctx.sessionId }),
...(args.ctx?.runId && { runId: args.ctx.runId }),
...(args.ctx?.trace && { trace: freezeDiagnosticTraceContext(args.ctx.trace) }),
...(args.toolCallId && { toolCallId: args.toolCallId }),
...(args.ctx?.channelId && { channelId: args.ctx.channelId }),
});
const toolContext = buildToolContext(toolIdentity);
const trustedPolicyResult = shouldRunTrustedPolicies
? await runTrustedToolPolicies(

View File

@@ -201,7 +201,7 @@ describe("createOpenClawCodingTools", () => {
expect(names.has("tool_call")).toBe(false);
});
it("passes requester origin context to wrapped tool hooks", async () => {
it("passes explicit hook channel ids to wrapped tool hooks", async () => {
const beforeToolCall = vi.fn();
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "before_tool_call", handler: beforeToolCall }]),
@@ -210,36 +210,15 @@ describe("createOpenClawCodingTools", () => {
await fs.writeFile(path.join(tmpDir, "note.txt"), "hello");
const tools = createOpenClawCodingTools({
workspaceDir: tmpDir,
messageChannel: "telegram",
messageProvider: "telegram",
toolPolicyMessageProvider: "telegram-voice",
messageTo: "telegram:-100123",
senderId: "speaker-1",
trigger: "user",
jobId: "job-1",
hookChannelContext: {
sender: { id: "transport-speaker" },
chat: { id: "transport-chat" },
},
currentChannelId: "telegram:-100123",
hookChannelId: "-100123",
});
const readTool = requireTool(tools, "read");
await requireToolExecute(readTool)("tool-hook-channel", { path: "note.txt" });
expect(beforeToolCall).toHaveBeenCalledTimes(1);
expect(beforeToolCall.mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
channel: "telegram",
messageProvider: "telegram-voice",
channelId: "-100123",
chatId: "transport-chat",
senderId: "speaker-1",
trigger: "user",
jobId: "job-1",
channelContext: {
sender: { id: "speaker-1" },
chat: { id: "transport-chat" },
},
}),
expect.objectContaining({ channelId: "-100123" }),
);
});

View File

@@ -19,7 +19,6 @@ import { resolveEventSessionRoutingPolicy } from "../infra/event-session-routing
import { applyExecPolicyLayer } from "../infra/exec-policy.js";
import { resolveMergedSafeBinProfileFixtures } from "../infra/exec-safe-bin-runtime-policy.js";
import { logWarn } from "../logger.js";
import { buildAgentHookContextOriginFields } from "../plugins/hook-agent-context.js";
import type { PluginHookChannelContext } from "../plugins/hook-types.js";
import { getPluginToolMeta } from "../plugins/tools.js";
import { createLazyImportLoader } from "../shared/lazy-promise.js";
@@ -471,12 +470,8 @@ export function createOpenClawCodingTools(options?: {
currentMessagingTarget?: string;
/** Normalized conversation id exposed to tool hooks. Defaults to currentChannelId. */
hookChannelId?: string;
/** Transport-native conversation id exposed to tool hooks when available. */
chatId?: string;
/** Channel-owned sender/chat metadata exposed to subprocess environments. */
channelContext?: PluginHookChannelContext;
/** Channel-owned sender/chat metadata exposed only to plugin tool hooks. */
hookChannelContext?: PluginHookChannelContext;
/** Current thread timestamp for auto-threading (Slack). */
currentThreadTs?: string;
/** Current inbound message id for action fallbacks (e.g. Telegram react). */
@@ -1192,8 +1187,7 @@ export function createOpenClawCodingTools(options?: {
);
options?.recordToolPrepStage?.("schema-normalization");
const turnSourceChannel = options?.messageChannel ?? options?.messageProvider;
const turnSourceTo =
options?.currentMessagingTarget ?? options?.messageTo ?? options?.currentChannelId;
const turnSourceTo = options?.currentMessagingTarget ?? options?.currentChannelId;
const hookContext = {
agentId,
...(options?.config ? { config: options.config } : {}),
@@ -1206,19 +1200,7 @@ export function createOpenClawCodingTools(options?: {
sessionKey: options?.sessionKey,
sessionId: options?.sessionId,
runId: options?.runId,
jobId: options?.jobId,
trigger: options?.trigger,
...buildAgentHookContextOriginFields({
sessionKey: options?.sessionKey,
messageChannel: options?.messageChannel,
messageProvider: options?.toolPolicyMessageProvider ?? options?.messageProvider,
currentChannelId: options?.hookChannelId ?? options?.currentChannelId,
messageTo: options?.currentMessagingTarget ?? options?.messageTo,
trigger: options?.trigger,
senderId: options?.senderId,
chatId: options?.chatId,
channelContext: options?.hookChannelContext ?? options?.channelContext,
}),
channelId: options?.hookChannelId ?? options?.currentChannelId,
...(turnSourceChannel ? { turnSourceChannel } : {}),
...(turnSourceTo ? { turnSourceTo } : {}),
...(options?.agentAccountId ? { turnSourceAccountId: options.agentAccountId } : {}),

View File

@@ -629,11 +629,6 @@ describe("runBtwSideQuestion", () => {
senderName: "Rosita",
senderUsername: "rosita",
senderE164: "+15550001",
chatId: "native-chat-1",
channelContext: {
sender: { id: "sender-1", providerUserId: "provider-user-1" },
chat: { id: "native-chat-1", providerThreadKey: "thread-key-1" },
},
});
expect(result).toEqual({ text: "Codex side answer." });
@@ -658,11 +653,6 @@ describe("runBtwSideQuestion", () => {
senderName?: string;
senderUsername?: string;
senderE164?: string;
chatId?: string;
channelContext?: {
sender?: Record<string, unknown>;
chat?: Record<string, unknown>;
};
toolsAllow?: string[];
},
]
@@ -685,11 +675,6 @@ describe("runBtwSideQuestion", () => {
senderName: "Rosita",
senderUsername: "rosita",
senderE164: "+15550001",
chatId: "native-chat-1",
channelContext: {
sender: { id: "sender-1", providerUserId: "provider-user-1" },
chat: { id: "native-chat-1", providerThreadKey: "thread-key-1" },
},
});
expect(
(mockArg(codexSideQuestionMock, 0, 0) as { sessionFile?: string }).sessionFile,

View File

@@ -18,7 +18,6 @@ import type {
Model,
TextContent,
} from "../llm/types.js";
import type { PluginHookChannelContext } from "../plugins/hook-types.js";
import { prepareProviderRuntimeAuth } from "../plugins/provider-runtime.js";
import { discoverAuthStorage, discoverModels } from "./agent-model-discovery.js";
import { resolveAgentWorkspaceDir, resolveSessionAgentId } from "./agent-scope.js";
@@ -389,8 +388,6 @@ type RunBtwSideQuestionParams = {
senderUsername?: string | null;
senderE164?: string | null;
senderIsOwner?: boolean;
chatId?: string;
channelContext?: PluginHookChannelContext;
currentChannelId?: string;
};

View File

@@ -52,7 +52,6 @@ import { getCurrentPluginMetadataSnapshot } from "../../../plugins/current-plugi
import {
buildAgentHookContextChannelFields,
buildAgentHookContextIdentityFields,
buildAgentHookContextOriginFields,
} from "../../../plugins/hook-agent-context.js";
import { resolveBlockMessage } from "../../../plugins/hook-decision-types.js";
import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js";
@@ -1242,7 +1241,6 @@ export async function runEmbeddedAttempt(
toolConstructionPlan.constructTools ||
toolSearchControlsEnabledForRun ||
codeModeControlsEnabledForRun;
const toolPolicyMessageProvider = resolveAttemptToolPolicyMessageProvider(params);
let toolSearchCatalogExecutor: ToolSearchCatalogToolExecutor | undefined;
toolSearchCatalogRef =
toolSearchControlsEnabledForRun || codeModeControlsEnabledForRun
@@ -1263,7 +1261,7 @@ export async function runEmbeddedAttempt(
elevated: params.bashElevated,
},
sandbox,
messageProvider: toolPolicyMessageProvider,
messageProvider: resolveAttemptToolPolicyMessageProvider(params),
agentAccountId: params.agentAccountId,
messageTo: params.messageTo,
messageThreadId: params.messageThreadId,
@@ -1316,7 +1314,6 @@ export async function runEmbeddedAttempt(
}),
currentChannelId: params.currentChannelId,
currentMessagingTarget: params.currentMessagingTarget,
chatId: params.chatId,
currentThreadTs: params.currentThreadTs,
currentMessageId: params.currentMessageId,
currentInboundAudio: params.currentInboundAudio,
@@ -1664,19 +1661,7 @@ export async function runEmbeddedAttempt(
sessionKey: sandboxSessionKey,
sessionId: params.sessionId,
runId: params.runId,
jobId: params.jobId,
trigger: params.trigger,
...buildAgentHookContextOriginFields({
sessionKey: sandboxSessionKey,
messageChannel: params.messageChannel,
messageProvider: toolPolicyMessageProvider,
currentChannelId: params.currentChannelId,
messageTo: params.currentMessagingTarget ?? params.messageTo,
trigger: params.trigger,
senderId: params.senderId,
chatId: params.chatId,
channelContext: params.channelContext,
}),
channelId: params.currentChannelId,
trace: runTrace,
loopDetection: resolveToolLoopDetectionConfig({
cfg: params.config,
@@ -2436,9 +2421,14 @@ export async function runEmbeddedAttempt(
},
},
{
...catalogToolHookContext,
agentId: sessionAgentId,
sessionKey: sandboxSessionKey,
config: toolSearchRuntimeConfig,
sessionId: params.sessionId,
runId: params.runId,
loopDetection: clientToolLoopDetection,
onToolOutcome: params.onToolOutcome,
allocateToolOutcomeOrdinal: params.allocateToolOutcomeOrdinal,
},
)
: [];
@@ -3565,7 +3555,6 @@ export async function runEmbeddedAttempt(
messageChannel: runtimeChannel,
initialReplayState: params.initialReplayState,
hookRunner: getGlobalHookRunner() ?? undefined,
toolHookContext: catalogToolHookContext,
verboseLevel: params.verboseLevel,
reasoningMode: params.reasoningLevel ?? "off",
thinkingLevel: params.thinkLevel,

View File

@@ -34,7 +34,6 @@ import type { PluginHookAfterToolCallEvent } from "../plugins/types.js";
import { createLazyImportLoader } from "../shared/lazy-promise.js";
import { truncateUtf16Safe } from "../utils.js";
import { normalizeAcceptedSessionSpawnResult } from "./accepted-session-spawn.js";
import { buildPluginHookToolContext } from "./agent-tools.before-tool-call.js";
import {
consumeAdjustedParamsForToolCall,
consumePreExecutionBlockedToolCall,
@@ -1574,20 +1573,14 @@ export async function handleToolExecutionEnd(
durationMs,
};
void hookRunnerAfter
.runAfterToolCall(
hookEvent,
buildPluginHookToolContext({
toolName,
toolCallId,
ctx: {
...ctx.params.toolHookContext,
...(ctx.params.agentId ? { agentId: ctx.params.agentId } : {}),
...(ctx.params.sessionKey ? { sessionKey: ctx.params.sessionKey } : {}),
...(ctx.params.sessionId ? { sessionId: ctx.params.sessionId } : {}),
runId,
},
}),
)
.runAfterToolCall(hookEvent, {
toolName,
agentId: ctx.params.agentId,
sessionKey: ctx.params.sessionKey,
sessionId: ctx.params.sessionId,
runId,
toolCallId,
})
.catch((err: unknown) => {
ctx.log.warn(`after_tool_call hook failed: tool=${toolName} error=${String(err)}`);
});

View File

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

View File

@@ -10,7 +10,6 @@ import type { ReplyPayload } from "../auto-reply/reply-payload.js";
import type { ReasoningLevel, ThinkLevel, VerboseLevel } from "../auto-reply/thinking.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { HookRunner } from "../plugins/hooks.js";
import type { ToolHookRunContext } from "./agent-tools.before-tool-call.js";
import type { BlockReplyPayload } from "./embedded-agent-payloads.js";
import type { EmbeddedRunReplayState } from "./embedded-agent-runner/replay-state.js";
import type {
@@ -36,8 +35,6 @@ export type SubscribeEmbeddedAgentSessionParams = {
messageChannel?: string;
initialReplayState?: EmbeddedRunReplayState;
hookRunner?: HookRunner;
/** Internal run context projected onto before/after tool hook contracts. */
toolHookContext?: ToolHookRunContext;
verboseLevel?: VerboseLevel;
reasoningMode?: ReasoningLevel;
thinkingLevel?: ThinkLevel;

View File

@@ -6,26 +6,25 @@
*/
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import {
buildPluginHookToolContext,
consumeAdjustedParamsForToolCall,
type ToolHookRunContext,
} from "../agent-tools.before-tool-call.js";
import { consumeAdjustedParamsForToolCall } from "../agent-tools.before-tool-call.js";
import type { AgentMessage } from "../runtime/index.js";
const log = createSubsystemLogger("agents/harness");
/** Runs best-effort after-tool-call hooks for a completed tool invocation. */
export async function runAgentHarnessAfterToolCallHook(
params: ToolHookRunContext & {
toolName: string;
toolCallId: string;
startArgs: Record<string, unknown>;
result?: unknown;
error?: string;
startedAt?: number;
},
): Promise<void> {
export async function runAgentHarnessAfterToolCallHook(params: {
toolName: string;
toolCallId: string;
runId?: string;
agentId?: string;
sessionId?: string;
sessionKey?: string;
channelId?: string;
startArgs: Record<string, unknown>;
result?: unknown;
error?: string;
startedAt?: number;
}): Promise<void> {
const adjustedArgs = consumeAdjustedParamsForToolCall(params.toolCallId, params.runId);
// Hooks should see adjusted tool params when before_tool_call rewrote them.
const resolvedArgs =
@@ -48,11 +47,15 @@ export async function runAgentHarnessAfterToolCallHook(
...(params.error ? { error: params.error } : {}),
...(params.startedAt != null ? { durationMs: Date.now() - params.startedAt } : {}),
},
buildPluginHookToolContext({
{
toolName: params.toolName,
...(params.agentId ? { agentId: params.agentId } : {}),
...(params.sessionId ? { sessionId: params.sessionId } : {}),
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
...(params.runId ? { runId: params.runId } : {}),
...(params.channelId ? { channelId: params.channelId } : {}),
toolCallId: params.toolCallId,
ctx: params,
}),
},
);
} catch (error) {
log.warn(`after_tool_call hook failed: tool=${params.toolName} error=${String(error)}`);

View File

@@ -1637,19 +1637,6 @@ describe("native hook relay registry", () => {
sessionKey: "agent:main:session-1",
runId: "run-1",
channelId: "telegram",
toolHookContext: {
jobId: "job-1",
trigger: "user",
messageProvider: "telegram-voice",
channel: "telegram",
chatId: "chat-1",
senderId: "user-1",
channelId: "telegram",
channelContext: {
sender: { id: "user-1" },
chat: { id: "chat-1" },
},
},
});
const response = await invokeNativeHookRelay({
@@ -1687,17 +1674,7 @@ describe("native hook relay registry", () => {
sessionId: "session-1",
sessionKey: "agent:main:session-1",
runId: "run-1",
jobId: "job-1",
trigger: "user",
messageProvider: "telegram-voice",
channel: "telegram",
chatId: "chat-1",
senderId: "user-1",
channelId: "telegram",
channelContext: {
sender: { id: "user-1" },
chat: { id: "chat-1" },
},
toolName: "exec",
toolCallId: "native-call-1",
});
@@ -1718,19 +1695,6 @@ describe("native hook relay registry", () => {
sessionKey: "agent:main:session-1",
runId: "run-1",
channelId: "telegram",
toolHookContext: {
jobId: "job-1",
trigger: "user",
messageProvider: "telegram-voice",
channel: "telegram",
chatId: "chat-1",
senderId: "user-1",
channelId: "telegram",
channelContext: {
sender: { id: "user-1" },
chat: { id: "chat-1" },
},
},
});
const response = await invokeNativeHookRelay({
@@ -2279,19 +2243,6 @@ describe("native hook relay registry", () => {
sessionKey: "agent:main:session-1",
runId: "run-1",
channelId: "telegram",
toolHookContext: {
jobId: "job-1",
trigger: "user",
messageProvider: "telegram-voice",
channel: "telegram",
chatId: "chat-1",
senderId: "user-1",
channelId: "telegram",
channelContext: {
sender: { id: "user-1" },
chat: { id: "chat-1" },
},
},
});
const response = await invokeNativeHookRelay({
@@ -2322,17 +2273,7 @@ describe("native hook relay registry", () => {
sessionId: "session-1",
sessionKey: "agent:main:session-1",
runId: "run-1",
jobId: "job-1",
trigger: "user",
messageProvider: "telegram-voice",
channel: "telegram",
chatId: "chat-1",
senderId: "user-1",
channelId: "telegram",
channelContext: {
sender: { id: "user-1" },
chat: { id: "chat-1" },
},
toolName: "exec",
toolCallId: "native-call-1",
});

View File

@@ -37,7 +37,6 @@ import {
requestDeferredPluginToolApproval,
runBeforeToolCallHook,
type DeferredPluginToolApproval,
type ToolHookRunContext,
} from "../agent-tools.before-tool-call.js";
import { stableStringify } from "../stable-stringify.js";
import { resolveToolLoopDetectionConfig } from "../tool-loop-detection-config.js";
@@ -105,7 +104,6 @@ export type NativeHookRelayRegistration = {
config?: OpenClawConfig;
runId: string;
channelId?: string;
toolHookContext?: ToolHookRunContext;
allowedEvents: readonly NativeHookRelayEvent[];
expiresAtMs: number;
signal?: AbortSignal;
@@ -133,7 +131,6 @@ export type RegisterNativeHookRelayParams = {
config?: OpenClawConfig;
runId: string;
channelId?: string;
toolHookContext?: ToolHookRunContext;
allowedEvents?: readonly NativeHookRelayEvent[];
ttlMs?: number;
command?: NativeHookRelayCommandOptions;
@@ -438,7 +435,6 @@ export function registerNativeHookRelay(
...(params.config ? { config: params.config } : {}),
runId: params.runId,
...(params.channelId ? { channelId: params.channelId } : {}),
...(params.toolHookContext ? { toolHookContext: params.toolHookContext } : {}),
allowedEvents,
expiresAtMs,
...(params.signal ? { signal: params.signal } : {}),
@@ -1402,7 +1398,6 @@ async function runNativeHookRelayPreToolUse(params: {
...(approvalMode === "report" ? { approvalMode: "defer" } : {}),
signal: params.registration.signal,
ctx: {
...params.registration.toolHookContext,
...(params.registration.agentId ? { agentId: params.registration.agentId } : {}),
sessionId: params.registration.sessionId,
...(params.registration.sessionKey ? { sessionKey: params.registration.sessionKey } : {}),
@@ -1452,7 +1447,6 @@ async function runNativeHookRelayPostToolUse(params: {
await runAgentHarnessAfterToolCallHook({
toolName,
toolCallId,
...params.registration.toolHookContext,
runId: params.registration.runId,
...(params.registration.agentId ? { agentId: params.registration.agentId } : {}),
sessionId: params.registration.sessionId,

View File

@@ -65,8 +65,6 @@ export type AgentHarnessSideQuestionParams = {
senderUsername?: string | null;
senderE164?: string | null;
senderIsOwner?: boolean;
chatId?: string;
channelContext?: import("../../plugins/hook-types.js").PluginHookChannelContext;
currentChannelId?: string;
toolsAllow?: string[];
authProfileId?: string;

View File

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

View File

@@ -36,6 +36,7 @@ export type SessionLockInspection = {
ageMs: number | null;
stale: boolean;
staleReasons: string[];
removable: boolean;
removed: boolean;
};
@@ -858,13 +859,15 @@ export async function cleanStaleLockFiles(params: {
reclaimLockWithoutStarttime: false,
readOwnerProcessArgs: ownerProcessArgsReader,
});
const removable = await shouldRemoveLockDuringCleanup(lockPath, inspected, staleMs, nowMs);
const lockInfo: SessionLockInspection = {
lockPath,
...inspected,
removable,
removed: false,
};
if (removeStale && (await shouldRemoveLockDuringCleanup(lockPath, lockInfo, staleMs, nowMs))) {
if (removeStale && removable) {
await fs.rm(lockPath, { force: true });
lockInfo.removed = true;
cleaned.push(lockInfo);

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

View File

@@ -2167,6 +2167,7 @@ describe("cron tool", () => {
expect(params?.patch?.payload).toEqual({
kind: "agentTurn",
toolsAllow: ["read", "cron"],
toolsAllowIsDefault: true,
});
});
@@ -2202,6 +2203,7 @@ describe("cron tool", () => {
payload: {
kind: "agentTurn",
toolsAllow: ["read", "cron"],
toolsAllowIsDefault: true,
},
},
},
@@ -2278,6 +2280,51 @@ describe("cron tool", () => {
});
});
it("preserves the default toolsAllow flag across an update that omits toolsAllow", async () => {
// Regression guard: a routine update (here, toggling enabled) of an
// agentTurn job whose cap was an auto-stamped default must keep
// toolsAllowIsDefault set. Otherwise the run-time CLI drop (which keys off
// the flag) stops applying and the job fails closed again after a restart —
// re-breaking the exact #91499 regression this change fixes.
callGatewayMock
.mockResolvedValueOnce({
id: "job-13",
payload: {
kind: "agentTurn",
message: "hi",
toolsAllow: ["read", "cron"],
toolsAllowIsDefault: true,
},
})
.mockResolvedValueOnce({ ok: true });
const tool = createTestCronTool({
agentSessionKey: "agent:main:telegram:group:restricted-room",
creatorToolAllowlist: ["read", "cron"],
});
await tool.execute("call-update-preserve-default-flag", {
action: "update",
id: "job-13",
patch: { enabled: false },
});
expect(callGatewayMock).toHaveBeenCalledTimes(2);
expect(readGatewayCall(1)).toEqual({
method: "cron.update",
params: {
id: "job-13",
patch: {
enabled: false,
payload: {
kind: "agentTurn",
toolsAllow: ["read", "cron"],
toolsAllowIsDefault: true,
},
},
},
});
});
it("adds the creator tool surface when converting an existing job to agentTurn", async () => {
callGatewayMock
.mockResolvedValueOnce({
@@ -2310,6 +2357,7 @@ describe("cron tool", () => {
kind: "agentTurn",
message: "run later",
toolsAllow: ["read", "cron"],
toolsAllowIsDefault: true,
},
},
},

View File

@@ -466,6 +466,7 @@ function capCronAgentTurnToolsAllow(params: {
: params.defaultToolsAllow;
if (!Array.isArray(requestedRaw)) {
params.payload.toolsAllow = creatorToolNames;
params.payload.toolsAllowIsDefault = true;
return;
}
const requestedToolsAllow = normalizeCronToolsAllow(
@@ -473,10 +474,12 @@ function capCronAgentTurnToolsAllow(params: {
);
if (requestedToolsAllow.length === 0) {
params.payload.toolsAllow = [];
delete params.payload.toolsAllowIsDefault;
return;
}
if (requestedToolsAllow.includes("*")) {
params.payload.toolsAllow = creatorToolNames;
params.payload.toolsAllowIsDefault = true;
return;
}
const pluginGroups = buildPluginToolGroups({
@@ -490,6 +493,7 @@ function capCronAgentTurnToolsAllow(params: {
params.payload.toolsAllow = creatorToolNames.filter((toolName) =>
isToolAllowedByPolicyName(toolName, requestedPolicy),
);
delete params.payload.toolsAllowIsDefault;
}
function capCronAgentTurnJobToolsAllow(
@@ -549,8 +553,12 @@ async function capCronAgentTurnUpdatePatchToolsAllow(params: {
capCronAgentTurnToolsAllow({
payload: nextPayload,
creatorToolAllowlist: params.creatorToolAllowlist,
// Flagged defaults are re-derived so normal updates do not turn them into
// explicit restrictions or lose the marker needed after restart.
defaultToolsAllow:
existingPayloadKind === "agentTurn" && isRecord(existingPayload)
existingPayloadKind === "agentTurn" &&
isRecord(existingPayload) &&
existingPayload.toolsAllowIsDefault !== true
? existingPayload.toolsAllow
: undefined,
});

View File

@@ -130,12 +130,6 @@ describe("handleBtwCommand", () => {
params.ctx.SenderUsername = "rosita";
params.ctx.SenderE164 = "+15550001";
params.ctx.MessageThreadId = "thread-1";
params.ctx.NativeChannelId = "native-chat-1";
params.ctx.ChatId = "legacy-chat";
params.ctx.ChannelContext = {
sender: { id: "sender-1", providerUserId: "provider-user-1" },
chat: { id: "native-chat-1", providerThreadKey: "thread-key-1" },
};
params.agentDir = "/tmp/agent";
params.sessionEntry = {
sessionId: "session-1",
@@ -167,11 +161,6 @@ describe("handleBtwCommand", () => {
senderUsername: "rosita",
senderE164: "+15550001",
senderIsOwner: true,
chatId: "native-chat-1",
channelContext: {
sender: { id: "sender-1", providerUserId: "provider-user-1" },
chat: { id: "native-chat-1", providerThreadKey: "thread-key-1" },
},
});
expect(String(runnerArgs.agentDir)).toContain("/agents/main/agent");
expect(result).toEqual({

View File

@@ -1,5 +1,4 @@
/** Handles /btw side-question commands against the active session context. */
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { resolveAgentDir, resolveSessionAgentId } from "../../agents/agent-scope.js";
import { runBtwSideQuestion } from "../../agents/btw.js";
import { resolveGroupSessionKey } from "../../config/sessions/group.js";
@@ -58,12 +57,6 @@ export const handleBtwCommand: CommandHandler = async (params, allowTextCommands
await params.typing?.startTypingLoop();
const currentChannelId =
params.ctx.OriginatingTo?.trim() || params.command.to || params.command.channelId;
const chatId =
normalizeOptionalString(params.ctx.NativeChannelId) ??
normalizeOptionalString(params.ctx.ChatId) ??
normalizeOptionalString(params.rootCtx?.NativeChannelId) ??
normalizeOptionalString(params.rootCtx?.ChatId);
const channelContext = params.ctx.ChannelContext ?? params.rootCtx?.ChannelContext;
const groupId = resolveGroupSessionKey(params.ctx)?.id ?? targetSessionEntry.groupId;
const reply = await runBtwSideQuestion({
cfg: params.cfg,
@@ -116,8 +109,6 @@ export const handleBtwCommand: CommandHandler = async (params, allowTextCommands
...(params.ctx.SenderUsername ? { senderUsername: params.ctx.SenderUsername } : {}),
...(params.ctx.SenderE164 ? { senderE164: params.ctx.SenderE164 } : {}),
senderIsOwner: params.command.senderIsOwner,
...(chatId ? { chatId } : {}),
...(channelContext ? { channelContext } : {}),
...(currentChannelId ? { currentChannelId } : {}),
});
return {

View File

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

View File

@@ -13,7 +13,12 @@ vi.mock("../../packages/terminal-core/src/note.js", () => ({
note,
}));
import { noteSessionLockHealth } from "./doctor-session-locks.js";
import {
detectStaleSessionLocks,
noteSessionLockHealth,
sessionLockToHealthFinding,
sessionLockToRepairEffect,
} from "./doctor-session-locks.js";
async function expectPathMissing(targetPath: string): Promise<void> {
try {
@@ -105,6 +110,154 @@ describe("noteSessionLockHealth", () => {
await expect(fs.access(freshLock)).resolves.toBeUndefined();
});
it("detects stale locks without removing them for structured lint", async () => {
const sessionsDir = state.sessionsDir();
await fs.mkdir(sessionsDir, { recursive: true });
const staleLock = path.join(sessionsDir, "stale.jsonl.lock");
const freshLock = path.join(sessionsDir, "fresh.jsonl.lock");
await fs.writeFile(
staleLock,
JSON.stringify({ pid: -1, createdAt: new Date(Date.now() - 120_000).toISOString() }),
"utf8",
);
await fs.writeFile(
freshLock,
JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }),
"utf8",
);
const locks = await detectStaleSessionLocks({
staleMs: 30_000,
readOwnerProcessArgs: () => ["node", "/opt/openclaw/openclaw.mjs", "doctor"],
});
expect(locks).toHaveLength(1);
expect(locks[0]?.lockPath).toBe(staleLock);
await expect(fs.access(staleLock)).resolves.toBeUndefined();
await expect(fs.access(freshLock)).resolves.toBeUndefined();
});
it("maps stale locks to structured findings and dry-run effects", async () => {
const sessionsDir = state.sessionsDir();
await fs.mkdir(sessionsDir, { recursive: true });
const lockPath = path.join(sessionsDir, "stale.jsonl.lock");
await fs.writeFile(
lockPath,
JSON.stringify({ pid: -1, createdAt: new Date(Date.now() - 120_000).toISOString() }),
"utf8",
);
const [lock] = await detectStaleSessionLocks({
staleMs: 30_000,
readOwnerProcessArgs: () => ["node", "/opt/openclaw/openclaw.mjs", "doctor"],
});
if (!lock) {
throw new Error("expected stale session lock");
}
expect(sessionLockToHealthFinding(lock)).toEqual(
expect.objectContaining({
checkId: "core/doctor/session-locks",
severity: "warning",
path: lockPath,
}),
);
expect(sessionLockToRepairEffect(lock)).toEqual({
kind: "state",
action: "would-remove-stale-session-lock",
target: lockPath,
dryRunSafe: false,
});
});
it("preserves fresh malformed stale locks in dry-run repair effects", async () => {
const sessionsDir = state.sessionsDir();
await fs.mkdir(sessionsDir, { recursive: true });
const malformedLock = path.join(sessionsDir, "malformed.jsonl.lock");
await fs.writeFile(malformedLock, "{}", "utf8");
const [lock] = await detectStaleSessionLocks({
staleMs: 30_000,
readOwnerProcessArgs: () => ["node", "/opt/openclaw/openclaw.mjs", "doctor"],
});
if (!lock) {
throw new Error("expected stale session lock");
}
expect(lock.staleReasons).toEqual(["missing-pid", "invalid-createdAt"]);
expect(lock.removable).toBe(false);
expect(sessionLockToHealthFinding(lock).fixHint).toContain("after the cleanup grace period");
expect(sessionLockToRepairEffect(lock)).toEqual({
kind: "state",
action: "would-preserve-mtime-gated-stale-session-lock",
target: malformedLock,
dryRunSafe: false,
});
await expect(fs.access(malformedLock)).resolves.toBeUndefined();
});
it("uses the supplied env to choose the structured lint state dir", async () => {
const other = await createOpenClawTestState({
layout: "state-only",
prefix: "openclaw-doctor-locks-other-",
applyEnv: false,
});
try {
await fs.mkdir(other.sessionsDir(), { recursive: true });
const lockPath = path.join(other.sessionsDir(), "other-stale.jsonl.lock");
await fs.writeFile(
lockPath,
JSON.stringify({ pid: -1, createdAt: new Date(Date.now() - 120_000).toISOString() }),
"utf8",
);
const locks = await detectStaleSessionLocks({
env: other.env,
staleMs: 30_000,
readOwnerProcessArgs: () => ["node", "/opt/openclaw/openclaw.mjs", "doctor"],
});
expect(locks.map((lock) => lock.lockPath)).toEqual([lockPath]);
} finally {
await other.cleanup();
}
});
it("preserves report-only live OpenClaw locks in dry-run repair effects", async () => {
const sessionsDir = state.sessionsDir();
await fs.mkdir(sessionsDir, { recursive: true });
const reportOnlyLock = path.join(sessionsDir, "report-only.jsonl.lock");
await fs.writeFile(
reportOnlyLock,
JSON.stringify({ pid: process.pid, createdAt: new Date(Date.now() - 45_000).toISOString() }),
"utf8",
);
const [lock] = await detectStaleSessionLocks({
staleMs: 30_000,
readOwnerProcessArgs: () => ["node", "/opt/openclaw/openclaw.mjs", "doctor"],
});
if (!lock) {
throw new Error("expected stale session lock");
}
expect(lock.staleReasons).toEqual(["too-old"]);
expect(sessionLockToHealthFinding(lock).fixHint).toBe(
"OpenClaw is preserving this live owned lock; inspect the owning process if it appears stuck.",
);
expect(sessionLockToRepairEffect(lock)).toEqual({
kind: "state",
action: "would-preserve-report-only-stale-session-lock",
target: reportOnlyLock,
dryRunSafe: false,
});
await expect(fs.access(reportOnlyLock)).resolves.toBeUndefined();
});
it("uses configured stale threshold without removing live OpenClaw lock files", async () => {
const sessionsDir = state.sessionsDir();
await fs.mkdir(sessionsDir, { recursive: true });

View File

@@ -9,8 +9,19 @@ import {
type SessionWriteLockAcquireTimeoutConfig,
} from "../agents/session-write-lock.js";
import { resolveStateDir } from "../config/paths.js";
import type { HealthFinding, HealthRepairEffect } from "../flows/health-checks.js";
import { shortenHomePath } from "../utils.js";
const SESSION_LOCKS_CHECK_ID = "core/doctor/session-locks";
const REPORT_ONLY_STALE_LOCK_REASONS = new Set(["too-old", "hold-exceeded"]);
function isReportOnlyStaleLock(lock: SessionLockInspection): boolean {
return (
lock.staleReasons.length > 0 &&
lock.staleReasons.every((reason) => REPORT_ONLY_STALE_LOCK_REASONS.has(reason))
);
}
function formatAge(ageMs: number | null): string {
if (ageMs === null) {
return "unknown";
@@ -40,6 +51,57 @@ function formatLockLine(lock: SessionLockInspection): string {
return `- ${shortenHomePath(lock.lockPath)} ${pidStatus} ${ageStatus} ${staleStatus}${removedStatus}`;
}
export async function detectStaleSessionLocks(params?: {
config?: SessionWriteLockAcquireTimeoutConfig;
env?: NodeJS.ProcessEnv;
staleMs?: number;
readOwnerProcessArgs?: SessionLockOwnerProcessArgsReader;
}): Promise<readonly SessionLockInspection[]> {
const staleMs = params?.staleMs ?? resolveSessionWriteLockStaleMs(params?.config, params?.env);
const env = params?.env ?? process.env;
const sessionDirs = await resolveAgentSessionDirs(resolveStateDir(env));
const staleLocks: SessionLockInspection[] = [];
for (const sessionsDir of sessionDirs) {
const result = await cleanStaleLockFiles({
sessionsDir,
staleMs,
removeStale: false,
readOwnerProcessArgs: params?.readOwnerProcessArgs,
});
staleLocks.push(...result.locks.filter((lock) => lock.stale));
}
return staleLocks.toSorted((a, b) => a.lockPath.localeCompare(b.lockPath));
}
export function sessionLockToHealthFinding(lock: SessionLockInspection): HealthFinding {
const fixHint = lock.removable
? 'Run "openclaw doctor --fix" to remove this stale lock file automatically.'
: isReportOnlyStaleLock(lock)
? "OpenClaw is preserving this live owned lock; inspect the owning process if it appears stuck."
: 'Run "openclaw doctor --fix" after the cleanup grace period if this stale lock remains.';
return {
checkId: SESSION_LOCKS_CHECK_ID,
severity: "warning",
message: `Stale session lock file: ${shortenHomePath(lock.lockPath)} (${lock.staleReasons.join(", ") || "unknown"})`,
path: lock.lockPath,
fixHint,
};
}
export function sessionLockToRepairEffect(lock: SessionLockInspection): HealthRepairEffect {
const action = lock.removable
? "would-remove-stale-session-lock"
: isReportOnlyStaleLock(lock)
? "would-preserve-report-only-stale-session-lock"
: "would-preserve-mtime-gated-stale-session-lock";
return {
kind: "state",
action,
target: lock.lockPath,
dryRunSafe: false,
};
}
/** Reports session write locks and removes stale locks when doctor repair is enabled. */
export async function noteSessionLockHealth(params?: {
shouldRepair?: boolean;

View File

@@ -1,7 +1,12 @@
// Context engine host compatibility tests cover doctor warnings for host/context mismatches.
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import { registerContextEngine } from "../../../context-engine/registry.js";
import {
getContextEngineFactory,
getContextEngineRegistration,
registerContextEngine,
registerContextEngineForOwner,
} from "../../../context-engine/registry.js";
import type { ContextEngine, ContextEngineHostCapability } from "../../../context-engine/types.js";
import {
collectConfiguredContextEngineAgentRunHosts,
@@ -60,6 +65,23 @@ function configWithEngine(engineId: string, cfg: OpenClawConfig = {}): OpenClawC
}
describe("doctor context-engine host compatibility", () => {
it("distinguishes read-only discovery registrations from runtime entries", () => {
const id = uniqueEngineId();
const factory = () => {
throw new Error("discovery-only");
};
const result = registerContextEngineForOwner(id, factory, `doctor-test-owner-${id}`, {
lifecycle: "readOnlyDiscovery",
});
expect(result).toEqual({ ok: true });
expect(getContextEngineRegistration(id)).toMatchObject({
factory,
lifecycle: "readOnlyDiscovery",
});
expect(getContextEngineFactory(id)).toBeUndefined();
});
it("collects native Codex and OpenClaw as compatible agent-run hosts", () => {
const hosts = collectConfiguredContextEngineAgentRunHosts({
cfg: {

View File

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

View File

@@ -370,6 +370,125 @@ describe("tasks commands", () => {
});
});
it("preserves both cron-run session key shapes for a running non-slug job id", async () => {
await withTaskCommandStateDir(async (state) => {
const now = Date.now();
const old = now - 8 * 24 * 60 * 60_000;
await saveCronStore(state.statePath("cron", "jobs.json"), {
version: 1,
jobs: [
{
id: "Daily Report",
name: "Daily Report",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "isolated",
sessionKey: "cron:daily-report",
wakeMode: "now",
payload: { kind: "agentTurn", message: "ping" },
delivery: { mode: "none" },
createdAtMs: now,
updatedAtMs: now,
state: { runningAtMs: now - 5_000 },
},
],
});
const sessionsDir = state.sessionsDir("main");
const storePath = path.join(sessionsDir, "sessions.json");
await fs.mkdir(sessionsDir, { recursive: true });
// A running job can be retargeted after its session is created, so maintenance must preserve
// both the raw and slugged historical shapes.
const slugKey = "agent:main:cron:daily-report:run:old-run";
const rawKey = "agent:main:cron:daily report:run:old-run";
const retiredKey = "agent:main:cron:retired-job:run:old-run";
await fs.writeFile(
storePath,
JSON.stringify(
{
[slugKey]: { sessionId: "slug-run", updatedAt: old },
[rawKey]: { sessionId: "raw-run", updatedAt: old },
[retiredKey]: { sessionId: "retired-run", updatedAt: old },
},
null,
2,
),
"utf8",
);
const runtime = createRuntime();
await tasksMaintenanceCommand({ json: true, apply: true }, runtime);
const payload = readFirstJsonLog(runtime) as {
maintenance: { sessions: { runningCronJobs: number } };
};
expect(payload.maintenance.sessions.runningCronJobs).toBe(1);
const updated = JSON.parse(await fs.readFile(storePath, "utf8")) as Record<string, unknown>;
expect(updated[slugKey]).toBeDefined();
expect(updated[rawKey]).toBeDefined();
expect(updated[retiredKey]).toBeUndefined();
});
});
it("preserves a running cron session with an explicit session key", async () => {
await withTaskCommandStateDir(async (state) => {
const now = Date.now();
const old = now - 8 * 24 * 60 * 60_000;
await saveCronStore(state.statePath("cron", "jobs.json"), {
version: 1,
jobs: [
{
id: "job-uuid",
name: "Daily monitor",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "isolated",
sessionKey: "cron:daily-monitor",
wakeMode: "now",
payload: { kind: "agentTurn", message: "ping" },
delivery: { mode: "none" },
createdAtMs: now,
updatedAtMs: now,
state: { runningAtMs: now - 5_000 },
},
],
});
const sessionsDir = state.sessionsDir("main");
const storePath = path.join(sessionsDir, "sessions.json");
await fs.mkdir(sessionsDir, { recursive: true });
await fs.writeFile(
storePath,
JSON.stringify(
{
"agent:main:cron:daily-monitor:run:old-run": {
sessionId: "explicit-run",
updatedAt: old,
},
"agent:main:cron:job-uuid:run:old-run": {
sessionId: "job-id-run",
updatedAt: old,
},
"agent:main:cron:retired-job:run:old-run": {
sessionId: "retired-run",
updatedAt: old,
},
},
null,
2,
),
"utf8",
);
const runtime = createRuntime();
await tasksMaintenanceCommand({ json: true, apply: true }, runtime);
const updated = JSON.parse(await fs.readFile(storePath, "utf8")) as Record<string, unknown>;
expect(updated["agent:main:cron:daily-monitor:run:old-run"]).toBeDefined();
expect(updated["agent:main:cron:retired-job:run:old-run"]).toBeUndefined();
});
});
it("does not build JSON-only diagnostics for text maintenance output", async () => {
await withTaskCommandStateDir(async () => {
const diagnosticsSpy = vi.spyOn(

View File

@@ -11,6 +11,7 @@ import {
resolveAllAgentSessionStoreTargetsSync,
runSessionRegistryMaintenanceForStore,
} from "../config/sessions.js";
import { normalizeCronLaneSegment } from "../cron/service/task-runs.js";
import { loadCronJobsStoreSync, resolveCronJobsStorePath } from "../cron/store.js";
import type { RuntimeEnv } from "../runtime.js";
import { getTaskById, updateTaskNotifyPolicyById } from "../tasks/runtime-internal.js";
@@ -128,17 +129,36 @@ type SessionRegistryMaintenanceSummary = {
stores: SessionRegistryMaintenanceStoreSummary[];
};
function readRunningCronJobIds(): Set<string> {
function resolveExplicitCronSessionSegment(sessionKey: string | undefined): string | undefined {
const match = /^(?:agent:[^:]+:)?cron:([^:]+)$/u.exec(sessionKey?.trim() ?? "");
return match?.[1]?.toLowerCase();
}
function readRunningCronJobIds(): { ids: Set<string>; count: number } {
try {
const cronStorePath = resolveCronJobsStorePath(getRuntimeConfig().cron?.store);
return new Set(
loadCronJobsStoreSync(cronStorePath)
.jobs.filter((job) => typeof job.state?.runningAtMs === "number")
// Cron session keys are matched case-insensitively against job ids.
.map((job) => job.id.toLowerCase()),
const runningJobs = loadCronJobsStoreSync(cronStorePath).jobs.filter(
(job) => typeof job.state?.runningAtMs === "number",
);
return {
// A running job may have been retargeted after its session was created. Keep both historical
// shapes; the registry has no producer metadata, so retaining an ambiguous alias is safer
// than pruning a live transcript.
ids: new Set(
runningJobs.flatMap((job) => [
job.id.toLowerCase(),
normalizeCronLaneSegment(job.id, "job"),
...(job.sessionTarget !== "main" && job.sessionKey
? [resolveExplicitCronSessionSegment(job.sessionKey)].filter(
(segment): segment is string => segment !== undefined,
)
: []),
]),
),
count: runningJobs.length,
};
} catch {
return new Set();
return { ids: new Set(), count: 0 };
}
}
@@ -146,13 +166,13 @@ async function runSessionRegistryMaintenance(params: {
apply: boolean;
}): Promise<SessionRegistryMaintenanceSummary> {
const cfg = getRuntimeConfig();
const runningCronJobIds = readRunningCronJobIds();
const runningCronJobs = readRunningCronJobIds();
const stores: SessionRegistryMaintenanceStoreSummary[] = [];
for (const target of resolveAllAgentSessionStoreTargetsSync(cfg)) {
const result = await runSessionRegistryMaintenanceForStore({
apply: params.apply,
retentionMs: SESSION_REGISTRY_RETENTION_MS,
runningCronJobIds,
runningCronJobIds: runningCronJobs.ids,
storePath: target.storePath,
});
stores.push({
@@ -166,7 +186,7 @@ async function runSessionRegistryMaintenance(params: {
}
return {
retentionMs: SESSION_REGISTRY_RETENTION_MS,
runningCronJobs: runningCronJobIds.size,
runningCronJobs: runningCronJobs.count,
pruned: stores.reduce((total, store) => total + store.pruned, 0),
stores,
};

View File

@@ -1199,6 +1199,7 @@ function resolveConfigIncludesForRead(
deps: Required<ConfigIoDeps>,
includeFileHashesForWrite?: Record<string, string>,
includeFileTargetsForWrite?: Record<string, string>,
includeFilePaths?: Set<string>,
): unknown {
const allowedRoots = resolveIncludeRoots(deps.env, deps.homedir);
const recordIncludeTarget = (resolvedPath: string, canonicalPath?: string) => {
@@ -1231,7 +1232,10 @@ function resolveConfigIncludesForRead(
resolvedPath,
rootRealDir,
ioFs: deps.fs,
onResolvedPath: (canonicalPath) => recordIncludeTarget(resolvedPath, canonicalPath),
onResolvedPath: (canonicalPath) => {
recordIncludeTarget(resolvedPath, canonicalPath);
includeFilePaths?.add(path.normalize(canonicalPath));
},
});
if (includeFileHashesForWrite) {
includeFileHashesForWrite[path.normalize(resolvedPath)] = hashConfigIncludeRaw(raw);
@@ -1307,11 +1311,13 @@ type ReadConfigFileSnapshotInternalResult = {
envSnapshotForRestore?: Record<string, string | undefined>;
includeFileHashesForWrite?: Record<string, string>;
includeFileTargetsForWrite?: Record<string, string>;
includeFilePaths?: readonly string[];
pluginMetadataSnapshot?: PluginMetadataSnapshot;
};
export type ReadConfigFileSnapshotWithPluginMetadataResult = {
snapshot: ConfigFileSnapshot;
includeFilePaths?: readonly string[];
pluginMetadataSnapshot?: PluginMetadataSnapshot;
};
@@ -1868,6 +1874,7 @@ export function createConfigIO(
let fallbackEnvSnapshotForRestore: Record<string, string | undefined> | undefined;
const includeFileHashesForWrite: Record<string, string> = {};
const includeFileTargetsForWrite: Record<string, string> = {};
const includeFilePaths = new Set<string>();
try {
const raw = await deps.measure("config.snapshot.read.file", () =>
@@ -1916,6 +1923,7 @@ export function createConfigIO(
deps,
includeFileHashesForWrite,
includeFileTargetsForWrite,
includeFilePaths,
),
);
} catch (err) {
@@ -2086,6 +2094,7 @@ export function createConfigIO(
envSnapshotForRestore: readResolution.envSnapshotForRestore,
includeFileHashesForWrite,
includeFileTargetsForWrite,
includeFilePaths: [...includeFilePaths].toSorted(),
pluginMetadataSnapshot: validationPluginMetadata.getSnapshot(),
},
{ observe: !callerRejectedSuspiciousRecovery },
@@ -2151,6 +2160,7 @@ export function createConfigIO(
});
return {
snapshot: result.snapshot,
...(result.snapshot.valid ? { includeFilePaths: result.includeFilePaths ?? [] } : {}),
...(result.pluginMetadataSnapshot
? { pluginMetadataSnapshot: result.pluginMetadataSnapshot }
: {}),

View File

@@ -1865,6 +1865,57 @@ describe("config io write", () => {
});
});
it.runIf(process.platform !== "win32")(
"exposes only canonical valid include paths through the metadata wrapper",
async () => {
await withSuiteHome(async (home) => {
const configDir = path.join(home, ".openclaw");
const configPath = path.join(configDir, "openclaw.json");
const fragmentsDir = path.join(configDir, "fragments");
const aliasDir = path.join(configDir, "alias");
const defaultsPath = path.join(fragmentsDir, "defaults.json5");
const nestedPath = path.join(fragmentsDir, "nested.json5");
await fs.mkdir(fragmentsDir, { recursive: true });
await fs.symlink(fragmentsDir, aliasDir, "dir");
await fs.writeFile(nestedPath, '{ workspace: "~/.openclaw/workspace" }\n', "utf-8");
await fs.writeFile(
defaultsPath,
'{ $include: "./nested.json5", maxConcurrent: 1 }\n',
"utf-8",
);
await fs.writeFile(
configPath,
'{ agents: { defaults: { $include: "./alias/defaults.json5" } } }\n',
"utf-8",
);
const io = createConfigIO({
env: { OPENCLAW_TEST_FAST: "1" } as NodeJS.ProcessEnv,
homedir: () => home,
logger: silentLogger,
});
const valid = await io.readConfigFileSnapshotWithPluginMetadata();
expect(valid.snapshot.valid).toBe(true);
expect(valid.includeFilePaths).toEqual(
[await fs.realpath(defaultsPath), await fs.realpath(nestedPath)].toSorted(),
);
expect(valid.includeFilePaths).not.toContain(path.join(aliasDir, "defaults.json5"));
expect(valid.snapshot).not.toHaveProperty("includeFilePaths");
await fs.writeFile(nestedPath, "{ malformed", "utf-8");
const invalid = await io.readConfigFileSnapshotWithPluginMetadata();
expect(invalid.snapshot.valid).toBe(false);
expect(invalid).not.toHaveProperty("includeFilePaths");
await fs.rm(nestedPath);
const missing = await io.readConfigFileSnapshotWithPluginMetadata();
expect(missing.snapshot.valid).toBe(false);
expect(missing).not.toHaveProperty("includeFilePaths");
});
},
);
it("repairs invalid root-authored siblings without flattening included config", async () => {
await withSuiteHome(async (home) => {
const configPath = path.join(home, ".openclaw", "openclaw.json");

View File

@@ -541,6 +541,46 @@ export type RestoreSessionFromCompactionCheckpointParams = {
storePath: string;
};
export type TemporarySessionMappingPreservationResult<T> = {
/** Result returned by the operation while the temporary mapping may exist. */
result: T;
/** Snapshot failure; callers may continue when temporary cleanup is best-effort. */
snapshotFailure?: string;
/** Restore/delete failure for the original temporary mapping state. */
restoreFailure?: string;
};
type TemporarySessionMappingSnapshot =
| {
canRestore: false;
sessionKey: string;
snapshotFailure: string;
storePath: string;
}
| {
canRestore: true;
hadEntry: false;
sessionKey: string;
storePath: string;
}
| {
canRestore: true;
entry: SessionEntry;
hadEntry: true;
sessionKey: string;
storePath: string;
};
type TemporarySessionMappingOperationResult<T> =
| {
ok: true;
result: T;
}
| {
error: unknown;
ok: false;
};
export type SessionEntryCreateWithTranscriptContext = {
/** Current entry under the requested key before creation, if any. */
existingEntry?: SessionEntry;
@@ -1393,6 +1433,37 @@ export async function applyRestartRecoveryLifecycle<T>(params: {
return writerResult.result;
}
/**
* Runs an operation while preserving one temporary session mapping.
* The storage backend snapshots exactly the named key before the operation and
* restores that entry, or deletes it when it did not previously exist, after
* the operation finishes. SQLite backends can implement the same named
* preservation lifecycle without exposing mutable store access to callers.
*/
export async function preserveTemporarySessionMapping<T>(
scope: SessionAccessScope,
operation: () => Promise<T> | T,
): Promise<TemporarySessionMappingPreservationResult<T>> {
const snapshot = snapshotTemporarySessionMapping(scope);
let operationResult: TemporarySessionMappingOperationResult<T>;
try {
operationResult = { ok: true, result: await operation() };
} catch (err) {
operationResult = { error: err, ok: false };
}
const restoreFailure = await restoreTemporarySessionMapping(snapshot);
if (!operationResult.ok) {
throw operationResult.error;
}
return {
result: operationResult.result,
...(snapshot.canRestore ? {} : { snapshotFailure: snapshot.snapshotFailure }),
...(restoreFailure ? { restoreFailure } : {}),
};
}
/** Removes entries and orphan transcript artifacts owned by a named session lifecycle. */
export async function cleanupSessionLifecycleArtifacts(
params: SessionLifecycleArtifactCleanupParams,
@@ -2515,6 +2586,53 @@ function createFallbackSessionEntry(patch: Partial<SessionEntry>): SessionEntry
};
}
function snapshotTemporarySessionMapping(
scope: SessionAccessScope,
): TemporarySessionMappingSnapshot {
const storePath = resolveAccessStorePath(scope);
try {
const store = loadSessionStore(storePath, { skipCache: true });
const entry = store[scope.sessionKey];
return {
canRestore: true,
...(entry ? { entry: structuredClone(entry), hadEntry: true } : { hadEntry: false }),
sessionKey: scope.sessionKey,
storePath,
};
} catch (err) {
return {
canRestore: false,
sessionKey: scope.sessionKey,
snapshotFailure: formatErrorMessage(err),
storePath,
};
}
}
async function restoreTemporarySessionMapping(
snapshot: TemporarySessionMappingSnapshot,
): Promise<string | undefined> {
if (!snapshot.canRestore) {
return undefined;
}
try {
await updateSessionStore(
snapshot.storePath,
(store) => {
if (snapshot.hadEntry) {
store[snapshot.sessionKey] = structuredClone(snapshot.entry);
return;
}
delete store[snapshot.sessionKey];
},
{ activeSessionKey: snapshot.sessionKey },
);
return undefined;
} catch (err) {
return formatErrorMessage(err);
}
}
function cleanupPreviousResetTranscripts(params: {
agentId: string;
previousEntry: SessionEntry;

View File

@@ -1084,6 +1084,88 @@ describe("Factory context passing", () => {
});
});
describe("Read-only plugin discovery registrations", () => {
beforeEach(() => {
registerLegacyContextEngine();
clearContextEngineRuntimeQuarantine();
vi.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("does not construct or quarantine read-only discovery context-engine factories", async () => {
const engineId = uniqueEngineId("lossless-readonly");
const owner = "plugin:lossless-claw";
let readOnlyFactoryCalls = 0;
let runtimeFactoryCalls = 0;
registerContextEngineForOwner(
engineId,
() => {
readOnlyFactoryCalls += 1;
throw new Error("Engine initialization is disabled during read-only plugin registration");
},
owner,
{ allowSameOwnerRefresh: true, lifecycle: "readOnlyDiscovery" },
);
const discoveryFallback = await resolveContextEngine(configWithSlot(engineId));
expect(discoveryFallback.info.id).toBe("legacy");
expect(readOnlyFactoryCalls).toBe(0);
expect(listContextEngineQuarantines().some((entry) => entry.engineId === engineId)).toBe(false);
expect(console.warn).toHaveBeenCalledWith(
`[context-engine] Context engine "${engineId}" owner=${owner} is registered for read-only discovery only; falling back to default engine "legacy" without quarantine until runtime activation registers it.`,
);
registerContextEngineForOwner(
engineId,
() => {
runtimeFactoryCalls += 1;
return {
info: { id: "lossless-claw", name: "Lossless Claw" },
async ingest() {
return { ingested: true };
},
async assemble({ messages }: { messages: AgentMessage[] }) {
return { messages, estimatedTokens: 0 };
},
async compact() {
return { ok: true, compacted: false };
},
} satisfies ContextEngine;
},
owner,
{ allowSameOwnerRefresh: true, lifecycle: "runtime" },
);
const runtimeEngine = await resolveContextEngine(configWithSlot(engineId));
expect(runtimeEngine.info.id).toBe("lossless-claw");
expect(readOnlyFactoryCalls).toBe(0);
expect(runtimeFactoryCalls).toBe(1);
expect(listContextEngineQuarantines().some((entry) => entry.engineId === engineId)).toBe(false);
registerContextEngineForOwner(
engineId,
() => {
readOnlyFactoryCalls += 1;
throw new Error("read-only discovery should not replace runtime registration");
},
owner,
{ allowSameOwnerRefresh: true, lifecycle: "readOnlyDiscovery" },
);
const stillRuntimeEngine = await resolveContextEngine(configWithSlot(engineId));
expect(stillRuntimeEngine.info.id).toBe("lossless-claw");
expect(readOnlyFactoryCalls).toBe(0);
expect(runtimeFactoryCalls).toBe(2);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// 4. Invalid engine fallback
// ═══════════════════════════════════════════════════════════════════════════

View File

@@ -44,9 +44,16 @@ export type ContextEngineFactory = (
ctx: ContextEngineFactoryContext,
) => ContextEngine | Promise<ContextEngine>;
export type ContextEngineRegistrationResult = { ok: true } | { ok: false; existingOwner: string };
export type ContextEngineRegistrationLifecycle = "runtime" | "readOnlyDiscovery";
export type ContextEngineRegistration = {
factory: ContextEngineFactory;
owner: string;
lifecycle: ContextEngineRegistrationLifecycle;
};
type RegisterContextEngineForOwnerOptions = {
allowSameOwnerRefresh?: boolean;
lifecycle?: ContextEngineRegistrationLifecycle;
};
const LEGACY_SESSION_KEY_COMPAT = Symbol.for("openclaw.contextEngine.sessionKeyCompat");
@@ -389,13 +396,7 @@ export type ContextEngineRuntimeQuarantine = {
};
type ContextEngineRegistryState = {
engines: Map<
string,
{
factory: ContextEngineFactory;
owner: string;
}
>;
engines: Map<string, ContextEngineRegistration>;
quarantinedEngines: Map<string, ContextEngineRuntimeQuarantine>;
};
@@ -512,6 +513,7 @@ export function registerContextEngineForOwner(
opts?: RegisterContextEngineForOwnerOptions,
): ContextEngineRegistrationResult {
const normalizedOwner = requireContextEngineOwner(owner);
const lifecycle = opts?.lifecycle ?? "runtime";
const registry = getContextEngineRegistryState().engines;
const existing = registry.get(id);
if (
@@ -524,11 +526,18 @@ export function registerContextEngineForOwner(
if (existing && existing.owner !== normalizedOwner) {
return { ok: false, existingOwner: existing.owner };
}
if (existing?.lifecycle === "runtime" && lifecycle === "readOnlyDiscovery") {
// Read-only discovery may re-run after live activation. It can collect metadata, but it must
// not replace the runtime-safe factory with a closure that captured a read-only plugin mode.
return { ok: true };
}
if (existing && opts?.allowSameOwnerRefresh !== true) {
return { ok: false, existingOwner: existing.owner };
}
registry.set(id, { factory, owner: normalizedOwner });
clearContextEngineRuntimeQuarantine(id);
registry.set(id, { factory, owner: normalizedOwner, lifecycle });
if (lifecycle === "runtime") {
clearContextEngineRuntimeQuarantine(id);
}
return { ok: true };
}
@@ -550,7 +559,13 @@ export function registerContextEngine(
* Return the factory for a registered engine, or undefined.
*/
export function getContextEngineFactory(id: string): ContextEngineFactory | undefined {
return getContextEngineRegistryState().engines.get(id)?.factory;
const registration = getContextEngineRegistration(id);
return registration?.lifecycle === "runtime" ? registration.factory : undefined;
}
/** Returns registration metadata so callers can distinguish discovery snapshots from runtime entries. */
export function getContextEngineRegistration(id: string): ContextEngineRegistration | undefined {
return getContextEngineRegistryState().engines.get(id);
}
/**
@@ -945,6 +960,13 @@ export async function resolveContextEngine(
return resolveDefaultContextEngine(defaultEngineId, factoryCtx);
}
if (!isDefaultEngine && entry.lifecycle === "readOnlyDiscovery") {
console.warn(
`[context-engine] Context engine "${engineId}" owner=${entry.owner} is registered for read-only discovery only; falling back to default engine "${defaultEngineId}" without quarantine until runtime activation registers it.`,
);
return resolveDefaultContextEngine(defaultEngineId, factoryCtx);
}
let engine: ContextEngine;
try {
engine = await entry.factory(factoryCtx);

View File

@@ -161,8 +161,19 @@ function buildCronDeliveryTargetRuntimeContext(params: {
].join("\n");
}
function resolveCliRuntimeToolsAllow(toolsAllow?: string[]): string[] | undefined {
return toolsAllow?.some((toolName) => normalizeToolName(toolName) === "*")
function resolveCliRuntimeToolsAllow(
toolsAllow?: string[],
toolsAllowIsDefault?: boolean,
): string[] | undefined {
if (toolsAllow === undefined) {
return undefined;
}
// CLI runners reject runtime toolsAllow. Drop only the auto-stamped default;
// explicit per-cron restrictions stay fail-closed in prepareCliRunContext.
if (toolsAllowIsDefault) {
return undefined;
}
return toolsAllow.some((toolName) => normalizeToolName(toolName) === "*")
? undefined
: toolsAllow;
}
@@ -326,7 +337,10 @@ export function createCronPromptExecutor(params: {
messageChannel,
sourceReplyDeliveryMode,
requireExplicitMessageTarget: params.sourceDelivery.messageTool.requireExplicitTarget,
toolsAllow: resolveCliRuntimeToolsAllow(params.agentPayload?.toolsAllow),
toolsAllow: resolveCliRuntimeToolsAllow(
params.agentPayload?.toolsAllow,
params.agentPayload?.toolsAllowIsDefault,
),
abortSignal: params.abortSignal,
onExecutionStarted: params.onExecutionStarted,
onExecutionPhase: params.onExecutionPhase,

View File

@@ -825,6 +825,41 @@ describe("runCronIsolatedAgentTurn message tool policy", () => {
expect(cliRun.prompt).toContain("Message delivery destination metadata");
});
it("drops the auto-applied default toolsAllow cap for CLI-backed runs instead of failing", async () => {
// A CLI backend cannot enforce a runtime toolsAllow, so the auto-applied
// creator-surface cap (#91499, flagged toolsAllowIsDefault) is dropped at
// run time rather than handed to the CLI runner — which would otherwise
// reject the run. An explicit user restriction (no flag) is still
// propagated; see the "restricted toolsAllow" case above.
mockRunCronFallbackPassthrough();
resolveCronDeliveryPlanMock.mockReturnValue(makeAnnounceDeliveryPlan());
isCliProviderMock.mockReturnValue(true);
runCliAgentMock.mockResolvedValue({
payloads: [{ text: "done" }],
meta: { agentMeta: { usage: { input: 10, output: 20 } } },
});
await runCronIsolatedAgentTurn({
...makeParams(),
job: makeMessageToolPolicyJob(
{ mode: "announce", channel: "messagechat", to: "123" },
{
kind: "agentTurn",
message: "send a message",
toolsAllow: ["read", "cron"],
toolsAllowIsDefault: true,
},
),
});
const cliRun = expectRecordFields(
getMockCallArg(runCliAgentMock, 0, 0, "CLI run"),
{},
"CLI run params",
);
expect(cliRun.toolsAllow).toBeUndefined();
});
it("keeps automatic exec completion notifications when announce delivery is active", async () => {
mockRunCronFallbackPassthrough();
resolveCronDeliveryPlanMock.mockReturnValue(makeAnnounceDeliveryPlan());

View File

@@ -392,6 +392,63 @@ describe("applyJobPatch", () => {
}
});
it("clears the default toolsAllow flag when editing to an explicit restriction", () => {
const job = createIsolatedAgentTurnJob("job-tools-explicit", {
mode: "announce",
channel: "telegram",
});
job.payload = {
kind: "agentTurn",
message: "do it",
toolsAllow: ["exec", "read"],
toolsAllowIsDefault: true,
};
applyJobPatch(job, {
payload: {
kind: "agentTurn",
message: "do it",
toolsAllow: ["read"],
toolsAllowIsDefault: true,
},
});
expect(job.payload.kind).toBe("agentTurn");
if (job.payload.kind === "agentTurn") {
expect(job.payload.toolsAllow).toEqual(["read"]);
expect(job.payload.toolsAllowIsDefault).toBeUndefined();
}
});
it("preserves the default toolsAllow flag when a full payload edit keeps the default list", () => {
const job = createIsolatedAgentTurnJob("job-tools-default-edit", {
mode: "announce",
channel: "telegram",
});
job.payload = {
kind: "agentTurn",
message: "do it",
toolsAllow: ["exec", "read"],
toolsAllowIsDefault: true,
};
applyJobPatch(job, {
payload: {
kind: "agentTurn",
message: "do it later",
toolsAllow: ["exec", "read"],
toolsAllowIsDefault: true,
},
});
expect(job.payload.kind).toBe("agentTurn");
if (job.payload.kind === "agentTurn") {
expect(job.payload.message).toBe("do it later");
expect(job.payload.toolsAllow).toEqual(["exec", "read"]);
expect(job.payload.toolsAllowIsDefault).toBe(true);
}
});
it("clears agentTurn payload.toolsAllow when patch requests null", () => {
const job = createIsolatedAgentTurnJob("job-tools-clear", {
mode: "announce",
@@ -401,6 +458,7 @@ describe("applyJobPatch", () => {
kind: "agentTurn",
message: "do it",
toolsAllow: ["exec", "read"],
toolsAllowIsDefault: true,
};
applyJobPatch(job, {
@@ -414,6 +472,7 @@ describe("applyJobPatch", () => {
expect(job.payload.kind).toBe("agentTurn");
if (job.payload.kind === "agentTurn") {
expect(job.payload.toolsAllow).toBeUndefined();
expect(job.payload.toolsAllowIsDefault).toBeUndefined();
}
});
@@ -527,6 +586,30 @@ describe("applyJobPatch", () => {
}
});
it("carries payload.toolsAllow default flag when replacing payload kind via patch", () => {
const job = createIsolatedAgentTurnJob("job-tools-default-switch", {
mode: "announce",
channel: "telegram",
});
job.payload = { kind: "systemEvent", text: "ping" };
applyJobPatch(job, {
payload: {
kind: "agentTurn",
message: "do it",
toolsAllow: ["exec", "read"],
toolsAllowIsDefault: true,
},
});
const payload = job.payload as CronJob["payload"];
expect(payload.kind).toBe("agentTurn");
if (payload.kind === "agentTurn") {
expect(payload.toolsAllow).toEqual(["exec", "read"]);
expect(payload.toolsAllowIsDefault).toBe(true);
}
});
it.each([
{ name: "no delivery update", patch: { enabled: true } satisfies CronJobPatch },
{

View File

@@ -40,6 +40,9 @@ const STUCK_RUN_MS = 2 * 60 * 60 * 1000;
const STAGGER_OFFSET_CACHE_MAX = 4096;
const staggerOffsetCache = new Map<string, number>();
type CronAgentTurnPayload = Extract<CronPayload, { kind: "agentTurn" }>;
type CronAgentTurnPayloadPatch = Extract<CronPayloadPatch, { kind: "agentTurn" }>;
/** Default retry delays applied after consecutive cron execution errors. */
export const DEFAULT_ERROR_BACKOFF_SCHEDULE_MS = [
30_000,
@@ -897,6 +900,42 @@ export function applyJobPatch(
}
}
function applyAgentTurnToolsAllowPatch(
payload: CronAgentTurnPayload,
patch: CronAgentTurnPayloadPatch,
existing?: CronAgentTurnPayload,
): void {
if (Array.isArray(patch.toolsAllow)) {
payload.toolsAllow = patch.toolsAllow;
// Same-kind edits keep the marker only when the default list is unchanged;
// kind replacements carry the cron-tool-stamped marker into persistence.
if (
patch.toolsAllowIsDefault === true &&
(!existing || (existing.toolsAllowIsDefault === true && toolsAllowEqual(existing, patch)))
) {
payload.toolsAllowIsDefault = true;
} else {
delete payload.toolsAllowIsDefault;
}
} else if (patch.toolsAllow === null) {
delete payload.toolsAllow;
delete payload.toolsAllowIsDefault;
}
}
function toolsAllowEqual(
left: Pick<CronAgentTurnPayload, "toolsAllow">,
right: Pick<CronAgentTurnPayloadPatch, "toolsAllow">,
): boolean {
const rightToolsAllow = right.toolsAllow;
return (
Array.isArray(left.toolsAllow) &&
Array.isArray(rightToolsAllow) &&
left.toolsAllow.length === rightToolsAllow.length &&
left.toolsAllow.every((toolName, index) => toolName === rightToolsAllow[index])
);
}
function mergeCronPayload(existing: CronPayload, patch: CronPayloadPatch): CronPayload {
if (patch.kind !== existing.kind) {
return buildPayloadFromPatch(patch);
@@ -943,7 +982,7 @@ function mergeCronPayload(existing: CronPayload, patch: CronPayloadPatch): CronP
return buildPayloadFromPatch(patch);
}
const next: Extract<CronPayload, { kind: "agentTurn" }> = { ...existing };
const next: CronAgentTurnPayload = { ...existing };
if (typeof patch.message === "string") {
next.message = patch.message;
}
@@ -957,11 +996,7 @@ function mergeCronPayload(existing: CronPayload, patch: CronPayloadPatch): CronP
} else if (patch.fallbacks === null) {
delete next.fallbacks;
}
if (Array.isArray(patch.toolsAllow)) {
next.toolsAllow = patch.toolsAllow;
} else if (patch.toolsAllow === null) {
delete next.toolsAllow;
}
applyAgentTurnToolsAllowPatch(next, patch, existing);
if (typeof patch.thinking === "string") {
next.thinking = patch.thinking;
}
@@ -1005,17 +1040,18 @@ function buildPayloadFromPatch(patch: CronPayloadPatch): CronPayload {
throw new Error('cron.update payload.kind="agentTurn" requires message');
}
return {
const next: CronAgentTurnPayload = {
kind: "agentTurn",
message: patch.message,
model: typeof patch.model === "string" ? patch.model : undefined,
fallbacks: Array.isArray(patch.fallbacks) ? patch.fallbacks : undefined,
toolsAllow: Array.isArray(patch.toolsAllow) ? patch.toolsAllow : undefined,
thinking: patch.thinking,
timeoutSeconds: patch.timeoutSeconds,
lightContext: patch.lightContext,
allowUnsafeExternalContent: patch.allowUnsafeExternalContent,
};
applyAgentTurnToolsAllowPatch(next, patch);
return next;
}
function mergeCronDelivery(

View File

@@ -522,6 +522,48 @@ describe("cron store", () => {
});
});
it("round-trips the toolsAllow default-cap flag through SQLite", async () => {
// The flag must survive a gateway restart: without it, a CLI-resolved run
// would re-hit the prepare.ts toolsAllow rejection after reload (#91499).
const store = await makeStorePath();
const payload = makeStore("tools-allow-default-job", true);
payload.jobs[0].sessionTarget = "isolated";
payload.jobs[0].payload = {
kind: "agentTurn",
message: "scheduled continuation",
toolsAllow: ["read", "cron"],
toolsAllowIsDefault: true,
};
await saveCronStore(store.storePath, payload);
expect((await loadCronStore(store.storePath)).jobs[0]?.payload).toMatchObject({
kind: "agentTurn",
toolsAllow: ["read", "cron"],
toolsAllowIsDefault: true,
});
});
it("does not persist a default-cap flag for an explicit toolsAllow restriction", async () => {
// An explicit user restriction is fail-closed: it carries no flag, so a CLI
// run still surfaces the prepare.ts rejection rather than silently dropping
// the requested policy.
const store = await makeStorePath();
const payload = makeStore("tools-allow-explicit-job", true);
payload.jobs[0].sessionTarget = "isolated";
payload.jobs[0].payload = {
kind: "agentTurn",
message: "scheduled continuation",
toolsAllow: ["read"],
};
await saveCronStore(store.storePath, payload);
const reloaded = (await loadCronStore(store.storePath)).jobs[0]?.payload;
expect(reloaded).toMatchObject({ kind: "agentTurn", toolsAllow: ["read"] });
expect(reloaded && "toolsAllowIsDefault" in reloaded).toBe(false);
});
it("round-trips command payloads through SQLite", async () => {
const store = await makeStorePath();
const payload = makeStore("command-job", true);

View File

@@ -75,6 +75,7 @@ export function bindPayloadColumns(
| "payload_thinking"
| "payload_timeout_seconds"
| "payload_tools_allow_json"
| "payload_tools_allow_is_default"
> {
if (payload.kind === "systemEvent") {
return {
@@ -88,6 +89,7 @@ export function bindPayloadColumns(
payload_external_content_source_json: null,
payload_light_context: null,
payload_tools_allow_json: null,
payload_tools_allow_is_default: null,
};
}
if (payload.kind === "command") {
@@ -103,6 +105,7 @@ export function bindPayloadColumns(
payload_external_content_source_json: null,
payload_light_context: null,
payload_tools_allow_json: null,
payload_tools_allow_is_default: null,
};
}
return {
@@ -116,6 +119,9 @@ export function bindPayloadColumns(
payload_external_content_source_json: serializeJson(payload.externalContentSource),
payload_light_context: booleanToInteger(payload.lightContext),
payload_tools_allow_json: serializeJson(payload.toolsAllow),
payload_tools_allow_is_default: payload.toolsAllow
? booleanToInteger(payload.toolsAllowIsDefault)
: null,
};
}
@@ -144,6 +150,10 @@ export function payloadFromRow(row: CronJobRow): CronPayload | null {
const toolsAllow = row.payload_tools_allow_json
? parseJsonArray(row.payload_tools_allow_json)
: undefined;
const toolsAllowIsDefault =
row.payload_tools_allow_is_default != null
? integerToBoolean(row.payload_tools_allow_is_default)
: undefined;
return {
kind: "agentTurn",
message: row.payload_message,
@@ -155,6 +165,7 @@ export function payloadFromRow(row: CronJobRow): CronPayload | null {
...(externalContentSource ? { externalContentSource } : {}),
...(lightContext != null ? { lightContext } : {}),
...(toolsAllow ? { toolsAllow } : {}),
...(toolsAllow && toolsAllowIsDefault ? { toolsAllowIsDefault: true } : {}),
};
}
if (row.payload_kind === "command") {

View File

@@ -248,6 +248,8 @@ type CronAgentTurnPayloadFields = {
lightContext?: boolean;
/** Optional tool allow-list; when set, only these tools are sent to the model. */
toolsAllow?: string[];
/** Server-managed marker for auto-stamped defaults; explicit restrictions omit it. */
toolsAllowIsDefault?: boolean;
};
type CronAgentTurnPayload = {

View File

@@ -748,4 +748,28 @@ describe("CORE_HEALTH_CHECKS", () => {
}),
);
});
it("registers stale session locks as a legacy-owned structured check", async () => {
const check = getCheck(createCoreHealthChecks(createDeps()), "core/doctor/session-locks");
if (typeof check.repair !== "function") {
throw new Error("expected session lock check repair");
}
await expect(
check.repair(
{
mode: "fix",
runtime,
cfg: {},
cwd: "/tmp/openclaw-test-workspace",
},
[],
),
).resolves.toEqual(
expect.objectContaining({
status: "skipped",
reason: "legacy doctor session lock contribution owns cleanup",
}),
);
});
});

View File

@@ -13,6 +13,11 @@ import {
shellCompletionStatusToHealthFindings,
shellCompletionStatusToRepairEffects,
} from "../commands/doctor-completion.js";
import {
detectStaleSessionLocks,
sessionLockToHealthFinding,
sessionLockToRepairEffect,
} from "../commands/doctor-session-locks.js";
import {
disableUnavailableSkillsInConfig,
formatMissingSkillSummary,
@@ -31,6 +36,7 @@ import { resolveGatewayAuth } from "../gateway/auth.js";
import { getSkippedExecRefStaticError } from "../secrets/exec-resolution-policy.js";
import type { SkillStatusEntry } from "../skills/discovery/status.js";
import { registerHealthCheck } from "./health-check-registry.js";
import type { SplitHealthCheckInput } from "./health-check-runner-types.js";
import type {
HealthCheck,
HealthCheckContext,
@@ -42,6 +48,7 @@ const BROWSER_CLAWD_PROFILE_RESIDUE_CHECK_ID = "core/doctor/browser-clawd-profil
const CODEX_SESSION_ROUTES_CHECK_ID = "core/doctor/codex-session-routes";
const FINAL_CONFIG_VALIDATION_CHECK_ID = "core/doctor/final-config-validation";
const GATEWAY_SERVICES_EXTRA_CHECK_ID = "core/doctor/gateway-services/extra";
const SESSION_LOCKS_CHECK_ID = "core/doctor/session-locks";
type CoreHealthCheckContext = HealthCheckContext & {
readonly deep?: boolean;
@@ -729,6 +736,33 @@ const gatewayPlatformNotesCheck: HealthCheck = {
},
};
const sessionLocksCheck: SplitHealthCheckInput = {
id: SESSION_LOCKS_CHECK_ID,
kind: "core",
description: "Stale session lock files are represented as structured findings.",
source: "doctor",
defaultEnabled: false,
async detect(ctx) {
return (await detectStaleSessionLocks({ config: ctx.cfg, env: process.env })).map(
sessionLockToHealthFinding,
);
},
async repair(ctx) {
const effects = (await detectStaleSessionLocks({ config: ctx.cfg, env: process.env })).map(
sessionLockToRepairEffect,
);
if (ctx.dryRun === true) {
return { status: "repaired", changes: [], effects };
}
return {
status: "skipped",
reason: "legacy doctor session lock contribution owns cleanup",
changes: [],
effects,
};
},
};
const browserCheck: HealthCheck = {
id: "core/doctor/browser",
kind: "core",
@@ -974,13 +1008,16 @@ function createWorkspaceSuggestionsCheck(deps: CoreHealthCheckDeps): HealthCheck
};
}
function createConvertedWorkflowChecks(deps: CoreHealthCheckDeps): readonly HealthCheck[] {
function createConvertedWorkflowChecks(
deps: CoreHealthCheckDeps,
): readonly SplitHealthCheckInput[] {
return [
claudeCliCheck,
gatewayAuthCheck,
legacyStateCheck,
legacyWhatsAppCrontabCheck,
codexSessionRoutesCheck,
sessionLocksCheck,
shellCompletionCheck,
uiProtocolFreshnessCheck,
gatewayServicesExtraCheck,
@@ -1015,7 +1052,7 @@ export function resetCoreHealthChecksForTest(): void {
export function createCoreHealthChecks(
deps: CoreHealthCheckDeps = defaultCoreHealthCheckDeps,
): readonly HealthCheck[] {
): readonly SplitHealthCheckInput[] {
return [
gatewayConfigCheck,
...createConvertedWorkflowChecks(deps),
@@ -1026,4 +1063,4 @@ export function createCoreHealthChecks(
];
}
export const CORE_HEALTH_CHECKS: readonly HealthCheck[] = createCoreHealthChecks();
export const CORE_HEALTH_CHECKS: readonly SplitHealthCheckInput[] = createCoreHealthChecks();

View File

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

View File

@@ -39,6 +39,36 @@ describe("runDoctorLintChecks", () => {
expect(result.findings.map((finding) => finding.checkId)).toEqual(["a"]);
});
it("skips default-disabled checks unless explicitly selected", async () => {
const defaultDisabled = normalizeHealthCheck({
...check("targeted", async () => [
{ checkId: "targeted", severity: "warning" as const, message: "warn" },
]),
defaultEnabled: false,
});
await expect(
runDoctorLintChecks(ctx, {
checks: [defaultDisabled],
}),
).resolves.toMatchObject({
checksRun: 0,
checksSkipped: 1,
findings: [],
});
await expect(
runDoctorLintChecks(ctx, {
checks: [defaultDisabled],
onlyIds: ["targeted"],
}),
).resolves.toMatchObject({
checksRun: 1,
checksSkipped: 0,
findings: [expect.objectContaining({ checkId: "targeted" })],
});
});
it("supports single-run checks in lint mode", async () => {
const runnable: RunnableHealthCheck = {
id: "run-check",

View File

@@ -37,6 +37,9 @@ export async function runDoctorLintChecks(
if (only.size > 0 && !only.has(c.id)) {
return false;
}
if (only.size === 0 && isDefaultDisabled(c)) {
return false;
}
if (skip.has(c.id)) {
return false;
}
@@ -78,6 +81,10 @@ export async function runDoctorLintChecks(
};
}
function isDefaultDisabled(check: HealthCheck): boolean {
return "defaultEnabled" in check && check.defaultEnabled === false;
}
// Stable ordering keeps CLI output and tests deterministic across registry order changes.
function compareFindings(a: HealthFinding, b: HealthFinding): number {
const sevDelta =

View File

@@ -3,17 +3,19 @@ import type {
HealthCheckInput,
HealthCheckRunResult,
RegisteredHealthCheck,
SplitHealthCheckInput,
} from "./health-check-runner-types.js";
import type { HealthCheck, HealthRepairContext } from "./health-checks.js";
import type { HealthRepairContext } from "./health-checks.js";
// Adapts legacy split detect/repair checks and newer runnable checks to one runner contract.
/** Wraps a detect/repair health check in the runnable health-check contract. */
export function defineSplitHealthCheck(check: HealthCheck): RegisteredHealthCheck {
export function defineSplitHealthCheck(check: SplitHealthCheckInput): RegisteredHealthCheck {
return {
id: check.id,
kind: check.kind,
description: check.description,
source: check.source,
defaultEnabled: check.defaultEnabled,
sourceContract: "split",
detect: (ctx, scope) => check.detect(ctx, scope),
repair:
@@ -73,6 +75,7 @@ export function normalizeHealthCheck(check: HealthCheckInput): RegisteredHealthC
kind: check.kind,
description: check.description,
source: check.source,
defaultEnabled: check.defaultEnabled,
sourceContract: "run",
async detect(ctx, scope) {
const result = await check.run({ ...ctx, repair: false }, scope);

View File

@@ -25,18 +25,23 @@ export interface HealthCheckRunResult extends Omit<HealthRepairResult, "changes"
readonly effects?: readonly HealthRepairEffect[];
}
/** Internal runner selection metadata. This is intentionally not part of the public SDK type. */
export interface HealthCheckSelectionOptions {
readonly defaultEnabled?: boolean;
}
export type SplitHealthCheckInput = HealthCheck & HealthCheckSelectionOptions;
/** Health-check implementation that owns its own detect/repair orchestration. */
export interface RunnableHealthCheck extends Pick<
HealthCheck,
"id" | "kind" | "description" | "source"
> {
export interface RunnableHealthCheck
extends Pick<HealthCheck, "id" | "kind" | "description" | "source">, HealthCheckSelectionOptions {
run(ctx: HealthCheckRunContext, scope?: HealthCheckScope): Promise<HealthCheckRunResult>;
}
export type HealthCheckInput = HealthCheck | RunnableHealthCheck;
export type HealthCheckInput = SplitHealthCheckInput | RunnableHealthCheck;
/** Normalized check contract consumed by lint and repair runners. */
export interface RegisteredHealthCheck extends HealthCheck {
export interface RegisteredHealthCheck extends HealthCheck, HealthCheckSelectionOptions {
readonly sourceContract: "split" | "run";
run(ctx: HealthCheckRunContext, scope?: HealthCheckScope): Promise<HealthCheckRunResult>;
}

View File

@@ -18,8 +18,7 @@ import {
resolveMainSessionKey,
} from "../config/sessions/main-session.js";
import { resolveStorePath } from "../config/sessions/paths.js";
import { loadSessionStore, updateSessionStore } from "../config/sessions/store.js";
import type { SessionEntry } from "../config/sessions/types.js";
import { preserveTemporarySessionMapping } from "../config/sessions/session-accessor.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { formatErrorMessage } from "../infra/errors.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
@@ -33,14 +32,6 @@ function generateBootSessionId(): string {
return `boot-${ts}-${suffix}`;
}
type SessionMappingSnapshot = {
storePath: string;
sessionKey: string;
canRestore: boolean;
hadEntry: boolean;
entry?: SessionEntry;
};
const log = createSubsystemLogger("gateway/boot");
const BOOT_FILENAME = "BOOT.md";
@@ -101,68 +92,6 @@ async function loadBootFile(
}
}
function snapshotSessionMapping(params: {
cfg: OpenClawConfig;
sessionKey: string;
}): SessionMappingSnapshot {
const agentId = resolveAgentIdFromSessionKey(params.sessionKey);
const storePath = resolveStorePath(params.cfg.session?.store, { agentId });
try {
const store = loadSessionStore(storePath, { skipCache: true });
const entry = store[params.sessionKey];
if (!entry) {
return {
storePath,
sessionKey: params.sessionKey,
canRestore: true,
hadEntry: false,
};
}
return {
storePath,
sessionKey: params.sessionKey,
canRestore: true,
hadEntry: true,
entry: structuredClone(entry),
};
} catch (err) {
log.debug("boot: could not snapshot session mapping", {
sessionKey: params.sessionKey,
error: String(err),
});
return {
storePath,
sessionKey: params.sessionKey,
canRestore: false,
hadEntry: false,
};
}
}
async function restoreSessionMapping(
snapshot: SessionMappingSnapshot,
): Promise<string | undefined> {
if (!snapshot.canRestore) {
return undefined;
}
try {
await updateSessionStore(
snapshot.storePath,
(store) => {
if (snapshot.hadEntry && snapshot.entry) {
store[snapshot.sessionKey] = snapshot.entry;
return;
}
delete store[snapshot.sessionKey];
},
{ activeSessionKey: snapshot.sessionKey },
);
return undefined;
} catch (err) {
return formatErrorMessage(err);
}
}
export async function runBootOnce(params: {
cfg: OpenClawConfig;
deps: CliDeps;
@@ -193,39 +122,49 @@ export async function runBootOnce(params: {
const sessionKey = resolveBootSessionKey(mainSessionKey);
const message = buildBootPrompt(result.content ?? "");
const sessionId = generateBootSessionId();
const mappingSnapshot = snapshotSessionMapping({
cfg: params.cfg,
sessionKey,
});
const agentId = resolveAgentIdFromSessionKey(sessionKey);
const storePath = resolveStorePath(params.cfg.session?.store, { agentId });
// Register the boot prompt for the message-tool echo guard so the
// tool layer can drop fallback-model echoes that copy substantial
// BOOT.md content without preserving the wrapper markers above.
// Always cleared in finally so a failed run does not leave a stale
// entry that mis-fires on an unrelated subsequent run reusing the
// same session key. Refs #53732.
setBootEchoContextForSession(sessionKey, message);
let agentFailure: string | undefined;
try {
await agentCommand(
{
message,
sessionKey,
sessionId,
deliver: false,
suppressPromptPersistence: true,
},
bootRuntime,
params.deps,
);
} catch (err) {
agentFailure = formatErrorMessage(err);
log.error(`boot: agent run failed: ${agentFailure}`);
} finally {
clearBootEchoContextForSession(sessionKey);
const mappingPreservation = await preserveTemporarySessionMapping(
{ storePath, sessionKey },
async () => {
// Register the boot prompt for the message-tool echo guard so the
// tool layer can drop fallback-model echoes that copy substantial
// BOOT.md content without preserving the wrapper markers above.
// Always cleared in finally so a failed run does not leave a stale
// entry that mis-fires on an unrelated subsequent run reusing the
// same session key. Refs #53732.
setBootEchoContextForSession(sessionKey, message);
try {
await agentCommand(
{
message,
sessionKey,
sessionId,
deliver: false,
suppressPromptPersistence: true,
},
bootRuntime,
params.deps,
);
return undefined;
} catch (err) {
const failure = formatErrorMessage(err);
log.error(`boot: agent run failed: ${failure}`);
return failure;
} finally {
clearBootEchoContextForSession(sessionKey);
}
},
);
const agentFailure = mappingPreservation.result;
if (mappingPreservation.snapshotFailure) {
log.debug("boot: could not snapshot session mapping", {
sessionKey,
error: mappingPreservation.snapshotFailure,
});
}
const mappingRestoreFailure = await restoreSessionMapping(mappingSnapshot);
const mappingRestoreFailure = mappingPreservation.restoreFailure;
if (mappingRestoreFailure) {
log.error(`boot: failed to restore session mapping: ${mappingRestoreFailure}`);
}

View File

@@ -1,5 +1,6 @@
// Gateway config reload tests cover changed-path detection, reload planning,
// plugin registry refresh, skill snapshot invalidation, and watcher behavior.
import nodePath from "node:path";
import chokidar from "chokidar";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { listChannelPlugins } from "../channels/plugins/index.js";
@@ -578,8 +579,8 @@ describe("resolveGatewayReloadSettings", () => {
});
});
type WatcherHandler = () => void;
type WatcherEvent = "add" | "change" | "unlink" | "error";
type WatcherHandler = (value?: string | Error) => void;
type WatcherEvent = "add" | "change" | "unlink" | "error" | "ready";
function createWatcherMock(effectiveUsePolling?: boolean) {
const handlers = new Map<WatcherEvent, WatcherHandler[]>();
@@ -592,9 +593,9 @@ function createWatcherMock(effectiveUsePolling?: boolean) {
handlers.set(event, existing);
return this;
},
emit(event: WatcherEvent) {
emit(event: WatcherEvent, value?: string | Error) {
for (const handler of handlers.get(event) ?? []) {
handler();
handler(value);
}
},
close: vi.fn(async () => {}),
@@ -659,17 +660,30 @@ function makeZeroDebounceHookWrite(persistedHash: string): ConfigWriteNotificati
}
function createReloaderHarness(
readSnapshot: () => Promise<ConfigFileSnapshot>,
readSnapshot: () => Promise<
ConfigFileSnapshot | { snapshot: ConfigFileSnapshot; includeFilePaths?: readonly string[] }
>,
options: {
initialCompareConfig?: OpenClawConfig;
initialInternalWriteHash?: string | null;
initialIncludeFilePaths?: readonly string[];
promoteSnapshot?: (snapshot: ConfigFileSnapshot, reason: string) => Promise<boolean>;
initialPluginInstallRecords?: Record<string, PluginInstallRecord>;
readPluginInstallRecords?: () => Promise<Record<string, PluginInstallRecord>>;
watchers?: ReturnType<typeof createWatcherMock>[];
} = {},
) {
const watcher = createWatcherMock();
vi.spyOn(chokidar, "watch").mockReturnValue(watcher as unknown as never);
const watchers = options.watchers ?? [createWatcherMock()];
const watcher = watchers[0] ?? createWatcherMock();
let watcherIndex = 0;
const watchSpy = vi.spyOn(chokidar, "watch").mockImplementation((_path, watchOptions) => {
const next = watchers[watcherIndex++];
if (!next) {
throw new Error("missing watcher mock");
}
next.options.usePolling = next.effectiveUsePolling ?? Boolean(watchOptions?.usePolling);
return next as unknown as never;
});
const onHotReload = vi.fn(async (_plan: GatewayReloadPlan, _nextConfig: OpenClawConfig) => {});
const onRestart = vi.fn((_plan: GatewayReloadPlan, _nextConfig: OpenClawConfig) => {});
let writeListener: ((event: ConfigWriteNotification) => void) | null = null;
@@ -690,6 +704,7 @@ function createReloaderHarness(
initialConfig: { gateway: { reload: { debounceMs: 0 } } },
initialCompareConfig: options.initialCompareConfig,
initialInternalWriteHash: options.initialInternalWriteHash,
initialIncludeFilePaths: options.initialIncludeFilePaths,
readSnapshot,
promoteSnapshot: options.promoteSnapshot,
initialPluginInstallRecords: options.initialPluginInstallRecords ?? {},
@@ -702,6 +717,8 @@ function createReloaderHarness(
});
return {
watcher,
watchers,
watchSpy,
onHotReload,
onRestart,
log,
@@ -999,7 +1016,9 @@ describe("startGatewayConfigReloader", () => {
await harness.reloader.stop();
});
it("does not promote external config edits when hot reload rejects them", async () => {
it("does not replay a rejected graph and accepts a later content change", async () => {
const oldInclude = nodePath.normalize("/tmp/includes/old.json5");
const nextInclude = nodePath.normalize("/tmp/includes/next.json5");
const acceptedSnapshot = makeSnapshot({
config: {
gateway: { reload: { debounceMs: 0 } },
@@ -1007,22 +1026,50 @@ describe("startGatewayConfigReloader", () => {
},
hash: "external-rejected-1",
});
const revisedSnapshot = makeSnapshot({
config: {
gateway: { reload: { debounceMs: 0 } },
hooks: { enabled: false },
},
hash: "external-revised-2",
});
const readSnapshot = vi
.fn<() => Promise<ConfigFileSnapshot>>()
.mockResolvedValueOnce(acceptedSnapshot);
.fn()
.mockResolvedValueOnce({ snapshot: acceptedSnapshot, includeFilePaths: [nextInclude] })
.mockResolvedValueOnce({ snapshot: acceptedSnapshot, includeFilePaths: [nextInclude] })
.mockResolvedValueOnce({ snapshot: revisedSnapshot, includeFilePaths: [nextInclude] });
const promoteSnapshot = vi.fn(async (_snapshot: ConfigFileSnapshot, _reason: string) => true);
const watchers = [createWatcherMock(), createWatcherMock(), createWatcherMock()];
const { watcher, onHotReload, log, reloader } = createReloaderHarness(readSnapshot, {
initialIncludeFilePaths: [oldInclude],
promoteSnapshot,
watchers,
});
onHotReload.mockRejectedValueOnce(new Error("reload refused"));
watcher.emit("change");
await vi.runAllTimersAsync();
await vi.runOnlyPendingTimersAsync();
expect(onHotReload).toHaveBeenCalledTimes(1);
expect(promoteSnapshot).not.toHaveBeenCalled();
expect(log.error).toHaveBeenCalledWith("config reload failed: Error: reload refused");
watcher.emit("change");
await vi.runOnlyPendingTimersAsync();
expect(onHotReload).toHaveBeenCalledTimes(1);
expect(promoteSnapshot).not.toHaveBeenCalled();
expect(log.warn).toHaveBeenCalledWith(
"config reload skipped (previous apply failed; waiting for config change)",
);
watcher.emit("change");
await vi.runOnlyPendingTimersAsync();
expect(onHotReload).toHaveBeenCalledTimes(2);
expect(promoteSnapshot).toHaveBeenCalledTimes(1);
expect(promoteSnapshot).toHaveBeenCalledWith(revisedSnapshot, "valid-config");
await reloader.stop();
});
@@ -1104,6 +1151,461 @@ describe("startGatewayConfigReloader", () => {
await harness.reloader.stop();
});
it("retains a queued include reconciliation when an in-process hot reload throws", async () => {
const includePath = nodePath.normalize("/tmp/includes/active.json5");
const acceptedSnapshot = makeZeroDebounceHookSnapshot("internal-reconcile-1");
const readSnapshot = vi.fn().mockResolvedValueOnce({
snapshot: acceptedSnapshot,
includeFilePaths: [includePath],
});
const watchers = [createWatcherMock(), createWatcherMock()];
const harness = createReloaderHarness(readSnapshot, {
initialIncludeFilePaths: [includePath],
watchers,
});
harness.onHotReload.mockRejectedValueOnce(new Error("reload refused"));
harness.emitWrite(makeZeroDebounceHookWrite("internal-reconcile-1"));
watchers[1]?.emit("ready");
await vi.runOnlyPendingTimersAsync();
await vi.runOnlyPendingTimersAsync();
expect(harness.log.error).toHaveBeenCalledWith("config reload failed: Error: reload refused");
expect(readSnapshot).toHaveBeenCalledTimes(1);
expect(harness.onHotReload).toHaveBeenCalledTimes(1);
expect(harness.log.warn).toHaveBeenCalledWith(
"config reload skipped (previous apply failed; waiting for config change)",
);
await harness.reloader.stop();
});
it("watches nested startup includes and does not apply root hash dedupe to include edits", async () => {
const includePaths = [
nodePath.normalize("/tmp/includes/outer.json5"),
nodePath.normalize("/tmp/includes/nested.json5"),
];
const readSnapshot = vi
.fn()
.mockResolvedValueOnce({
snapshot: makeZeroDebounceHookSnapshot("internal-include-1"),
includeFilePaths: includePaths,
})
.mockResolvedValueOnce({
snapshot: makeSnapshot({
sourceConfig: {
gateway: { reload: { debounceMs: 0 } },
hooks: { enabled: false },
},
runtimeConfig: {
gateway: { reload: { debounceMs: 0 } },
hooks: { enabled: false },
},
config: {
gateway: { reload: { debounceMs: 0 } },
hooks: { enabled: false },
},
hash: "internal-include-1",
}),
includeFilePaths: includePaths,
});
const watchers = [createWatcherMock(), createWatcherMock(), createWatcherMock()];
const harness = createReloaderHarness(readSnapshot, {
initialIncludeFilePaths: includePaths,
promoteSnapshot: vi.fn(async () => true),
watchers,
});
expect(harness.watchSpy.mock.calls.map((call) => call[0])).toEqual([
"/tmp/openclaw.json",
nodePath.normalize("/tmp/includes/nested.json5"),
nodePath.normalize("/tmp/includes/outer.json5"),
]);
harness.emitWrite(makeZeroDebounceHookWrite("internal-include-1"));
await vi.runOnlyPendingTimersAsync();
expect(harness.onHotReload).toHaveBeenCalledTimes(1);
watchers[2]?.emit("change", nodePath.normalize("/tmp/includes/outer.json5"));
await vi.runOnlyPendingTimersAsync();
expect(readSnapshot).toHaveBeenCalledTimes(2);
expect(harness.onHotReload).toHaveBeenCalledTimes(2);
await harness.reloader.stop();
});
it("clears a stale root write hash when an include-triggered read sees different root bytes", async () => {
const includePath = nodePath.normalize("/tmp/includes/active.json5");
const readSnapshot = vi
.fn()
.mockResolvedValueOnce({
snapshot: makeZeroDebounceHookSnapshot("external-root-2"),
includeFilePaths: [includePath],
})
.mockResolvedValueOnce({
snapshot: makeSnapshot({
sourceConfig: { gateway: { reload: { debounceMs: 0 } }, hooks: { enabled: false } },
runtimeConfig: { gateway: { reload: { debounceMs: 0 } }, hooks: { enabled: false } },
config: { gateway: { reload: { debounceMs: 0 } }, hooks: { enabled: false } },
hash: "internal-root-1",
}),
includeFilePaths: [includePath],
});
const watchers = [createWatcherMock(), createWatcherMock()];
const harness = createReloaderHarness(readSnapshot, {
initialIncludeFilePaths: [includePath],
initialInternalWriteHash: "internal-root-1",
watchers,
});
watchers[1]?.emit("change", includePath);
await vi.runOnlyPendingTimersAsync();
expect(harness.onHotReload).toHaveBeenCalledTimes(1);
watchers[0]?.emit("change", nodePath.normalize("/tmp/openclaw.json"));
await vi.runOnlyPendingTimersAsync();
expect(readSnapshot).toHaveBeenCalledTimes(2);
expect(harness.onHotReload).toHaveBeenCalledTimes(2);
await harness.reloader.stop();
});
it("retries a failed include watcher handoff while the prior set stays active", async () => {
const rootPath = nodePath.normalize("/tmp/openclaw.json");
const oldInclude = nodePath.normalize("/tmp/includes/old.json5");
const nextInclude = nodePath.normalize("/tmp/includes/next.json5");
const nextSnapshot = {
snapshot: makeZeroDebounceHookSnapshot("graph-retry-1"),
includeFilePaths: [nextInclude],
};
const readSnapshot = vi
.fn()
.mockResolvedValueOnce(nextSnapshot)
.mockResolvedValueOnce(nextSnapshot);
const watchers = [
createWatcherMock(),
createWatcherMock(),
createWatcherMock(),
createWatcherMock(),
];
const [rootWatcher, oldWatcher, failedCandidate, retryCandidate] = watchers;
if (!rootWatcher || !oldWatcher || !failedCandidate || !retryCandidate) {
throw new Error("expected watcher mocks");
}
const harness = createReloaderHarness(readSnapshot, {
initialIncludeFilePaths: [oldInclude],
watchers,
});
rootWatcher.emit("change", rootPath);
await vi.runOnlyPendingTimersAsync();
failedCandidate.emit("error", new Error("ENOSPC"));
failedCandidate.emit("ready");
expect(oldWatcher.close).not.toHaveBeenCalled();
expect(rootWatcher.close).not.toHaveBeenCalled();
expect(harness.watchSpy).toHaveBeenCalledTimes(3);
await vi.advanceTimersByTimeAsync(500);
expect(harness.watchSpy).toHaveBeenCalledTimes(4);
retryCandidate.emit("ready");
await vi.runOnlyPendingTimersAsync();
expect(oldWatcher.close).toHaveBeenCalledTimes(1);
expect(rootWatcher.close).not.toHaveBeenCalled();
expect(readSnapshot).toHaveBeenCalledTimes(2);
expect(harness.log.warn).toHaveBeenCalledWith(
expect.stringContaining("retrying replacement (attempt 1/3 in 500ms)"),
);
await harness.reloader.stop();
});
it("uses the include watcher's effective polling mode when retries are exhausted", async () => {
const originalVitest = process.env.VITEST;
const originalChokidarPolling = process.env.CHOKIDAR_USEPOLLING;
delete process.env.VITEST;
delete process.env.CHOKIDAR_USEPOLLING;
let harness: ReloaderHarness | undefined;
try {
const oldInclude = nodePath.normalize("/tmp/includes/old.json5");
const nextInclude = nodePath.normalize("/tmp/includes/next.json5");
const watchers = [
createWatcherMock(false),
createWatcherMock(false),
createWatcherMock(true),
createWatcherMock(true),
createWatcherMock(true),
createWatcherMock(true),
];
harness = createReloaderHarness(
vi.fn().mockResolvedValueOnce({
snapshot: makeZeroDebounceHookSnapshot("effective-polling"),
includeFilePaths: [nextInclude],
}),
{ initialIncludeFilePaths: [oldInclude], watchers },
);
watchers[0]?.emit("change", nodePath.normalize("/tmp/openclaw.json"));
await vi.runOnlyPendingTimersAsync();
for (const [index, delay] of [
[2, 500],
[3, 2000],
[4, 5000],
] as const) {
watchers[index]?.emit("error", new Error("polling failed"));
await vi.advanceTimersByTimeAsync(delay);
}
watchers[5]?.emit("error", new Error("polling failed"));
expect(harness.reloader.hotReloadStatus()).toBe("disabled");
expect(harness.log.error).toHaveBeenCalledWith(expect.stringContaining("in polling mode"));
expect(harness.log.warn).not.toHaveBeenCalledWith(
expect.stringContaining("degrading to polling mode"),
);
} finally {
if (originalVitest === undefined) {
delete process.env.VITEST;
} else {
process.env.VITEST = originalVitest;
}
if (originalChokidarPolling === undefined) {
delete process.env.CHOKIDAR_USEPOLLING;
} else {
process.env.CHOKIDAR_USEPOLLING = originalChokidarPolling;
}
await harness?.reloader.stop();
}
});
it("reconciles once the initial include watcher set is ready", async () => {
const includePath = nodePath.normalize("/tmp/includes/startup.json5");
const readSnapshot = vi.fn().mockResolvedValueOnce({
snapshot: makeZeroDebounceHookSnapshot("startup-include-ready"),
includeFilePaths: [includePath],
});
const watchers = [createWatcherMock(), createWatcherMock()];
const harness = createReloaderHarness(readSnapshot, {
initialIncludeFilePaths: [includePath],
watchers,
});
watchers[1]?.emit("ready");
await vi.runOnlyPendingTimersAsync();
expect(readSnapshot).toHaveBeenCalledTimes(1);
await harness.reloader.stop();
});
it("reconciles a retained initial watcher after a graph change reverts before ready", async () => {
const rootPath = nodePath.normalize("/tmp/openclaw.json");
const initialInclude = nodePath.normalize("/tmp/includes/initial.json5");
const transientInclude = nodePath.normalize("/tmp/includes/transient.json5");
const initialSnapshot = {
snapshot: makeZeroDebounceHookSnapshot("initial-graph"),
includeFilePaths: [initialInclude],
};
const readSnapshot = vi
.fn()
.mockResolvedValueOnce({
snapshot: makeZeroDebounceHookSnapshot("transient-graph"),
includeFilePaths: [transientInclude],
})
.mockResolvedValueOnce(initialSnapshot)
.mockResolvedValueOnce(initialSnapshot);
const watchers = [createWatcherMock(), createWatcherMock(), createWatcherMock()];
const [rootWatcher, initialWatcher, transientCandidate] = watchers;
if (!rootWatcher || !initialWatcher || !transientCandidate) {
throw new Error("expected watcher mocks");
}
const harness = createReloaderHarness(readSnapshot, {
initialIncludeFilePaths: [initialInclude],
watchers,
});
rootWatcher.emit("change", rootPath);
await vi.runOnlyPendingTimersAsync();
rootWatcher.emit("change", rootPath);
await vi.runOnlyPendingTimersAsync();
expect(transientCandidate.close).toHaveBeenCalledTimes(1);
initialWatcher.emit("ready");
await vi.runOnlyPendingTimersAsync();
expect(readSnapshot).toHaveBeenCalledTimes(3);
await harness.reloader.stop();
});
it("invalidates an active include watcher that errors during a newer graph handoff", async () => {
const rootPath = nodePath.normalize("/tmp/openclaw.json");
const oldInclude = nodePath.normalize("/tmp/includes/old.json5");
const nextInclude = nodePath.normalize("/tmp/includes/next.json5");
const nextSnapshot = {
snapshot: makeZeroDebounceHookSnapshot("graph-active-error"),
includeFilePaths: [nextInclude],
};
const readSnapshot = vi
.fn()
.mockResolvedValueOnce(nextSnapshot)
.mockResolvedValueOnce(nextSnapshot);
const watchers = [createWatcherMock(), createWatcherMock(), createWatcherMock()];
const [rootWatcher, oldWatcher, candidateWatcher] = watchers;
if (!rootWatcher || !oldWatcher || !candidateWatcher) {
throw new Error("expected watcher mocks");
}
const harness = createReloaderHarness(readSnapshot, {
initialIncludeFilePaths: [oldInclude],
watchers,
});
rootWatcher.emit("change", rootPath);
await vi.runOnlyPendingTimersAsync();
oldWatcher.emit("error", new Error("active failed"));
expect(oldWatcher.close).toHaveBeenCalledTimes(1);
expect(rootWatcher.close).not.toHaveBeenCalled();
candidateWatcher.emit("ready");
await vi.runOnlyPendingTimersAsync();
expect(readSnapshot).toHaveBeenCalledTimes(2);
await harness.reloader.stop();
});
it("atomically swaps changed include graphs after ready and reconciles without watcher leaks", async () => {
const rootPath = nodePath.normalize("/tmp/openclaw.json");
const oldInclude = nodePath.normalize("/tmp/includes/old.json5");
const nextInclude = nodePath.normalize("/tmp/includes/next.json5");
const finalInclude = nodePath.normalize("/tmp/includes/final.json5");
const firstConfig: OpenClawConfig = {
gateway: { reload: { debounceMs: 0 } },
hooks: { enabled: true },
};
const finalConfig: OpenClawConfig = {
gateway: { reload: { debounceMs: 0 } },
hooks: { enabled: false },
};
const readSnapshot = vi
.fn()
.mockResolvedValueOnce({
snapshot: makeSnapshot({
sourceConfig: firstConfig,
runtimeConfig: firstConfig,
config: firstConfig,
hash: "graph-1",
}),
includeFilePaths: [nextInclude],
})
.mockResolvedValueOnce({
snapshot: makeSnapshot({
sourceConfig: firstConfig,
runtimeConfig: firstConfig,
config: firstConfig,
hash: "graph-1",
}),
includeFilePaths: [nextInclude],
})
.mockResolvedValueOnce({
snapshot: makeSnapshot({
sourceConfig: finalConfig,
runtimeConfig: finalConfig,
config: finalConfig,
hash: "graph-2",
}),
includeFilePaths: [finalInclude],
});
const watchers = [
createWatcherMock(),
createWatcherMock(),
createWatcherMock(),
createWatcherMock(),
];
const harness = createReloaderHarness(readSnapshot, {
initialIncludeFilePaths: [oldInclude],
watchers,
});
const [rootWatcher, initialIncludeWatcher, replacementWatcher, pendingFinalWatcher] = watchers;
if (!rootWatcher || !initialIncludeWatcher || !replacementWatcher || !pendingFinalWatcher) {
throw new Error("expected watcher mocks");
}
rootWatcher.emit("change", rootPath);
await vi.runOnlyPendingTimersAsync();
expect(harness.watchSpy.mock.calls[2]?.[0]).toBe(nextInclude);
expect(rootWatcher.close).not.toHaveBeenCalled();
expect(initialIncludeWatcher.close).not.toHaveBeenCalled();
replacementWatcher.emit("change", nextInclude);
await vi.runOnlyPendingTimersAsync();
expect(readSnapshot).toHaveBeenCalledTimes(1);
replacementWatcher.emit("ready");
expect(initialIncludeWatcher.close).toHaveBeenCalledTimes(1);
expect(rootWatcher.close).not.toHaveBeenCalled();
await vi.runOnlyPendingTimersAsync();
expect(readSnapshot).toHaveBeenCalledTimes(2);
expect(harness.onHotReload).toHaveBeenCalledTimes(1);
initialIncludeWatcher.emit("change", oldInclude);
await vi.runOnlyPendingTimersAsync();
expect(readSnapshot).toHaveBeenCalledTimes(2);
rootWatcher.emit("change", rootPath);
await vi.runOnlyPendingTimersAsync();
expect(harness.watchSpy.mock.calls[3]?.[0]).toBe(finalInclude);
expect(harness.onHotReload).toHaveBeenCalledTimes(2);
await harness.reloader.stop();
expect(rootWatcher.close).toHaveBeenCalledTimes(1);
expect(initialIncludeWatcher.close).toHaveBeenCalledTimes(1);
expect(replacementWatcher.close).toHaveBeenCalledTimes(1);
expect(pendingFinalWatcher.close).toHaveBeenCalledTimes(1);
});
it("keeps the last valid include watch set when a candidate snapshot is invalid", async () => {
const rootPath = nodePath.normalize("/tmp/openclaw.json");
const acceptedInclude = nodePath.normalize("/tmp/includes/accepted.json5");
const rejectedInclude = nodePath.normalize("/tmp/includes/rejected.json5");
const nextConfig: OpenClawConfig = {
gateway: { reload: { debounceMs: 0 } },
hooks: { enabled: true },
};
const readSnapshot = vi
.fn()
.mockResolvedValueOnce({
snapshot: makeSnapshot({
valid: false,
issues: [{ path: "hooks.enabled", message: "Expected boolean" }],
hash: "invalid-graph",
}),
includeFilePaths: [rejectedInclude],
})
.mockResolvedValueOnce({
snapshot: makeSnapshot({
sourceConfig: nextConfig,
runtimeConfig: nextConfig,
config: nextConfig,
hash: "accepted-graph",
}),
includeFilePaths: [acceptedInclude],
});
const harness = createReloaderHarness(readSnapshot, {
initialIncludeFilePaths: [acceptedInclude],
watchers: [createWatcherMock(), createWatcherMock()],
});
harness.watcher.emit("change", rootPath);
await vi.runOnlyPendingTimersAsync();
expect(harness.watchSpy).toHaveBeenCalledTimes(2);
harness.watchers[1]?.emit("change", acceptedInclude);
await vi.runOnlyPendingTimersAsync();
expect(harness.watchSpy).toHaveBeenCalledTimes(2);
expect(harness.onHotReload).toHaveBeenCalledTimes(1);
await harness.reloader.stop();
});
it("honors in-process write intent to skip reload", async () => {
const readSnapshot = vi
.fn<() => Promise<ConfigFileSnapshot>>()
@@ -1533,6 +2035,40 @@ describe("startGatewayConfigReloader", () => {
await harness.reloader.stop();
});
it("skips in-process promotion when includes change under the same root hash", async () => {
const oldInclude = nodePath.normalize("/tmp/includes/old.json5");
const nextInclude = nodePath.normalize("/tmp/includes/next.json5");
const changedByInclude: OpenClawConfig = {
gateway: { reload: { debounceMs: 0 } },
hooks: { enabled: false },
};
const readSnapshot = vi.fn().mockResolvedValueOnce({
snapshot: makeSnapshot({
sourceConfig: changedByInclude,
runtimeConfig: changedByInclude,
config: changedByInclude,
hash: "internal-1",
}),
includeFilePaths: [nextInclude],
});
const promoteSnapshot = vi.fn(async () => true);
const harness = createReloaderHarness(readSnapshot, {
initialIncludeFilePaths: [oldInclude],
promoteSnapshot,
watchers: [createWatcherMock(), createWatcherMock()],
});
harness.emitWrite(makeZeroDebounceHookWrite("internal-1"));
await vi.runOnlyPendingTimersAsync();
expect(harness.onHotReload).toHaveBeenCalledTimes(1);
expect(readSnapshot).toHaveBeenCalledTimes(1);
expect(promoteSnapshot).not.toHaveBeenCalled();
expect(harness.watchSpy).toHaveBeenCalledTimes(2);
await harness.reloader.stop();
});
it("dedupes the first watcher reread for startup internal writes", async () => {
const readSnapshot = vi
.fn<() => Promise<ConfigFileSnapshot>>()

View File

@@ -1,5 +1,6 @@
// Gateway config hot-reload watcher.
// Diffs config/plugin install snapshots and dispatches hot reload or restart plans.
import nodePath from "node:path";
import chokidar from "chokidar";
import type { ConfigWriteNotification } from "../config/io.js";
import { formatConfigIssueLines } from "../config/issue-format.js";
@@ -102,6 +103,36 @@ type GatewayConfigReloader = {
type PluginInstallRecords = Record<string, PluginInstallRecord>;
type ConfigReloadSnapshotReadResult =
| ConfigFileSnapshot
| {
snapshot: ConfigFileSnapshot;
includeFilePaths?: readonly string[];
};
function unpackConfigReloadSnapshot(result: ConfigReloadSnapshotReadResult): {
snapshot: ConfigFileSnapshot;
includeFilePaths?: readonly string[];
} {
return "snapshot" in result ? result : { snapshot: result };
}
function normalizeIncludeWatcherPaths(
rootPath: string,
includeFilePaths: readonly string[] = [],
): string[] {
const normalizedRoot = nodePath.normalize(rootPath);
const includes = new Set(
includeFilePaths.map((includePath) => nodePath.normalize(includePath)).filter(Boolean),
);
includes.delete(normalizedRoot);
return [...includes].toSorted((left, right) => left.localeCompare(right));
}
function watcherPathsEqual(left: readonly string[], right: readonly string[]): boolean {
return left.length === right.length && left.every((entry, index) => entry === right[index]);
}
function asPluginInstallConfig(records: PluginInstallRecords): OpenClawConfig {
return {
plugins: {
@@ -114,7 +145,8 @@ export function startGatewayConfigReloader(opts: {
initialConfig: OpenClawConfig;
initialCompareConfig?: OpenClawConfig;
initialInternalWriteHash?: string | null;
readSnapshot: () => Promise<ConfigFileSnapshot>;
initialIncludeFilePaths?: readonly string[];
readSnapshot: () => Promise<ConfigReloadSnapshotReadResult>;
onHotReload: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => Promise<void>;
onRestart: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => void | Promise<void>;
promoteSnapshot?: (snapshot: ConfigFileSnapshot, reason: string) => Promise<boolean>;
@@ -135,6 +167,7 @@ export function startGatewayConfigReloader(opts: {
let pending = false;
let running = false;
let stopped = false;
let pendingIncludeReload = false;
let restartQueued = false;
let missingConfigRetries = 0;
let pendingInProcessConfig: {
@@ -144,6 +177,7 @@ export function startGatewayConfigReloader(opts: {
afterWrite?: ConfigWriteNotification["afterWrite"];
} | null = null;
let lastAppliedWriteHash = opts.initialInternalWriteHash ?? null;
let currentApplyRejected = false;
let currentPluginInstallRecords =
opts.initialPluginInstallRecords ?? loadInstalledPluginIndexInstallRecordsSync();
const readPluginInstallRecords =
@@ -256,7 +290,11 @@ export function startGatewayConfigReloader(opts: {
currentPluginInstallRecords = nextPluginInstallRecords;
settings = resolveGatewayReloadSettings(nextConfig);
if (changedPaths.length === 0) {
return;
if (currentApplyRejected) {
opts.log.warn("config reload skipped (previous apply failed; waiting for config change)");
return false;
}
return true;
}
// Invalidate cached skills snapshots (persisted in sessions.json) whenever
@@ -273,18 +311,21 @@ export function startGatewayConfigReloader(opts: {
opts.log.info(`config change detected; evaluating reload (${changedPaths.join(", ")})`);
if (followUp.mode === "none") {
opts.log.info(`config reload skipped by writer intent (${followUp.reason})`);
return;
currentApplyRejected = false;
return true;
}
const plan = buildGatewayReloadPlan(changedPaths, {
noopPaths: pluginInstallTimestampNoopPaths,
forceChangedPaths: pluginInstallWholeRecordPaths,
});
if (isNoopReloadPlan(plan) && !followUp.requiresRestart) {
return;
currentApplyRejected = false;
return true;
}
if (settings.mode === "off") {
opts.log.info("config reload disabled (gateway.reload.mode=off)");
return;
currentApplyRejected = false;
return true;
}
if (followUp.requiresRestart) {
queueRestart(
@@ -295,11 +336,13 @@ export function startGatewayConfigReloader(opts: {
},
nextConfig,
);
return;
currentApplyRejected = false;
return true;
}
if (settings.mode === "restart") {
queueRestart(plan, nextConfig);
return;
currentApplyRejected = false;
return true;
}
if (plan.restartGateway) {
if (settings.mode === "hot") {
@@ -308,13 +351,23 @@ export function startGatewayConfigReloader(opts: {
", ",
)})`,
);
return;
currentApplyRejected = false;
return true;
}
queueRestart(plan, nextConfig);
return;
currentApplyRejected = false;
return true;
}
await opts.onHotReload(plan, nextConfig);
try {
await opts.onHotReload(plan, nextConfig);
currentApplyRejected = false;
return true;
} catch (err) {
currentApplyRejected = true;
opts.log.error(`config reload failed: ${String(err)}`);
return false;
}
};
const promoteAcceptedSnapshot = async (snapshot: ConfigFileSnapshot, reason: string) => {
@@ -328,15 +381,26 @@ export function startGatewayConfigReloader(opts: {
}
};
const promoteAcceptedInProcessWrite = async (persistedHash: string) => {
const promoteAcceptedInProcessWrite = async (
persistedHash: string,
acceptedCompareConfig: OpenClawConfig,
) => {
if (!opts.promoteSnapshot) {
return;
}
try {
const snapshot = await opts.readSnapshot();
if (snapshot.hash !== persistedHash || !snapshot.valid) {
const snapshotRead = unpackConfigReloadSnapshot(await opts.readSnapshot());
const snapshot = snapshotRead.snapshot;
if (
snapshot.hash !== persistedHash ||
!snapshot.valid ||
diffConfigPaths(acceptedCompareConfig, snapshot.sourceConfig).length > 0
) {
return;
}
if (snapshotRead.includeFilePaths) {
replaceWatchedPaths(snapshotRead.includeFilePaths);
}
await promoteAcceptedSnapshot(snapshot, "in-process-write");
} catch (err) {
opts.log.warn(`config reload in-process last-known-good promotion failed: ${String(err)}`);
@@ -361,20 +425,31 @@ export function startGatewayConfigReloader(opts: {
const pendingWrite = pendingInProcessConfig;
pendingInProcessConfig = null;
missingConfigRetries = 0;
await applySnapshot(
const applied = await applySnapshot(
pendingWrite.config,
pendingWrite.compareConfig,
pendingWrite.afterWrite,
);
await promoteAcceptedInProcessWrite(pendingWrite.persistedHash);
return;
}
const snapshot = await opts.readSnapshot();
if (lastAppliedWriteHash && typeof snapshot.hash === "string") {
if (snapshot.hash === lastAppliedWriteHash) {
if (!applied) {
if (lastAppliedWriteHash === pendingWrite.persistedHash) {
lastAppliedWriteHash = null;
}
return;
}
lastAppliedWriteHash = null;
await promoteAcceptedInProcessWrite(pendingWrite.persistedHash, pendingWrite.compareConfig);
return;
}
const bypassRootWriteHashDedupe = pendingIncludeReload;
pendingIncludeReload = false;
const snapshotRead = unpackConfigReloadSnapshot(await opts.readSnapshot());
const snapshot = snapshotRead.snapshot;
if (lastAppliedWriteHash && typeof snapshot.hash === "string") {
if (!bypassRootWriteHashDedupe && snapshot.hash === lastAppliedWriteHash) {
return;
}
if (snapshot.hash !== lastAppliedWriteHash) {
lastAppliedWriteHash = null;
}
}
if (handleMissingSnapshot(snapshot)) {
return;
@@ -383,7 +458,13 @@ export function startGatewayConfigReloader(opts: {
handleInvalidSnapshot(snapshot);
return;
}
await applySnapshot(snapshot.config, snapshot.sourceConfig);
const applied = await applySnapshot(snapshot.config, snapshot.sourceConfig);
if (!applied) {
return;
}
if (snapshotRead.includeFilePaths) {
replaceWatchedPaths(snapshotRead.includeFilePaths);
}
await promoteAcceptedSnapshot(snapshot, "valid-config");
} catch (err) {
opts.log.error(`config reload failed: ${String(err)}`);
@@ -392,11 +473,20 @@ export function startGatewayConfigReloader(opts: {
if (pending) {
pending = false;
schedule();
} else if (pendingIncludeReload) {
scheduleAfter(0);
}
}
};
const scheduleFromWatcher = () => {
const normalizedRootWatchPath = nodePath.normalize(opts.watchPath);
const scheduleFromWatcher = (changedPath?: string) => {
if (
typeof changedPath === "string" &&
nodePath.normalize(changedPath) !== normalizedRootWatchPath
) {
pendingIncludeReload = true;
}
schedule();
};
@@ -415,35 +505,254 @@ export function startGatewayConfigReloader(opts: {
scheduleAfter(0);
}) ?? (() => {});
let watcher: ReturnType<typeof chokidar.watch> | null = null;
type ConfigWatcher = ReturnType<typeof chokidar.watch>;
type IncludeWatcherGroup = {
paths: string[];
watchers: ConfigWatcher[];
ready: Set<ConfigWatcher>;
usePolling: boolean;
};
const emptyIncludeGroup = (paths: string[] = []): IncludeWatcherGroup => ({
paths,
watchers: [],
ready: new Set(),
usePolling: false,
});
let watcher: ConfigWatcher | null = null;
let watcherRecreateRetries = 0;
let watcherRecreateTimer: ReturnType<typeof setTimeout> | null = null;
let hotReloadStatus: GatewayHotReloadStatus = "active";
let rootHotReloadDisabled = false;
let degradedToPolling = false;
let watcherUsesPolling = false;
const initialIncludePaths = normalizeIncludeWatcherPaths(
opts.watchPath,
opts.initialIncludeFilePaths,
);
let activeIncludeGroup = emptyIncludeGroup(initialIncludePaths);
let pendingIncludeGroup: IncludeWatcherGroup | null = null;
let desiredIncludePaths = initialIncludePaths;
let includeGeneration = 0;
let includeReplacementRetries = 0;
let includeReplacementTimer: ReturnType<typeof setTimeout> | null = null;
let includeHotReloadDisabled = false;
let includeDegradedToPolling = false;
const closeWatcher = (target: ConfigWatcher | null) => {
void target?.close().catch(() => {});
};
const closeIncludeGroup = (group: IncludeWatcherGroup | null) => {
for (const target of group?.watchers ?? []) {
closeWatcher(target);
}
};
const createWatcherInstance = (watchPath: string, usePolling: boolean): ConfigWatcher =>
chokidar.watch(watchPath, {
ignoreInitial: true,
awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 },
usePolling,
});
const activateIncludeGroup = (group: IncludeWatcherGroup) => {
if (stopped || group !== pendingIncludeGroup) {
return;
}
const previous = activeIncludeGroup;
activeIncludeGroup = group;
pendingIncludeGroup = null;
includeReplacementRetries = 0;
includeHotReloadDisabled = false;
closeIncludeGroup(previous);
// Re-read once after the handoff so edits during candidate startup are
// reconciled without opening a gap between the old and new exact sets.
pendingIncludeReload = true;
schedule();
};
const scheduleIncludeReplacementRetry = (
generation: number,
failedWithPolling: boolean,
err: unknown,
) => {
if (stopped || generation !== includeGeneration) {
return;
}
if (includeReplacementRetries >= WATCHER_RECREATE_MAX_RETRIES) {
if (!failedWithPolling && resolveChokidarUsePolling(true)) {
includeDegradedToPolling = true;
includeReplacementRetries = 0;
opts.log.warn(
`config include watcher native retries exhausted; degrading to polling mode: ${String(err)}`,
);
includeReplacementTimer = setTimeout(() => {
includeReplacementTimer = null;
stageIncludeReplacement(generation);
}, WATCHER_RECREATE_BACKOFF_MS[0] ?? 500);
return;
}
const mode = failedWithPolling ? "polling mode" : "native mode";
includeHotReloadDisabled = true;
opts.log.error(
`config include hot-reload disabled: watcher failed after ${WATCHER_RECREATE_MAX_RETRIES} re-create attempts in ${mode}; keeping prior paths: ${String(err)}`,
);
return;
}
const backoff =
WATCHER_RECREATE_BACKOFF_MS[includeReplacementRetries] ??
WATCHER_RECREATE_BACKOFF_MS[WATCHER_RECREATE_BACKOFF_MS.length - 1] ??
0;
includeReplacementRetries += 1;
opts.log.warn(
`config include watcher error; retrying replacement (attempt ${includeReplacementRetries}/${WATCHER_RECREATE_MAX_RETRIES} in ${backoff}ms): ${String(err)}`,
);
includeReplacementTimer = setTimeout(() => {
includeReplacementTimer = null;
stageIncludeReplacement(generation);
}, backoff);
};
const createIncludeGroup = (paths: string[], generation: number): IncludeWatcherGroup => {
const usePolling = resolveChokidarUsePolling(includeDegradedToPolling);
const group: IncludeWatcherGroup = {
paths,
watchers: [],
ready: new Set(),
usePolling: false,
};
try {
for (const includePath of paths) {
const next = createWatcherInstance(includePath, usePolling);
group.watchers.push(next);
group.usePolling ||= Boolean(next.options.usePolling);
const scheduleIfActive = (changedPath: string) => {
if (group === activeIncludeGroup) {
scheduleFromWatcher(changedPath);
}
};
next.on("add", scheduleIfActive);
next.on("change", scheduleIfActive);
next.on("unlink", scheduleIfActive);
next.on("ready", () => {
if (stopped) {
return;
}
group.ready.add(next);
if (group.ready.size !== group.watchers.length) {
return;
}
if (group === pendingIncludeGroup) {
if (generation !== includeGeneration) {
return;
}
activateIncludeGroup(group);
} else if (group === activeIncludeGroup) {
pendingIncludeReload = true;
schedule();
}
});
next.on("error", (err) => {
if (stopped) {
return;
}
if (group === pendingIncludeGroup) {
if (generation !== includeGeneration) {
return;
}
pendingIncludeGroup = null;
closeIncludeGroup(group);
scheduleIncludeReplacementRetry(generation, group.usePolling, err);
return;
}
if (group === activeIncludeGroup) {
activeIncludeGroup = emptyIncludeGroup();
closeIncludeGroup(group);
if (!pendingIncludeGroup && !includeReplacementTimer) {
scheduleIncludeReplacementRetry(includeGeneration, group.usePolling, err);
}
}
});
}
return group;
} catch (err) {
closeIncludeGroup(group);
throw err;
}
};
function stageIncludeReplacement(generation: number) {
if (
stopped ||
generation !== includeGeneration ||
pendingIncludeGroup ||
watcherPathsEqual(desiredIncludePaths, activeIncludeGroup.paths)
) {
return;
}
if (desiredIncludePaths.length === 0) {
pendingIncludeGroup = emptyIncludeGroup();
activateIncludeGroup(pendingIncludeGroup);
return;
}
try {
pendingIncludeGroup = createIncludeGroup([...desiredIncludePaths], generation);
} catch (err) {
scheduleIncludeReplacementRetry(
generation,
resolveChokidarUsePolling(includeDegradedToPolling),
err,
);
}
}
const replaceWatchedPaths = (includeFilePaths: readonly string[]) => {
const nextPaths = normalizeIncludeWatcherPaths(opts.watchPath, includeFilePaths);
if (watcherPathsEqual(nextPaths, desiredIncludePaths)) {
return;
}
includeGeneration += 1;
desiredIncludePaths = nextPaths;
includeReplacementRetries = 0;
if (includeReplacementTimer) {
clearTimeout(includeReplacementTimer);
includeReplacementTimer = null;
}
const stagedGroup = pendingIncludeGroup;
pendingIncludeGroup = null;
closeIncludeGroup(stagedGroup);
if (watcherPathsEqual(nextPaths, activeIncludeGroup.paths)) {
includeHotReloadDisabled = false;
return;
}
stageIncludeReplacement(includeGeneration);
};
const createWatcher = () => {
if (stopped) {
return;
}
const usePolling = resolveChokidarUsePolling(degradedToPolling);
const next = chokidar.watch(opts.watchPath, {
ignoreInitial: true,
awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 },
usePolling,
});
next.on("add", scheduleFromWatcher);
next.on("change", scheduleFromWatcher);
next.on("unlink", scheduleFromWatcher);
next.on("error", (err) => {
handleWatcherError(next, err);
});
const next = createWatcherInstance(
opts.watchPath,
resolveChokidarUsePolling(degradedToPolling),
);
watcher = next;
watcherUsesPolling = next.options.usePolling;
hotReloadStatus = "active";
watcherUsesPolling = Boolean(next.options.usePolling);
rootHotReloadDisabled = false;
const scheduleIfActive = (changedPath: string) => {
if (next === watcher) {
scheduleFromWatcher(changedPath);
}
};
next.on("add", scheduleIfActive);
next.on("change", scheduleIfActive);
next.on("unlink", scheduleIfActive);
next.on("error", (err) => handleWatcherError(next, err));
};
const handleWatcherError = (source: typeof watcher, err: unknown) => {
const handleWatcherError = (source: ConfigWatcher, err: unknown) => {
// Ignore stale errors from a watcher we already replaced or stopped.
if (stopped || source !== watcher) {
return;
@@ -451,7 +760,7 @@ export function startGatewayConfigReloader(opts: {
const failedWatcherUsedPolling = watcherUsesPolling;
watcher = null;
watcherUsesPolling = false;
void source?.close().catch(() => {});
closeWatcher(source);
if (watcherRecreateRetries >= WATCHER_RECREATE_MAX_RETRIES) {
// All native (inotify/kqueue) retries exhausted — fall back to polling
// mode so config hot-reload survives on hosts where inotify resources
@@ -469,7 +778,7 @@ export function startGatewayConfigReloader(opts: {
return;
}
const mode = failedWatcherUsedPolling ? "polling mode" : "native mode";
hotReloadStatus = "disabled";
rootHotReloadDisabled = true;
opts.log.error(
`config hot-reload disabled: watcher failed after ${WATCHER_RECREATE_MAX_RETRIES} re-create attempts in ${mode}: ${String(err)}`,
);
@@ -490,6 +799,18 @@ export function startGatewayConfigReloader(opts: {
};
createWatcher();
if (initialIncludePaths.length > 0) {
try {
activeIncludeGroup = createIncludeGroup(initialIncludePaths, includeGeneration);
} catch (err) {
activeIncludeGroup = emptyIncludeGroup();
scheduleIncludeReplacementRetry(
includeGeneration,
resolveChokidarUsePolling(includeDegradedToPolling),
err,
);
}
}
return {
stop: async () => {
@@ -502,11 +823,26 @@ export function startGatewayConfigReloader(opts: {
clearTimeout(watcherRecreateTimer);
watcherRecreateTimer = null;
}
if (includeReplacementTimer) {
clearTimeout(includeReplacementTimer);
includeReplacementTimer = null;
}
unsubscribeFromWrites();
const active = watcher;
const rootWatcher = watcher;
const activeIncludes = activeIncludeGroup;
const stagedIncludes = pendingIncludeGroup;
watcher = null;
await active?.close().catch(() => {});
activeIncludeGroup = emptyIncludeGroup();
pendingIncludeGroup = null;
await Promise.all(
[
...(rootWatcher ? [rootWatcher] : []),
...activeIncludes.watchers,
...(stagedIncludes?.watchers ?? []),
].map(async (target) => await target.close().catch(() => {})),
);
},
hotReloadStatus: () => hotReloadStatus,
hotReloadStatus: () =>
rootHotReloadDisabled || includeHotReloadDisabled ? "disabled" : "active",
};
}

View File

@@ -47,16 +47,6 @@ type BeforeToolCallHookInput = {
agentId?: string;
config?: unknown;
sessionKey?: string;
sessionId?: string;
messageProvider?: string;
channel?: string;
channelId?: string;
chatId?: string;
senderId?: string;
channelContext?: {
sender?: { id?: string };
chat?: { id?: string };
};
};
signal?: unknown;
};
@@ -1449,37 +1439,6 @@ describe("mcp loopback server", () => {
expectMcpResultText(payload, "blocked by hook", true);
});
it("passes canonical prefixed channel origin into loopback before-tool hooks", async () => {
const execute = vi.fn<MockGatewayTool["execute"]>(async () => ({
content: [{ type: "text", text: "EXECUTED" }],
}));
mockScopedTools([makeMessageTool({ execute })]);
const { runtime } = await startLoopbackServerForTest();
const response = await sendLoopbackToolCall({
token: runtime.ownerToken,
name: "message",
args: { body: "hello" },
headers: {
"x-session-key": "agent:main:main",
"x-openclaw-session-id": "session-origin-1",
"x-openclaw-message-channel": "telegram",
"x-openclaw-current-channel-id": "telegram:chat123",
},
});
expectMcpResultText(await readOkMcpPayload(response), "EXECUTED", false);
expect(getBeforeToolCallHookInput(0).ctx).toEqual({
agentId: "main",
config: { session: { mainKey: "main" } },
sessionKey: "agent:main:main",
sessionId: "session-origin-1",
messageProvider: "telegram",
channel: "telegram",
channelId: "chat123",
});
});
it("forwards the request abort signal to loopback tool execution", async () => {
const execute = vi.fn<MockGatewayTool["execute"]>(async () => ({
content: [{ type: "text", text: "EXECUTED" }],

View File

@@ -11,7 +11,6 @@ import { getRuntimeConfig } from "../config/io.js";
import { isTruthyEnvValue } from "../infra/env.js";
import { formatErrorMessage } from "../infra/errors.js";
import { logDebug, logWarn } from "../logger.js";
import { buildAgentHookContextOriginFields } from "../plugins/hook-agent-context.js";
import { handleMcpJsonRpc } from "./mcp-http.handlers.js";
import {
clearActiveMcpLoopbackRuntimeByOwnerToken,
@@ -274,12 +273,6 @@ export async function startMcpLoopbackServer(port = 0): Promise<{
agentId: scopedTools.agentId,
config: cfg,
sessionKey: requestContext.sessionKey,
...(requestContext.sessionId ? { sessionId: requestContext.sessionId } : {}),
...buildAgentHookContextOriginFields({
sessionKey: requestContext.sessionKey,
messageProvider: requestContext.messageProvider,
currentChannelId: requestContext.currentChannelId,
}),
},
signal: requestAbort.signal,
onToolCallPrepared: cliCaptureHandle

View File

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

View File

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

View File

@@ -17,7 +17,7 @@ import { isRestartEnabled } from "../config/commands.flags.js";
import {
getRuntimeConfig,
promoteConfigSnapshotToLastKnownGood,
readConfigFileSnapshot,
readConfigFileSnapshotWithPluginMetadata,
registerConfigWriteListener,
setRuntimeConfigSnapshot,
type ReadConfigFileSnapshotWithPluginMetadataResult,
@@ -638,6 +638,7 @@ export async function startGatewayServer(
let cfgAtStart: OpenClawConfig;
let startupInternalWriteHash: string | null = null;
let startupLastGoodSnapshot = configSnapshot;
let startupIncludeFilePaths = startupConfigLoad.includeFilePaths;
const startupActivationSourceConfig = configSnapshot.sourceConfig;
const startupRuntimeConfig = applyConfigOverrides(configSnapshot.config);
startupTrace.setConfig(startupRuntimeConfig);
@@ -694,11 +695,15 @@ export async function startGatewayServer(
// Keep the old startup-write suppression path intact for compatibility with
// callers that may still report a write, but startup itself no longer mutates config.
if (startupConfigLoad.wroteConfig || authBootstrap.persistedGeneratedToken) {
const startupSnapshot = await startupTrace.measure("config.final-snapshot", () =>
readConfigFileSnapshot(),
const startupSnapshotRead = await startupTrace.measure("config.final-snapshot", () =>
readConfigFileSnapshotWithPluginMetadata(),
);
const startupSnapshot = startupSnapshotRead.snapshot;
startupInternalWriteHash = startupSnapshot.hash ?? null;
startupLastGoodSnapshot = startupSnapshot;
if (startupSnapshotRead.includeFilePaths) {
startupIncludeFilePaths = startupSnapshotRead.includeFilePaths;
}
}
setRuntimeConfigSnapshot(cfgAtStart, startupLastGoodSnapshot.sourceConfig);
const { prepareGatewayPluginBootstrap } = await loadStartupPluginsModule();
@@ -1727,8 +1732,9 @@ export async function startGatewayServer(
initialConfig: cfgAtStart,
initialCompareConfig: startupLastGoodSnapshot.sourceConfig,
initialInternalWriteHash: startupInternalWriteHash,
initialIncludeFilePaths: startupIncludeFilePaths,
watchPath: configSnapshot.path,
readSnapshot: readConfigFileSnapshot,
readSnapshot: readConfigFileSnapshotWithPluginMetadata,
promoteSnapshot: promoteConfigSnapshotToLastKnownGood,
subscribeToWrites: registerConfigWriteListener,
deps,

View File

@@ -684,7 +684,6 @@ describe("POST /tools/invoke", () => {
port: sharedPort,
headers: {
...gatewayAuthHeaders(),
"x-openclaw-message-channel": "telegram",
"x-openclaw-message-to": "channel:24514",
"x-openclaw-thread-id": "thread-24514",
},
@@ -697,14 +696,6 @@ describe("POST /tools/invoke", () => {
agentTo: "channel:24514",
agentThreadId: "thread-24514",
});
expect(firstHookCallArg().ctx).toMatchObject({
messageProvider: "telegram",
channel: "telegram",
channelId: "24514",
});
expect(firstHookCallArg().ctx?.senderId).toBeUndefined();
expect(firstHookCallArg().ctx?.chatId).toBeUndefined();
expect(firstHookCallArg().ctx?.channelContext).toBeUndefined();
});
it("propagates owner-only HTTP denies into spawned session inheritance", async () => {

View File

@@ -13,7 +13,6 @@ import { resolveMainSessionKey } from "../config/sessions.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { logWarn } from "../logger.js";
import { isTestDefaultMemorySlotDisabled } from "../plugins/config-state.js";
import { buildAgentHookContextOriginFields } from "../plugins/hook-agent-context.js";
import { defaultSlotIdForKey } from "../plugins/slots.js";
import { getPluginToolMeta } from "../plugins/tools.js";
import { canonicalizeSessionKeyForAgent } from "./session-store-key.js";
@@ -258,12 +257,6 @@ export async function invokeGatewayTool(params: {
agentId,
config: params.cfg,
sessionKey,
...buildAgentHookContextOriginFields({
sessionKey,
messageChannel: params.messageChannel,
messageProvider: params.messageChannel,
messageTo: params.agentTo,
}),
loopDetection: resolveToolLoopDetectionConfig({ cfg: params.cfg, agentId }),
},
approvalMode: params.approvalMode,

View File

@@ -108,16 +108,11 @@ export type {
NativeHookRelayProvider,
NativeHookRelayRegistrationHandle,
} from "../agents/harness/native-hook-relay.js";
export type { ToolHookRunContext } from "../agents/agent-tools.before-tool-call.js";
export { VERSION as OPENCLAW_VERSION } from "../version.js";
export { formatErrorMessage } from "../infra/errors.js";
export { formatApprovalDisplayPath } from "../infra/approval-display-paths.js";
export {
buildAgentHookContextChannelFields,
buildAgentHookContextOriginFields,
} from "../plugins/hook-agent-context.js";
export { resolveToolLoopDetectionConfig } from "../agents/tool-loop-detection-config.js";
export { buildAgentHookContextChannelFields } from "../plugins/hook-agent-context.js";
export { emitAgentEvent, onAgentEvent, resetAgentEventsForTest } from "../infra/agent-events.js";
export { runAgentCleanupStep } from "../agents/run-cleanup-timeout.js";
export { log as embeddedAgentLog } from "../agents/embedded-agent-runner/logger.js";

View File

@@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest";
import {
buildAgentHookContextChannelFields,
buildAgentHookContextIdentityFields,
buildAgentHookContextOriginFields,
resolveAgentHookChannelId,
} from "./hook-agent-context.js";
@@ -136,63 +135,3 @@ describe("buildAgentHookContextIdentityFields", () => {
).toEqual({});
});
});
describe("buildAgentHookContextOriginFields", () => {
it("infers a canonical channel while preserving native chat identity", () => {
expect(
buildAgentHookContextOriginFields({
sessionKey: "agent:main:main",
messageProvider: "discord-voice",
currentChannelId: "discord:1472750640760623226",
trigger: "user",
channelContext: {
sender: { id: "user-123" },
chat: { id: "native-chat-1" },
},
}),
).toEqual({
channel: "discord",
messageProvider: "discord-voice",
channelId: "1472750640760623226",
chatId: "native-chat-1",
senderId: "user-123",
channelContext: {
sender: { id: "user-123" },
chat: { id: "native-chat-1" },
},
});
});
it("does not infer native chat identity from a routing target", () => {
expect(
buildAgentHookContextOriginFields({
messageChannel: "telegram",
messageProvider: "telegram",
currentChannelId: "telegram:-100123:topic:7",
trigger: "user",
}),
).toEqual({
channel: "telegram",
messageProvider: "telegram",
channelId: "-100123:topic:7",
});
});
it("keeps routing fields but omits requester identity for system triggers", () => {
expect(
buildAgentHookContextOriginFields({
messageChannel: "telegram",
messageProvider: "telegram",
currentChannelId: "telegram:-100123",
trigger: "cron",
senderId: "stale-user",
chatId: "stale-chat",
channelContext: { sender: { id: "stale-user" }, chat: { id: "stale-chat" } },
}),
).toEqual({
channel: "telegram",
messageProvider: "telegram",
channelId: "-100123",
});
});
});

View File

@@ -38,48 +38,18 @@ function stripConversationPrefix(
return text;
}
function inferNarrowProviderChannel(params: {
messageProvider?: string | null;
currentChannelId?: string | null;
messageTo?: string | null;
}): string | undefined {
const providerKey = normalizeKey(normalizeOptionalString(params.messageProvider));
if (!providerKey) {
return undefined;
}
for (const value of [params.currentChannelId, params.messageTo]) {
const text = normalizeOptionalString(value);
const separatorIndex = text?.indexOf(":") ?? -1;
if (!text || separatorIndex <= 0) {
continue;
}
const prefix = normalizeOptionalString(text.slice(0, separatorIndex));
const prefixKey = normalizeKey(prefix);
if (prefix && providerKey.startsWith(`${prefixKey}-`)) {
return prefix;
}
}
return undefined;
}
function resolveAgentHookChannel(params: {
messageChannel?: string | null;
messageProvider?: string | null;
currentChannelId?: string | null;
messageTo?: string | null;
}): string | undefined {
const messageChannel = normalizeOptionalString(params.messageChannel);
const provider = normalizeOptionalString(params.messageProvider);
const inferredProviderChannel = inferNarrowProviderChannel(params);
if (!messageChannel) {
return inferredProviderChannel ?? provider;
return provider;
}
const separatorIndex = messageChannel.indexOf(":");
if (separatorIndex === -1) {
if (inferredProviderChannel && normalizeKey(messageChannel) === normalizeKey(provider)) {
return inferredProviderChannel;
}
return messageChannel;
}
@@ -91,7 +61,7 @@ function resolveAgentHookChannel(params: {
TARGET_PREFIXES.has(normalizeKey(prefix)) ||
normalizeKey(prefix) === normalizeKey(provider)
) {
return inferredProviderChannel ?? provider;
return provider;
}
return prefix;
}
@@ -106,19 +76,14 @@ export function resolveAgentHookChannelId(params: {
}): string | undefined {
const provider = normalizeOptionalString(params.messageProvider);
const messageChannel = normalizeOptionalString(params.messageChannel);
const channel = resolveAgentHookChannel(params);
const parsed = parseRawSessionConversationRef(params.sessionKey);
if (parsed?.rawId) {
return parsed.rawId;
}
const metadataChannel =
stripConversationPrefix(
params.currentChannelId ?? undefined,
provider,
messageChannel,
channel,
) ?? stripConversationPrefix(params.messageTo ?? undefined, provider, messageChannel, channel);
stripConversationPrefix(params.currentChannelId ?? undefined, provider, messageChannel) ??
stripConversationPrefix(params.messageTo ?? undefined, provider, messageChannel);
if (metadataChannel && normalizeKey(metadataChannel) !== normalizeKey(provider)) {
return metadataChannel;
}
@@ -191,38 +156,3 @@ export function buildAgentHookContextIdentityFields(params: {
...(channelContext ? { channelContext } : {}),
};
}
/** Builds canonical channel and requester fields shared by agent and tool hooks. */
export function buildAgentHookContextOriginFields(params: {
sessionKey?: string | null;
messageChannel?: string | null;
messageProvider?: string | null;
currentChannelId?: string | null;
messageTo?: string | null;
trigger?: string | null;
senderId?: string | null;
chatId?: string | null;
channelContext?: PluginHookChannelContext;
}): Pick<
PluginHookAgentContext,
"channel" | "messageProvider" | "channelId" | "chatId" | "senderId" | "channelContext"
> {
const channelFields = buildAgentHookContextChannelFields({
sessionKey: params.sessionKey,
messageChannel: params.messageChannel,
messageProvider: params.messageProvider,
currentChannelId: params.currentChannelId,
messageTo: params.messageTo,
});
return {
...(channelFields.channel ? { channel: channelFields.channel } : {}),
...(channelFields.messageProvider ? { messageProvider: channelFields.messageProvider } : {}),
...(channelFields.channelId ? { channelId: channelFields.channelId } : {}),
...buildAgentHookContextIdentityFields({
trigger: params.trigger,
senderId: params.senderId ?? params.channelContext?.sender?.id,
chatId: params.chatId ?? params.channelContext?.chat?.id,
channelContext: params.channelContext,
}),
};
}

View File

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

Some files were not shown because too many files have changed in this diff Show More