Compare commits

..

12 Commits

Author SHA1 Message Date
weiqinl
552ec2b49d fix(opencode-go): re-arm idle timer on block-boundary events to prevent false stalled-stream abort (#97128)
* fix(opencode-go): re-arm idle timer on block-boundary events to prevent false stalled-stream abort

When the opencode-go model finalizes a tool call and deliberates before
the next one, the provider emits real block-boundary SSE events
(text_end, thinking_end, toolcall_start, toolcall_end) that prove the
socket is alive, but the watchdog's isProviderProgressEvent only
returned true for token deltas (text_delta, thinking_delta,
toolcall_delta). This caused the idle timer to fire and falsely abort a
live stream, replacing a completed answer with a stalled error and
dropping the provider's real done event.

Fix: include block-boundary events in isProviderProgressEvent so the
idle timer is re-armed on any forward-progress provider event.
text_start and thinking_start are intentionally excluded because they
are synthetic preamble events that should not shorten the first-event
window.

Closes #96518

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(opencode-go): satisfy lint in stream regression

* test(opencode-go): satisfy lint in stream regression

* test(opencode-go): satisfy lint in stream regression

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-27 09:48:42 +08:00
Gio Della-Libera
4d0f19a968 test(policy): add config coverage report (#87081)
Merged via squash.

Prepared head SHA: 689734541b
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Reviewed-by: @giodl73-repo
2026-06-26 18:28:35 -07:00
Peter Steinberger
072d3ed7b5 fix(telegram): retain socket failure context (#97130)
Co-authored-by: zhang-guiping <zhang.guiping@xydigit.com>
2026-06-27 02:11:36 +01:00
wangmiao0668000666
1bccd29304 fix(provider-transport-fetch): bound SSE buffer to prevent OOM (#96989)
* fix(provider-transport-fetch): bound SSE buffer to prevent OOM

* fix(provider-transport-fetch): appease oxlint curly rule in test

* fix(provider-transport-fetch): drain events before cap + cancel reader on overflow

* fix(provider-transport-fetch): remove unused encoder from coalesced chunk test

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(transport): tighten SSE buffer guards

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-27 08:54:09 +08:00
pick-cat
498567190d fix(agents): guard delivery-evidence attachment recursion against cycles (#97041)
* fix(agents): guard delivery-evidence attachment recursion against cycles

* fix(agents): guard delivery-evidence attachment recursion against cycles

* fix(agents): guard delivery-evidence attachment recursion against cycles

---------

Co-authored-by: Pick-cat <266665499+Pick-cat@users.noreply.github.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-27 08:52:46 +08:00
wangmiao0668000666
5880e0afc4 fix(openai-chatgpt-responses): bound streaming success-body SSE reads at 16 MiB (#96762)
* fix(openai-chatgpt-responses): bound streaming success-body SSE reads at 16 MiB

Apply createSseByteGuard to src/llm/providers/openai-chatgpt-responses.ts
(parseSSE) so the ChatGPT Responses SSE parser cannot be exhausted by a
hostile or malfunctioning endpoint that streams an unbounded SSE body.
The 16 MiB cap matches the non-streaming readProviderJsonResponse cap.

What changed:
- src/llm/providers/openai-chatgpt-responses.ts: wrap body.getReader() in
  createSseByteGuard before the existing parseSSE loop. The function is
  also re-exported as parseSSEForTest for direct test access.
- Inline test added to openai-chatgpt-responses.test.ts: hostile 1 MiB
  pull stream, asserts canonical overflow message, cancel(reason) was an
  Error instance, pullCount bounded to 17-20.

Reuses the createSseByteGuard helper from src/agents/streaming-byte-guard.ts
(introduced in #96701 for the anthropic-transport-stream path; same
helper, same 16 MiB cap, same overflow-error shape). This PR is the
third of the planned per-surface rescue series for the previously closed
PR #96666, after PR #3b (#96723).

No SDK surface change. No repro script committed (proof in this body).

* fix(openai): bound ChatGPT error body reads

* fix(openai): bound chatgpt error parsing

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-27 08:35:10 +08:00
mushuiyu886
65fec9d787 fix(voice-call): emit canonical session keys (#89884)
* fix(voice-call): scope generated session keys by agent

* docs(voice-call): document session key canonicalization

* test(voice-call): prove legacy session migration

* fix(voice-call): preserve canonical session ownership

* fix(sessions): isolate opaque nested identities

* fix(voice-call): preserve routing ownership

* fix(voice-call): enforce inbound route direction

* fix(sessions): preserve migration and policy boundaries

* fix(sessions): normalize ambiguous main aliases

* fix(sessions): preserve canonical peer and warning paths

* fix(sessions): exclude mixed-case scoped legacy keys

* fix(sessions): cover first-run plugin migration gaps

* fix(sessions): compare aliased store identities

* fix(sessions): coalesce aliased store ownership

* fix(sessions): defer ambiguous aliased migrations

* fix(sessions): preserve shared migration boundaries

* fix(sessions): preserve opaque peer ownership

* fix(sessions): reject ambiguous ownership shapes

* fix(sessions): preserve transcript rewrite keys

* fix(sessions): close routing and migration ambiguities

* fix(sessions): preserve plugin-owned ACP aliases

* fix(sessions): retain physical store ownership

* fix(sessions): restore configured store owners

* fix(sessions): reject malformed store owners

* fix(sessions): validate ACP store ownership

* fix(sessions): include canonical store owners

* fix(sessions): preserve final store symlinks

* fix(sessions): retain shared row owners

* fix(sessions): close legacy policy gaps

* fix(sessions): preserve aliases across migrations

* fix(sessions): resolve first-run store ownership

* fix(sessions): preserve hostile legacy keys

* fix(sessions): inspect unlisted store owners

* test(doctor): refresh migration harness

* fix(sessions): preserve opaque route segments

* fix(sessions): retain metadata during migration

* fix(sessions): fail closed on store alias uncertainty

* fix(sessions): defer aliased store rewrites

* fix(sessions): retain legacy row owners

* test(sessions): harden migration proof

* fix(sessions): migrate opaque agent keys

* chore(plugin-sdk): refresh API baseline

* fix(voice-call): reuse public routing parser

* fix(sessions): retain readable alias warnings

* fix(sessions): reject opaque nested routes

* fix(sessions): share strict delivery parsing

* test(voice-call): preserve malformed Matrix case

* fix(sessions): reject legacy peer overlap

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-27 01:29:58 +01:00
Bartok
4d9cd7d227 fix(agents): truncate exec command detail on code-point boundaries (#96963)
* fix(agents): truncate exec command detail on code-point boundaries

compactRawCommand in src/agents/tool-display-exec.ts middle-truncated the
one-line command with raw String.prototype.slice. When the head or tail
boundary fell between the two UTF-16 code units of a surrogate pair (e.g. an
emoji like U+1F600), the slice kept a lone surrogate, which renders as the
replacement character in the tool-call summary shown in chat/transcripts.

Use the existing sliceUtf16Safe helper for both ends so the boundary falls on
a code-point boundary, dropping the whole emoji instead of half of it. This is
behavior-preserving for non-surrogate input.

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

* refactor: move surrogate-safe slice helpers to browser-safe module

ClawSweeper flagged that importing sliceUtf16Safe from src/utils.ts into
tool-display-exec.ts pulls node:fs/os/path into the Control UI browser-shared
bundle (ui/vite.config.ts treats tool-display-exec.ts as browser-shared).

Move sliceUtf16Safe/truncateUtf16Safe into a self-contained, dependency-free
module src/shared/text/surrogate-safe-slice.ts. src/utils.ts re-exports them
(zero churn for existing Node-side callers), and tool-display-exec.ts now
imports directly from the node-free module so no Node built-ins can leak into
the browser bundle.

* fix(agents): use shared utf16 helper in exec display

---------

Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-27 08:24:14 +08:00
linhongkuan
12ea61a08d chore(catalog): bump Weixin plugin to 2.4.6 (#96801)
* chore(catalog): bump Weixin plugin to 2.4.6

* chore(catalog): align Weixin host floor

* chore: retrigger PR checks

---------

Co-authored-by: lin-hongkuan <lin-hongkuan@users.noreply.github.com>
2026-06-27 08:12:12 +08:00
Wynne668
4932366b92 fix(cli): keep built-in nodes commands off the plugin load path (#96702)
registerNodesCli unconditionally registered plugin CLI commands, so
lightweight built-in commands like `nodes status`/`nodes list` paid the
full plugin CLI/runtime load cost. Only resolve plugin-provided node
subcommands (e.g. `nodes canvas`) when the invoked subcommand is not
already a built-in, keeping the built-in nodes path fast.

Fixes #96697
2026-06-27 08:11:31 +08:00
wangmiao0668000666
4f3d81b918 fix(googlechat): replace unbounded response.json() with readProviderJsonResponse (#96772)
* fix(googlechat): replace unbounded response.json() with readProviderJsonResponse

Replace the local readGoogleChatJsonResponse and
readGoogleChatCertsResponse wrappers with the existing SDK helper
readProviderJsonResponse (from openclaw/plugin-sdk/provider-http) so the
Google Chat API JSON responses are bounded at 16 MiB, matching the
non-streaming cap already used by 15+ other extensions.

What changed:
- extensions/googlechat/src/api.ts: readGoogleChatJsonResponse now
  delegates to readProviderJsonResponse. Removed the local try/catch
  wrapper.
- extensions/googlechat/src/auth.ts: readGoogleChatCertsResponse now
  delegates to readProviderJsonResponse. Error message preserved.
  Removed the local try/catch wrapper.

This PR applies the same pattern as Alix-007's #96042, #96038 (lmstudio,
provider JSON reads). No SDK promotion needed — readProviderJsonResponse
is already available in openclaw/plugin-sdk/provider-http.

* fix(googlechat): add inline bounded-read regression tests

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(googlechat): remove unused variable flagged by oxlint

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(googlechat): bound api error body reads

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-27 08:11:10 +08:00
Bartok
e09b9dfc1b fix(agents): truncate tool-display detail on code-point boundaries (#96958)
* fix(agents): truncate tool-display detail on code-point boundaries

coerceDisplayValue truncated a long first-line detail value with raw
UTF-16 slice() at half = floor((maxStringChars-1)/2). When an emoji
(surrogate pair) straddles the cut boundary, the head kept a lone high
surrogate and the tail could begin on a lone low surrogate, rendering as
the replacement character. Use the existing sliceUtf16Safe helper so the
whole code point is dropped at the boundary, matching the UTF-16-safe
truncation used elsewhere in the repo. Behavior-preserving for
non-surrogate input.

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

* fix(build): import surrogate-safe slice from node-free leaf module

tool-display-common.ts is in the UI browser bundle graph; importing
sliceUtf16Safe from utils.js dragged node:fs (via infra/fs-safe) into the
bundle and broke build-artifacts/QA Smoke. Extract sliceUtf16Safe/truncateUtf16Safe
into src/shared/utf16-slice.ts (dependency-free) and re-export from utils.js to
preserve the existing import surface.

* fix(build): import surrogate-safe slice from node-free leaf module

* fix(build): import surrogate-safe slice from node-free leaf module

* fix(build): import surrogate-safe slice from node-free leaf module

---------

Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-27 08:10:06 +08:00
78 changed files with 5534 additions and 18511 deletions

View File

@@ -848,28 +848,6 @@ jobs:
path: .local/gateway-watch-regression/
retention-days: 7
native-i18n:
permissions:
contents: read
needs: [preflight]
if: ${{ !cancelled() && always() && (needs.preflight.outputs.run_macos == 'true' || needs.preflight.outputs.run_android == 'true' || needs.preflight.outputs.run_node == 'true') }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ needs.preflight.outputs.checkout_revision }}
persist-credentials: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Check native app i18n inventory
run: pnpm native:i18n:check
checks-fast-core:
permissions:
contents: read

View File

@@ -1,119 +0,0 @@
name: Native App Locale Refresh
on:
push:
branches:
- main
paths:
- apps/android/app/src/main/**
- apps/ios/**
- apps/macos/Sources/**
- apps/macos/Package.swift
- apps/shared/OpenClawKit/Sources/**
- apps/.i18n/native-source.json
- scripts/control-ui-i18n.ts
- scripts/native-app-i18n.ts
- .github/workflows/native-app-locale-refresh.yml
workflow_dispatch:
permissions:
contents: write
concurrency:
group: native-app-locale-refresh-${{ github.event_name == 'push' && github.ref || format('manual-{0}', github.run_id) }}
cancel-in-progress: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
jobs:
refresh:
if: github.repository == 'openclaw/openclaw' && (github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/main') && (github.event_name != 'push' || github.actor != 'github-actions[bot]')
strategy:
fail-fast: false
max-parallel: 2
matrix:
locale:
[
zh-CN,
zh-TW,
pt-BR,
de,
es,
ja-JP,
ko,
fr,
hi,
ar,
it,
tr,
uk,
id,
pl,
th,
vi,
nl,
fa,
ru,
]
runs-on: ubuntu-latest
name: Refresh native ${{ matrix.locale }}
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: true
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Ensure translation provider secrets exist
env:
OPENAI_API_KEY: ${{ secrets.OPENCLAW_DOCS_I18N_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
set -euo pipefail
if [ -z "${OPENAI_API_KEY:-}" ] && [ -z "${ANTHROPIC_API_KEY:-}" ]; then
echo "Missing OPENCLAW_DOCS_I18N_OPENAI_API_KEY, OPENAI_API_KEY, or ANTHROPIC_API_KEY secret."
exit 1
fi
- name: Refresh native locale artifact
env:
OPENAI_API_KEY: ${{ secrets.OPENCLAW_DOCS_I18N_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
OPENCLAW_CONTROL_UI_I18N_PROVIDER: ${{ secrets.ANTHROPIC_API_KEY != '' && 'anthropic' || 'openai' }}
OPENCLAW_CONTROL_UI_I18N_MODEL: ${{ secrets.ANTHROPIC_API_KEY != '' && 'claude-opus-4-8' || vars.OPENCLAW_CI_OPENAI_MODEL_BARE }}
OPENCLAW_CONTROL_UI_I18N_THINKING: low
OPENCLAW_CONTROL_UI_I18N_AUTH_OPTIONAL: "0"
LOCALE: ${{ matrix.locale }}
run: node --import tsx scripts/native-app-i18n.ts sync --write --locale "${LOCALE}"
- name: Commit and push locale artifact
env:
LOCALE: ${{ matrix.locale }}
TARGET_BRANCH: ${{ github.event.repository.default_branch }}
run: |
set -euo pipefail
if ! git status --porcelain -- apps/.i18n/native apps/.i18n/native-source.json | grep -q .; then
echo "No native locale changes for ${LOCALE}."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add -A apps/.i18n/native apps/.i18n/native-source.json
git commit --no-verify -m "chore(i18n): refresh native ${LOCALE} locale"
for attempt in 1 2 3 4 5; do
git fetch origin "${TARGET_BRANCH}"
git rebase --autostash "origin/${TARGET_BRANCH}"
if git push origin HEAD:"${TARGET_BRANCH}"; then
exit 0
fi
echo "Push attempt ${attempt} for ${LOCALE} failed; retrying."
sleep $((attempt * 2))
done
echo "Failed to push ${LOCALE} native locale update after retries."
exit 1

File diff suppressed because it is too large Load Diff

View File

@@ -211,6 +211,18 @@ each carrier call should start with fresh context, for example reception,
booking, IVR, or Google Meet bridge flows where the same phone number may
represent different meetings.
Voice Call stores generated session keys under the configured agent namespace
(`agent:<agentId>:voice:*`) so call memory survives Gateway session-key
canonicalization after restarts. Raw explicit integration keys use the same
agent namespace. A canonical `agent:<configuredAgentId>:*` key keeps that owner,
and its main aliases honor core `session.mainKey` and global scope. Foreign or
malformed `agent:*` input is scoped as an opaque key under the configured agent;
`global` and `unknown` remain global sentinels. Gateway startup promotes older
raw keys in default or `{agentId}`-templated stores where the path proves one
owner. In fixed custom stores, ambiguous legacy rows remain untouched because
they do not contain enough information to choose an owner; new calls use
canonical agent-scoped history.
## Realtime voice conversations
`realtime` selects a full-duplex realtime voice provider for live call

View File

@@ -2,6 +2,10 @@
import crypto from "node:crypto";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { parseMediaContentLength } from "openclaw/plugin-sdk/media-runtime";
import {
readProviderJsonResponse,
readResponseTextLimited,
} from "openclaw/plugin-sdk/provider-http";
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
@@ -13,11 +17,7 @@ const CHAT_API_BASE = "https://chat.googleapis.com/v1";
const CHAT_UPLOAD_BASE = "https://chat.googleapis.com/upload/v1";
async function readGoogleChatJsonResponse<T>(response: Response, label: string): Promise<T> {
try {
return (await response.json()) as T;
} catch (cause) {
throw new Error(`${label}: malformed JSON response`, { cause });
}
return readProviderJsonResponse<T>(response, label);
}
const headersToObject = (headers?: HeadersInit): Record<string, string> =>
@@ -57,7 +57,7 @@ async function withGoogleChatResponse<T>(params: {
});
try {
if (!response.ok) {
const text = await response.text().catch(() => "");
const text = await readResponseTextLimited(response).catch(() => "");
throw new Error(`${errorPrefix} ${response.status}: ${text || response.statusText}`);
}
return await handleResponse(response);

View File

@@ -1,4 +1,5 @@
// Googlechat plugin module implements auth behavior.
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { fetchWithSsrFGuard } from "../runtime-api.js";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
@@ -17,11 +18,10 @@ const CHAT_CERTS_URL =
"https://www.googleapis.com/service_accounts/v1/metadata/x509/chat@system.gserviceaccount.com";
async function readGoogleChatCertsResponse(response: Response): Promise<Record<string, string>> {
try {
return (await response.json()) as Record<string, string>;
} catch (cause) {
throw new Error("Google Chat cert fetch failed: malformed JSON response", { cause });
}
return readProviderJsonResponse<Record<string, string>>(
response,
"Google Chat cert fetch failed",
);
}
// Size-capped to prevent unbounded growth in long-running deployments (#4948)

View File

@@ -568,4 +568,137 @@ describe("verifyGoogleChatRequest", () => {
});
expect(release).toHaveBeenCalledOnce();
});
describe("bounded JSON read (readProviderJsonResponse delegation)", () => {
afterEach(() => {
authTesting.resetGoogleChatAuthForTests();
mocks.fetchWithSsrFGuard.mockClear();
vi.unstubAllGlobals();
});
it("cancels oversized cert fetch JSON body via the 16 MiB provider cap", async () => {
const ONE_MIB = 1024 * 1024;
const TOTAL_CHUNKS = 32;
const chunk = new Uint8Array(ONE_MIB);
let bytesPulled = 0;
let canceled = false;
const oversizedJson = new Response(
new ReadableStream<Uint8Array>({
pull(controller) {
if (bytesPulled >= TOTAL_CHUNKS * ONE_MIB) {
controller.close();
return;
}
bytesPulled += chunk.length;
controller.enqueue(chunk);
},
cancel() {
canceled = true;
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
const release = vi.fn(async () => {});
mocks.fetchWithSsrFGuard.mockResolvedValueOnce({
response: oversizedJson,
release,
});
const result = await verifyGoogleChatRequest({
bearer: "token",
audienceType: "project-number",
audience: "123456789",
});
expect(result.ok).toBe(false);
expect(result.reason).toMatch(/JSON response exceeds 16777216 bytes/);
expect(canceled).toBe(true);
expect(bytesPulled).toBeLessThan(TOTAL_CHUNKS * ONE_MIB);
expect(release).toHaveBeenCalledOnce();
});
it("rejects oversized sendMessage JSON body via the 16 MiB provider cap", async () => {
const ONE_MIB = 1024 * 1024;
const TOTAL_CHUNKS = 32;
const chunk = new Uint8Array(ONE_MIB);
let bytesPulled = 0;
let canceled = false;
const oversizedJson = new Response(
new ReadableStream<Uint8Array>({
pull(controller) {
if (bytesPulled >= TOTAL_CHUNKS * ONE_MIB) {
controller.close();
return;
}
bytesPulled += chunk.length;
controller.enqueue(chunk);
},
cancel() {
canceled = true;
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
const release = vi.fn(async () => {});
mocks.fetchWithSsrFGuard.mockResolvedValueOnce({
response: oversizedJson,
release,
});
await expect(
sendGoogleChatMessage({
account,
space: "spaces/AAA",
text: "hello",
}),
).rejects.toThrow(/Google Chat API request failed: JSON response exceeds 16777216 bytes/);
expect(canceled).toBe(true);
expect(bytesPulled).toBeLessThan(TOTAL_CHUNKS * ONE_MIB);
});
it("caps non-OK sendMessage error bodies before formatting the API error", async () => {
const ONE_MIB = 1024 * 1024;
const TOTAL_CHUNKS = 32;
const chunk = new TextEncoder().encode("x".repeat(ONE_MIB));
let bytesPulled = 0;
let canceled = false;
const oversizedError = new Response(
new ReadableStream<Uint8Array>({
pull(controller) {
if (bytesPulled >= TOTAL_CHUNKS * ONE_MIB) {
controller.close();
return;
}
bytesPulled += chunk.length;
controller.enqueue(chunk);
},
cancel() {
canceled = true;
},
}),
{ status: 500, statusText: "Internal Server Error" },
);
const release = vi.fn(async () => {});
mocks.fetchWithSsrFGuard.mockResolvedValueOnce({
response: oversizedError,
release,
});
await expect(
sendGoogleChatMessage({
account,
space: "spaces/AAA",
text: "hello",
}),
).rejects.toThrow(/^Google Chat API 500: x+/);
expect(canceled).toBe(true);
expect(bytesPulled).toBeLessThan(TOTAL_CHUNKS * ONE_MIB);
expect(release).toHaveBeenCalledOnce();
});
});
});

View File

@@ -713,4 +713,100 @@ describe("createOpencodeGoStalledStreamWrapper", () => {
controller.end();
await consumer;
});
it("must NOT abort a live stream that keeps emitting block-boundary events between deltas", async () => {
// Regression for https://github.com/openclaw/openclaw/issues/96518:
// the idle timer must re-arm on block-boundary events (text_end,
// thinking_end, toolcall_start, toolcall_end), not only on token
// deltas. A stream that keeps producing boundary events between
// deltas is demonstrably alive and must not be aborted.
const { stream: baseStream, controller } = createFakeBaseStream();
let abortCalled = false;
const underlying = vi.fn((_model, _context, options) => {
if (options?.signal) {
options.signal.addEventListener("abort", () => {
abortCalled = true;
});
}
return baseStream;
});
const idleTimeoutMs = 5_000;
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
provider: "opencode-go",
idleTimeoutMs,
});
const downstream = await Promise.resolve(
wrapper({ provider: "opencode-go", id: "glm-4.6" } as any, {} as any, {} as any),
);
expect(downstream).toBeDefined();
if (!downstream) {
return;
}
const received: AnyEvent[] = [];
const consumer = (async () => {
for await (const event of downstream) {
received.push(event);
}
})();
const partial = { role: "assistant", content: [{ type: "text", text: "x" }] };
// Provider starts producing a tool-call turn. The last *delta* arms the idle timer.
controller.emit({ type: "start", partial } as any);
controller.emit({
type: "toolcall_delta",
contentIndex: 0,
delta: "{",
partial,
} as any);
await vi.advanceTimersByTimeAsync(0);
// The model finalizes the tool call and deliberates on the next one,
// emitting real block-boundary events that prove the SSE socket is alive.
// Each gap is < idleTimeoutMs, so a liveness-aware watchdog must stay armed.
await vi.advanceTimersByTimeAsync(3_000);
controller.emit({
type: "toolcall_end",
contentIndex: 0,
toolCall: { name: "f", arguments: "{}" },
partial,
} as any);
await vi.advanceTimersByTimeAsync(3_000);
controller.emit({
type: "toolcall_start",
contentIndex: 1,
partial,
} as any);
// Advance to 5s after the last delta, but only 2s after the last
// boundary event. The idle timer should have been re-armed by the
// boundary events, so it must NOT fire yet.
await vi.advanceTimersByTimeAsync(1_000);
// The provider's completed answer arrives right after.
controller.emit({
type: "done",
reason: "stop",
message: {
...partial,
content: [{ type: "text", text: "final answer" }],
stopReason: "stop",
},
} as any);
controller.end();
await vi.advanceTimersByTimeAsync(0);
await consumer;
const hasDone = received.some((e) => e.type === "done");
const hasStalledError = received.some(
(e) => e.type === "error" && (e as any).error?.stopReason === "error",
);
expect(abortCalled).toBe(false);
expect(hasDone).toBe(true);
expect(hasStalledError).toBe(false);
});
});

View File

@@ -55,7 +55,11 @@ function isProviderProgressEvent(event: AssistantMessageEvent): boolean {
return (
event.type === "text_delta" ||
event.type === "thinking_delta" ||
event.type === "toolcall_delta"
event.type === "toolcall_delta" ||
event.type === "text_end" ||
event.type === "thinking_end" ||
event.type === "toolcall_start" ||
event.type === "toolcall_end"
);
}

View File

@@ -957,7 +957,7 @@ describe("resolveTelegramFetch", () => {
expect(eighthDispatcher).toBe(firstDispatcher);
expect(ninthDispatcher).toBe(firstDispatcher);
expectPinnedFallbackIpDispatcher(3);
expectLoggerMessageContaining(loggerWarn, "fetch fallback: DNS-resolved IP unreachable");
expectLoggerMessageContaining(loggerWarn, "fetch fallback: primary connection path failed");
expectLoggerMessageContaining(
loggerDebug,
"fetch fallback: recovered from attempt 2 to attempt 0",
@@ -1193,6 +1193,31 @@ describe("resolveTelegramFetch", () => {
expect(undiciFetch).toHaveBeenCalledTimes(1);
});
it("does not automatically retry structured EADDRNOTAVAIL fetch failures", async () => {
const fetchError = buildFetchFallbackError("EADDRNOTAVAIL");
undiciFetch.mockRejectedValue(fetchError);
const resolved = resolveTelegramFetchOrThrow(undefined, STICKY_IPV4_FALLBACK_NETWORK);
await expect(resolved("https://api.telegram.org/botx/sendMessage")).rejects.toThrow(
"fetch failed",
);
expect(undiciFetch).toHaveBeenCalledTimes(1);
});
it("preserves EADDRNOTAVAIL in forced fallback diagnostics", () => {
const transport = resolveTelegramTransport(undefined, STICKY_IPV4_FALLBACK_NETWORK);
const fetchError = buildFetchFallbackError("EADDRNOTAVAIL");
expect(transport.forceFallback?.("probe timeout/network error", fetchError)).toBe(true);
expect(transport.forceFallback?.("probe timeout/network error", fetchError)).toBe(true);
expectLoggerMessageContaining(loggerWarn, "primary connection path failed");
expectLoggerMessageContaining(loggerWarn, "codes=EADDRNOTAVAIL");
expectNoLoggerMessageContaining(loggerWarn, "DNS-resolved IP unreachable");
});
it("retries sticky fallback when the local network is down during connect", async () => {
undiciFetch
.mockRejectedValueOnce(buildFetchFallbackError("ENETDOWN"))

View File

@@ -488,9 +488,10 @@ export type TelegramTransport = {
dispatcherAttempts?: TelegramDispatcherAttempt[];
/**
* Promote this transport to its next fallback dispatcher before the next
* request. Returns false when no fallback path exists.
* request. The original error, when available, is retained in diagnostics.
* Returns false when no fallback path exists.
*/
forceFallback?: (reason: string) => boolean;
forceFallback?: (reason: string, err?: unknown) => boolean;
/**
* Release all dispatchers owned by this transport and the TCP sockets they
* hold. Safe to call multiple times; subsequent calls resolve immediately.
@@ -563,7 +564,8 @@ function createTelegramTransportAttempts(params: {
},
exportAttempt: { dispatcherPolicy: fallbackIpPolicy },
logLevel: "warn",
logMessage: "fetch fallback: DNS-resolved IP unreachable; trying alternative Telegram API IP",
logMessage:
"fetch fallback: primary connection path failed; trying alternative Telegram API IP",
});
return attempts;
@@ -864,8 +866,8 @@ export function resolveTelegramTransport(
fetch: resolvedFetch,
sourceFetch,
dispatcherAttempts: transportAttempts.map((attempt) => attempt.exportAttempt),
forceFallback: (reason: string) =>
promoteStickyAttempt(stickyAttemptIndex + 1, new Error("forced fallback"), reason),
forceFallback: (reason: string, err?: unknown) =>
promoteStickyAttempt(stickyAttemptIndex + 1, err ?? new Error("forced fallback"), reason),
close,
};
}

View File

@@ -362,7 +362,7 @@ describe("probeTelegram retry logic", () => {
const result = await probePromise;
expect(result.ok).toBe(true);
expect(localForceFallback).toHaveBeenCalledWith("probe timeout/network error");
expect(localForceFallback).toHaveBeenCalledWith("probe timeout/network error", timeoutError);
expect(fetchMock).toHaveBeenCalledTimes(3); // 1 failed + 1 getMe success + 1 webhook
} finally {
vi.useRealTimers();

View File

@@ -162,7 +162,8 @@ export async function probeTelegram(
// On timeout or network error, promote the transport to its IPv4
// fallback dispatcher so the next retry (and all future probes
// sharing this cached transport) skip the stalled IPv6 path.
transport.forceFallback?.("probe timeout/network error");
// Keep the original socket code in transport fallback diagnostics.
transport.forceFallback?.("probe timeout/network error", err);
if (i < 2) {
const remainingAfterAttemptMs = resolveRemainingBudgetMs();
if (remainingAfterAttemptMs <= 0) {

View File

@@ -12,7 +12,7 @@ import type {
PluginDoctorStateMigrationContext,
} from "openclaw/plugin-sdk/runtime-doctor";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { stateMigrations } from "./doctor-contract-api.js";
import { resolveSessionStoreAgentIds, stateMigrations } from "./doctor-contract-api.js";
import {
createTestStorePath,
makePersistedCall,
@@ -68,6 +68,42 @@ describe("voice-call doctor state migration", () => {
await fs.rm(storePath, { recursive: true, force: true });
});
it("reports top-level and per-number session-store agents", () => {
expect(
resolveSessionStoreAgentIds({
cfg: {
plugins: {
entries: {
"voice-call": {
config: {
agentId: "Voice",
numbers: {
"+15550001111": { agentId: "Cards" },
"+15550002222": {},
},
},
},
},
},
},
}),
).toEqual(["cards", "voice"]);
expect(
resolveSessionStoreAgentIds({
cfg: {
plugins: { entries: { "@openclaw/voice-call": { config: {} } } },
},
}),
).toEqual(["main"]);
expect(
resolveSessionStoreAgentIds({
cfg: {
plugins: { entries: { "voice-call": { enabled: true } } },
},
}),
).toEqual(["main"]);
});
it("imports legacy calls.jsonl into plugin state", async () => {
const sourcePath = path.join(storePath, "calls.jsonl");
const call = makePersistedCall({

View File

@@ -2,6 +2,8 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry";
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
import type {
PluginDoctorStateMigration,
PluginStateKeyedStore,
@@ -81,6 +83,36 @@ type PluginDoctorStateMigrationParams = Parameters<
PluginDoctorStateMigration["detectLegacyState"]
>[0];
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
/** Return Voice Call agents whose templated core session stores need migration. */
export function resolveSessionStoreAgentIds(params: { cfg: OpenClawConfig }): string[] {
const agentIds = new Set<string>();
for (const pluginId of ["voice-call", "@openclaw/voice-call"]) {
const entry = params.cfg.plugins?.entries?.[pluginId];
if (!entry) {
continue;
}
const config = entry.config === undefined ? {} : asRecord(entry.config);
if (!config) {
continue;
}
agentIds.add(normalizeAgentId(typeof config.agentId === "string" ? config.agentId : undefined));
const numbers = asRecord(config.numbers);
for (const route of Object.values(numbers ?? {})) {
const agentId = asRecord(route)?.agentId;
if (typeof agentId === "string") {
agentIds.add(normalizeAgentId(agentId));
}
}
}
return [...agentIds].toSorted();
}
/** Resolve the voice-call store path used by legacy and plugin-state call records. */
function resolveVoiceCallStorePath(params: {
config: PluginDoctorStateMigrationParams["config"];

View File

@@ -2,9 +2,11 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
VoiceCallConfigSchema,
resolveVoiceCallAgentSessionKey,
resolveTwilioAuthToken,
resolveVoiceCallEffectiveConfig,
resolveVoiceCallNumberRouteKey,
resolveVoiceCallNumberRouteKeyForCall,
resolveVoiceCallSessionKey,
validateProviderConfig,
normalizeVoiceCallConfig,
@@ -296,7 +298,23 @@ describe("resolveVoiceCallConfig session routing", () => {
callId: "call-123",
phone: "+1 (555) 000-1111",
}),
).toBe("voice:15550001111");
).toBe("agent:main:voice:15550001111");
});
it("scopes generated voice session keys by configured agent", () => {
const config = resolveVoiceCallConfig({
enabled: true,
provider: "mock",
agentId: "Voice",
});
expect(
resolveVoiceCallSessionKey({
config,
callId: "CALL-123",
phone: "+1 (555) 000-1111",
}),
).toBe("agent:voice:voice:15550001111");
});
it("can scope voice sessions to each call", () => {
@@ -313,10 +331,10 @@ describe("resolveVoiceCallConfig session routing", () => {
callId: "call-123",
phone: "+1 (555) 000-1111",
}),
).toBe("voice:call:call-123");
).toBe("agent:main:voice:call:call-123");
});
it("preserves explicit voice session keys", () => {
it("scopes explicit voice session keys by configured agent", () => {
const config = resolveVoiceCallConfig({
enabled: true,
provider: "mock",
@@ -328,9 +346,135 @@ describe("resolveVoiceCallConfig session routing", () => {
config,
callId: "call-123",
phone: "+1 (555) 000-1111",
explicitSessionKey: "meet-room-1",
explicitSessionKey: "Meet-Room-1",
}),
).toBe("meet-room-1");
).toBe("agent:main:meet-room-1");
});
it("scopes persisted and explicit keys at the agent session boundary", () => {
const config = resolveVoiceCallConfig({
enabled: true,
provider: "mock",
agentId: "Voice",
});
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey: "voice:call:legacy-call",
}),
).toBe("agent:voice:voice:call:legacy-call");
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey: "meet-room-1",
}),
).toBe("agent:voice:meet-room-1");
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey: "agent:main:shared-room",
}),
).toBe("agent:voice:agent:main:shared-room");
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey: "agent:other:Matrix:Channel:!RoomAbC:example.org",
}),
).toBe("agent:voice:agent:other:matrix:channel:!RoomAbC:example.org");
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey: "agent:voice:agent:other:matrix:channel:!RoomAbC:example.org",
}),
).toBe("agent:voice:agent:other:matrix:channel:!RoomAbC:example.org");
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey: "Signal:Group:AbC123=",
}),
).toBe("agent:voice:signal:group:AbC123=");
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey: "agent:broken",
}),
).toBe("agent:voice:agent:broken");
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey: "agent::broken",
}),
).toBe("agent:voice:agent::broken");
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey: "agent::Matrix:Channel:!RoomAbC:example.org",
}),
).toBe("agent:voice:agent::matrix:channel:!RoomAbC:example.org");
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey: "agent:other:room::part",
}),
).toBe("agent:voice:agent:other:room::part");
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey: "agent:voice:room::part",
}),
).toBe("agent:voice:room::part");
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey: "agent:voice::Matrix:Channel:!RoomAbC:example.org",
}),
).toBe("agent:voice:agent:voice::matrix:channel:!RoomAbC:example.org");
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey: "agent:bad/id:room",
}),
).toBe("agent:voice:agent:bad/id:room");
});
it("canonicalizes raw and scoped main aliases with the core session config", () => {
const config = resolveVoiceCallConfig({
enabled: true,
provider: "mock",
agentId: "Voice",
});
for (const sessionKey of ["main", "agent:voice:main"]) {
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey,
coreSession: { mainKey: "work" },
}),
).toBe("agent:voice:work");
}
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey: "main",
coreSession: { scope: "global" },
}),
).toBe("global");
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey: "agent:main:main",
coreSession: { mainKey: "work" },
}),
).toBe("agent:voice:agent:main:main");
expect(
resolveVoiceCallAgentSessionKey({
config,
sessionKey: "agent:main:main",
coreSession: { scope: "global" },
}),
).toBe("agent:voice:agent:main:main");
});
it("resolves per-number inbound route overrides over global voice settings", () => {
@@ -395,6 +539,35 @@ describe("resolveVoiceCallConfig session routing", () => {
expect(effective.config).toBe(config);
expect(effective.config.inboundGreeting).toBe("Hello from global.");
});
it("uses dialed-number fallback only for inbound calls", () => {
expect(
resolveVoiceCallNumberRouteKeyForCall({
direction: "inbound",
to: "+15550001111",
}),
).toBe("+15550001111");
expect(
resolveVoiceCallNumberRouteKeyForCall({
direction: "outbound",
to: "+15550001111",
}),
).toBeUndefined();
expect(
resolveVoiceCallNumberRouteKeyForCall({
direction: "inbound",
to: "+15550001111",
metadata: { numberRouteKey: "+15550002222" },
}),
).toBe("+15550002222");
expect(
resolveVoiceCallNumberRouteKeyForCall({
direction: "outbound",
to: "+15550001111",
metadata: { numberRouteKey: "+15550002222" },
}),
).toBeUndefined();
});
});
describe("normalizeVoiceCallConfig", () => {

View File

@@ -1,11 +1,16 @@
// Voice Call helper module supports config behavior.
import { REALTIME_VOICE_AGENT_CONSULT_TOOL_POLICIES } from "openclaw/plugin-sdk/realtime-voice";
import { normalizeAgentId, parseAgentSessionKey } from "openclaw/plugin-sdk/routing";
import {
buildSecretInputSchema,
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,
type SecretInput,
} from "openclaw/plugin-sdk/secret-input";
import {
canonicalizeMainSessionAlias,
type SessionScope,
} from "openclaw/plugin-sdk/session-store-runtime";
import { z } from "zod";
import { TtsConfigSchema } from "../api.js";
import { deepMergeDefined } from "./deep-merge.js";
@@ -569,6 +574,22 @@ export function resolveVoiceCallNumberRouteKey(
);
}
/** Resolve inbound-only number routing from a persisted call record. */
export function resolveVoiceCallNumberRouteKeyForCall(call: {
direction?: "inbound" | "outbound";
to?: string;
metadata?: { numberRouteKey?: unknown };
}): string | undefined {
if (call.direction !== "inbound") {
return undefined;
}
const storedRouteKey = call.metadata?.numberRouteKey;
if (typeof storedRouteKey === "string") {
return storedRouteKey;
}
return call.to;
}
export function resolveVoiceCallEffectiveConfig(
config: VoiceCallConfig,
phoneOrRouteKey: string | undefined,
@@ -695,21 +716,73 @@ export function normalizeVoiceCallConfig(config: VoiceCallConfigInput): VoiceCal
};
}
export type VoiceCallCoreSessionConfig = { mainKey?: string; scope?: SessionScope };
export function resolveVoiceCallSessionKey(params: {
config: Pick<VoiceCallConfig, "sessionScope">;
config: Pick<VoiceCallConfig, "agentId" | "sessionScope">;
callId: string;
phone?: string;
explicitSessionKey?: string;
coreSession?: VoiceCallCoreSessionConfig;
}): string {
const explicit = params.explicitSessionKey?.trim();
if (explicit) {
return explicit;
return resolveVoiceCallAgentSessionKey({
config: params.config,
sessionKey: explicit,
coreSession: params.coreSession,
});
}
// Startup migration promotes unambiguous shipped `voice:*` rows;
// generate only canonical keys here so new history never needs repair.
const prefix = `agent:${normalizeAgentId(params.config.agentId)}:voice`;
if (params.config.sessionScope === "per-call") {
return `voice:call:${params.callId}`;
return `${prefix}:call:${params.callId}`.toLowerCase();
}
const normalizedPhone = params.phone?.replace(/\D/g, "");
return normalizedPhone ? `voice:${normalizedPhone}` : `voice:${params.callId}`;
return (
normalizedPhone ? `${prefix}:${normalizedPhone}` : `${prefix}:${params.callId}`
).toLowerCase();
}
/** Resolve persisted or integration-provided keys into the configured agent namespace. */
export function resolveVoiceCallAgentSessionKey(params: {
config: Pick<VoiceCallConfig, "agentId">;
sessionKey: string;
coreSession?: VoiceCallCoreSessionConfig;
}): string {
const sessionKey = params.sessionKey.trim();
if (!sessionKey) {
throw new Error("Voice Call session key cannot be empty");
}
const lower = sessionKey.toLowerCase();
const agentId = normalizeAgentId(params.config.agentId);
if (lower === "global" || lower === "unknown") {
return lower;
}
const parsedInput = parseAgentSessionKey(sessionKey);
let normalizedScopedKey: string;
if (
parsedInput &&
normalizeAgentId(parsedInput.agentId) === parsedInput.agentId &&
parsedInput.agentId === agentId
) {
normalizedScopedKey = `agent:${parsedInput.agentId}:${parsedInput.rest}`;
} else {
// Voice Call's configured agent owns both the store and runtime. Foreign or
// malformed agent-shaped input is an opaque integration key, not a route.
const wrappedInput = parseAgentSessionKey(`agent:${agentId}:${sessionKey}`);
if (!wrappedInput) {
throw new Error("Voice Call session key could not be normalized");
}
normalizedScopedKey = `agent:${agentId}:${wrappedInput.rest}`;
}
const canonicalMain = canonicalizeMainSessionAlias({
cfg: { session: params.coreSession },
agentId,
sessionKey: normalizedScopedKey,
});
return canonicalMain === normalizedScopedKey ? normalizedScopedKey : canonicalMain;
}
/**

View File

@@ -1,14 +1,12 @@
// Voice Call plugin module implements core bridge behavior.
import type { OpenClawPluginApi } from "../api.js";
import type { VoiceCallTtsConfig } from "./config.js";
import type { VoiceCallCoreSessionConfig, VoiceCallTtsConfig } from "./config.js";
// Narrow core runtime/config contracts consumed by the voice-call plugin.
/** Core config subset read by voice-call helpers. */
export type CoreConfig = {
session?: {
store?: string;
};
session?: VoiceCallCoreSessionConfig & { store?: string };
messages?: {
tts?: VoiceCallTtsConfig;
};

View File

@@ -4,7 +4,7 @@ import os from "node:os";
import path from "node:path";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { VoiceCallConfig } from "./config.js";
import type { VoiceCallConfig, VoiceCallCoreSessionConfig } from "./config.js";
import type { CallManagerContext, StreamSessionIssuer } from "./manager/context.js";
import { processEvent as processManagerEvent } from "./manager/events.js";
import { getCallByProviderCallId as getCallByProviderCallIdFromMaps } from "./manager/lookup.js";
@@ -82,6 +82,7 @@ export class CallManager {
private rejectedProviderCallIds = new Set<string>();
private provider: VoiceCallProvider | null = null;
private config: VoiceCallConfig;
private coreSession: VoiceCallCoreSessionConfig | undefined;
private storePath: string;
private webhookUrl: string | null = null;
private activeTurnCalls = new Set<CallId>();
@@ -103,8 +104,13 @@ export class CallManager {
*/
streamSessionIssuer: StreamSessionIssuer | undefined;
constructor(config: VoiceCallConfig, storePath?: string) {
constructor(
config: VoiceCallConfig,
storePath?: string,
coreSession?: VoiceCallCoreSessionConfig,
) {
this.config = config;
this.coreSession = coreSession;
this.storePath = resolveDefaultStoreBase(config, storePath);
}
@@ -353,6 +359,7 @@ export class CallManager {
rejectedProviderCallIds: this.rejectedProviderCallIds,
provider: this.provider,
config: this.config,
coreSession: this.coreSession,
storePath: this.storePath,
webhookUrl: this.webhookUrl,
activeTurnCalls: this.activeTurnCalls,

View File

@@ -1,5 +1,5 @@
// Voice Call plugin module implements context behavior.
import type { VoiceCallConfig } from "../config.js";
import type { VoiceCallConfig, VoiceCallCoreSessionConfig } from "../config.js";
import type { VoiceCallProvider } from "../providers/base.js";
import type { CallId, CallRecord } from "../types.js";
@@ -21,6 +21,7 @@ type CallManagerRuntimeState = {
type CallManagerRuntimeDeps = {
provider: VoiceCallProvider | null;
config: VoiceCallConfig;
coreSession?: VoiceCallCoreSessionConfig;
storePath: string;
webhookUrl: string | null;
};

View File

@@ -633,7 +633,7 @@ describe("processEvent (functional)", () => {
processEvent(ctx, event);
const call = requireFirstActiveCall(ctx);
expect(call.sessionKey).toBe(`voice:call:${call.callId}`);
expect(call.sessionKey).toBe(`agent:main:voice:call:${call.callId}`);
});
it("applies per-number inbound greeting and stores the matched route key", () => {

View File

@@ -155,11 +155,12 @@ describe("voice-call outbound helpers", () => {
fromNumber: "+14155550100",
tts: { provider: "openai", providers: { openai: { voice: "nova" } } },
},
coreSession: { mainKey: "work" },
storePath: "/tmp/voice-call.json",
webhookUrl: "https://example.com/webhook",
};
const result = await initiateCall(ctx as never, "+14155550123", "session-1", {
const result = await initiateCall(ctx as never, "+14155550123", "main", {
mode: "notify",
message: "hello there",
});
@@ -178,7 +179,7 @@ describe("voice-call outbound helpers", () => {
inlineTwiml: "<Response />",
});
expect(ctx.providerCallIdMap.get("provider-1")).toBe(callId);
expect(ctx.activeCalls.get(callId)?.sessionKey).toBe("session-1");
expect(ctx.activeCalls.get(callId)?.sessionKey).toBe("agent:main:work");
expect(persistCallRecordMock).toHaveBeenCalledTimes(2);
});
@@ -203,7 +204,9 @@ describe("voice-call outbound helpers", () => {
expect(result.success).toBe(true);
expect(result.callId).toBeTypeOf("string");
expect(result.callId).not.toBe("");
expect(ctx.activeCalls.get(result.callId)?.sessionKey).toBe(`voice:call:${result.callId}`);
expect(ctx.activeCalls.get(result.callId)?.sessionKey).toBe(
`agent:main:voice:call:${result.callId}`,
);
});
it("initiates conversation calls with pre-connect DTMF TwiML", async () => {
@@ -404,6 +407,7 @@ describe("voice-call outbound helpers", () => {
const call = {
callId: "call-1",
providerCallId: "provider-1",
direction: "inbound",
state: "active",
to: "+15550002222",
metadata: { numberRouteKey: "+15550002222" },
@@ -438,6 +442,40 @@ describe("voice-call outbound helpers", () => {
});
});
it("keeps top-level TTS for outbound calls to a number with an inbound route", async () => {
const call = {
callId: "call-1",
providerCallId: "provider-1",
direction: "outbound",
state: "active",
to: "+15550002222",
};
const playTts = vi.fn(async () => {});
const ctx = {
activeCalls: new Map([["call-1", call]]),
providerCallIdMap: new Map(),
provider: { name: "twilio", playTts },
config: {
tts: { provider: "openai", providers: { openai: { voice: "coral" } } },
numbers: {
"+15550002222": {
tts: { providers: { openai: { voice: "alloy" } } },
},
},
},
storePath: "/tmp/voice-call.json",
};
await expect(speak(ctx as never, "call-1", "hello")).resolves.toEqual({ success: true });
expect(playTts).toHaveBeenCalledWith({
callId: "call-1",
providerCallId: "provider-1",
text: "hello",
voice: "coral",
});
});
it("sends DTMF through connected provider calls", async () => {
const call = { callId: "call-1", providerCallId: "provider-1", state: "active" };
const sendDtmfProvider = vi.fn(async () => {});

View File

@@ -3,6 +3,7 @@ import crypto from "node:crypto";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
resolveVoiceCallEffectiveConfig,
resolveVoiceCallNumberRouteKeyForCall,
resolveVoiceCallSessionKey,
type CallMode,
} from "../config.js";
@@ -34,6 +35,7 @@ type InitiateContext = Pick<
| "providerCallIdMap"
| "provider"
| "config"
| "coreSession"
| "storePath"
| "webhookUrl"
| "streamSessionIssuer"
@@ -190,6 +192,7 @@ export async function initiateCall(
callId,
phone: to,
explicitSessionKey: sessionKey,
coreSession: ctx.coreSession,
}),
startedAt: Date.now(),
transcript: [],
@@ -288,8 +291,7 @@ export async function speak(
transitionState(call, "speaking");
persistCallRecord(ctx.storePath, call);
const numberRouteKey =
typeof call.metadata?.numberRouteKey === "string" ? call.metadata.numberRouteKey : call.to;
const numberRouteKey = resolveVoiceCallNumberRouteKeyForCall(call);
const voice = resolvePreferredTtsVoice(
resolveVoiceCallEffectiveConfig(ctx.config, numberRouteKey).config,
);

View File

@@ -71,7 +71,7 @@ function createAgentRuntime(payloads: Array<Record<string, unknown>>) {
sessionStore[params.sessionKey] = { ...params.entry };
},
);
const runEmbeddedAgent = vi.fn(async () => ({
const runEmbeddedAgent = vi.fn(async (_args: EmbeddedAgentArgs) => ({
payloads,
meta: { durationMs: 12, aborted: false },
}));
@@ -233,7 +233,7 @@ describe("generateVoiceResponse", () => {
const { runtime, runEmbeddedAgent, patchSessionEntry, sessionStore } = createAgentRuntime([
{ text: '{"spoken":"Pinned model works."}' },
]);
sessionStore["voice:15550001111"] = {
sessionStore["agent:main:voice:15550001111"] = {
sessionId: "existing-session",
updatedAt: 100,
model: "old-model",
@@ -257,7 +257,7 @@ describe("generateVoiceResponse", () => {
});
expect(result.text).toBe("Pinned model works.");
const pinnedSessionEntry = sessionStore["voice:15550001111"];
const pinnedSessionEntry = sessionStore["agent:main:voice:15550001111"];
expect(pinnedSessionEntry?.providerOverride).toBe("openai");
expect(pinnedSessionEntry?.modelOverride).toBe("gpt-4.1-nano");
expect(pinnedSessionEntry?.modelOverrideSource).toBe("auto");
@@ -271,17 +271,17 @@ describe("generateVoiceResponse", () => {
);
expect(patchSessionEntryCall[0]).toMatchObject({
storePath: "/tmp/openclaw/main/sessions.json",
sessionKey: "voice:15550001111",
sessionKey: "agent:main:voice:15550001111",
replaceEntry: true,
});
expect((patchSessionEntryCall[0] as { update?: unknown }).update).toBeTypeOf("function");
const args = requireEmbeddedAgentArgs(runEmbeddedAgent);
expect(args.provider).toBe("openai");
expect(args.model).toBe("gpt-4.1-nano");
expect(args.sessionKey).toBe("voice:15550001111");
expect(args.sessionKey).toBe("agent:main:voice:15550001111");
});
it("uses the persisted per-call session key for classic responses", async () => {
it("canonicalizes a restored legacy per-call key for classic responses", async () => {
const { runtime, runEmbeddedAgent, sessionStore } = createAgentRuntime([
{ text: '{"spoken":"Fresh call context."}' },
]);
@@ -302,15 +302,102 @@ describe("generateVoiceResponse", () => {
});
expect(result.text).toBe("Fresh call context.");
const perCallSessionEntry = sessionStore["voice:call:call-123"];
const perCallSessionEntry = sessionStore["agent:main:voice:call:call-123"];
expect(perCallSessionEntry?.sessionId).toBeTypeOf("string");
expect(perCallSessionEntry?.sessionId).not.toBe("");
expect(sessionStore["voice:15550001111"]).toBeUndefined();
const args = requireEmbeddedAgentArgs(runEmbeddedAgent);
expect(args.sessionKey).toBe("voice:call:call-123");
expect(args.sessionKey).toBe("agent:main:voice:call:call-123");
expect(args.sandboxSessionKey).toBe("agent:main:voice:call:call-123");
});
it("preserves an explicit call key while scoping its session-store identity", async () => {
const { runtime, runEmbeddedAgent, sessionStore } = createAgentRuntime([
{ text: '{"spoken":"Shared meeting context."}' },
]);
const voiceConfig = VoiceCallConfigSchema.parse({
agentId: "voice",
responseTimeoutMs: 5000,
});
await generateVoiceResponse({
voiceConfig,
coreConfig: {} as CoreConfig,
agentRuntime: runtime,
callId: "call-123",
sessionKey: "meet-room-1",
from: "+15550001111",
transcript: [],
userMessage: "hello there",
});
expect(sessionStore["agent:voice:meet-room-1"]?.sessionId).toBeTypeOf("string");
expect(sessionStore["meet-room-1"]).toBeUndefined();
expect(requireEmbeddedAgentArgs(runEmbeddedAgent).sessionKey).toBe("agent:voice:meet-room-1");
});
it("keeps wrapped foreign Matrix identities stable across restore", async () => {
const { runtime, runEmbeddedAgent, sessionStore } = createAgentRuntime([
{ text: '{"spoken":"Matrix context."}' },
]);
const voiceConfig = VoiceCallConfigSchema.parse({
agentId: "voice",
responseTimeoutMs: 5000,
});
const canonical = "agent:voice:agent:other:matrix:channel:!RoomAbC:example.org";
const generate = (sessionKey: string) =>
generateVoiceResponse({
voiceConfig,
coreConfig: {} as CoreConfig,
agentRuntime: runtime,
callId: "call-123",
sessionKey,
from: "+15550001111",
transcript: [],
userMessage: "hello there",
});
await generate("agent:other:matrix:channel:!RoomAbC:example.org");
await generate(canonical);
await generate("agent:other:matrix:channel:!Roomabc:example.org");
expect(sessionStore[canonical]?.sessionId).toBeTypeOf("string");
expect(
sessionStore["agent:voice:agent:other:matrix:channel:!Roomabc:example.org"]?.sessionId,
).toBeTypeOf("string");
expect(Object.keys(sessionStore)).toHaveLength(2);
const sessionKeys = runEmbeddedAgent.mock.calls.map(([args]) => args.sessionKey);
expect(sessionKeys).toEqual([
canonical,
canonical,
"agent:voice:agent:other:matrix:channel:!Roomabc:example.org",
]);
});
it("uses the configured core main key for restored call aliases", async () => {
const { runtime, runEmbeddedAgent, sessionStore } = createAgentRuntime([
{ text: '{"spoken":"Main context."}' },
]);
const voiceConfig = VoiceCallConfigSchema.parse({
agentId: "voice",
responseTimeoutMs: 5000,
});
await generateVoiceResponse({
voiceConfig,
coreConfig: { session: { mainKey: "work" } },
agentRuntime: runtime,
callId: "call-123",
sessionKey: "agent:voice:main",
from: "+15550001111",
transcript: [],
userMessage: "hello there",
});
expect(sessionStore["agent:voice:work"]?.sessionId).toBeTypeOf("string");
expect(requireEmbeddedAgentArgs(runEmbeddedAgent).sessionKey).toBe("agent:voice:work");
});
it("uses the main agent workspace when voice config omits agentId", async () => {
const {
runtime,
@@ -337,17 +424,18 @@ describe("generateVoiceResponse", () => {
expect(resolveAgentDir).toHaveBeenCalledWith(coreConfig, "main");
expect(resolveAgentWorkspaceDir).toHaveBeenCalledWith(coreConfig, "main");
expect(resolveAgentIdentity).toHaveBeenCalledWith(coreConfig, "main");
const defaultSessionEntry = sessionStore["voice:15550001111"];
const defaultSessionEntry = sessionStore["agent:main:voice:15550001111"];
if (!defaultSessionEntry) {
throw new Error("Expected default voice session entry");
}
const args = requireEmbeddedAgentArgs(runEmbeddedAgent);
expect(args.agentDir).toBe("/tmp/openclaw/agents/main");
expect(args.agentId).toBe("main");
expect(args.sessionKey).toBe("agent:main:voice:15550001111");
expect(args.sessionTarget).toStrictEqual({
agentId: "main",
sessionId: defaultSessionEntry.sessionId,
sessionKey: "voice:15550001111",
sessionKey: "agent:main:voice:15550001111",
storePath: "/tmp/openclaw/main/sessions.json",
});
expect(args.sandboxSessionKey).toBe("agent:main:voice:15550001111");
@@ -385,17 +473,18 @@ describe("generateVoiceResponse", () => {
expect(resolveAgentDir).toHaveBeenCalledWith(coreConfig, "voice");
expect(resolveAgentWorkspaceDir).toHaveBeenCalledWith(coreConfig, "voice");
expect(resolveAgentIdentity).toHaveBeenCalledWith(coreConfig, "voice");
const voiceSessionEntry = sessionStore["voice:15550001111"];
const voiceSessionEntry = sessionStore["agent:voice:voice:15550001111"];
if (!voiceSessionEntry) {
throw new Error("Expected routed voice session entry");
}
const args = requireEmbeddedAgentArgs(runEmbeddedAgent);
expect(args.agentDir).toBe("/tmp/openclaw/agents/voice");
expect(args.agentId).toBe("voice");
expect(args.sessionKey).toBe("agent:voice:voice:15550001111");
expect(args.sessionTarget).toStrictEqual({
agentId: "voice",
sessionId: voiceSessionEntry.sessionId,
sessionKey: "voice:15550001111",
sessionKey: "agent:voice:voice:15550001111",
storePath: "/tmp/openclaw/voice/sessions.json",
});
expect(args.sandboxSessionKey).toBe("agent:voice:voice:15550001111");

View File

@@ -234,6 +234,7 @@ export async function generateVoiceResponse(
callId,
phone: from,
explicitSessionKey: sessionKey,
coreSession: coreConfig.session,
});
const agentId = voiceConfig.agentId ?? "main";
const toolsAllow = resolveVoiceAgentToolsAllow(cfg, agentId);

View File

@@ -29,22 +29,42 @@ const mocks = vi.hoisted(() => ({
vi.mock("./config.js", () => ({
resolveVoiceCallSessionKey: (params: {
config: Pick<VoiceCallConfig, "sessionScope">;
config: Pick<VoiceCallConfig, "agentId" | "sessionScope">;
callId: string;
phone?: string;
explicitSessionKey?: string;
}) => {
const explicit = params.explicitSessionKey?.trim();
if (explicit) {
return explicit;
const lower = explicit.toLowerCase();
return lower === "global" || lower === "unknown" || lower.startsWith("agent:")
? explicit
: `agent:${params.config.agentId?.trim().toLowerCase() || "main"}:${explicit}`;
}
const agentId = params.config.agentId?.trim().toLowerCase() || "main";
const prefix = `agent:${agentId}:voice`;
if (params.config.sessionScope === "per-call") {
return `voice:call:${params.callId}`;
return `${prefix}:call:${params.callId}`.toLowerCase();
}
const normalizedPhone = params.phone?.replace(/\D/g, "");
return normalizedPhone ? `voice:${normalizedPhone}` : `voice:${params.callId}`;
return (
normalizedPhone ? `${prefix}:${normalizedPhone}` : `${prefix}:${params.callId}`
).toLowerCase();
},
resolveVoiceCallNumberRouteKeyForCall: (call: {
direction?: "inbound" | "outbound";
to?: string;
metadata?: { numberRouteKey?: unknown };
}) =>
call.direction === "inbound"
? typeof call.metadata?.numberRouteKey === "string"
? call.metadata.numberRouteKey
: call.to
: undefined,
resolveVoiceCallEffectiveConfig: (config: VoiceCallConfig, numberRouteKey?: string) => {
const route = numberRouteKey ? config.numbers[numberRouteKey] : undefined;
return route ? { config: { ...config, ...route }, numberRouteKey } : { config };
},
resolveVoiceCallEffectiveConfig: (config: VoiceCallConfig) => ({ config }),
resolveVoiceCallConfig: mocks.resolveVoiceCallConfig,
resolveTwilioAuthToken: mocks.resolveTwilioAuthToken,
validateProviderConfig: mocks.validateProviderConfig,
@@ -378,9 +398,13 @@ describe("createVoiceCallRuntime lifecycle", () => {
await runtime.stop();
});
it("wires the shared realtime agent consult tool and handler", async () => {
it("wires realtime consults and keeps outbound calls off inbound number routes", async () => {
const config = createBaseConfig();
config.inboundPolicy = "allowlist";
config.numbers["+15550009999"] = {
agentId: "inbound-route",
responseModel: "openai/gpt-5.5",
};
config.realtime.enabled = true;
config.realtime.tools = [
{
@@ -446,7 +470,7 @@ describe("createVoiceCallRuntime lifecycle", () => {
firstCallParam(runEmbeddedAgent.mock.calls as unknown[][], "embedded OpenClaw consult"),
"embedded OpenClaw consult params",
);
expect(consultParams.sessionKey).toBe("voice:15550009999");
expect(consultParams.sessionKey).toBe("agent:main:voice:15550009999");
expect(consultParams.spawnedBy).toBe("agent:main:discord:channel:general");
expect(consultParams.messageProvider).toBe("voice");
expect(consultParams.lane).toBe("voice");
@@ -465,7 +489,7 @@ describe("createVoiceCallRuntime lifecycle", () => {
expect(consultParams.prompt).toContain("Caller: Also check the ETA.");
});
it("uses persisted per-call session keys for realtime consults", async () => {
it("canonicalizes restored legacy per-call keys for realtime consults", async () => {
const config = createBaseConfig();
config.inboundPolicy = "allowlist";
config.realtime.enabled = true;
@@ -513,7 +537,7 @@ describe("createVoiceCallRuntime lifecycle", () => {
),
"per-call embedded OpenClaw consult params",
);
expect(consultParams.sessionKey).toBe("voice:call:call-1");
expect(consultParams.sessionKey).toBe("agent:main:voice:call:call-1");
});
it("answers realtime consults from fast memory context before starting the full agent", async () => {
@@ -582,7 +606,7 @@ describe("createVoiceCallRuntime lifecycle", () => {
error: console.error,
debug: console.debug,
},
sessionKey: "voice:15550001234",
sessionKey: "agent:main:voice:15550001234",
});
expect(runEmbeddedAgent).not.toHaveBeenCalled();
});

View File

@@ -13,6 +13,7 @@ import {
import type { VoiceCallConfig } from "./config.js";
import {
resolveVoiceCallEffectiveConfig,
resolveVoiceCallNumberRouteKeyForCall,
resolveVoiceCallSessionKey,
resolveTwilioAuthToken,
resolveVoiceCallConfig,
@@ -111,20 +112,19 @@ function loadRealtimeHandler(): Promise<RealtimeHandlerModule> {
function resolveVoiceCallConsultSessionKey(call: {
config: VoiceCallConfig;
coreSession?: OpenClawConfig["session"];
sessionKey?: string;
from?: string;
to?: string;
direction?: "inbound" | "outbound";
callId: string;
}): string {
if (call.sessionKey) {
return call.sessionKey;
}
const phone = call.direction === "outbound" ? call.to : call.from;
return resolveVoiceCallSessionKey({
config: call.config,
callId: call.callId,
phone,
phone: call.direction === "outbound" ? call.to : call.from,
explicitSessionKey: call.sessionKey,
coreSession: call.coreSession,
});
}
@@ -309,7 +309,7 @@ export async function createVoiceCallRuntime(params: {
if (stateRuntime) {
setVoiceCallStateRuntime({ state: stateRuntime });
}
const manager = new CallManager(config);
const manager = new CallManager(config, undefined, cfg.session);
const realtimeProvider = config.realtime.enabled
? await resolveRealtimeProvider({
config,
@@ -358,15 +358,13 @@ export async function createVoiceCallRuntime(params: {
if (!call) {
return { error: `Call "${callId}" not found` };
}
const numberRouteKey =
typeof call.metadata?.numberRouteKey === "string"
? call.metadata.numberRouteKey
: call.to;
const numberRouteKey = resolveVoiceCallNumberRouteKeyForCall(call);
const effectiveConfig = resolveVoiceCallEffectiveConfig(config, numberRouteKey).config;
const agentId = effectiveConfig.agentId ?? "main";
const sessionKey = resolveVoiceCallConsultSessionKey({
...call,
config: effectiveConfig,
coreSession: cfg.session,
});
const requesterSessionKey =
typeof call.metadata?.requesterSessionKey === "string"

View File

@@ -31,6 +31,7 @@ const mocks = vi.hoisted(() => {
};
return {
generateVoiceResponse: vi.fn(async () => ({ text: null })),
getRealtimeTranscriptionProvider: vi.fn<(...args: unknown[]) => unknown>(
() => realtimeTranscriptionProvider,
),
@@ -43,6 +44,10 @@ vi.mock("./realtime-transcription.runtime.js", () => ({
listRealtimeTranscriptionProviders: mocks.listRealtimeTranscriptionProviders,
}));
vi.mock("./response-generator.js", () => ({
generateVoiceResponse: mocks.generateVoiceResponse,
}));
const provider: VoiceCallProvider = {
name: "mock",
verifyWebhook: () => ({ ok: true, verifiedRequestKey: "mock:req:base" }),
@@ -1646,6 +1651,46 @@ describe("VoiceCallWebhookServer pre-auth webhook guards", () => {
});
});
describe("VoiceCallWebhookServer classic response routing", () => {
it("keeps outbound calls on the top-level agent when the dialed number has an inbound route", async () => {
const call = createCall(Date.now());
call.direction = "outbound";
call.to = "+15550001111";
call.sessionKey = "agent:top:voice:15550001111";
const manager = {
getCall: (callId: string) => (callId === call.callId ? call : undefined),
speak: vi.fn(async () => ({ success: true })),
} as unknown as CallManager;
const config = createConfig({
agentId: "top",
numbers: {
"+15550001111": { agentId: "inbound-route" },
},
});
const server = new VoiceCallWebhookServer(
config,
manager,
provider,
{} as never,
undefined,
{} as never,
);
mocks.generateVoiceResponse.mockReset().mockResolvedValue({ text: null });
await (
server as unknown as {
handleInboundResponse: (callId: string, message: string) => Promise<void>;
}
).handleInboundResponse(call.callId, "hello");
const params = requireFirstMockCall(
mocks.generateVoiceResponse.mock.calls,
"classic voice response",
)[0] as { voiceConfig?: VoiceCallConfig } | undefined;
expect(params?.voiceConfig?.agentId).toBe("top");
});
});
describe("VoiceCallWebhookServer response normalization", () => {
it("preserves explicit empty provider response bodies", async () => {
const responseProvider: VoiceCallProvider = {

View File

@@ -25,6 +25,7 @@ import { isAllowlistedCaller, normalizePhoneNumber } from "./allowlist.js";
import {
normalizeVoiceCallConfig,
resolveVoiceCallEffectiveConfig,
resolveVoiceCallNumberRouteKeyForCall,
type VoiceCallConfig,
} from "./config.js";
import type { CoreAgentDeps, CoreConfig } from "./core-bridge.js";
@@ -1031,8 +1032,7 @@ export class VoiceCallWebhookServer {
try {
const { generateVoiceResponse } = await loadResponseGeneratorModule();
const numberRouteKey =
typeof call.metadata?.numberRouteKey === "string" ? call.metadata.numberRouteKey : call.to;
const numberRouteKey = resolveVoiceCallNumberRouteKeyForCall(call);
const effectiveConfig = resolveVoiceCallEffectiveConfig(this.config, numberRouteKey).config;
const result = await generateVoiceResponse({

View File

@@ -1695,6 +1695,7 @@
"plugin-sdk:surface:check": "node --max-old-space-size=8192 scripts/plugin-sdk-surface-report.mjs --check",
"plugin-sdk:sync-exports": "node scripts/sync-plugin-sdk-exports.mjs",
"plugin-sdk:usage": "node --max-old-space-size=8192 --import tsx scripts/analyze-plugin-sdk-usage.ts",
"policy:config-coverage": "node --import tsx scripts/check-policy-config-coverage.ts",
"plugins:boundary-report": "node --import tsx scripts/plugin-boundary-report.ts",
"plugins:boundary-report:ci": "node --import tsx scripts/plugin-boundary-report.ts --summary --fail-on-cross-owner --fail-on-unclassified-unused-reserved --fail-on-eligible-compat",
"plugins:boundary-report:json": "node --import tsx scripts/plugin-boundary-report.ts --json",
@@ -1951,8 +1952,6 @@
"ui:i18n:check": "node --import tsx scripts/control-ui-i18n.ts check",
"ui:i18n:report": "node --import tsx scripts/control-ui-i18n-report.ts",
"ui:i18n:sync": "node --import tsx scripts/control-ui-i18n.ts sync --write",
"native:i18n:check": "node --import tsx scripts/native-app-i18n.ts check",
"native:i18n:sync": "node --import tsx scripts/native-app-i18n.ts sync --write",
"ui:install": "node scripts/ui.js install",
"verify": "node scripts/verify.mjs"
},

View File

@@ -0,0 +1,232 @@
#!/usr/bin/env node
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import JSON5 from "json5";
import {
renderConfigDocBaselineArtifacts,
type ConfigDocBaselineEntry,
} from "../src/config/doc-baseline.js";
type ClassificationStatus = "observed" | "ignored" | "out-of-scope" | "deferred";
type CoverageClassification = {
readonly pattern: string;
readonly status: ClassificationStatus;
readonly area: string;
readonly policy?: string;
readonly reason: string;
readonly allowNoSchemaPath?: boolean;
};
type CoverageConfig = {
readonly monitored: readonly string[];
readonly classifications: readonly CoverageClassification[];
};
type ConfigDocBaseline = {
readonly coreEntries: readonly ConfigDocBaselineEntry[];
readonly channelEntries: readonly ConfigDocBaselineEntry[];
readonly pluginEntries: readonly ConfigDocBaselineEntry[];
};
function flattenConfigDocBaselineEntries(
baseline: ConfigDocBaseline,
): readonly ConfigDocBaselineEntry[] {
return [...baseline.coreEntries, ...baseline.channelEntries, ...baseline.pluginEntries];
}
type ClassifiedEntry = {
readonly path: string;
readonly kind: ConfigDocBaselineEntry["kind"];
readonly classification?: CoverageClassification;
};
type UnmatchedMonitoredPattern = {
readonly pattern: string;
};
const args = new Set(process.argv.slice(2));
const json = args.has("--json");
const check = args.has("--check");
const showCovered = args.has("--show-covered");
if (args.has("--help")) {
console.log(`Usage: pnpm policy:config-coverage [--check] [--json] [--show-covered]
Internal maintainer report for Policy config coverage.
Default mode is report-only and exits 0 even when paths are unclassified.
Use --check when a policy maintainer intentionally wants unclassified or stale
coverage entries to fail locally.`);
process.exit(0);
}
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const configPath = path.join(repoRoot, "scripts/lib/policy-config-coverage.jsonc");
const config = JSON5.parse(await fs.readFile(configPath, "utf8")) as CoverageConfig;
const { baseline } = await renderConfigDocBaselineArtifacts();
const monitoredEntries = flattenConfigDocBaselineEntries(baseline)
.filter((entry) => !entry.hasChildren)
.filter((entry) => matchesAny(config.monitored, entry.path))
.toSorted((left, right) => left.path.localeCompare(right.path));
const leafEntries = flattenConfigDocBaselineEntries(baseline).filter((entry) => !entry.hasChildren);
const unmatchedMonitored = config.monitored
.filter(
(pattern) =>
!leafEntries.some((entry) => pathMatchesPattern(pattern, entry.path)) &&
!config.classifications.some(
(item) => item.allowNoSchemaPath === true && pathMatchesPattern(item.pattern, pattern),
),
)
.map((pattern) => ({ pattern }))
.toSorted((left, right) => left.pattern.localeCompare(right.pattern));
const classified: ClassifiedEntry[] = monitoredEntries.map((entry) => ({
path: entry.path,
kind: entry.kind,
classification: config.classifications.find((item) =>
pathMatchesPattern(item.pattern, entry.path),
),
}));
const unclassified = classified.filter((entry) => entry.classification === undefined);
const stale = config.classifications.filter(
(item) =>
item.allowNoSchemaPath !== true &&
!monitoredEntries.some((entry) => pathMatchesPattern(item.pattern, entry.path)),
);
const summaryCounts = summarize(classified);
if (json) {
console.log(
JSON.stringify(
{
ok: unclassified.length === 0 && stale.length === 0 && unmatchedMonitored.length === 0,
monitoredPaths: monitoredEntries.length,
counts: summaryCounts,
unclassified,
unmatchedMonitored,
stale,
},
null,
2,
),
);
} else {
printTextReport({
monitoredPaths: monitoredEntries.length,
counts: summaryCounts,
unclassified,
unmatchedMonitored,
stale,
classified,
});
}
if (check && (unclassified.length > 0 || stale.length > 0 || unmatchedMonitored.length > 0)) {
process.exit(1);
}
function printTextReport(input: {
readonly monitoredPaths: number;
readonly counts: Record<string, number>;
readonly unclassified: readonly ClassifiedEntry[];
readonly unmatchedMonitored: readonly UnmatchedMonitoredPattern[];
readonly stale: readonly CoverageClassification[];
readonly classified: readonly ClassifiedEntry[];
}): void {
console.log(`Policy config coverage: ${input.monitoredPaths} monitored config leaf paths`);
for (const [key, count] of Object.entries(input.counts).toSorted(([a], [b]) =>
a.localeCompare(b),
)) {
console.log(` ${key}: ${count}`);
}
if (input.unclassified.length > 0) {
console.log("\nUnclassified config paths:");
for (const entry of input.unclassified) {
console.log(` - ${entry.path} (${entry.kind})`);
}
console.log(
"\nClassify each as observed, ignored, out-of-scope, or deferred in scripts/lib/policy-config-coverage.jsonc.",
);
} else {
console.log("\nNo unclassified monitored config paths.");
}
if (input.unmatchedMonitored.length > 0) {
console.log("\nMonitored patterns with no matching config paths:");
for (const entry of input.unmatchedMonitored) {
console.log(` - ${entry.pattern}`);
}
} else {
console.log("\nNo monitored patterns without matching config paths.");
}
if (input.stale.length > 0) {
console.log("\nStale coverage classifications:");
for (const entry of input.stale) {
console.log(` - ${entry.pattern} (${entry.area}, ${entry.status})`);
}
}
if (showCovered) {
console.log("\nCovered paths:");
for (const entry of input.classified) {
const classification = entry.classification;
console.log(
` - ${entry.path}: ${classification?.area ?? "unclassified"} / ${
classification?.status ?? "unclassified"
}`,
);
}
}
}
function summarize(entries: readonly ClassifiedEntry[]): Record<string, number> {
const counts: Record<string, number> = {};
for (const entry of entries) {
const key =
entry.classification === undefined
? "unclassified"
: `${entry.classification.area}.${entry.classification.status}`;
counts[key] = (counts[key] ?? 0) + 1;
}
return counts;
}
function matchesAny(patterns: readonly string[], value: string): boolean {
return patterns.some((pattern) => pathMatchesPattern(pattern, value));
}
function pathMatchesPattern(pattern: string, value: string): boolean {
const patternParts = pattern.split(".");
const valueParts = value.split(".");
return matchesParts(patternParts, valueParts);
}
function matchesParts(patternParts: readonly string[], valueParts: readonly string[]): boolean {
if (patternParts.length === 0) {
return valueParts.length === 0;
}
const [head, ...tail] = patternParts;
if (head === "**") {
if (tail.length === 0) {
return true;
}
for (let index = 0; index <= valueParts.length; index += 1) {
if (matchesParts(tail, valueParts.slice(index))) {
return true;
}
}
return false;
}
if (valueParts.length === 0) {
return false;
}
if (head !== "*" && head !== valueParts[0]) {
return false;
}
return matchesParts(tail, valueParts.slice(1));
}

View File

@@ -595,8 +595,6 @@ function buildSystemPrompt(targetLocale: string, glossary: readonly GlossaryEntr
"- The JSON must be an object whose keys exactly match the provided ids.",
"- Translate all English prose; keep code, URLs, product names, CLI commands, config keys, and env vars in English.",
"- Preserve placeholders exactly, including {count}, {time}, {shown}, {total}, and similar tokens.",
"- Preserve Swift interpolation expressions such as \\(name) exactly, including the backslash and parentheses.",
"- Preserve Kotlin interpolation expressions such as $name and ${value} exactly.",
"- Preserve punctuation, ellipses, arrows, and casing when they are part of literal UI text.",
"- Preserve Markdown, inline code, HTML tags, and slash commands when present.",
"- Use fluent, neutral product UI language.",
@@ -1486,63 +1484,6 @@ async function translateBatch(
throw lastError ?? new Error("translation failed");
}
export type NativeTranslationEntry = {
id: string;
source: string;
sourcePath: string;
};
export async function translateNativeEntries(
entries: readonly NativeTranslationEntry[],
targetLocale: string,
glossary: readonly GlossaryEntry[] = [],
): Promise<Map<string, string>> {
if (!hasTranslationProvider()) {
throw new Error("native app translation requires OPENAI_API_KEY or ANTHROPIC_API_KEY");
}
const pending = entries.map((entry) => ({
cacheKey: cacheKey(entry.id, hashText(entry.source), targetLocale),
key: entry.id,
text: entry.source,
textHash: hashText(entry.source),
}));
const batches = buildTranslationBatches(pending);
let client: TranslationClient | null = null;
const clientAccess: ClientAccess = {
async getClient() {
if (!client) {
client = await TranslationClient.create(buildSystemPrompt(targetLocale, glossary));
}
return client;
},
async resetClient() {
if (!client) {
return;
}
await client.close();
client = null;
},
};
try {
const translated = new Map<string, string>();
for (const [batchIndex, batch] of batches.entries()) {
const result = await translateBatch(clientAccess, batch, {
locale: targetLocale,
localeCount: 1,
localeIndex: 1,
batchCount: batches.length,
batchIndex: batchIndex + 1,
});
for (const [id, value] of result) {
translated.set(id, value);
}
}
return translated;
} finally {
await clientAccess.resetClient();
}
}
type SyncOutcome = {
changed: boolean;
fallbackCount: number;

View File

@@ -114,10 +114,10 @@
}
},
"install": {
"npmSpec": "@tencent-weixin/openclaw-weixin@2.4.3",
"npmSpec": "@tencent-weixin/openclaw-weixin@2.4.6",
"defaultChoice": "npm",
"expectedIntegrity": "sha512-dPQbidUNWigC6V10vGW4i+GLH09x+6zUhafZRjuxkJ9GDu8o62WBsnUTojp4KqUH756hz+t2v9khiCRSi0dBDw==",
"minHostVersion": ">=2026.3.22"
"expectedIntegrity": "sha512-qw9k3PLTiMWGNjjsknHgcTManH1w4j+Ji1ArWIaYLKCq3aFRsVwcqnPi127bvOoVMJGW4dbyJ8NECEMgoO+iRw==",
"minHostVersion": ">=2026.5.12"
}
}
},

View File

@@ -0,0 +1,761 @@
{
// Internal maintainer inventory for `pnpm policy:config-coverage`.
// Keep this report-only by default: it helps policy maintainers notice config
// drift without making every config PR author update Policy.
"monitored": [
"auth.profiles.*.mode",
"auth.profiles.*.provider",
"browser.ssrfPolicy.allowPrivateNetwork",
"browser.ssrfPolicy.dangerouslyAllowPrivateNetwork",
"channels.*.accounts.*.dmPolicy",
"channels.*.accounts.*.groupPolicy",
"channels.*.accounts.*.groups.*.requireMention",
"channels.*.dmPolicy",
"channels.*.enabled",
"channels.*.groupPolicy",
"channels.*.groups.*.requireMention",
"diagnostics.otel.captureContent",
"gateway.auth.mode",
"gateway.auth.rateLimit.*",
"gateway.bind",
"gateway.controlUi.allowInsecureAuth",
"gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback",
"gateway.controlUi.dangerouslyDisableDeviceAuth",
"gateway.customBindHost",
"gateway.http.endpoints.*.*.allowUrl",
"gateway.http.endpoints.*.*.urlAllowlist.*",
"gateway.http.endpoints.*.enabled",
"gateway.mode",
"gateway.remote.enabled",
"gateway.tailscale.mode",
"gateway.tailscale.preserveFunnel",
"logging.redactSensitive",
"memory.qmd.sessions.enabled",
"mcp.servers.*.command",
"mcp.servers.*.transport",
"mcp.servers.*.url",
"models.providers.*.type",
"models.selected",
"models.selectedByAgent.*",
"models.selectedByChannel.*",
"session.dmScope",
"session.maintenance.mode",
"secrets.defaults.provider",
"secrets.providers.*.allowInsecureTransport",
"secrets.providers.*.source",
"tools.allow.*",
"tools.alsoAllow.*",
"tools.deny.*",
"tools.elevated.allowFrom.*.*",
"tools.elevated.enabled",
"tools.exec.ask",
"tools.exec.host",
"tools.exec.security",
"tools.fs.workspaceOnly",
"tools.profile",
"tools.sandbox.tools.allow.*",
"tools.sandbox.tools.alsoAllow.*",
"tools.sandbox.tools.deny.*",
"tools.web.fetch.ssrfPolicy.allowIpv6UniqueLocalRange",
"tools.web.fetch.ssrfPolicy.allowPrivateNetwork",
"tools.web.fetch.ssrfPolicy.allowRfc2544BenchmarkRange",
"tools.web.fetch.ssrfPolicy.dangerouslyAllowPrivateNetwork",
"agents.defaults.memorySearch.enabled",
"agents.defaults.memorySearch.experimental.sessionMemory",
"agents.defaults.memorySearch.sources.*",
"agents.defaults.model.fallbacks.*",
"agents.defaults.model.primary",
"agents.defaults.models.*.alias",
"agents.defaults.sandbox.backend",
"agents.defaults.sandbox.browser.binds.*",
"agents.defaults.sandbox.browser.cdpSourceRange",
"agents.defaults.sandbox.docker.apparmorProfile",
"agents.defaults.sandbox.docker.binds.*",
"agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin",
"agents.defaults.sandbox.docker.network",
"agents.defaults.sandbox.docker.readOnlyRoot",
"agents.defaults.sandbox.docker.seccompProfile",
"agents.defaults.sandbox.mode",
"agents.defaults.sandbox.workspaceAccess",
"agents.defaults.tools.allow.*",
"agents.defaults.tools.alsoAllow.*",
"agents.defaults.tools.deny.*",
"agents.defaults.tools.elevated.allowFrom.*.*",
"agents.defaults.tools.elevated.enabled",
"agents.defaults.tools.exec.ask",
"agents.defaults.tools.exec.host",
"agents.defaults.tools.exec.security",
"agents.defaults.tools.fs.workspaceOnly",
"agents.defaults.tools.profile",
"agents.defaults.tools.sandbox.tools.allow.*",
"agents.defaults.tools.sandbox.tools.alsoAllow.*",
"agents.defaults.tools.sandbox.tools.deny.*",
"agents.list.*.memorySearch.enabled",
"agents.list.*.memorySearch.experimental.sessionMemory",
"agents.list.*.memorySearch.sources.*",
"agents.list.*.model.fallbacks.*",
"agents.list.*.model.primary",
"agents.list.*.models.*.alias",
"agents.list.*.sandbox.backend",
"agents.list.*.sandbox.browser.binds.*",
"agents.list.*.sandbox.browser.cdpSourceRange",
"agents.list.*.sandbox.docker.apparmorProfile",
"agents.list.*.sandbox.docker.binds.*",
"agents.list.*.sandbox.docker.dangerouslyAllowContainerNamespaceJoin",
"agents.list.*.sandbox.docker.network",
"agents.list.*.sandbox.docker.readOnlyRoot",
"agents.list.*.sandbox.docker.seccompProfile",
"agents.list.*.sandbox.mode",
"agents.list.*.sandbox.workspaceAccess",
"agents.list.*.tools.allow.*",
"agents.list.*.tools.alsoAllow.*",
"agents.list.*.tools.deny.*",
"agents.list.*.tools.elevated.allowFrom.*.*",
"agents.list.*.tools.elevated.enabled",
"agents.list.*.tools.exec.ask",
"agents.list.*.tools.exec.host",
"agents.list.*.tools.exec.security",
"agents.list.*.tools.fs.workspaceOnly",
"agents.list.*.tools.profile",
"agents.list.*.tools.sandbox.tools.allow.*",
"agents.list.*.tools.sandbox.tools.alsoAllow.*",
"agents.list.*.tools.sandbox.tools.deny.*",
],
"classifications": [
{
"pattern": "browser.ssrfPolicy.dangerouslyAllowPrivateNetwork",
"status": "observed",
"area": "network",
"policy": "network.privateNetwork.allow",
"reason": "Policy observes private-network browser SSRF posture.",
},
{
"pattern": "browser.ssrfPolicy.allowPrivateNetwork",
"status": "observed",
"area": "network",
"policy": "network.privateNetwork.allow",
"reason": "Policy observes the legacy browser private-network toggle.",
"allowNoSchemaPath": true,
},
{
"pattern": "tools.web.fetch.ssrfPolicy.dangerouslyAllowPrivateNetwork",
"status": "observed",
"area": "network",
"policy": "network.privateNetwork.allow",
"reason": "Policy observes private-network web-fetch SSRF posture.",
"allowNoSchemaPath": true,
},
{
"pattern": "tools.web.fetch.ssrfPolicy.allowPrivateNetwork",
"status": "observed",
"area": "network",
"policy": "network.privateNetwork.allow",
"reason": "Policy observes the legacy web-fetch private-network toggle.",
"allowNoSchemaPath": true,
},
{
"pattern": "tools.web.fetch.ssrfPolicy.allowRfc2544BenchmarkRange",
"status": "observed",
"area": "network",
"policy": "network.privateNetwork.allow",
"reason": "Policy treats RFC 2544 benchmark ranges as private-network posture.",
},
{
"pattern": "tools.web.fetch.ssrfPolicy.allowIpv6UniqueLocalRange",
"status": "observed",
"area": "network",
"policy": "network.privateNetwork.allow",
"reason": "Policy treats IPv6 unique-local ranges as private-network posture.",
},
{
"pattern": "session.dmScope",
"status": "observed",
"area": "ingress",
"policy": "ingress.session.requireDmScope",
"reason": "Policy observes direct-message session isolation scope.",
},
{
"pattern": "logging.redactSensitive",
"status": "observed",
"area": "dataHandling",
"policy": "dataHandling.sensitiveLogging.requireRedaction",
"reason": "Policy observes sensitive log redaction posture.",
"allowNoSchemaPath": true,
},
{
"pattern": "diagnostics.otel.captureContent",
"status": "observed",
"area": "dataHandling",
"policy": "dataHandling.telemetry.denyContentCapture",
"reason": "Policy observes telemetry content-capture posture.",
"allowNoSchemaPath": true,
},
{
"pattern": "session.maintenance.mode",
"status": "observed",
"area": "dataHandling",
"policy": "dataHandling.retention.requireSessionMaintenance",
"reason": "Policy observes session maintenance enforcement posture.",
},
{
"pattern": "memory.qmd.sessions.enabled",
"status": "observed",
"area": "dataHandling",
"policy": "dataHandling.memory.denySessionTranscriptIndexing",
"reason": "Policy observes QMD session-transcript indexing.",
},
{
"pattern": "agents.defaults.memorySearch.enabled",
"status": "observed",
"area": "dataHandling",
"policy": "dataHandling.memory.denySessionTranscriptIndexing",
"reason": "Policy observes default memory-search session indexing enablement.",
},
{
"pattern": "agents.defaults.memorySearch.experimental.sessionMemory",
"status": "observed",
"area": "dataHandling",
"policy": "dataHandling.memory.denySessionTranscriptIndexing",
"reason": "Policy observes default memory-search session-memory toggle.",
},
{
"pattern": "agents.defaults.memorySearch.sources.*",
"status": "observed",
"area": "dataHandling",
"policy": "dataHandling.memory.denySessionTranscriptIndexing",
"reason": "Policy observes whether default memory-search sources include sessions.",
},
{
"pattern": "agents.list.*.memorySearch.enabled",
"status": "observed",
"area": "dataHandling",
"policy": "dataHandling.memory.denySessionTranscriptIndexing",
"reason": "Policy observes per-agent memory-search session indexing enablement.",
},
{
"pattern": "agents.list.*.memorySearch.experimental.sessionMemory",
"status": "observed",
"area": "dataHandling",
"policy": "dataHandling.memory.denySessionTranscriptIndexing",
"reason": "Policy observes per-agent memory-search session-memory toggle.",
},
{
"pattern": "agents.list.*.memorySearch.sources.*",
"status": "observed",
"area": "dataHandling",
"policy": "dataHandling.memory.denySessionTranscriptIndexing",
"reason": "Policy observes whether per-agent memory-search sources include sessions.",
},
{
"pattern": "auth.profiles.*.mode",
"status": "observed",
"area": "auth",
"policy": "auth.profiles.allowModes",
"reason": "Policy observes configured auth profile mode metadata.",
},
{
"pattern": "auth.profiles.*.provider",
"status": "observed",
"area": "auth",
"policy": "auth.profiles.requireMetadata",
"reason": "Policy observes configured auth profile provider metadata.",
},
{
"pattern": "channels.*.enabled",
"status": "observed",
"area": "channels",
"policy": "channels.denyRules",
"reason": "Provider deny rules only apply to enabled configured channels.",
},
{
"pattern": "channels.*.accounts.*.dmPolicy",
"status": "observed",
"area": "ingress",
"policy": "ingress.channels.allowDmPolicies",
"reason": "Policy observes account-level direct-message access posture.",
},
{
"pattern": "channels.*.dmPolicy",
"status": "observed",
"area": "ingress",
"policy": "ingress.channels.allowDmPolicies",
"reason": "Policy observes channel-level direct-message access posture.",
},
{
"pattern": "channels.*.accounts.*.groupPolicy",
"status": "observed",
"area": "ingress",
"policy": "ingress.channels.denyOpenGroups",
"reason": "Policy observes account-level group access posture.",
},
{
"pattern": "channels.*.groupPolicy",
"status": "observed",
"area": "ingress",
"policy": "ingress.channels.denyOpenGroups",
"reason": "Policy observes channel-level group access posture.",
},
{
"pattern": "channels.*.accounts.*.groups.*.requireMention",
"status": "observed",
"area": "ingress",
"policy": "ingress.channels.requireMentionInGroups",
"reason": "Policy observes account group mention gates.",
},
{
"pattern": "channels.*.groups.*.requireMention",
"status": "observed",
"area": "ingress",
"policy": "ingress.channels.requireMentionInGroups",
"reason": "Policy observes channel group mention gates.",
},
{
"pattern": "gateway.bind",
"status": "observed",
"area": "gateway",
"policy": "gateway.exposure.allowNonLoopbackBind",
"reason": "Policy observes Gateway bind exposure posture.",
},
{
"pattern": "gateway.customBindHost",
"status": "observed",
"area": "gateway",
"policy": "gateway.exposure.allowNonLoopbackBind",
"reason": "Policy observes custom bind host exposure posture.",
},
{
"pattern": "gateway.tailscale.mode",
"status": "observed",
"area": "gateway",
"policy": "gateway.exposure.allowTailscaleFunnel",
"reason": "Policy observes Tailscale serve/funnel mode when deriving Gateway exposure posture.",
},
{
"pattern": "gateway.tailscale.preserveFunnel",
"status": "observed",
"area": "gateway",
"policy": "gateway.exposure.allowTailscaleFunnel",
"reason": "Policy observes preserveFunnel because serve mode can preserve Funnel exposure.",
},
{
"pattern": "gateway.auth.mode",
"status": "observed",
"area": "gateway",
"policy": "gateway.auth.requireAuth",
"reason": "Policy observes Gateway auth mode posture.",
},
{
"pattern": "gateway.auth.rateLimit.*",
"status": "observed",
"area": "gateway",
"policy": "gateway.auth.requireExplicitRateLimit",
"reason": "Policy observes whether Gateway auth rate limiting is explicitly configured.",
},
{
"pattern": "gateway.controlUi.allowInsecureAuth",
"status": "observed",
"area": "gateway",
"policy": "gateway.controlUi.allowInsecure",
"reason": "Policy observes the Control UI insecure auth toggle.",
},
{
"pattern": "gateway.controlUi.dangerouslyDisableDeviceAuth",
"status": "observed",
"area": "gateway",
"policy": "gateway.controlUi.allowInsecure",
"reason": "Policy observes the Control UI device-auth disable toggle.",
},
{
"pattern": "gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback",
"status": "observed",
"area": "gateway",
"policy": "gateway.controlUi.allowInsecure",
"reason": "Policy observes the Control UI Host-header origin fallback toggle.",
},
{
"pattern": "gateway.mode",
"status": "observed",
"area": "gateway",
"policy": "gateway.remote.allow",
"reason": "Policy observes whether Gateway remote mode is enabled.",
},
{
"pattern": "gateway.remote.enabled",
"status": "observed",
"area": "gateway",
"policy": "gateway.remote.allow",
"reason": "Policy observes explicit remote Gateway enablement.",
},
{
"pattern": "gateway.http.endpoints.*.enabled",
"status": "observed",
"area": "gateway",
"policy": "gateway.http.denyEndpoints",
"reason": "Policy observes Gateway HTTP endpoint enablement.",
},
{
"pattern": "gateway.http.endpoints.*.*.allowUrl",
"status": "observed",
"area": "gateway",
"policy": "gateway.http.requireUrlAllowlists",
"reason": "Policy observes URL-fetch enablement on Gateway HTTP inputs.",
},
{
"pattern": "gateway.http.endpoints.*.*.urlAllowlist.*",
"status": "observed",
"area": "gateway",
"policy": "gateway.http.requireUrlAllowlists",
"reason": "Policy observes URL-fetch allowlists on Gateway HTTP inputs.",
},
{
"pattern": "mcp.servers.*.command",
"status": "observed",
"area": "mcp",
"policy": "mcp.servers.allow / mcp.servers.deny",
"reason": "Policy observes configured MCP server ids and command posture context.",
},
{
"pattern": "mcp.servers.*.transport",
"status": "observed",
"area": "mcp",
"policy": "mcp.servers.allow / mcp.servers.deny",
"reason": "Policy observes configured MCP server transport posture context.",
},
{
"pattern": "mcp.servers.*.url",
"status": "observed",
"area": "mcp",
"policy": "mcp.servers.allow / mcp.servers.deny",
"reason": "Policy observes configured MCP server URL posture context.",
},
{
"pattern": "models.providers.*.type",
"status": "observed",
"area": "models",
"policy": "models.providers.allow / models.providers.deny",
"reason": "Policy observes configured provider ids.",
"allowNoSchemaPath": true,
},
{
"pattern": "models.selected",
"status": "observed",
"area": "models",
"policy": "models.providers.allow / models.providers.deny",
"reason": "Policy observes selected model refs.",
"allowNoSchemaPath": true,
},
{
"pattern": "models.selectedByAgent.*",
"status": "observed",
"area": "models",
"policy": "models.providers.allow / models.providers.deny",
"reason": "Policy observes agent-specific selected model refs.",
"allowNoSchemaPath": true,
},
{
"pattern": "models.selectedByChannel.*",
"status": "observed",
"area": "models",
"policy": "models.providers.allow / models.providers.deny",
"reason": "Policy observes channel-specific selected model refs.",
"allowNoSchemaPath": true,
},
{
"pattern": "agents.defaults.model.**",
"status": "observed",
"area": "models",
"policy": "models.providers.allow / models.providers.deny",
"reason": "Policy observes default agent model refs.",
},
{
"pattern": "agents.defaults.models.*.alias",
"status": "observed",
"area": "models",
"policy": "models.providers.allow / models.providers.deny",
"reason": "Policy observes default agent model aliases.",
},
{
"pattern": "agents.list.*.model.**",
"status": "observed",
"area": "models",
"policy": "models.providers.allow / models.providers.deny",
"reason": "Policy observes per-agent model refs.",
},
{
"pattern": "agents.list.*.models.*.alias",
"status": "observed",
"area": "models",
"policy": "models.providers.allow / models.providers.deny",
"reason": "Policy observes per-agent model aliases.",
},
{
"pattern": "secrets.defaults.provider",
"status": "observed",
"area": "secrets",
"policy": "secrets.requireManagedProviders",
"reason": "Policy observes default SecretRef provider provenance.",
"allowNoSchemaPath": true,
},
{
"pattern": "secrets.providers.*.source",
"status": "observed",
"area": "secrets",
"policy": "secrets.denySources",
"reason": "Policy observes configured secret provider source type.",
},
{
"pattern": "secrets.providers.*.allowInsecureTransport",
"status": "observed",
"area": "secrets",
"policy": "secrets.allowInsecureProviders",
"reason": "Policy observes insecure secret-provider transport posture.",
"allowNoSchemaPath": true,
},
{
"pattern": "tools.profile",
"status": "observed",
"area": "tools",
"policy": "tools.profiles.allow",
"reason": "Policy observes global tool profile posture.",
},
{
"pattern": "tools.fs.workspaceOnly",
"status": "observed",
"area": "tools",
"policy": "tools.fs.requireWorkspaceOnly",
"reason": "Policy observes global filesystem workspace-only posture.",
},
{
"pattern": "tools.exec.security",
"status": "observed",
"area": "tools",
"policy": "tools.exec.allowSecurity",
"reason": "Policy observes global exec security posture.",
},
{
"pattern": "tools.exec.ask",
"status": "observed",
"area": "tools",
"policy": "tools.exec.requireAsk",
"reason": "Policy observes global exec approval posture.",
},
{
"pattern": "tools.exec.host",
"status": "observed",
"area": "tools",
"policy": "tools.exec.allowHosts",
"reason": "Policy observes global exec host routing posture.",
},
{
"pattern": "tools.elevated.enabled",
"status": "observed",
"area": "tools",
"policy": "tools.elevated.allow",
"reason": "Policy observes global elevated tool posture.",
},
{
"pattern": "tools.elevated.allowFrom.*.*",
"status": "observed",
"area": "tools",
"policy": "tools.elevated.allow",
"reason": "Policy observes global elevated provider allowlists.",
},
{
"pattern": "tools.allow.*",
"status": "observed",
"area": "tools",
"policy": "tool posture evidence",
"reason": "Policy includes global tool allow posture in evidence for attestation drift.",
},
{
"pattern": "tools.alsoAllow.*",
"status": "observed",
"area": "tools",
"policy": "tools.alsoAllow.expected",
"reason": "Policy observes global tools.alsoAllow posture.",
},
{
"pattern": "tools.deny.*",
"status": "observed",
"area": "tools",
"policy": "tools.denyTools",
"reason": "Policy observes global tool deny posture.",
},
{
"pattern": "tools.sandbox.tools.*.*",
"status": "observed",
"area": "tools",
"policy": "tools.denyTools",
"reason": "Policy observes global sandbox tool posture.",
},
{
"pattern": "agents.*.tools.**",
"status": "observed",
"area": "tools",
"policy": "tools.* scoped by agentIds",
"reason": "Policy observes default and per-agent tool posture overrides.",
"allowNoSchemaPath": true,
},
{
"pattern": "agents.list.*.tools.**",
"status": "observed",
"area": "tools",
"policy": "tools.* scoped by agentIds",
"reason": "Policy observes per-agent tool posture overrides.",
},
{
"pattern": "agents.*.sandbox.mode",
"status": "observed",
"area": "sandbox",
"policy": "sandbox.requireMode",
"reason": "Policy observes sandbox mode posture.",
},
{
"pattern": "agents.list.*.sandbox.mode",
"status": "observed",
"area": "sandbox",
"policy": "sandbox.requireMode",
"reason": "Policy observes per-agent sandbox mode posture.",
},
{
"pattern": "agents.*.sandbox.backend",
"status": "observed",
"area": "sandbox",
"policy": "sandbox.allowBackends",
"reason": "Policy observes sandbox backend posture.",
},
{
"pattern": "agents.list.*.sandbox.backend",
"status": "observed",
"area": "sandbox",
"policy": "sandbox.allowBackends",
"reason": "Policy observes per-agent sandbox backend posture.",
},
{
"pattern": "agents.*.sandbox.workspaceAccess",
"status": "observed",
"area": "agents",
"policy": "agents.workspace.allowedAccess",
"reason": "Policy observes sandbox workspace access posture.",
},
{
"pattern": "agents.list.*.sandbox.workspaceAccess",
"status": "observed",
"area": "agents",
"policy": "agents.workspace.allowedAccess",
"reason": "Policy observes per-agent sandbox workspace access posture.",
},
{
"pattern": "agents.*.sandbox.docker.network",
"status": "observed",
"area": "sandbox",
"policy": "sandbox.containers.denyHostNetwork and sandbox.containers.denyContainerNamespaceJoin",
"reason": "Policy observes Docker container network posture.",
},
{
"pattern": "agents.list.*.sandbox.docker.network",
"status": "observed",
"area": "sandbox",
"policy": "sandbox.containers.denyHostNetwork and sandbox.containers.denyContainerNamespaceJoin",
"reason": "Policy observes per-agent Docker container network posture.",
},
{
"pattern": "agents.*.sandbox.docker.binds.*",
"status": "observed",
"area": "sandbox",
"policy": "sandbox.containers.requireReadOnlyMounts and sandbox.containers.denyContainerRuntimeSocketMounts",
"reason": "Policy observes Docker bind mount posture.",
},
{
"pattern": "agents.list.*.sandbox.docker.binds.*",
"status": "observed",
"area": "sandbox",
"policy": "sandbox.containers.requireReadOnlyMounts and sandbox.containers.denyContainerRuntimeSocketMounts",
"reason": "Policy observes per-agent Docker bind mount posture.",
},
{
"pattern": "agents.*.sandbox.browser.binds.*",
"status": "observed",
"area": "sandbox",
"policy": "sandbox.containers.requireReadOnlyMounts",
"reason": "Policy observes sandbox browser bind mount posture.",
},
{
"pattern": "agents.list.*.sandbox.browser.binds.*",
"status": "observed",
"area": "sandbox",
"policy": "sandbox.containers.requireReadOnlyMounts",
"reason": "Policy observes per-agent sandbox browser bind mount posture.",
},
{
"pattern": "agents.*.sandbox.docker.apparmorProfile",
"status": "observed",
"area": "sandbox",
"policy": "sandbox.containers.denyUnconfinedProfiles",
"reason": "Policy observes Docker AppArmor profile posture.",
},
{
"pattern": "agents.list.*.sandbox.docker.apparmorProfile",
"status": "observed",
"area": "sandbox",
"policy": "sandbox.containers.denyUnconfinedProfiles",
"reason": "Policy observes per-agent Docker AppArmor profile posture.",
},
{
"pattern": "agents.*.sandbox.docker.seccompProfile",
"status": "observed",
"area": "sandbox",
"policy": "sandbox.containers.denyUnconfinedProfiles",
"reason": "Policy observes Docker seccomp profile posture.",
},
{
"pattern": "agents.list.*.sandbox.docker.seccompProfile",
"status": "observed",
"area": "sandbox",
"policy": "sandbox.containers.denyUnconfinedProfiles",
"reason": "Policy observes per-agent Docker seccomp profile posture.",
},
{
"pattern": "agents.*.sandbox.docker.dangerouslyAllowContainerNamespaceJoin",
"status": "observed",
"area": "sandbox",
"policy": "sandbox.containers.denyContainerNamespaceJoin",
"reason": "Policy observes explicit Docker namespace-join escape posture.",
},
{
"pattern": "agents.list.*.sandbox.docker.dangerouslyAllowContainerNamespaceJoin",
"status": "observed",
"area": "sandbox",
"policy": "sandbox.containers.denyContainerNamespaceJoin",
"reason": "Policy observes explicit per-agent Docker namespace-join escape posture.",
},
{
"pattern": "agents.*.sandbox.docker.readOnlyRoot",
"status": "observed",
"area": "sandbox",
"policy": "sandbox.containers.requireReadOnlyMounts",
"reason": "Policy observes Docker read-only root posture.",
},
{
"pattern": "agents.list.*.sandbox.docker.readOnlyRoot",
"status": "observed",
"area": "sandbox",
"policy": "sandbox.containers.requireReadOnlyMounts",
"reason": "Policy observes per-agent Docker read-only root posture.",
},
{
"pattern": "agents.*.sandbox.browser.cdpSourceRange",
"status": "observed",
"area": "sandbox",
"policy": "sandbox.browser.requireCdpSourceRange",
"reason": "Policy observes sandbox browser CDP source range posture.",
},
{
"pattern": "agents.list.*.sandbox.browser.cdpSourceRange",
"status": "observed",
"area": "sandbox",
"policy": "sandbox.browser.requireCdpSourceRange",
"reason": "Policy observes per-agent sandbox browser CDP source range posture.",
},
],
}

View File

@@ -1,454 +0,0 @@
import { createHash } from "node:crypto";
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { translateNativeEntries } from "./control-ui-i18n.ts";
export type NativeI18nSurface = "android" | "apple";
export const NATIVE_I18N_LOCALES = [
"zh-CN",
"zh-TW",
"pt-BR",
"de",
"es",
"ja-JP",
"ko",
"fr",
"hi",
"ar",
"it",
"tr",
"uk",
"id",
"pl",
"th",
"vi",
"nl",
"fa",
"ru",
] as const;
export type NativeI18nEntry = {
id: string;
kind: string;
line: number;
path: string;
source: string;
surface: NativeI18nSurface;
};
type Candidate = Omit<NativeI18nEntry, "id">;
type NativeTranslationArtifact = {
entries: Array<{ id: string; source: string; translated: string }>;
locale: string;
version: 1;
};
const HERE = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(HERE, "..");
const OUTPUT_PATH = path.join(ROOT, "apps", ".i18n", "native-source.json");
const TRANSLATIONS_DIR = path.join(ROOT, "apps", ".i18n", "native");
const SOURCE_ROOTS: Record<NativeI18nSurface, string[]> = {
android: [path.join(ROOT, "apps", "android", "app", "src", "main")],
apple: [
path.join(ROOT, "apps", "ios"),
path.join(ROOT, "apps", "macos", "Sources"),
path.join(ROOT, "apps", "shared", "OpenClawKit", "Sources"),
],
};
const ANDROID_EXTENSIONS = new Set([".kt", ".kts"]);
const APPLE_EXTENSIONS = new Set([".swift", ".plist"]);
const APPLE_UI_CALLS =
/(?:Text|Label|Button|TextField|SecureField|Picker|Section|LabeledContent|Toggle|Menu|ShareLink|Link|TextEditor|ProgressView|Gauge|DisclosureGroup|ControlGroup|DatePicker|Stepper)\s*\(\s*"((?:\\.|[^"\\])*)"/gu;
const APPLE_MODIFIER_CALLS =
/\.(?:navigationTitle|accessibilityLabel|accessibilityHint|help|alert|confirmationDialog)\s*\(\s*"((?:\\.|[^"\\])*)"/gu;
const ANDROID_CALLS =
/\b(?:Text|OutlinedTextField|BasicTextField|Button|IconButton|TopAppBar|Snackbar|AlertDialog)\s*\(\s*(?:text\s*=\s*)?"((?:\\.|[^"\\])*)"/gu;
const ANDROID_PROPERTIES =
/\b(?:contentDescription|label|placeholder|title|message|supportingText)\s*=\s*"((?:\\.|[^"\\])*)"/gu;
const ANDROID_WRAPPER_ARGS =
/\b[A-Z][A-Za-z0-9_]*\s*\([^)\n]{0,160}?\b(?:text|title|label|message|contentDescription|placeholder)\s*=\s*"((?:\\.|[^"\\])*)"/gu;
const ANDROID_TOAST_ARGS =
/\b(?:Toast\.makeText|Snackbar\.make)\s*\([^,\n]*,\s*"((?:\\.|[^"\\])*)"/gu;
const ANDROID_DIALOG_CALLS =
/\.(?:setTitle|setMessage|setPositiveButton|setNegativeButton|setNeutralButton)\s*\(\s*"((?:\\.|[^"\\])*)"/gu;
const ANDROID_STATE_CALLS = /\b(?:MutableStateFlow|StateFlow|flowOf)\s*\(\s*"((?:\\.|[^"\\])*)"/gu;
const CONDITIONAL_BRANCHES = [
/\bif\s*\([^)]*\)\s*"((?:\\.|[^"\\])*)"\s*else\s*"((?:\\.|[^"\\])*)"/gu,
/\?\s*"((?:\\.|[^"\\])*)"\s*:\s*"((?:\\.|[^"\\])*)"/gu,
];
const ANDROID_RESOURCE_STRINGS = /<string\b[^>]*>([\s\S]*?)<\/string>/gu;
const APPLE_NAMED_ARGUMENTS =
/\b(?:title|subtitle|label|message|text|prompt|description|help)\s*:\s*"((?:\\.|[^"\\])*)"/gu;
const APPLE_PLIST_STRINGS = /<string>([\s\S]*?)<\/string>/gu;
const GENERATED_PATH_RE = /(?:^|[\\/])(?:build|\.gradle|\.build|DerivedData)(?:$|[\\/])/u;
const EXCLUDED_PATH_RE = /(?:^|[\\/])(?:Tests?|UITests?|test|Preview(?:s)?)(?:$|[\\/])/u;
const EXCLUDED_FILE_RE = /(?:Tests?|UITests?|Previews?|Testing)\.(?:swift|kt|kts)$/u;
const BUILD_SETTING_RE = /\$\([A-Za-z0-9_.-]+\)/gu;
const NATIVE_I18N_LOCALE_SET = new Set<string>(NATIVE_I18N_LOCALES);
function extractSwiftInterpolations(source: string): string[] | null {
const values: string[] = [];
for (let index = 0; index < source.length; index += 1) {
if (source[index] !== "\\" || source[index + 1] !== "(") continue;
const start = index;
let depth = 1;
let quoted = false;
let escaped = false;
for (index += 2; index < source.length; index += 1) {
const character = source[index];
if (escaped) escaped = false;
else if (character === "\\") escaped = true;
else if (character === '"') quoted = !quoted;
else if (!quoted && character === "(") depth += 1;
else if (!quoted && character === ")") {
depth -= 1;
if (depth === 0) {
values.push(source.slice(start, index + 1));
break;
}
}
}
if (depth !== 0) return null;
}
return values;
}
function extractKotlinInterpolations(source: string): string[] | null {
const values = [...source.matchAll(/\$[A-Za-z_][A-Za-z0-9_]*/gu)].map((match) => match[0]);
for (let index = 0; index < source.length; index += 1) {
if (source[index] !== "$" || source[index + 1] !== "{") continue;
const start = index;
let depth = 1;
for (index += 2; index < source.length; index += 1) {
if (source[index] === "{") depth += 1;
else if (source[index] === "}") {
depth -= 1;
if (depth === 0) {
values.push(source.slice(start, index + 1));
break;
}
}
}
if (depth !== 0) return null;
}
return values;
}
function lineNumber(source: string, offset: number): number {
return source.slice(0, offset).split("\n").length;
}
function decodeLiteral(raw: string): string {
try {
return JSON.parse(`"${raw}"`) as string;
} catch {
return raw;
}
}
function normalizeSource(source: string): string {
return source;
}
function structuralTokenSignature(source: string): string {
const swift = extractSwiftInterpolations(source);
const kotlin = extractKotlinInterpolations(source);
const buildSettings = source.match(BUILD_SETTING_RE) ?? [];
const lineBreaks = (source.match(/\n/gu) ?? []).length;
return JSON.stringify({ swift, kotlin, buildSettings, lineBreaks });
}
function isTranslatableCandidate(source: string, kind: string): boolean {
if (BUILD_SETTING_RE.test(source)) {
BUILD_SETTING_RE.lastIndex = 0;
return false;
}
BUILD_SETTING_RE.lastIndex = 0;
if (/^[a-z0-9_.:/$-]+$/u.test(source) || /^[A-Z0-9_.:/$-]+$/u.test(source)) {
return false;
}
if (/[{}[\]]/u.test(source) && !/(?:\\\(|\$\{)/u.test(source)) {
return false;
}
return kind !== "plist-string" || /\s/u.test(source);
}
function addCandidate(
entries: Candidate[],
surface: NativeI18nSurface,
repoPath: string,
source: string,
kind: string,
line: number,
) {
const normalized = normalizeSource(decodeLiteral(source));
if (!normalized.trim() || !/\p{L}/u.test(normalized)) {
return;
}
if (!isTranslatableCandidate(normalized, kind)) {
return;
}
if (
normalized.length > 500 ||
extractSwiftInterpolations(normalized) === null ||
extractKotlinInterpolations(normalized) === null
) {
return;
}
entries.push({ kind, line, path: repoPath, source: normalized, surface });
}
function extractCandidates(
surface: NativeI18nSurface,
repoPath: string,
source: string,
): Candidate[] {
const entries: Candidate[] = [];
const patterns =
surface === "apple"
? [
[APPLE_UI_CALLS, "ui-call"],
[APPLE_MODIFIER_CALLS, "ui-modifier"],
[APPLE_NAMED_ARGUMENTS, "ui-named-argument"],
...CONDITIONAL_BRANCHES.map((pattern) => [pattern, "conditional-branch"] as const),
]
: [
[ANDROID_CALLS, "ui-call"],
[ANDROID_PROPERTIES, "ui-property"],
[ANDROID_WRAPPER_ARGS, "ui-wrapper-argument"],
[ANDROID_TOAST_ARGS, "ui-toast"],
[ANDROID_DIALOG_CALLS, "ui-dialog"],
[ANDROID_STATE_CALLS, "ui-state"],
...CONDITIONAL_BRANCHES.map((pattern) => [pattern, "conditional-branch"] as const),
];
for (const [pattern, kind] of patterns) {
for (const match of source.matchAll(pattern)) {
const offset = match.index ?? 0;
for (const value of match.slice(1)) {
if (value) {
addCandidate(entries, surface, repoPath, value, kind, lineNumber(source, offset));
}
}
}
}
if (surface === "android" && repoPath.endsWith("/res/values/strings.xml")) {
for (const match of source.matchAll(ANDROID_RESOURCE_STRINGS)) {
if (match[1])
addCandidate(
entries,
surface,
repoPath,
match[1],
"resource-string",
lineNumber(source, match.index ?? 0),
);
}
}
if (surface === "apple" && repoPath.endsWith(".plist")) {
for (const match of source.matchAll(APPLE_PLIST_STRINGS)) {
if (match[1])
addCandidate(
entries,
surface,
repoPath,
match[1],
"plist-string",
lineNumber(source, match.index ?? 0),
);
}
}
return entries;
}
async function walkFiles(
root: string,
surface: NativeI18nSurface,
out: string[] = [],
): Promise<string[]> {
const entries = await readdir(root, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(root, entry.name);
if (entry.isDirectory()) {
if (GENERATED_PATH_RE.test(fullPath) || EXCLUDED_PATH_RE.test(fullPath)) {
continue;
}
await walkFiles(fullPath, surface, out);
continue;
}
const extension = path.extname(entry.name);
const allowed =
surface === "apple"
? APPLE_EXTENSIONS
: fullPath.endsWith(`${path.sep}res${path.sep}values${path.sep}strings.xml`)
? new Set([...ANDROID_EXTENSIONS, ".xml"])
: ANDROID_EXTENSIONS;
if (entry.isFile() && allowed.has(extension) && !EXCLUDED_FILE_RE.test(entry.name)) {
out.push(fullPath);
}
}
return out;
}
function withIds(entries: Candidate[]): NativeI18nEntry[] {
const seen = new Set<string>();
const unique = [
...new Map(
entries.map((entry) => [`${entry.surface}\u0000${entry.path}\u0000${entry.source}`, entry]),
).values(),
];
return unique
.toSorted(
(left, right) =>
left.surface.localeCompare(right.surface) ||
left.path.localeCompare(right.path) ||
left.line - right.line ||
left.kind.localeCompare(right.kind) ||
left.source.localeCompare(right.source),
)
.map((entry) => {
const digest = createHash("sha256")
.update([entry.surface, entry.path, entry.kind, entry.source].join("\u0000"))
.digest("hex")
.slice(0, 16);
let id = `native.${entry.surface}.${digest}`;
if (seen.has(id)) {
id = `${id}.${entry.line}`;
}
seen.add(id);
return { ...entry, id };
});
}
export async function collectNativeI18nEntries(): Promise<NativeI18nEntry[]> {
const entries: Candidate[] = [];
for (const surface of ["android", "apple"] as const) {
for (const sourceRoot of SOURCE_ROOTS[surface]) {
const files = await walkFiles(sourceRoot, surface);
for (const filePath of files.toSorted()) {
const source = await readFile(filePath, "utf8");
const repoPath = path.relative(ROOT, filePath).split(path.sep).join("/");
entries.push(...extractCandidates(surface, repoPath, source));
}
}
}
return withIds(entries);
}
function render(entries: NativeI18nEntry[]): string {
return `${JSON.stringify({ version: 1, entries }, null, 2)}\n`;
}
export async function syncNativeI18n(options: { checkOnly: boolean; write: boolean }) {
const expected = render(await collectNativeI18nEntries());
let current = "";
try {
current = await readFile(OUTPUT_PATH, "utf8");
} catch {
// The first sync creates the inventory.
}
if (current !== expected && options.checkOnly) {
throw new Error(
"native app i18n inventory drift detected. Run `pnpm native:i18n:sync` and commit apps/.i18n/native-source.json.",
);
}
if (current !== expected && options.write) {
await mkdir(path.dirname(OUTPUT_PATH), { recursive: true });
await writeFile(OUTPUT_PATH, expected, "utf8");
}
const count = JSON.parse(expected).entries.length as number;
process.stdout.write(`native-app-i18n: entries=${count} changed=${current !== expected}\n`);
}
async function loadGlossary(locale: string): Promise<Array<{ source: string; target: string }>> {
try {
return JSON.parse(
await readFile(
path.join(ROOT, "ui", "src", "i18n", ".i18n", `glossary.${locale}.json`),
"utf8",
),
) as Array<{ source: string; target: string }>;
} catch {
return [];
}
}
async function syncNativeLocale(locale: string, entries: NativeI18nEntry[]) {
// Native runtime resources are owned by the Android and Apple slices; these
// artifacts keep the shared translation-memory handoff current between them.
const artifactPath = path.join(TRANSLATIONS_DIR, `${locale}.json`);
let previous: NativeTranslationArtifact = { entries: [], locale, version: 1 };
try {
previous = JSON.parse(await readFile(artifactPath, "utf8")) as NativeTranslationArtifact;
} catch {
// The first refresh creates the locale artifact.
}
const previousById = new Map(previous.entries.map((entry) => [entry.id, entry]));
const pending = entries
.filter((entry) => {
const current = previousById.get(entry.id);
return !current || current.source !== entry.source || !current.translated.trim();
})
.map((entry) => ({
id: entry.id,
source: entry.source,
sourcePath: entry.path,
}));
const translated = pending.length
? await translateNativeEntries(pending, locale, await loadGlossary(locale))
: new Map<string, string>();
const artifact: NativeTranslationArtifact = {
version: 1,
locale,
entries: entries.map((entry) => ({
id: entry.id,
source: entry.source,
translated:
translated.get(entry.id) ?? previousById.get(entry.id)?.translated ?? entry.source,
})),
};
for (const entry of artifact.entries) {
if (structuralTokenSignature(entry.source) !== structuralTokenSignature(entry.translated)) {
throw new Error(
`native translation changed placeholders or line breaks for ${locale}:${entry.id}`,
);
}
}
await mkdir(TRANSLATIONS_DIR, { recursive: true });
await writeFile(artifactPath, `${JSON.stringify(artifact, null, 2)}\n`, "utf8");
process.stdout.write(
`native-app-i18n: locale=${locale} entries=${entries.length} translated=${translated.size}\n`,
);
}
async function main() {
const [command, ...args] = process.argv.slice(2);
if (command !== "check" && command !== "sync") {
throw new Error(
"usage: node --import tsx scripts/native-app-i18n.ts check|sync [--write] [--locale <code>]",
);
}
await syncNativeI18n({
checkOnly: command === "check",
write: command === "sync" && process.argv.includes("--write"),
});
const localeFlag = args.indexOf("--locale");
const locale = localeFlag >= 0 ? args[localeFlag + 1] : undefined;
if (locale) {
if (command !== "sync" || !process.argv.includes("--write")) {
throw new Error("native locale refresh requires `sync --write --locale <code>`");
}
if (!NATIVE_I18N_LOCALE_SET.has(locale)) {
throw new Error(
`unsupported native locale "${locale}". Expected one of: ${NATIVE_I18N_LOCALES.join(", ")}`,
);
}
await syncNativeLocale(locale, await collectNativeI18nEntries());
}
}
if (process.argv[1] && import.meta.url === `file://${path.resolve(process.argv[1])}`) {
await main();
}

View File

@@ -746,7 +746,6 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
["scripts/ci-changed-scope.mjs", ["src/scripts/ci-changed-scope.test.ts"]],
["scripts/ci-docker-pull-retry.sh", ["test/scripts/ci-docker-pull-retry.test.ts"]],
["scripts/control-ui-i18n.ts", ["test/scripts/control-ui-i18n.test.ts"]],
["scripts/native-app-i18n.ts", ["test/scripts/native-app-i18n.test.ts"]],
[
"scripts/copy-bundled-plugin-metadata.mjs",
["src/plugins/copy-bundled-plugin-metadata.test.ts", "src/infra/run-node.test.ts"],

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { collectDeliveredMediaUrls } from "./delivery-evidence.js";
describe("collectDeliveredMediaUrls attachment recursion", () => {
it("collects media URLs across nested attachments", () => {
const urls = collectDeliveredMediaUrls({
payloads: [
{
url: "https://example.com/root.png",
attachments: [
{ mediaUrl: "https://example.com/child.png" },
{ attachments: [{ filePath: "/tmp/grandchild.jpg" }] },
],
},
],
});
expect(urls.toSorted()).toEqual([
"/tmp/grandchild.jpg",
"https://example.com/child.png",
"https://example.com/root.png",
]);
});
it("does not overflow the stack on a self-referential attachments cycle", () => {
// Payloads arrive as in-process `unknown` objects; a malformed self-referential
// attachments chain previously recursed until the stack overflowed.
const cyclic: Record<string, unknown> = { url: "https://example.com/loop.png" };
cyclic.attachments = [cyclic];
let urls: string[] = [];
expect(() => {
urls = collectDeliveredMediaUrls({ payloads: [cyclic] });
}).not.toThrow();
expect(urls).toEqual(["https://example.com/loop.png"]);
});
it("does not overflow on a mutual attachments cycle", () => {
const a: Record<string, unknown> = { mediaUrl: "https://example.com/a.png" };
const b: Record<string, unknown> = { mediaUrl: "https://example.com/b.png" };
a.attachments = [b];
b.attachments = [a];
const urls = collectDeliveredMediaUrls({ payloads: [a] });
expect(urls.toSorted()).toEqual(["https://example.com/a.png", "https://example.com/b.png"]);
});
});

View File

@@ -80,7 +80,19 @@ function collectStringValues(value: unknown, output: Set<string>) {
}
}
function collectMediaUrlsFromRecord(record: Record<string, unknown>, output: Set<string>) {
function collectMediaUrlsFromRecord(
record: Record<string, unknown>,
output: Set<string>,
// Payloads arrive as in-process `unknown` objects, so a malformed
// self-referential `attachments` chain would recurse until the stack
// overflows. Track visited records to bound the descent, matching
// redactStringsDeep in embedded-agent-subscribe.tools.ts.
seen = new WeakSet<object>(),
) {
if (seen.has(record)) {
return;
}
seen.add(record);
collectStringValues(record.mediaUrl, output);
collectStringValues(record.mediaUrls, output);
collectStringValues(record.path, output);
@@ -90,7 +102,7 @@ function collectMediaUrlsFromRecord(record: Record<string, unknown>, output: Set
if (Array.isArray(attachments)) {
for (const attachment of attachments) {
if (attachment && typeof attachment === "object" && !Array.isArray(attachment)) {
collectMediaUrlsFromRecord(attachment as Record<string, unknown>, output);
collectMediaUrlsFromRecord(attachment as Record<string, unknown>, output, seen);
}
}
}

View File

@@ -1130,6 +1130,37 @@ describe("buildGuardedModelFetch", () => {
expect(items).toEqual([{ ok: true }]);
});
it("handles a large transport chunk containing many valid small SSE events", async () => {
// Regression: one TCP read can deliver >64 KiB of already-delimited SSE
// events; the cap must apply only to the unterminated tail, not the full chunk.
const eventCount = 5_000;
const manyEvents = `data: ${JSON.stringify({ ok: true })}\n\n`.repeat(eventCount);
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response(manyEvents, {
headers: { "content-type": "text/event-stream" },
}),
finalUrl: "https://openrouter.ai/api/v1/chat/completions",
release: vi.fn(async () => undefined),
});
const model = {
id: "gpt-5.4",
provider: "openrouter",
api: "openai-completions",
baseUrl: "https://openrouter.ai/api/v1",
} as unknown as Model<"openai-completions">;
const response = await buildGuardedModelFetch(model)(
"https://openrouter.ai/api/v1/chat/completions",
{ method: "POST" },
);
const items: unknown[] = [];
for await (const item of Stream.fromSSEResponse(response, new AbortController())) {
items.push(item);
}
expect(items.length).toBe(eventCount);
expect(items[0]).toEqual({ ok: true });
});
it("synthesizes SSE frames for JSON bodies returned to streaming OpenAI SDK requests", async () => {
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response(' {"ok": true} ', {
@@ -1338,6 +1369,102 @@ describe("buildGuardedModelFetch", () => {
expect(refreshTimeout).toHaveBeenCalledTimes(2);
});
it("errors on oversized SSE body without event boundary in sanitizer", async () => {
const oversized = "x".repeat(65 * 1024);
const encoder = new TextEncoder();
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response(
new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode(oversized));
controller.close();
},
}),
{ headers: { "content-type": "text/event-stream" } },
),
finalUrl: "https://openrouter.ai/api/v1/chat/completions",
release: vi.fn(async () => undefined),
});
const model = {
id: "gpt-5.4",
provider: "openrouter",
api: "openai-completions",
baseUrl: "https://openrouter.ai/api/v1",
} as unknown as Model<"openai-completions">;
const response = await buildGuardedModelFetch(model)(
"https://openrouter.ai/api/v1/chat/completions",
{ method: "POST" },
);
const reader = response.body?.getReader();
let caught: unknown = null;
try {
while (true) {
const { done } = await reader!.read();
if (done) {
break;
}
}
} catch (e) {
caught = e;
}
expect(caught).toBeTruthy();
expect(String(caught)).toMatch(/exceeded max buffer size/i);
});
it("errors on oversized streaming JSON body without content-length in SSE synthesis", async () => {
const CHUNK = 1024 * 1024;
let sends = 0;
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response(
new ReadableStream({
pull(controller) {
if (sends < 17) {
sends++;
controller.enqueue(new Uint8Array(CHUNK));
} else {
controller.close();
}
},
}),
{ headers: { "content-type": "application/json" } },
),
finalUrl: "https://openrouter.ai/api/v1/chat/completions",
release: vi.fn(async () => undefined),
});
const model = {
id: "moonshotai/kimi-k2.6",
provider: "openrouter",
api: "openai-completions",
baseUrl: "https://openrouter.ai/api/v1",
} as unknown as Model<"openai-completions">;
const response = await buildGuardedModelFetch(model)(
"https://openrouter.ai/api/v1/chat/completions",
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ model: "moonshotai/kimi-k2.6", stream: true }),
},
);
const reader = response.body?.getReader();
let caught: unknown = null;
try {
while (true) {
const { done } = await reader!.read();
if (done) {
break;
}
}
} catch (e) {
caught = e;
}
expect(caught).toBeTruthy();
expect(String(caught)).toMatch(/exceeded.*bytes while synthesizing SSE/i);
});
describe("long retry-after handling", () => {
const anthropicModel = {
id: "sonnet-4.6",

View File

@@ -45,6 +45,17 @@ import {
const DEFAULT_MAX_SDK_RETRY_WAIT_SECONDS = 60;
const OPENAI_SDK_STREAM_CONTENT_SNIFF_BYTES = 2 * 1024;
const log = createSubsystemLogger("provider-transport-fetch");
/** Max bytes for an entire JSON body synthesized into SSE frames. Prevents OOM
* when a hostile streaming endpoint returns a never-ending JSON response
* without Content-Length. */
const SSE_SYNTHESIZE_JSON_MAX_BYTES = 16 * 1024 * 1024;
/** Max bytes for the internal SSE sanitization buffer between event boundaries.
* A response that cannot find a \n\n boundary within this many characters is
* almost certainly hostile or broken — cap the buffer rather than let it grow. */
const SSE_SANITIZE_BUFFER_MAX_BYTES = 64 * 1024;
const BLOCKED_EXACT_ORIGIN_TRUST_HOSTNAME_LABELS = new Set(["instance-data"]);
const PLAIN_DECIMAL_NUMBER_RE = /^\d+(?:\.\d+)?$/;
const RETRY_AFTER_HTTP_DATE_RE =
@@ -102,6 +113,7 @@ function sanitizeOpenAISdkSseResponse(
const encoder = new TextEncoder();
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
let buffer = "";
let totalBytes = 0;
const sseBody = new ReadableStream<Uint8Array>({
start() {
reader = source.getReader();
@@ -120,9 +132,17 @@ function sanitizeOpenAISdkSseResponse(
controller.close();
return;
}
const nextTotalBytes = totalBytes + chunk.value.byteLength;
if (nextTotalBytes > SSE_SYNTHESIZE_JSON_MAX_BYTES) {
throw new Error(
`Streaming JSON body exceeded ${SSE_SYNTHESIZE_JSON_MAX_BYTES} bytes while synthesizing SSE frames`,
);
}
totalBytes = nextTotalBytes;
buffer += decoder.decode(chunk.value, { stream: true });
}
} catch (error) {
await reader?.cancel(error).catch(() => {});
controller.error(error);
}
},
@@ -157,6 +177,11 @@ function sanitizeOpenAISdkSseResponse(
for (;;) {
const boundary = findSseEventBoundary(buffer);
if (!boundary) {
if (buffer.length > SSE_SANITIZE_BUFFER_MAX_BYTES) {
throw new Error(
`SSE response exceeded max buffer size (${SSE_SANITIZE_BUFFER_MAX_BYTES} bytes) without event boundary`,
);
}
return enqueued;
}
const block = buffer.slice(0, boundary.index);
@@ -167,6 +192,7 @@ function sanitizeOpenAISdkSseResponse(
if (hasReadableSseData(block)) {
controller.enqueue(encoder.encode(`${block}${separator}`));
enqueued += 1;
return enqueued;
}
}
};
@@ -178,6 +204,10 @@ function sanitizeOpenAISdkSseResponse(
async pull(controller) {
try {
for (;;) {
const pending = enqueueSanitized(controller, "");
if (pending > 0) {
return;
}
const chunk = await reader?.read();
if (!chunk || chunk.done) {
const tail = decoder.decode();
@@ -200,6 +230,7 @@ function sanitizeOpenAISdkSseResponse(
}
}
} catch (error) {
await reader?.cancel(error).catch(() => {});
controller.error(error);
}
},

View File

@@ -0,0 +1,87 @@
/**
* Bounded SSE / NDJSON stream reader guard.
*
* Wraps a `ReadableStreamDefaultReader<Uint8Array>` so the caller's existing
* chunk-by-chunk parsing logic is unchanged, but accumulated bytes are tracked
* against a hard cap. On overflow the underlying reader is cancelled and a
* canonical error is thrown. Mirrors the `readResponseWithLimit` / bounded
* JSON response pattern (see `src/agents/provider-http-errors.ts`).
*
* Internal helper for now. If extensions need it, promote to a plugin-SDK
* subpath in a separate, dedicated PR with full SDK metadata sync.
*/
export type SseStreamOverflow = {
size: number;
maxBytes: number;
};
export type ReadSseStreamWithLimitOptions = {
maxBytes: number;
onOverflow?: (params: SseStreamOverflow) => Error;
};
export type SseByteGuard = {
read(): Promise<ReadableStreamReadResult<Uint8Array>>;
cancel(reason?: unknown): Promise<void>;
totalBytes(): number;
overflowed(): boolean;
cancelled(): boolean;
};
export function createSseByteGuard(
reader: ReadableStreamDefaultReader<Uint8Array>,
opts: ReadSseStreamWithLimitOptions,
): SseByteGuard {
if (!Number.isFinite(opts.maxBytes) || opts.maxBytes < 0) {
throw new RangeError(`maxBytes must be a non-negative finite number: ${opts.maxBytes}`);
}
const onOverflow =
opts.onOverflow ??
((params) =>
new Error(`SSE stream exceeds ${params.maxBytes} bytes (received ${params.size})`));
let total = 0;
let overflowedFlag = false;
let cancelledFlag = false;
return {
read: async () => {
if (overflowedFlag || cancelledFlag) {
return { done: true, value: undefined };
}
const result = await reader.read();
if (result.done) {
return result;
}
const chunkLen = result.value?.byteLength ?? 0;
const next = total + chunkLen;
if (next > opts.maxBytes) {
overflowedFlag = true;
cancelledFlag = true;
const err = onOverflow({ size: next, maxBytes: opts.maxBytes });
try {
await reader.cancel(err);
} catch {
// best-effort cancellation; caller observes the overflow error
}
throw err;
}
total = next;
return result;
},
cancel: async (reason?: unknown) => {
if (overflowedFlag) {
// overflow already set cancelledFlag; do not overwrite
return;
}
cancelledFlag = true;
try {
await reader.cancel(reason);
} catch {
// best-effort cancellation
}
},
totalBytes: () => total,
overflowed: () => overflowedFlag,
cancelled: () => cancelledFlag,
};
}

View File

@@ -0,0 +1,81 @@
/**
* Regression coverage for surrogate-safe truncation in compact tool display
* detail coercion (coerceDisplayValue, reached via resolveToolVerbAndDetailForArgs
* -> resolveDetailFromKeys).
*/
import { describe, expect, it } from "vitest";
import { resolveToolVerbAndDetailForArgs } from "./tool-display-common.js";
function isHighSurrogate(codeUnit: number): boolean {
return codeUnit >= 0xd800 && codeUnit <= 0xdbff;
}
function isLowSurrogate(codeUnit: number): boolean {
return codeUnit >= 0xdc00 && codeUnit <= 0xdfff;
}
function hasLoneSurrogate(value: string): boolean {
for (let i = 0; i < value.length; i += 1) {
const codeUnit = value.charCodeAt(i);
if (isHighSurrogate(codeUnit)) {
if (i + 1 >= value.length || !isLowSurrogate(value.charCodeAt(i + 1))) {
return true;
}
} else if (isLowSurrogate(codeUnit)) {
if (i === 0 || !isHighSurrogate(value.charCodeAt(i - 1))) {
return true;
}
}
}
return false;
}
describe("coerceDisplayValue surrogate-safe truncation", () => {
it("does not split an emoji across the truncation boundary (default maxStringChars=160)", () => {
// 200 UTF-16 units: 78 'a', an emoji (surrogate pair at indices 78-79), 120 'b'.
// With maxStringChars=160, half = floor(159/2) = 79, so the naive
// firstLine.slice(0, 79) keeps only the emoji's high surrogate at index 78.
const detailValue = `${"a".repeat(78)}\u{1F600}${"b".repeat(120)}`;
expect(detailValue.length).toBe(200);
const { detail } = resolveToolVerbAndDetailForArgs({
toolKey: "custom_tool",
args: { note: detailValue },
fallbackDetailKeys: ["note"],
detailMode: "first",
});
expect(detail).toBeDefined();
// The bug rendered a lone high surrogate (and possibly a lone low surrogate
// at the tail head); the fix must drop the whole emoji at the cut.
expect(hasLoneSurrogate(detail as string)).toBe(false);
// Head keeps only the 78 leading 'a's (emoji dropped, not half-kept).
expect((detail as string).split("…")[0]).toBe("a".repeat(78));
// Tail must not begin mid-pair on a lone low surrogate.
const tail = (detail as string).split("…")[1] ?? "";
expect(isLowSurrogate(tail.charCodeAt(0))).toBe(false);
});
it("leaves plain (non-surrogate) long values truncated as before", () => {
const detailValue = "x".repeat(300);
const { detail } = resolveToolVerbAndDetailForArgs({
toolKey: "custom_tool",
args: { note: detailValue },
fallbackDetailKeys: ["note"],
detailMode: "first",
});
// Behavior-preserving for ASCII: half = 79, so 79 'x' + ellipsis + 80 'x'.
expect(detail).toBe(`${"x".repeat(79)}${"x".repeat(80)}`);
expect(hasLoneSurrogate(detail as string)).toBe(false);
});
it("returns short values unchanged", () => {
const { detail } = resolveToolVerbAndDetailForArgs({
toolKey: "custom_tool",
args: { note: "short value with no emoji" },
fallbackDetailKeys: ["note"],
detailMode: "first",
});
expect(detail).toBe("short value with no emoji");
});
});

View File

@@ -3,15 +3,14 @@
* Redacts and summarizes arguments into short labels/details for chat and UI
* tool update streams.
*/
import {
asOptionalObjectRecord as asRecord,
} from "@openclaw/normalization-core/record-coerce";
import { asOptionalObjectRecord as asRecord } from "@openclaw/normalization-core/record-coerce";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "@openclaw/normalization-core/string-coerce";
import { parseStrictFiniteNumber } from "../infra/parse-finite-number.js";
import { redactToolPayloadText } from "../logging/redact.js";
import { sliceUtf16Safe } from "../shared/utf16-slice.js";
import { resolveExecDetail, type ToolDetailMode } from "./tool-display-exec.js";
type ToolDisplayActionSpec = {
@@ -136,7 +135,7 @@ function coerceDisplayValue(
const firstLine = redactToolPayloadText(rawLine);
if (firstLine.length > maxStringChars) {
const half = Math.floor((maxStringChars - 1) / 2);
return `${firstLine.slice(0, half)}${firstLine.slice(-(maxStringChars - 1 - half))}`;
return `${sliceUtf16Safe(firstLine, 0, half)}${sliceUtf16Safe(firstLine, -(maxStringChars - 1 - half))}`;
}
return firstLine;
}

View File

@@ -5,6 +5,7 @@
*/
import { asOptionalObjectRecord as asRecord } from "@openclaw/normalization-core/record-coerce";
import { redactToolPayloadText } from "../logging/redact.js";
import { sliceUtf16Safe } from "../shared/utf16-slice.js";
import {
binaryName,
firstPositional,
@@ -442,7 +443,7 @@ function compactRawCommand(raw: string, maxLength = 120): string {
return oneLine;
}
const half = Math.floor((maxLength - 1) / 2);
return `${oneLine.slice(0, half)}${oneLine.slice(-(maxLength - 1 - half))}`;
return `${sliceUtf16Safe(oneLine, 0, half)}${sliceUtf16Safe(oneLine, -(maxLength - 1 - half))}`;
}
export type ToolDetailMode = "explain" | "raw";

View File

@@ -562,6 +562,28 @@ describe("compactRawCommand middle truncation", () => {
expect(result).not.toContain("AKIDABCDEFGHIJKLMNOP1234567890");
expect(result).toContain("AKIDAB…7890");
});
it("does not split a surrogate pair when the head boundary lands on an emoji", () => {
// The one-line form is 140 UTF-16 units. With the default maxLength=120 the head
// slice ends at index 59, but the 😀 emoji (U+1F600, a surrogate pair) occupies
// indices 58-59 — so a raw .slice(0, 59) would keep the high surrogate and drop
// its low half, leaving a lone surrogate that renders as the replacement char.
const emoji = String.fromCodePoint(0x1f600);
// Unknown binary so resolveExecDetail returns the compact raw form directly.
const longCommand = `/opt/custom/bin/run ${"a".repeat(38)}${emoji}${"b".repeat(80)}`;
const result = resolveExecDetail({ command: longCommand });
expect(result).toBeDefined();
// The whole emoji is dropped at the boundary rather than half of it.
expect(result).not.toContain(emoji);
// No dangling/lone surrogate code units remain in the rendered detail.
expect(result).not.toMatch(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/);
expect(result).not.toMatch(/(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/);
// Start and end of the command are still preserved around the ellipsis.
expect(result).toContain("/opt/custom/bin/run");
expect(result).toContain("…");
expect(result).toMatch(/b{4}$/);
});
});
describe("coerceDisplayValue middle truncation", () => {

View File

@@ -735,6 +735,69 @@ describe("message tool secret scoping", () => {
expect(Array.from(secretResolveCall.targetIds ?? [])).toEqual(["channels.telegram.botToken"]);
});
it("preserves empty opaque target segments in inferred session delivery", async () => {
mockSendResult();
const input = await executeSend({
action: { message: "hi" },
toolOptions: {
config: {
channels: {
telegram: {
botToken: { source: "env", provider: "default", id: "TELEGRAM_BOT_TOKEN" },
},
},
} as never,
sourceReplyDeliveryMode: "message_tool_only",
currentChannelProvider: "webchat",
agentSessionKey: "agent:main:telegram:group:room::part",
},
});
expect(input?.toolContext?.currentChannelProvider).toBe("telegram");
expect(input?.toolContext?.currentChannelId).toBe("room::part");
});
it("does not infer delivery from empty structural session segments", async () => {
mockSendResult();
const input = await executeSend({
action: { message: "hi" },
toolOptions: {
config: {
channels: {
telegram: {
botToken: { source: "env", provider: "default", id: "TELEGRAM_BOT_TOKEN" },
},
},
} as never,
sourceReplyDeliveryMode: "message_tool_only",
currentChannelProvider: "webchat",
agentSessionKey: "agent:main:telegram::group:room",
},
});
expect(input?.toolContext?.currentChannelProvider).toBe("webchat");
expect(input?.toolContext?.currentChannelId).toBeUndefined();
});
it("does not infer delivery from a nested opaque agent identity", async () => {
mockSendResult();
const input = await executeSend({
action: { message: "hi" },
toolOptions: {
config: {} as never,
sourceReplyDeliveryMode: "message_tool_only",
currentChannelProvider: "webchat",
agentSessionKey: "agent:voice:agent:channel:room",
},
});
expect(input?.toolContext?.currentChannelProvider).toBe("webchat");
expect(input?.toolContext?.currentChannelId).toBeUndefined();
});
it("preserves direct session keys as explicit user targets when ambient channel drifted to webchat", async () => {
mockSendResult({ channel: "discord", to: "user:123456789" });

View File

@@ -57,11 +57,7 @@ import {
import { hasReplyPayloadContent } from "../../interactive/payload.js";
import { stringifyRouteThreadId } from "../../plugin-sdk/channel-route.js";
import { POLL_CREATION_PARAM_DEFS, SHARED_POLL_CREATION_PARAM_NAMES } from "../../poll-params.js";
import {
normalizeAccountId,
parseAgentSessionKey,
parseThreadSessionSuffix,
} from "../../routing/session-key.js";
import { normalizeAccountId, parseSessionDeliveryRoute } from "../../routing/session-key.js";
import { stripFormattedReasoningMessage } from "../../shared/text/formatted-reasoning-message.js";
import { normalizeMessageChannel } from "../../utils/message-channel.js";
import { resolveSessionAgentId } from "../agent-scope.js";
@@ -863,7 +859,6 @@ type InferredSessionDelivery = {
to: string;
};
const SESSION_DELIVERY_PEER_KINDS = new Set(["channel", "direct", "dm", "group"]);
const USER_PREFIXED_DIRECT_TARGET_CHANNELS = new Set(["discord", "mattermost", "msteams", "slack"]);
function formatSessionDeliveryTarget(channel: string, peerKind: string, to: string): string {
@@ -876,44 +871,21 @@ function formatSessionDeliveryTarget(channel: string, peerKind: string, to: stri
function inferDeliveryFromSessionKey(
sessionKey: string | undefined,
): InferredSessionDelivery | null {
const parsedThread = parseThreadSessionSuffix(sessionKey);
const baseSessionKey = parsedThread.baseSessionKey ?? sessionKey;
const parsed = parseAgentSessionKey(baseSessionKey);
if (!parsed) {
const route = parseSessionDeliveryRoute(sessionKey);
if (!route) {
return null;
}
const parts = parsed.rest.split(":").filter(Boolean);
if (parts.length < 3) {
return null;
}
const channel = normalizeMessageChannel(parts[0]);
const channel = normalizeMessageChannel(route.channel);
if (!channel) {
return null;
}
if (parts.length >= 4 && (parts[2] === "direct" || parts[2] === "dm")) {
const accountId = resolveAgentAccountId(parts[1]);
const to = parts.slice(3).join(":").trim();
return to
? {
accountId,
channel,
threadId: parsedThread.threadId,
to: formatSessionDeliveryTarget(channel, parts[2], to),
}
: null;
}
const peerKind = parts[1] ?? "";
if (SESSION_DELIVERY_PEER_KINDS.has(peerKind)) {
const to = parts.slice(2).join(":").trim();
return to
? {
channel,
threadId: parsedThread.threadId,
to: formatSessionDeliveryTarget(channel, peerKind, to),
}
: null;
}
return null;
const accountId = route.accountId ? resolveAgentAccountId(route.accountId) : undefined;
return {
accountId,
channel,
threadId: route.threadId,
to: formatSessionDeliveryTarget(channel, route.peerKind, route.peerId),
};
}
function resolveEffectiveCurrentChannelContext(options?: MessageToolOptions): {

View File

@@ -1,31 +1,16 @@
// Nodes CLI plugin registration tests cover node command plugin registration.
// Built-in node command registration runs for real so the guard is exercised against the actual
// registered subcommand names; only the plugin-loader boundary is stubbed.
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { loggingState } from "../logging/state.js";
const registerPluginCliCommandsFromValidatedConfig = vi.fn(async () => ({}));
const registerNodesCameraCommands = vi.fn();
const registerNodesInvokeCommands = vi.fn();
const registerNodesLocationCommands = vi.fn();
const registerNodesNotifyCommand = vi.fn();
const registerNodesPairingCommands = vi.fn();
const registerNodesPushCommand = vi.fn();
const registerNodesScreenCommands = vi.fn();
const registerNodesStatusCommands = vi.fn();
vi.mock("../plugins/cli.js", () => ({
registerPluginCliCommandsFromValidatedConfig,
}));
vi.mock("./nodes-cli/register.camera.js", () => ({ registerNodesCameraCommands }));
vi.mock("./nodes-cli/register.invoke.js", () => ({ registerNodesInvokeCommands }));
vi.mock("./nodes-cli/register.location.js", () => ({ registerNodesLocationCommands }));
vi.mock("./nodes-cli/register.notify.js", () => ({ registerNodesNotifyCommand }));
vi.mock("./nodes-cli/register.pairing.js", () => ({ registerNodesPairingCommands }));
vi.mock("./nodes-cli/register.push.js", () => ({ registerNodesPushCommand }));
vi.mock("./nodes-cli/register.screen.js", () => ({ registerNodesScreenCommands }));
vi.mock("./nodes-cli/register.status.js", () => ({ registerNodesStatusCommands }));
const { registerNodesCli } = await import("./nodes-cli/register.js");
describe("registerNodesCli plugin registration", () => {
@@ -50,14 +35,29 @@ describe("registerNodesCli plugin registration", () => {
return program;
}
it("routes plugin registration logs to stderr for nodes --json commands", async () => {
it("skips plugin CLI/runtime registration for built-in nodes subcommands", async () => {
for (const subcommand of ["status", "list", "describe", "invoke", "pending", "camera"]) {
registerPluginCliCommandsFromValidatedConfig.mockClear();
await registerWithArgv(["node", "openclaw", "nodes", subcommand, "--json"]);
expect(registerPluginCliCommandsFromValidatedConfig).not.toHaveBeenCalled();
}
});
it("registers plugin-provided node subcommands lazily and routes their logs to stderr", async () => {
let forceStderrDuringRegistration = false;
registerPluginCliCommandsFromValidatedConfig.mockImplementationOnce(async () => {
forceStderrDuringRegistration = loggingState.forceConsoleToStderr;
return {};
});
const program = await registerWithArgv(["node", "openclaw", "nodes", "list", "--json"]);
const program = await registerWithArgv([
"node",
"openclaw",
"nodes",
"canvas",
"snapshot",
"--json",
]);
expect(registerPluginCliCommandsFromValidatedConfig).toHaveBeenCalledWith(
program,
@@ -69,6 +69,16 @@ describe("registerNodesCli plugin registration", () => {
expect(loggingState.forceConsoleToStderr).toBe(false);
});
it("surfaces plugin subcommands for bare `nodes` listing", async () => {
const program = await registerWithArgv(["node", "openclaw", "nodes"]);
expect(registerPluginCliCommandsFromValidatedConfig).toHaveBeenCalledWith(
program,
undefined,
undefined,
{ mode: "lazy", primary: "nodes" },
);
});
it("does not route pass-through --json after the terminator", async () => {
let forceStderrDuringRegistration = true;
registerPluginCliCommandsFromValidatedConfig.mockImplementationOnce(async () => {
@@ -76,7 +86,7 @@ describe("registerNodesCli plugin registration", () => {
return {};
});
await registerWithArgv(["node", "openclaw", "nodes", "invoke", "--", "--json"]);
await registerWithArgv(["node", "openclaw", "nodes", "canvas", "--", "--json"]);
expect(forceStderrDuringRegistration).toBe(false);
expect(loggingState.forceConsoleToStderr).toBe(false);

View File

@@ -2,6 +2,7 @@
import type { Command } from "commander";
import { formatDocsLink } from "../../../packages/terminal-core/src/links.js";
import { theme } from "../../../packages/terminal-core/src/theme.js";
import { resolveCliArgvInvocation } from "../argv-invocation.js";
import { formatHelpExamples } from "../help-format.js";
import { withConsoleLogsRoutedToStderrForJson } from "../json-output-mode.js";
import { registerNodesCameraCommands } from "./register.camera.js";
@@ -42,6 +43,13 @@ export async function registerNodesCli(program: Command, argv: readonly string[]
registerNodesScreenCommands(nodes);
registerNodesLocationCommands(nodes);
// Built-in `nodes` subcommands (status/list/pairing/invoke/...) must stay on the lightweight
// path: loading plugin CLI/runtime to resolve them only adds startup cost. Plugin-provided node
// subcommands (e.g. `nodes canvas`) are not registered above, so only pay the plugin load when
// the invoked subcommand is not already a built-in.
if (!shouldRegisterNodesPluginCommands(nodes, argv)) {
return;
}
const { registerPluginCliCommandsFromValidatedConfig } = await import("../../plugins/cli.js");
await withConsoleLogsRoutedToStderrForJson(
argv,
@@ -52,3 +60,19 @@ export async function registerNodesCli(program: Command, argv: readonly string[]
}),
);
}
/** Plugin node subcommands are only resolved when the invocation is not a built-in nodes command. */
function shouldRegisterNodesPluginCommands(nodes: Command, argv: readonly string[]): boolean {
const { commandPath } = resolveCliArgvInvocation([...argv]);
if (commandPath[0] !== "nodes") {
// Eager registration (root help/completion) needs the full command tree, plugins included.
return true;
}
const requestedSubcommand = commandPath[1];
if (!requestedSubcommand) {
// Bare `openclaw nodes` listing should still surface plugin-provided subcommands.
return true;
}
const builtInSubcommands = new Set(nodes.commands.map((command) => command.name()));
return !builtInSubcommands.has(requestedSubcommand);
}

View File

@@ -144,6 +144,12 @@ const autoMigrateLegacyState = vi.fn().mockResolvedValue({
changes: [],
warnings: [],
}) as unknown as MockFn;
const autoMigrateLegacyPluginDoctorState = vi.fn().mockResolvedValue({
migrated: false,
skipped: false,
changes: [],
warnings: [],
}) as unknown as MockFn;
const autoMigrateLegacyTaskStateSidecars = vi.fn().mockResolvedValue({
migrated: false,
skipped: false,
@@ -209,6 +215,13 @@ function createLegacyStateMigrationDetectionResult(params?: {
targetStorePath: "/tmp/state/agents/main/sessions/sessions.json",
hasLegacy: params?.hasLegacySessions ?? false,
legacyKeys: [],
preserveAmbiguousKeys: false,
preserveForeignMainAliases: false,
targetStoreAliases: {
hasDistinctAliases: false,
hasFinalSymlink: false,
hasUnresolvedIdentity: false,
},
},
agentDir: {
legacyDir: "/tmp/state/agent",
@@ -515,6 +528,7 @@ vi.mock("./onboard-helpers.js", () => ({
}));
vi.mock("./doctor-state-migrations.js", () => ({
autoMigrateLegacyPluginDoctorState,
autoMigrateLegacyState,
autoMigrateLegacyStateDir,
autoMigrateLegacyTaskStateSidecars,

View File

@@ -217,11 +217,19 @@ function isMainScopeStaleDirectSessionKey(params: {
if (!parsed || normalizeAgentId(parsed.agentId) !== normalizeAgentId(params.targetAgentId)) {
return false;
}
const parts = parsed.rest.split(":").filter(Boolean);
const parts = parsed.rest.split(":");
// A nested agent wrapper is opaque plugin identity, never a stale DM route.
if (parts[0] === "agent") {
return false;
}
return (
(parts.length === 2 && parts[0] === "direct") ||
(parts.length === 3 && parts[1] === "direct") ||
(parts.length === 4 && parts[2] === "direct")
(parts.length === 2 && parts[0] === "direct" && Boolean(parts[1])) ||
(parts.length === 3 && Boolean(parts[0]) && parts[1] === "direct" && Boolean(parts[2])) ||
(parts.length === 4 &&
Boolean(parts[0]) &&
Boolean(parts[1]) &&
parts[2] === "direct" &&
Boolean(parts[3]))
);
}

View File

@@ -36,4 +36,29 @@ describe("resolveGroupSessionKey", () => {
chatType: "group",
});
});
it("preserves empty opaque segments in originating group ids", () => {
const ctx = {
Provider: "matrix",
ChatType: "channel",
From: "matrix:channel:!room:[2001:db8::1]",
} satisfies Partial<MsgContext>;
expect(resolveGroupSessionKey(ctx as MsgContext)).toEqual({
key: "matrix:channel:!room:[2001:db8::1]",
channel: "matrix",
id: "!room:[2001:db8::1]",
chatType: "channel",
});
});
it("rejects empty structural group-route segments", () => {
const ctx = {
Provider: "telegram",
ChatType: "group",
From: "telegram::group:room",
} satisfies Partial<MsgContext>;
expect(resolveGroupSessionKey(ctx as MsgContext)).toBeNull();
});
});

View File

@@ -35,6 +35,10 @@ function normalizeGroupLabel(raw?: string) {
return normalizeHyphenSlug(raw);
}
function joinOpaqueTail(parts: string[], start: number): string | null {
return normalizeOptionalString(parts[start]) ? parts.slice(start).join(":") : null;
}
function resolveOriginatingGroupTargetId(params: {
ctx: MsgContext;
provider: string;
@@ -43,7 +47,7 @@ function resolveOriginatingGroupTargetId(params: {
if (!target) {
return null;
}
const parts = target.split(":").filter(Boolean);
const parts = target.split(":");
if (parts.length < 2) {
return null;
}
@@ -54,13 +58,13 @@ function resolveOriginatingGroupTargetId(params: {
const second = normalizeOptionalLowercaseString(parts[1]);
const secondIsKind = second === "group" || second === "channel";
if (secondIsKind && (head === params.provider || getGroupSurfaces().has(head))) {
return parts.slice(2).join(":") || null;
return joinOpaqueTail(parts, 2);
}
if (head === params.provider || head === "chat" || head === "room" || head === "group") {
return parts.slice(1).join(":") || null;
return joinOpaqueTail(parts, 1);
}
if (head === "channel") {
return parts.slice(1).join(":") || null;
return joinOpaqueTail(parts, 1);
}
return null;
}
@@ -134,7 +138,7 @@ export function resolveGroupSessionKey(ctx: MsgContext): GroupKeyResolution | nu
const providerHint = normalizeOptionalLowercaseString(ctx.Provider);
const parts = from.split(":").filter(Boolean);
const parts = from.split(":");
const head = normalizeLowercaseStringOrEmpty(parts[0]);
const headIsSurface = head ? getGroupSurfaces().has(head) : false;
@@ -164,9 +168,12 @@ export function resolveGroupSessionKey(ctx: MsgContext): GroupKeyResolution | nu
? originatingGroupTargetId
: headIsSurface
? secondIsKind
? parts.slice(2).join(":")
: parts.slice(1).join(":")
? joinOpaqueTail(parts, 2)
: joinOpaqueTail(parts, 1)
: from;
if (!id) {
return null;
}
const finalId = normalizeSessionPeerId({ channel: provider, peerKind: kind, peerId: id });
if (!finalId) {
return null;

View File

@@ -97,6 +97,27 @@ describe("session accessor file-backed seam", () => {
});
});
it("keeps case-distinct Matrix sessions separate under nested agent ownership", async () => {
const mixedKey = "agent:voice:agent:other:matrix:channel:!RoomAbC:example.org";
const lowerKey = "agent:voice:agent:other:matrix:channel:!Roomabc:example.org";
await upsertSessionEntry(
{ sessionKey: mixedKey, storePath },
{ sessionId: "mixed-session", updatedAt: 10 },
);
await upsertSessionEntry(
{ sessionKey: lowerKey, storePath },
{ sessionId: "lower-session", updatedAt: 20 },
);
expect(loadSessionEntry({ sessionKey: mixedKey, storePath })?.sessionId).toBe("mixed-session");
expect(loadSessionEntry({ sessionKey: lowerKey, storePath })?.sessionId).toBe("lower-session");
expect(listSessionEntries({ storePath }).map((entry) => entry.sessionKey)).toEqual([
mixedKey,
lowerKey,
]);
});
it("marks abort targets while canonicalizing legacy session keys", async () => {
fs.writeFileSync(
storePath,

View File

@@ -597,6 +597,10 @@ describe("Integration: saveSessionStore with pruning", () => {
lastChannel: "telegram",
lastTo: "6101296751",
},
"agent:main:telegram::direct:malformed": {
sessionId: "malformed-session",
updatedAt: now,
},
} satisfies Record<string, SessionEntry>,
null,
2,
@@ -614,8 +618,9 @@ describe("Integration: saveSessionStore with pruning", () => {
const preview = dryRun.previewResults[0];
expect(preview?.summary.dmScopeRetired).toBe(1);
expect(preview?.summary.afterCount).toBe(1);
expect(preview?.summary.afterCount).toBe(2);
expect(preview?.dmScopeRetiredKeys.has("agent:main:telegram:direct:6101296751")).toBe(true);
expect(preview?.dmScopeRetiredKeys.has("agent:main:telegram::direct:malformed")).toBe(false);
expect(preview?.summary.unreferencedArtifacts.removedFiles).toBe(0);
await expectPathExists(directTranscript);
});
@@ -625,6 +630,7 @@ describe("Integration: saveSessionStore with pruning", () => {
const now = Date.now();
const directTranscript = path.join(testDir, "direct-session.jsonl");
const nestedTranscript = path.join(testDir, "nested-agent-session.jsonl");
await fs.writeFile(
storePath,
JSON.stringify(
@@ -640,6 +646,11 @@ describe("Integration: saveSessionStore with pruning", () => {
lastChannel: "telegram",
lastTo: "6101296751",
},
"agent:main:agent:direct:customer": {
sessionId: "nested-agent-session",
updatedAt: now,
sessionFile: nestedTranscript,
},
} satisfies Record<string, SessionEntry>,
null,
2,
@@ -648,6 +659,7 @@ describe("Integration: saveSessionStore with pruning", () => {
);
await fs.writeFile(path.join(testDir, "main-session.jsonl"), "main", "utf-8");
await fs.writeFile(directTranscript, "direct", "utf-8");
await fs.writeFile(nestedTranscript, "nested", "utf-8");
const applied = await runSessionsCleanup({
cfg: { session: { dmScope: "main" } },
@@ -658,8 +670,10 @@ describe("Integration: saveSessionStore with pruning", () => {
expect(applied.appliedSummaries[0]?.dmScopeRetired).toBe(1);
const persisted = loadSessionStore(storePath, { skipCache: true });
expect(persisted).toHaveProperty("agent:main:main");
expect(persisted).toHaveProperty("agent:main:agent:direct:customer");
expect(persisted["agent:main:telegram:direct:6101296751"]).toBeUndefined();
await expectPathMissing(directTranscript);
await expectPathExists(nestedTranscript);
const files = await fs.readdir(testDir);
const archivedDirectTranscripts = files.filter((name) =>
name.startsWith("direct-session.jsonl.deleted."),

View File

@@ -75,7 +75,8 @@ export function listConfiguredSessionStoreAgentIds(cfg: OpenClawConfig): string[
for (const agentId of cfg.acp?.allowedAgents ?? []) {
addAcpAgentId(agentId);
}
for (const agent of cfg.agents?.list ?? []) {
const configuredAgents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : [];
for (const agent of configuredAgents) {
if (agent.runtime?.type === "acp") {
addAcpAgentId(agent.runtime.acp?.agent ?? agent.id);
}

View File

@@ -52,6 +52,8 @@ const mocks = vi.hoisted(() => ({
getHealthCheck: vi.fn(),
registerHealthCheck: vi.fn(),
noteChromeMcpBrowserReadiness: vi.fn(),
detectLegacyStateMigrations: vi.fn(),
runLegacyStateMigrations: vi.fn(),
detectLegacyClawdBrowserProfileResidue: vi.fn(),
maybeArchiveLegacyClawdBrowserProfileResidue: vi.fn(),
resolveAgentWorkspaceDir: vi.fn(() => "/tmp/openclaw-workspace"),
@@ -132,6 +134,11 @@ vi.mock("../commands/doctor-auth-legacy-oauth.js", () => ({
maybeRepairLegacyOAuthProfileIds: mocks.maybeRepairLegacyOAuthProfileIds,
}));
vi.mock("../commands/doctor-state-migrations.js", () => ({
detectLegacyStateMigrations: mocks.detectLegacyStateMigrations,
runLegacyStateMigrations: mocks.runLegacyStateMigrations,
}));
vi.mock("../commands/doctor-auth-oauth-sidecar.js", () => ({
maybeRepairLegacyOAuthSidecarProfiles: mocks.maybeRepairLegacyOAuthSidecarProfiles,
}));
@@ -379,6 +386,10 @@ describe("doctor health contributions", () => {
mocks.registerHealthCheck.mockReset();
mocks.noteChromeMcpBrowserReadiness.mockReset();
mocks.noteChromeMcpBrowserReadiness.mockResolvedValue(undefined);
mocks.detectLegacyStateMigrations.mockReset();
mocks.detectLegacyStateMigrations.mockResolvedValue({ preview: [], warnings: [] });
mocks.runLegacyStateMigrations.mockReset();
mocks.runLegacyStateMigrations.mockResolvedValue({ changes: [], warnings: [] });
mocks.detectLegacyClawdBrowserProfileResidue.mockReset();
mocks.detectLegacyClawdBrowserProfileResidue.mockReturnValue(null);
mocks.maybeArchiveLegacyClawdBrowserProfileResidue.mockReset();
@@ -921,6 +932,28 @@ describe("doctor health contributions", () => {
);
});
it("passes the active config into legacy state migration", async () => {
const contribution = requireDoctorContribution("doctor:legacy-state");
const cfg = { session: { store: "/tmp/shared-sessions.json" } };
const detected = { preview: ["legacy sessions"], warnings: [] };
mocks.detectLegacyStateMigrations.mockResolvedValue(detected);
const ctx = {
cfg,
sourceConfigValid: true,
prompter: buildDoctorPrompter(true),
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
options: { nonInteractive: true },
} as unknown as Parameters<(typeof contribution)["run"]>[0];
await contribution.run(ctx);
expect(mocks.runLegacyStateMigrations).toHaveBeenCalledWith({
detected,
config: cfg,
recoverCorruptTargetStore: false,
});
});
it("skips Gateway health probes for exec SecretRefs unless allow-exec is set", async () => {
const contribution = requireDoctorContribution("doctor:gateway-health");
mocks.gatewaySecretInputPathCanWin.mockImplementation(

View File

@@ -534,6 +534,7 @@ async function runLegacyStateHealth(ctx: DoctorHealthFlowContext): Promise<void>
}
const migrated = await runLegacyStateMigrations({
detected: legacyState,
config: ctx.cfg,
recoverCorruptTargetStore: ctx.options.repair === true || ctx.options.yes === true,
});
if (migrated.changes.length > 0) {

View File

@@ -1,7 +1,10 @@
/**
* Gateway startup session migration tests.
*/
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { withTempDir } from "../test-helpers/temp-dir.js";
import { runStartupSessionMigration } from "./server-startup-session-migration.js";
function makeLog() {
@@ -26,6 +29,47 @@ function firstLogMessage(log: ReturnType<typeof vi.fn>, label: string): string {
}
describe("runStartupSessionMigration", () => {
it("discovers plugin-owned agents during direct gateway startup", async () => {
await withTempDir({ prefix: "openclaw-startup-migration-" }, async (tempDir) => {
const storeTemplate = path.join(tempDir, "stores", "{agentId}", "sessions.json");
const voiceStorePath = path.join(tempDir, "stores", "voice", "sessions.json");
fs.mkdirSync(path.dirname(voiceStorePath), { recursive: true });
fs.writeFileSync(
voiceStorePath,
JSON.stringify({
"voice:15550001111": { sessionId: "legacy-voice", updatedAt: 1 },
}),
);
const cfg = {
session: { store: storeTemplate },
agents: { list: [{ id: "main", default: true }] },
plugins: {
entries: { "voice-call": { config: { agentId: "voice" } } },
},
} as ReturnType<typeof makeCfg>;
const log = makeLog();
await runStartupSessionMigration({
cfg,
env: {
...process.env,
HOME: tempDir,
OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined,
OPENCLAW_STATE_DIR: path.join(tempDir, "state"),
},
log,
});
const store = JSON.parse(fs.readFileSync(voiceStorePath, "utf8")) as Record<
string,
{ sessionId?: string }
>;
expect(store["agent:voice:voice:15550001111"]?.sessionId).toBe("legacy-voice");
expect(store["voice:15550001111"]).toBeUndefined();
expect(log.info).toHaveBeenCalledOnce();
});
});
it("logs changes when orphaned keys are canonicalized", async () => {
const log = makeLog();
const migrate = vi.fn().mockResolvedValue({

View File

@@ -6,7 +6,7 @@ import {
import type { SourceReplyDeliveryMode } from "../../auto-reply/get-reply-options.types.js";
import type { ChannelThreadingToolContext } from "../../channels/plugins/types.public.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { parseAgentSessionKey, parseThreadSessionSuffix } from "../../routing/session-key.js";
import { parseSessionDeliveryRoute } from "../../routing/session-key.js";
import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.js";
import { resolveOutboundChannelPlugin } from "./channel-resolution.js";
import { isConfiguredChannel, listConfiguredMessageChannels } from "./channel-selection.js";
@@ -19,29 +19,13 @@ type InternalSourceReplySinkInput = {
sourceReplyDeliveryMode?: SourceReplyDeliveryMode;
};
const SESSION_DELIVERY_PEER_KINDS = new Set(["channel", "direct", "dm", "group"]);
function hasExternalSessionDeliveryRoute(sessionKey: string | undefined): boolean {
const parsedThread = parseThreadSessionSuffix(sessionKey);
const baseSessionKey = parsedThread.baseSessionKey ?? sessionKey;
const parsed = parseAgentSessionKey(baseSessionKey);
if (!parsed) {
const route = parseSessionDeliveryRoute(sessionKey);
if (!route) {
return false;
}
const parts = parsed.rest.split(":").filter(Boolean);
if (parts.length < 3) {
return false;
}
const channel = normalizeMessageChannel(parts[0]);
if (!channel || channel === INTERNAL_MESSAGE_CHANNEL) {
return false;
}
if (parts.length >= 4 && (parts[2] === "direct" || parts[2] === "dm")) {
return Boolean(parts.slice(3).join(":").trim());
}
return (
SESSION_DELIVERY_PEER_KINDS.has(parts[1] ?? "") && Boolean(parts.slice(2).join(":").trim())
);
const channel = normalizeMessageChannel(route.channel);
return Boolean(channel && channel !== INTERNAL_MESSAGE_CHANNEL);
}
function hasExplicitRouteParam(params: Record<string, unknown>): boolean {

View File

@@ -143,6 +143,27 @@ describe("runMessageAction send validation", () => {
expect(JSON.stringify(result.toolResult?.content)).not.toContain("hello from codex");
});
it.each(["agent:voice:agent:channel:room", "agent:main:telegram::group:room"])(
"keeps malformed session route %s on the internal source sink",
async (sessionKey) => {
const result = await runMessageAction({
cfg: emptyConfig,
action: "send",
params: { message: "private reply" },
toolContext: { currentChannelProvider: "webchat" },
sessionKey,
sourceReplyDeliveryMode: "message_tool_only",
});
expect(result).toMatchObject({
kind: "send",
channel: "webchat",
to: "current-run",
handledBy: "internal-source",
});
},
);
it("uses non-webchat current source context as the message-tool-only send sink", async () => {
const result = await runMessageAction({
cfg: emptyConfig,

View File

@@ -1,7 +1,7 @@
// Tests migration cleanup for orphaned state keys.
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { withTempDir } from "../test-helpers/temp-dir.js";
import {
@@ -89,6 +89,15 @@ describe("migrateOrphanedSessionKeys", () => {
mainKey: "work",
}),
).toBe(true);
expect(
sessionStoreTextMayNeedCanonicalization({
raw: JSON.stringify({
"agent:archive:main": { sessionId: "retired-main", updatedAt: 1 },
}),
storeAgentIds: ["main"],
mainKey: "work",
}),
).toBe(true);
expect(
sessionStoreTextMayNeedCanonicalization({
raw: JSON.stringify({
@@ -140,6 +149,17 @@ describe("migrateOrphanedSessionKeys", () => {
mainKey: "work",
}),
).toBe(true);
for (const malformedKey of ["agent::room", "agent:_bad:room"]) {
expect(
sessionStoreTextMayNeedCanonicalization({
raw: JSON.stringify({
[malformedKey]: { sessionId: "opaque", updatedAt: 1 },
}),
storeAgentIds: ["voice"],
mainKey: "main",
}),
).toBe(true);
}
});
it("renames orphaned raw key to canonical form", async () => {
@@ -158,6 +178,397 @@ describe("migrateOrphanedSessionKeys", () => {
});
});
it("promotes legacy voice sessions before canonical runtime access", async () => {
await withStateFixture(async ({ stateDir }) => {
const storePath = path.join(stateDir, "agents", "main", "sessions", "sessions.json");
writeStore(storePath, {
"voice:15550001111": { sessionId: "legacy-voice", updatedAt: 2_000 },
"agent:main:voice:15550001111": { sessionId: "stale-canonical", updatedAt: 1_000 },
});
await migrateFixtureState(stateDir, {} as OpenClawConfig);
const store = readStore(storePath);
expect(requireStoreEntry(store, "agent:main:voice:15550001111").sessionId).toBe(
"legacy-voice",
);
expect(store["voice:15550001111"]).toBeUndefined();
});
});
it("treats a blank session store as the default per-agent store", async () => {
await withStateFixture(async ({ stateDir }) => {
const storePath = path.join(stateDir, "agents", "main", "sessions", "sessions.json");
writeStore(storePath, {
"voice:15550001111": { sessionId: "legacy-voice", updatedAt: 2000 },
});
const result = await migrateFixtureState(stateDir, {
session: { store: "" },
agents: { list: [{ id: "main", default: true }] },
} as OpenClawConfig);
const store = readStore(storePath);
expect(requireStoreEntry(store, "agent:main:voice:15550001111").sessionId).toBe(
"legacy-voice",
);
expect(store["voice:15550001111"]).toBeUndefined();
expect(result.warnings).toHaveLength(0);
});
});
it("migrates plugin-owned agents in templated session stores", async () => {
await withStateFixture(async ({ tmpDir, stateDir }) => {
const storeTemplate = path.join(tmpDir, "stores", "{agentId}", "sessions.json");
const voiceStorePath = path.join(tmpDir, "stores", "voice", "sessions.json");
writeStore(voiceStorePath, {
"voice:15550001111": { sessionId: "legacy-voice", updatedAt: 2000 },
"agent:voice:metadata": { updatedAt: 1500, groupActivation: "always" },
});
const cfg = {
session: { store: storeTemplate },
agents: { list: [{ id: "main", default: true }] },
plugins: {
entries: {
"voice-call": { config: { agentId: "voice" } },
},
},
} as OpenClawConfig;
const result = await migrateOrphanedSessionKeys({
cfg,
env: { OPENCLAW_STATE_DIR: stateDir },
});
const store = readStore(voiceStorePath);
expect(requireStoreEntry(store, "agent:voice:voice:15550001111").sessionId).toBe(
"legacy-voice",
);
expect(store["agent:voice:metadata"]).toEqual({
updatedAt: 1500,
groupActivation: "always",
});
expect(store["voice:15550001111"]).toBeUndefined();
expect(result.changes).toHaveLength(1);
expect(result.warnings).toHaveLength(0);
});
});
it.each([
{ scope: undefined, canonicalMainKey: "agent:voice:main" },
{ scope: "global" as const, canonicalMainKey: "global" },
])(
"preserves opaque foreign main aliases in plugin-owned $scope stores",
async ({ scope, canonicalMainKey }) => {
await withStateFixture(async ({ tmpDir, stateDir }) => {
const storeTemplate = path.join(tmpDir, "stores", "{agentId}", "sessions.json");
const voiceStorePath = path.join(tmpDir, "stores", "voice", "sessions.json");
writeStore(voiceStorePath, {
"agent:main:main": { sessionId: "explicit-foreign", updatedAt: 3000 },
[canonicalMainKey]: { sessionId: "voice-main", updatedAt: 1000 },
"voice:15550001111": { sessionId: "legacy-voice", updatedAt: 2000 },
});
const cfg = {
session: { store: storeTemplate, scope },
agents: { list: [{ id: "main", default: true }] },
plugins: {
entries: {
"voice-call": { config: { agentId: "voice" } },
},
},
} as OpenClawConfig;
const result = await migrateFixtureState(stateDir, cfg);
const store = readStore(voiceStorePath);
expect(requireStoreEntry(store, "agent:main:main").sessionId).toBe("explicit-foreign");
expect(requireStoreEntry(store, canonicalMainKey).sessionId).toBe("voice-main");
expect(requireStoreEntry(store, "agent:voice:voice:15550001111").sessionId).toBe(
"legacy-voice",
);
expect(store["voice:15550001111"]).toBeUndefined();
expect(result.changes).toHaveLength(1);
expect(result.warnings).toHaveLength(1);
});
},
);
it("preserves foreign main aliases before global canonicalization in shared plugin stores", async () => {
await withStateFixture(async ({ tmpDir, stateDir }) => {
const sharedStorePath = path.join(tmpDir, "shared-sessions.json");
writeStore(sharedStorePath, {
"agent:main:main": { sessionId: "ambiguous-main", updatedAt: 2000 },
global: { sessionId: "real-global", updatedAt: 1000 },
});
const cfg = {
session: { store: sharedStorePath, scope: "global" },
agents: { list: [{ id: "main", default: true }] },
plugins: {
entries: {
"voice-call": { config: { agentId: "voice" } },
},
},
} as OpenClawConfig;
const result = await migrateFixtureState(stateDir, cfg);
const store = readStore(sharedStorePath);
expect(requireStoreEntry(store, "agent:main:main").sessionId).toBe("ambiguous-main");
expect(requireStoreEntry(store, "global").sessionId).toBe("real-global");
expect(result.changes).toHaveLength(0);
expect(result.warnings).toHaveLength(1);
});
});
it("warns on custom main aliases in fixed plugin stores", async () => {
await withStateFixture(async ({ tmpDir, stateDir }) => {
const sharedStorePath = path.join(tmpDir, "shared-sessions.json");
writeStore(sharedStorePath, {
"agent:main:work": { sessionId: "ambiguous-main", updatedAt: 2000 },
});
const cfg = {
session: { mainKey: "work", store: sharedStorePath },
agents: { list: [{ id: "main", default: true }] },
plugins: {
entries: {
"voice-call": { config: { agentId: "voice" } },
},
},
} as OpenClawConfig;
const result = await migrateFixtureState(stateDir, cfg);
const store = readStore(sharedStorePath);
expect(requireStoreEntry(store, "agent:main:work").sessionId).toBe("ambiguous-main");
expect(result.changes).toHaveLength(0);
expect(result.warnings).toEqual([
`Preserved 1 ambiguous session key(s) in potentially shared store ${sharedStorePath}`,
]);
});
});
it("coalesces configured and standard paths that alias one store", async () => {
await withStateFixture(async ({ tmpDir, stateDir }) => {
const standardStorePath = path.join(stateDir, "agents", "voice", "sessions", "sessions.json");
writeStore(standardStorePath, {
"agent:voice::matrix:channel:!room:example.org": {
sessionId: "malformed-owner",
updatedAt: 2000,
},
"voice:15550001111": { sessionId: "legacy-voice", updatedAt: 1000 },
"agent:voice:MixedCase": { sessionId: "scoped", updatedAt: 1000 },
});
const configuredStorePath = path.join(tmpDir, "configured-sessions.json");
fs.linkSync(standardStorePath, configuredStorePath);
const cfg = {
session: { store: configuredStorePath },
agents: { list: [{ id: "ops", default: true }] },
plugins: {
entries: {
"voice-call": { config: { agentId: "voice" } },
},
},
} as OpenClawConfig;
const result = await migrateFixtureState(stateDir, cfg);
const rerun = await migrateFixtureState(stateDir, cfg);
expect(result.changes).toHaveLength(0);
expect(result.warnings).toEqual([
`Deferred migration of 2 ambiguous session key(s) in aliased store ${configuredStorePath}; remove filesystem aliases or configure one canonical session.store path, then rerun openclaw doctor --fix`,
]);
expect(rerun).toEqual(result);
expect(
requireStoreEntry(
readStore(standardStorePath),
"agent:voice::matrix:channel:!room:example.org",
).sessionId,
).toBe("malformed-owner");
expect(
requireStoreEntry(readStore(standardStorePath), "agent:voice:MixedCase").sessionId,
).toBe("scoped");
expect(
readStore(standardStorePath)["agent:ops:agent:voice::matrix:channel:!room:example.org"],
).toBeUndefined();
expect(fs.statSync(configuredStorePath).ino).toBe(fs.statSync(standardStorePath).ino);
});
});
it("warns from a readable alias when the configured path identity is inaccessible", async () => {
await withStateFixture(async ({ tmpDir, stateDir }) => {
const configuredStorePath = path.join(tmpDir, "configured-sessions.json");
writeStore(configuredStorePath, {});
const standardStorePath = path.join(stateDir, "agents", "voice", "sessions", "sessions.json");
writeStore(standardStorePath, {
"voice:15550001111": { sessionId: "legacy-voice", updatedAt: 1000 },
});
const cfg = {
session: { store: configuredStorePath },
agents: { list: [{ id: "ops", default: true }] },
} as OpenClawConfig;
const realStatSync = fs.statSync.bind(fs);
const statSpy = vi.spyOn(fs, "statSync").mockImplementation((candidate) => {
if (path.resolve(candidate.toString()) === configuredStorePath) {
throw Object.assign(new Error("inaccessible store"), { code: "EACCES" });
}
return realStatSync(candidate);
});
let result: Awaited<ReturnType<typeof migrateOrphanedSessionKeys>>;
try {
result = await migrateOrphanedSessionKeys({
cfg,
env: { OPENCLAW_STATE_DIR: stateDir },
additionalAgentIds: ["voice"],
});
} finally {
statSpy.mockRestore();
}
expect(result.changes).toHaveLength(0);
expect(result.warnings).toEqual([
`Deferred session key migration for ${standardStorePath}; filesystem identity could not be established for every configured store path. Restore path access or configure one canonical session.store path, then rerun openclaw doctor --fix`,
]);
expect(requireStoreEntry(readStore(standardStorePath), "voice:15550001111").sessionId).toBe(
"legacy-voice",
);
});
});
it("defers migration through a final-component store symlink", async () => {
await withStateFixture(async ({ tmpDir, stateDir }) => {
const standardStorePath = path.join(stateDir, "agents", "voice", "sessions", "sessions.json");
writeStore(standardStorePath, {
"agent:voice::matrix:channel:!room:example.org": {
sessionId: "malformed-owner",
updatedAt: 2000,
},
"voice:15550001111": { sessionId: "legacy-voice", updatedAt: 1000 },
});
const configuredStorePath = path.join(tmpDir, "configured-sessions.json");
fs.symlinkSync(standardStorePath, configuredStorePath);
const cfg = {
session: { store: configuredStorePath },
agents: { list: [{ id: "ops", default: true }] },
plugins: {
entries: {
"voice-call": { config: { agentId: "voice" } },
},
},
} as OpenClawConfig;
const result = await migrateFixtureState(stateDir, cfg);
expect(result.changes).toHaveLength(0);
expect(result.warnings).toEqual([
`Deferred migration of 2 ambiguous session key(s) in aliased store ${configuredStorePath}; remove filesystem aliases or configure one canonical session.store path, then rerun openclaw doctor --fix`,
]);
expect(fs.lstatSync(configuredStorePath).isSymbolicLink()).toBe(true);
expect(
requireStoreEntry(
readStore(standardStorePath),
"agent:voice::matrix:channel:!room:example.org",
).sessionId,
).toBe("malformed-owner");
});
});
it("defers a singleton final-component store symlink", async () => {
await withStateFixture(async ({ tmpDir, stateDir }) => {
const outsideStorePath = path.join(tmpDir, "outside-sessions.json");
writeStore(outsideStorePath, {
"voice:15550001111": { sessionId: "outside-voice", updatedAt: 1000 },
});
const storePath = path.join(stateDir, "agents", "main", "sessions", "sessions.json");
fs.mkdirSync(path.dirname(storePath), { recursive: true });
fs.symlinkSync(outsideStorePath, storePath);
const result = await migrateFixtureState(stateDir, {} as OpenClawConfig);
expect(result.changes).toHaveLength(0);
expect(result.warnings).toEqual([
`Deferred session key migration in final-component symlink store ${storePath}; configure one canonical session.store path, then rerun openclaw doctor --fix`,
]);
expect(fs.lstatSync(storePath).isSymbolicLink()).toBe(true);
expect(requireStoreEntry(readStore(outsideStorePath), "voice:15550001111").sessionId).toBe(
"outside-voice",
);
});
});
it("defers an unambiguous rewrite through a singleton final symlink", async () => {
await withStateFixture(async ({ tmpDir, stateDir }) => {
const outsideStorePath = path.join(tmpDir, "outside-sessions.json");
writeStore(outsideStorePath, {
"agent:main:main": { sessionId: "outside-global", updatedAt: 1000 },
});
const storePath = path.join(stateDir, "agents", "main", "sessions", "sessions.json");
fs.mkdirSync(path.dirname(storePath), { recursive: true });
fs.symlinkSync(outsideStorePath, storePath);
const cfg = { session: { scope: "global" } } as OpenClawConfig;
const result = await migrateFixtureState(stateDir, cfg);
expect(result.changes).toHaveLength(0);
expect(result.warnings).toEqual([
`Deferred session key migration in final-component symlink store ${storePath}; configure one canonical session.store path, then rerun openclaw doctor --fix`,
]);
expect(fs.lstatSync(storePath).isSymbolicLink()).toBe(true);
expect(requireStoreEntry(readStore(outsideStorePath), "agent:main:main").sessionId).toBe(
"outside-global",
);
});
});
it("defers global main aliases across hard-linked store paths", async () => {
await withStateFixture(async ({ tmpDir, stateDir }) => {
const standardStorePath = path.join(stateDir, "agents", "main", "sessions", "sessions.json");
writeStore(standardStorePath, {
"agent:main:main": { sessionId: "legacy-global", updatedAt: 1000 },
});
const configuredStorePath = path.join(tmpDir, "configured-sessions.json");
fs.linkSync(standardStorePath, configuredStorePath);
const cfg = {
session: { scope: "global", store: configuredStorePath },
agents: { list: [{ id: "main", default: true }] },
} as OpenClawConfig;
const result = await migrateFixtureState(stateDir, cfg);
for (const storePath of [configuredStorePath, standardStorePath]) {
expect(requireStoreEntry(readStore(storePath), "agent:main:main").sessionId).toBe(
"legacy-global",
);
expect(readStore(storePath).global).toBeUndefined();
}
expect(result.changes).toHaveLength(0);
expect(result.warnings).toEqual([
`Deferred session key migration in aliased store ${configuredStorePath}; atomic replacement cannot update distinct filesystem aliases as one operation. Remove filesystem aliases or configure one canonical session.store path, then rerun openclaw doctor --fix`,
]);
});
});
it("normalizes main aliases in a fixed single-owner store", async () => {
await withStateFixture(async ({ tmpDir, stateDir }) => {
const storePath = path.join(tmpDir, "sessions.json");
writeStore(storePath, {
"agent:main:main": { sessionId: "legacy-main", updatedAt: 1000 },
});
const cfg = {
session: { mainKey: "work", store: storePath },
agents: { list: [{ id: "main", default: true }] },
} as OpenClawConfig;
const result = await migrateFixtureState(stateDir, cfg);
const store = readStore(storePath);
expect(requireStoreEntry(store, "agent:main:work").sessionId).toBe("legacy-main");
expect(store["agent:main:main"]).toBeUndefined();
expect(result.changes).toHaveLength(1);
expect(result.warnings).toHaveLength(0);
});
});
it("renames same-agent main aliases when mainKey changes", async () => {
await withStateFixture(async ({ stateDir }) => {
const storePath = opsSessionStorePath(stateDir);
@@ -265,7 +676,7 @@ describe("migrateOrphanedSessionKeys", () => {
});
});
it("preserves legitimate agent:main:* keys in shared stores with both main and non-main agents", async () => {
it("preserves legacy default-main aliases in shared stores", async () => {
await withStateFixture(async ({ tmpDir, stateDir }) => {
// When session.store lacks {agentId}, all agents resolve to the same file.
// The "main" agent's keys must not be remapped into the "ops" namespace.
@@ -275,21 +686,110 @@ describe("migrateOrphanedSessionKeys", () => {
"agent:ops:work": { sessionId: "ops-session", updatedAt: 1000 },
});
await migrateFixtureState(stateDir, sharedMainOpsConfig(sharedStorePath));
const result = await migrateFixtureState(stateDir, sharedMainOpsConfig(sharedStorePath));
const store = readStore(sharedStorePath);
// main agent's session is canonicalised to use configured mainKey ("work"),
// but stays in the "main" agent namespace — NOT remapped into "ops".
expect(requireStoreEntry(store, "agent:main:work").sessionId).toBe("main-session");
expect(requireStoreEntry(store, "agent:main:main").sessionId).toBe("main-session");
expect(store["agent:main:work"]).toBeUndefined();
expect(requireStoreEntry(store, "agent:ops:work").sessionId).toBe("ops-session");
// The key must NOT have been merged into ops namespace
expect(
Object.keys(store).reduce((count, k) => count + (k.startsWith("agent:ops:") ? 1 : 0), 0),
).toBe(1);
expect(result.warnings).toHaveLength(1);
});
});
it("lets the main agent claim bare main aliases in shared stores", async () => {
it("canonicalizes global main aliases in shared stores", async () => {
await withStateFixture(async ({ tmpDir, stateDir }) => {
const sharedStorePath = path.join(tmpDir, "shared-sessions.json");
writeStore(sharedStorePath, {
global: { sessionId: "stale-global", updatedAt: 1000 },
main: { sessionId: "bare-main", updatedAt: 2000 },
"agent:main:main": { sessionId: "legacy-main", updatedAt: 3000 },
"agent:main:work": { sessionId: "fresh-main", updatedAt: 4000 },
});
const cfg = {
session: { scope: "global", mainKey: "work", store: sharedStorePath },
agents: { list: [{ id: "main" }, { id: "ops", default: true }] },
} as OpenClawConfig;
const result = await migrateFixtureState(stateDir, cfg);
const store = readStore(sharedStorePath);
expect(requireStoreEntry(store, "global").sessionId).toBe("fresh-main");
expect(store.main).toBeUndefined();
expect(store["agent:main:main"]).toBeUndefined();
expect(store["agent:main:work"]).toBeUndefined();
expect(result.changes).toHaveLength(1);
expect(result.warnings).toHaveLength(0);
});
});
it("does not assign legacy default-main aliases among non-main shared owners", async () => {
await withStateFixture(async ({ tmpDir, stateDir }) => {
const sharedStorePath = path.join(tmpDir, "shared-sessions.json");
writeStore(sharedStorePath, {
"agent:main:main": { sessionId: "ambiguous-session", updatedAt: 2000 },
});
const cfg = {
session: { mainKey: "work", store: sharedStorePath },
agents: { list: [{ id: "ops", default: true }, { id: "research" }] },
} as OpenClawConfig;
const result = await migrateFixtureState(stateDir, cfg);
const store = readStore(sharedStorePath);
expect(requireStoreEntry(store, "agent:main:main").sessionId).toBe("ambiguous-session");
expect(store["agent:ops:work"]).toBeUndefined();
expect(store["agent:research:work"]).toBeUndefined();
expect(result.changes).toHaveLength(0);
expect(result.warnings).toHaveLength(1);
});
});
it("canonicalizes non-main shared rows within their declared owners", async () => {
await withStateFixture(async ({ tmpDir, stateDir }) => {
const sharedStorePath = path.join(tmpDir, "shared-sessions.json");
writeStore(sharedStorePath, {
"agent:ops:main": { sessionId: "ops-session", updatedAt: 1000 },
"agent:research:main": { sessionId: "research-session", updatedAt: 2000 },
});
const cfg = {
session: { mainKey: "work", store: sharedStorePath },
agents: { list: [{ id: "ops", default: true }, { id: "research" }] },
} as OpenClawConfig;
const result = await migrateFixtureState(stateDir, cfg);
const store = readStore(sharedStorePath);
expect(requireStoreEntry(store, "agent:ops:work").sessionId).toBe("ops-session");
expect(requireStoreEntry(store, "agent:research:work").sessionId).toBe("research-session");
expect(store["agent:ops:main"]).toBeUndefined();
expect(store["agent:research:main"]).toBeUndefined();
expect(result.changes).toHaveLength(1);
expect(result.warnings).toHaveLength(0);
});
});
it("canonicalizes main aliases for unlisted shared-store owners", async () => {
await withStateFixture(async ({ tmpDir, stateDir }) => {
const sharedStorePath = path.join(tmpDir, "shared-sessions.json");
writeStore(sharedStorePath, {
"agent:archive:main": { sessionId: "archive-session", updatedAt: 1000 },
});
const cfg = {
session: { mainKey: "work", store: sharedStorePath },
agents: { list: [{ id: "main", default: true }] },
} as OpenClawConfig;
const result = await migrateFixtureState(stateDir, cfg);
const store = readStore(sharedStorePath);
expect(requireStoreEntry(store, "agent:archive:work").sessionId).toBe("archive-session");
expect(store["agent:archive:main"]).toBeUndefined();
expect(result.changes).toHaveLength(1);
expect(result.warnings).toHaveLength(0);
});
});
it("preserves bare main aliases when a store has multiple possible owners", async () => {
await withStateFixture(async ({ tmpDir, stateDir }) => {
const sharedStorePath = path.join(tmpDir, "shared-sessions.json");
writeStore(sharedStorePath, {
@@ -297,12 +797,150 @@ describe("migrateOrphanedSessionKeys", () => {
"agent:ops:work": { sessionId: "ops-session", updatedAt: 1000 },
});
await migrateFixtureState(stateDir, sharedMainOpsConfig(sharedStorePath));
const result = await migrateFixtureState(stateDir, sharedMainOpsConfig(sharedStorePath));
const store = readStore(sharedStorePath);
expect(requireStoreEntry(store, "agent:main:work").sessionId).toBe("main-session");
expect(store.main).toBeUndefined();
expect(requireStoreEntry(store, "main").sessionId).toBe("main-session");
expect(store["agent:main:work"]).toBeUndefined();
expect(requireStoreEntry(store, "agent:ops:work").sessionId).toBe("ops-session");
expect(result.warnings).toHaveLength(1);
});
});
it("does not guess the owner of raw keys in shared multi-agent stores", async () => {
await withStateFixture(async ({ tmpDir, stateDir }) => {
const sharedStorePath = path.join(tmpDir, "shared-sessions.json");
writeStore(sharedStorePath, {
"voice:15550001111": { sessionId: "legacy-voice", updatedAt: 2000 },
"agent:ops:work": { sessionId: "ops-session", updatedAt: 1000 },
});
const result = await migrateFixtureState(stateDir, sharedMainOpsConfig(sharedStorePath));
const store = readStore(sharedStorePath);
expect(requireStoreEntry(store, "voice:15550001111").sessionId).toBe("legacy-voice");
expect(store["agent:main:voice:15550001111"]).toBeUndefined();
expect(store["agent:ops:voice:15550001111"]).toBeUndefined();
expect(result.warnings).toContain(
`Preserved 1 ambiguous session key(s) in potentially shared store ${sharedStorePath}`,
);
});
});
it("preserves distinct ambiguous keys that differ only by surrounding whitespace", async () => {
await withStateFixture(async ({ tmpDir, stateDir }) => {
const sharedStorePath = path.join(tmpDir, "shared-sessions.json");
writeStore(sharedStorePath, {
"voice:shared": { sessionId: "first-session", updatedAt: 1000 },
" voice:shared ": { sessionId: "second-session", updatedAt: 2000 },
});
const result = await migrateFixtureState(stateDir, sharedMainOpsConfig(sharedStorePath));
const store = readStore(sharedStorePath);
expect(requireStoreEntry(store, "voice:shared").sessionId).toBe("first-session");
expect(requireStoreEntry(store, " voice:shared ").sessionId).toBe("second-session");
expect(result.changes).toHaveLength(0);
expect(result.warnings).toHaveLength(1);
});
});
it("preserves prototype-shaped keys when another shared-store row migrates", async () => {
await withStateFixture(async ({ tmpDir, stateDir }) => {
const sharedStorePath = path.join(tmpDir, "shared-sessions.json");
const source = Object.create(null) as Record<string, unknown>;
Object.defineProperty(source, "__proto__", {
configurable: true,
enumerable: true,
value: { sessionId: "prototype-session", updatedAt: 1000 },
writable: true,
});
source["agent:ops:main"] = { sessionId: "ops-session", updatedAt: 2000 };
writeStore(sharedStorePath, source);
const result = await migrateFixtureState(stateDir, sharedMainOpsConfig(sharedStorePath));
const store = readStore(sharedStorePath);
expect(Object.hasOwn(store, "__proto__")).toBe(true);
expect(requireStoreEntry(store, "__proto__").sessionId).toBe("prototype-session");
expect(requireStoreEntry(store, "agent:ops:work").sessionId).toBe("ops-session");
expect(result.changes).toHaveLength(1);
expect(result.warnings).toHaveLength(1);
});
});
it("preserves mixed-case main aliases in a shared store", async () => {
await withStateFixture(async ({ tmpDir, stateDir }) => {
const sharedStorePath = path.join(tmpDir, "shared-sessions.json");
writeStore(sharedStorePath, {
MAIN: { sessionId: "main-session", updatedAt: 2000 },
});
const cfg = {
session: { store: sharedStorePath },
agents: { list: [{ id: "main", default: true }, { id: "ops" }] },
} as OpenClawConfig;
const first = await migrateFixtureState(stateDir, cfg);
const second = await migrateFixtureState(stateDir, cfg);
const store = readStore(sharedStorePath);
expect(requireStoreEntry(store, "MAIN").sessionId).toBe("main-session");
expect(store["agent:main:main"]).toBeUndefined();
expect(first.changes).toHaveLength(0);
expect(first.warnings).toHaveLength(1);
expect(second).toEqual(first);
});
});
it("canonicalizes raw keys in fixed custom stores with one configured agent", async () => {
await withStateFixture(async ({ tmpDir, stateDir }) => {
const fixedStorePath = path.join(tmpDir, "custom-sessions.json");
const discoveredOpsStorePath = opsSessionStorePath(stateDir);
writeStore(fixedStorePath, {
"voice:15550001111": { sessionId: "legacy-voice", updatedAt: 2000 },
});
writeStore(discoveredOpsStorePath, {
"voice:15550002222": { sessionId: "ops-voice", updatedAt: 2000 },
});
const cfg = {
session: { store: fixedStorePath },
agents: { list: [{ id: "main", default: true }] },
} as OpenClawConfig;
const first = await migrateFixtureState(stateDir, cfg);
const second = await migrateFixtureState(stateDir, cfg);
const store = readStore(fixedStorePath);
expect(requireStoreEntry(store, "agent:main:voice:15550001111").sessionId).toBe(
"legacy-voice",
);
expect(store["voice:15550001111"]).toBeUndefined();
const opsStore = readStore(discoveredOpsStorePath);
expect(requireStoreEntry(opsStore, "agent:ops:voice:15550002222").sessionId).toBe(
"ops-voice",
);
expect(opsStore["voice:15550002222"]).toBeUndefined();
expect(first.changes).toHaveLength(2);
expect(first.warnings).toHaveLength(0);
expect(second).toEqual({ changes: [], warnings: [] });
});
});
it("canonicalizes mixed-case scoped main aliases on the first run", async () => {
await withStateFixture(async ({ stateDir }) => {
const storePath = opsSessionStorePath(stateDir);
writeStore(storePath, {
"Agent:OPS:MAIN": { sessionId: "ops-session", updatedAt: 2000 },
});
const first = await migrateFixtureState(stateDir);
const second = await migrateFixtureState(stateDir);
const store = readStore(storePath);
expect(requireStoreEntry(store, "agent:ops:work").sessionId).toBe("ops-session");
expect(store["Agent:OPS:MAIN"]).toBeUndefined();
expect(first.changes).toHaveLength(1);
expect(second).toEqual({ changes: [], warnings: [] });
});
});

View File

@@ -4,7 +4,9 @@ import fs from "node:fs/promises";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { readAcpSessionMetaForEntry } from "../acp/runtime/session-meta.js";
import type { OpenClawConfig } from "../config/config.js";
import * as sessionStore from "../config/sessions.js";
import { resolveChannelAllowFromPath } from "../pairing/pairing-store.js";
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
import {
@@ -106,10 +108,14 @@ vi.mock("../channels/plugins/bundled.js", () => {
listBundledChannelLegacySessionSurfaces: vi.fn(() => [
{
isLegacyGroupSessionKey: (key: string) => /^group:mobile-/i.test(key.trim()),
canonicalizeLegacySessionKey: ({ key, agentId }: { key: string; agentId: string }) =>
/^group:mobile-/i.test(key.trim())
canonicalizeLegacySessionKey: ({ key, agentId }: { key: string; agentId: string }) => {
if (key === "legacy-prototype") {
return "__proto__";
}
return /^group:mobile-/i.test(key.trim())
? `agent:${agentId}:mobileauth:${key.trim().toLowerCase()}`
: null,
: null;
},
},
]),
listBundledChannelLegacyStateMigrationDetectors: vi.fn(() => [
@@ -445,23 +451,76 @@ describe("state migrations", () => {
it("runs legacy state migrations and canonicalizes the merged session store", async () => {
const { root, stateDir, env, cfg } = await createLegacyStateFixture({ includePreKey: true });
cfg.session = { ...cfg.session, mainKey: "Desk" };
const targetStorePath = path.join(stateDir, "agents", "worker-1", "sessions", "sessions.json");
const targetStore = JSON.parse(await fs.readFile(targetStorePath, "utf8")) as Record<
string,
unknown
>;
targetStore["agent:main:desk"] = { sessionId: "explicit-foreign", updatedAt: 30 };
targetStore["voice:15550001111"] = {
sessionId: "shared-voice",
updatedAt: 20,
acp: {
backend: "test",
agent: "worker-1",
runtimeSessionName: "shared-runtime",
mode: "persistent",
state: "idle",
lastActivityAt: 20,
},
};
targetStore["agent:worker-1:acp:task"] = {
sessionId: "canonical-acp",
updatedAt: 15,
acp: {
backend: "test",
agent: "worker-1",
runtimeSessionName: "canonical-runtime",
mode: "persistent",
state: "idle",
lastActivityAt: 15,
},
};
await fs.writeFile(targetStorePath, `${JSON.stringify(targetStore, null, 2)}\n`, "utf8");
cfg.session = { ...cfg.session, store: targetStorePath };
const legacyStorePath = path.join(stateDir, "sessions", "sessions.json");
const legacyStore = JSON.parse(await fs.readFile(legacyStorePath, "utf8")) as Record<
string,
unknown
>;
legacyStore["Agent:main:desk"] = { sessionId: "mixed-case-foreign", updatedAt: 40 };
legacyStore["legacy-prototype"] = {
sessionId: "prototype-row",
updatedAt: 10,
sessionFile: "trace.jsonl",
};
await fs.writeFile(legacyStorePath, `${JSON.stringify(legacyStore, null, 2)}\n`, "utf8");
const detected = await detectLegacyStateMigrations({
cfg,
env,
homedir: () => root,
pluginSessionStoreAgentIds: ["worker-1"],
});
expect(detected.sessions.preserveAmbiguousKeys).toBe(false);
expect(detected.sessions.preserveForeignMainAliases).toBe(true);
expect(detected.sessions.targetStoreAliases.hasDistinctAliases).toBe(false);
const result = await runLegacyStateMigrations({
detected,
config: cfg,
now: () => 1234,
});
expect(result.warnings).toStrictEqual([]);
expect(result.warnings).toStrictEqual([
`Preserved 1 ambiguous session key(s) while importing legacy sessions into ${targetStorePath}`,
]);
expect(result.changes).toEqual([
`Migrated latest direct-chat session → agent:worker-1:desk`,
`Merged sessions store → ${path.join(stateDir, "agents", "worker-1", "sessions", "sessions.json")}`,
"Canonicalized 2 legacy session key(s)",
"Canonicalized 3 legacy session key(s)",
"Moved trace.jsonl → agents/worker-1/sessions",
"Rewrote migrated session transcript paths",
"Migrated 2 ACP session metadata rows → shared SQLite state",
"Moved agent file settings.json → agents/worker-1/agent",
`Moved MobileAuth auth creds.json → ${path.join(stateDir, "credentials", "mobileauth", "default", "creds.json")}`,
`Moved MobileAuth auth pre-key-1.json → ${path.join(stateDir, "credentials", "mobileauth", "default", "pre-key-1.json")}`,
@@ -473,14 +532,29 @@ describe("state migrations", () => {
path.join(stateDir, "agents", "worker-1", "sessions", "sessions.json"),
"utf8",
),
) as Record<string, { sessionId: string }>;
) as Record<string, { sessionId: string; sessionFile?: string; acp?: unknown }>;
expect(mergedStore["agent:worker-1:desk"]?.sessionId).toBe("legacy-direct");
expect(mergedStore["group:mobile-room"]).toBeUndefined();
expect(mergedStore["group:legacy-room"]).toBeUndefined();
expect(mergedStore["agent:worker-1:mobileauth:group:mobile-room"]?.sessionId).toBe(
"group-session",
);
expect(mergedStore["agent:worker-1:unknown:group:legacy-room"]?.sessionId).toBe(
"generic-group-session",
);
expect(mergedStore["agent:main:desk"]?.sessionId).toBe("explicit-foreign");
expect(mergedStore["Agent:main:desk"]?.sessionId).toBe("mixed-case-foreign");
expect(mergedStore["voice:15550001111"]).toBeUndefined();
expect(mergedStore["agent:worker-1:voice:15550001111"]?.sessionId).toBe("shared-voice");
expect(mergedStore["agent:worker-1:voice:15550001111"]?.acp).toBeUndefined();
expect(Object.hasOwn(mergedStore, "__proto__")).toBe(true);
expect(Object.getOwnPropertyDescriptor(mergedStore, "__proto__")?.value.sessionId).toBe(
"prototype-row",
);
expect(Object.getOwnPropertyDescriptor(mergedStore, "__proto__")?.value.sessionFile).toBe(
path.join(stateDir, "agents", "worker-1", "sessions", "trace.jsonl"),
);
expect(mergedStore["agent:worker-1:acp:task"]?.acp).toBeUndefined();
await expect(
fs.readFile(path.join(stateDir, "agents", "worker-1", "sessions", "trace.jsonl"), "utf8"),
@@ -513,6 +587,817 @@ describe("state migrations", () => {
await expectMissingPath(resolveChannelAllowFromPath("chatapp", env, "beta"));
});
it("canonicalizes parsed owners before removing the legacy store", async () => {
const root = await createTempDir();
const stateDir = path.join(root, ".openclaw");
const env = createEnv(stateDir);
const legacyStorePath = path.join(stateDir, "sessions", "sessions.json");
await fs.mkdir(path.dirname(legacyStorePath), { recursive: true });
await fs.writeFile(
legacyStorePath,
JSON.stringify({
"agent:archive:main": { sessionId: "archive-session", updatedAt: 20 },
}),
"utf8",
);
const cfg = {
session: { mainKey: "work" },
agents: { list: [{ id: "main", default: true }] },
} as OpenClawConfig;
const detected = await detectLegacyStateMigrations({ cfg, env, homedir: () => root });
await runLegacyStateMigrations({ detected, config: cfg, now: () => 1234 });
const targetStorePath = path.join(stateDir, "agents", "main", "sessions", "sessions.json");
const store = JSON.parse(await fs.readFile(targetStorePath, "utf8")) as Record<
string,
{ sessionId: string }
>;
expect(store["agent:archive:work"]?.sessionId).toBe("archive-session");
expect(store["agent:archive:main"]).toBeUndefined();
await expectMissingPath(legacyStorePath);
});
it("defers non-main owner merges across hard-linked stores", async () => {
const root = await createTempDir();
const stateDir = path.join(root, ".openclaw");
const env = createEnv(stateDir);
const targetStorePath = path.join(stateDir, "agents", "ops", "sessions", "sessions.json");
await fs.mkdir(path.dirname(targetStorePath), { recursive: true });
await fs.writeFile(
targetStorePath,
JSON.stringify({
"agent:ops:main": { sessionId: "ops-session", updatedAt: 10 },
}),
"utf8",
);
const configuredStorePath = path.join(root, "configured-sessions.json");
await fs.link(targetStorePath, configuredStorePath);
const legacyStorePath = path.join(stateDir, "sessions", "sessions.json");
await fs.mkdir(path.dirname(legacyStorePath), { recursive: true });
await fs.writeFile(
legacyStorePath,
JSON.stringify({
"agent:research:main": { sessionId: "research-session", updatedAt: 20 },
}),
"utf8",
);
const cfg = {
session: { mainKey: "work", store: configuredStorePath },
agents: { list: [{ id: "ops", default: true }, { id: "research" }] },
} as OpenClawConfig;
const detected = await detectLegacyStateMigrations({ cfg, env, homedir: () => root });
expect(detected.sessions.preserveAmbiguousKeys).toBe(true);
const result = await runLegacyStateMigrations({ detected, config: cfg, now: () => 1234 });
for (const storePath of [targetStorePath, configuredStorePath]) {
const store = JSON.parse(await fs.readFile(storePath, "utf8")) as Record<
string,
{ sessionId: string }
>;
expect(store["agent:ops:main"]?.sessionId).toBe("ops-session");
expect(store["agent:ops:work"]).toBeUndefined();
expect(store["agent:research:main"]).toBeUndefined();
}
await expect(fs.readFile(legacyStorePath, "utf8")).resolves.toContain("research-session");
expect(result.warnings).toContainEqual(
expect.stringContaining("atomic replacement cannot update distinct filesystem aliases"),
);
});
it("defers an unambiguous legacy merge through a final store symlink", async () => {
const root = await createTempDir();
const stateDir = path.join(root, ".openclaw");
const env = createEnv(stateDir);
const outsideStorePath = path.join(root, "outside-sessions.json");
await fs.writeFile(outsideStorePath, "{}\n", "utf8");
const targetStorePath = path.join(stateDir, "agents", "main", "sessions", "sessions.json");
await fs.mkdir(path.dirname(targetStorePath), { recursive: true });
await fs.symlink(outsideStorePath, targetStorePath);
const legacyStorePath = path.join(stateDir, "sessions", "sessions.json");
await fs.mkdir(path.dirname(legacyStorePath), { recursive: true });
await fs.writeFile(
legacyStorePath,
JSON.stringify({
"agent:main:task": { sessionId: "legacy-task", updatedAt: 10 },
}),
"utf8",
);
const cfg = { agents: { list: [{ id: "main", default: true }] } } as OpenClawConfig;
const detected = await detectLegacyStateMigrations({ cfg, env, homedir: () => root });
const result = await runLegacyStateMigrations({ detected, config: cfg, now: () => 1234 });
expect((await fs.lstat(targetStorePath)).isSymbolicLink()).toBe(true);
await expect(fs.readFile(outsideStorePath, "utf8")).resolves.toBe("{}\n");
await expect(fs.readFile(legacyStorePath, "utf8")).resolves.toContain("legacy-task");
expect(result.warnings).toContain(
`Deferred legacy session migration in final-component symlink store ${targetStorePath}; configure one canonical session.store path, then rerun openclaw doctor --fix`,
);
});
it("defers legacy migration when configured store identity is inaccessible", async () => {
const root = await createTempDir();
const stateDir = path.join(root, ".openclaw");
const env = createEnv(stateDir);
const targetStorePath = path.join(stateDir, "agents", "main", "sessions", "sessions.json");
await fs.mkdir(path.dirname(targetStorePath), { recursive: true });
await fs.writeFile(targetStorePath, "{}\n", "utf8");
const configuredStorePath = path.join(root, "configured-sessions.json");
await fs.writeFile(configuredStorePath, "{}\n", "utf8");
const legacyStorePath = path.join(stateDir, "sessions", "sessions.json");
await fs.mkdir(path.dirname(legacyStorePath), { recursive: true });
await fs.writeFile(
legacyStorePath,
JSON.stringify({ "agent:main:task": { sessionId: "legacy", updatedAt: 10 } }),
"utf8",
);
const cfg = {
session: { store: configuredStorePath },
agents: { list: [{ id: "main", default: true }] },
} as OpenClawConfig;
const realStatSync = fsSync.statSync.bind(fsSync);
const statSpy = vi.spyOn(fsSync, "statSync").mockImplementation((candidate) => {
if (path.resolve(candidate.toString()) === configuredStorePath) {
throw Object.assign(new Error("inaccessible store"), { code: "EACCES" });
}
return realStatSync(candidate);
});
let detected: Awaited<ReturnType<typeof detectLegacyStateMigrations>>;
try {
detected = await detectLegacyStateMigrations({ cfg, env, homedir: () => root });
} finally {
statSpy.mockRestore();
}
expect(detected.sessions.targetStoreAliases.hasUnresolvedIdentity).toBe(true);
const result = await runLegacyStateMigrations({ detected, config: cfg, now: () => 1234 });
expect(result.warnings).toContainEqual(
expect.stringContaining("filesystem identity could not be established"),
);
await expect(fs.readFile(legacyStorePath, "utf8")).resolves.toContain("legacy");
await expect(fs.readFile(targetStorePath, "utf8")).resolves.toBe("{}\n");
});
it("keeps the legacy source when its store write fails", async () => {
const root = await createTempDir();
const stateDir = path.join(root, ".openclaw");
const env = createEnv(stateDir);
const targetStorePath = path.join(stateDir, "agents", "main", "sessions", "sessions.json");
await fs.mkdir(path.dirname(targetStorePath), { recursive: true });
await fs.writeFile(targetStorePath, "{}\n", "utf8");
const legacyStorePath = path.join(stateDir, "sessions", "sessions.json");
await fs.mkdir(path.dirname(legacyStorePath), { recursive: true });
await fs.writeFile(
legacyStorePath,
JSON.stringify({ "agent:main:task": { sessionId: "legacy", updatedAt: 10 } }),
"utf8",
);
const cfg = {
agents: { list: [{ id: "main", default: true }] },
} as OpenClawConfig;
const detected = await detectLegacyStateMigrations({ cfg, env, homedir: () => root });
const realSaveSessionStore = sessionStore.saveSessionStore;
let sawRequiredWrite = false;
const saveSpy = vi
.spyOn(sessionStore, "saveSessionStore")
.mockImplementation(async (storePath, store, options) => {
sawRequiredWrite ||= options?.requireWriteSuccess === true;
if (storePath === targetStorePath) {
if (options?.requireWriteSuccess) {
throw new Error("simulated alias write failure");
}
return;
}
await realSaveSessionStore(storePath, store, options);
});
try {
await expect(
runLegacyStateMigrations({ detected, config: cfg, now: () => 1234 }),
).rejects.toThrow("simulated alias write failure");
} finally {
saveSpy.mockRestore();
}
expect(sawRequiredWrite).toBe(true);
await expect(fs.readFile(legacyStorePath, "utf8")).resolves.toContain("legacy");
});
it("preserves shared ownership through missing parent-symlink store paths", async () => {
const root = await createTempDir();
const stateDir = path.join(root, ".openclaw");
const env = createEnv(stateDir);
const agentsDir = path.join(stateDir, "agents");
await fs.mkdir(agentsDir, { recursive: true });
const aliasAgentsDir = path.join(root, "agents-alias");
await fs.symlink(agentsDir, aliasAgentsDir, "dir");
const configuredStorePath = path.join(aliasAgentsDir, "ops", "sessions", "sessions.json");
const targetStorePath = path.join(agentsDir, "ops", "sessions", "sessions.json");
const legacyStorePath = path.join(stateDir, "sessions", "sessions.json");
await fs.mkdir(path.dirname(legacyStorePath), { recursive: true });
await fs.writeFile(
legacyStorePath,
JSON.stringify({
"agent:main:work": { sessionId: "foreign-main", updatedAt: 10 },
}),
"utf8",
);
const cfg = {
session: { mainKey: "work", store: configuredStorePath },
agents: { list: [{ id: "ops", default: true }] },
} as OpenClawConfig;
const detected = await detectLegacyStateMigrations({
cfg,
env,
homedir: () => root,
pluginSessionStoreAgentIds: ["voice"],
});
expect(detected.sessions.preserveAmbiguousKeys).toBe(true);
expect(detected.sessions.preserveForeignMainAliases).toBe(true);
await runLegacyStateMigrations({ detected, config: cfg, now: () => 1234 });
const store = JSON.parse(await fs.readFile(targetStorePath, "utf8")) as Record<
string,
{ sessionId: string }
>;
expect(store["agent:main:work"]?.sessionId).toBe("foreign-main");
expect(store["agent:ops:work"]).toBeUndefined();
await expect(fs.readFile(configuredStorePath, "utf8")).resolves.toBe(
await fs.readFile(targetStorePath, "utf8"),
);
});
it("preserves plugin ownership captured before an aliased store rewrite", async () => {
const root = await createTempDir();
const stateDir = path.join(root, ".openclaw");
const env = createEnv(stateDir);
const targetStorePath = path.join(stateDir, "agents", "worker-1", "sessions", "sessions.json");
await fs.mkdir(path.dirname(targetStorePath), { recursive: true });
await fs.writeFile(
targetStorePath,
JSON.stringify({
"agent:main:desk": { sessionId: "foreign-main", updatedAt: 30 },
"agent:worker-1:main": {
sessionId: "worker-main",
updatedAt: 20,
acp: {
backend: "test",
agent: "worker-1",
runtimeSessionName: "legacy-runtime",
mode: "persistent",
state: "idle",
lastActivityAt: 20,
},
},
"voice:15550001111": { sessionId: "legacy-voice", updatedAt: 10 },
}),
"utf8",
);
const configuredStorePath = path.join(root, "configured-sessions.json");
await fs.link(targetStorePath, configuredStorePath);
const cfg = {
agents: { list: [{ id: "worker-1", default: true }] },
session: { mainKey: "desk", store: configuredStorePath },
plugins: {
entries: {
"voice-call": { config: { agentId: "worker-1" } },
},
},
} as OpenClawConfig;
const result = await autoMigrateLegacyState({ cfg, env, homedir: () => root });
const targetStore = JSON.parse(await fs.readFile(targetStorePath, "utf8")) as Record<
string,
{ sessionId: string }
>;
expect(targetStore["agent:main:desk"]?.sessionId).toBe("foreign-main");
expect(targetStore["agent:worker-1:main"]?.sessionId).toBe("worker-main");
expect(targetStore["agent:worker-1:desk"]).toBeUndefined();
expect(targetStore["agent:worker-1:main"]).toHaveProperty("acp");
expect(fsSync.statSync(configuredStorePath).ino).toBe(fsSync.statSync(targetStorePath).ino);
expect(result.warnings).toEqual(
expect.arrayContaining([
expect.stringContaining(`aliased store ${configuredStorePath}`),
expect.stringContaining(`aliased store ${targetStorePath}`),
expect.stringContaining("Deferred ACP metadata migration"),
]),
);
});
it("preserves a singleton final symlink through all session migration phases", async () => {
const root = await createTempDir();
const stateDir = path.join(root, ".openclaw");
const env = createEnv(stateDir);
const outsideStorePath = path.join(root, "outside-sessions.json");
await fs.writeFile(
outsideStorePath,
JSON.stringify({
"voice:15550001111": { sessionId: "outside-voice", updatedAt: 10 },
}),
"utf8",
);
const storePath = path.join(stateDir, "agents", "main", "sessions", "sessions.json");
await fs.mkdir(path.dirname(storePath), { recursive: true });
await fs.symlink(outsideStorePath, storePath);
const cfg = { agents: { list: [{ id: "main", default: true }] } } as OpenClawConfig;
const result = await autoMigrateLegacyState({ cfg, env, homedir: () => root });
expect((await fs.lstat(storePath)).isSymbolicLink()).toBe(true);
const outsideStore = JSON.parse(await fs.readFile(outsideStorePath, "utf8")) as Record<
string,
{ sessionId: string }
>;
expect(outsideStore["voice:15550001111"]?.sessionId).toBe("outside-voice");
expect(result.warnings).toEqual([
`Deferred session key migration in final-component symlink store ${storePath}; configure one canonical session.store path, then rerun openclaw doctor --fix`,
`Deferred legacy session migration in final-component symlink store ${storePath}; configure one canonical session.store path, then rerun openclaw doctor --fix`,
]);
});
it("preserves ACP metadata through a singleton fixed-store symlink", async () => {
const root = await createTempDir();
const stateDir = path.join(root, ".openclaw");
const env = createEnv(stateDir);
const outsideStorePath = path.join(root, "outside-sessions.json");
await fs.writeFile(
outsideStorePath,
JSON.stringify({
"agent:main:task": {
sessionId: "canonical-acp",
updatedAt: 10,
acp: {
backend: "test",
agent: "main",
runtimeSessionName: "outside-runtime",
mode: "persistent",
state: "idle",
lastActivityAt: 10,
},
},
}),
"utf8",
);
const configuredStorePath = path.join(root, "configured-sessions.json");
await fs.symlink(outsideStorePath, configuredStorePath);
const cfg = {
session: { store: configuredStorePath },
agents: { list: [{ id: "main", default: true }] },
} as OpenClawConfig;
const result = await autoMigrateLegacyState({ cfg, env, homedir: () => root });
expect((await fs.lstat(configuredStorePath)).isSymbolicLink()).toBe(true);
const outsideStore = JSON.parse(await fs.readFile(outsideStorePath, "utf8")) as Record<
string,
{ sessionId: string; acp?: unknown }
>;
expect(outsideStore["agent:main:task"]?.acp).toBeDefined();
expect(result.warnings).toContain(
`Deferred ACP metadata migration in final-component symlink store ${configuredStorePath}; configure one canonical session.store path, then rerun openclaw doctor --fix`,
);
expect(result.changes).not.toContain(
"Migrated 1 ACP session metadata row → shared SQLite state",
);
});
it("defers ACP metadata migration across hard-linked store paths", async () => {
const root = await createTempDir();
const stateDir = path.join(root, ".openclaw");
const env = createEnv(stateDir);
const targetStorePath = path.join(stateDir, "agents", "main", "sessions", "sessions.json");
await fs.mkdir(path.dirname(targetStorePath), { recursive: true });
await fs.writeFile(
targetStorePath,
JSON.stringify({
"agent:main:task": {
sessionId: "canonical-acp",
updatedAt: 10,
acp: {
backend: "test",
agent: "main",
runtimeSessionName: "hardlink-runtime",
mode: "persistent",
state: "idle",
lastActivityAt: 10,
},
},
}),
"utf8",
);
const configuredStorePath = path.join(root, "configured-sessions.json");
await fs.link(targetStorePath, configuredStorePath);
const cfg = {
session: { store: configuredStorePath },
agents: { list: [{ id: "main", default: true }] },
} as OpenClawConfig;
const result = await autoMigrateLegacyState({ cfg, env, homedir: () => root });
for (const storePath of [targetStorePath, configuredStorePath]) {
const store = JSON.parse(await fs.readFile(storePath, "utf8")) as Record<
string,
{ acp?: unknown }
>;
expect(store["agent:main:task"]?.acp).toBeDefined();
}
expect(result.changes).not.toContain(
"Migrated 1 ACP session metadata row → shared SQLite state",
);
expect(result.warnings).toContainEqual(
expect.stringContaining("atomic replacement cannot update distinct filesystem aliases"),
);
});
it("defers global main aliases across hard-linked store paths", async () => {
const root = await createTempDir();
const stateDir = path.join(root, ".openclaw");
const env = createEnv(stateDir);
const targetStorePath = path.join(stateDir, "agents", "main", "sessions", "sessions.json");
await fs.mkdir(path.dirname(targetStorePath), { recursive: true });
await fs.writeFile(
targetStorePath,
JSON.stringify({
"agent:main:main": {
sessionId: "legacy-global",
updatedAt: 20,
acp: {
backend: "test",
agent: "main",
runtimeSessionName: "global-runtime",
mode: "persistent",
state: "idle",
lastActivityAt: 20,
},
},
}),
"utf8",
);
const configuredStorePath = path.join(root, "configured-sessions.json");
await fs.link(targetStorePath, configuredStorePath);
const cfg = {
session: { scope: "global", store: configuredStorePath },
agents: { list: [{ id: "main", default: true }] },
} as OpenClawConfig;
const result = await autoMigrateLegacyState({ cfg, env, homedir: () => root });
for (const storePath of [configuredStorePath, targetStorePath]) {
const store = JSON.parse(await fs.readFile(storePath, "utf8")) as Record<
string,
{ sessionId: string; acp?: unknown }
>;
expect(store["agent:main:main"]?.sessionId).toBe("legacy-global");
expect(store["agent:main:main"]?.acp).toBeDefined();
expect(store.global).toBeUndefined();
}
expect(result.warnings).toContainEqual(
expect.stringContaining("atomic replacement cannot update distinct filesystem aliases"),
);
expect(result.changes).not.toContain(
"Migrated 1 ACP session metadata row → shared SQLite state",
);
});
it.each([
{ name: "default", templated: false },
{ name: "templated plugin", templated: true },
])("preserves foreign ACP aliases in $name stores", async ({ templated }) => {
const root = await createTempDir();
const stateDir = path.join(root, ".openclaw");
const env = createEnv(stateDir);
const storeTemplate = path.join(root, "stores", "{agentId}", "sessions.json");
const storePath = templated
? path.join(root, "stores", "voice", "sessions.json")
: path.join(stateDir, "agents", "voice", "sessions", "sessions.json");
await fs.mkdir(path.dirname(storePath), { recursive: true });
await fs.writeFile(
storePath,
JSON.stringify({
"agent:main:main": {
sessionId: "foreign-main",
updatedAt: 20,
acp: {
backend: "test",
agent: "voice",
runtimeSessionName: "foreign-runtime",
mode: "persistent",
state: "idle",
lastActivityAt: 20,
},
},
}),
"utf8",
);
const cfg = {
session: { scope: "global", ...(templated ? { store: storeTemplate } : {}) },
agents: { list: [{ id: templated ? "main" : "voice", default: true }] },
plugins: {
entries: {
"voice-call": { config: { agentId: "voice" } },
},
},
} as OpenClawConfig;
const result = await autoMigrateLegacyState({ cfg, env, homedir: () => root });
const store = JSON.parse(await fs.readFile(storePath, "utf8")) as Record<
string,
{ sessionId: string; acp?: unknown }
>;
expect(store["agent:main:main"]?.sessionId).toBe("foreign-main");
expect(store["agent:main:main"]?.acp).toBeDefined();
expect(store.global).toBeUndefined();
expect(result.changes).not.toContain(
"Migrated 1 ACP session metadata row → shared SQLite state",
);
const acpWarningPrefix =
"Preserved ACP metadata for 1 ambiguous session key(s) in potentially shared store ";
expect(result.warnings.filter((warning) => warning.startsWith(acpWarningPrefix))).toHaveLength(
1,
);
});
it("migrates malformed agent-shaped rows in single-owner plugin stores", async () => {
const root = await createTempDir();
const stateDir = path.join(root, ".openclaw");
const env = createEnv(stateDir);
const storeTemplate = path.join(root, "stores", "{agentId}", "sessions.json");
const storePath = path.join(root, "stores", "voice", "sessions.json");
const cases = [
{
legacyKey: "agent::matrix:channel:!RoomAbC:example.org",
canonicalKey: "agent:voice:agent::matrix:channel:!RoomAbC:example.org",
sessionId: "malformed-owner",
runtimeSessionName: "malformed-runtime",
},
{
legacyKey: "agent:_bad:opaque",
canonicalKey: "agent:voice:agent:_bad:opaque",
sessionId: "invalid-owner",
runtimeSessionName: "invalid-runtime",
},
];
await fs.mkdir(path.dirname(storePath), { recursive: true });
await fs.writeFile(
storePath,
JSON.stringify(
Object.fromEntries(
cases.map(({ legacyKey, sessionId, runtimeSessionName }) => [
legacyKey,
{
sessionId,
updatedAt: 10,
acp: {
backend: "test",
agent: "voice",
runtimeSessionName,
mode: "persistent",
state: "idle",
lastActivityAt: 10,
},
},
]),
),
),
"utf8",
);
const cfg = {
session: { store: storeTemplate },
agents: { list: [{ id: "main", default: true }] },
plugins: {
entries: {
"voice-call": { config: { agentId: "voice" } },
},
},
} as OpenClawConfig;
const result = await autoMigrateLegacyState({ cfg, env, homedir: () => root });
const store = JSON.parse(await fs.readFile(storePath, "utf8")) as Record<
string,
{ sessionId: string; acp?: unknown }
>;
for (const { legacyKey, canonicalKey, sessionId, runtimeSessionName } of cases) {
expect(store[legacyKey]).toBeUndefined();
expect(store[canonicalKey]).toEqual({ sessionId, updatedAt: 10 });
expect(
readAcpSessionMetaForEntry({
sessionKey: canonicalKey,
entry: { sessionId },
env,
})?.runtimeSessionName,
).toBe(runtimeSessionName);
expect(
readAcpSessionMetaForEntry({
sessionKey: legacyKey,
entry: { sessionId },
env,
}),
).toBeUndefined();
}
expect(result.changes).toContain("Migrated 2 ACP session metadata rows → shared SQLite state");
expect(result.warnings).toHaveLength(0);
});
it("preserves multi-owner rows through coalesced templated-store migration", async () => {
const root = await createTempDir();
const stateDir = path.join(root, ".openclaw");
const env = createEnv(stateDir);
const storeTemplate = path.join(
stateDir,
"agents",
"{agentId}",
"..",
"main",
"sessions",
"sessions.json",
);
const storePath = path.join(stateDir, "agents", "main", "sessions", "sessions.json");
await fs.mkdir(path.dirname(storePath), { recursive: true });
await fs.writeFile(
storePath,
JSON.stringify({
"voice:15550001111": {
sessionId: "shared-voice",
updatedAt: 20,
acp: {
backend: "test",
agent: "voice",
runtimeSessionName: "shared-runtime",
mode: "persistent",
state: "idle",
lastActivityAt: 20,
},
},
"agent:voice::matrix:channel:!room:example.org": {
sessionId: "malformed-owner",
updatedAt: 10,
acp: {
backend: "test",
agent: "voice",
runtimeSessionName: "malformed-runtime",
mode: "persistent",
state: "idle",
lastActivityAt: 10,
},
},
"agent:_bad:opaque": {
sessionId: "invalid-owner",
updatedAt: 5,
acp: {
backend: "test",
agent: "voice",
runtimeSessionName: "invalid-runtime",
mode: "persistent",
state: "idle",
lastActivityAt: 5,
},
},
}),
"utf8",
);
const legacyStorePath = path.join(stateDir, "sessions", "sessions.json");
await fs.mkdir(path.dirname(legacyStorePath), { recursive: true });
await fs.writeFile(legacyStorePath, "{}\n", "utf8");
const cfg = {
session: { store: storeTemplate },
agents: { list: [{ id: "main", default: true }] },
acp: { allowedAgents: ["voice"] },
} as OpenClawConfig;
const result = await autoMigrateLegacyState({ cfg, env, homedir: () => root });
const store = JSON.parse(await fs.readFile(storePath, "utf8")) as Record<
string,
{ sessionId: string; acp?: unknown }
>;
expect(store["voice:15550001111"]?.sessionId).toBe("shared-voice");
expect(store["voice:15550001111"]?.acp).toBeDefined();
expect(store["agent:voice::matrix:channel:!room:example.org"]?.sessionId).toBe(
"malformed-owner",
);
expect(store["agent:voice::matrix:channel:!room:example.org"]?.acp).toBeDefined();
expect(store["agent:_bad:opaque"]?.sessionId).toBe("invalid-owner");
expect(store["agent:_bad:opaque"]?.acp).toBeDefined();
expect(store["agent:main:voice:15550001111"]).toBeUndefined();
expect(store["agent:voice:voice:15550001111"]).toBeUndefined();
expect(store["agent:main:agent:voice::matrix:channel:!room:example.org"]).toBeUndefined();
expect(result.changes).not.toContain(
"Migrated 1 ACP session metadata row → shared SQLite state",
);
const acpWarningPrefix =
"Preserved ACP metadata for 3 ambiguous session key(s) in potentially shared store ";
expect(result.warnings.filter((warning) => warning.startsWith(acpWarningPrefix))).toHaveLength(
2,
);
});
it("does not process ACP stores rejected by target validation", async () => {
const root = await createTempDir();
const stateDir = path.join(root, ".openclaw");
const env = createEnv(stateDir);
const outsideStorePath = path.join(root, "outside-sessions.json");
await fs.writeFile(
outsideStorePath,
JSON.stringify({
"agent:main:opaque": {
sessionId: "outside-session",
updatedAt: 10,
acp: {
backend: "test",
agent: "main",
runtimeSessionName: "outside-runtime",
mode: "persistent",
state: "idle",
lastActivityAt: 10,
},
},
}),
"utf8",
);
const storePath = path.join(stateDir, "agents", "main", "sessions", "sessions.json");
await fs.mkdir(path.dirname(storePath), { recursive: true });
await fs.symlink(outsideStorePath, storePath);
const cfg = { agents: { list: [{ id: "main", default: true }] } } as OpenClawConfig;
const result = await autoMigrateLegacyState({ cfg, env, homedir: () => root });
expect((await fs.lstat(storePath)).isSymbolicLink()).toBe(true);
const outsideStore = JSON.parse(await fs.readFile(outsideStorePath, "utf8")) as Record<
string,
{ acp?: unknown }
>;
expect(outsideStore["agent:main:opaque"]?.acp).toBeDefined();
expect(result.changes).not.toContain(
"Migrated 1 ACP session metadata row → shared SQLite state",
);
});
it("canonicalizes imported ACP aliases with their session row owner", async () => {
const root = await createTempDir();
const stateDir = path.join(root, ".openclaw");
const env = createEnv(stateDir);
const storeTemplate = path.join(
stateDir,
"agents",
"{agentId}",
"..",
"main",
"sessions",
"sessions.json",
);
const storePath = path.join(stateDir, "agents", "main", "sessions", "sessions.json");
await fs.mkdir(path.dirname(storePath), { recursive: true });
await fs.writeFile(storePath, "{}\n", "utf8");
const legacyStorePath = path.join(stateDir, "sessions", "sessions.json");
await fs.mkdir(path.dirname(legacyStorePath), { recursive: true });
await fs.writeFile(
legacyStorePath,
JSON.stringify({
"agent:voice:main": {
sessionId: "voice-main",
updatedAt: 10,
acp: {
backend: "test",
agent: "voice",
runtimeSessionName: "voice-runtime",
mode: "persistent",
state: "idle",
lastActivityAt: 10,
},
},
}),
"utf8",
);
const cfg = {
session: { mainKey: "desk", store: storeTemplate },
agents: { list: [{ id: "main", default: true }, { id: "voice" }] },
} as OpenClawConfig;
const result = await autoMigrateLegacyState({ cfg, env, homedir: () => root });
expect(
readAcpSessionMetaForEntry({
sessionKey: "agent:voice:desk",
entry: { sessionId: "voice-main" },
env,
})?.runtimeSessionName,
).toBe("voice-runtime");
expect(
readAcpSessionMetaForEntry({
sessionKey: "agent:voice:main",
entry: { sessionId: "voice-main" },
env,
}),
).toBeUndefined();
expect(result.changes).toContain("Migrated 1 ACP session metadata row → shared SQLite state");
});
it("migrates legacy delivery queue files into shared SQLite state", async () => {
const root = await createTempDir();
const stateDir = path.join(root, ".openclaw");

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@ import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "../../agents/system-prompt-cache-b
import type { Context, Model } from "../types.js";
import {
extractOpenAICodexAccountId,
parseSSEForTest,
resetOpenAICodexWebSocketDebugStats,
streamOpenAICodexResponses,
} from "./openai-chatgpt-responses.js";
@@ -560,4 +561,95 @@ describe("streamOpenAICodexResponses transport", () => {
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS);
});
it("bounds non-OK ChatGPT response bodies before formatting API errors", async () => {
const chunkSize = 1024 * 1024;
const totalChunks = 32;
const chunk = new TextEncoder()
.encode("usage limit ".repeat(Math.ceil(chunkSize / "usage limit ".length)))
.subarray(0, chunkSize);
let pullCount = 0;
let canceled = false;
const overflowing = new ReadableStream<Uint8Array>({
pull(controller) {
pullCount += 1;
if (pullCount > totalChunks) {
controller.close();
return;
}
controller.enqueue(chunk);
},
cancel() {
canceled = true;
},
});
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce(
new Response(overflowing, {
status: 400,
statusText: "Bad Request",
}),
);
vi.stubGlobal("fetch", fetchMock);
const stream = streamOpenAICodexResponses(model, context, {
apiKey: createJwt({
"https://api.openai.com/auth": {
chatgpt_account_id: "acct-1",
},
}),
transport: "sse",
});
const result = await stream.result();
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toContain("usage limit");
expect(result.errorMessage?.length).toBeLessThanOrEqual(16 * 1024);
expect(canceled).toBe(true);
expect(pullCount).toBeGreaterThanOrEqual(1);
expect(pullCount).toBeLessThanOrEqual(3);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
describe("parseSSEForTest", () => {
it("bounds streamed OpenAI ChatGPT Responses success bodies without content-length", async () => {
// 1 MiB chunks; cap is 16 MiB so the bounded reader cancels well before
// draining the full 32 MiB advertised body.
const CHUNK = 1024 * 1024;
const TOTAL = 32;
let pullCount = 0;
let cancelReason: unknown;
const overflowing = new ReadableStream<Uint8Array>({
pull(controller) {
pullCount += 1;
if (pullCount > TOTAL) {
controller.close();
return;
}
controller.enqueue(new Uint8Array(CHUNK));
},
cancel(reason) {
cancelReason = reason;
},
});
let caught: Error | null = null;
try {
// parseSSE expects a Response-like; pass the streaming body directly
// through a minimal Response shim that only exposes .body.
const response = { body: overflowing } as unknown as Response;
for await (const event of parseSSEForTest(response)) {
expect(event).toBeDefined();
}
} catch (err) {
caught = err as Error;
}
expect(caught?.message).toMatch(
/OpenAI ChatGPT Responses success body exceeded 16777216 bytes/,
);
expect(cancelReason).toBeInstanceOf(Error);
// 16 MiB + a couple of overshoot pulls, well under 32.
expect(pullCount).toBeGreaterThanOrEqual(17);
expect(pullCount).toBeLessThanOrEqual(20);
});
});

View File

@@ -25,6 +25,7 @@ import {
resolveTimerTimeoutMs,
clampTimerTimeoutMs,
} from "@openclaw/normalization-core/number-coercion";
import { createSseByteGuard } from "../../agents/streaming-byte-guard.js";
import { stripSystemPromptCacheBoundary } from "../../agents/system-prompt-cache-boundary.js";
import { getEnvApiKey } from "../env-api-keys.js";
import { clampThinkingLevel } from "../model-utils.js";
@@ -66,6 +67,8 @@ const RETRY_AFTER_HTTP_DATE_RE =
/^(?:(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} \d{2}:\d{2}:\d{2} GMT|(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), \d{2}-(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-\d{2} \d{2}:\d{2}:\d{2} GMT|(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) [ \d]\d \d{2}:\d{2}:\d{2} \d{4})$/;
const CODEX_TOOL_CALL_PROVIDERS = new Set(["openai", "opencode"]);
const WEBSOCKET_MESSAGE_TOO_BIG_CLOSE_CODE = 1009;
const OPENAI_CHATGPT_RESPONSES_ERROR_BODY_MAX_BYTES = 16 * 1024;
const OPENAI_CHATGPT_RESPONSES_SUCCESS_BODY_MAX_BYTES = 16 * 1024 * 1024;
const CODEX_RESPONSE_STATUSES = new Set<CodexResponseStatus>([
"completed",
@@ -339,7 +342,7 @@ export const streamOpenAICodexResponses: StreamFunction<
break;
}
const errorText = await response.text();
const errorText = await readChatGptResponsesErrorTextLimited(response);
if (attempt < MAX_RETRIES && isRetryableError(response.status, errorText)) {
let delayMs = BASE_DELAY_MS * 2 ** attempt;
@@ -722,12 +725,23 @@ async function* parseSSE(response: Response): AsyncGenerator<Record<string, unkn
}
const reader = response.body.getReader();
// Cap the streaming 200 success-body read at 16 MiB, mirroring the
// non-streaming `readProviderJsonResponse` cap so a hostile or
// malfunctioning ChatGPT Responses endpoint cannot exhaust memory by
// streaming an unbounded SSE body.
const guard = createSseByteGuard(reader, {
maxBytes: OPENAI_CHATGPT_RESPONSES_SUCCESS_BODY_MAX_BYTES,
onOverflow: ({ size, maxBytes }) =>
new Error(
`OpenAI ChatGPT Responses success body exceeded ${maxBytes} bytes (received ${size})`,
),
});
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
const { done, value } = await guard.read();
if (done) {
break;
}
@@ -760,7 +774,7 @@ async function* parseSSE(response: Response): AsyncGenerator<Record<string, unkn
}
} finally {
try {
await reader.cancel();
await guard.cancel();
} catch {}
try {
reader.releaseLock();
@@ -768,6 +782,10 @@ async function* parseSSE(response: Response): AsyncGenerator<Record<string, unkn
}
}
// Test-only re-export of the bounded SSE parser. Mirrors
// `parseAnthropicSseBodyForTest` / `iterateSseMessagesForTest` patterns.
export const parseSSEForTest = parseSSE;
// ============================================================================
// WebSocket Parsing
// ============================================================================
@@ -1521,10 +1539,57 @@ async function processWebSocketStream(
// Error Handling
// ============================================================================
async function readChatGptResponsesErrorTextLimited(response: Response): Promise<string> {
const reader = response.body?.getReader();
if (!reader) {
return "";
}
const decoder = new TextDecoder();
let total = 0;
let text = "";
let reachedLimit = false;
try {
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
if (!value || value.byteLength === 0) {
continue;
}
const remaining = OPENAI_CHATGPT_RESPONSES_ERROR_BODY_MAX_BYTES - total;
if (remaining <= 0) {
reachedLimit = true;
break;
}
const chunk = value.byteLength > remaining ? value.subarray(0, remaining) : value;
total += chunk.byteLength;
text += decoder.decode(chunk, { stream: true });
if (total >= OPENAI_CHATGPT_RESPONSES_ERROR_BODY_MAX_BYTES) {
reachedLimit = true;
break;
}
}
text += decoder.decode();
} finally {
if (reachedLimit) {
// This provider module is browser-safe, so keep error-body capping on Web APIs.
await reader.cancel().catch(() => {});
}
try {
reader.releaseLock();
} catch {}
}
return text;
}
async function parseErrorResponse(
response: Response,
): Promise<{ message: string; friendlyMessage?: string }> {
const raw = await response.text();
const raw = await readChatGptResponsesErrorTextLimited(response);
let message = raw || response.statusText || "Request failed";
let friendlyMessage: string | undefined;

View File

@@ -19,6 +19,7 @@ let collectRelevantDoctorPluginIds: typeof import("./doctor-contract-registry.js
let collectRelevantDoctorPluginIdsForTouchedPaths: typeof import("./doctor-contract-registry.js").collectRelevantDoctorPluginIdsForTouchedPaths;
let listPluginDoctorLegacyConfigRules: typeof import("./doctor-contract-registry.js").listPluginDoctorLegacyConfigRules;
let listPluginDoctorSessionRouteStateOwners: typeof import("./doctor-contract-registry.js").listPluginDoctorSessionRouteStateOwners;
let listPluginDoctorSessionStoreAgentIds: typeof import("./doctor-contract-registry.js").listPluginDoctorSessionStoreAgentIds;
let setPluginDoctorContractRegistryModuleLoaderFactoryForTest:
| typeof import("./doctor-contract-registry.js").setPluginDoctorContractRegistryModuleLoaderFactoryForTest
| undefined;
@@ -51,6 +52,7 @@ describe("doctor-contract-registry module loader", () => {
collectRelevantDoctorPluginIdsForTouchedPaths,
listPluginDoctorLegacyConfigRules,
listPluginDoctorSessionRouteStateOwners,
listPluginDoctorSessionStoreAgentIds,
setPluginDoctorContractRegistryModuleLoaderFactoryForTest,
} = await import("./doctor-contract-registry.js"));
setPluginDoctorContractRegistryModuleLoaderFactoryForTest(mocks.createJiti);
@@ -215,6 +217,30 @@ describe("doctor-contract-registry module loader", () => {
]);
});
it("loads config-derived session-store agent IDs from doctor contract modules", () => {
const pluginRoot = makeTempDir();
fs.writeFileSync(
path.join(pluginRoot, "doctor-contract-api.cjs"),
"module.exports = { resolveSessionStoreAgentIds: ({ cfg }) => [cfg.plugins.entries.demo.config.agentId, 'voice', ' '] };\n",
"utf-8",
);
mocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [{ id: "test-plugin", packageName: "@openclaw/demo", rootDir: pluginRoot }],
diagnostics: [],
});
expect(
listPluginDoctorSessionStoreAgentIds({
config: {
plugins: { entries: { demo: { config: { agentId: "cards" } } } },
},
workspaceDir: pluginRoot,
env: {},
pluginIds: ["@openclaw/demo"],
}),
).toEqual(["cards", "voice"]);
});
it("loads multiple bundled CLI route-state owners from doctor contract modules", () => {
const anthropicRoot = makeTempDir();
const googleRoot = makeTempDir();

View File

@@ -29,6 +29,7 @@ const RUNNING_FROM_BUILT_ARTIFACT =
type PluginDoctorContractModule = {
legacyConfigRules?: unknown;
normalizeCompatibilityConfig?: unknown;
resolveSessionStoreAgentIds?: unknown;
sessionRouteStateOwners?: unknown;
stateMigrations?: unknown;
};
@@ -42,10 +43,15 @@ type PluginDoctorCompatibilityNormalizer = (params: {
cfg: OpenClawConfig;
}) => PluginDoctorCompatibilityMutation;
type PluginDoctorSessionStoreAgentIdsResolver = (params: {
cfg: OpenClawConfig;
}) => readonly string[];
type PluginDoctorContractEntry = {
pluginId: string;
rules: LegacyConfigRule[];
normalizeCompatibilityConfig?: PluginDoctorCompatibilityNormalizer;
resolveSessionStoreAgentIds?: PluginDoctorSessionStoreAgentIdsResolver;
sessionRouteStateOwners: DoctorSessionRouteStateOwner[];
stateMigrations: PluginDoctorStateMigration[];
};
@@ -137,6 +143,14 @@ function coerceNormalizeCompatibilityConfig(
return typeof value === "function" ? (value as PluginDoctorCompatibilityNormalizer) : undefined;
}
function coerceSessionStoreAgentIdsResolver(
value: unknown,
): PluginDoctorSessionStoreAgentIdsResolver | undefined {
return typeof value === "function"
? (value as PluginDoctorSessionStoreAgentIdsResolver)
: undefined;
}
function isDoctorSessionRouteStateOwner(value: unknown): value is DoctorSessionRouteStateOwner {
if (!value || typeof value !== "object") {
return false;
@@ -322,6 +336,10 @@ function loadPluginDoctorContractEntry(
mod.normalizeCompatibilityConfig ??
(mod as { default?: PluginDoctorContractModule }).default?.normalizeCompatibilityConfig,
);
const resolveSessionStoreAgentIds = coerceSessionStoreAgentIdsResolver(
mod.resolveSessionStoreAgentIds ??
(mod as { default?: PluginDoctorContractModule }).default?.resolveSessionStoreAgentIds,
);
const sessionRouteStateOwners = coerceDoctorSessionRouteStateOwners(
mod.sessionRouteStateOwners ??
(mod as { default?: PluginDoctorContractModule }).default?.sessionRouteStateOwners,
@@ -333,6 +351,7 @@ function loadPluginDoctorContractEntry(
if (
rules.length === 0 &&
!normalizeCompatibilityConfig &&
!resolveSessionStoreAgentIds &&
sessionRouteStateOwners.length === 0 &&
stateMigrations.length === 0
) {
@@ -342,6 +361,7 @@ function loadPluginDoctorContractEntry(
pluginId: record.id,
rules,
normalizeCompatibilityConfig,
resolveSessionStoreAgentIds,
sessionRouteStateOwners,
stateMigrations,
};
@@ -371,6 +391,8 @@ function resolvePluginDoctorContracts(params?: {
if (
scopedPluginIds &&
!scopedPluginIds.has(record.id) &&
!(record.packageName && scopedPluginIds.has(record.packageName)) &&
!record.legacyPluginIds?.some((pluginId) => scopedPluginIds.has(pluginId)) &&
!record.channels.some((channelId) => scopedPluginIds.has(channelId)) &&
!record.providers.some((providerId) => scopedPluginIds.has(providerId))
) {
@@ -422,6 +444,30 @@ export function listPluginDoctorSessionRouteStateOwners(params?: {
return [...owners.values()].toSorted((left, right) => left.id.localeCompare(right.id));
}
/** Resolve plugin-owned agent IDs whose core session stores need migration. */
export function listPluginDoctorSessionStoreAgentIds(params?: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
pluginIds?: readonly string[];
}): string[] {
const cfg = params?.config ?? {};
const agentIds = new Set<string>();
for (const entry of resolvePluginDoctorContracts(params)) {
let resolved: readonly string[] | undefined;
try {
resolved = entry.resolveSessionStoreAgentIds?.({ cfg });
} catch {
// A plugin-owned hint must never block core startup migration.
continue;
}
for (const agentId of normalizeTrimmedStringList(resolved)) {
agentIds.add(agentId);
}
}
return [...agentIds].toSorted();
}
export function listPluginDoctorStateMigrationEntries(params?: {
config?: OpenClawConfig;
workspaceDir?: string;

View File

@@ -1,5 +1,6 @@
// Routing session key tests cover route-derived session key behavior.
import { describe, expect, it } from "vitest";
import { resolveSessionStoreAgentId } from "../gateway/session-store-key.js";
import { deriveSessionChatTypeFromKey } from "../sessions/session-chat-type-shared.js";
import {
getSubagentDepth,
@@ -129,13 +130,29 @@ describe("isCronSessionKey", () => {
describe("deriveSessionChatTypeFromKey", () => {
it.each([
{ key: "agent:main:direct:user1", expected: "direct" },
{ key: "agent:main:discord:direct:user1", expected: "direct" },
{ key: "agent:main:telegram:group:g1", expected: "group" },
{ key: "agent:main:discord:channel:c1", expected: "channel" },
{ key: "agent:main:discord:guild-123:channel-456", expected: "channel" },
{ key: "agent:main:channel:legacy-room", expected: "channel" },
{ key: "agent:main:channel:!room:example.org", expected: "channel" },
{ key: "agent:main:channel:direct:user", expected: "channel" },
{ key: "agent:main:group:room:part", expected: "group" },
{ key: "agent:main:group:dm:user", expected: "group" },
{ key: "agent:main:whatsapp:123@g.us", expected: "group" },
{ key: "agent:main:telegram:dm:123456", expected: "direct" },
{ key: "telegram:dm:123456", expected: "direct" },
{ key: "agent:main:matrix:channel:!Room:example.org", expected: "channel" },
{ key: "agent:main:matrix:channel:!room:[2001:db8::1]", expected: "channel" },
{ key: "agent:voice:agent:other:matrix:channel:!room:example.org", expected: "unknown" },
{ key: "agent:main:direct", expected: "unknown" },
{ key: "agent:main:demo:acct:channel", expected: "unknown" },
{ key: "agent:main:telegram:group:direct:user", expected: "unknown" },
{ key: "agent:main:direct:group:room", expected: "unknown" },
{ key: "agent:main:dm:account:group:room", expected: "unknown" },
{ key: "agent:main:demo::channel:room", expected: "unknown" },
{ key: "agent::demo:direct:user", expected: "unknown" },
{ key: "agent:main:main", expected: "unknown" },
{ key: "agent:main", expected: "unknown" },
{ key: "", expected: "unknown" },
@@ -143,7 +160,7 @@ describe("deriveSessionChatTypeFromKey", () => {
expect(deriveSessionChatTypeFromKey(key)).toBe(expected);
});
it("uses plugin-owned legacy chat-type hooks after generic token parsing", () => {
it("uses plugin-owned legacy chat-type hooks after canonical parsing", () => {
expect(
deriveSessionChatTypeFromKey("legacy-room:abc", [
(sessionKey) => (sessionKey.startsWith("legacy-room:") ? "channel" : undefined),
@@ -196,6 +213,16 @@ describe("session key canonicalization", () => {
rest: "hook:webhook:42",
}),
},
{
name: "preserves empty segments inside opaque agent-scoped tails",
run: () => {
expect(parseAgentSessionKey("agent:voice:room::part")).toEqual({
agentId: "voice",
rest: "room::part",
});
expect(resolveSessionStoreAgentId({}, "agent:voice:room::part")).toBe("voice");
},
},
{
name: "does not double-prefix already-qualified agent keys",
run: () =>

View File

@@ -18,8 +18,10 @@ export {
isAcpSessionKey,
isSubagentSessionKey,
parseAgentSessionKey,
parseSessionDeliveryRoute,
parseThreadSessionSuffix,
type ParsedAgentSessionKey,
type ParsedSessionDeliveryRoute,
} from "../sessions/session-key-utils.js";
export {
DEFAULT_ACCOUNT_ID,

View File

@@ -87,6 +87,60 @@ describe("resolveSendPolicy", () => {
}),
expected: "deny",
},
{
name: "chat-type deny fires for a per-peer DM key without session metadata",
cfg: cfgWithRules([{ action: "deny", match: { chatType: "direct" } }]),
sessionKey: buildAgentPeerSessionKey({
agentId: "main",
channel: "demo-channel",
peerKind: "direct",
peerId: "user-1",
dmScope: "per-peer",
}),
expected: "deny",
},
{
name: "channel deny accepts opaque Matrix peers with empty tail segments",
cfg: cfgWithRules([{ action: "deny", match: { channel: "matrix" } }]),
sessionKey: "agent:main:matrix:channel:!room:[2001:db8::1]",
expected: "deny",
},
{
name: "chat-type deny applies to legacy channel keys",
cfg: cfgWithRules([{ action: "deny", match: { chatType: "channel" } }]),
sessionKey: "agent:main:channel:legacy-room",
expected: "deny",
},
{
name: "chat-type deny applies to colon-bearing legacy channel keys",
cfg: cfgWithRules([{ action: "deny", match: { chatType: "channel" } }]),
sessionKey: "agent:main:channel:!room:example.org",
expected: "deny",
},
{
name: "legacy channel keys overlapping canonical direct peers fail closed",
cfg: cfgWithRules([{ action: "deny", match: { chatType: "channel" } }]),
sessionKey: "agent:main:channel:direct:user",
expected: "deny",
},
{
name: "ambiguous account and peer-kind tokens fail closed",
cfg: cfgWithRules([{ action: "deny", match: { chatType: "direct" } }]),
sessionKey: "agent:main:telegram:group:direct:user",
expected: "deny",
},
{
name: "bare direct and channel-shaped tokens fail closed",
cfg: cfgWithRules([{ action: "deny", match: { channel: "direct" } }]),
sessionKey: "agent:main:direct:group:room",
expected: "deny",
},
{
name: "bare dm and account-shaped tokens fail closed",
cfg: cfgWithRules([{ action: "deny", match: { chatType: "group" } }]),
sessionKey: "agent:main:dm:account:group:room",
expected: "deny",
},
{
name: "channel-scoped deny ignores later peer-kind-looking tokens in non-channel keys",
cfg: cfgWithRules([{ action: "deny", match: { channel: "demo-channel" } }]),
@@ -102,4 +156,53 @@ describe("resolveSendPolicy", () => {
])("$name", ({ cfg, entry, sessionKey, expected }) => {
expect(resolveSendPolicy({ cfg, entry, sessionKey })).toBe(expected);
});
it("does not apply channel allow rules to nested opaque identities", () => {
const cfg = {
session: {
sendPolicy: {
default: "deny",
rules: [
{ action: "allow", match: { channel: "matrix" } },
{ action: "allow", match: { chatType: "channel" } },
],
},
},
} as OpenClawConfig;
expect(
resolveSendPolicy({
cfg,
sessionKey: "agent:voice:agent:other:matrix:channel:!room:example.org",
}),
).toBe("deny");
expect(
resolveSendPolicy({
cfg,
sessionKey: "agent:voice:agent:voice::matrix:channel:!roomabc:example.org",
}),
).toBe("deny");
});
it.each([
"agent:main:direct",
"agent:main:demo:acct:channel",
"agent:main:demo::channel:room",
"agent::demo:direct:user",
])("does not apply peer allow rules to malformed key %s", (sessionKey) => {
const cfg = {
session: {
sendPolicy: {
default: "deny",
rules: [
{ action: "allow", match: { channel: "demo" } },
{ action: "allow", match: { chatType: "direct" } },
{ action: "allow", match: { chatType: "channel" } },
],
},
},
} as OpenClawConfig;
expect(resolveSendPolicy({ cfg, sessionKey })).toBe("deny");
});
});

View File

@@ -6,6 +6,10 @@ import {
import { normalizeChatType } from "../channels/chat-type.js";
import type { SessionChatType, SessionEntry } from "../config/sessions.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
hasAmbiguousCanonicalSessionPeerShape,
parseCanonicalSessionPeerShape,
} from "./session-chat-type-shared.js";
import { deriveSessionChatType } from "./session-chat-type.js";
/** Session send-policy decision after config and per-session overrides are evaluated. */
@@ -32,50 +36,30 @@ function stripAgentSessionKeyPrefix(key?: string): string | undefined {
if (!key) {
return undefined;
}
const parts = key.split(":").filter(Boolean);
const parts = key.split(":");
// Canonical agent session keys: agent:<agentId>:<sessionKey...>
if (parts.length >= 3 && parts[0] === "agent") {
if (parts[0] === "agent") {
if (parts.length < 3 || !parts[1] || !parts[2]) {
return undefined;
}
return parts.slice(2).join(":");
}
return key;
}
const CHANNEL_SESSION_KEY_PEER_KINDS = new Set(["group", "channel", "direct", "dm"]);
function deriveChannelFromKey(key?: string) {
const normalizedKey = stripAgentSessionKeyPrefix(key);
if (!normalizedKey) {
return undefined;
}
const parts = normalizedKey.split(":").filter(Boolean);
// Key layout is <channel>:[<accountId>:]<peerKind>:<peerId>; parts[0] is the
// channel for account-scoped DM keys too, so channel-scoped rules also fire
// for per-account-channel-peer sessions, not just 3-part direct/group keys.
const hasChannelPeerShape =
parts.length >= 3 && CHANNEL_SESSION_KEY_PEER_KINDS.has(parts[1] ?? "");
const hasAccountScopedPeerShape =
parts.length >= 4 && CHANNEL_SESSION_KEY_PEER_KINDS.has(parts[2] ?? "");
if (hasChannelPeerShape || hasAccountScopedPeerShape) {
return normalizeMatchValue(parts[0]);
}
return undefined;
return normalizeMatchValue(parseCanonicalSessionPeerShape(normalizedKey)?.channel);
}
function deriveChatTypeFromKey(key?: string): SessionChatType | undefined {
const normalizedKey = normalizeOptionalLowercaseString(stripAgentSessionKeyPrefix(key));
if (!normalizedKey) {
if (!normalizedKey || normalizedKey.startsWith("agent:")) {
return undefined;
}
const tokens = new Set(normalizedKey.split(":").filter(Boolean));
if (tokens.has("group")) {
return "group";
}
if (tokens.has("channel")) {
return "channel";
}
if (tokens.has("direct") || tokens.has("dm")) {
return "direct";
}
const derived = deriveSessionChatType(normalizedKey);
if (derived !== "unknown") {
return derived;
@@ -83,6 +67,11 @@ function deriveChatTypeFromKey(key?: string): SessionChatType | undefined {
return undefined;
}
function hasAmbiguousPeerShape(key?: string): boolean {
const normalizedKey = normalizeOptionalLowercaseString(stripAgentSessionKeyPrefix(key));
return normalizedKey ? hasAmbiguousCanonicalSessionPeerShape(normalizedKey) : false;
}
/** Resolves whether a session send is allowed by entry override and config rules. */
export function resolveSendPolicy(params: {
cfg: OpenClawConfig;
@@ -100,6 +89,11 @@ export function resolveSendPolicy(params: {
if (!policy) {
return "allow";
}
// The legacy key grammar cannot distinguish a peer-kind-shaped account id
// from a channel peer. Never let that ambiguity satisfy an allow policy.
if (hasAmbiguousPeerShape(params.sessionKey)) {
return "deny";
}
const rawSessionKey = params.sessionKey ?? "";
const strippedSessionKey = stripAgentSessionKeyPrefix(rawSessionKey) ?? "";

View File

@@ -4,12 +4,86 @@ import { parseAgentSessionKey } from "./session-key-utils.js";
export type SessionKeyChatType = "direct" | "group" | "channel" | "unknown";
type CanonicalPeerKind = "direct" | "dm" | "group" | "channel";
const CANONICAL_PEER_KINDS: ReadonlySet<string> = new Set(["direct", "dm", "group", "channel"]);
function isCanonicalPeerKind(value: string | undefined): value is CanonicalPeerKind {
return CANONICAL_PEER_KINDS.has(value ?? "");
}
export type CanonicalSessionPeerShape = {
channel?: string;
chatType: Exclude<SessionKeyChatType, "unknown">;
};
export function hasAmbiguousCanonicalSessionPeerShape(scopedSessionKey: string): boolean {
const parts = scopedSessionKey.split(":");
if (parts[0] === "agent") {
return false;
}
const hasBareDirectPeerShape = Boolean((parts[0] === "direct" || parts[0] === "dm") && parts[1]);
const hasChannelPeerShape = Boolean(parts[0] && isCanonicalPeerKind(parts[1]) && parts[2]);
const hasAccountPeerShape = Boolean(
parts[0] && parts[1] && isCanonicalPeerKind(parts[2]) && parts[3],
);
const hasBuiltInLegacyPeerShape =
deriveBuiltInLegacySessionChatType(scopedSessionKey) !== undefined;
return (
[
hasBareDirectPeerShape,
hasChannelPeerShape,
hasAccountPeerShape,
hasBuiltInLegacyPeerShape,
].filter(Boolean).length > 1
);
}
export function parseCanonicalSessionPeerShape(
scopedSessionKey: string,
): CanonicalSessionPeerShape | undefined {
const parts = scopedSessionKey.split(":");
// A second agent wrapper is opaque plugin identity, never a channel route.
if (parts[0] === "agent" || hasAmbiguousCanonicalSessionPeerShape(scopedSessionKey)) {
return undefined;
}
let channel: string | undefined;
let peerKind: CanonicalPeerKind | undefined;
let peerIdStart = 0;
if (parts[0] === "direct" || parts[0] === "dm") {
peerKind = parts[0];
peerIdStart = 1;
} else if (parts[0] && isCanonicalPeerKind(parts[1])) {
channel = parts[0];
peerKind = parts[1];
peerIdStart = 2;
} else if (parts[0] && parts[1] && isCanonicalPeerKind(parts[2])) {
channel = parts[0];
peerKind = parts[2];
peerIdStart = 3;
}
// Peer ids are opaque tails and may contain empty colon-delimited segments.
// Only the structural prefix and first peer-id segment must be present.
if (!peerKind || !parts[peerIdStart]) {
return undefined;
}
const chatType = peerKind === "direct" || peerKind === "dm" ? "direct" : peerKind;
return { ...(channel ? { channel } : {}), chatType };
}
function deriveCanonicalSessionChatType(scopedSessionKey: string): SessionKeyChatType | undefined {
return parseCanonicalSessionPeerShape(scopedSessionKey)?.chatType;
}
function deriveBuiltInLegacySessionChatType(
scopedSessionKey: string,
): SessionKeyChatType | undefined {
if (/^group:[^:]+$/.test(scopedSessionKey)) {
if (/^group:[^:]+(?::.*)?$/u.test(scopedSessionKey)) {
return "group";
}
if (/^channel:[^:]+(?::.*)?$/u.test(scopedSessionKey)) {
return "channel";
}
if (/^(?:whatsapp:)?[^:]+@g\.us$/.test(scopedSessionKey)) {
return "group";
}
@@ -25,15 +99,9 @@ export function deriveSessionChatTypeFromScopedKey(
(scopedSessionKey: string) => SessionKeyChatType | undefined
> = [],
): SessionKeyChatType {
const tokens = new Set(scopedSessionKey.split(":").filter(Boolean));
if (tokens.has("group")) {
return "group";
}
if (tokens.has("channel")) {
return "channel";
}
if (tokens.has("direct") || tokens.has("dm")) {
return "direct";
const canonical = deriveCanonicalSessionChatType(scopedSessionKey);
if (canonical) {
return canonical;
}
const builtInLegacy = deriveBuiltInLegacySessionChatType(scopedSessionKey);
if (builtInLegacy) {

View File

@@ -7,6 +7,7 @@ import {
isCasePreservingPeer,
normalizeSessionKeyPreservingOpaquePeerIds,
normalizeSessionPeerId,
parseRawSessionConversationRef,
requiresFoldedSessionKeyAliasProof,
} from "./session-key-utils.js";
@@ -48,6 +49,36 @@ describe("requiresFoldedSessionKeyAliasProof", () => {
expect(requiresFoldedSessionKeyAliasProof("agent:ops:signal:group:AbC123=")).toBe(false);
expect(requiresFoldedSessionKeyAliasProof("agent:main:telegram:group:MixedHandle")).toBe(false);
});
it("recognizes nested Matrix identities without trusting them as channel routes", () => {
const opaqueKey = `agent:voice:agent:other:matrix:channel:${ROOM_A}`;
expect(requiresFoldedSessionKeyAliasProof(opaqueKey)).toBe(true);
expect(parseRawSessionConversationRef(opaqueKey)).toBeNull();
});
});
describe("parseRawSessionConversationRef", () => {
it("preserves empty segments inside opaque Matrix room ids", () => {
expect(parseRawSessionConversationRef("agent:main:matrix:channel:!room:[2001:db8::1]")).toEqual(
{
channel: "matrix",
kind: "channel",
rawId: "!room:[2001:db8::1]",
prefix: "agent:main:matrix:channel",
},
);
});
it.each([
"agent::matrix:channel:room",
"agent:voice::matrix:channel:room",
"agent:voice:agent:channel:room",
"agent:voice:matrix::room",
"agent:voice:matrix:channel::room",
])("rejects empty structural segments in %s", (sessionKey) => {
expect(parseRawSessionConversationRef(sessionKey)).toBeNull();
});
});
describe("normalizeSessionPeerId (construction)", () => {
@@ -140,6 +171,35 @@ describe("normalizeSessionKeyPreservingOpaquePeerIds (store canonicalization)",
);
});
it("preserves Matrix tails under nested agent ownership wrappers", () => {
const key = `Agent:Voice:Agent:Other:Matrix:Channel:${ROOM_A}:Thread:${EVENT}`;
const normalized = `agent:voice:agent:other:matrix:channel:${ROOM_A}:thread:${EVENT}`;
expect(normalizeSessionKeyPreservingOpaquePeerIds(key)).toBe(normalized);
expect(requiresFoldedSessionKeyAliasProof(normalized)).toBe(true);
});
it("preserves Matrix tails behind malformed nested ownership wrappers", () => {
const key = `Agent:Voice:Agent::Matrix:Channel:${ROOM_A}:Thread:${EVENT}`;
const normalized = `agent:voice:agent::matrix:channel:${ROOM_A}:thread:${EVENT}`;
expect(normalizeSessionKeyPreservingOpaquePeerIds(key)).toBe(normalized);
expect(requiresFoldedSessionKeyAliasProof(normalized)).toBe(true);
expect(parseRawSessionConversationRef(normalized)).toBeNull();
});
it("preserves Matrix tails after an extra empty nested-wrapper segment", () => {
const mixed = `Agent:Voice:Agent:Voice::Matrix:Channel:${ROOM_A}`;
const lower = `agent:voice:agent:voice::matrix:channel:${ROOM_A.toLowerCase()}`;
const normalized = `agent:voice:agent:voice::matrix:channel:${ROOM_A}`;
expect(normalizeSessionKeyPreservingOpaquePeerIds(mixed)).toBe(normalized);
expect(normalizeSessionKeyPreservingOpaquePeerIds(mixed)).not.toBe(
normalizeSessionKeyPreservingOpaquePeerIds(lower),
);
expect(requiresFoldedSessionKeyAliasProof(normalized)).toBe(true);
expect(parseRawSessionConversationRef(normalized)).toBeNull();
});
it("preserves unscoped Matrix room and thread ids before agent scoping", () => {
expect(normalizeSessionKeyPreservingOpaquePeerIds(`Matrix:Channel:${ROOM_A}`)).toBe(
`matrix:channel:${ROOM_A}`,

View File

@@ -15,6 +15,14 @@ export type ParsedThreadSessionSuffix = {
threadId: string | undefined;
};
export type ParsedSessionDeliveryRoute = {
accountId?: string;
channel: string;
peerId: string;
peerKind: "channel" | "direct" | "dm" | "group";
threadId?: string;
};
export type RawSessionConversationRef = {
channel: string;
kind: "group" | "channel";
@@ -71,8 +79,29 @@ function findCasePreservingPeerDescriptor(
}
export function requiresFoldedSessionKeyAliasProof(sessionKey: string | undefined | null): boolean {
const ref = parseRawSessionConversationRef(sessionKey);
const descriptor = findCasePreservingPeerDescriptor(ref?.channel, ref?.kind);
const raw = normalizeOptionalString(sessionKey);
if (!raw) {
return false;
}
const parts = raw.split(":");
let bodyStartIndex = 0;
let hasAgentWrapper = false;
while (
parts.length - bodyStartIndex >= 3 &&
normalizeOptionalLowercaseString(parts[bodyStartIndex]) === "agent"
) {
hasAgentWrapper = true;
bodyStartIndex += 2;
}
if (hasAgentWrapper) {
while (bodyStartIndex < parts.length && !normalizeOptionalString(parts[bodyStartIndex])) {
bodyStartIndex += 1;
}
}
const descriptor = findCasePreservingPeerDescriptor(
parts[bodyStartIndex],
parts[bodyStartIndex + 1],
);
return descriptor?.span === "tail";
}
@@ -167,8 +196,9 @@ function collectCasePreservedSpans(raw: string): PreservedSpan[] {
spans.push({ start: threadIdStart, end: raw.length, trim: false });
}
};
// Tail: anchored to the real agent-scoped head; preserve through key end.
const scopedRe = new RegExp(`^agent:[^:]+:${channel}:${kind}:`, "i");
// Preserve tails behind nested or malformed ownership wrappers without
// treating an inner channel-shaped identity as a runtime route.
const scopedRe = new RegExp(`^(?:agent:[^:]*:)+:*${channel}:${kind}:`, "i");
const scopedMatch = scopedRe.exec(raw);
if (scopedMatch) {
collectTailSpan(scopedMatch[0].length);
@@ -236,8 +266,8 @@ export function parseAgentSessionKey(
if (!raw) {
return null;
}
const parts = raw.split(":").filter(Boolean);
if (parts.length < 3) {
const parts = raw.split(":");
if (parts.length < 3 || !parts[1] || !parts[2]) {
return null;
}
if (parts[0] !== "agent") {
@@ -321,6 +351,56 @@ export function parseThreadSessionSuffix(
return { baseSessionKey, threadId };
}
const SESSION_DELIVERY_PEER_KINDS = new Set<ParsedSessionDeliveryRoute["peerKind"]>([
"channel",
"direct",
"dm",
"group",
]);
/** Parse only complete external delivery shapes; nested ownership stays opaque. */
export function parseSessionDeliveryRoute(
sessionKey: string | undefined | null,
): ParsedSessionDeliveryRoute | null {
const parsedThread = parseThreadSessionSuffix(sessionKey);
const parsed = parseAgentSessionKey(parsedThread.baseSessionKey ?? sessionKey);
if (!parsed) {
return null;
}
const parts = parsed.rest.split(":");
if (parts[0] === "agent" || parts.length < 3) {
return null;
}
const channel = normalizeOptionalLowercaseString(parts[0]);
if (!channel) {
return null;
}
if (parts.length >= 4 && (parts[2] === "direct" || parts[2] === "dm")) {
const accountId = normalizeOptionalString(parts[1]);
const firstPeerIdSegment = normalizeOptionalString(parts[3]);
const peerId = normalizeOptionalString(parts.slice(3).join(":"));
if (!accountId || !firstPeerIdSegment || !peerId) {
return null;
}
return {
accountId,
channel,
peerId,
peerKind: parts[2],
threadId: parsedThread.threadId,
};
}
const peerKind = parts[1] as ParsedSessionDeliveryRoute["peerKind"] | undefined;
const firstPeerIdSegment = normalizeOptionalString(parts[2]);
const peerId = normalizeOptionalString(parts.slice(2).join(":"));
if (!peerKind || !SESSION_DELIVERY_PEER_KINDS.has(peerKind) || !firstPeerIdSegment || !peerId) {
return null;
}
return { channel, peerId, peerKind, threadId: parsedThread.threadId };
}
export function parseRawSessionConversationRef(
sessionKey: string | undefined | null,
): RawSessionConversationRef | null {
@@ -329,11 +409,21 @@ export function parseRawSessionConversationRef(
return null;
}
const rawParts = raw.split(":").filter(Boolean);
const bodyStartIndex =
rawParts.length >= 3 && normalizeOptionalLowercaseString(rawParts[0]) === "agent" ? 2 : 0;
const rawParts = raw.split(":");
// Only the outer ownership wrapper is authoritative for routing. Any inner
// agent-shaped identity is opaque plugin input and must not inherit policy.
const hasAgentWrapper = normalizeOptionalLowercaseString(rawParts[0]) === "agent";
if (hasAgentWrapper && (!normalizeOptionalString(rawParts[1]) || rawParts.length < 3)) {
return null;
}
const bodyStartIndex = hasAgentWrapper ? 2 : 0;
const parts = rawParts.slice(bodyStartIndex);
if (parts.length < 3) {
if (normalizeOptionalLowercaseString(parts[0]) === "agent") {
return null;
}
// Empty opaque tail segments are valid (for example compressed IPv6), but
// structural owner/channel/kind/first-id segments must be present.
if (parts.length < 3 || !normalizeOptionalString(parts[2])) {
return null;
}

52
src/shared/utf16-slice.ts Normal file
View File

@@ -0,0 +1,52 @@
// Surrogate-safe UTF-16 string slicing helpers.
//
// Kept dependency-free (no node: imports) so browser/UI bundles can import them
// without dragging in filesystem/runtime code. See utils.ts, which re-exports
// these for the broad runtime surface.
function isHighSurrogate(codeUnit: number): boolean {
return codeUnit >= 0xd800 && codeUnit <= 0xdbff;
}
function isLowSurrogate(codeUnit: number): boolean {
return codeUnit >= 0xdc00 && codeUnit <= 0xdfff;
}
/** Slices a UTF-16 string without returning dangling surrogate halves at either edge. */
export function sliceUtf16Safe(input: string, start: number, end?: number): string {
const len = input.length;
let from = start < 0 ? Math.max(len + start, 0) : Math.min(start, len);
let to = end === undefined ? len : end < 0 ? Math.max(len + end, 0) : Math.min(end, len);
if (to < from) {
const tmp = from;
from = to;
to = tmp;
}
if (from > 0 && from < len) {
const codeUnit = input.charCodeAt(from);
if (isLowSurrogate(codeUnit) && isHighSurrogate(input.charCodeAt(from - 1))) {
from += 1;
}
}
if (to > 0 && to < len) {
const codeUnit = input.charCodeAt(to - 1);
if (isHighSurrogate(codeUnit) && isLowSurrogate(input.charCodeAt(to))) {
to -= 1;
}
}
return input.slice(from, to);
}
/** Truncates a UTF-16 string without cutting a surrogate pair in half. */
export function truncateUtf16Safe(input: string, maxLen: number): string {
const limit = Math.max(0, Math.floor(maxLen));
if (input.length <= limit) {
return input;
}
return sliceUtf16Safe(input, 0, limit);
}

View File

@@ -69,52 +69,10 @@ export function sleep(ms: number) {
});
}
function isHighSurrogate(codeUnit: number): boolean {
return codeUnit >= 0xd800 && codeUnit <= 0xdbff;
}
function isLowSurrogate(codeUnit: number): boolean {
return codeUnit >= 0xdc00 && codeUnit <= 0xdfff;
}
/** Slices a UTF-16 string without returning dangling surrogate halves at either edge. */
export function sliceUtf16Safe(input: string, start: number, end?: number): string {
const len = input.length;
let from = start < 0 ? Math.max(len + start, 0) : Math.min(start, len);
let to = end === undefined ? len : end < 0 ? Math.max(len + end, 0) : Math.min(end, len);
if (to < from) {
const tmp = from;
from = to;
to = tmp;
}
if (from > 0 && from < len) {
const codeUnit = input.charCodeAt(from);
if (isLowSurrogate(codeUnit) && isHighSurrogate(input.charCodeAt(from - 1))) {
from += 1;
}
}
if (to > 0 && to < len) {
const codeUnit = input.charCodeAt(to - 1);
if (isHighSurrogate(codeUnit) && isLowSurrogate(input.charCodeAt(to))) {
to -= 1;
}
}
return input.slice(from, to);
}
/** Truncates a UTF-16 string without cutting a surrogate pair in half. */
export function truncateUtf16Safe(input: string, maxLen: number): string {
const limit = Math.max(0, Math.floor(maxLen));
if (input.length <= limit) {
return input;
}
return sliceUtf16Safe(input, 0, limit);
}
// Surrogate-safe slicing helpers live in a node-free leaf module so browser/UI
// bundles can import them without pulling in filesystem code. Re-exported here
// to preserve the historical `utils.ts` import surface.
export { sliceUtf16Safe, truncateUtf16Safe } from "./shared/utf16-slice.js";
/** Resolves `~` and OpenClaw home-relative paths with injectable env/home sources. */
export function resolveUserPath(

View File

@@ -1,53 +0,0 @@
import { describe, expect, it } from "vitest";
import { collectNativeI18nEntries, NATIVE_I18N_LOCALES } from "../../scripts/native-app-i18n.ts";
describe("native app i18n inventory", () => {
it("collects stable Android and Apple UI entries", async () => {
const entries = await collectNativeI18nEntries();
const surfaces = new Set(entries.map((entry) => entry.surface));
expect(entries.length).toBeGreaterThan(100);
expect(surfaces).toEqual(new Set(["android", "apple"]));
expect(entries.every((entry) => entry.id.startsWith(`native.${entry.surface}.`))).toBe(true);
expect(new Set(entries.map((entry) => entry.id)).size).toBe(entries.length);
expect(
entries.every(
(entry) => !/(?:\/|\\)(?:Tests?|UITests?|test|Preview(?:s)?)(?:\/|\\)/u.test(entry.path),
),
).toBe(true);
expect(
entries.every(
(entry) => !/(?:Tests?|UITests?|Previews?|Testing)\.(?:swift|kt|kts)$/u.test(entry.path),
),
).toBe(true);
expect(
entries
.filter((entry) => entry.surface === "apple")
.every((entry) =>
/^(?:apps\/ios|apps\/macos\/Sources|apps\/shared\/OpenClawKit\/Sources)\//u.test(
entry.path,
),
),
).toBe(true);
expect(entries.some((entry) => entry.source === "QR Scanner Unavailable")).toBe(true);
expect(entries.some((entry) => entry.source === "Request ID: \\(requestId)")).toBe(true);
expect(entries.some((entry) => entry.source === "Open ${row.title}")).toBe(true);
expect(entries.some((entry) => entry.source === "$deviceModel · $appVersion")).toBe(true);
expect(entries.some((entry) => entry.source === "Approval command copied")).toBe(true);
expect(entries.some((entry) => entry.source === "Save Profile")).toBe(true);
expect(entries.some((entry) => entry.source === "Pairing required")).toBe(true);
expect(entries.some((entry) => entry.source === "Mute")).toBe(true);
expect(entries.some((entry) => entry.source === "Creating...")).toBe(true);
expect(entries.some((entry) => entry.source === "Permission required")).toBe(true);
expect(entries.some((entry) => entry.source === "Searching…")).toBe(true);
expect(entries.some((entry) => entry.source === "Run now")).toBe(true);
expect(entries.some((entry) => entry.source === "Loading chat")).toBe(true);
expect(entries.some((entry) => entry.source === "$(PRODUCT_BUNDLE_IDENTIFIER)")).toBe(false);
expect(entries.some((entry) => entry.source === "false")).toBe(false);
expect(entries.some((entry) => entry.source === "ws")).toBe(false);
expect(entries.some((entry) => entry.source === '{"includeSecrets":true}')).toBe(false);
expect(entries.some((entry) => entry.source === "State: \\(stateDir)")).toBe(true);
expect(entries.some((entry) => entry.path.endsWith("Info.plist"))).toBe(true);
expect(NATIVE_I18N_LOCALES).toHaveLength(20);
});
});