refactor: split inbound and reload pipelines into staged modules

This commit is contained in:
Peter Steinberger
2026-03-02 21:54:53 +00:00
parent 99a3db6ba9
commit 55a2d12f40
9 changed files with 791 additions and 472 deletions

View File

@@ -9,6 +9,7 @@ import {
HARD_MAX_TOOL_RESULT_CHARS, HARD_MAX_TOOL_RESULT_CHARS,
truncateToolResultMessage, truncateToolResultMessage,
} from "./pi-embedded-runner/tool-result-truncation.js"; } from "./pi-embedded-runner/tool-result-truncation.js";
import { createPendingToolCallState } from "./session-tool-result-state.js";
import { makeMissingToolResult, sanitizeToolCallInputs } from "./session-transcript-repair.js"; import { makeMissingToolResult, sanitizeToolCallInputs } from "./session-transcript-repair.js";
import { extractToolCallsFromAssistant, extractToolResultId } from "./tool-call-id.js"; import { extractToolCallsFromAssistant, extractToolResultId } from "./tool-call-id.js";
@@ -106,7 +107,7 @@ export function installSessionToolResultGuard(
getPendingIds: () => string[]; getPendingIds: () => string[];
} { } {
const originalAppend = sessionManager.appendMessage.bind(sessionManager); const originalAppend = sessionManager.appendMessage.bind(sessionManager);
const pending = new Map<string, string | undefined>(); const pendingState = createPendingToolCallState();
const persistMessage = (message: AgentMessage) => { const persistMessage = (message: AgentMessage) => {
const transformer = opts?.transformMessageForPersistence; const transformer = opts?.transformMessageForPersistence;
return transformer ? transformer(message) : message; return transformer ? transformer(message) : message;
@@ -142,11 +143,11 @@ export function installSessionToolResultGuard(
}; };
const flushPendingToolResults = () => { const flushPendingToolResults = () => {
if (pending.size === 0) { if (pendingState.size() === 0) {
return; return;
} }
if (allowSyntheticToolResults) { if (allowSyntheticToolResults) {
for (const [id, name] of pending.entries()) { for (const [id, name] of pendingState.entries()) {
const synthetic = makeMissingToolResult({ toolCallId: id, toolName: name }); const synthetic = makeMissingToolResult({ toolCallId: id, toolName: name });
const flushed = applyBeforeWriteHook( const flushed = applyBeforeWriteHook(
persistToolResult(persistMessage(synthetic), { persistToolResult(persistMessage(synthetic), {
@@ -160,7 +161,7 @@ export function installSessionToolResultGuard(
} }
} }
} }
pending.clear(); pendingState.clear();
}; };
const guardedAppend = (message: AgentMessage) => { const guardedAppend = (message: AgentMessage) => {
@@ -171,7 +172,7 @@ export function installSessionToolResultGuard(
allowedToolNames: opts?.allowedToolNames, allowedToolNames: opts?.allowedToolNames,
}); });
if (sanitized.length === 0) { if (sanitized.length === 0) {
if (pending.size > 0) { if (pendingState.shouldFlushForSanitizedDrop()) {
flushPendingToolResults(); flushPendingToolResults();
} }
return undefined; return undefined;
@@ -182,9 +183,9 @@ export function installSessionToolResultGuard(
if (nextRole === "toolResult") { if (nextRole === "toolResult") {
const id = extractToolResultId(nextMessage as Extract<AgentMessage, { role: "toolResult" }>); const id = extractToolResultId(nextMessage as Extract<AgentMessage, { role: "toolResult" }>);
const toolName = id ? pending.get(id) : undefined; const toolName = id ? pendingState.getToolName(id) : undefined;
if (id) { if (id) {
pending.delete(id); pendingState.delete(id);
} }
const normalizedToolResult = normalizePersistedToolResultName(nextMessage, toolName); const normalizedToolResult = normalizePersistedToolResultName(nextMessage, toolName);
// Apply hard size cap before persistence to prevent oversized tool results // Apply hard size cap before persistence to prevent oversized tool results
@@ -221,11 +222,11 @@ export function installSessionToolResultGuard(
// synthetic results (e.g. OpenAI) accumulate stale pending state when a user message // synthetic results (e.g. OpenAI) accumulate stale pending state when a user message
// interrupts in-flight tool calls, leaving orphaned tool_use blocks in the transcript // interrupts in-flight tool calls, leaving orphaned tool_use blocks in the transcript
// that cause API 400 errors on subsequent requests. // that cause API 400 errors on subsequent requests.
if (pending.size > 0 && (toolCalls.length === 0 || nextRole !== "assistant")) { if (pendingState.shouldFlushBeforeNonToolResult(nextRole, toolCalls.length)) {
flushPendingToolResults(); flushPendingToolResults();
} }
// If new tool calls arrive while older ones are pending, flush the old ones first. // If new tool calls arrive while older ones are pending, flush the old ones first.
if (pending.size > 0 && toolCalls.length > 0) { if (pendingState.shouldFlushBeforeNewToolCalls(toolCalls.length)) {
flushPendingToolResults(); flushPendingToolResults();
} }
@@ -243,9 +244,7 @@ export function installSessionToolResultGuard(
} }
if (toolCalls.length > 0) { if (toolCalls.length > 0) {
for (const call of toolCalls) { pendingState.trackToolCalls(toolCalls);
pending.set(call.id, call.name);
}
} }
return result; return result;
@@ -256,6 +255,6 @@ export function installSessionToolResultGuard(
return { return {
flushPendingToolResults, flushPendingToolResults,
getPendingIds: () => Array.from(pending.keys()), getPendingIds: pendingState.getPendingIds,
}; };
} }

View File

@@ -0,0 +1,40 @@
export type PendingToolCall = { id: string; name?: string };
export type PendingToolCallState = {
size: () => number;
entries: () => IterableIterator<[string, string | undefined]>;
getToolName: (id: string) => string | undefined;
delete: (id: string) => void;
clear: () => void;
trackToolCalls: (calls: PendingToolCall[]) => void;
getPendingIds: () => string[];
shouldFlushForSanitizedDrop: () => boolean;
shouldFlushBeforeNonToolResult: (nextRole: unknown, toolCallCount: number) => boolean;
shouldFlushBeforeNewToolCalls: (toolCallCount: number) => boolean;
};
export function createPendingToolCallState(): PendingToolCallState {
const pending = new Map<string, string | undefined>();
return {
size: () => pending.size,
entries: () => pending.entries(),
getToolName: (id: string) => pending.get(id),
delete: (id: string) => {
pending.delete(id);
},
clear: () => {
pending.clear();
},
trackToolCalls: (calls: PendingToolCall[]) => {
for (const call of calls) {
pending.set(call.id, call.name);
}
},
getPendingIds: () => Array.from(pending.keys()),
shouldFlushForSanitizedDrop: () => pending.size > 0,
shouldFlushBeforeNonToolResult: (nextRole: unknown, toolCallCount: number) =>
pending.size > 0 && (toolCallCount === 0 || nextRole !== "assistant"),
shouldFlushBeforeNewToolCalls: (toolCallCount: number) => pending.size > 0 && toolCallCount > 0,
};
}

View File

@@ -0,0 +1,210 @@
import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js";
import { getActivePluginRegistry } from "../plugins/runtime.js";
export type ChannelKind = ChannelId;
export type GatewayReloadPlan = {
changedPaths: string[];
restartGateway: boolean;
restartReasons: string[];
hotReasons: string[];
reloadHooks: boolean;
restartGmailWatcher: boolean;
restartBrowserControl: boolean;
restartCron: boolean;
restartHeartbeat: boolean;
restartHealthMonitor: boolean;
restartChannels: Set<ChannelKind>;
noopPaths: string[];
};
type ReloadRule = {
prefix: string;
kind: "restart" | "hot" | "none";
actions?: ReloadAction[];
};
type ReloadAction =
| "reload-hooks"
| "restart-gmail-watcher"
| "restart-browser-control"
| "restart-cron"
| "restart-heartbeat"
| "restart-health-monitor"
| `restart-channel:${ChannelId}`;
const BASE_RELOAD_RULES: ReloadRule[] = [
{ prefix: "gateway.remote", kind: "none" },
{ prefix: "gateway.reload", kind: "none" },
{
prefix: "gateway.channelHealthCheckMinutes",
kind: "hot",
actions: ["restart-health-monitor"],
},
// Stuck-session warning threshold is read by the diagnostics heartbeat loop.
{ prefix: "diagnostics.stuckSessionWarnMs", kind: "none" },
{ prefix: "hooks.gmail", kind: "hot", actions: ["restart-gmail-watcher"] },
{ prefix: "hooks", kind: "hot", actions: ["reload-hooks"] },
{
prefix: "agents.defaults.heartbeat",
kind: "hot",
actions: ["restart-heartbeat"],
},
{
prefix: "agents.defaults.model",
kind: "hot",
actions: ["restart-heartbeat"],
},
{
prefix: "models",
kind: "hot",
actions: ["restart-heartbeat"],
},
{ prefix: "agent.heartbeat", kind: "hot", actions: ["restart-heartbeat"] },
{ prefix: "cron", kind: "hot", actions: ["restart-cron"] },
{
prefix: "browser",
kind: "hot",
actions: ["restart-browser-control"],
},
];
const BASE_RELOAD_RULES_TAIL: ReloadRule[] = [
{ prefix: "meta", kind: "none" },
{ prefix: "identity", kind: "none" },
{ prefix: "wizard", kind: "none" },
{ prefix: "logging", kind: "none" },
{ prefix: "agents", kind: "none" },
{ prefix: "tools", kind: "none" },
{ prefix: "bindings", kind: "none" },
{ prefix: "audio", kind: "none" },
{ prefix: "agent", kind: "none" },
{ prefix: "routing", kind: "none" },
{ prefix: "messages", kind: "none" },
{ prefix: "session", kind: "none" },
{ prefix: "talk", kind: "none" },
{ prefix: "skills", kind: "none" },
{ prefix: "secrets", kind: "none" },
{ prefix: "plugins", kind: "restart" },
{ prefix: "ui", kind: "none" },
{ prefix: "gateway", kind: "restart" },
{ prefix: "discovery", kind: "restart" },
{ prefix: "canvasHost", kind: "restart" },
];
let cachedReloadRules: ReloadRule[] | null = null;
let cachedRegistry: ReturnType<typeof getActivePluginRegistry> | null = null;
function listReloadRules(): ReloadRule[] {
const registry = getActivePluginRegistry();
if (registry !== cachedRegistry) {
cachedReloadRules = null;
cachedRegistry = registry;
}
if (cachedReloadRules) {
return cachedReloadRules;
}
// Channel docking: plugins contribute hot reload/no-op prefixes here.
const channelReloadRules: ReloadRule[] = listChannelPlugins().flatMap((plugin) => [
...(plugin.reload?.configPrefixes ?? []).map(
(prefix): ReloadRule => ({
prefix,
kind: "hot",
actions: [`restart-channel:${plugin.id}` as ReloadAction],
}),
),
...(plugin.reload?.noopPrefixes ?? []).map(
(prefix): ReloadRule => ({
prefix,
kind: "none",
}),
),
]);
const rules = [...BASE_RELOAD_RULES, ...channelReloadRules, ...BASE_RELOAD_RULES_TAIL];
cachedReloadRules = rules;
return rules;
}
function matchRule(path: string): ReloadRule | null {
for (const rule of listReloadRules()) {
if (path === rule.prefix || path.startsWith(`${rule.prefix}.`)) {
return rule;
}
}
return null;
}
export function buildGatewayReloadPlan(changedPaths: string[]): GatewayReloadPlan {
const plan: GatewayReloadPlan = {
changedPaths,
restartGateway: false,
restartReasons: [],
hotReasons: [],
reloadHooks: false,
restartGmailWatcher: false,
restartBrowserControl: false,
restartCron: false,
restartHeartbeat: false,
restartHealthMonitor: false,
restartChannels: new Set(),
noopPaths: [],
};
const applyAction = (action: ReloadAction) => {
if (action.startsWith("restart-channel:")) {
const channel = action.slice("restart-channel:".length) as ChannelId;
plan.restartChannels.add(channel);
return;
}
switch (action) {
case "reload-hooks":
plan.reloadHooks = true;
break;
case "restart-gmail-watcher":
plan.restartGmailWatcher = true;
break;
case "restart-browser-control":
plan.restartBrowserControl = true;
break;
case "restart-cron":
plan.restartCron = true;
break;
case "restart-heartbeat":
plan.restartHeartbeat = true;
break;
case "restart-health-monitor":
plan.restartHealthMonitor = true;
break;
default:
break;
}
};
for (const path of changedPaths) {
const rule = matchRule(path);
if (!rule) {
plan.restartGateway = true;
plan.restartReasons.push(path);
continue;
}
if (rule.kind === "restart") {
plan.restartGateway = true;
plan.restartReasons.push(path);
continue;
}
if (rule.kind === "none") {
plan.noopPaths.push(path);
continue;
}
plan.hotReasons.push(path);
for (const action of rule.actions ?? []) {
applyAction(action);
}
}
if (plan.restartGmailWatcher) {
plan.reloadHooks = true;
}
return plan;
}

View File

@@ -188,6 +188,53 @@ describe("buildGatewayReloadPlan", () => {
const plan = buildGatewayReloadPlan(["unknownField"]); const plan = buildGatewayReloadPlan(["unknownField"]);
expect(plan.restartGateway).toBe(true); expect(plan.restartGateway).toBe(true);
}); });
it.each([
{
path: "gateway.channelHealthCheckMinutes",
expectRestartGateway: false,
expectHotPath: "gateway.channelHealthCheckMinutes",
expectRestartHealthMonitor: true,
},
{
path: "hooks.gmail.account",
expectRestartGateway: false,
expectHotPath: "hooks.gmail.account",
expectRestartGmailWatcher: true,
expectReloadHooks: true,
},
{
path: "gateway.remote.url",
expectRestartGateway: false,
expectNoopPath: "gateway.remote.url",
},
{
path: "unknownField",
expectRestartGateway: true,
expectRestartReason: "unknownField",
},
])("classifies reload path: $path", (testCase) => {
const plan = buildGatewayReloadPlan([testCase.path]);
expect(plan.restartGateway).toBe(testCase.expectRestartGateway);
if (testCase.expectHotPath) {
expect(plan.hotReasons).toContain(testCase.expectHotPath);
}
if (testCase.expectNoopPath) {
expect(plan.noopPaths).toContain(testCase.expectNoopPath);
}
if (testCase.expectRestartReason) {
expect(plan.restartReasons).toContain(testCase.expectRestartReason);
}
if (testCase.expectRestartHealthMonitor) {
expect(plan.restartHealthMonitor).toBe(true);
}
if (testCase.expectRestartGmailWatcher) {
expect(plan.restartGmailWatcher).toBe(true);
}
if (testCase.expectReloadHooks) {
expect(plan.reloadHooks).toBe(true);
}
});
}); });
describe("resolveGatewayReloadSettings", () => { describe("resolveGatewayReloadSettings", () => {

View File

@@ -1,47 +1,17 @@
import { isDeepStrictEqual } from "node:util"; import { isDeepStrictEqual } from "node:util";
import chokidar from "chokidar"; import chokidar from "chokidar";
import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js";
import type { OpenClawConfig, ConfigFileSnapshot, GatewayReloadMode } from "../config/config.js"; import type { OpenClawConfig, ConfigFileSnapshot, GatewayReloadMode } from "../config/config.js";
import { getActivePluginRegistry } from "../plugins/runtime.js";
import { isPlainObject } from "../utils.js"; import { isPlainObject } from "../utils.js";
import { buildGatewayReloadPlan, type GatewayReloadPlan } from "./config-reload-plan.js";
export { buildGatewayReloadPlan };
export type { GatewayReloadPlan } from "./config-reload-plan.js";
export type GatewayReloadSettings = { export type GatewayReloadSettings = {
mode: GatewayReloadMode; mode: GatewayReloadMode;
debounceMs: number; debounceMs: number;
}; };
export type ChannelKind = ChannelId;
export type GatewayReloadPlan = {
changedPaths: string[];
restartGateway: boolean;
restartReasons: string[];
hotReasons: string[];
reloadHooks: boolean;
restartGmailWatcher: boolean;
restartBrowserControl: boolean;
restartCron: boolean;
restartHeartbeat: boolean;
restartHealthMonitor: boolean;
restartChannels: Set<ChannelKind>;
noopPaths: string[];
};
type ReloadRule = {
prefix: string;
kind: "restart" | "hot" | "none";
actions?: ReloadAction[];
};
type ReloadAction =
| "reload-hooks"
| "restart-gmail-watcher"
| "restart-browser-control"
| "restart-cron"
| "restart-heartbeat"
| "restart-health-monitor"
| `restart-channel:${ChannelId}`;
const DEFAULT_RELOAD_SETTINGS: GatewayReloadSettings = { const DEFAULT_RELOAD_SETTINGS: GatewayReloadSettings = {
mode: "hybrid", mode: "hybrid",
debounceMs: 300, debounceMs: 300,
@@ -49,107 +19,6 @@ const DEFAULT_RELOAD_SETTINGS: GatewayReloadSettings = {
const MISSING_CONFIG_RETRY_DELAY_MS = 150; const MISSING_CONFIG_RETRY_DELAY_MS = 150;
const MISSING_CONFIG_MAX_RETRIES = 2; const MISSING_CONFIG_MAX_RETRIES = 2;
const BASE_RELOAD_RULES: ReloadRule[] = [
{ prefix: "gateway.remote", kind: "none" },
{ prefix: "gateway.reload", kind: "none" },
{
prefix: "gateway.channelHealthCheckMinutes",
kind: "hot",
actions: ["restart-health-monitor"],
},
// Stuck-session warning threshold is read by the diagnostics heartbeat loop.
{ prefix: "diagnostics.stuckSessionWarnMs", kind: "none" },
{ prefix: "hooks.gmail", kind: "hot", actions: ["restart-gmail-watcher"] },
{ prefix: "hooks", kind: "hot", actions: ["reload-hooks"] },
{
prefix: "agents.defaults.heartbeat",
kind: "hot",
actions: ["restart-heartbeat"],
},
{
prefix: "agents.defaults.model",
kind: "hot",
actions: ["restart-heartbeat"],
},
{
prefix: "models",
kind: "hot",
actions: ["restart-heartbeat"],
},
{ prefix: "agent.heartbeat", kind: "hot", actions: ["restart-heartbeat"] },
{ prefix: "cron", kind: "hot", actions: ["restart-cron"] },
{
prefix: "browser",
kind: "hot",
actions: ["restart-browser-control"],
},
];
const BASE_RELOAD_RULES_TAIL: ReloadRule[] = [
{ prefix: "meta", kind: "none" },
{ prefix: "identity", kind: "none" },
{ prefix: "wizard", kind: "none" },
{ prefix: "logging", kind: "none" },
{ prefix: "agents", kind: "none" },
{ prefix: "tools", kind: "none" },
{ prefix: "bindings", kind: "none" },
{ prefix: "audio", kind: "none" },
{ prefix: "agent", kind: "none" },
{ prefix: "routing", kind: "none" },
{ prefix: "messages", kind: "none" },
{ prefix: "session", kind: "none" },
{ prefix: "talk", kind: "none" },
{ prefix: "skills", kind: "none" },
{ prefix: "secrets", kind: "none" },
{ prefix: "plugins", kind: "restart" },
{ prefix: "ui", kind: "none" },
{ prefix: "gateway", kind: "restart" },
{ prefix: "discovery", kind: "restart" },
{ prefix: "canvasHost", kind: "restart" },
];
let cachedReloadRules: ReloadRule[] | null = null;
let cachedRegistry: ReturnType<typeof getActivePluginRegistry> | null = null;
function listReloadRules(): ReloadRule[] {
const registry = getActivePluginRegistry();
if (registry !== cachedRegistry) {
cachedReloadRules = null;
cachedRegistry = registry;
}
if (cachedReloadRules) {
return cachedReloadRules;
}
// Channel docking: plugins contribute hot reload/no-op prefixes here.
const channelReloadRules: ReloadRule[] = listChannelPlugins().flatMap((plugin) => [
...(plugin.reload?.configPrefixes ?? []).map(
(prefix): ReloadRule => ({
prefix,
kind: "hot",
actions: [`restart-channel:${plugin.id}` as ReloadAction],
}),
),
...(plugin.reload?.noopPrefixes ?? []).map(
(prefix): ReloadRule => ({
prefix,
kind: "none",
}),
),
]);
const rules = [...BASE_RELOAD_RULES, ...channelReloadRules, ...BASE_RELOAD_RULES_TAIL];
cachedReloadRules = rules;
return rules;
}
function matchRule(path: string): ReloadRule | null {
for (const rule of listReloadRules()) {
if (path === rule.prefix || path.startsWith(`${rule.prefix}.`)) {
return rule;
}
}
return null;
}
export function diffConfigPaths(prev: unknown, next: unknown, prefix = ""): string[] { export function diffConfigPaths(prev: unknown, next: unknown, prefix = ""): string[] {
if (prev === next) { if (prev === next) {
return []; return [];
@@ -195,81 +64,6 @@ export function resolveGatewayReloadSettings(cfg: OpenClawConfig): GatewayReload
return { mode, debounceMs }; return { mode, debounceMs };
} }
export function buildGatewayReloadPlan(changedPaths: string[]): GatewayReloadPlan {
const plan: GatewayReloadPlan = {
changedPaths,
restartGateway: false,
restartReasons: [],
hotReasons: [],
reloadHooks: false,
restartGmailWatcher: false,
restartBrowserControl: false,
restartCron: false,
restartHeartbeat: false,
restartHealthMonitor: false,
restartChannels: new Set(),
noopPaths: [],
};
const applyAction = (action: ReloadAction) => {
if (action.startsWith("restart-channel:")) {
const channel = action.slice("restart-channel:".length) as ChannelId;
plan.restartChannels.add(channel);
return;
}
switch (action) {
case "reload-hooks":
plan.reloadHooks = true;
break;
case "restart-gmail-watcher":
plan.restartGmailWatcher = true;
break;
case "restart-browser-control":
plan.restartBrowserControl = true;
break;
case "restart-cron":
plan.restartCron = true;
break;
case "restart-heartbeat":
plan.restartHeartbeat = true;
break;
case "restart-health-monitor":
plan.restartHealthMonitor = true;
break;
default:
break;
}
};
for (const path of changedPaths) {
const rule = matchRule(path);
if (!rule) {
plan.restartGateway = true;
plan.restartReasons.push(path);
continue;
}
if (rule.kind === "restart") {
plan.restartGateway = true;
plan.restartReasons.push(path);
continue;
}
if (rule.kind === "none") {
plan.noopPaths.push(path);
continue;
}
plan.hotReasons.push(path);
for (const action of rule.actions ?? []) {
applyAction(action);
}
}
if (plan.restartGmailWatcher) {
plan.reloadHooks = true;
}
return plan;
}
export type GatewayConfigReloader = { export type GatewayConfigReloader = {
stop: () => Promise<void>; stop: () => Promise<void>;
}; };

View File

@@ -0,0 +1,83 @@
import fs from "node:fs";
import { expandHomePrefix } from "./home-dir.js";
const GLOB_REGEX_CACHE_LIMIT = 512;
const globRegexCache = new Map<string, RegExp>();
function normalizeMatchTarget(value: string): string {
if (process.platform === "win32") {
const stripped = value.replace(/^\\\\[?.]\\/, "");
return stripped.replace(/\\/g, "/").toLowerCase();
}
return value.replace(/\\\\/g, "/").toLowerCase();
}
function tryRealpath(value: string): string | null {
try {
return fs.realpathSync(value);
} catch {
return null;
}
}
function escapeRegExpLiteral(input: string): string {
return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function compileGlobRegex(pattern: string): RegExp {
const cached = globRegexCache.get(pattern);
if (cached) {
return cached;
}
let regex = "^";
let i = 0;
while (i < pattern.length) {
const ch = pattern[i];
if (ch === "*") {
const next = pattern[i + 1];
if (next === "*") {
regex += ".*";
i += 2;
continue;
}
regex += "[^/]*";
i += 1;
continue;
}
if (ch === "?") {
regex += ".";
i += 1;
continue;
}
regex += escapeRegExpLiteral(ch);
i += 1;
}
regex += "$";
const compiled = new RegExp(regex, "i");
if (globRegexCache.size >= GLOB_REGEX_CACHE_LIMIT) {
globRegexCache.clear();
}
globRegexCache.set(pattern, compiled);
return compiled;
}
export function matchesExecAllowlistPattern(pattern: string, target: string): boolean {
const trimmed = pattern.trim();
if (!trimmed) {
return false;
}
const expanded = trimmed.startsWith("~") ? expandHomePrefix(trimmed) : trimmed;
const hasWildcard = /[*?]/.test(expanded);
let normalizedPattern = expanded;
let normalizedTarget = target;
if (process.platform === "win32" && !hasWildcard) {
normalizedPattern = tryRealpath(expanded) ?? expanded;
normalizedTarget = tryRealpath(target) ?? target;
}
normalizedPattern = normalizeMatchTarget(normalizedPattern);
normalizedTarget = normalizeMatchTarget(normalizedTarget);
return compileGlobRegex(normalizedPattern).test(normalizedTarget);
}

View File

@@ -1,5 +1,6 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { matchesExecAllowlistPattern } from "./exec-allowlist-pattern.js";
import type { ExecAllowlistEntry } from "./exec-approvals.js"; import type { ExecAllowlistEntry } from "./exec-approvals.js";
import { resolveDispatchWrapperExecutionPlan } from "./exec-wrapper-resolution.js"; import { resolveDispatchWrapperExecutionPlan } from "./exec-wrapper-resolution.js";
import { resolveExecutablePath as resolveExecutableCandidatePath } from "./executable-path.js"; import { resolveExecutablePath as resolveExecutableCandidatePath } from "./executable-path.js";
@@ -114,73 +115,6 @@ export function resolveCommandResolutionFromArgv(
}); });
} }
function normalizeMatchTarget(value: string): string {
if (process.platform === "win32") {
const stripped = value.replace(/^\\\\[?.]\\/, "");
return stripped.replace(/\\/g, "/").toLowerCase();
}
return value.replace(/\\\\/g, "/").toLowerCase();
}
function tryRealpath(value: string): string | null {
try {
return fs.realpathSync(value);
} catch {
return null;
}
}
function escapeRegExpLiteral(input: string): string {
return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function globToRegExp(pattern: string): RegExp {
let regex = "^";
let i = 0;
while (i < pattern.length) {
const ch = pattern[i];
if (ch === "*") {
const next = pattern[i + 1];
if (next === "*") {
regex += ".*";
i += 2;
continue;
}
regex += "[^/]*";
i += 1;
continue;
}
if (ch === "?") {
regex += ".";
i += 1;
continue;
}
regex += escapeRegExpLiteral(ch);
i += 1;
}
regex += "$";
return new RegExp(regex, "i");
}
function matchesPattern(pattern: string, target: string): boolean {
const trimmed = pattern.trim();
if (!trimmed) {
return false;
}
const expanded = trimmed.startsWith("~") ? expandHomePrefix(trimmed) : trimmed;
const hasWildcard = /[*?]/.test(expanded);
let normalizedPattern = expanded;
let normalizedTarget = target;
if (process.platform === "win32" && !hasWildcard) {
normalizedPattern = tryRealpath(expanded) ?? expanded;
normalizedTarget = tryRealpath(target) ?? target;
}
normalizedPattern = normalizeMatchTarget(normalizedPattern);
normalizedTarget = normalizeMatchTarget(normalizedTarget);
const regex = globToRegExp(normalizedPattern);
return regex.test(normalizedTarget);
}
export function resolveAllowlistCandidatePath( export function resolveAllowlistCandidatePath(
resolution: CommandResolution | null, resolution: CommandResolution | null,
cwd?: string, cwd?: string,
@@ -233,7 +167,7 @@ export function matchAllowlist(
if (!hasPath) { if (!hasPath) {
continue; continue;
} }
if (matchesPattern(pattern, resolvedPath)) { if (matchesExecAllowlistPattern(pattern, resolvedPath)) {
return entry; return entry;
} }
} }

View File

@@ -77,13 +77,47 @@ function resolveCachedMentionRegexes(
return built; return built;
} }
export async function prepareSlackMessage(params: { type SlackConversationContext = {
channelInfo: {
name?: string;
type?: SlackMessageEvent["channel_type"];
topic?: string;
purpose?: string;
};
channelName?: string;
resolvedChannelType: ReturnType<typeof normalizeSlackChannelType>;
isDirectMessage: boolean;
isGroupDm: boolean;
isRoom: boolean;
isRoomish: boolean;
channelConfig: ReturnType<typeof resolveSlackChannelConfig> | null;
allowBots: boolean;
isBotMessage: boolean;
};
type SlackAuthorizationContext = {
senderId: string;
allowFromLower: string[];
};
type SlackRoutingContext = {
route: ReturnType<typeof resolveAgentRoute>;
chatType: "direct" | "group" | "channel";
replyToMode: ReturnType<typeof resolveSlackReplyToMode>;
threadContext: ReturnType<typeof resolveSlackThreadContext>;
threadTs: string | undefined;
isThreadReply: boolean;
threadKeys: ReturnType<typeof resolveThreadSessionKeys>;
sessionKey: string;
historyKey: string;
};
async function resolveSlackConversationContext(params: {
ctx: SlackMonitorContext; ctx: SlackMonitorContext;
account: ResolvedSlackAccount; account: ResolvedSlackAccount;
message: SlackMessageEvent; message: SlackMessageEvent;
opts: { source: "message" | "app_mention"; wasMentioned?: boolean }; }): Promise<SlackConversationContext> {
}): Promise<PreparedSlackMessage | null> { const { ctx, account, message } = params;
const { ctx, account, message, opts } = params;
const cfg = ctx.cfg; const cfg = ctx.cfg;
let channelInfo: { let channelInfo: {
@@ -107,7 +141,6 @@ export async function prepareSlackMessage(params: {
const isGroupDm = resolvedChannelType === "mpim"; const isGroupDm = resolvedChannelType === "mpim";
const isRoom = resolvedChannelType === "channel" || resolvedChannelType === "group"; const isRoom = resolvedChannelType === "channel" || resolvedChannelType === "group";
const isRoomish = isRoom || isGroupDm; const isRoomish = isRoom || isGroupDm;
const channelConfig = isRoom const channelConfig = isRoom
? resolveSlackChannelConfig({ ? resolveSlackChannelConfig({
channelId: message.channel, channelId: message.channel,
@@ -117,14 +150,36 @@ export async function prepareSlackMessage(params: {
defaultRequireMention: ctx.defaultRequireMention, defaultRequireMention: ctx.defaultRequireMention,
}) })
: null; : null;
const allowBots = const allowBots =
channelConfig?.allowBots ?? channelConfig?.allowBots ??
account.config?.allowBots ?? account.config?.allowBots ??
cfg.channels?.slack?.allowBots ?? cfg.channels?.slack?.allowBots ??
false; false;
const isBotMessage = Boolean(message.bot_id); return {
channelInfo,
channelName,
resolvedChannelType,
isDirectMessage,
isGroupDm,
isRoom,
isRoomish,
channelConfig,
allowBots,
isBotMessage: Boolean(message.bot_id),
};
}
async function authorizeSlackInboundMessage(params: {
ctx: SlackMonitorContext;
account: ResolvedSlackAccount;
message: SlackMessageEvent;
conversation: SlackConversationContext;
}): Promise<SlackAuthorizationContext | null> {
const { ctx, account, message, conversation } = params;
const { isDirectMessage, channelName, resolvedChannelType, isBotMessage, allowBots } =
conversation;
if (isBotMessage) { if (isBotMessage) {
if (message.user && ctx.botUserId && message.user === ctx.botUserId) { if (message.user && ctx.botUserId && message.user === ctx.botUserId) {
return null; return null;
@@ -195,8 +250,24 @@ export async function prepareSlackMessage(params: {
} }
} }
return {
senderId,
allowFromLower,
};
}
function resolveSlackRoutingContext(params: {
ctx: SlackMonitorContext;
account: ResolvedSlackAccount;
message: SlackMessageEvent;
isDirectMessage: boolean;
isGroupDm: boolean;
isRoom: boolean;
isRoomish: boolean;
}): SlackRoutingContext {
const { ctx, account, message, isDirectMessage, isGroupDm, isRoom, isRoomish } = params;
const route = resolveAgentRoute({ const route = resolveAgentRoute({
cfg, cfg: ctx.cfg,
channel: "slack", channel: "slack",
accountId: account.accountId, accountId: account.accountId,
teamId: ctx.teamId || undefined, teamId: ctx.teamId || undefined,
@@ -206,7 +277,6 @@ export async function prepareSlackMessage(params: {
}, },
}); });
const baseSessionKey = route.sessionKey;
const chatType = isDirectMessage ? "direct" : isGroupDm ? "group" : "channel"; const chatType = isDirectMessage ? "direct" : isGroupDm ? "group" : "channel";
const replyToMode = resolveSlackReplyToMode(account, chatType); const replyToMode = resolveSlackReplyToMode(account, chatType);
const threadContext = resolveSlackThreadContext({ message, replyToMode }); const threadContext = resolveSlackThreadContext({ message, replyToMode });
@@ -224,14 +294,76 @@ export async function prepareSlackMessage(params: {
? threadTs ? threadTs
: autoThreadId; : autoThreadId;
const threadKeys = resolveThreadSessionKeys({ const threadKeys = resolveThreadSessionKeys({
baseSessionKey, baseSessionKey: route.sessionKey,
threadId: canonicalThreadId, threadId: canonicalThreadId,
parentSessionKey: canonicalThreadId && ctx.threadInheritParent ? baseSessionKey : undefined, parentSessionKey: canonicalThreadId && ctx.threadInheritParent ? route.sessionKey : undefined,
}); });
const sessionKey = threadKeys.sessionKey; const sessionKey = threadKeys.sessionKey;
const historyKey = const historyKey =
isThreadReply && ctx.threadHistoryScope === "thread" ? sessionKey : message.channel; isThreadReply && ctx.threadHistoryScope === "thread" ? sessionKey : message.channel;
return {
route,
chatType,
replyToMode,
threadContext,
threadTs,
isThreadReply,
threadKeys,
sessionKey,
historyKey,
};
}
export async function prepareSlackMessage(params: {
ctx: SlackMonitorContext;
account: ResolvedSlackAccount;
message: SlackMessageEvent;
opts: { source: "message" | "app_mention"; wasMentioned?: boolean };
}): Promise<PreparedSlackMessage | null> {
const { ctx, account, message, opts } = params;
const cfg = ctx.cfg;
const conversation = await resolveSlackConversationContext({ ctx, account, message });
const {
channelInfo,
channelName,
isDirectMessage,
isGroupDm,
isRoom,
isRoomish,
channelConfig,
isBotMessage,
} = conversation;
const authorization = await authorizeSlackInboundMessage({
ctx,
account,
message,
conversation,
});
if (!authorization) {
return null;
}
const { senderId, allowFromLower } = authorization;
const routing = resolveSlackRoutingContext({
ctx,
account,
message,
isDirectMessage,
isGroupDm,
isRoom,
isRoomish,
});
const {
route,
replyToMode,
threadContext,
threadTs,
isThreadReply,
threadKeys,
sessionKey,
historyKey,
} = routing;
const mentionRegexes = resolveCachedMentionRegexes(ctx, route.agentId); const mentionRegexes = resolveCachedMentionRegexes(ctx, route.agentId);
const hasAnyMention = /<@[^>]+>/.test(message.text ?? ""); const hasAnyMention = /<@[^>]+>/.test(message.text ?? "");
const explicitlyMentioned = Boolean( const explicitlyMentioned = Boolean(

View File

@@ -151,36 +151,42 @@ export async function monitorWebInbox(options: {
} }
}; };
const handleMessagesUpsert = async (upsert: { type?: string; messages?: Array<WAMessage> }) => { type NormalizedInboundMessage = {
if (upsert.type !== "notify" && upsert.type !== "append") { id?: string;
return; remoteJid: string;
} group: boolean;
for (const msg of upsert.messages ?? []) { participantJid?: string;
recordChannelActivity({ from: string;
channel: "whatsapp", senderE164: string | null;
accountId: options.accountId, groupSubject?: string;
direction: "inbound", groupParticipants?: string[];
}); messageTimestampMs?: number;
access: Awaited<ReturnType<typeof checkInboundAccessControl>>;
};
const normalizeInboundMessage = async (
msg: WAMessage,
): Promise<NormalizedInboundMessage | null> => {
const id = msg.key?.id ?? undefined; const id = msg.key?.id ?? undefined;
const remoteJid = msg.key?.remoteJid; const remoteJid = msg.key?.remoteJid;
if (!remoteJid) { if (!remoteJid) {
continue; return null;
} }
if (remoteJid.endsWith("@status") || remoteJid.endsWith("@broadcast")) { if (remoteJid.endsWith("@status") || remoteJid.endsWith("@broadcast")) {
continue; return null;
} }
const group = isJidGroup(remoteJid) === true; const group = isJidGroup(remoteJid) === true;
if (id) { if (id) {
const dedupeKey = `${options.accountId}:${remoteJid}:${id}`; const dedupeKey = `${options.accountId}:${remoteJid}:${id}`;
if (isRecentInboundMessage(dedupeKey)) { if (isRecentInboundMessage(dedupeKey)) {
continue; return null;
} }
} }
const participantJid = msg.key?.participant ?? undefined; const participantJid = msg.key?.participant ?? undefined;
const from = group ? remoteJid : await resolveInboundJid(remoteJid); const from = group ? remoteJid : await resolveInboundJid(remoteJid);
if (!from) { if (!from) {
continue; return null;
} }
const senderE164 = group const senderE164 = group
? participantJid ? participantJid
@@ -213,15 +219,30 @@ export async function monitorWebInbox(options: {
remoteJid, remoteJid,
}); });
if (!access.allowed) { if (!access.allowed) {
continue; return null;
} }
return {
id,
remoteJid,
group,
participantJid,
from,
senderE164,
groupSubject,
groupParticipants,
messageTimestampMs,
access,
};
};
const maybeMarkInboundAsRead = async (inbound: NormalizedInboundMessage) => {
const { id, remoteJid, participantJid, access } = inbound;
if (id && !access.isSelfChat && options.sendReadReceipts !== false) { if (id && !access.isSelfChat && options.sendReadReceipts !== false) {
const participant = msg.key?.participant;
try { try {
await sock.readMessages([{ remoteJid, id, participant, fromMe: false }]); await sock.readMessages([{ remoteJid, id, participant: participantJid, fromMe: false }]);
if (shouldLogVerbose()) { if (shouldLogVerbose()) {
const suffix = participant ? ` (participant ${participant})` : ""; const suffix = participantJid ? ` (participant ${participantJid})` : "";
logVerbose(`Marked message ${id} as read for ${remoteJid}${suffix}`); logVerbose(`Marked message ${id} as read for ${remoteJid}${suffix}`);
} }
} catch (err) { } catch (err) {
@@ -231,12 +252,18 @@ export async function monitorWebInbox(options: {
// Self-chat mode: never auto-send read receipts (blue ticks) on behalf of the owner. // Self-chat mode: never auto-send read receipts (blue ticks) on behalf of the owner.
logVerbose(`Self-chat mode: skipping read receipt for ${id}`); logVerbose(`Self-chat mode: skipping read receipt for ${id}`);
} }
};
// If this is history/offline catch-up, mark read above but skip auto-reply. type EnrichedInboundMessage = {
if (upsert.type === "append") { body: string;
continue; location?: ReturnType<typeof extractLocationData>;
} replyContext?: ReturnType<typeof describeReplyContext>;
mediaPath?: string;
mediaType?: string;
mediaFileName?: string;
};
const enrichInboundMessage = async (msg: WAMessage): Promise<EnrichedInboundMessage | null> => {
const location = extractLocationData(msg.message ?? undefined); const location = extractLocationData(msg.message ?? undefined);
const locationText = location ? formatLocationText(location) : undefined; const locationText = location ? formatLocationText(location) : undefined;
let body = extractText(msg.message ?? undefined); let body = extractText(msg.message ?? undefined);
@@ -246,7 +273,7 @@ export async function monitorWebInbox(options: {
if (!body) { if (!body) {
body = extractMediaPlaceholder(msg.message ?? undefined); body = extractMediaPlaceholder(msg.message ?? undefined);
if (!body) { if (!body) {
continue; return null;
} }
} }
const replyContext = describeReplyContext(msg.message as proto.IMessage | undefined); const replyContext = describeReplyContext(msg.message as proto.IMessage | undefined);
@@ -277,7 +304,22 @@ export async function monitorWebInbox(options: {
logVerbose(`Inbound media download failed: ${String(err)}`); logVerbose(`Inbound media download failed: ${String(err)}`);
} }
const chatJid = remoteJid; return {
body,
location: location ?? undefined,
replyContext,
mediaPath,
mediaType,
mediaFileName,
};
};
const enqueueInboundMessage = async (
msg: WAMessage,
inbound: NormalizedInboundMessage,
enriched: EnrichedInboundMessage,
) => {
const chatJid = inbound.remoteJid;
const sendComposing = async () => { const sendComposing = async () => {
try { try {
await sock.sendPresenceUpdate("composing", chatJid); await sock.sendPresenceUpdate("composing", chatJid);
@@ -291,46 +333,54 @@ export async function monitorWebInbox(options: {
const sendMedia = async (payload: AnyMessageContent) => { const sendMedia = async (payload: AnyMessageContent) => {
await sock.sendMessage(chatJid, payload); await sock.sendMessage(chatJid, payload);
}; };
const timestamp = messageTimestampMs; const timestamp = inbound.messageTimestampMs;
const mentionedJids = extractMentionedJids(msg.message as proto.IMessage | undefined); const mentionedJids = extractMentionedJids(msg.message as proto.IMessage | undefined);
const senderName = msg.pushName ?? undefined; const senderName = msg.pushName ?? undefined;
inboundLogger.info( inboundLogger.info(
{ from, to: selfE164 ?? "me", body, mediaPath, mediaType, mediaFileName, timestamp }, {
from: inbound.from,
to: selfE164 ?? "me",
body: enriched.body,
mediaPath: enriched.mediaPath,
mediaType: enriched.mediaType,
mediaFileName: enriched.mediaFileName,
timestamp,
},
"inbound message", "inbound message",
); );
const inboundMessage: WebInboundMessage = { const inboundMessage: WebInboundMessage = {
id, id: inbound.id,
from, from: inbound.from,
conversationId: from, conversationId: inbound.from,
to: selfE164 ?? "me", to: selfE164 ?? "me",
accountId: access.resolvedAccountId, accountId: inbound.access.resolvedAccountId,
body, body: enriched.body,
pushName: senderName, pushName: senderName,
timestamp, timestamp,
chatType: group ? "group" : "direct", chatType: inbound.group ? "group" : "direct",
chatId: remoteJid, chatId: inbound.remoteJid,
senderJid: participantJid, senderJid: inbound.participantJid,
senderE164: senderE164 ?? undefined, senderE164: inbound.senderE164 ?? undefined,
senderName, senderName,
replyToId: replyContext?.id, replyToId: enriched.replyContext?.id,
replyToBody: replyContext?.body, replyToBody: enriched.replyContext?.body,
replyToSender: replyContext?.sender, replyToSender: enriched.replyContext?.sender,
replyToSenderJid: replyContext?.senderJid, replyToSenderJid: enriched.replyContext?.senderJid,
replyToSenderE164: replyContext?.senderE164, replyToSenderE164: enriched.replyContext?.senderE164,
groupSubject, groupSubject: inbound.groupSubject,
groupParticipants, groupParticipants: inbound.groupParticipants,
mentionedJids: mentionedJids ?? undefined, mentionedJids: mentionedJids ?? undefined,
selfJid, selfJid,
selfE164, selfE164,
fromMe: Boolean(msg.key?.fromMe), fromMe: Boolean(msg.key?.fromMe),
location: location ?? undefined, location: enriched.location ?? undefined,
sendComposing, sendComposing,
reply, reply,
sendMedia, sendMedia,
mediaPath, mediaPath: enriched.mediaPath,
mediaType, mediaType: enriched.mediaType,
mediaFileName, mediaFileName: enriched.mediaFileName,
}; };
try { try {
const task = Promise.resolve(debouncer.enqueue(inboundMessage)); const task = Promise.resolve(debouncer.enqueue(inboundMessage));
@@ -342,6 +392,36 @@ export async function monitorWebInbox(options: {
inboundLogger.error({ error: String(err) }, "failed handling inbound web message"); inboundLogger.error({ error: String(err) }, "failed handling inbound web message");
inboundConsoleLog.error(`Failed handling inbound web message: ${String(err)}`); inboundConsoleLog.error(`Failed handling inbound web message: ${String(err)}`);
} }
};
const handleMessagesUpsert = async (upsert: { type?: string; messages?: Array<WAMessage> }) => {
if (upsert.type !== "notify" && upsert.type !== "append") {
return;
}
for (const msg of upsert.messages ?? []) {
recordChannelActivity({
channel: "whatsapp",
accountId: options.accountId,
direction: "inbound",
});
const inbound = await normalizeInboundMessage(msg);
if (!inbound) {
continue;
}
await maybeMarkInboundAsRead(inbound);
// If this is history/offline catch-up, mark read above but skip auto-reply.
if (upsert.type === "append") {
continue;
}
const enriched = await enrichInboundMessage(msg);
if (!enriched) {
continue;
}
await enqueueInboundMessage(msg, inbound, enriched);
} }
}; };
sock.ev.on("messages.upsert", handleMessagesUpsert); sock.ev.on("messages.upsert", handleMessagesUpsert);