mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-27 09:52:10 +08:00
Compare commits
12 Commits
codex/i18n
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
552ec2b49d | ||
|
|
4d0f19a968 | ||
|
|
072d3ed7b5 | ||
|
|
1bccd29304 | ||
|
|
498567190d | ||
|
|
5880e0afc4 | ||
|
|
65fec9d787 | ||
|
|
4d9cd7d227 | ||
|
|
12ea61a08d | ||
|
|
4932366b92 | ||
|
|
4f3d81b918 | ||
|
|
e09b9dfc1b |
22
.github/workflows/ci.yml
vendored
22
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
119
.github/workflows/native-app-locale-refresh.yml
vendored
119
.github/workflows/native-app-locale-refresh.yml
vendored
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 () => {});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
232
scripts/check-policy-config-coverage.ts
Normal file
232
scripts/check-policy-config-coverage.ts
Normal 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));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
761
scripts/lib/policy-config-coverage.jsonc
Normal file
761
scripts/lib/policy-config-coverage.jsonc
Normal 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.",
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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"],
|
||||
|
||||
46
src/agents/embedded-agent-runner/delivery-evidence.test.ts
Normal file
46
src/agents/embedded-agent-runner/delivery-evidence.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
87
src/agents/streaming-byte-guard.ts
Normal file
87
src/agents/streaming-byte-guard.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
81
src/agents/tool-display-common.test.ts
Normal file
81
src/agents/tool-display-common.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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" });
|
||||
|
||||
|
||||
@@ -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): {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [] });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -18,8 +18,10 @@ export {
|
||||
isAcpSessionKey,
|
||||
isSubagentSessionKey,
|
||||
parseAgentSessionKey,
|
||||
parseSessionDeliveryRoute,
|
||||
parseThreadSessionSuffix,
|
||||
type ParsedAgentSessionKey,
|
||||
type ParsedSessionDeliveryRoute,
|
||||
} from "../sessions/session-key-utils.js";
|
||||
export {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) ?? "";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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
52
src/shared/utf16-slice.ts
Normal 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);
|
||||
}
|
||||
50
src/utils.ts
50
src/utils.ts
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user