Compare commits

..

18 Commits

Author SHA1 Message Date
Peter Steinberger
7ba044c6cd fix(docker): correct sandbox fingerprint awk quoting (#32411) (thanks @Supsumintong) 2026-03-03 02:32:21 +00:00
苏敏童0668001043
c34b12b346 fix(docker): correct awk quoting in Docker GPG fingerprint check (#32153) 2026-03-03 02:31:58 +00:00
Peter Steinberger
6ab9e00e17 fix: resolve pi-tools typing regressions 2026-03-03 02:27:59 +00:00
Peter Steinberger
2380c1b5fd refactor(ui): dedupe inline code wrap rules 2026-03-03 02:19:34 +00:00
Peter Steinberger
493b560dfd refactor(runtime): unify node version guard parsing 2026-03-03 02:19:34 +00:00
Peter Steinberger
1dd77e4106 refactor(slack): extract socket reconnect policy helpers 2026-03-03 02:19:34 +00:00
Peter Steinberger
4d52dfe85b refactor(sessions): add explicit merge activity policies 2026-03-03 02:19:34 +00:00
Peter Steinberger
d380ed710d refactor(agents): split pi-tools param and host-edit wrappers 2026-03-03 02:19:34 +00:00
Peter Steinberger
03755f8463 test(telegram): dedupe streaming cases and tighten sequential key checks 2026-03-03 02:14:15 +00:00
Peter Steinberger
7fdbf1202e test(security): reduce audit fixture setup overhead 2026-03-03 02:14:15 +00:00
Peter Steinberger
70db52de71 test(agents): centralize AgentMessage fixtures and remove unsafe casts 2026-03-03 02:14:15 +00:00
Gustavo Madeira Santana
15a0455d04 CLI: unify routed config positional parsing 2026-03-02 21:11:53 -05:00
Peter Steinberger
d3c637d193 fix: recover host edit success after post-write upstream throw (#32383) (thanks @polooooo) 2026-03-03 02:06:59 +00:00
倪汉杰0668001185
0fb3f188b2 fix(agents): only recover edit when oldText no longer in file (review feedback) 2026-03-03 02:06:59 +00:00
倪汉杰0668001185
bf6aa7ca67 fix(agents): treat host edit tool as success when file contains newText after upstream throw (fixes #32333) 2026-03-03 02:06:59 +00:00
Peter Steinberger
0fd77c9856 refactor: modularize plugin runtime and test hooks 2026-03-03 02:06:58 +00:00
Peter Steinberger
f77f1d3800 fix: preserve inline code copy fidelity in web ui (#32346) (thanks @hclsys) 2026-03-03 02:05:45 +00:00
HCL
7c90ef7c52 fix(webui): prevent inline code from breaking mid-token on copy/paste
The parent `.chat-text` applies `overflow-wrap: anywhere; word-break: break-word;`
which forces long tokens (UUIDs, hashes) inside inline `<code>` to break across
visual lines. When copied, the browser injects spaces at those break points,
corrupting the pasted value.

Override with `overflow-wrap: normal; word-break: keep-all;` on inline `<code>`
selectors so tokens stay intact.

Fixes #32230

Signed-off-by: HCL <chenglunhu@gmail.com>
2026-03-03 02:05:37 +00:00
50 changed files with 1971 additions and 1357 deletions

View File

@@ -223,8 +223,8 @@ jobs:
# Types, lint, and format check.
check:
name: "check"
needs: [docs-scope]
if: needs.docs-scope.outputs.docs_only != 'true'
needs: [docs-scope, changed-scope]
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout

View File

@@ -41,6 +41,7 @@ Docs: https://docs.openclaw.ai
- Slack/socket auth failure handling: fail fast on non-recoverable auth errors (`account_inactive`, `invalid_auth`, etc.) during startup and reconnect instead of retry-looping indefinitely, including `unable_to_socket_mode_start` error payload propagation. (#32377) Thanks @scoootscooob.
- CLI/installer Node preflight: enforce Node.js `v22.12+` consistently in both `openclaw.mjs` runtime bootstrap and installer active-shell checks, with actionable nvm recovery guidance for mismatched shell PATH/defaults. (#32356) Thanks @jasonhargrove.
- Web UI/inline code copy fidelity: disable forced mid-token wraps on inline `<code>` spans so copied UUID/hash/token strings preserve exact content instead of inserting line-break spaces. (#32346) Thanks @hclsys.
- Agents/host edit reliability: treat host edit-tool throws as success only when on-disk post-check confirms replacement likely happened (`newText` present and `oldText` absent), preventing false failure reports while avoiding pre-write false positives. (#32383) Thanks @polooooo.
- Gateway/message tool reliability: avoid false `Unknown channel` failures when `message.*` actions receive platform-specific channel ids by falling back to `toolContext.currentChannelProvider`, and prevent health-monitor restart thrash for channels that just (re)started by adding a per-channel startup-connect grace window. (from #32367) Thanks @MunemHashmi.
- Discord/lifecycle startup status: push an immediate `connected` status snapshot when the gateway is already connected before lifecycle debug listeners attach, with abort-guarding to avoid contradictory status flips during pre-aborted startup. (#32336) Thanks @mitchmcalister.
- Cron/isolated delivery target fallback: remove early unresolved-target return so cron delivery can flow through shared outbound target resolution (including per-channel `resolveDefaultTo` fallback) when `delivery.to` is omitted. (#32364) Thanks @hclsys.
@@ -74,6 +75,7 @@ Docs: https://docs.openclaw.ai
- Feishu/File upload filenames: percent-encode non-ASCII/special-character `file_name` values in Feishu multipart uploads so Chinese/symbol-heavy filenames are sent as proper attachments instead of plain text links. (#31179) Thanks @Kay-051.
- Auto-reply/inline command cleanup: preserve newline structure when stripping inline `/status` and extracting inline slash commands by collapsing only horizontal whitespace, preventing paragraph flattening in multi-line replies. (#32224) Thanks @scoootscooob.
- macOS/LaunchAgent security defaults: write `Umask=63` (octal `077`) into generated gateway launchd plists so post-update service reinstalls keep owner-only file permissions by default instead of falling back to system `022`. (#32022) Fixes #31905. Thanks @liuxiaopai-ai.
- Docker/build sandbox CLI install: fix Docker GPG fingerprint awk quoting in the optional Docker CLI install path so `OPENCLAW_SANDBOX=1` builds do not fail with awk parse errors. (#32411) Thanks @Supsumintong.
- Plugin SDK/runtime hardening: add package export verification in CI/release checks to catch missing runtime exports before publish-time regressions. (#28575) Thanks @Glucksberg.
- Media understanding/provider HTTP proxy routing: pass a proxy-aware fetch function from `HTTPS_PROXY`/`HTTP_PROXY` env vars into audio/video provider calls (with graceful malformed-proxy fallback) so transcription/video requests honor configured outbound proxies. (#27093) Thanks @mcaxtr.
- Media/MIME normalization: normalize parameterized/case-variant MIME strings in `kindFromMime` (for example `Audio/Ogg; codecs=opus`) so WhatsApp voice notes are classified as audio and routed through transcription correctly. (#32280) Thanks @Lucenx9.

View File

@@ -72,7 +72,7 @@ RUN if [ -n "$OPENCLAW_INSTALL_DOCKER_CLI" ]; then \
# Update OPENCLAW_DOCKER_GPG_FINGERPRINT when Docker rotates release keys.
curl -fsSL https://download.docker.com/linux/debian/gpg -o /tmp/docker.gpg.asc && \
expected_fingerprint="$(printf '%s' "$OPENCLAW_DOCKER_GPG_FINGERPRINT" | tr '[:lower:]' '[:upper:]' | tr -d '[:space:]')" && \
actual_fingerprint="$(gpg --batch --show-keys --with-colons /tmp/docker.gpg.asc | awk -F: '$1 == \"fpr\" { print toupper($10); exit }')" && \
actual_fingerprint="$(gpg --batch --show-keys --with-colons /tmp/docker.gpg.asc | awk -F: '$1 == "fpr" { print toupper($10); exit }')" && \
if [ -z "$actual_fingerprint" ] || [ "$actual_fingerprint" != "$expected_fingerprint" ]; then \
echo "ERROR: Docker apt key fingerprint mismatch (expected $expected_fingerprint, got ${actual_fingerprint:-<empty>})" >&2; \
exit 1; \

View File

@@ -13,20 +13,20 @@ The CI runs on every push to `main` and every pull request. It uses smart scopin
## Job Overview
| Job | Purpose | When it runs |
| ----------------- | ----------------------------------------------- | ------------------------- |
| `docs-scope` | Detect docs-only changes | Always |
| `changed-scope` | Detect which areas changed (node/macos/android) | Non-docs PRs |
| `check` | TypeScript types, lint, format | Non-docs changes |
| `check-docs` | Markdown lint + broken link check | Docs changed |
| `code-analysis` | LOC threshold check (1000 lines) | PRs only |
| `secrets` | Detect leaked secrets | Always |
| `build-artifacts` | Build dist once, share with other jobs | Non-docs, node changes |
| `release-check` | Validate npm pack contents | After build |
| `checks` | Node/Bun tests + protocol check | Non-docs, node changes |
| `checks-windows` | Windows-specific tests | Non-docs, node changes |
| `macos` | Swift lint/build/test + TS tests | PRs with macos changes |
| `android` | Gradle build + tests | Non-docs, android changes |
| Job | Purpose | When it runs |
| ----------------- | ----------------------------------------------- | ------------------------------------------------- |
| `docs-scope` | Detect docs-only changes | Always |
| `changed-scope` | Detect which areas changed (node/macos/android) | Non-docs PRs |
| `check` | TypeScript types, lint, format | Push to `main`, or PRs with Node-relevant changes |
| `check-docs` | Markdown lint + broken link check | Docs changed |
| `code-analysis` | LOC threshold check (1000 lines) | PRs only |
| `secrets` | Detect leaked secrets | Always |
| `build-artifacts` | Build dist once, share with other jobs | Non-docs, node changes |
| `release-check` | Validate npm pack contents | After build |
| `checks` | Node/Bun tests + protocol check | Non-docs, node changes |
| `checks-windows` | Windows-specific tests | Non-docs, node changes |
| `macos` | Swift lint/build/test + TS tests | PRs with macos changes |
| `android` | Gradle build + tests | Non-docs, android changes |
## Fail-Fast Order

View File

@@ -1,8 +1,8 @@
import { EventEmitter } from "node:events";
import type { IncomingMessage, ServerResponse } from "node:http";
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
import { removeAckReactionAfterReply, shouldAckReaction } from "openclaw/plugin-sdk";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js";
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
import { fetchBlueBubblesHistory } from "./history.js";
import {
@@ -94,47 +94,15 @@ const mockResolveChunkMode = vi.fn(() => "length");
const mockFetchBlueBubblesHistory = vi.mocked(fetchBlueBubblesHistory);
function createMockRuntime(): PluginRuntime {
return {
version: "1.0.0",
config: {
loadConfig: vi.fn(() => ({})) as unknown as PluginRuntime["config"]["loadConfig"],
writeConfigFile: vi.fn() as unknown as PluginRuntime["config"]["writeConfigFile"],
},
return createPluginRuntimeMock({
system: {
enqueueSystemEvent:
mockEnqueueSystemEvent as unknown as PluginRuntime["system"]["enqueueSystemEvent"],
requestHeartbeatNow: vi.fn() as unknown as PluginRuntime["system"]["requestHeartbeatNow"],
runCommandWithTimeout: vi.fn() as unknown as PluginRuntime["system"]["runCommandWithTimeout"],
formatNativeDependencyHint: vi.fn(
() => "",
) as unknown as PluginRuntime["system"]["formatNativeDependencyHint"],
},
media: {
loadWebMedia: vi.fn() as unknown as PluginRuntime["media"]["loadWebMedia"],
detectMime: vi.fn() as unknown as PluginRuntime["media"]["detectMime"],
mediaKindFromMime: vi.fn() as unknown as PluginRuntime["media"]["mediaKindFromMime"],
isVoiceCompatibleAudio:
vi.fn() as unknown as PluginRuntime["media"]["isVoiceCompatibleAudio"],
getImageMetadata: vi.fn() as unknown as PluginRuntime["media"]["getImageMetadata"],
resizeToJpeg: vi.fn() as unknown as PluginRuntime["media"]["resizeToJpeg"],
},
tts: {
textToSpeechTelephony: vi.fn() as unknown as PluginRuntime["tts"]["textToSpeechTelephony"],
},
stt: {
transcribeAudioFile: vi.fn() as unknown as PluginRuntime["stt"]["transcribeAudioFile"],
},
tools: {
createMemoryGetTool: vi.fn() as unknown as PluginRuntime["tools"]["createMemoryGetTool"],
createMemorySearchTool:
vi.fn() as unknown as PluginRuntime["tools"]["createMemorySearchTool"],
registerMemoryCli: vi.fn() as unknown as PluginRuntime["tools"]["registerMemoryCli"],
},
channel: {
text: {
chunkMarkdownText:
mockChunkMarkdownText as unknown as PluginRuntime["channel"]["text"]["chunkMarkdownText"],
chunkText: vi.fn() as unknown as PluginRuntime["channel"]["text"]["chunkText"],
chunkByNewline:
mockChunkByNewline as unknown as PluginRuntime["channel"]["text"]["chunkByNewline"],
chunkMarkdownTextWithMode:
@@ -143,50 +111,12 @@ function createMockRuntime(): PluginRuntime {
mockChunkTextWithMode as unknown as PluginRuntime["channel"]["text"]["chunkTextWithMode"],
resolveChunkMode:
mockResolveChunkMode as unknown as PluginRuntime["channel"]["text"]["resolveChunkMode"],
resolveTextChunkLimit: vi.fn(
() => 4000,
) as unknown as PluginRuntime["channel"]["text"]["resolveTextChunkLimit"],
hasControlCommand:
mockHasControlCommand as unknown as PluginRuntime["channel"]["text"]["hasControlCommand"],
resolveMarkdownTableMode: vi.fn(
() => "code",
) as unknown as PluginRuntime["channel"]["text"]["resolveMarkdownTableMode"],
convertMarkdownTables: vi.fn(
(text: string) => text,
) as unknown as PluginRuntime["channel"]["text"]["convertMarkdownTables"],
},
reply: {
dispatchReplyWithBufferedBlockDispatcher:
mockDispatchReplyWithBufferedBlockDispatcher as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"],
createReplyDispatcherWithTyping:
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["createReplyDispatcherWithTyping"],
resolveEffectiveMessagesConfig:
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["resolveEffectiveMessagesConfig"],
resolveHumanDelayConfig:
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["resolveHumanDelayConfig"],
dispatchReplyFromConfig:
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyFromConfig"],
withReplyDispatcher: vi.fn(
async ({
dispatcher,
run,
onSettled,
}: Parameters<PluginRuntime["channel"]["reply"]["withReplyDispatcher"]>[0]) => {
try {
return await run();
} finally {
dispatcher.markComplete();
try {
await dispatcher.waitForIdle();
} finally {
await onSettled?.();
}
}
},
) as unknown as PluginRuntime["channel"]["reply"]["withReplyDispatcher"],
finalizeInboundContext: vi.fn(
(ctx: Record<string, unknown>) => ctx,
) as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
formatAgentEnvelope:
mockFormatAgentEnvelope as unknown as PluginRuntime["channel"]["reply"]["formatAgentEnvelope"],
formatInboundEnvelope:
@@ -207,8 +137,6 @@ function createMockRuntime(): PluginRuntime {
mockUpsertPairingRequest as unknown as PluginRuntime["channel"]["pairing"]["upsertPairingRequest"],
},
media: {
fetchRemoteMedia:
vi.fn() as unknown as PluginRuntime["channel"]["media"]["fetchRemoteMedia"],
saveMediaBuffer:
mockSaveMediaBuffer as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"],
},
@@ -217,12 +145,6 @@ function createMockRuntime(): PluginRuntime {
mockResolveStorePath as unknown as PluginRuntime["channel"]["session"]["resolveStorePath"],
readSessionUpdatedAt:
mockReadSessionUpdatedAt as unknown as PluginRuntime["channel"]["session"]["readSessionUpdatedAt"],
recordInboundSession:
vi.fn() as unknown as PluginRuntime["channel"]["session"]["recordInboundSession"],
recordSessionMetaFromInbound:
vi.fn() as unknown as PluginRuntime["channel"]["session"]["recordSessionMetaFromInbound"],
updateLastRoute:
vi.fn() as unknown as PluginRuntime["channel"]["session"]["updateLastRoute"],
},
mentions: {
buildMentionRegexes:
@@ -232,72 +154,18 @@ function createMockRuntime(): PluginRuntime {
matchesMentionWithExplicit:
mockMatchesMentionWithExplicit as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionWithExplicit"],
},
reactions: {
shouldAckReaction,
removeAckReactionAfterReply,
},
groups: {
resolveGroupPolicy:
mockResolveGroupPolicy as unknown as PluginRuntime["channel"]["groups"]["resolveGroupPolicy"],
resolveRequireMention:
mockResolveRequireMention as unknown as PluginRuntime["channel"]["groups"]["resolveRequireMention"],
},
debounce: {
// Create a pass-through debouncer that immediately calls onFlush
createInboundDebouncer: vi.fn(
(params: { onFlush: (items: unknown[]) => Promise<void> }) => ({
enqueue: async (item: unknown) => {
await params.onFlush([item]);
},
flushKey: vi.fn(),
}),
) as unknown as PluginRuntime["channel"]["debounce"]["createInboundDebouncer"],
resolveInboundDebounceMs: vi.fn(
() => 0,
) as unknown as PluginRuntime["channel"]["debounce"]["resolveInboundDebounceMs"],
},
commands: {
resolveCommandAuthorizedFromAuthorizers:
mockResolveCommandAuthorizedFromAuthorizers as unknown as PluginRuntime["channel"]["commands"]["resolveCommandAuthorizedFromAuthorizers"],
isControlCommandMessage:
vi.fn() as unknown as PluginRuntime["channel"]["commands"]["isControlCommandMessage"],
shouldComputeCommandAuthorized:
vi.fn() as unknown as PluginRuntime["channel"]["commands"]["shouldComputeCommandAuthorized"],
shouldHandleTextCommands:
vi.fn() as unknown as PluginRuntime["channel"]["commands"]["shouldHandleTextCommands"],
},
discord: {} as PluginRuntime["channel"]["discord"],
activity: {} as PluginRuntime["channel"]["activity"],
line: {} as PluginRuntime["channel"]["line"],
slack: {} as PluginRuntime["channel"]["slack"],
telegram: {} as PluginRuntime["channel"]["telegram"],
signal: {} as PluginRuntime["channel"]["signal"],
imessage: {} as PluginRuntime["channel"]["imessage"],
whatsapp: {} as PluginRuntime["channel"]["whatsapp"],
},
events: {
onAgentEvent: vi.fn(() => () => {}) as unknown as PluginRuntime["events"]["onAgentEvent"],
onSessionTranscriptUpdate: vi.fn(
() => () => {},
) as unknown as PluginRuntime["events"]["onSessionTranscriptUpdate"],
},
logging: {
shouldLogVerbose: vi.fn(
() => false,
) as unknown as PluginRuntime["logging"]["shouldLogVerbose"],
getChildLogger: vi.fn(() => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
})) as unknown as PluginRuntime["logging"]["getChildLogger"],
},
state: {
resolveStateDir: vi.fn(
() => "/tmp/openclaw",
) as unknown as PluginRuntime["state"]["resolveStateDir"],
},
};
});
}
function createMockAccount(

View File

@@ -0,0 +1,248 @@
import type { PluginRuntime } from "openclaw/plugin-sdk";
import { removeAckReactionAfterReply, shouldAckReaction } from "openclaw/plugin-sdk";
import { vi } from "vitest";
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends (...args: never[]) => unknown
? T[K]
: T[K] extends ReadonlyArray<unknown>
? T[K]
: T[K] extends object
? DeepPartial<T[K]>
: T[K];
};
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function mergeDeep<T>(base: T, overrides: DeepPartial<T>): T {
const result: Record<string, unknown> = { ...(base as Record<string, unknown>) };
for (const [key, overrideValue] of Object.entries(overrides as Record<string, unknown>)) {
if (overrideValue === undefined) {
continue;
}
const baseValue = result[key];
if (isObject(baseValue) && isObject(overrideValue)) {
result[key] = mergeDeep(baseValue, overrideValue);
continue;
}
result[key] = overrideValue;
}
return result as T;
}
export function createPluginRuntimeMock(overrides: DeepPartial<PluginRuntime> = {}): PluginRuntime {
const base: PluginRuntime = {
version: "1.0.0-test",
config: {
loadConfig: vi.fn(() => ({})) as unknown as PluginRuntime["config"]["loadConfig"],
writeConfigFile: vi.fn() as unknown as PluginRuntime["config"]["writeConfigFile"],
},
system: {
enqueueSystemEvent: vi.fn() as unknown as PluginRuntime["system"]["enqueueSystemEvent"],
requestHeartbeatNow: vi.fn() as unknown as PluginRuntime["system"]["requestHeartbeatNow"],
runCommandWithTimeout: vi.fn() as unknown as PluginRuntime["system"]["runCommandWithTimeout"],
formatNativeDependencyHint: vi.fn(
() => "",
) as unknown as PluginRuntime["system"]["formatNativeDependencyHint"],
},
media: {
loadWebMedia: vi.fn() as unknown as PluginRuntime["media"]["loadWebMedia"],
detectMime: vi.fn() as unknown as PluginRuntime["media"]["detectMime"],
mediaKindFromMime: vi.fn() as unknown as PluginRuntime["media"]["mediaKindFromMime"],
isVoiceCompatibleAudio:
vi.fn() as unknown as PluginRuntime["media"]["isVoiceCompatibleAudio"],
getImageMetadata: vi.fn() as unknown as PluginRuntime["media"]["getImageMetadata"],
resizeToJpeg: vi.fn() as unknown as PluginRuntime["media"]["resizeToJpeg"],
},
tts: {
textToSpeechTelephony: vi.fn() as unknown as PluginRuntime["tts"]["textToSpeechTelephony"],
},
stt: {
transcribeAudioFile: vi.fn() as unknown as PluginRuntime["stt"]["transcribeAudioFile"],
},
tools: {
createMemoryGetTool: vi.fn() as unknown as PluginRuntime["tools"]["createMemoryGetTool"],
createMemorySearchTool:
vi.fn() as unknown as PluginRuntime["tools"]["createMemorySearchTool"],
registerMemoryCli: vi.fn() as unknown as PluginRuntime["tools"]["registerMemoryCli"],
},
channel: {
text: {
chunkByNewline: vi.fn((text: string) => (text ? [text] : [])),
chunkMarkdownText: vi.fn((text: string) => [text]),
chunkMarkdownTextWithMode: vi.fn((text: string) => (text ? [text] : [])),
chunkText: vi.fn((text: string) => (text ? [text] : [])),
chunkTextWithMode: vi.fn((text: string) => (text ? [text] : [])),
resolveChunkMode: vi.fn(
() => "length",
) as unknown as PluginRuntime["channel"]["text"]["resolveChunkMode"],
resolveTextChunkLimit: vi.fn(() => 4000),
hasControlCommand: vi.fn(() => false),
resolveMarkdownTableMode: vi.fn(
() => "code",
) as unknown as PluginRuntime["channel"]["text"]["resolveMarkdownTableMode"],
convertMarkdownTables: vi.fn((text: string) => text),
},
reply: {
dispatchReplyWithBufferedBlockDispatcher: vi.fn(
async () => undefined,
) as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"],
createReplyDispatcherWithTyping:
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["createReplyDispatcherWithTyping"],
resolveEffectiveMessagesConfig:
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["resolveEffectiveMessagesConfig"],
resolveHumanDelayConfig:
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["resolveHumanDelayConfig"],
dispatchReplyFromConfig:
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyFromConfig"],
withReplyDispatcher: vi.fn(async ({ dispatcher, run, onSettled }) => {
try {
return await run();
} finally {
dispatcher.markComplete();
try {
await dispatcher.waitForIdle();
} finally {
await onSettled?.();
}
}
}) as unknown as PluginRuntime["channel"]["reply"]["withReplyDispatcher"],
finalizeInboundContext: vi.fn(
(ctx: Record<string, unknown>) => ctx,
) as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
formatAgentEnvelope: vi.fn(
(opts: { body: string }) => opts.body,
) as unknown as PluginRuntime["channel"]["reply"]["formatAgentEnvelope"],
formatInboundEnvelope: vi.fn(
(opts: { body: string }) => opts.body,
) as unknown as PluginRuntime["channel"]["reply"]["formatInboundEnvelope"],
resolveEnvelopeFormatOptions: vi.fn(() => ({
template: "channel+name+time",
})) as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"],
},
routing: {
resolveAgentRoute: vi.fn(() => ({
agentId: "main",
accountId: "default",
sessionKey: "agent:main:test:dm:peer",
})) as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
},
pairing: {
buildPairingReply: vi.fn(
() => "Pairing code: TESTCODE",
) as unknown as PluginRuntime["channel"]["pairing"]["buildPairingReply"],
readAllowFromStore: vi
.fn()
.mockResolvedValue(
[],
) as unknown as PluginRuntime["channel"]["pairing"]["readAllowFromStore"],
upsertPairingRequest: vi.fn().mockResolvedValue({
code: "TESTCODE",
created: true,
}) as unknown as PluginRuntime["channel"]["pairing"]["upsertPairingRequest"],
},
media: {
fetchRemoteMedia:
vi.fn() as unknown as PluginRuntime["channel"]["media"]["fetchRemoteMedia"],
saveMediaBuffer: vi.fn().mockResolvedValue({
path: "/tmp/test-media.jpg",
contentType: "image/jpeg",
}) as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"],
},
session: {
resolveStorePath: vi.fn(
() => "/tmp/sessions.json",
) as unknown as PluginRuntime["channel"]["session"]["resolveStorePath"],
readSessionUpdatedAt: vi.fn(
() => undefined,
) as unknown as PluginRuntime["channel"]["session"]["readSessionUpdatedAt"],
recordSessionMetaFromInbound:
vi.fn() as unknown as PluginRuntime["channel"]["session"]["recordSessionMetaFromInbound"],
recordInboundSession:
vi.fn() as unknown as PluginRuntime["channel"]["session"]["recordInboundSession"],
updateLastRoute:
vi.fn() as unknown as PluginRuntime["channel"]["session"]["updateLastRoute"],
},
mentions: {
buildMentionRegexes: vi.fn(() => [
/\bbert\b/i,
]) as unknown as PluginRuntime["channel"]["mentions"]["buildMentionRegexes"],
matchesMentionPatterns: vi.fn((text: string, regexes: RegExp[]) =>
regexes.some((regex) => regex.test(text)),
) as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionPatterns"],
matchesMentionWithExplicit: vi.fn(
(params: { text: string; mentionRegexes: RegExp[]; explicitWasMentioned?: boolean }) =>
params.explicitWasMentioned === true
? true
: params.mentionRegexes.some((regex) => regex.test(params.text)),
) as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionWithExplicit"],
},
reactions: {
shouldAckReaction,
removeAckReactionAfterReply,
},
groups: {
resolveGroupPolicy: vi.fn(
() => "open",
) as unknown as PluginRuntime["channel"]["groups"]["resolveGroupPolicy"],
resolveRequireMention: vi.fn(
() => false,
) as unknown as PluginRuntime["channel"]["groups"]["resolveRequireMention"],
},
debounce: {
createInboundDebouncer: vi.fn(
(params: { onFlush: (items: unknown[]) => Promise<void> }) => ({
enqueue: async (item: unknown) => {
await params.onFlush([item]);
},
flushKey: vi.fn(),
}),
) as unknown as PluginRuntime["channel"]["debounce"]["createInboundDebouncer"],
resolveInboundDebounceMs: vi.fn(
() => 0,
) as unknown as PluginRuntime["channel"]["debounce"]["resolveInboundDebounceMs"],
},
commands: {
resolveCommandAuthorizedFromAuthorizers: vi.fn(
() => false,
) as unknown as PluginRuntime["channel"]["commands"]["resolveCommandAuthorizedFromAuthorizers"],
isControlCommandMessage:
vi.fn() as unknown as PluginRuntime["channel"]["commands"]["isControlCommandMessage"],
shouldComputeCommandAuthorized:
vi.fn() as unknown as PluginRuntime["channel"]["commands"]["shouldComputeCommandAuthorized"],
shouldHandleTextCommands:
vi.fn() as unknown as PluginRuntime["channel"]["commands"]["shouldHandleTextCommands"],
},
discord: {} as PluginRuntime["channel"]["discord"],
activity: {} as PluginRuntime["channel"]["activity"],
line: {} as PluginRuntime["channel"]["line"],
slack: {} as PluginRuntime["channel"]["slack"],
telegram: {} as PluginRuntime["channel"]["telegram"],
signal: {} as PluginRuntime["channel"]["signal"],
imessage: {} as PluginRuntime["channel"]["imessage"],
whatsapp: {} as PluginRuntime["channel"]["whatsapp"],
},
events: {
onAgentEvent: vi.fn(() => () => {}) as unknown as PluginRuntime["events"]["onAgentEvent"],
onSessionTranscriptUpdate: vi.fn(
() => () => {},
) as unknown as PluginRuntime["events"]["onSessionTranscriptUpdate"],
},
logging: {
shouldLogVerbose: vi.fn(() => false),
getChildLogger: vi.fn(() => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
})),
},
state: {
resolveStateDir: vi.fn(() => "/tmp/openclaw"),
},
};
return mergeDeep(base, overrides);
}

View File

@@ -4,18 +4,27 @@ import module from "node:module";
const MIN_NODE_MAJOR = 22;
const MIN_NODE_MINOR = 12;
const MIN_NODE_VERSION = `${MIN_NODE_MAJOR}.${MIN_NODE_MINOR}`;
const parseNodeVersion = (rawVersion) => {
const [majorRaw = "0", minorRaw = "0"] = rawVersion.split(".");
return {
major: Number(majorRaw),
minor: Number(minorRaw),
};
};
const isSupportedNodeVersion = (version) =>
version.major > MIN_NODE_MAJOR ||
(version.major === MIN_NODE_MAJOR && version.minor >= MIN_NODE_MINOR);
const ensureSupportedNodeVersion = () => {
const [majorRaw = "0", minorRaw = "0"] = process.versions.node.split(".");
const major = Number(majorRaw);
const minor = Number(minorRaw);
const supported = major > MIN_NODE_MAJOR || (major === MIN_NODE_MAJOR && minor >= MIN_NODE_MINOR);
if (supported) {
if (isSupportedNodeVersion(parseNodeVersion(process.versions.node))) {
return;
}
process.stderr.write(
`openclaw: Node.js v${MIN_NODE_MAJOR}.${MIN_NODE_MINOR}+ is required (current: v${process.versions.node}).\n` +
`openclaw: Node.js v${MIN_NODE_VERSION}+ is required (current: v${process.versions.node}).\n` +
"If you use nvm, run:\n" +
" nvm install 22\n" +
" nvm use 22\n" +

View File

@@ -16,6 +16,9 @@ MUTED='\033[38;2;90;100;128m' # text-muted #5a6480
NC='\033[0m' # No Color
DEFAULT_TAGLINE="All your chats, one OpenClaw."
NODE_MIN_MAJOR=22
NODE_MIN_MINOR=12
NODE_MIN_VERSION="${NODE_MIN_MAJOR}.${NODE_MIN_MINOR}"
ORIGINAL_PATH="${PATH:-}"
@@ -1247,26 +1250,10 @@ install_homebrew() {
}
# Check Node.js version
node_major_version() {
parse_node_version_components() {
if ! command -v node &> /dev/null; then
return 1
fi
local version major
version="$(node -v 2>/dev/null || true)"
major="${version#v}"
major="${major%%.*}"
if [[ "$major" =~ ^[0-9]+$ ]]; then
echo "$major"
return 0
fi
return 1
}
node_is_at_least_22_12() {
if ! command -v node &> /dev/null; then
return 1
fi
local version major minor
version="$(node -v 2>/dev/null || true)"
major="${version#v}"
@@ -1281,11 +1268,32 @@ node_is_at_least_22_12() {
if [[ ! "$minor" =~ ^[0-9]+$ ]]; then
return 1
fi
echo "${major} ${minor}"
return 0
}
if [[ "$major" -gt 22 ]]; then
node_major_version() {
local version_components major minor
version_components="$(parse_node_version_components || true)"
read -r major minor <<< "$version_components"
if [[ "$major" =~ ^[0-9]+$ && "$minor" =~ ^[0-9]+$ ]]; then
echo "$major"
return 0
fi
if [[ "$major" -eq 22 && "$minor" -ge 12 ]]; then
return 1
}
node_is_at_least_required() {
local version_components major minor
version_components="$(parse_node_version_components || true)"
read -r major minor <<< "$version_components"
if [[ ! "$major" =~ ^[0-9]+$ || ! "$minor" =~ ^[0-9]+$ ]]; then
return 1
fi
if [[ "$major" -gt "$NODE_MIN_MAJOR" ]]; then
return 0
fi
if [[ "$major" -eq "$NODE_MIN_MAJOR" && "$minor" -ge "$NODE_MIN_MINOR" ]]; then
return 0
fi
return 1
@@ -1343,7 +1351,7 @@ ensure_macos_node22_active() {
}
ensure_node22_active_shell() {
if node_is_at_least_22_12; then
if node_is_at_least_required; then
return 0
fi
@@ -1351,7 +1359,7 @@ ensure_node22_active_shell() {
active_path="$(command -v node 2>/dev/null || echo "not found")"
active_version="$(node -v 2>/dev/null || echo "missing")"
ui_error "Active Node.js must be v22.12+ but this shell is using ${active_version} (${active_path})"
ui_error "Active Node.js must be v${NODE_MIN_VERSION}+ but this shell is using ${active_version} (${active_path})"
print_active_node_paths || true
local nvm_detected=0
@@ -1380,15 +1388,15 @@ ensure_node22_active_shell() {
check_node() {
if command -v node &> /dev/null; then
NODE_VERSION="$(node_major_version || true)"
if node_is_at_least_22_12; then
if node_is_at_least_required; then
ui_success "Node.js v$(node -v | cut -d'v' -f2) found"
print_active_node_paths || true
return 0
else
if [[ -n "$NODE_VERSION" ]]; then
ui_info "Node.js $(node -v) found, upgrading to v22.12+"
ui_info "Node.js $(node -v) found, upgrading to v${NODE_MIN_VERSION}+"
else
ui_info "Node.js found but version could not be parsed; reinstalling v22.12+"
ui_info "Node.js found but version could not be parsed; reinstalling v${NODE_MIN_VERSION}+"
fi
return 1
fi

View File

@@ -5,6 +5,7 @@ import {
sanitizeGoogleTurnOrdering,
sanitizeSessionMessagesImages,
} from "./pi-embedded-helpers.js";
import { castAgentMessages } from "./test-helpers/agent-message-fixtures.js";
let testTimestamp = 1;
const nextTimestamp = () => testTimestamp++;
@@ -93,7 +94,7 @@ describe("sanitizeSessionMessagesImages", () => {
});
it("does not synthesize tool call input when missing", async () => {
const input = [
const input = castAgentMessages([
{
role: "assistant",
content: [{ type: "toolCall", id: "call_1", name: "read" }],
@@ -111,7 +112,7 @@ describe("sanitizeSessionMessagesImages", () => {
stopReason: "toolUse",
timestamp: nextTimestamp(),
},
] as unknown as AgentMessage[];
]);
const out = await sanitizeSessionMessagesImages(input, "test");
const assistant = out[0] as { content?: Array<Record<string, unknown>> };
@@ -122,7 +123,7 @@ describe("sanitizeSessionMessagesImages", () => {
});
it("removes empty assistant text blocks but preserves tool calls", async () => {
const input = [
const input = castAgentMessages([
{
role: "assistant",
content: [
@@ -143,7 +144,7 @@ describe("sanitizeSessionMessagesImages", () => {
stopReason: "toolUse",
timestamp: nextTimestamp(),
},
] as AgentMessage[];
]);
const out = await sanitizeSessionMessagesImages(input, "test");
@@ -153,7 +154,7 @@ describe("sanitizeSessionMessagesImages", () => {
});
it("sanitizes tool ids in strict mode (alphanumeric only)", async () => {
const input = [
const input = castAgentMessages([
{
role: "assistant",
content: [
@@ -171,7 +172,7 @@ describe("sanitizeSessionMessagesImages", () => {
toolUseId: "call_abc|item:123",
content: [{ type: "text", text: "ok" }],
},
] as unknown as AgentMessage[];
]);
const out = await sanitizeSessionMessagesImages(input, "test", {
sanitizeToolCallIds: true,
@@ -188,7 +189,7 @@ describe("sanitizeSessionMessagesImages", () => {
});
it("sanitizes tool IDs in images-only mode when explicitly enabled", async () => {
const input = [
const input = castAgentMessages([
{
role: "assistant",
content: [{ type: "toolCall", id: "call_123|fc_456", name: "read", arguments: {} }],
@@ -214,7 +215,7 @@ describe("sanitizeSessionMessagesImages", () => {
isError: false,
timestamp: nextTimestamp(),
},
] as AgentMessage[];
]);
const out = await sanitizeSessionMessagesImages(input, "test", {
sanitizeMode: "images-only",
@@ -236,7 +237,7 @@ describe("sanitizeSessionMessagesImages", () => {
}
});
it("filters whitespace-only assistant text blocks", async () => {
const input = [
const input = castAgentMessages([
{
role: "assistant",
content: [
@@ -257,7 +258,7 @@ describe("sanitizeSessionMessagesImages", () => {
stopReason: "stop",
timestamp: nextTimestamp(),
},
] as AgentMessage[];
]);
const out = await sanitizeSessionMessagesImages(input, "test");
@@ -266,7 +267,7 @@ describe("sanitizeSessionMessagesImages", () => {
});
});
it("drops assistant messages that only contain empty text", async () => {
const input = [
const input = castAgentMessages([
{ role: "user", content: "hello", timestamp: nextTimestamp() } satisfies UserMessage,
{
role: "assistant",
@@ -285,7 +286,7 @@ describe("sanitizeSessionMessagesImages", () => {
stopReason: "stop",
timestamp: nextTimestamp(),
} satisfies AssistantMessage,
];
]);
const out = await sanitizeSessionMessagesImages(input, "test");
@@ -293,7 +294,7 @@ describe("sanitizeSessionMessagesImages", () => {
expect(out[0]?.role).toBe("user");
});
it("keeps empty assistant error messages", async () => {
const input = [
const input = castAgentMessages([
{ role: "user", content: "hello", timestamp: nextTimestamp() } satisfies UserMessage,
{
role: "assistant",
@@ -329,7 +330,7 @@ describe("sanitizeSessionMessagesImages", () => {
},
timestamp: nextTimestamp(),
} satisfies AssistantMessage,
] as unknown as AgentMessage[];
]);
const out = await sanitizeSessionMessagesImages(input, "test");
@@ -360,7 +361,7 @@ describe("sanitizeSessionMessagesImages", () => {
describe("thought_signature stripping", () => {
it("strips msg_-prefixed thought_signature from assistant message content blocks", async () => {
const input = [
const input = castAgentMessages([
{
role: "assistant",
content: [
@@ -372,7 +373,7 @@ describe("sanitizeSessionMessagesImages", () => {
},
],
},
] as unknown as AgentMessage[];
]);
const out = await sanitizeSessionMessagesImages(input, "test");
@@ -387,19 +388,19 @@ describe("sanitizeSessionMessagesImages", () => {
describe("sanitizeGoogleTurnOrdering", () => {
it("prepends a synthetic user turn when history starts with assistant", () => {
const input = [
const input = castAgentMessages([
{
role: "assistant",
content: [{ type: "toolCall", id: "call_1", name: "exec", arguments: {} }],
},
] as unknown as AgentMessage[];
]);
const out = sanitizeGoogleTurnOrdering(input);
expect(out[0]?.role).toBe("user");
expect(out[1]?.role).toBe("assistant");
});
it("is a no-op when history starts with user", () => {
const input = [{ role: "user", content: "hi" }] as unknown as AgentMessage[];
const input = castAgentMessages([{ role: "user", content: "hi" }]);
const out = sanitizeGoogleTurnOrdering(input);
expect(out).toBe(input);
});

View File

@@ -2,13 +2,14 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { SessionManager } from "@mariozechner/pi-coding-agent";
import { describe, expect, it, vi } from "vitest";
import { applyGoogleTurnOrderingFix } from "./pi-embedded-runner.js";
import { castAgentMessage } from "./test-helpers/agent-message-fixtures.js";
describe("applyGoogleTurnOrderingFix", () => {
const makeAssistantFirst = (): AgentMessage[] => [
{
castAgentMessage({
role: "assistant",
content: [{ type: "toolCall", id: "call_1", name: "exec", arguments: {} }],
} as unknown as AgentMessage,
}),
];
it("prepends a bootstrap once and records a marker for Google models", () => {

View File

@@ -5,6 +5,7 @@ import {
makeModelSnapshotEntry,
} from "./pi-embedded-runner.sanitize-session-history.test-harness.js";
import { sanitizeSessionHistory } from "./pi-embedded-runner/google.js";
import { castAgentMessage } from "./test-helpers/agent-message-fixtures.js";
describe("sanitizeSessionHistory openai tool id preservation", () => {
const makeSessionManager = () =>
@@ -17,7 +18,7 @@ describe("sanitizeSessionHistory openai tool id preservation", () => {
]);
const makeMessages = (withReasoning: boolean): AgentMessage[] => [
{
castAgentMessage({
role: "assistant",
content: [
...(withReasoning
@@ -31,14 +32,14 @@ describe("sanitizeSessionHistory openai tool id preservation", () => {
: []),
{ type: "toolCall", id: "call_123|fc_123", name: "noop", arguments: {} },
],
} as unknown as AgentMessage,
{
}),
castAgentMessage({
role: "toolResult",
toolCallId: "call_123|fc_123",
toolName: "noop",
content: [{ type: "text", text: "ok" }],
isError: false,
} as unknown as AgentMessage,
}),
];
it.each([

View File

@@ -15,6 +15,7 @@ import {
sanitizeWithOpenAIResponses,
TEST_SESSION_ID,
} from "./pi-embedded-runner.sanitize-session-history.test-harness.js";
import { castAgentMessage, castAgentMessages } from "./test-helpers/agent-message-fixtures.js";
import { makeZeroUsageSnapshot } from "./usage.js";
vi.mock("./pi-embedded-helpers.js", async () => ({
@@ -136,12 +137,12 @@ describe("sanitizeSessionHistory", () => {
});
const makeCompactionSummaryMessage = (tokensBefore: number, timestamp: string) =>
({
castAgentMessage({
role: "compactionSummary",
summary: "compressed",
tokensBefore,
timestamp,
}) as unknown as AgentMessage;
});
const sanitizeOpenAIHistory = async (
messages: AgentMessage[],
@@ -258,7 +259,7 @@ describe("sanitizeSessionHistory", () => {
setNonGoogleModelApi();
const messages: AgentMessage[] = [
{
castAgentMessage({
role: "user",
content: "forwarded instruction",
provenance: {
@@ -266,7 +267,7 @@ describe("sanitizeSessionHistory", () => {
sourceSessionKey: "agent:main:req",
sourceTool: "sessions_send",
},
} as unknown as AgentMessage,
}),
];
const result = await sanitizeSessionHistory({
@@ -287,14 +288,14 @@ describe("sanitizeSessionHistory", () => {
it("drops stale assistant usage snapshots kept before latest compaction summary", async () => {
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
const messages = [
const messages = castAgentMessages([
{ role: "user", content: "old context" },
makeAssistantUsageMessage({
text: "old answer",
usage: makeUsage(191_919, 2_000, 193_919),
}),
makeCompactionSummaryMessage(191_919, new Date().toISOString()),
] as unknown as AgentMessage[];
]);
const result = await sanitizeOpenAIHistory(messages);
@@ -308,7 +309,7 @@ describe("sanitizeSessionHistory", () => {
it("preserves fresh assistant usage snapshots created after latest compaction summary", async () => {
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
const messages = [
const messages = castAgentMessages([
makeAssistantUsageMessage({
text: "pre-compaction answer",
usage: makeUsage(120_000, 3_000, 123_000),
@@ -319,7 +320,7 @@ describe("sanitizeSessionHistory", () => {
text: "fresh answer",
usage: makeUsage(1_000, 250, 1_250),
}),
] as unknown as AgentMessage[];
]);
const result = await sanitizeOpenAIHistory(messages);
@@ -333,14 +334,14 @@ describe("sanitizeSessionHistory", () => {
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
const compactionTs = Date.parse("2026-02-26T12:00:00.000Z");
const messages = [
const messages = castAgentMessages([
makeCompactionSummaryMessage(191_919, new Date(compactionTs).toISOString()),
makeAssistantUsageMessage({
text: "kept pre-compaction answer",
timestamp: compactionTs - 1_000,
usage: makeUsage(191_919, 2_000, 193_919),
}),
] as unknown as AgentMessage[];
]);
const result = await sanitizeOpenAIHistory(messages);
@@ -354,7 +355,7 @@ describe("sanitizeSessionHistory", () => {
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
const compactionTs = Date.parse("2026-02-26T12:00:00.000Z");
const messages = [
const messages = castAgentMessages([
makeCompactionSummaryMessage(123_000, new Date(compactionTs).toISOString()),
makeAssistantUsageMessage({
text: "kept pre-compaction answer",
@@ -367,7 +368,7 @@ describe("sanitizeSessionHistory", () => {
timestamp: compactionTs + 2_000,
usage: makeUsage(1_000, 250, 1_250),
}),
] as unknown as AgentMessage[];
]);
const result = await sanitizeOpenAIHistory(messages);
@@ -431,13 +432,13 @@ describe("sanitizeSessionHistory", () => {
{
name: "missing input or arguments",
makeMessages: () =>
[
{
castAgentMessages([
castAgentMessage({
role: "assistant",
content: [{ type: "toolCall", id: "call_1", name: "read" }],
} as unknown as AgentMessage,
}),
makeUserMessage("hello"),
] as AgentMessage[],
]),
overrides: { sessionId: "test-session" } as Partial<
Parameters<typeof sanitizeOpenAIHistory>[1]
>,
@@ -445,7 +446,7 @@ describe("sanitizeSessionHistory", () => {
{
name: "invalid or overlong names",
makeMessages: () =>
[
castAgentMessages([
makeAssistantMessage(
[
{
@@ -464,7 +465,7 @@ describe("sanitizeSessionHistory", () => {
{ stopReason: "toolUse" },
),
makeUserMessage("hello"),
] as AgentMessage[],
]),
overrides: {} as Partial<Parameters<typeof sanitizeOpenAIHistory>[1]>,
},
])("drops malformed tool calls: $name", async ({ makeMessages, overrides }) => {

View File

@@ -1,5 +1,5 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { describe, expect, it } from "vitest";
import { castAgentMessage } from "../../test-helpers/agent-message-fixtures.js";
import {
selectCompactionTimeoutSnapshot,
shouldFlagCompactionTimeout,
@@ -32,8 +32,8 @@ describe("compaction-timeout helpers", () => {
});
it("uses pre-compaction snapshot when compaction timeout occurs", () => {
const pre = [{ role: "assistant", content: "pre" } as unknown as AgentMessage] as const;
const current = [{ role: "assistant", content: "current" } as unknown as AgentMessage] as const;
const pre = [castAgentMessage({ role: "assistant", content: "pre" })] as const;
const current = [castAgentMessage({ role: "assistant", content: "current" })] as const;
const selected = selectCompactionTimeoutSnapshot({
timedOutDuringCompaction: true,
preCompactionSnapshot: [...pre],
@@ -47,7 +47,7 @@ describe("compaction-timeout helpers", () => {
});
it("falls back to current snapshot when pre-compaction snapshot is unavailable", () => {
const current = [{ role: "assistant", content: "current" } as unknown as AgentMessage] as const;
const current = [castAgentMessage({ role: "assistant", content: "current" })] as const;
const selected = selectCompactionTimeoutSnapshot({
timedOutDuringCompaction: true,
preCompactionSnapshot: null,

View File

@@ -1,6 +1,7 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { ImageContent } from "@mariozechner/pi-ai";
import { describe, expect, it } from "vitest";
import { castAgentMessage } from "../../test-helpers/agent-message-fixtures.js";
import { PRUNED_HISTORY_IMAGE_MARKER, pruneProcessedHistoryImages } from "./history-image-prune.js";
describe("pruneProcessedHistoryImages", () => {
@@ -8,14 +9,14 @@ describe("pruneProcessedHistoryImages", () => {
it("prunes image blocks from user messages that already have assistant replies", () => {
const messages: AgentMessage[] = [
{
castAgentMessage({
role: "user",
content: [{ type: "text", text: "See /tmp/photo.png" }, { ...image }],
} as AgentMessage,
{
}),
castAgentMessage({
role: "assistant",
content: "got it",
} as unknown as AgentMessage,
}),
];
const didMutate = pruneProcessedHistoryImages(messages);
@@ -31,10 +32,10 @@ describe("pruneProcessedHistoryImages", () => {
it("does not prune latest user message when no assistant response exists yet", () => {
const messages: AgentMessage[] = [
{
castAgentMessage({
role: "user",
content: [{ type: "text", text: "See /tmp/photo.png" }, { ...image }],
} as AgentMessage,
}),
];
const didMutate = pruneProcessedHistoryImages(messages);
@@ -50,10 +51,10 @@ describe("pruneProcessedHistoryImages", () => {
it("does not change messages when no assistant turn exists", () => {
const messages: AgentMessage[] = [
{
castAgentMessage({
role: "user",
content: "noop",
} as AgentMessage,
}),
];
const didMutate = pruneProcessedHistoryImages(messages);

View File

@@ -1,15 +1,16 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { describe, expect, it } from "vitest";
import { castAgentMessage } from "../test-helpers/agent-message-fixtures.js";
import { dropThinkingBlocks, isAssistantMessageWithContent } from "./thinking.js";
describe("isAssistantMessageWithContent", () => {
it("accepts assistant messages with array content and rejects others", () => {
const assistant = {
const assistant = castAgentMessage({
role: "assistant",
content: [{ type: "text", text: "ok" }],
} as AgentMessage;
const user = { role: "user", content: "hi" } as AgentMessage;
const malformed = { role: "assistant", content: "not-array" } as unknown as AgentMessage;
});
const user = castAgentMessage({ role: "user", content: "hi" });
const malformed = castAgentMessage({ role: "assistant", content: "not-array" });
expect(isAssistantMessageWithContent(assistant)).toBe(true);
expect(isAssistantMessageWithContent(user)).toBe(false);
@@ -20,8 +21,8 @@ describe("isAssistantMessageWithContent", () => {
describe("dropThinkingBlocks", () => {
it("returns the original reference when no thinking blocks are present", () => {
const messages: AgentMessage[] = [
{ role: "user", content: "hello" } as AgentMessage,
{ role: "assistant", content: [{ type: "text", text: "world" }] } as AgentMessage,
castAgentMessage({ role: "user", content: "hello" }),
castAgentMessage({ role: "assistant", content: [{ type: "text", text: "world" }] }),
];
const result = dropThinkingBlocks(messages);
@@ -30,13 +31,13 @@ describe("dropThinkingBlocks", () => {
it("drops thinking blocks while preserving non-thinking assistant content", () => {
const messages: AgentMessage[] = [
{
castAgentMessage({
role: "assistant",
content: [
{ type: "thinking", thinking: "internal" },
{ type: "text", text: "final" },
],
} as unknown as AgentMessage,
}),
];
const result = dropThinkingBlocks(messages);
@@ -47,10 +48,10 @@ describe("dropThinkingBlocks", () => {
it("keeps assistant turn structure when all content blocks were thinking", () => {
const messages: AgentMessage[] = [
{
castAgentMessage({
role: "assistant",
content: [{ type: "thinking", thinking: "internal-only" }],
} as unknown as AgentMessage,
}),
];
const result = dropThinkingBlocks(messages);

View File

@@ -1,5 +1,6 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { describe, expect, it } from "vitest";
import { castAgentMessage } from "../test-helpers/agent-message-fixtures.js";
import {
CONTEXT_LIMIT_TRUNCATION_NOTICE,
PREEMPTIVE_TOOL_RESULT_COMPACTION_PLACEHOLDER,
@@ -7,35 +8,35 @@ import {
} from "./tool-result-context-guard.js";
function makeUser(text: string): AgentMessage {
return {
return castAgentMessage({
role: "user",
content: text,
timestamp: Date.now(),
} as unknown as AgentMessage;
});
}
function makeToolResult(id: string, text: string): AgentMessage {
return {
return castAgentMessage({
role: "toolResult",
toolCallId: id,
toolName: "read",
content: [{ type: "text", text }],
isError: false,
timestamp: Date.now(),
} as unknown as AgentMessage;
});
}
function makeLegacyToolResult(id: string, text: string): AgentMessage {
return {
return castAgentMessage({
role: "tool",
tool_call_id: id,
tool_name: "read",
content: text,
} as unknown as AgentMessage;
});
}
function makeToolResultWithDetails(id: string, text: string, detailText: string): AgentMessage {
return {
return castAgentMessage({
role: "toolResult",
toolCallId: id,
toolName: "read",
@@ -49,7 +50,7 @@ function makeToolResultWithDetails(id: string, text: string, detailText: string)
},
isError: false,
timestamp: Date.now(),
} as unknown as AgentMessage;
});
}
function getToolResultText(msg: AgentMessage): string {
@@ -199,11 +200,10 @@ describe("installToolResultContextGuard", () => {
it("wraps an existing transformContext and guards the transformed output", async () => {
const agent = makeGuardableAgent((messages) => {
return messages.map(
(msg) =>
({
...(msg as unknown as Record<string, unknown>),
}) as unknown as AgentMessage,
return messages.map((msg) =>
castAgentMessage({
...(msg as unknown as Record<string, unknown>),
}),
);
});
const contextForNextCall = makeTwoToolResultOverflowContext();
@@ -254,10 +254,10 @@ describe("installToolResultContextGuard", () => {
await agent.transformContext?.(contextForNextCall, new AbortController().signal);
const oldResult = contextForNextCall[1] as unknown as {
const oldResult = contextForNextCall[1] as {
details?: unknown;
};
const newResult = contextForNextCall[2] as unknown as {
const newResult = contextForNextCall[2] as {
details?: unknown;
};
const oldResultText = getToolResultText(contextForNextCall[1]);

View File

@@ -5,6 +5,7 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { Api, Model } from "@mariozechner/pi-ai";
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
import { describe, expect, it, vi } from "vitest";
import { castAgentMessage } from "../test-helpers/agent-message-fixtures.js";
import {
getCompactionSafeguardRuntime,
setCompactionSafeguardRuntime,
@@ -218,11 +219,11 @@ describe("computeAdaptiveChunkRatio", () => {
// Small messages: 1000 tokens each, well under 10% of context
const messages: AgentMessage[] = [
{ role: "user", content: "x".repeat(1000), timestamp: Date.now() },
{
castAgentMessage({
role: "assistant",
content: [{ type: "text", text: "y".repeat(1000) }],
timestamp: Date.now(),
} as unknown as AgentMessage,
}),
];
const ratio = computeAdaptiveChunkRatio(messages, CONTEXT_WINDOW);
@@ -233,11 +234,11 @@ describe("computeAdaptiveChunkRatio", () => {
// Large messages: ~50K tokens each (25% of context)
const messages: AgentMessage[] = [
{ role: "user", content: "x".repeat(50_000 * 4), timestamp: Date.now() },
{
castAgentMessage({
role: "assistant",
content: [{ type: "text", text: "y".repeat(50_000 * 4) }],
timestamp: Date.now(),
} as unknown as AgentMessage,
}),
];
const ratio = computeAdaptiveChunkRatio(messages, CONTEXT_WINDOW);

View File

@@ -0,0 +1,82 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { AnyAgentTool } from "./pi-tools.types.js";
/** Resolve path for host edit: expand ~ and resolve relative paths against root. */
function resolveHostEditPath(root: string, pathParam: string): string {
const expanded =
pathParam.startsWith("~/") || pathParam === "~"
? pathParam.replace(/^~/, os.homedir())
: pathParam;
return path.isAbsolute(expanded) ? path.resolve(expanded) : path.resolve(root, expanded);
}
/**
* When the upstream edit tool throws after having already written (e.g. generateDiffString fails),
* the file may be correctly updated but the tool reports failure. This wrapper catches errors and
* if the target file on disk contains the intended newText, returns success so we don't surface
* a false "edit failed" to the user (fixes #32333, same pattern as #30773 for write).
*/
export function wrapHostEditToolWithPostWriteRecovery(
base: AnyAgentTool,
root: string,
): AnyAgentTool {
return {
...base,
execute: async (
toolCallId: string,
params: unknown,
signal: AbortSignal | undefined,
onUpdate?: (update: unknown) => void,
) => {
try {
return await base.execute(toolCallId, params, signal, onUpdate);
} catch (err) {
const record =
params && typeof params === "object" ? (params as Record<string, unknown>) : undefined;
const pathParam = record && typeof record.path === "string" ? record.path : undefined;
const newText =
record && typeof record.newText === "string"
? record.newText
: record && typeof record.new_string === "string"
? record.new_string
: undefined;
const oldText =
record && typeof record.oldText === "string"
? record.oldText
: record && typeof record.old_string === "string"
? record.old_string
: undefined;
if (!pathParam || !newText) {
throw err;
}
try {
const absolutePath = resolveHostEditPath(root, pathParam);
const content = await fs.readFile(absolutePath, "utf-8");
// Only recover when the replacement likely occurred: newText is present and oldText
// is no longer present. This avoids false success when upstream threw before writing
// (e.g. oldText not found) but the file already contained newText (review feedback).
const hasNew = content.includes(newText);
const stillHasOld =
oldText !== undefined && oldText.length > 0 && content.includes(oldText);
if (hasNew && !stillHasOld) {
return {
content: [
{
type: "text",
text: `Successfully replaced text in ${pathParam}.`,
},
],
details: { diff: "", firstChangedLine: undefined },
} as AgentToolResult<unknown>;
}
} catch {
// File read failed or path invalid; rethrow original error.
}
throw err;
}
},
};
}

View File

@@ -0,0 +1,225 @@
import type { AnyAgentTool } from "./pi-tools.types.js";
export type RequiredParamGroup = {
keys: readonly string[];
allowEmpty?: boolean;
label?: string;
};
const RETRY_GUIDANCE_SUFFIX = " Supply correct parameters before retrying.";
function parameterValidationError(message: string): Error {
return new Error(`${message}.${RETRY_GUIDANCE_SUFFIX}`);
}
export const CLAUDE_PARAM_GROUPS = {
read: [{ keys: ["path", "file_path"], label: "path (path or file_path)" }],
write: [
{ keys: ["path", "file_path"], label: "path (path or file_path)" },
{ keys: ["content"], label: "content" },
],
edit: [
{ keys: ["path", "file_path"], label: "path (path or file_path)" },
{
keys: ["oldText", "old_string"],
label: "oldText (oldText or old_string)",
},
{
keys: ["newText", "new_string"],
label: "newText (newText or new_string)",
allowEmpty: true,
},
],
} as const;
function extractStructuredText(value: unknown, depth = 0): string | undefined {
if (depth > 6) {
return undefined;
}
if (typeof value === "string") {
return value;
}
if (Array.isArray(value)) {
const parts = value
.map((entry) => extractStructuredText(entry, depth + 1))
.filter((entry): entry is string => typeof entry === "string");
return parts.length > 0 ? parts.join("") : undefined;
}
if (!value || typeof value !== "object") {
return undefined;
}
const record = value as Record<string, unknown>;
if (typeof record.text === "string") {
return record.text;
}
if (typeof record.content === "string") {
return record.content;
}
if (Array.isArray(record.content)) {
return extractStructuredText(record.content, depth + 1);
}
if (Array.isArray(record.parts)) {
return extractStructuredText(record.parts, depth + 1);
}
if (typeof record.value === "string" && record.value.length > 0) {
const type = typeof record.type === "string" ? record.type.toLowerCase() : "";
const kind = typeof record.kind === "string" ? record.kind.toLowerCase() : "";
if (type.includes("text") || kind === "text") {
return record.value;
}
}
return undefined;
}
function normalizeTextLikeParam(record: Record<string, unknown>, key: string) {
const value = record[key];
if (typeof value === "string") {
return;
}
const extracted = extractStructuredText(value);
if (typeof extracted === "string") {
record[key] = extracted;
}
}
// Normalize tool parameters from Claude Code conventions to pi-coding-agent conventions.
// Claude Code uses file_path/old_string/new_string while pi-coding-agent uses path/oldText/newText.
// This prevents models trained on Claude Code from getting stuck in tool-call loops.
export function normalizeToolParams(params: unknown): Record<string, unknown> | undefined {
if (!params || typeof params !== "object") {
return undefined;
}
const record = params as Record<string, unknown>;
const normalized = { ...record };
// file_path → path (read, write, edit)
if ("file_path" in normalized && !("path" in normalized)) {
normalized.path = normalized.file_path;
delete normalized.file_path;
}
// old_string → oldText (edit)
if ("old_string" in normalized && !("oldText" in normalized)) {
normalized.oldText = normalized.old_string;
delete normalized.old_string;
}
// new_string → newText (edit)
if ("new_string" in normalized && !("newText" in normalized)) {
normalized.newText = normalized.new_string;
delete normalized.new_string;
}
// Some providers/models emit text payloads as structured blocks instead of raw strings.
// Normalize these for write/edit so content matching and writes stay deterministic.
normalizeTextLikeParam(normalized, "content");
normalizeTextLikeParam(normalized, "oldText");
normalizeTextLikeParam(normalized, "newText");
return normalized;
}
export function patchToolSchemaForClaudeCompatibility(tool: AnyAgentTool): AnyAgentTool {
const schema =
tool.parameters && typeof tool.parameters === "object"
? (tool.parameters as Record<string, unknown>)
: undefined;
if (!schema || !schema.properties || typeof schema.properties !== "object") {
return tool;
}
const properties = { ...(schema.properties as Record<string, unknown>) };
const required = Array.isArray(schema.required)
? schema.required.filter((key): key is string => typeof key === "string")
: [];
let changed = false;
const aliasPairs: Array<{ original: string; alias: string }> = [
{ original: "path", alias: "file_path" },
{ original: "oldText", alias: "old_string" },
{ original: "newText", alias: "new_string" },
];
for (const { original, alias } of aliasPairs) {
if (!(original in properties)) {
continue;
}
if (!(alias in properties)) {
properties[alias] = properties[original];
changed = true;
}
const idx = required.indexOf(original);
if (idx !== -1) {
required.splice(idx, 1);
changed = true;
}
}
if (!changed) {
return tool;
}
return {
...tool,
parameters: {
...schema,
properties,
required,
},
};
}
export function assertRequiredParams(
record: Record<string, unknown> | undefined,
groups: readonly RequiredParamGroup[],
toolName: string,
): void {
if (!record || typeof record !== "object") {
throw parameterValidationError(`Missing parameters for ${toolName}`);
}
const missingLabels: string[] = [];
for (const group of groups) {
const satisfied = group.keys.some((key) => {
if (!(key in record)) {
return false;
}
const value = record[key];
if (typeof value !== "string") {
return false;
}
if (group.allowEmpty) {
return true;
}
return value.trim().length > 0;
});
if (!satisfied) {
const label = group.label ?? group.keys.join(" or ");
missingLabels.push(label);
}
}
if (missingLabels.length > 0) {
const joined = missingLabels.join(", ");
const noun = missingLabels.length === 1 ? "parameter" : "parameters";
throw parameterValidationError(`Missing required ${noun}: ${joined}`);
}
}
// Generic wrapper to normalize parameters for any tool.
export function wrapToolParamNormalization(
tool: AnyAgentTool,
requiredParamGroups?: readonly RequiredParamGroup[],
): AnyAgentTool {
const patched = patchToolSchemaForClaudeCompatibility(tool);
return {
...patched,
execute: async (toolCallId, params, signal, onUpdate) => {
const normalized = normalizeToolParams(params);
const record =
normalized ??
(params && typeof params === "object" ? (params as Record<string, unknown>) : undefined);
if (requiredParamGroups?.length) {
assertRequiredParams(record, requiredParamGroups, tool.name);
}
return tool.execute(toolCallId, normalized ?? params, signal, onUpdate);
},
};
}

View File

@@ -0,0 +1,89 @@
/**
* Tests for edit tool post-write recovery: when the upstream library throws after
* having already written the file (e.g. generateDiffString fails), we catch and
* if the file on disk contains the intended newText we return success (#32333).
*/
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { EditToolOptions } from "@mariozechner/pi-coding-agent";
import { afterEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
executeThrows: true,
}));
vi.mock("@mariozechner/pi-coding-agent", async (importOriginal) => {
const actual = await importOriginal<typeof import("@mariozechner/pi-coding-agent")>();
return {
...actual,
createEditTool: (cwd: string, options?: EditToolOptions) => {
const base = actual.createEditTool(cwd, options);
return {
...base,
execute: async (...args: Parameters<typeof base.execute>) => {
if (mocks.executeThrows) {
throw new Error("Simulated post-write failure (e.g. generateDiffString)");
}
return base.execute(...args);
},
};
},
};
});
const { createHostWorkspaceEditTool } = await import("./pi-tools.read.js");
describe("createHostWorkspaceEditTool post-write recovery", () => {
let tmpDir = "";
afterEach(async () => {
mocks.executeThrows = true;
if (tmpDir) {
await fs.rm(tmpDir, { recursive: true, force: true });
tmpDir = "";
}
});
it("returns success when upstream throws but file has newText and no longer has oldText", async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-edit-recovery-"));
const filePath = path.join(tmpDir, "MEMORY.md");
const oldText = "# Memory";
const newText = "Blog Writing";
await fs.writeFile(filePath, `\n\n${newText}\n`, "utf-8");
const tool = createHostWorkspaceEditTool(tmpDir);
const result = await tool.execute("call-1", { path: filePath, oldText, newText }, undefined);
expect(result).toBeDefined();
const content = Array.isArray((result as { content?: unknown }).content)
? (result as { content: Array<{ type?: string; text?: string }> }).content
: [];
const textBlock = content.find((b) => b?.type === "text" && typeof b.text === "string");
expect(textBlock?.text).toContain("Successfully replaced text");
});
it("rethrows when file on disk does not contain newText", async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-edit-recovery-"));
const filePath = path.join(tmpDir, "other.md");
await fs.writeFile(filePath, "unchanged content", "utf-8");
const tool = createHostWorkspaceEditTool(tmpDir);
await expect(
tool.execute("call-1", { path: filePath, oldText: "x", newText: "never-written" }, undefined),
).rejects.toThrow("Simulated post-write failure");
});
it("rethrows when file still contains oldText (pre-write failure; avoid false success)", async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-edit-recovery-"));
const filePath = path.join(tmpDir, "pre-write-fail.md");
const oldText = "replace me";
const newText = "new content";
await fs.writeFile(filePath, `before ${oldText} after ${newText}`, "utf-8");
const tool = createHostWorkspaceEditTool(tmpDir);
await expect(
tool.execute("call-1", { path: filePath, oldText, newText }, undefined),
).rejects.toThrow("Simulated post-write failure");
});
});

View File

@@ -13,11 +13,26 @@ import { detectMime } from "../media/mime.js";
import { sniffMimeFromBase64 } from "../media/sniff-mime-from-base64.js";
import type { ImageSanitizationLimits } from "./image-sanitization.js";
import { toRelativeWorkspacePath } from "./path-policy.js";
import { wrapHostEditToolWithPostWriteRecovery } from "./pi-tools.host-edit.js";
import {
CLAUDE_PARAM_GROUPS,
assertRequiredParams,
normalizeToolParams,
patchToolSchemaForClaudeCompatibility,
wrapToolParamNormalization,
} from "./pi-tools.params.js";
import type { AnyAgentTool } from "./pi-tools.types.js";
import { assertSandboxPath } from "./sandbox-paths.js";
import type { SandboxFsBridge } from "./sandbox/fs-bridge.js";
import { sanitizeToolResultImages } from "./tool-images.js";
export {
CLAUDE_PARAM_GROUPS,
normalizeToolParams,
patchToolSchemaForClaudeCompatibility,
wrapToolParamNormalization,
} from "./pi-tools.params.js";
// NOTE(steipete): Upstream read now does file-magic MIME detection; we keep the wrapper
// to normalize payloads and sanitize oversized images before they hit providers.
type ToolContentBlock = AgentToolResult<unknown>["content"][number];
@@ -334,230 +349,6 @@ async function normalizeReadImageResult(
return { ...result, content: nextContent };
}
type RequiredParamGroup = {
keys: readonly string[];
allowEmpty?: boolean;
label?: string;
};
const RETRY_GUIDANCE_SUFFIX = " Supply correct parameters before retrying.";
function parameterValidationError(message: string): Error {
return new Error(`${message}.${RETRY_GUIDANCE_SUFFIX}`);
}
export const CLAUDE_PARAM_GROUPS = {
read: [{ keys: ["path", "file_path"], label: "path (path or file_path)" }],
write: [
{ keys: ["path", "file_path"], label: "path (path or file_path)" },
{ keys: ["content"], label: "content" },
],
edit: [
{ keys: ["path", "file_path"], label: "path (path or file_path)" },
{
keys: ["oldText", "old_string"],
label: "oldText (oldText or old_string)",
},
{
keys: ["newText", "new_string"],
label: "newText (newText or new_string)",
allowEmpty: true,
},
],
} as const;
function extractStructuredText(value: unknown, depth = 0): string | undefined {
if (depth > 6) {
return undefined;
}
if (typeof value === "string") {
return value;
}
if (Array.isArray(value)) {
const parts = value
.map((entry) => extractStructuredText(entry, depth + 1))
.filter((entry): entry is string => typeof entry === "string");
return parts.length > 0 ? parts.join("") : undefined;
}
if (!value || typeof value !== "object") {
return undefined;
}
const record = value as Record<string, unknown>;
if (typeof record.text === "string") {
return record.text;
}
if (typeof record.content === "string") {
return record.content;
}
if (Array.isArray(record.content)) {
return extractStructuredText(record.content, depth + 1);
}
if (Array.isArray(record.parts)) {
return extractStructuredText(record.parts, depth + 1);
}
if (typeof record.value === "string" && record.value.length > 0) {
const type = typeof record.type === "string" ? record.type.toLowerCase() : "";
const kind = typeof record.kind === "string" ? record.kind.toLowerCase() : "";
if (type.includes("text") || kind === "text") {
return record.value;
}
}
return undefined;
}
function normalizeTextLikeParam(record: Record<string, unknown>, key: string) {
const value = record[key];
if (typeof value === "string") {
return;
}
const extracted = extractStructuredText(value);
if (typeof extracted === "string") {
record[key] = extracted;
}
}
// Normalize tool parameters from Claude Code conventions to pi-coding-agent conventions.
// Claude Code uses file_path/old_string/new_string while pi-coding-agent uses path/oldText/newText.
// This prevents models trained on Claude Code from getting stuck in tool-call loops.
export function normalizeToolParams(params: unknown): Record<string, unknown> | undefined {
if (!params || typeof params !== "object") {
return undefined;
}
const record = params as Record<string, unknown>;
const normalized = { ...record };
// file_path → path (read, write, edit)
if ("file_path" in normalized && !("path" in normalized)) {
normalized.path = normalized.file_path;
delete normalized.file_path;
}
// old_string → oldText (edit)
if ("old_string" in normalized && !("oldText" in normalized)) {
normalized.oldText = normalized.old_string;
delete normalized.old_string;
}
// new_string → newText (edit)
if ("new_string" in normalized && !("newText" in normalized)) {
normalized.newText = normalized.new_string;
delete normalized.new_string;
}
// Some providers/models emit text payloads as structured blocks instead of raw strings.
// Normalize these for write/edit so content matching and writes stay deterministic.
normalizeTextLikeParam(normalized, "content");
normalizeTextLikeParam(normalized, "oldText");
normalizeTextLikeParam(normalized, "newText");
return normalized;
}
export function patchToolSchemaForClaudeCompatibility(tool: AnyAgentTool): AnyAgentTool {
const schema =
tool.parameters && typeof tool.parameters === "object"
? (tool.parameters as Record<string, unknown>)
: undefined;
if (!schema || !schema.properties || typeof schema.properties !== "object") {
return tool;
}
const properties = { ...(schema.properties as Record<string, unknown>) };
const required = Array.isArray(schema.required)
? schema.required.filter((key): key is string => typeof key === "string")
: [];
let changed = false;
const aliasPairs: Array<{ original: string; alias: string }> = [
{ original: "path", alias: "file_path" },
{ original: "oldText", alias: "old_string" },
{ original: "newText", alias: "new_string" },
];
for (const { original, alias } of aliasPairs) {
if (!(original in properties)) {
continue;
}
if (!(alias in properties)) {
properties[alias] = properties[original];
changed = true;
}
const idx = required.indexOf(original);
if (idx !== -1) {
required.splice(idx, 1);
changed = true;
}
}
if (!changed) {
return tool;
}
return {
...tool,
parameters: {
...schema,
properties,
required,
},
};
}
export function assertRequiredParams(
record: Record<string, unknown> | undefined,
groups: readonly RequiredParamGroup[],
toolName: string,
): void {
if (!record || typeof record !== "object") {
throw parameterValidationError(`Missing parameters for ${toolName}`);
}
const missingLabels: string[] = [];
for (const group of groups) {
const satisfied = group.keys.some((key) => {
if (!(key in record)) {
return false;
}
const value = record[key];
if (typeof value !== "string") {
return false;
}
if (group.allowEmpty) {
return true;
}
return value.trim().length > 0;
});
if (!satisfied) {
const label = group.label ?? group.keys.join(" or ");
missingLabels.push(label);
}
}
if (missingLabels.length > 0) {
const joined = missingLabels.join(", ");
const noun = missingLabels.length === 1 ? "parameter" : "parameters";
throw parameterValidationError(`Missing required ${noun}: ${joined}`);
}
}
// Generic wrapper to normalize parameters for any tool
export function wrapToolParamNormalization(
tool: AnyAgentTool,
requiredParamGroups?: readonly RequiredParamGroup[],
): AnyAgentTool {
const patched = patchToolSchemaForClaudeCompatibility(tool);
return {
...patched,
execute: async (toolCallId, params, signal, onUpdate) => {
const normalized = normalizeToolParams(params);
const record =
normalized ??
(params && typeof params === "object" ? (params as Record<string, unknown>) : undefined);
if (requiredParamGroups?.length) {
assertRequiredParams(record, requiredParamGroups, tool.name);
}
return tool.execute(toolCallId, normalized ?? params, signal, onUpdate);
},
};
}
export function wrapToolWorkspaceRootGuard(tool: AnyAgentTool, root: string): AnyAgentTool {
return wrapToolWorkspaceRootGuardWithOptions(tool, root);
}
@@ -684,7 +475,8 @@ export function createHostWorkspaceEditTool(root: string, options?: { workspaceO
const base = createEditTool(root, {
operations: createHostEditOperations(root, options),
}) as unknown as AnyAgentTool;
return wrapToolParamNormalization(base, CLAUDE_PARAM_GROUPS.edit);
const withRecovery = wrapHostEditToolWithPostWriteRecovery(base, root);
return wrapToolParamNormalization(withRecovery, CLAUDE_PARAM_GROUPS.edit);
}
export function createOpenClawReadTool(

View File

@@ -2,6 +2,7 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { SessionManager } from "@mariozechner/pi-coding-agent";
import { describe, expect, it } from "vitest";
import { installSessionToolResultGuard } from "./session-tool-result-guard.js";
import { castAgentMessage } from "./test-helpers/agent-message-fixtures.js";
type AppendMessage = Parameters<SessionManager["appendMessage"]>[0];
@@ -388,10 +389,10 @@ describe("installSessionToolResultGuard", () => {
return undefined;
}
return {
message: {
message: castAgentMessage({
...(message as unknown as Record<string, unknown>),
content: [{ type: "text", text: "rewritten by hook" }],
} as unknown as AgentMessage,
}),
};
},
});
@@ -425,10 +426,10 @@ describe("installSessionToolResultGuard", () => {
installSessionToolResultGuard(sm, {
transformMessageForPersistence: (message) =>
(message as { role?: string }).role === "user"
? ({
? castAgentMessage({
...(message as unknown as Record<string, unknown>),
provenance: { kind: "inter_session", sourceTool: "sessions_send" },
} as unknown as AgentMessage)
})
: message,
});

View File

@@ -1,9 +1,10 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { describe, it, expect } from "vitest";
import { sanitizeToolCallInputs } from "./session-transcript-repair.js";
import { castAgentMessage, castAgentMessages } from "./test-helpers/agent-message-fixtures.js";
function mkSessionsSpawnToolCall(content: string): AgentMessage {
return {
return castAgentMessage({
role: "assistant",
content: [
{
@@ -23,7 +24,7 @@ function mkSessionsSpawnToolCall(content: string): AgentMessage {
},
],
timestamp: Date.now(),
} as unknown as AgentMessage;
});
}
describe("sanitizeToolCallInputs redacts sessions_spawn attachments", () => {
@@ -44,7 +45,7 @@ describe("sanitizeToolCallInputs redacts sessions_spawn attachments", () => {
it("redacts attachments content from tool input payloads too", () => {
const secret = "INPUT_SECRET_SHOULD_NOT_PERSIST";
const input = [
const input = castAgentMessages([
{
role: "assistant",
content: [
@@ -59,7 +60,7 @@ describe("sanitizeToolCallInputs redacts sessions_spawn attachments", () => {
},
],
},
] as unknown as AgentMessage[];
]);
const out = sanitizeToolCallInputs(input);
const msg = out[0] as { content?: unknown[] };

View File

@@ -6,6 +6,7 @@ import {
repairToolUseResultPairing,
stripToolResultDetails,
} from "./session-transcript-repair.js";
import { castAgentMessage, castAgentMessages } from "./test-helpers/agent-message-fixtures.js";
const TOOL_CALL_BLOCK_TYPES = new Set(["toolCall", "toolUse", "functionCall"]);
@@ -25,7 +26,7 @@ describe("sanitizeToolUseResultPairing", () => {
middleMessage?: unknown;
secondText?: string;
}): AgentMessage[] =>
[
castAgentMessages([
{
role: "assistant",
content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }],
@@ -37,7 +38,7 @@ describe("sanitizeToolUseResultPairing", () => {
content: [{ type: "text", text: "first" }],
isError: false,
},
...(opts?.middleMessage ? [opts.middleMessage as AgentMessage] : []),
...(opts?.middleMessage ? [castAgentMessage(opts.middleMessage)] : []),
{
role: "toolResult",
toolCallId: "call_1",
@@ -45,10 +46,10 @@ describe("sanitizeToolUseResultPairing", () => {
content: [{ type: "text", text: opts?.secondText ?? "second" }],
isError: false,
},
] as unknown as AgentMessage[];
]);
it("moves tool results directly after tool calls and inserts missing results", () => {
const input = [
const input = castAgentMessages([
{
role: "assistant",
content: [
@@ -64,7 +65,7 @@ describe("sanitizeToolUseResultPairing", () => {
content: [{ type: "text", text: "ok" }],
isError: false,
},
] as unknown as AgentMessage[];
]);
const out = sanitizeToolUseResultPairing(input);
expect(out[0]?.role).toBe("assistant");
@@ -76,7 +77,7 @@ describe("sanitizeToolUseResultPairing", () => {
});
it("repairs blank tool result names from matching tool calls", () => {
const input = [
const input = castAgentMessages([
{
role: "assistant",
content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }],
@@ -88,7 +89,7 @@ describe("sanitizeToolUseResultPairing", () => {
content: [{ type: "text", text: "ok" }],
isError: false,
},
] as unknown as AgentMessage[];
]);
const out = sanitizeToolUseResultPairing(input);
const toolResult = out.find((message) => message.role === "toolResult") as {
@@ -99,10 +100,10 @@ describe("sanitizeToolUseResultPairing", () => {
});
it("drops duplicate tool results for the same id within a span", () => {
const input = [
const input = castAgentMessages([
...buildDuplicateToolResultInput(),
{ role: "user", content: "ok" },
] as AgentMessage[];
]);
const out = sanitizeToolUseResultPairing(input);
expect(out.filter((m) => m.role === "toolResult")).toHaveLength(1);
@@ -123,7 +124,7 @@ describe("sanitizeToolUseResultPairing", () => {
});
it("drops orphan tool results that do not match any tool call", () => {
const input = [
const input = castAgentMessages([
{ role: "user", content: "hello" },
{
role: "toolResult",
@@ -136,7 +137,7 @@ describe("sanitizeToolUseResultPairing", () => {
role: "assistant",
content: [{ type: "text", text: "ok" }],
},
] as unknown as AgentMessage[];
]);
const out = sanitizeToolUseResultPairing(input);
expect(out.some((m) => m.role === "toolResult")).toBe(false);
@@ -147,14 +148,14 @@ describe("sanitizeToolUseResultPairing", () => {
// When an assistant message has stopReason: "error", its tool_use blocks may be
// incomplete/malformed. We should NOT create synthetic tool_results for them,
// as this causes API 400 errors: "unexpected tool_use_id found in tool_result blocks"
const input = [
const input = castAgentMessages([
{
role: "assistant",
content: [{ type: "toolCall", id: "call_error", name: "exec", arguments: {} }],
stopReason: "error",
},
{ role: "user", content: "something went wrong" },
] as unknown as AgentMessage[];
]);
const result = repairToolUseResultPairing(input);
@@ -169,14 +170,14 @@ describe("sanitizeToolUseResultPairing", () => {
it("skips tool call extraction for assistant messages with stopReason 'aborted'", () => {
// When a request is aborted mid-stream, the assistant message may have incomplete
// tool_use blocks (with partialJson). We should NOT create synthetic tool_results.
const input = [
const input = castAgentMessages([
{
role: "assistant",
content: [{ type: "toolCall", id: "call_aborted", name: "Bash", arguments: {} }],
stopReason: "aborted",
},
{ role: "user", content: "retrying after abort" },
] as unknown as AgentMessage[];
]);
const result = repairToolUseResultPairing(input);
@@ -190,14 +191,14 @@ describe("sanitizeToolUseResultPairing", () => {
it("still repairs tool results for normal assistant messages with stopReason 'toolUse'", () => {
// Normal tool calls (stopReason: "toolUse" or "stop") should still be repaired
const input = [
const input = castAgentMessages([
{
role: "assistant",
content: [{ type: "toolCall", id: "call_normal", name: "read", arguments: {} }],
stopReason: "toolUse",
},
{ role: "user", content: "user message" },
] as unknown as AgentMessage[];
]);
const result = repairToolUseResultPairing(input);
@@ -210,7 +211,7 @@ describe("sanitizeToolUseResultPairing", () => {
// When an assistant message is aborted, any tool results that follow should be
// dropped as orphans (since we skip extracting tool calls from aborted messages).
// This addresses the edge case where a partial tool result was persisted before abort.
const input = [
const input = castAgentMessages([
{
role: "assistant",
content: [{ type: "toolCall", id: "call_aborted", name: "exec", arguments: {} }],
@@ -224,7 +225,7 @@ describe("sanitizeToolUseResultPairing", () => {
isError: false,
},
{ role: "user", content: "retrying" },
] as unknown as AgentMessage[];
]);
const result = repairToolUseResultPairing(input);
@@ -244,12 +245,12 @@ describe("sanitizeToolCallInputs", () => {
options?: Parameters<typeof sanitizeToolCallInputs>[1],
) {
return sanitizeToolCallInputs(
[
castAgentMessages([
{
role: "assistant",
content,
},
] as unknown as AgentMessage[],
]),
options,
);
}
@@ -262,13 +263,13 @@ describe("sanitizeToolCallInputs", () => {
}
it("drops tool calls missing input or arguments", () => {
const input = [
const input = castAgentMessages([
{
role: "assistant",
content: [{ type: "toolCall", id: "call_1", name: "read" }],
},
{ role: "user", content: "hello" },
] as unknown as AgentMessage[];
]);
const out = sanitizeToolCallInputs(input);
expect(out.map((m) => m.role)).toEqual(["user"]);
@@ -325,7 +326,7 @@ describe("sanitizeToolCallInputs", () => {
});
it("keeps valid tool calls and preserves text blocks", () => {
const input = [
const input = castAgentMessages([
{
role: "assistant",
content: [
@@ -334,7 +335,7 @@ describe("sanitizeToolCallInputs", () => {
{ type: "toolCall", id: "call_drop", name: "read" },
],
},
] as unknown as AgentMessage[];
]);
const out = sanitizeToolCallInputs(input);
const assistant = out[0] as Extract<AgentMessage, { role: "assistant" }>;
@@ -384,7 +385,7 @@ describe("sanitizeToolCallInputs", () => {
});
it("preserves toolUse input shape for sessions_spawn when no attachments are present", () => {
const input = [
const input = castAgentMessages([
{
role: "assistant",
content: [
@@ -396,7 +397,7 @@ describe("sanitizeToolCallInputs", () => {
},
],
},
] as unknown as AgentMessage[];
]);
const out = sanitizeToolCallInputs(input);
const toolCalls = getAssistantToolCallBlocks(out) as Array<Record<string, unknown>>;
@@ -408,7 +409,7 @@ describe("sanitizeToolCallInputs", () => {
});
it("redacts sessions_spawn attachments for mixed-case and padded tool names", () => {
const input = [
const input = castAgentMessages([
{
role: "assistant",
content: [
@@ -423,7 +424,7 @@ describe("sanitizeToolCallInputs", () => {
},
],
},
] as unknown as AgentMessage[];
]);
const out = sanitizeToolCallInputs(input);
const toolCalls = getAssistantToolCallBlocks(out) as Array<Record<string, unknown>>;
@@ -448,7 +449,7 @@ describe("sanitizeToolCallInputs", () => {
describe("stripToolResultDetails", () => {
it("removes details only from toolResult messages", () => {
const input = [
const input = castAgentMessages([
{
role: "toolResult",
toolCallId: "call_1",
@@ -458,7 +459,7 @@ describe("stripToolResultDetails", () => {
},
{ role: "assistant", content: [{ type: "text", text: "keep me" }], details: { no: "touch" } },
{ role: "user", content: "hello" },
] as unknown as AgentMessage[];
]);
const out = stripToolResultDetails(input) as unknown as Array<Record<string, unknown>>;
@@ -472,7 +473,7 @@ describe("stripToolResultDetails", () => {
});
it("returns the same array reference when there are no toolResult details", () => {
const input = [
const input = castAgentMessages([
{ role: "assistant", content: [{ type: "text", text: "a" }] },
{
role: "toolResult",
@@ -481,7 +482,7 @@ describe("stripToolResultDetails", () => {
content: [{ type: "text", text: "ok" }],
},
{ role: "user", content: "b" },
] as unknown as AgentMessage[];
]);
const out = stripToolResultDetails(input);
expect(out).toBe(input);

View File

@@ -0,0 +1,66 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { AssistantMessage, ToolResultMessage, Usage, UserMessage } from "@mariozechner/pi-ai";
const ZERO_USAGE: Usage = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
};
export function castAgentMessage(message: unknown): AgentMessage {
return message as AgentMessage;
}
export function castAgentMessages(messages: unknown[]): AgentMessage[] {
return messages as AgentMessage[];
}
export function makeAgentUserMessage(
overrides: Partial<UserMessage> & Pick<UserMessage, "content">,
): UserMessage {
return {
role: "user",
timestamp: 0,
...overrides,
};
}
export function makeAgentAssistantMessage(
overrides: Partial<AssistantMessage> & Pick<AssistantMessage, "content">,
): AssistantMessage {
return {
role: "assistant",
api: "openai-responses",
provider: "openai",
model: "test-model",
usage: ZERO_USAGE,
stopReason: "stop",
timestamp: 0,
...overrides,
};
}
export function makeAgentToolResultMessage(
overrides: Partial<ToolResultMessage> &
Pick<ToolResultMessage, "toolCallId" | "toolName" | "content">,
): ToolResultMessage {
const { toolCallId, toolName, content, ...rest } = overrides;
return {
role: "toolResult",
toolCallId,
toolName,
content,
isError: false,
timestamp: 0,
...rest,
};
}

View File

@@ -1,12 +1,13 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { describe, expect, it } from "vitest";
import { castAgentMessages } from "./test-helpers/agent-message-fixtures.js";
import {
isValidCloudCodeAssistToolId,
sanitizeToolCallIdsForCloudCodeAssist,
} from "./tool-call-id.js";
const buildDuplicateIdCollisionInput = () =>
[
castAgentMessages([
{
role: "assistant",
content: [
@@ -26,7 +27,7 @@ const buildDuplicateIdCollisionInput = () =>
toolName: "read",
content: [{ type: "text", text: "two" }],
},
] as unknown as AgentMessage[];
]);
function expectCollisionIdsRemainDistinct(
out: AgentMessage[],
@@ -65,7 +66,7 @@ function expectSingleToolCallRewrite(
describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
describe("strict mode (default)", () => {
it("is a no-op for already-valid non-colliding IDs", () => {
const input = [
const input = castAgentMessages([
{
role: "assistant",
content: [{ type: "toolCall", id: "call1", name: "read", arguments: {} }],
@@ -76,14 +77,14 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
toolName: "read",
content: [{ type: "text", text: "ok" }],
},
] as unknown as AgentMessage[];
]);
const out = sanitizeToolCallIdsForCloudCodeAssist(input);
expect(out).toBe(input);
});
it("strips non-alphanumeric characters from tool call IDs", () => {
const input = [
const input = castAgentMessages([
{
role: "assistant",
content: [{ type: "toolCall", id: "call|item:123", name: "read", arguments: {} }],
@@ -94,7 +95,7 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
toolName: "read",
content: [{ type: "text", text: "ok" }],
},
] as unknown as AgentMessage[];
]);
const out = sanitizeToolCallIdsForCloudCodeAssist(input);
expect(out).not.toBe(input);
@@ -113,7 +114,7 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
it("caps tool call IDs at 40 chars while preserving uniqueness", () => {
const longA = `call_${"a".repeat(60)}`;
const longB = `call_${"a".repeat(59)}b`;
const input = [
const input = castAgentMessages([
{
role: "assistant",
content: [
@@ -133,7 +134,7 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
toolName: "read",
content: [{ type: "text", text: "two" }],
},
] as unknown as AgentMessage[];
]);
const out = sanitizeToolCallIdsForCloudCodeAssist(input);
const { aId, bId } = expectCollisionIdsRemainDistinct(out, "strict");
@@ -144,7 +145,7 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
describe("strict mode (alphanumeric only)", () => {
it("strips underscores and hyphens from tool call IDs", () => {
const input = [
const input = castAgentMessages([
{
role: "assistant",
content: [
@@ -162,7 +163,7 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
toolName: "login",
content: [{ type: "text", text: "ok" }],
},
] as unknown as AgentMessage[];
]);
const out = sanitizeToolCallIdsForCloudCodeAssist(input, "strict");
expect(out).not.toBe(input);
@@ -184,7 +185,7 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
describe("strict9 mode (Mistral tool call IDs)", () => {
it("is a no-op for already-valid 9-char alphanumeric IDs", () => {
const input = [
const input = castAgentMessages([
{
role: "assistant",
content: [{ type: "toolCall", id: "abc123XYZ", name: "read", arguments: {} }],
@@ -195,14 +196,14 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
toolName: "read",
content: [{ type: "text", text: "ok" }],
},
] as unknown as AgentMessage[];
]);
const out = sanitizeToolCallIdsForCloudCodeAssist(input, "strict9");
expect(out).toBe(input);
});
it("enforces alphanumeric IDs with length 9", () => {
const input = [
const input = castAgentMessages([
{
role: "assistant",
content: [
@@ -222,7 +223,7 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
toolName: "read",
content: [{ type: "text", text: "two" }],
},
] as unknown as AgentMessage[];
]);
const out = sanitizeToolCallIdsForCloudCodeAssist(input, "strict9");
expect(out).not.toBe(input);

View File

@@ -67,7 +67,8 @@ describe("session hook context wiring", () => {
await vi.waitFor(() => expect(hookRunnerMocks.runSessionStart).toHaveBeenCalledTimes(1));
const [event, context] = hookRunnerMocks.runSessionStart.mock.calls[0] ?? [];
expect(event).toMatchObject({ sessionKey });
expect(context).toMatchObject({ sessionKey });
expect(context).toMatchObject({ sessionKey, agentId: "main" });
expect(context).toMatchObject({ sessionId: event?.sessionId });
});
it("passes sessionKey to session_end hook context on reset", async () => {
@@ -88,8 +89,13 @@ describe("session hook context wiring", () => {
});
await vi.waitFor(() => expect(hookRunnerMocks.runSessionEnd).toHaveBeenCalledTimes(1));
await vi.waitFor(() => expect(hookRunnerMocks.runSessionStart).toHaveBeenCalledTimes(1));
const [event, context] = hookRunnerMocks.runSessionEnd.mock.calls[0] ?? [];
expect(event).toMatchObject({ sessionKey });
expect(context).toMatchObject({ sessionKey });
expect(context).toMatchObject({ sessionKey, agentId: "main" });
expect(context).toMatchObject({ sessionId: event?.sessionId });
const [startEvent] = hookRunnerMocks.runSessionStart.mock.calls[0] ?? [];
expect(startEvent).toMatchObject({ resumedFrom: "old-session" });
});
});

View File

@@ -146,6 +146,70 @@ type LegacyMainDeliveryRetirement = {
entry: SessionEntry;
};
type SessionHookContext = {
sessionId: string;
sessionKey: string;
agentId: string;
};
function buildSessionHookContext(params: {
sessionId: string;
sessionKey: string;
cfg: OpenClawConfig;
}): SessionHookContext {
return {
sessionId: params.sessionId,
sessionKey: params.sessionKey,
agentId: resolveSessionAgentId({ sessionKey: params.sessionKey, config: params.cfg }),
};
}
function buildSessionStartHookPayload(params: {
sessionId: string;
sessionKey: string;
cfg: OpenClawConfig;
resumedFrom?: string;
}): {
event: { sessionId: string; sessionKey: string; resumedFrom?: string };
context: SessionHookContext;
} {
return {
event: {
sessionId: params.sessionId,
sessionKey: params.sessionKey,
resumedFrom: params.resumedFrom,
},
context: buildSessionHookContext({
sessionId: params.sessionId,
sessionKey: params.sessionKey,
cfg: params.cfg,
}),
};
}
function buildSessionEndHookPayload(params: {
sessionId: string;
sessionKey: string;
cfg: OpenClawConfig;
messageCount?: number;
}): {
event: { sessionId: string; sessionKey: string; messageCount: number };
context: SessionHookContext;
} {
return {
event: {
sessionId: params.sessionId,
sessionKey: params.sessionKey,
messageCount: params.messageCount ?? 0,
},
context: buildSessionHookContext({
sessionId: params.sessionId,
sessionKey: params.sessionKey,
cfg: params.cfg,
}),
};
}
function resolveParentForkMaxTokens(cfg: OpenClawConfig): number {
const configured = cfg.session?.parentForkMaxTokens;
if (typeof configured === "number" && Number.isFinite(configured) && configured >= 0) {
@@ -643,39 +707,24 @@ export async function initSessionState(params: {
// If replacing an existing session, fire session_end for the old one
if (previousSessionEntry?.sessionId && previousSessionEntry.sessionId !== effectiveSessionId) {
if (hookRunner.hasHooks("session_end")) {
void hookRunner
.runSessionEnd(
{
sessionId: previousSessionEntry.sessionId,
sessionKey,
messageCount: 0,
},
{
sessionId: previousSessionEntry.sessionId,
sessionKey,
agentId: resolveSessionAgentId({ sessionKey, config: cfg }),
},
)
.catch(() => {});
const payload = buildSessionEndHookPayload({
sessionId: previousSessionEntry.sessionId,
sessionKey,
cfg,
});
void hookRunner.runSessionEnd(payload.event, payload.context).catch(() => {});
}
}
// Fire session_start for the new session
if (hookRunner.hasHooks("session_start")) {
void hookRunner
.runSessionStart(
{
sessionId: effectiveSessionId,
sessionKey,
resumedFrom: previousSessionEntry?.sessionId,
},
{
sessionId: effectiveSessionId,
sessionKey,
agentId: resolveSessionAgentId({ sessionKey, config: cfg }),
},
)
.catch(() => {});
const payload = buildSessionStartHookPayload({
sessionId: effectiveSessionId,
sessionKey,
cfg,
resumedFrom: previousSessionEntry?.sessionId,
});
void hookRunner.runSessionStart(payload.event, payload.context).catch(() => {});
}
}

View File

@@ -3,6 +3,7 @@ import {
buildParseArgv,
getFlagValue,
getCommandPath,
getCommandPositionalsWithRootOptions,
getCommandPathWithRootOptions,
getPrimaryCommand,
getPositiveIntFlagValue,
@@ -170,6 +171,41 @@ describe("argv helpers", () => {
).toEqual(["config", "validate"]);
});
it("extracts routed config get positionals with interleaved root options", () => {
expect(
getCommandPositionalsWithRootOptions(
["node", "openclaw", "config", "get", "--log-level", "debug", "update.channel", "--json"],
{
commandPath: ["config", "get"],
booleanFlags: ["--json"],
},
),
).toEqual(["update.channel"]);
});
it("extracts routed config unset positionals with interleaved root options", () => {
expect(
getCommandPositionalsWithRootOptions(
["node", "openclaw", "config", "unset", "--profile", "work", "update.channel"],
{
commandPath: ["config", "unset"],
},
),
).toEqual(["update.channel"]);
});
it("returns null when routed command sees unknown options", () => {
expect(
getCommandPositionalsWithRootOptions(
["node", "openclaw", "config", "get", "--mystery", "value", "update.channel"],
{
commandPath: ["config", "get"],
booleanFlags: ["--json"],
},
),
).toBeNull();
});
it.each([
{
name: "returns first command token",

View File

@@ -188,6 +188,91 @@ export function getPrimaryCommand(argv: string[]): string | null {
return primary ?? null;
}
type CommandPositionalsParseOptions = {
commandPath: ReadonlyArray<string>;
booleanFlags?: ReadonlyArray<string>;
valueFlags?: ReadonlyArray<string>;
};
function consumeKnownOptionToken(
args: ReadonlyArray<string>,
index: number,
booleanFlags: ReadonlySet<string>,
valueFlags: ReadonlySet<string>,
): number {
const arg = args[index];
if (!arg || arg === FLAG_TERMINATOR || !arg.startsWith("-")) {
return 0;
}
const equalsIndex = arg.indexOf("=");
const flag = equalsIndex === -1 ? arg : arg.slice(0, equalsIndex);
if (booleanFlags.has(flag)) {
return equalsIndex === -1 ? 1 : 0;
}
if (!valueFlags.has(flag)) {
return 0;
}
if (equalsIndex !== -1) {
const value = arg.slice(equalsIndex + 1).trim();
return value ? 1 : 0;
}
return isValueToken(args[index + 1]) ? 2 : 0;
}
export function getCommandPositionalsWithRootOptions(
argv: string[],
options: CommandPositionalsParseOptions,
): string[] | null {
const args = argv.slice(2);
const commandPath = options.commandPath;
const booleanFlags = new Set(options.booleanFlags ?? []);
const valueFlags = new Set(options.valueFlags ?? []);
const positionals: string[] = [];
let commandIndex = 0;
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (!arg || arg === FLAG_TERMINATOR) {
break;
}
const rootConsumed = consumeRootOptionToken(args, i);
if (rootConsumed > 0) {
i += rootConsumed - 1;
continue;
}
if (arg.startsWith("-")) {
const optionConsumed = consumeKnownOptionToken(args, i, booleanFlags, valueFlags);
if (optionConsumed === 0) {
return null;
}
i += optionConsumed - 1;
continue;
}
if (commandIndex < commandPath.length) {
if (arg !== commandPath[commandIndex]) {
return null;
}
commandIndex += 1;
continue;
}
positionals.push(arg);
}
if (commandIndex < commandPath.length) {
return null;
}
return positionals;
}
export function buildParseArgv(params: {
programName?: string;
rawArgs?: string[];

View File

@@ -102,6 +102,38 @@ describe("program routes", () => {
expect(runConfigUnsetMock).toHaveBeenCalledWith({ path: "update.channel" });
});
it("passes config get path when root value options appear after subcommand", async () => {
const route = expectRoute(["config", "get"]);
await expect(
route?.run([
"node",
"openclaw",
"config",
"get",
"--log-level",
"debug",
"update.channel",
"--json",
]),
).resolves.toBe(true);
expect(runConfigGetMock).toHaveBeenCalledWith({ path: "update.channel", json: true });
});
it("passes config unset path when root value options appear after subcommand", async () => {
const route = expectRoute(["config", "unset"]);
await expect(
route?.run(["node", "openclaw", "config", "unset", "--profile", "work", "update.channel"]),
).resolves.toBe(true);
expect(runConfigUnsetMock).toHaveBeenCalledWith({ path: "update.channel" });
});
it("returns false for config get route when unknown option appears", async () => {
await expectRunFalse(
["config", "get"],
["node", "openclaw", "config", "get", "--mystery", "value", "update.channel"],
);
});
it("returns false for memory status route when --agent value is missing", async () => {
await expectRunFalse(["memory", "status"], ["node", "openclaw", "memory", "status", "--agent"]);
});

View File

@@ -1,6 +1,12 @@
import { consumeRootOptionToken, isValueToken } from "../../infra/cli-root-options.js";
import { isValueToken } from "../../infra/cli-root-options.js";
import { defaultRuntime } from "../../runtime.js";
import { getFlagValue, getPositiveIntFlagValue, getVerboseFlag, hasFlag } from "../argv.js";
import {
getCommandPositionalsWithRootOptions,
getFlagValue,
getPositiveIntFlagValue,
getVerboseFlag,
hasFlag,
} from "../argv.js";
export type RouteSpec = {
match: (path: string[]) => boolean;
@@ -100,31 +106,6 @@ const routeMemoryStatus: RouteSpec = {
},
};
function getCommandPositionals(argv: string[]): string[] {
const out: string[] = [];
const args = argv.slice(2);
let commandStarted = false;
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (!arg || arg === "--") {
break;
}
if (!commandStarted) {
const consumed = consumeRootOptionToken(args, i);
if (consumed > 0) {
i += consumed - 1;
continue;
}
}
if (arg.startsWith("-")) {
continue;
}
commandStarted = true;
out.push(arg);
}
return out;
}
function getFlagValues(argv: string[], name: string): string[] | null {
const values: string[] = [];
const args = argv.slice(2);
@@ -156,8 +137,14 @@ function getFlagValues(argv: string[], name: string): string[] | null {
const routeConfigGet: RouteSpec = {
match: (path) => path[0] === "config" && path[1] === "get",
run: async (argv) => {
const positionals = getCommandPositionals(argv);
const pathArg = positionals[2];
const positionals = getCommandPositionalsWithRootOptions(argv, {
commandPath: ["config", "get"],
booleanFlags: ["--json"],
});
if (!positionals || positionals.length !== 1) {
return false;
}
const pathArg = positionals[0];
if (!pathArg) {
return false;
}
@@ -171,8 +158,13 @@ const routeConfigGet: RouteSpec = {
const routeConfigUnset: RouteSpec = {
match: (path) => path[0] === "config" && path[1] === "unset",
run: async (argv) => {
const positionals = getCommandPositionals(argv);
const pathArg = positionals[2];
const positionals = getCommandPositionalsWithRootOptions(argv, {
commandPath: ["config", "unset"],
});
if (!positionals || positionals.length !== 1) {
return false;
}
const pathArg = positionals[0];
if (!pathArg) {
return false;
}

View File

@@ -1,4 +1,3 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { acquireSessionWriteLock } from "../../agents/session-write-lock.js";
@@ -39,6 +38,7 @@ import {
import { applySessionStoreMigrations } from "./store-migrations.js";
import {
mergeSessionEntry,
mergeSessionEntryPreserveActivity,
normalizeSessionRuntimeModelFields,
type SessionEntry,
} from "./types.js";
@@ -738,14 +738,9 @@ export async function recordSessionMetaFromInbound(params: {
return null;
}
const next = existing
? normalizeSessionRuntimeModelFields({
...existing,
...patch,
// Inbound metadata updates must not refresh activity timestamps;
// idle reset evaluation relies on updatedAt from actual session turns.
sessionId: existing.sessionId ?? crypto.randomUUID(),
updatedAt: existing.updatedAt ?? Date.now(),
})
? // Inbound metadata updates must not refresh activity timestamps;
// idle reset evaluation relies on updatedAt from actual session turns.
mergeSessionEntryPreserveActivity(existing, patch)
: mergeSessionEntry(existing, patch);
store[resolved.normalizedKey] = next;
for (const legacyKey of resolved.legacyKeys) {

View File

@@ -225,12 +225,31 @@ export function setSessionRuntimeModel(
return true;
}
export function mergeSessionEntry(
export type SessionEntryMergePolicy = "touch-activity" | "preserve-activity";
type MergeSessionEntryOptions = {
policy?: SessionEntryMergePolicy;
now?: number;
};
function resolveMergedUpdatedAt(
existing: SessionEntry | undefined,
patch: Partial<SessionEntry>,
options?: MergeSessionEntryOptions,
): number {
if (options?.policy === "preserve-activity" && existing) {
return existing.updatedAt ?? patch.updatedAt ?? options.now ?? Date.now();
}
return Math.max(existing?.updatedAt ?? 0, patch.updatedAt ?? 0, options?.now ?? Date.now());
}
export function mergeSessionEntryWithPolicy(
existing: SessionEntry | undefined,
patch: Partial<SessionEntry>,
options?: MergeSessionEntryOptions,
): SessionEntry {
const sessionId = patch.sessionId ?? existing?.sessionId ?? crypto.randomUUID();
const updatedAt = Math.max(existing?.updatedAt ?? 0, patch.updatedAt ?? 0, Date.now());
const updatedAt = resolveMergedUpdatedAt(existing, patch, options);
if (!existing) {
return normalizeSessionRuntimeModelFields({ ...patch, sessionId, updatedAt });
}
@@ -248,6 +267,22 @@ export function mergeSessionEntry(
return normalizeSessionRuntimeModelFields(next);
}
export function mergeSessionEntry(
existing: SessionEntry | undefined,
patch: Partial<SessionEntry>,
): SessionEntry {
return mergeSessionEntryWithPolicy(existing, patch);
}
export function mergeSessionEntryPreserveActivity(
existing: SessionEntry | undefined,
patch: Partial<SessionEntry>,
): SessionEntry {
return mergeSessionEntryWithPolicy(existing, patch, {
policy: "preserve-activity",
});
}
export function resolveFreshSessionTotalTokens(
entry?: Pick<SessionEntry, "totalTokens" | "totalTokensFresh"> | null,
): number | undefined {

View File

@@ -27,4 +27,10 @@ describe("Dockerfile", () => {
expect(dockerfile).toContain('find "$dir" -type d -exec chmod 755 {} +');
expect(dockerfile).toContain('find "$dir" -type f -exec chmod 644 {} +');
});
it("Docker GPG fingerprint awk uses correct quoting for OPENCLAW_SANDBOX=1 build", async () => {
const dockerfile = await readFile(dockerfilePath, "utf8");
expect(dockerfile).toContain('== "fpr" {');
expect(dockerfile).not.toContain('\\"fpr\\"');
});
});

View File

@@ -1,148 +1,14 @@
import { createRequire } from "node:module";
import { resolveEffectiveMessagesConfig, resolveHumanDelayConfig } from "../../agents/identity.js";
import { createMemoryGetTool, createMemorySearchTool } from "../../agents/tools/memory-tool.js";
import { handleSlackAction } from "../../agents/tools/slack-actions.js";
import {
chunkByNewline,
chunkMarkdownText,
chunkMarkdownTextWithMode,
chunkText,
chunkTextWithMode,
resolveChunkMode,
resolveTextChunkLimit,
} from "../../auto-reply/chunk.js";
import {
hasControlCommand,
isControlCommandMessage,
shouldComputeCommandAuthorized,
} from "../../auto-reply/command-detection.js";
import { shouldHandleTextCommands } from "../../auto-reply/commands-registry.js";
import { withReplyDispatcher } from "../../auto-reply/dispatch.js";
import {
formatAgentEnvelope,
formatInboundEnvelope,
resolveEnvelopeFormatOptions,
} from "../../auto-reply/envelope.js";
import {
createInboundDebouncer,
resolveInboundDebounceMs,
} from "../../auto-reply/inbound-debounce.js";
import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js";
import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js";
import {
buildMentionRegexes,
matchesMentionPatterns,
matchesMentionWithExplicit,
} from "../../auto-reply/reply/mentions.js";
import { dispatchReplyWithBufferedBlockDispatcher } from "../../auto-reply/reply/provider-dispatcher.js";
import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.js";
import { removeAckReactionAfterReply, shouldAckReaction } from "../../channels/ack-reactions.js";
import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js";
import { discordMessageActions } from "../../channels/plugins/actions/discord.js";
import { signalMessageActions } from "../../channels/plugins/actions/signal.js";
import { telegramMessageActions } from "../../channels/plugins/actions/telegram.js";
import { createWhatsAppLoginTool } from "../../channels/plugins/agent-tools/whatsapp-login.js";
import { recordInboundSession } from "../../channels/session.js";
import { registerMemoryCli } from "../../cli/memory-cli.js";
import { loadConfig, writeConfigFile } from "../../config/config.js";
import {
resolveChannelGroupPolicy,
resolveChannelGroupRequireMention,
} from "../../config/group-policy.js";
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
import { resolveStateDir } from "../../config/paths.js";
import {
readSessionUpdatedAt,
recordSessionMetaFromInbound,
resolveStorePath,
updateLastRoute,
} from "../../config/sessions.js";
import { auditDiscordChannelPermissions } from "../../discord/audit.js";
import {
listDiscordDirectoryGroupsLive,
listDiscordDirectoryPeersLive,
} from "../../discord/directory-live.js";
import { monitorDiscordProvider } from "../../discord/monitor.js";
import { probeDiscord } from "../../discord/probe.js";
import { resolveDiscordChannelAllowlist } from "../../discord/resolve-channels.js";
import { resolveDiscordUserAllowlist } from "../../discord/resolve-users.js";
import { sendMessageDiscord, sendPollDiscord } from "../../discord/send.js";
import { shouldLogVerbose } from "../../globals.js";
import { monitorIMessageProvider } from "../../imessage/monitor.js";
import { probeIMessage } from "../../imessage/probe.js";
import { sendMessageIMessage } from "../../imessage/send.js";
import { onAgentEvent } from "../../infra/agent-events.js";
import { getChannelActivity, recordChannelActivity } from "../../infra/channel-activity.js";
import { requestHeartbeatNow } from "../../infra/heartbeat-wake.js";
import { enqueueSystemEvent } from "../../infra/system-events.js";
import {
listLineAccountIds,
normalizeAccountId as normalizeLineAccountId,
resolveDefaultLineAccountId,
resolveLineAccount,
} from "../../line/accounts.js";
import { monitorLineProvider } from "../../line/monitor.js";
import { probeLineBot } from "../../line/probe.js";
import {
createQuickReplyItems,
pushMessageLine,
pushMessagesLine,
pushFlexMessage,
pushTemplateMessage,
pushLocationMessage,
pushTextMessageWithQuickReplies,
sendMessageLine,
} from "../../line/send.js";
import { buildTemplateMessageFromPayload } from "../../line/template-messages.js";
import { getChildLogger } from "../../logging.js";
import { normalizeLogLevel } from "../../logging/levels.js";
import { convertMarkdownTables } from "../../markdown/tables.js";
import { transcribeAudioFile } from "../../media-understanding/transcribe-audio.js";
import { isVoiceCompatibleAudio } from "../../media/audio.js";
import { mediaKindFromMime } from "../../media/constants.js";
import { fetchRemoteMedia } from "../../media/fetch.js";
import { getImageMetadata, resizeToJpeg } from "../../media/image-ops.js";
import { detectMime } from "../../media/mime.js";
import { saveMediaBuffer } from "../../media/store.js";
import { buildPairingReply } from "../../pairing/pairing-messages.js";
import {
readChannelAllowFromStore,
upsertChannelPairingRequest,
} from "../../pairing/pairing-store.js";
import { runCommandWithTimeout } from "../../process/exec.js";
import { resolveAgentRoute } from "../../routing/resolve-route.js";
import { onSessionTranscriptUpdate } from "../../sessions/transcript-events.js";
import { monitorSignalProvider } from "../../signal/index.js";
import { probeSignal } from "../../signal/probe.js";
import { sendMessageSignal } from "../../signal/send.js";
import {
listSlackDirectoryGroupsLive,
listSlackDirectoryPeersLive,
} from "../../slack/directory-live.js";
import { monitorSlackProvider } from "../../slack/index.js";
import { probeSlack } from "../../slack/probe.js";
import { resolveSlackChannelAllowlist } from "../../slack/resolve-channels.js";
import { resolveSlackUserAllowlist } from "../../slack/resolve-users.js";
import { sendMessageSlack } from "../../slack/send.js";
import {
auditTelegramGroupMembership,
collectTelegramUnmentionedGroupIds,
} from "../../telegram/audit.js";
import { monitorTelegramProvider } from "../../telegram/monitor.js";
import { probeTelegram } from "../../telegram/probe.js";
import { sendMessageTelegram, sendPollTelegram } from "../../telegram/send.js";
import { resolveTelegramToken } from "../../telegram/token.js";
import { textToSpeechTelephony } from "../../tts/tts.js";
import { getActiveWebListener } from "../../web/active-listener.js";
import {
getWebAuthAgeMs,
logoutWeb,
logWebSelfId,
readWebSelfId,
webAuthExists,
} from "../../web/auth-store.js";
import { loadWebMedia } from "../../web/media.js";
import { formatNativeDependencyHint } from "./native-deps.js";
import { createRuntimeChannel } from "./runtime-channel.js";
import { createRuntimeConfig } from "./runtime-config.js";
import { createRuntimeEvents } from "./runtime-events.js";
import { createRuntimeLogging } from "./runtime-logging.js";
import { createRuntimeMedia } from "./runtime-media.js";
import { createRuntimeSystem } from "./runtime-system.js";
import { createRuntimeTools } from "./runtime-tools.js";
import type { PluginRuntime } from "./types.js";
let cachedVersion: string | null = null;
@@ -162,87 +28,8 @@ function resolveVersion(): string {
}
}
const sendMessageWhatsAppLazy: PluginRuntime["channel"]["whatsapp"]["sendMessageWhatsApp"] = async (
...args
) => {
const { sendMessageWhatsApp } = await loadWebOutbound();
return sendMessageWhatsApp(...args);
};
const sendPollWhatsAppLazy: PluginRuntime["channel"]["whatsapp"]["sendPollWhatsApp"] = async (
...args
) => {
const { sendPollWhatsApp } = await loadWebOutbound();
return sendPollWhatsApp(...args);
};
const loginWebLazy: PluginRuntime["channel"]["whatsapp"]["loginWeb"] = async (...args) => {
const { loginWeb } = await loadWebLogin();
return loginWeb(...args);
};
const startWebLoginWithQrLazy: PluginRuntime["channel"]["whatsapp"]["startWebLoginWithQr"] = async (
...args
) => {
const { startWebLoginWithQr } = await loadWebLoginQr();
return startWebLoginWithQr(...args);
};
const waitForWebLoginLazy: PluginRuntime["channel"]["whatsapp"]["waitForWebLogin"] = async (
...args
) => {
const { waitForWebLogin } = await loadWebLoginQr();
return waitForWebLogin(...args);
};
const monitorWebChannelLazy: PluginRuntime["channel"]["whatsapp"]["monitorWebChannel"] = async (
...args
) => {
const { monitorWebChannel } = await loadWebChannel();
return monitorWebChannel(...args);
};
const handleWhatsAppActionLazy: PluginRuntime["channel"]["whatsapp"]["handleWhatsAppAction"] =
async (...args) => {
const { handleWhatsAppAction } = await loadWhatsAppActions();
return handleWhatsAppAction(...args);
};
let webOutboundPromise: Promise<typeof import("../../web/outbound.js")> | null = null;
let webLoginPromise: Promise<typeof import("../../web/login.js")> | null = null;
let webLoginQrPromise: Promise<typeof import("../../web/login-qr.js")> | null = null;
let webChannelPromise: Promise<typeof import("../../channels/web/index.js")> | null = null;
let whatsappActionsPromise: Promise<
typeof import("../../agents/tools/whatsapp-actions.js")
> | null = null;
function loadWebOutbound() {
webOutboundPromise ??= import("../../web/outbound.js");
return webOutboundPromise;
}
function loadWebLogin() {
webLoginPromise ??= import("../../web/login.js");
return webLoginPromise;
}
function loadWebLoginQr() {
webLoginQrPromise ??= import("../../web/login-qr.js");
return webLoginQrPromise;
}
function loadWebChannel() {
webChannelPromise ??= import("../../channels/web/index.js");
return webChannelPromise;
}
function loadWhatsAppActions() {
whatsappActionsPromise ??= import("../../agents/tools/whatsapp-actions.js");
return whatsappActionsPromise;
}
export function createPluginRuntime(): PluginRuntime {
return {
const runtime = {
version: resolveVersion(),
config: createRuntimeConfig(),
system: createRuntimeSystem(),
@@ -251,226 +38,12 @@ export function createPluginRuntime(): PluginRuntime {
stt: { transcribeAudioFile },
tools: createRuntimeTools(),
channel: createRuntimeChannel(),
events: {
onAgentEvent,
onSessionTranscriptUpdate,
},
events: createRuntimeEvents(),
logging: createRuntimeLogging(),
state: { resolveStateDir },
};
}
} satisfies PluginRuntime;
function createRuntimeConfig(): PluginRuntime["config"] {
return {
loadConfig,
writeConfigFile,
};
}
function createRuntimeSystem(): PluginRuntime["system"] {
return {
enqueueSystemEvent,
requestHeartbeatNow,
runCommandWithTimeout,
formatNativeDependencyHint,
};
}
function createRuntimeMedia(): PluginRuntime["media"] {
return {
loadWebMedia,
detectMime,
mediaKindFromMime,
isVoiceCompatibleAudio,
getImageMetadata,
resizeToJpeg,
};
}
function createRuntimeTools(): PluginRuntime["tools"] {
return {
createMemoryGetTool,
createMemorySearchTool,
registerMemoryCli,
};
}
function createRuntimeChannel(): PluginRuntime["channel"] {
return {
text: {
chunkByNewline,
chunkMarkdownText,
chunkMarkdownTextWithMode,
chunkText,
chunkTextWithMode,
resolveChunkMode,
resolveTextChunkLimit,
hasControlCommand,
resolveMarkdownTableMode,
convertMarkdownTables,
},
reply: {
dispatchReplyWithBufferedBlockDispatcher,
createReplyDispatcherWithTyping,
resolveEffectiveMessagesConfig,
resolveHumanDelayConfig,
dispatchReplyFromConfig,
withReplyDispatcher,
finalizeInboundContext,
formatAgentEnvelope,
/** @deprecated Prefer `BodyForAgent` + structured user-context blocks (do not build plaintext envelopes for prompts). */
formatInboundEnvelope,
resolveEnvelopeFormatOptions,
},
routing: {
resolveAgentRoute,
},
pairing: {
buildPairingReply,
readAllowFromStore: ({ channel, accountId, env }) =>
readChannelAllowFromStore(channel, env, accountId),
upsertPairingRequest: ({ channel, id, accountId, meta, env, pairingAdapter }) =>
upsertChannelPairingRequest({
channel,
id,
accountId,
meta,
env,
pairingAdapter,
}),
},
media: {
fetchRemoteMedia,
saveMediaBuffer,
},
activity: {
record: recordChannelActivity,
get: getChannelActivity,
},
session: {
resolveStorePath,
readSessionUpdatedAt,
recordSessionMetaFromInbound,
recordInboundSession,
updateLastRoute,
},
mentions: {
buildMentionRegexes,
matchesMentionPatterns,
matchesMentionWithExplicit,
},
reactions: {
shouldAckReaction,
removeAckReactionAfterReply,
},
groups: {
resolveGroupPolicy: resolveChannelGroupPolicy,
resolveRequireMention: resolveChannelGroupRequireMention,
},
debounce: {
createInboundDebouncer,
resolveInboundDebounceMs,
},
commands: {
resolveCommandAuthorizedFromAuthorizers,
isControlCommandMessage,
shouldComputeCommandAuthorized,
shouldHandleTextCommands,
},
discord: {
messageActions: discordMessageActions,
auditChannelPermissions: auditDiscordChannelPermissions,
listDirectoryGroupsLive: listDiscordDirectoryGroupsLive,
listDirectoryPeersLive: listDiscordDirectoryPeersLive,
probeDiscord,
resolveChannelAllowlist: resolveDiscordChannelAllowlist,
resolveUserAllowlist: resolveDiscordUserAllowlist,
sendMessageDiscord,
sendPollDiscord,
monitorDiscordProvider,
},
slack: {
listDirectoryGroupsLive: listSlackDirectoryGroupsLive,
listDirectoryPeersLive: listSlackDirectoryPeersLive,
probeSlack,
resolveChannelAllowlist: resolveSlackChannelAllowlist,
resolveUserAllowlist: resolveSlackUserAllowlist,
sendMessageSlack,
monitorSlackProvider,
handleSlackAction,
},
telegram: {
auditGroupMembership: auditTelegramGroupMembership,
collectUnmentionedGroupIds: collectTelegramUnmentionedGroupIds,
probeTelegram,
resolveTelegramToken,
sendMessageTelegram,
sendPollTelegram,
monitorTelegramProvider,
messageActions: telegramMessageActions,
},
signal: {
probeSignal,
sendMessageSignal,
monitorSignalProvider,
messageActions: signalMessageActions,
},
imessage: {
monitorIMessageProvider,
probeIMessage,
sendMessageIMessage,
},
whatsapp: {
getActiveWebListener,
getWebAuthAgeMs,
logoutWeb,
logWebSelfId,
readWebSelfId,
webAuthExists,
sendMessageWhatsApp: sendMessageWhatsAppLazy,
sendPollWhatsApp: sendPollWhatsAppLazy,
loginWeb: loginWebLazy,
startWebLoginWithQr: startWebLoginWithQrLazy,
waitForWebLogin: waitForWebLoginLazy,
monitorWebChannel: monitorWebChannelLazy,
handleWhatsAppAction: handleWhatsAppActionLazy,
createLoginTool: createWhatsAppLoginTool,
},
line: {
listLineAccountIds,
resolveDefaultLineAccountId,
resolveLineAccount,
normalizeAccountId: normalizeLineAccountId,
probeLineBot,
sendMessageLine,
pushMessageLine,
pushMessagesLine,
pushFlexMessage,
pushTemplateMessage,
pushLocationMessage,
pushTextMessageWithQuickReplies,
createQuickReplyItems,
buildTemplateMessageFromPayload,
monitorLineProvider,
},
};
}
function createRuntimeLogging(): PluginRuntime["logging"] {
return {
shouldLogVerbose,
getChildLogger: (bindings, opts) => {
const logger = getChildLogger(bindings, {
level: opts?.level ? normalizeLogLevel(opts.level) : undefined,
});
return {
debug: (message) => logger.debug?.(message),
info: (message) => logger.info(message),
warn: (message) => logger.warn(message),
error: (message) => logger.error(message),
};
},
};
return runtime;
}
export type { PluginRuntime } from "./types.js";

View File

@@ -0,0 +1,263 @@
import { resolveEffectiveMessagesConfig, resolveHumanDelayConfig } from "../../agents/identity.js";
import { handleSlackAction } from "../../agents/tools/slack-actions.js";
import {
chunkByNewline,
chunkMarkdownText,
chunkMarkdownTextWithMode,
chunkText,
chunkTextWithMode,
resolveChunkMode,
resolveTextChunkLimit,
} from "../../auto-reply/chunk.js";
import {
hasControlCommand,
isControlCommandMessage,
shouldComputeCommandAuthorized,
} from "../../auto-reply/command-detection.js";
import { shouldHandleTextCommands } from "../../auto-reply/commands-registry.js";
import { withReplyDispatcher } from "../../auto-reply/dispatch.js";
import {
formatAgentEnvelope,
formatInboundEnvelope,
resolveEnvelopeFormatOptions,
} from "../../auto-reply/envelope.js";
import {
createInboundDebouncer,
resolveInboundDebounceMs,
} from "../../auto-reply/inbound-debounce.js";
import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js";
import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js";
import {
buildMentionRegexes,
matchesMentionPatterns,
matchesMentionWithExplicit,
} from "../../auto-reply/reply/mentions.js";
import { dispatchReplyWithBufferedBlockDispatcher } from "../../auto-reply/reply/provider-dispatcher.js";
import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.js";
import { removeAckReactionAfterReply, shouldAckReaction } from "../../channels/ack-reactions.js";
import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js";
import { discordMessageActions } from "../../channels/plugins/actions/discord.js";
import { signalMessageActions } from "../../channels/plugins/actions/signal.js";
import { telegramMessageActions } from "../../channels/plugins/actions/telegram.js";
import { recordInboundSession } from "../../channels/session.js";
import {
resolveChannelGroupPolicy,
resolveChannelGroupRequireMention,
} from "../../config/group-policy.js";
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
import {
readSessionUpdatedAt,
recordSessionMetaFromInbound,
resolveStorePath,
updateLastRoute,
} from "../../config/sessions.js";
import { auditDiscordChannelPermissions } from "../../discord/audit.js";
import {
listDiscordDirectoryGroupsLive,
listDiscordDirectoryPeersLive,
} from "../../discord/directory-live.js";
import { monitorDiscordProvider } from "../../discord/monitor.js";
import { probeDiscord } from "../../discord/probe.js";
import { resolveDiscordChannelAllowlist } from "../../discord/resolve-channels.js";
import { resolveDiscordUserAllowlist } from "../../discord/resolve-users.js";
import { sendMessageDiscord, sendPollDiscord } from "../../discord/send.js";
import { monitorIMessageProvider } from "../../imessage/monitor.js";
import { probeIMessage } from "../../imessage/probe.js";
import { sendMessageIMessage } from "../../imessage/send.js";
import { getChannelActivity, recordChannelActivity } from "../../infra/channel-activity.js";
import {
listLineAccountIds,
normalizeAccountId as normalizeLineAccountId,
resolveDefaultLineAccountId,
resolveLineAccount,
} from "../../line/accounts.js";
import { monitorLineProvider } from "../../line/monitor.js";
import { probeLineBot } from "../../line/probe.js";
import {
createQuickReplyItems,
pushFlexMessage,
pushLocationMessage,
pushMessageLine,
pushMessagesLine,
pushTemplateMessage,
pushTextMessageWithQuickReplies,
sendMessageLine,
} from "../../line/send.js";
import { buildTemplateMessageFromPayload } from "../../line/template-messages.js";
import { convertMarkdownTables } from "../../markdown/tables.js";
import { fetchRemoteMedia } from "../../media/fetch.js";
import { saveMediaBuffer } from "../../media/store.js";
import { buildPairingReply } from "../../pairing/pairing-messages.js";
import {
readChannelAllowFromStore,
upsertChannelPairingRequest,
} from "../../pairing/pairing-store.js";
import { resolveAgentRoute } from "../../routing/resolve-route.js";
import { monitorSignalProvider } from "../../signal/index.js";
import { probeSignal } from "../../signal/probe.js";
import { sendMessageSignal } from "../../signal/send.js";
import {
listSlackDirectoryGroupsLive,
listSlackDirectoryPeersLive,
} from "../../slack/directory-live.js";
import { monitorSlackProvider } from "../../slack/index.js";
import { probeSlack } from "../../slack/probe.js";
import { resolveSlackChannelAllowlist } from "../../slack/resolve-channels.js";
import { resolveSlackUserAllowlist } from "../../slack/resolve-users.js";
import { sendMessageSlack } from "../../slack/send.js";
import {
auditTelegramGroupMembership,
collectTelegramUnmentionedGroupIds,
} from "../../telegram/audit.js";
import { monitorTelegramProvider } from "../../telegram/monitor.js";
import { probeTelegram } from "../../telegram/probe.js";
import { sendMessageTelegram, sendPollTelegram } from "../../telegram/send.js";
import { resolveTelegramToken } from "../../telegram/token.js";
import { createRuntimeWhatsApp } from "./runtime-whatsapp.js";
import type { PluginRuntime } from "./types.js";
export function createRuntimeChannel(): PluginRuntime["channel"] {
return {
text: {
chunkByNewline,
chunkMarkdownText,
chunkMarkdownTextWithMode,
chunkText,
chunkTextWithMode,
resolveChunkMode,
resolveTextChunkLimit,
hasControlCommand,
resolveMarkdownTableMode,
convertMarkdownTables,
},
reply: {
dispatchReplyWithBufferedBlockDispatcher,
createReplyDispatcherWithTyping,
resolveEffectiveMessagesConfig,
resolveHumanDelayConfig,
dispatchReplyFromConfig,
withReplyDispatcher,
finalizeInboundContext,
formatAgentEnvelope,
/** @deprecated Prefer `BodyForAgent` + structured user-context blocks (do not build plaintext envelopes for prompts). */
formatInboundEnvelope,
resolveEnvelopeFormatOptions,
},
routing: {
resolveAgentRoute,
},
pairing: {
buildPairingReply,
readAllowFromStore: ({ channel, accountId, env }) =>
readChannelAllowFromStore(channel, env, accountId),
upsertPairingRequest: ({ channel, id, accountId, meta, env, pairingAdapter }) =>
upsertChannelPairingRequest({
channel,
id,
accountId,
meta,
env,
pairingAdapter,
}),
},
media: {
fetchRemoteMedia,
saveMediaBuffer,
},
activity: {
record: recordChannelActivity,
get: getChannelActivity,
},
session: {
resolveStorePath,
readSessionUpdatedAt,
recordSessionMetaFromInbound,
recordInboundSession,
updateLastRoute,
},
mentions: {
buildMentionRegexes,
matchesMentionPatterns,
matchesMentionWithExplicit,
},
reactions: {
shouldAckReaction,
removeAckReactionAfterReply,
},
groups: {
resolveGroupPolicy: resolveChannelGroupPolicy,
resolveRequireMention: resolveChannelGroupRequireMention,
},
debounce: {
createInboundDebouncer,
resolveInboundDebounceMs,
},
commands: {
resolveCommandAuthorizedFromAuthorizers,
isControlCommandMessage,
shouldComputeCommandAuthorized,
shouldHandleTextCommands,
},
discord: {
messageActions: discordMessageActions,
auditChannelPermissions: auditDiscordChannelPermissions,
listDirectoryGroupsLive: listDiscordDirectoryGroupsLive,
listDirectoryPeersLive: listDiscordDirectoryPeersLive,
probeDiscord,
resolveChannelAllowlist: resolveDiscordChannelAllowlist,
resolveUserAllowlist: resolveDiscordUserAllowlist,
sendMessageDiscord,
sendPollDiscord,
monitorDiscordProvider,
},
slack: {
listDirectoryGroupsLive: listSlackDirectoryGroupsLive,
listDirectoryPeersLive: listSlackDirectoryPeersLive,
probeSlack,
resolveChannelAllowlist: resolveSlackChannelAllowlist,
resolveUserAllowlist: resolveSlackUserAllowlist,
sendMessageSlack,
monitorSlackProvider,
handleSlackAction,
},
telegram: {
auditGroupMembership: auditTelegramGroupMembership,
collectUnmentionedGroupIds: collectTelegramUnmentionedGroupIds,
probeTelegram,
resolveTelegramToken,
sendMessageTelegram,
sendPollTelegram,
monitorTelegramProvider,
messageActions: telegramMessageActions,
},
signal: {
probeSignal,
sendMessageSignal,
monitorSignalProvider,
messageActions: signalMessageActions,
},
imessage: {
monitorIMessageProvider,
probeIMessage,
sendMessageIMessage,
},
whatsapp: createRuntimeWhatsApp(),
line: {
listLineAccountIds,
resolveDefaultLineAccountId,
resolveLineAccount,
normalizeAccountId: normalizeLineAccountId,
probeLineBot,
sendMessageLine,
pushMessageLine,
pushMessagesLine,
pushFlexMessage,
pushTemplateMessage,
pushLocationMessage,
pushTextMessageWithQuickReplies,
createQuickReplyItems,
buildTemplateMessageFromPayload,
monitorLineProvider,
},
};
}

View File

@@ -0,0 +1,9 @@
import { loadConfig, writeConfigFile } from "../../config/config.js";
import type { PluginRuntime } from "./types.js";
export function createRuntimeConfig(): PluginRuntime["config"] {
return {
loadConfig,
writeConfigFile,
};
}

View File

@@ -0,0 +1,10 @@
import { onAgentEvent } from "../../infra/agent-events.js";
import { onSessionTranscriptUpdate } from "../../sessions/transcript-events.js";
import type { PluginRuntime } from "./types.js";
export function createRuntimeEvents(): PluginRuntime["events"] {
return {
onAgentEvent,
onSessionTranscriptUpdate,
};
}

View File

@@ -0,0 +1,21 @@
import { shouldLogVerbose } from "../../globals.js";
import { getChildLogger } from "../../logging.js";
import { normalizeLogLevel } from "../../logging/levels.js";
import type { PluginRuntime } from "./types.js";
export function createRuntimeLogging(): PluginRuntime["logging"] {
return {
shouldLogVerbose,
getChildLogger: (bindings, opts) => {
const logger = getChildLogger(bindings, {
level: opts?.level ? normalizeLogLevel(opts.level) : undefined,
});
return {
debug: (message) => logger.debug?.(message),
info: (message) => logger.info(message),
warn: (message) => logger.warn(message),
error: (message) => logger.error(message),
};
},
};
}

View File

@@ -0,0 +1,17 @@
import { isVoiceCompatibleAudio } from "../../media/audio.js";
import { mediaKindFromMime } from "../../media/constants.js";
import { getImageMetadata, resizeToJpeg } from "../../media/image-ops.js";
import { detectMime } from "../../media/mime.js";
import { loadWebMedia } from "../../web/media.js";
import type { PluginRuntime } from "./types.js";
export function createRuntimeMedia(): PluginRuntime["media"] {
return {
loadWebMedia,
detectMime,
mediaKindFromMime,
isVoiceCompatibleAudio,
getImageMetadata,
resizeToJpeg,
};
}

View File

@@ -0,0 +1,14 @@
import { requestHeartbeatNow } from "../../infra/heartbeat-wake.js";
import { enqueueSystemEvent } from "../../infra/system-events.js";
import { runCommandWithTimeout } from "../../process/exec.js";
import { formatNativeDependencyHint } from "./native-deps.js";
import type { PluginRuntime } from "./types.js";
export function createRuntimeSystem(): PluginRuntime["system"] {
return {
enqueueSystemEvent,
requestHeartbeatNow,
runCommandWithTimeout,
formatNativeDependencyHint,
};
}

View File

@@ -0,0 +1,11 @@
import { createMemoryGetTool, createMemorySearchTool } from "../../agents/tools/memory-tool.js";
import { registerMemoryCli } from "../../cli/memory-cli.js";
import type { PluginRuntime } from "./types.js";
export function createRuntimeTools(): PluginRuntime["tools"] {
return {
createMemoryGetTool,
createMemorySearchTool,
registerMemoryCli,
};
}

View File

@@ -0,0 +1,108 @@
import { createWhatsAppLoginTool } from "../../channels/plugins/agent-tools/whatsapp-login.js";
import { getActiveWebListener } from "../../web/active-listener.js";
import {
getWebAuthAgeMs,
logoutWeb,
logWebSelfId,
readWebSelfId,
webAuthExists,
} from "../../web/auth-store.js";
import type { PluginRuntime } from "./types.js";
const sendMessageWhatsAppLazy: PluginRuntime["channel"]["whatsapp"]["sendMessageWhatsApp"] = async (
...args
) => {
const { sendMessageWhatsApp } = await loadWebOutbound();
return sendMessageWhatsApp(...args);
};
const sendPollWhatsAppLazy: PluginRuntime["channel"]["whatsapp"]["sendPollWhatsApp"] = async (
...args
) => {
const { sendPollWhatsApp } = await loadWebOutbound();
return sendPollWhatsApp(...args);
};
const loginWebLazy: PluginRuntime["channel"]["whatsapp"]["loginWeb"] = async (...args) => {
const { loginWeb } = await loadWebLogin();
return loginWeb(...args);
};
const startWebLoginWithQrLazy: PluginRuntime["channel"]["whatsapp"]["startWebLoginWithQr"] = async (
...args
) => {
const { startWebLoginWithQr } = await loadWebLoginQr();
return startWebLoginWithQr(...args);
};
const waitForWebLoginLazy: PluginRuntime["channel"]["whatsapp"]["waitForWebLogin"] = async (
...args
) => {
const { waitForWebLogin } = await loadWebLoginQr();
return waitForWebLogin(...args);
};
const monitorWebChannelLazy: PluginRuntime["channel"]["whatsapp"]["monitorWebChannel"] = async (
...args
) => {
const { monitorWebChannel } = await loadWebChannel();
return monitorWebChannel(...args);
};
const handleWhatsAppActionLazy: PluginRuntime["channel"]["whatsapp"]["handleWhatsAppAction"] =
async (...args) => {
const { handleWhatsAppAction } = await loadWhatsAppActions();
return handleWhatsAppAction(...args);
};
let webOutboundPromise: Promise<typeof import("../../web/outbound.js")> | null = null;
let webLoginPromise: Promise<typeof import("../../web/login.js")> | null = null;
let webLoginQrPromise: Promise<typeof import("../../web/login-qr.js")> | null = null;
let webChannelPromise: Promise<typeof import("../../channels/web/index.js")> | null = null;
let whatsappActionsPromise: Promise<
typeof import("../../agents/tools/whatsapp-actions.js")
> | null = null;
function loadWebOutbound() {
webOutboundPromise ??= import("../../web/outbound.js");
return webOutboundPromise;
}
function loadWebLogin() {
webLoginPromise ??= import("../../web/login.js");
return webLoginPromise;
}
function loadWebLoginQr() {
webLoginQrPromise ??= import("../../web/login-qr.js");
return webLoginQrPromise;
}
function loadWebChannel() {
webChannelPromise ??= import("../../channels/web/index.js");
return webChannelPromise;
}
function loadWhatsAppActions() {
whatsappActionsPromise ??= import("../../agents/tools/whatsapp-actions.js");
return whatsappActionsPromise;
}
export function createRuntimeWhatsApp(): PluginRuntime["channel"]["whatsapp"] {
return {
getActiveWebListener,
getWebAuthAgeMs,
logoutWeb,
logWebSelfId,
readWebSelfId,
webAuthExists,
sendMessageWhatsApp: sendMessageWhatsAppLazy,
sendPollWhatsApp: sendPollWhatsAppLazy,
loginWeb: loginWebLazy,
startWebLoginWithQr: startWebLoginWithQrLazy,
waitForWebLogin: waitForWebLoginLazy,
monitorWebChannel: monitorWebChannelLazy,
handleWhatsAppAction: handleWhatsAppActionLazy,
createLoginTool: createWhatsAppLoginTool,
};
}

View File

@@ -150,6 +150,7 @@ describe("security audit", () => {
let fixtureRoot = "";
let caseId = 0;
let channelSecurityRoot = "";
let sharedChannelSecurityStateDir = "";
let sharedCodeSafetyStateDir = "";
let sharedCodeSafetyWorkspaceDir = "";
let sharedExtensionsStateDir = "";
@@ -161,12 +162,24 @@ describe("security audit", () => {
return dir;
};
const createFilesystemAuditFixture = async (label: string) => {
const tmp = await makeTmpDir(label);
const stateDir = path.join(tmp, "state");
await fs.mkdir(stateDir, { recursive: true, mode: 0o700 });
const configPath = path.join(stateDir, "openclaw.json");
await fs.writeFile(configPath, "{}\n", "utf-8");
if (!isWindows) {
await fs.chmod(configPath, 0o600);
}
return { tmp, stateDir, configPath };
};
const withChannelSecurityStateDir = async (fn: (tmp: string) => Promise<void>) => {
const channelSecurityStateDir = path.join(channelSecurityRoot, `state-${caseId++}`);
const credentialsDir = path.join(channelSecurityStateDir, "credentials");
const credentialsDir = path.join(sharedChannelSecurityStateDir, "credentials");
await fs.rm(credentialsDir, { recursive: true, force: true }).catch(() => undefined);
await fs.mkdir(credentialsDir, { recursive: true, mode: 0o700 });
await withEnvAsync({ OPENCLAW_STATE_DIR: channelSecurityStateDir }, () =>
fn(channelSecurityStateDir),
await withEnvAsync({ OPENCLAW_STATE_DIR: sharedChannelSecurityStateDir }, () =>
fn(sharedChannelSecurityStateDir),
);
};
@@ -214,6 +227,11 @@ description: test skill
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-audit-"));
channelSecurityRoot = path.join(fixtureRoot, "channel-security");
await fs.mkdir(channelSecurityRoot, { recursive: true, mode: 0o700 });
sharedChannelSecurityStateDir = path.join(channelSecurityRoot, "state-shared");
await fs.mkdir(path.join(sharedChannelSecurityStateDir, "credentials"), {
recursive: true,
mode: 0o700,
});
const codeSafetyFixture = await createSharedCodeSafetyFixture();
sharedCodeSafetyStateDir = codeSafetyFixture.stateDir;
sharedCodeSafetyWorkspaceDir = codeSafetyFixture.workspaceDir;
@@ -682,12 +700,7 @@ description: test skill
});
it("warns when sandbox browser containers have missing or stale hash labels", async () => {
const tmp = await makeTmpDir("browser-hash-labels");
const stateDir = path.join(tmp, "state");
await fs.mkdir(stateDir, { recursive: true, mode: 0o700 });
const configPath = path.join(stateDir, "openclaw.json");
await fs.writeFile(configPath, "{}\n", "utf-8");
await fs.chmod(configPath, 0o600);
const { stateDir, configPath } = await createFilesystemAuditFixture("browser-hash-labels");
const execDockerRawFn = (async (args: string[]) => {
if (args[0] === "ps") {
@@ -736,12 +749,7 @@ description: test skill
});
it("skips sandbox browser hash label checks when docker inspect is unavailable", async () => {
const tmp = await makeTmpDir("browser-hash-labels-skip");
const stateDir = path.join(tmp, "state");
await fs.mkdir(stateDir, { recursive: true, mode: 0o700 });
const configPath = path.join(stateDir, "openclaw.json");
await fs.writeFile(configPath, "{}\n", "utf-8");
await fs.chmod(configPath, 0o600);
const { stateDir, configPath } = await createFilesystemAuditFixture("browser-hash-labels-skip");
const execDockerRawFn = (async () => {
throw new Error("spawn docker ENOENT");
@@ -761,12 +769,9 @@ description: test skill
});
it("flags sandbox browser containers with non-loopback published ports", async () => {
const tmp = await makeTmpDir("browser-non-loopback-publish");
const stateDir = path.join(tmp, "state");
await fs.mkdir(stateDir, { recursive: true, mode: 0o700 });
const configPath = path.join(stateDir, "openclaw.json");
await fs.writeFile(configPath, "{}\n", "utf-8");
await fs.chmod(configPath, 0o600);
const { stateDir, configPath } = await createFilesystemAuditFixture(
"browser-non-loopback-publish",
);
const execDockerRawFn = (async (args: string[]) => {
if (args[0] === "ps") {

View File

@@ -33,6 +33,13 @@ import { resolveSlackSlashCommandConfig } from "./commands.js";
import { createSlackMonitorContext } from "./context.js";
import { registerSlackMonitorEvents } from "./events.js";
import { createSlackMessageHandler } from "./message-handler.js";
import {
formatUnknownError,
getSocketEmitter,
isNonRecoverableSlackAuthError,
SLACK_SOCKET_RECONNECT_POLICY,
waitForSlackSocketDisconnect,
} from "./reconnect-policy.js";
import { registerSlackMonitorSlashCommands } from "./slash.js";
import type { MonitorSlackOpts } from "./types.js";
@@ -47,113 +54,6 @@ const { App, HTTPReceiver } = slackBolt;
const SLACK_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
const SLACK_WEBHOOK_BODY_TIMEOUT_MS = 30_000;
const SLACK_SOCKET_RECONNECT_POLICY = {
initialMs: 2_000,
maxMs: 30_000,
factor: 1.8,
jitter: 0.25,
maxAttempts: 12,
} as const;
type SlackSocketDisconnectEvent = "disconnect" | "unable_to_socket_mode_start" | "error";
type EmitterLike = {
on: (event: string, listener: (...args: unknown[]) => void) => unknown;
off: (event: string, listener: (...args: unknown[]) => void) => unknown;
};
function getSocketEmitter(app: unknown): EmitterLike | null {
const receiver = (app as { receiver?: unknown }).receiver;
const client =
receiver && typeof receiver === "object"
? (receiver as { client?: unknown }).client
: undefined;
if (!client || typeof client !== "object") {
return null;
}
const on = (client as { on?: unknown }).on;
const off = (client as { off?: unknown }).off;
if (typeof on !== "function" || typeof off !== "function") {
return null;
}
return {
on: (event, listener) =>
(
on as (this: unknown, event: string, listener: (...args: unknown[]) => void) => unknown
).call(client, event, listener),
off: (event, listener) =>
(
off as (this: unknown, event: string, listener: (...args: unknown[]) => void) => unknown
).call(client, event, listener),
};
}
function waitForSlackSocketDisconnect(
app: unknown,
abortSignal?: AbortSignal,
): Promise<{
event: SlackSocketDisconnectEvent;
error?: unknown;
}> {
return new Promise((resolve) => {
const emitter = getSocketEmitter(app);
if (!emitter) {
abortSignal?.addEventListener("abort", () => resolve({ event: "disconnect" }), {
once: true,
});
return;
}
const disconnectListener = () => resolveOnce({ event: "disconnect" });
const startFailListener = (error?: unknown) =>
resolveOnce({ event: "unable_to_socket_mode_start", error });
const errorListener = (error: unknown) => resolveOnce({ event: "error", error });
const abortListener = () => resolveOnce({ event: "disconnect" });
const cleanup = () => {
emitter.off("disconnected", disconnectListener);
emitter.off("unable_to_socket_mode_start", startFailListener);
emitter.off("error", errorListener);
abortSignal?.removeEventListener("abort", abortListener);
};
const resolveOnce = (value: { event: SlackSocketDisconnectEvent; error?: unknown }) => {
cleanup();
resolve(value);
};
emitter.on("disconnected", disconnectListener);
emitter.on("unable_to_socket_mode_start", startFailListener);
emitter.on("error", errorListener);
abortSignal?.addEventListener("abort", abortListener, { once: true });
});
}
/**
* Detect non-recoverable Slack API / auth errors that should NOT be retried.
* These indicate permanent credential problems (revoked bot, deactivated account, etc.)
* and retrying will never succeed — continuing to retry blocks the entire gateway.
*/
export function isNonRecoverableSlackAuthError(error: unknown): boolean {
const msg = error instanceof Error ? error.message : typeof error === "string" ? error : "";
return /account_inactive|invalid_auth|token_revoked|token_expired|not_authed|org_login_required|team_access_not_granted|missing_scope|cannot_find_service|invalid_token/i.test(
msg,
);
}
function formatUnknownError(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
if (typeof error === "string") {
return error;
}
try {
return JSON.stringify(error);
} catch {
return "unknown error";
}
}
function parseApiAppIdFromAppToken(raw?: string) {
const token = raw?.trim();
@@ -572,6 +472,8 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
}
}
export { isNonRecoverableSlackAuthError } from "./reconnect-policy.js";
export const __testing = {
resolveSlackRuntimeGroupPolicy: resolveOpenProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,

View File

@@ -0,0 +1,108 @@
const SLACK_AUTH_ERROR_RE =
/account_inactive|invalid_auth|token_revoked|token_expired|not_authed|org_login_required|team_access_not_granted|missing_scope|cannot_find_service|invalid_token/i;
export const SLACK_SOCKET_RECONNECT_POLICY = {
initialMs: 2_000,
maxMs: 30_000,
factor: 1.8,
jitter: 0.25,
maxAttempts: 12,
} as const;
export type SlackSocketDisconnectEvent = "disconnect" | "unable_to_socket_mode_start" | "error";
type EmitterLike = {
on: (event: string, listener: (...args: unknown[]) => void) => unknown;
off: (event: string, listener: (...args: unknown[]) => void) => unknown;
};
export function getSocketEmitter(app: unknown): EmitterLike | null {
const receiver = (app as { receiver?: unknown }).receiver;
const client =
receiver && typeof receiver === "object"
? (receiver as { client?: unknown }).client
: undefined;
if (!client || typeof client !== "object") {
return null;
}
const on = (client as { on?: unknown }).on;
const off = (client as { off?: unknown }).off;
if (typeof on !== "function" || typeof off !== "function") {
return null;
}
return {
on: (event, listener) =>
(
on as (this: unknown, event: string, listener: (...args: unknown[]) => void) => unknown
).call(client, event, listener),
off: (event, listener) =>
(
off as (this: unknown, event: string, listener: (...args: unknown[]) => void) => unknown
).call(client, event, listener),
};
}
export function waitForSlackSocketDisconnect(
app: unknown,
abortSignal?: AbortSignal,
): Promise<{
event: SlackSocketDisconnectEvent;
error?: unknown;
}> {
return new Promise((resolve) => {
const emitter = getSocketEmitter(app);
if (!emitter) {
abortSignal?.addEventListener("abort", () => resolve({ event: "disconnect" }), {
once: true,
});
return;
}
const disconnectListener = () => resolveOnce({ event: "disconnect" });
const startFailListener = (error?: unknown) =>
resolveOnce({ event: "unable_to_socket_mode_start", error });
const errorListener = (error: unknown) => resolveOnce({ event: "error", error });
const abortListener = () => resolveOnce({ event: "disconnect" });
const cleanup = () => {
emitter.off("disconnected", disconnectListener);
emitter.off("unable_to_socket_mode_start", startFailListener);
emitter.off("error", errorListener);
abortSignal?.removeEventListener("abort", abortListener);
};
const resolveOnce = (value: { event: SlackSocketDisconnectEvent; error?: unknown }) => {
cleanup();
resolve(value);
};
emitter.on("disconnected", disconnectListener);
emitter.on("unable_to_socket_mode_start", startFailListener);
emitter.on("error", errorListener);
abortSignal?.addEventListener("abort", abortListener, { once: true });
});
}
/**
* Detect non-recoverable Slack API / auth errors that should NOT be retried.
* These indicate permanent credential problems (revoked bot, deactivated account, etc.)
* and retrying will never succeed — continuing to retry blocks the entire gateway.
*/
export function isNonRecoverableSlackAuthError(error: unknown): boolean {
const msg = error instanceof Error ? error.message : typeof error === "string" ? error : "";
return SLACK_AUTH_ERROR_RE.test(msg);
}
export function formatUnknownError(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
if (typeof error === "string") {
return error;
}
try {
return JSON.stringify(error);
} catch {
return "unknown error";
}
}

View File

@@ -588,7 +588,10 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(draftStream.stop).toHaveBeenCalled();
});
it("disables block streaming when streamMode is off", async () => {
it.each([
{ label: "default account config", telegramCfg: {} },
{ label: "account blockStreaming override", telegramCfg: { blockStreaming: true } },
])("disables block streaming when streamMode is off ($label)", async ({ telegramCfg }) => {
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
await dispatcherOptions.deliver({ text: "Hello" }, { kind: "final" });
return { queuedFinal: true };
@@ -598,6 +601,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
await dispatchWithContext({
context: createContext(),
streamMode: "off",
telegramCfg,
});
expect(createTelegramDraftStream).not.toHaveBeenCalled();
@@ -610,69 +614,27 @@ describe("dispatchTelegramMessage draft streaming", () => {
);
});
it("disables block streaming when streamMode is off even if blockStreaming config is true", async () => {
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
await dispatcherOptions.deliver({ text: "Hello" }, { kind: "final" });
return { queuedFinal: true };
});
deliverReplies.mockResolvedValue({ delivered: true });
it.each(["block", "partial"] as const)(
"forces new message when assistant message restarts (%s mode)",
async (streamMode) => {
const draftStream = createDraftStream(999);
createTelegramDraftStream.mockReturnValue(draftStream);
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
async ({ dispatcherOptions, replyOptions }) => {
await replyOptions?.onPartialReply?.({ text: "First response" });
await replyOptions?.onAssistantMessageStart?.();
await replyOptions?.onPartialReply?.({ text: "After tool call" });
await dispatcherOptions.deliver({ text: "After tool call" }, { kind: "final" });
return { queuedFinal: true };
},
);
deliverReplies.mockResolvedValue({ delivered: true });
await dispatchWithContext({
context: createContext(),
streamMode: "off",
telegramCfg: { blockStreaming: true },
});
await dispatchWithContext({ context: createContext(), streamMode });
expect(createTelegramDraftStream).not.toHaveBeenCalled();
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledWith(
expect.objectContaining({
replyOptions: expect.objectContaining({
disableBlockStreaming: true,
}),
}),
);
});
it("forces new message for next assistant block in legacy block stream mode", async () => {
const draftStream = createDraftStream(999);
createTelegramDraftStream.mockReturnValue(draftStream);
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
async ({ dispatcherOptions, replyOptions }) => {
// First assistant message: partial text
await replyOptions?.onPartialReply?.({ text: "First response" });
// New assistant message starts (e.g., after tool call)
await replyOptions?.onAssistantMessageStart?.();
// Second assistant message: new text
await replyOptions?.onPartialReply?.({ text: "After tool call" });
await dispatcherOptions.deliver({ text: "After tool call" }, { kind: "final" });
return { queuedFinal: true };
},
);
deliverReplies.mockResolvedValue({ delivered: true });
await dispatchWithContext({ context: createContext(), streamMode: "block" });
expect(draftStream.forceNewMessage).toHaveBeenCalledTimes(1);
});
it("forces new message in partial mode when assistant message restarts", async () => {
const draftStream = createDraftStream(999);
createTelegramDraftStream.mockReturnValue(draftStream);
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
async ({ dispatcherOptions, replyOptions }) => {
await replyOptions?.onPartialReply?.({ text: "First response" });
await replyOptions?.onAssistantMessageStart?.();
await replyOptions?.onPartialReply?.({ text: "After tool call" });
await dispatcherOptions.deliver({ text: "After tool call" }, { kind: "final" });
return { queuedFinal: true };
},
);
deliverReplies.mockResolvedValue({ delivered: true });
await dispatchWithContext({ context: createContext(), streamMode: "partial" });
expect(draftStream.forceNewMessage).toHaveBeenCalledTimes(1);
});
expect(draftStream.forceNewMessage).toHaveBeenCalledTimes(1);
},
);
it("does not force new message on first assistant message start", async () => {
const draftStream = createDraftStream(999);
@@ -1076,7 +1038,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
it.each([undefined, null] as const)(
"skips outbound send when final payload text is %s and has no media",
async (emptyText) => {
setupDraftStreams({ answerMessageId: 999 });
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 999 });
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
await dispatcherOptions.deliver(
{ text: emptyText as unknown as string },
@@ -1090,6 +1052,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(deliverReplies).not.toHaveBeenCalled();
expect(editMessageTelegram).not.toHaveBeenCalled();
expect(answerDraftStream.clear).toHaveBeenCalledTimes(1);
},
);
@@ -1595,21 +1558,6 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(draftStream.clear).toHaveBeenCalledTimes(1);
});
it("skips final payload when text is undefined", async () => {
const draftStream = createDraftStream(999);
createTelegramDraftStream.mockReturnValue(draftStream);
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
await dispatcherOptions.deliver({ text: undefined as unknown as string }, { kind: "final" });
return { queuedFinal: true };
});
deliverReplies.mockResolvedValue({ delivered: true });
await dispatchWithContext({ context: createContext() });
expect(deliverReplies).not.toHaveBeenCalled();
expect(draftStream.clear).toHaveBeenCalledTimes(1);
});
it("falls back when all finals are skipped and clears preview", async () => {
const draftStream = createDraftStream(999);
createTelegramDraftStream.mockReturnValue(draftStream);

View File

@@ -5,6 +5,7 @@ import type { Chat, Message } from "@grammyjs/types";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js";
import { withEnvAsync } from "../test-utils/env.js";
import { useFrozenTime, useRealTime } from "../test-utils/frozen-time.js";
import {
answerCallbackQuerySpy,
botCtorSpy,
@@ -123,97 +124,87 @@ describe("createTelegramBot", () => {
expect(sequentializeSpy).toHaveBeenCalledTimes(1);
expect(middlewareUseSpy).toHaveBeenCalledWith(sequentializeSpy.mock.results[0]?.value);
expect(sequentializeKey).toBe(getTelegramSequentialKey);
expect(
getTelegramSequentialKey({ message: mockMessage({ chat: mockChat({ id: 123 }) }) }),
).toBe("telegram:123");
expect(
getTelegramSequentialKey({
message: mockMessage({
chat: mockChat({ id: 123, type: "private" }),
message_thread_id: 9,
}),
}),
).toBe("telegram:123:topic:9");
expect(
getTelegramSequentialKey({
message: mockMessage({
chat: mockChat({ id: 123, type: "supergroup" }),
message_thread_id: 9,
}),
}),
).toBe("telegram:123");
expect(
getTelegramSequentialKey({
message: mockMessage({ chat: mockChat({ id: 123, type: "supergroup", is_forum: true }) }),
}),
).toBe("telegram:123:topic:1");
expect(
getTelegramSequentialKey({
update: { message: mockMessage({ chat: mockChat({ id: 555 }) }) },
}),
).toBe("telegram:555");
expect(
getTelegramSequentialKey({
channelPost: mockMessage({ chat: mockChat({ id: -100777111222, type: "channel" }) }),
}),
).toBe("telegram:-100777111222");
expect(
getTelegramSequentialKey({
update: {
channel_post: mockMessage({ chat: mockChat({ id: -100777111223, type: "channel" }) }),
const cases = [
[{ message: mockMessage({ chat: mockChat({ id: 123 }) }) }, "telegram:123"],
[
{
message: mockMessage({
chat: mockChat({ id: 123, type: "private" }),
message_thread_id: 9,
}),
},
}),
).toBe("telegram:-100777111223");
expect(
getTelegramSequentialKey({
message: mockMessage({ chat: mockChat({ id: 123 }), text: "/stop" }),
}),
).toBe("telegram:123:control");
expect(
getTelegramSequentialKey({
message: mockMessage({ chat: mockChat({ id: 123 }), text: "/status" }),
}),
).toBe("telegram:123");
expect(
getTelegramSequentialKey({
message: mockMessage({ chat: mockChat({ id: 123 }), text: "stop" }),
}),
).toBe("telegram:123:control");
expect(
getTelegramSequentialKey({
message: mockMessage({ chat: mockChat({ id: 123 }), text: "stop please" }),
}),
).toBe("telegram:123:control");
expect(
getTelegramSequentialKey({
message: mockMessage({ chat: mockChat({ id: 123 }), text: "do not do that" }),
}),
).toBe("telegram:123:control");
expect(
getTelegramSequentialKey({
message: mockMessage({ chat: mockChat({ id: 123 }), text: "остановись" }),
}),
).toBe("telegram:123:control");
expect(
getTelegramSequentialKey({
message: mockMessage({ chat: mockChat({ id: 123 }), text: "halt" }),
}),
).toBe("telegram:123:control");
expect(
getTelegramSequentialKey({
message: mockMessage({ chat: mockChat({ id: 123 }), text: "/abort" }),
}),
).toBe("telegram:123");
expect(
getTelegramSequentialKey({
message: mockMessage({ chat: mockChat({ id: 123 }), text: "/abort now" }),
}),
).toBe("telegram:123");
expect(
getTelegramSequentialKey({
message: mockMessage({ chat: mockChat({ id: 123 }), text: "please do not do that" }),
}),
).toBe("telegram:123");
"telegram:123:topic:9",
],
[
{
message: mockMessage({
chat: mockChat({ id: 123, type: "supergroup" }),
message_thread_id: 9,
}),
},
"telegram:123",
],
[
{
message: mockMessage({
chat: mockChat({ id: 123, type: "supergroup", is_forum: true }),
}),
},
"telegram:123:topic:1",
],
[{ update: { message: mockMessage({ chat: mockChat({ id: 555 }) }) } }, "telegram:555"],
[
{
channelPost: mockMessage({ chat: mockChat({ id: -100777111222, type: "channel" }) }),
},
"telegram:-100777111222",
],
[
{
update: {
channel_post: mockMessage({ chat: mockChat({ id: -100777111223, type: "channel" }) }),
},
},
"telegram:-100777111223",
],
[
{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "/stop" }) },
"telegram:123:control",
],
[{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "/status" }) }, "telegram:123"],
[
{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "stop" }) },
"telegram:123:control",
],
[
{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "stop please" }) },
"telegram:123:control",
],
[
{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "do not do that" }) },
"telegram:123:control",
],
[
{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "остановись" }) },
"telegram:123:control",
],
[
{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "halt" }) },
"telegram:123:control",
],
[{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "/abort" }) }, "telegram:123"],
[
{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "/abort now" }) },
"telegram:123",
],
[
{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "please do not do that" }) },
"telegram:123",
],
] as const;
for (const [input, expected] of cases) {
expect(getTelegramSequentialKey(input)).toBe(expected);
}
});
it("routes callback_query payloads as messages and answers callbacks", async () => {
createTelegramBot({ token: "tok" });
@@ -2031,7 +2022,7 @@ describe("createTelegramBot", () => {
},
});
vi.useFakeTimers();
useFrozenTime("2026-02-20T00:00:00.000Z");
try {
createTelegramBot({ token: "tok", testTimings: TELEGRAM_TEST_TIMINGS });
const handler = getOnHandler("channel_post") as (
@@ -2071,7 +2062,7 @@ describe("createTelegramBot", () => {
expect(payload.RawBody).toContain(part1.slice(0, 32));
expect(payload.RawBody).toContain(part2.slice(0, 32));
} finally {
vi.useRealTimers();
useRealTime();
}
});
it("drops oversized channel_post media instead of dispatching a placeholder message", async () => {

View File

@@ -1895,8 +1895,6 @@
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--secondary);
overflow-wrap: normal;
word-break: keep-all;
}
:root[data-theme="light"] .chat-text :where(:not(pre) > code) {