refactor: remove duplicate provider and runtime code (#115529)

* refactor: remove duplicated runtime and provider code

* refactor: exclude unrelated browser test cleanup

* refactor: preserve canonical subagent cleanup ordering
This commit is contained in:
Peter Steinberger
2026-07-29 00:04:52 -04:00
committed by GitHub
parent 8ffc567075
commit 430c293ee0
27 changed files with 1178 additions and 2652 deletions

View File

@@ -9,9 +9,9 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
applyAccountNameToChannelSection,
migrateBaseNameToDefaultAccount,
moveSingleAccountChannelSectionToDefaultAccount,
patchScopedAccountConfig,
prepareScopedSetupConfig,
} from "openclaw/plugin-sdk/setup";
import { createSetupInputPresenceValidator } from "openclaw/plugin-sdk/setup-runtime";
import { resolveClickClackAccountConfig } from "./accounts.js";
@@ -171,21 +171,14 @@ export function applyClickClackSetupConfigPatch(params: {
channelKey: channel,
setupSurface: clickClackSetupAdapter,
});
const namedConfig = applyAccountNameToChannelSection({
cfg: scopedConfig,
channelKey: channel,
accountId,
name: params.name,
});
const next =
accountId !== DEFAULT_ACCOUNT_ID
? migrateBaseNameToDefaultAccount({
cfg: namedConfig,
channelKey: channel,
})
: namedConfig;
return patchScopedAccountConfig({
cfg: next,
cfg: prepareScopedSetupConfig({
cfg: scopedConfig,
channelKey: channel,
accountId,
name: params.name,
migrateBaseName: accountId !== DEFAULT_ACCOUNT_ID,
}),
channelKey: channel,
accountId,
patch: params.patch,

View File

@@ -1,16 +1,13 @@
import { readManifestProviderDefaultModelRef } from "openclaw/plugin-sdk/provider-catalog-shared";
import {
createModelCatalogPresetAppliers,
type OpenClawConfig,
} from "openclaw/plugin-sdk/provider-onboard";
import { createModelCatalogPresetAppliers } from "openclaw/plugin-sdk/provider-onboard";
import { buildCohereCatalogModels, COHERE_BASE_URL } from "./models.js";
import manifest from "./openclaw.plugin.json" with { type: "json" };
const COHERE_DEFAULT_MODEL_REF = readManifestProviderDefaultModelRef(manifest, "cohere")!;
const coherePresetAppliers = createModelCatalogPresetAppliers({
export const { applyConfig: applyCohereConfig } = createModelCatalogPresetAppliers<[]>({
primaryModelRef: COHERE_DEFAULT_MODEL_REF,
resolveParams: (_cfg: OpenClawConfig) => ({
resolveParams: () => ({
providerId: "cohere",
api: "openai-completions",
baseUrl: COHERE_BASE_URL,
@@ -18,7 +15,3 @@ const coherePresetAppliers = createModelCatalogPresetAppliers({
aliases: [{ modelRef: COHERE_DEFAULT_MODEL_REF, alias: "Cohere Command A+" }],
}),
});
export function applyCohereConfig(cfg: OpenClawConfig): OpenClawConfig {
return coherePresetAppliers.applyConfig(cfg);
}

View File

@@ -9,6 +9,15 @@ const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({
import { buildFalImageGenerationProvider } from "./image-generation-provider.js";
import { setFalFetchGuardForTesting } from "./test-support.js";
function mockFalImageProviderRuntime() {
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "fal-test-key",
source: "env",
mode: "api-key",
});
setFalFetchGuardForTesting(fetchWithSsrFGuardMock);
}
function expectFalJsonPost(params: { call: number; url: string; body: Record<string, unknown> }) {
const request = fetchWithSsrFGuardMock.mock.calls[params.call - 1]?.[0];
if (!request) {
@@ -74,12 +83,7 @@ describe("fal image-generation provider", () => {
});
it("generates image buffers from the fal sync API", async () => {
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "fal-test-key",
source: "env",
mode: "api-key",
});
setFalFetchGuardForTesting(fetchWithSsrFGuardMock);
mockFalImageProviderRuntime();
const releaseRequest = vi.fn(async () => {});
const releaseDownload = vi.fn(async () => {});
fetchWithSsrFGuardMock
@@ -216,12 +220,7 @@ describe("fal image-generation provider", () => {
});
it("releases a timed-out generated image download", async () => {
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "fal-test-key",
source: "env",
mode: "api-key",
});
setFalFetchGuardForTesting(fetchWithSsrFGuardMock);
mockFalImageProviderRuntime();
const releaseDownload = vi.fn(async () => {});
fetchWithSsrFGuardMock
.mockResolvedValueOnce({
@@ -257,12 +256,7 @@ describe("fal image-generation provider", () => {
});
it("rejects generated image downloads that exceed the configured media cap", async () => {
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "fal-test-key",
source: "env",
mode: "api-key",
});
setFalFetchGuardForTesting(fetchWithSsrFGuardMock);
mockFalImageProviderRuntime();
fetchWithSsrFGuardMock
.mockResolvedValueOnce({
response: new Response(
@@ -296,12 +290,7 @@ describe("fal image-generation provider", () => {
});
it("wraps wrong-shape successful fal image responses", async () => {
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "fal-test-key",
source: "env",
mode: "api-key",
});
setFalFetchGuardForTesting(fetchWithSsrFGuardMock);
mockFalImageProviderRuntime();
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: new Response(
JSON.stringify({ images: { url: "https://example.test/image.png" } }),
@@ -325,12 +314,7 @@ describe("fal image-generation provider", () => {
});
it("uses image-to-image endpoint and data-uri input for edits", async () => {
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "fal-test-key",
source: "env",
mode: "api-key",
});
setFalFetchGuardForTesting(fetchWithSsrFGuardMock);
mockFalImageProviderRuntime();
fetchWithSsrFGuardMock
.mockResolvedValueOnce({
response: new Response(
@@ -382,12 +366,7 @@ describe("fal image-generation provider", () => {
});
it("routes GPT Image 2 edits through /edit with image_urls", async () => {
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "fal-test-key",
source: "env",
mode: "api-key",
});
setFalFetchGuardForTesting(fetchWithSsrFGuardMock);
mockFalImageProviderRuntime();
fetchWithSsrFGuardMock
.mockResolvedValueOnce({
response: new Response(
@@ -439,12 +418,7 @@ describe("fal image-generation provider", () => {
});
it("allows GPT Image 2 edits up to 10 reference images", async () => {
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "fal-test-key",
source: "env",
mode: "api-key",
});
setFalFetchGuardForTesting(fetchWithSsrFGuardMock);
mockFalImageProviderRuntime();
fetchWithSsrFGuardMock
.mockResolvedValueOnce({
response: new Response(
@@ -495,12 +469,7 @@ describe("fal image-generation provider", () => {
});
it("rejects GPT Image 2 edits above 10 reference images", async () => {
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "fal-test-key",
source: "env",
mode: "api-key",
});
setFalFetchGuardForTesting(fetchWithSsrFGuardMock);
mockFalImageProviderRuntime();
const provider = buildFalImageGenerationProvider();
await expect(
@@ -519,12 +488,7 @@ describe("fal image-generation provider", () => {
});
it("routes Nano Banana 2 text generation with native resolution", async () => {
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "fal-test-key",
source: "env",
mode: "api-key",
});
setFalFetchGuardForTesting(fetchWithSsrFGuardMock);
mockFalImageProviderRuntime();
fetchWithSsrFGuardMock
.mockResolvedValueOnce({
response: new Response(
@@ -570,12 +534,7 @@ describe("fal image-generation provider", () => {
});
it("does not synthesize Nano Banana 2 aspect ratio from resolution alone", async () => {
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "fal-test-key",
source: "env",
mode: "api-key",
});
setFalFetchGuardForTesting(fetchWithSsrFGuardMock);
mockFalImageProviderRuntime();
fetchWithSsrFGuardMock
.mockResolvedValueOnce({
response: new Response(
@@ -622,12 +581,7 @@ describe("fal image-generation provider", () => {
{ model: "fal-ai/nano-banana", resolution: undefined },
{ model: "fal-ai/nano-banana-2", resolution: "2K" as const },
])("routes $model edits through /edit with model geometry", async ({ model, resolution }) => {
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "fal-test-key",
source: "env",
mode: "api-key",
});
setFalFetchGuardForTesting(fetchWithSsrFGuardMock);
mockFalImageProviderRuntime();
fetchWithSsrFGuardMock
.mockResolvedValueOnce({
response: new Response(
@@ -692,12 +646,7 @@ describe("fal image-generation provider", () => {
error: "fal Nano Banana 2 supports at most 14 reference images",
},
])("rejects $model edits above its reference limit", async ({ model, inputCount, error }) => {
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "fal-test-key",
source: "env",
mode: "api-key",
});
setFalFetchGuardForTesting(fetchWithSsrFGuardMock);
mockFalImageProviderRuntime();
const provider = buildFalImageGenerationProvider();
await expect(
@@ -716,12 +665,7 @@ describe("fal image-generation provider", () => {
});
it("rejects Krea-only aspect ratios for Nano Banana 2", async () => {
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "fal-test-key",
source: "env",
mode: "api-key",
});
setFalFetchGuardForTesting(fetchWithSsrFGuardMock);
mockFalImageProviderRuntime();
const provider = buildFalImageGenerationProvider();
await expect(
@@ -737,12 +681,7 @@ describe("fal image-generation provider", () => {
});
it("routes Nano Banana 2 Lite edits through /edit with image_urls", async () => {
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "fal-test-key",
source: "env",
mode: "api-key",
});
setFalFetchGuardForTesting(fetchWithSsrFGuardMock);
mockFalImageProviderRuntime();
fetchWithSsrFGuardMock
.mockResolvedValueOnce({
response: new Response(
@@ -794,12 +733,7 @@ describe("fal image-generation provider", () => {
});
it("rejects Krea-only aspect ratios for Nano Banana 2 Lite", async () => {
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "fal-test-key",
source: "env",
mode: "api-key",
});
setFalFetchGuardForTesting(fetchWithSsrFGuardMock);
mockFalImageProviderRuntime();
const provider = buildFalImageGenerationProvider();
await expect(
@@ -817,12 +751,7 @@ describe("fal image-generation provider", () => {
it.each(["1K", "2K", "4K"] as const)(
"rejects %s resolution overrides for Nano Banana 2 Lite",
async (resolution) => {
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "fal-test-key",
source: "env",
mode: "api-key",
});
setFalFetchGuardForTesting(fetchWithSsrFGuardMock);
mockFalImageProviderRuntime();
const provider = buildFalImageGenerationProvider();
await expect(
@@ -841,12 +770,7 @@ describe("fal image-generation provider", () => {
);
it("rejects Nano Banana 2 Lite edits above 14 reference images", async () => {
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "fal-test-key",
source: "env",
mode: "api-key",
});
setFalFetchGuardForTesting(fetchWithSsrFGuardMock);
mockFalImageProviderRuntime();
const provider = buildFalImageGenerationProvider();
await expect(
@@ -891,12 +815,7 @@ describe("fal image-generation provider", () => {
},
},
])("keeps $label text-to-image on its base endpoint", async (testCase) => {
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "fal-test-key",
source: "env",
mode: "api-key",
});
setFalFetchGuardForTesting(fetchWithSsrFGuardMock);
mockFalImageProviderRuntime();
fetchWithSsrFGuardMock
.mockResolvedValueOnce({
response: new Response(
@@ -936,12 +855,7 @@ describe("fal image-generation provider", () => {
});
it("routes Grok Imagine edits through /edit with lowercase resolution", async () => {
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "fal-test-key",
source: "env",
mode: "api-key",
});
setFalFetchGuardForTesting(fetchWithSsrFGuardMock);
mockFalImageProviderRuntime();
fetchWithSsrFGuardMock
.mockResolvedValueOnce({
response: new Response(
@@ -989,12 +903,7 @@ describe("fal image-generation provider", () => {
});
it("rejects 4K resolution for Grok Imagine edits", async () => {
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "fal-test-key",
source: "env",
mode: "api-key",
});
setFalFetchGuardForTesting(fetchWithSsrFGuardMock);
mockFalImageProviderRuntime();
const provider = buildFalImageGenerationProvider();
await expect(
@@ -1012,12 +921,7 @@ describe("fal image-generation provider", () => {
});
it("rejects Nano Banana ratios for Grok Imagine", async () => {
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "fal-test-key",
source: "env",
mode: "api-key",
});
setFalFetchGuardForTesting(fetchWithSsrFGuardMock);
mockFalImageProviderRuntime();
const provider = buildFalImageGenerationProvider();
await expect(
@@ -1033,12 +937,7 @@ describe("fal image-generation provider", () => {
});
it("rejects Grok Imagine edits above 3 reference images", async () => {
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "fal-test-key",
source: "env",
mode: "api-key",
});
setFalFetchGuardForTesting(fetchWithSsrFGuardMock);
mockFalImageProviderRuntime();
const provider = buildFalImageGenerationProvider();
await expect(
@@ -1057,12 +956,7 @@ describe("fal image-generation provider", () => {
});
it("preserves an explicit Grok Imagine /quality/edit model path", async () => {
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "fal-test-key",
source: "env",
mode: "api-key",
});
setFalFetchGuardForTesting(fetchWithSsrFGuardMock);
mockFalImageProviderRuntime();
fetchWithSsrFGuardMock
.mockResolvedValueOnce({
response: new Response(
@@ -1106,12 +1000,7 @@ describe("fal image-generation provider", () => {
});
it("preserves exact custom Fal edit endpoints", async () => {
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "fal-test-key",
source: "env",
mode: "api-key",
});
setFalFetchGuardForTesting(fetchWithSsrFGuardMock);
mockFalImageProviderRuntime();
fetchWithSsrFGuardMock
.mockResolvedValueOnce({
response: new Response(
@@ -1155,12 +1044,7 @@ describe("fal image-generation provider", () => {
});
it("maps aspect ratio for text generation without forcing a square default", async () => {
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "fal-test-key",
source: "env",
mode: "api-key",
});
setFalFetchGuardForTesting(fetchWithSsrFGuardMock);
mockFalImageProviderRuntime();
fetchWithSsrFGuardMock
.mockResolvedValueOnce({
response: new Response(
@@ -1204,12 +1088,7 @@ describe("fal image-generation provider", () => {
});
it("combines resolution and aspect ratio for text generation", async () => {
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "fal-test-key",
source: "env",
mode: "api-key",
});
setFalFetchGuardForTesting(fetchWithSsrFGuardMock);
mockFalImageProviderRuntime();
fetchWithSsrFGuardMock
.mockResolvedValueOnce({
response: new Response(
@@ -1254,12 +1133,7 @@ describe("fal image-generation provider", () => {
});
it("uses Krea 2 native aspect-ratio and creativity payload schema", async () => {
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "fal-test-key",
source: "env",
mode: "api-key",
});
setFalFetchGuardForTesting(fetchWithSsrFGuardMock);
mockFalImageProviderRuntime();
fetchWithSsrFGuardMock
.mockResolvedValueOnce({
response: new Response(
@@ -1308,12 +1182,7 @@ describe("fal image-generation provider", () => {
});
it("passes reference images to Krea 2 as style references without edit suffix", async () => {
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "fal-test-key",
source: "env",
mode: "api-key",
});
setFalFetchGuardForTesting(fetchWithSsrFGuardMock);
mockFalImageProviderRuntime();
fetchWithSsrFGuardMock
.mockResolvedValueOnce({
response: new Response(
@@ -1364,12 +1233,7 @@ describe("fal image-generation provider", () => {
});
it("maps Krea 2 size hints to the closest native aspect ratio", async () => {
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "fal-test-key",
source: "env",
mode: "api-key",
});
setFalFetchGuardForTesting(fetchWithSsrFGuardMock);
mockFalImageProviderRuntime();
fetchWithSsrFGuardMock
.mockResolvedValueOnce({
response: new Response(
@@ -1521,12 +1385,7 @@ describe("fal image-generation provider", () => {
});
it("blocks private-network image download URLs through the SSRF guard", async () => {
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "fal-test-key",
source: "env",
mode: "api-key",
});
setFalFetchGuardForTesting(fetchWithSsrFGuardMock);
mockFalImageProviderRuntime();
const blocked = new Error("Blocked: resolves to private/internal/special-use IP address");
fetchWithSsrFGuardMock
.mockResolvedValueOnce({
@@ -1560,12 +1419,7 @@ describe("fal image-generation provider", () => {
});
it("does not auto-whitelist trusted private relay hosts from a configured baseUrl", async () => {
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "fal-test-key",
source: "env",
mode: "api-key",
});
setFalFetchGuardForTesting(fetchWithSsrFGuardMock);
mockFalImageProviderRuntime();
fetchWithSsrFGuardMock
.mockResolvedValueOnce({
response: new Response(

View File

@@ -1,8 +1,5 @@
// Featherless onboarding applies the curated model catalog and default.
import {
createModelCatalogPresetAppliers,
type OpenClawConfig,
} from "openclaw/plugin-sdk/provider-onboard";
import { createModelCatalogPresetAppliers } from "openclaw/plugin-sdk/provider-onboard";
import {
buildFeatherlessCatalogModels,
FEATHERLESS_BASE_URL,
@@ -11,9 +8,9 @@ import {
export { FEATHERLESS_DEFAULT_MODEL_REF } from "./models.js";
const featherlessPresetAppliers = createModelCatalogPresetAppliers({
export const { applyConfig: applyFeatherlessConfig } = createModelCatalogPresetAppliers<[]>({
primaryModelRef: FEATHERLESS_DEFAULT_MODEL_REF,
resolveParams: (_cfg: OpenClawConfig) => ({
resolveParams: () => ({
providerId: "featherless",
api: "openai-completions",
baseUrl: FEATHERLESS_BASE_URL,
@@ -21,7 +18,3 @@ const featherlessPresetAppliers = createModelCatalogPresetAppliers({
aliases: [{ modelRef: FEATHERLESS_DEFAULT_MODEL_REF, alias: "Qwen3 32B" }],
}),
});
export function applyFeatherlessConfig(cfg: OpenClawConfig): OpenClawConfig {
return featherlessPresetAppliers.applyConfig(cfg);
}

View File

@@ -9,11 +9,6 @@ import {
type ChannelIngressIdentitySubjectInput,
type ResolveChannelMessageIngressParams,
} from "openclaw/plugin-sdk/channel-ingress-runtime";
import {
resolveScopeKeyCaseInsensitive,
resolveScopeToolsPolicy,
type ScopeTree,
} from "openclaw/plugin-sdk/channel-policy";
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { ChannelGroupContext } from "../runtime-api.js";
@@ -231,66 +226,47 @@ export async function resolveFeishuGroupSenderActivationIngressAccess(params: {
});
}
export function resolveFeishuGroupConfig(params: { cfg?: FeishuConfig; groupId?: string | null }) {
function resolveFeishuExplicitGroupConfigKey(params: {
cfg?: FeishuConfig;
groupId?: string | null;
}): string | undefined {
const groups = params.cfg?.groups ?? {};
const wildcard = groups["*"];
const groupId = params.groupId?.trim();
if (!groupId) {
if (!groupId || groupId === "*") {
return undefined;
}
const direct = groups[groupId];
if (direct) {
return direct;
if (Object.hasOwn(groups, groupId)) {
return groupId;
}
const lowered = normalizeOptionalLowercaseString(groupId) ?? "";
const matchKey = Object.keys(groups).find(
(key) => normalizeOptionalLowercaseString(key) === lowered,
return Object.keys(groups).find(
(key) => key !== "*" && normalizeOptionalLowercaseString(key) === lowered,
);
if (matchKey) {
return groups[matchKey];
}
export function resolveFeishuGroupConfig(params: { cfg?: FeishuConfig; groupId?: string | null }) {
if (!params.groupId?.trim()) {
return undefined;
}
return wildcard;
const groups = params.cfg?.groups ?? {};
const key = resolveFeishuExplicitGroupConfigKey(params);
return key ? groups[key] : groups["*"];
}
export function hasExplicitFeishuGroupConfig(params: {
cfg?: FeishuConfig;
groupId?: string | null;
}): boolean {
const groups = params.cfg?.groups ?? {};
const groupId = params.groupId?.trim();
if (!groupId) {
return false;
}
if (Object.hasOwn(groups, groupId) && groupId !== "*") {
return true;
}
const lowered = normalizeOptionalLowercaseString(groupId) ?? "";
return Object.keys(groups).some(
(key) => key !== "*" && normalizeOptionalLowercaseString(key) === lowered,
);
return resolveFeishuExplicitGroupConfigKey(params) !== undefined;
}
export function resolveFeishuGroupToolPolicy(params: ChannelGroupContext) {
// This adapter intentionally reads root channels.feishu without account merge;
// reply mention policy merges accounts, and changing that asymmetry is product behavior.
const cfg: FeishuConfig | undefined = params.cfg.channels?.feishu;
if (!cfg) {
return undefined;
}
const groups: NonNullable<FeishuConfig["groups"]> = cfg.groups ?? {};
// Whole-entry selection: a matched group hides every wildcard field.
const tree: ScopeTree = {
scopes: Object.fromEntries(
Object.entries(groups).map(([key, entry]) => [key, { tools: entry?.tools }]),
),
};
const groupId = params.groupId?.trim();
const matchedKey = resolveScopeKeyCaseInsensitive(tree, groupId);
const scopeKey = groupId && !matchedKey && Object.hasOwn(tree.scopes, "*") ? "*" : matchedKey;
return resolveScopeToolsPolicy({ tree, path: scopeKey ? [scopeKey] : [] });
return resolveFeishuGroupConfig({
cfg: params.cfg.channels?.feishu,
groupId: params.groupId,
})?.tools;
}
export function resolveFeishuReplyPolicy(params: {

View File

@@ -1,7 +1,4 @@
import {
createDefaultModelsPresetAppliers,
type OpenClawConfig,
} from "openclaw/plugin-sdk/provider-onboard";
import { createDefaultModelsPresetAppliers } from "openclaw/plugin-sdk/provider-onboard";
import {
buildFireworksCatalogModels,
buildFireworksProvider,
@@ -9,9 +6,9 @@ import {
FIREWORKS_DEFAULT_MODEL_REF,
} from "./provider-catalog.js";
const fireworksPresetAppliers = createDefaultModelsPresetAppliers({
export const { applyConfig: applyFireworksConfig } = createDefaultModelsPresetAppliers<[]>({
primaryModelRef: FIREWORKS_DEFAULT_MODEL_REF,
resolveParams: (_cfg: OpenClawConfig) => {
resolveParams: () => {
const defaultProvider = buildFireworksProvider();
return {
providerId: "fireworks",
@@ -23,7 +20,3 @@ const fireworksPresetAppliers = createDefaultModelsPresetAppliers({
};
},
});
export function applyFireworksConfig(cfg: OpenClawConfig): OpenClawConfig {
return fireworksPresetAppliers.applyConfig(cfg);
}

View File

@@ -1,8 +1,5 @@
// Huggingface setup module handles plugin onboarding behavior.
import {
createModelCatalogPresetAppliers,
type OpenClawConfig,
} from "openclaw/plugin-sdk/provider-onboard";
import { createModelCatalogPresetAppliers } from "openclaw/plugin-sdk/provider-onboard";
import {
buildHuggingfaceModelDefinition,
HUGGINGFACE_BASE_URL,
@@ -11,9 +8,9 @@ import {
export const HUGGINGFACE_DEFAULT_MODEL_REF = "huggingface/deepseek-ai/DeepSeek-R1";
const huggingfacePresetAppliers = createModelCatalogPresetAppliers({
export const { applyConfig: applyHuggingfaceConfig } = createModelCatalogPresetAppliers<[]>({
primaryModelRef: HUGGINGFACE_DEFAULT_MODEL_REF,
resolveParams: (_cfg: OpenClawConfig) => ({
resolveParams: () => ({
providerId: "huggingface",
api: "openai-completions",
baseUrl: HUGGINGFACE_BASE_URL,
@@ -21,7 +18,3 @@ const huggingfacePresetAppliers = createModelCatalogPresetAppliers({
aliases: [{ modelRef: HUGGINGFACE_DEFAULT_MODEL_REF, alias: "Hugging Face" }],
}),
});
export function applyHuggingfaceConfig(cfg: OpenClawConfig): OpenClawConfig {
return huggingfacePresetAppliers.applyConfig(cfg);
}

View File

@@ -1,16 +1,13 @@
// Kilocode setup module handles plugin onboarding behavior.
import {
createModelCatalogPresetAppliers,
type OpenClawConfig,
} from "openclaw/plugin-sdk/provider-onboard";
import { createModelCatalogPresetAppliers } from "openclaw/plugin-sdk/provider-onboard";
import { buildKilocodeProvider } from "./provider-catalog.js";
import { KILOCODE_BASE_URL, KILOCODE_DEFAULT_MODEL_REF } from "./provider-models.js";
export { KILOCODE_DEFAULT_MODEL_REF };
const kilocodePresetAppliers = createModelCatalogPresetAppliers({
export const { applyConfig: applyKilocodeConfig } = createModelCatalogPresetAppliers<[]>({
primaryModelRef: KILOCODE_DEFAULT_MODEL_REF,
resolveParams: (_cfg: OpenClawConfig) => ({
resolveParams: () => ({
providerId: "kilocode",
api: "openai-completions",
baseUrl: KILOCODE_BASE_URL,
@@ -18,7 +15,3 @@ const kilocodePresetAppliers = createModelCatalogPresetAppliers({
aliases: [{ modelRef: KILOCODE_DEFAULT_MODEL_REF, alias: "Kilo Gateway" }],
}),
});
export function applyKilocodeConfig(cfg: OpenClawConfig): OpenClawConfig {
return kilocodePresetAppliers.applyConfig(cfg);
}

View File

@@ -78,46 +78,28 @@ export const lineSetupAdapter: ChannelSetupAdapter = {
// Shipped alias: `--token` writes channelAccessToken; the explicit switch wins.
const accessToken = typedInput.channelAccessToken ?? typedInput.token;
const normalizedAccountId = normalizeAccountId(accountId);
if (normalizedAccountId === DEFAULT_ACCOUNT_ID) {
return patchLineAccountConfig({
cfg,
accountId: normalizedAccountId,
enabled: true,
clearFields: typedInput.useEnv
? ["channelAccessToken", "channelSecret", "tokenFile", "secretFile"]
: undefined,
patch: typedInput.useEnv
? {}
: {
...(typedInput.tokenFile
? { tokenFile: typedInput.tokenFile }
: accessToken
? { channelAccessToken: accessToken }
: {}),
...(typedInput.secretFile
? { secretFile: typedInput.secretFile }
: typedInput.channelSecret
? { channelSecret: typedInput.channelSecret }
: {}),
},
});
}
const useEnv = normalizedAccountId === DEFAULT_ACCOUNT_ID && Boolean(typedInput.useEnv);
return patchLineAccountConfig({
cfg,
accountId: normalizedAccountId,
enabled: true,
patch: {
...(typedInput.tokenFile
? { tokenFile: typedInput.tokenFile }
: accessToken
? { channelAccessToken: accessToken }
: {}),
...(typedInput.secretFile
? { secretFile: typedInput.secretFile }
: typedInput.channelSecret
? { channelSecret: typedInput.channelSecret }
: {}),
},
clearFields: useEnv
? ["channelAccessToken", "channelSecret", "tokenFile", "secretFile"]
: undefined,
patch: useEnv
? {}
: {
...(typedInput.tokenFile
? { tokenFile: typedInput.tokenFile }
: accessToken
? { channelAccessToken: accessToken }
: {}),
...(typedInput.secretFile
? { secretFile: typedInput.secretFile }
: typedInput.channelSecret
? { channelSecret: typedInput.channelSecret }
: {}),
},
});
},
};

View File

@@ -29,28 +29,21 @@ export function buildLitellmModelDefinition(): ModelDefinitionConfig {
};
}
const litellmPresetAppliers = createDefaultModelPresetAppliers({
primaryModelRef: LITELLM_DEFAULT_MODEL_REF,
resolveParams: (cfg: OpenClawConfig) => {
const existingProvider = cfg.models?.providers?.litellm as { baseUrl?: unknown } | undefined;
const resolvedBaseUrl =
typeof existingProvider?.baseUrl === "string" ? existingProvider.baseUrl.trim() : "";
export const { applyConfig: applyLitellmConfig, applyProviderConfig: applyLitellmProviderConfig } =
createDefaultModelPresetAppliers<[]>({
primaryModelRef: LITELLM_DEFAULT_MODEL_REF,
resolveParams: (cfg: OpenClawConfig) => {
const existingProvider = cfg.models?.providers?.litellm as { baseUrl?: unknown } | undefined;
const resolvedBaseUrl =
typeof existingProvider?.baseUrl === "string" ? existingProvider.baseUrl.trim() : "";
return {
providerId: "litellm",
api: "openai-completions" as const,
baseUrl: resolvedBaseUrl || LITELLM_BASE_URL,
defaultModel: buildLitellmModelDefinition(),
defaultModelId: LITELLM_DEFAULT_MODEL_ID,
aliases: [{ modelRef: LITELLM_DEFAULT_MODEL_REF, alias: "LiteLLM" }],
};
},
});
export function applyLitellmProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
return litellmPresetAppliers.applyProviderConfig(cfg);
}
export function applyLitellmConfig(cfg: OpenClawConfig): OpenClawConfig {
return litellmPresetAppliers.applyConfig(cfg);
}
return {
providerId: "litellm",
api: "openai-completions" as const,
baseUrl: resolvedBaseUrl || LITELLM_BASE_URL,
defaultModel: buildLitellmModelDefinition(),
defaultModelId: LITELLM_DEFAULT_MODEL_ID,
aliases: [{ modelRef: LITELLM_DEFAULT_MODEL_REF, alias: "LiteLLM" }],
};
},
});

View File

@@ -2,19 +2,17 @@
* Meta onboarding config helpers.
*/
import { readManifestProviderDefaultModelRef } from "openclaw/plugin-sdk/provider-catalog-shared";
import {
createModelCatalogPresetAppliers,
type OpenClawConfig,
} from "openclaw/plugin-sdk/provider-onboard";
import { createModelCatalogPresetAppliers } from "openclaw/plugin-sdk/provider-onboard";
import { buildMetaCatalogModels, META_BASE_URL } from "./models.js";
import manifest from "./openclaw.plugin.json" with { type: "json" };
/** Default Meta model reference used after onboarding. */
export const META_DEFAULT_MODEL_REF = readManifestProviderDefaultModelRef(manifest, "meta")!;
const metaPresetAppliers = createModelCatalogPresetAppliers({
/** Applies Meta provider/catalog config and default model aliases. */
export const { applyConfig: applyMetaConfig } = createModelCatalogPresetAppliers<[]>({
primaryModelRef: META_DEFAULT_MODEL_REF,
resolveParams: (_cfg: OpenClawConfig) => ({
resolveParams: () => ({
providerId: "meta",
api: "openai-responses",
baseUrl: META_BASE_URL,
@@ -22,8 +20,3 @@ const metaPresetAppliers = createModelCatalogPresetAppliers({
aliases: [{ modelRef: META_DEFAULT_MODEL_REF, alias: "Muse Spark 1.1" }],
}),
});
/** Applies Meta provider/catalog config and default model aliases. */
export function applyMetaConfig(cfg: OpenClawConfig): OpenClawConfig {
return metaPresetAppliers.applyConfig(cfg);
}

View File

@@ -38,7 +38,7 @@ function resolveQianfanPreset(cfg: OpenClawConfig): {
};
}
const qianfanPresetAppliers = createDefaultModelsPresetAppliers({
export const { applyConfig: applyQianfanConfig } = createDefaultModelsPresetAppliers<[]>({
primaryModelRef: QIANFAN_DEFAULT_MODEL_REF,
resolveParams: (cfg: OpenClawConfig) => {
const preset = resolveQianfanPreset(cfg);
@@ -52,7 +52,3 @@ const qianfanPresetAppliers = createDefaultModelsPresetAppliers({
};
},
});
export function applyQianfanConfig(cfg: OpenClawConfig): OpenClawConfig {
return qianfanPresetAppliers.applyConfig(cfg);
}

View File

@@ -1,8 +1,5 @@
// Synthetic setup module handles plugin onboarding behavior.
import {
createModelCatalogPresetAppliers,
type OpenClawConfig,
} from "openclaw/plugin-sdk/provider-onboard";
import { createModelCatalogPresetAppliers } from "openclaw/plugin-sdk/provider-onboard";
import {
buildSyntheticModelDefinition,
SYNTHETIC_BASE_URL,
@@ -12,9 +9,12 @@ import {
export { SYNTHETIC_DEFAULT_MODEL_REF };
const syntheticPresetAppliers = createModelCatalogPresetAppliers({
export const {
applyConfig: applySyntheticConfig,
applyProviderConfig: applySyntheticProviderConfig,
} = createModelCatalogPresetAppliers<[]>({
primaryModelRef: SYNTHETIC_DEFAULT_MODEL_REF,
resolveParams: (_cfg: OpenClawConfig) => ({
resolveParams: () => ({
providerId: "synthetic",
api: "anthropic-messages",
baseUrl: SYNTHETIC_BASE_URL,
@@ -22,11 +22,3 @@ const syntheticPresetAppliers = createModelCatalogPresetAppliers({
aliases: [{ modelRef: SYNTHETIC_DEFAULT_MODEL_REF, alias: "MiniMax M3" }],
}),
});
export function applySyntheticProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
return syntheticPresetAppliers.applyProviderConfig(cfg);
}
export function applySyntheticConfig(cfg: OpenClawConfig): OpenClawConfig {
return syntheticPresetAppliers.applyConfig(cfg);
}

View File

@@ -124,6 +124,17 @@ const FIRE_EMOJI = "\u{1F525}";
const PARTY_EMOJI = "\u{1F389}";
const EYES_EMOJI = "\u{1F440}";
const HEART_EMOJI = "\u{2764}\u{FE0F}";
type TelegramReactionPolicyCase = {
name: string;
updateId: number;
channelConfig: NonNullable<NonNullable<OpenClawConfig["channels"]>["telegram"]>;
reaction?: Record<string, unknown>;
sentByBot?: boolean;
expectedEnqueueCalls: number;
expectedEvent?: string;
};
async function withTelegramSpooledReplayUpdate<T>(
update: object,
fn: () => Promise<T>,
@@ -6214,7 +6225,7 @@ describe("createTelegramBot", () => {
expect(String(systemEventOptions().contextKey)).toContain("telegram:reaction:add:1234:42:9");
});
it.each([
const telegramReactionPolicyCases: TelegramReactionPolicyCase[] = [
{
name: "blocks reaction when dmPolicy is disabled",
updateId: 510,
@@ -6271,243 +6282,123 @@ describe("createTelegramBot", () => {
},
expectedEnqueueCalls: 0,
},
])("$name", async ({ updateId, channelConfig, reaction, expectedEnqueueCalls }) => {
onSpy.mockClear();
enqueueSystemEventSpy.mockClear();
loadConfig.mockReturnValue({
channels: {
telegram: channelConfig,
},
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message_reaction") as (
ctx: Record<string, unknown>,
) => Promise<void>;
await handler(createTelegramReactionContext({ updateId, reaction }));
expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(expectedEnqueueCalls);
});
it("skips reaction when reactionNotifications is off", async () => {
onSpy.mockClear();
enqueueSystemEventSpy.mockClear();
wasSentByBot.mockReturnValue(true);
loadConfig.mockReturnValue({
channels: {
telegram: { dmPolicy: "open", reactionNotifications: "off" },
},
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message_reaction") as (
ctx: Record<string, unknown>,
) => Promise<void>;
await handler({
update: { update_id: 501 },
messageReaction: {
chat: { id: 1234, type: "private" },
message_id: 42,
user: { id: 9, first_name: "Ada" },
date: 1736380800,
old_reaction: [],
new_reaction: [{ type: "emoji", emoji: THUMBS_UP_EMOJI }],
},
});
expect(enqueueSystemEventSpy).not.toHaveBeenCalled();
});
it("defaults reactionNotifications to own", async () => {
onSpy.mockClear();
enqueueSystemEventSpy.mockClear();
wasSentByBot.mockReturnValue(true);
loadConfig.mockReturnValue({
channels: {
telegram: { dmPolicy: "open", allowFrom: ["*"] },
},
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message_reaction") as (
ctx: Record<string, unknown>,
) => Promise<void>;
await handler({
update: { update_id: 502 },
messageReaction: {
chat: { id: 1234, type: "private" },
message_id: 43,
user: { id: 9, first_name: "Ada" },
date: 1736380800,
old_reaction: [],
new_reaction: [{ type: "emoji", emoji: THUMBS_UP_EMOJI }],
},
});
expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(1);
});
it("allows reaction in all mode regardless of message sender", async () => {
onSpy.mockClear();
enqueueSystemEventSpy.mockClear();
wasSentByBot.mockReturnValue(false);
loadConfig.mockReturnValue({
channels: {
telegram: { dmPolicy: "open", allowFrom: ["*"], reactionNotifications: "all" },
},
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message_reaction") as (
ctx: Record<string, unknown>,
) => Promise<void>;
await handler({
update: { update_id: 503 },
messageReaction: {
chat: { id: 1234, type: "private" },
{
name: "skips reaction when reactionNotifications is off",
updateId: 501,
channelConfig: { dmPolicy: "open", reactionNotifications: "off" },
sentByBot: true,
expectedEnqueueCalls: 0,
},
{
name: "defaults reactionNotifications to own",
updateId: 502,
channelConfig: { dmPolicy: "open", allowFrom: ["*"] },
reaction: { message_id: 43 },
sentByBot: true,
expectedEnqueueCalls: 1,
},
{
name: "allows reaction in all mode regardless of message sender",
updateId: 503,
channelConfig: { dmPolicy: "open", allowFrom: ["*"], reactionNotifications: "all" },
reaction: {
message_id: 99,
user: { id: 9, first_name: "Ada" },
date: 1736380800,
old_reaction: [],
new_reaction: [{ type: "emoji", emoji: PARTY_EMOJI }],
},
});
expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(1);
expect(firstSystemEventArg(0)).toBe(`Telegram reaction added: ${PARTY_EMOJI} by Ada on msg 99`);
expect(firstSystemEventArg(1)).toBeTypeOf("object");
});
it("skips reaction in own mode when message is not sent by bot", async () => {
onSpy.mockClear();
enqueueSystemEventSpy.mockClear();
wasSentByBot.mockReturnValue(false);
loadConfig.mockReturnValue({
channels: {
telegram: { dmPolicy: "open", allowFrom: ["*"], reactionNotifications: "own" },
},
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message_reaction") as (
ctx: Record<string, unknown>,
) => Promise<void>;
await handler({
update: { update_id: 503 },
messageReaction: {
chat: { id: 1234, type: "private" },
sentByBot: false,
expectedEnqueueCalls: 1,
expectedEvent: `Telegram reaction added: ${PARTY_EMOJI} by Ada on msg 99`,
},
{
name: "skips reaction in own mode when message is not sent by bot",
updateId: 503,
channelConfig: { dmPolicy: "open", allowFrom: ["*"], reactionNotifications: "own" },
reaction: {
message_id: 99,
user: { id: 9, first_name: "Ada" },
date: 1736380800,
old_reaction: [],
new_reaction: [{ type: "emoji", emoji: PARTY_EMOJI }],
},
});
expect(enqueueSystemEventSpy).not.toHaveBeenCalled();
});
it("allows reaction in own mode when message is sent by bot", async () => {
onSpy.mockClear();
enqueueSystemEventSpy.mockClear();
wasSentByBot.mockReturnValue(true);
loadConfig.mockReturnValue({
channels: {
telegram: { dmPolicy: "open", allowFrom: ["*"], reactionNotifications: "own" },
},
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message_reaction") as (
ctx: Record<string, unknown>,
) => Promise<void>;
await handler({
update: { update_id: 503 },
messageReaction: {
chat: { id: 1234, type: "private" },
sentByBot: false,
expectedEnqueueCalls: 0,
},
{
name: "allows reaction in own mode when message is sent by bot",
updateId: 503,
channelConfig: { dmPolicy: "open", allowFrom: ["*"], reactionNotifications: "own" },
reaction: {
message_id: 99,
user: { id: 9, first_name: "Ada" },
date: 1736380800,
old_reaction: [],
new_reaction: [{ type: "emoji", emoji: PARTY_EMOJI }],
},
});
expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(1);
});
it("skips reaction from bot users", async () => {
onSpy.mockClear();
enqueueSystemEventSpy.mockClear();
wasSentByBot.mockReturnValue(true);
loadConfig.mockReturnValue({
channels: {
telegram: { dmPolicy: "open", allowFrom: ["*"], reactionNotifications: "all" },
},
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message_reaction") as (
ctx: Record<string, unknown>,
) => Promise<void>;
await handler({
update: { update_id: 503 },
messageReaction: {
chat: { id: 1234, type: "private" },
sentByBot: true,
expectedEnqueueCalls: 1,
},
{
name: "skips reaction from bot users",
updateId: 503,
channelConfig: { dmPolicy: "open", allowFrom: ["*"], reactionNotifications: "all" },
reaction: {
message_id: 99,
user: { id: 9, first_name: "Bot", is_bot: true },
date: 1736380800,
old_reaction: [],
new_reaction: [{ type: "emoji", emoji: PARTY_EMOJI }],
},
});
expect(enqueueSystemEventSpy).not.toHaveBeenCalled();
});
it("skips reaction removal (only processes added reactions)", async () => {
onSpy.mockClear();
enqueueSystemEventSpy.mockClear();
loadConfig.mockReturnValue({
channels: {
telegram: { dmPolicy: "open", allowFrom: ["*"], reactionNotifications: "all" },
},
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message_reaction") as (
ctx: Record<string, unknown>,
) => Promise<void>;
await handler({
update: { update_id: 504 },
messageReaction: {
chat: { id: 1234, type: "private" },
message_id: 42,
user: { id: 9, first_name: "Ada" },
date: 1736380800,
sentByBot: true,
expectedEnqueueCalls: 0,
},
{
name: "skips reaction removal (only processes added reactions)",
updateId: 504,
channelConfig: { dmPolicy: "open", allowFrom: ["*"], reactionNotifications: "all" },
reaction: {
old_reaction: [{ type: "emoji", emoji: THUMBS_UP_EMOJI }],
new_reaction: [],
},
});
expectedEnqueueCalls: 0,
},
{
name: "blocks reaction in own mode when cache is warm and message not sent by bot",
updateId: 601,
channelConfig: { dmPolicy: "open", reactionNotifications: "own" },
reaction: { message_id: 99 },
sentByBot: false,
expectedEnqueueCalls: 0,
},
];
expect(enqueueSystemEventSpy).not.toHaveBeenCalled();
});
it.each(telegramReactionPolicyCases)(
"$name",
async ({
updateId,
channelConfig,
reaction,
sentByBot,
expectedEnqueueCalls,
expectedEvent,
}) => {
onSpy.mockClear();
enqueueSystemEventSpy.mockClear();
if (sentByBot !== undefined) {
wasSentByBot.mockReturnValue(sentByBot);
}
loadConfig.mockReturnValue({
channels: {
telegram: channelConfig,
},
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message_reaction") as (
ctx: Record<string, unknown>,
) => Promise<void>;
await handler(createTelegramReactionContext({ updateId, reaction }));
expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(expectedEnqueueCalls);
if (expectedEvent) {
expect(firstSystemEventArg(0)).toBe(expectedEvent);
expect(firstSystemEventArg(1)).toBeTypeOf("object");
}
},
);
it("enqueues one event per added emoji reaction", async () => {
onSpy.mockClear();
@@ -6658,36 +6549,5 @@ describe("createTelegramBot", () => {
const sessionKey = eventOptions.sessionKey ?? "";
expect(sessionKey).not.toContain(":topic:");
});
it("blocks reaction in own mode when cache is warm and message not sent by bot", async () => {
onSpy.mockClear();
enqueueSystemEventSpy.mockClear();
wasSentByBot.mockReturnValue(false);
loadConfig.mockReturnValue({
channels: {
telegram: { dmPolicy: "open", reactionNotifications: "own" },
},
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message_reaction") as (
ctx: Record<string, unknown>,
) => Promise<void>;
await handler({
update: { update_id: 601 },
messageReaction: {
chat: { id: 1234, type: "private" },
message_id: 99,
user: { id: 9, first_name: "Ada" },
date: 1736380800,
old_reaction: [],
new_reaction: [{ type: "emoji", emoji: THUMBS_UP_EMOJI }],
},
});
expect(enqueueSystemEventSpy).not.toHaveBeenCalled();
});
});
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */

View File

@@ -1,8 +1,5 @@
import { readManifestProviderDefaultModelRef } from "openclaw/plugin-sdk/provider-catalog-shared";
import {
createModelCatalogPresetAppliers,
type OpenClawConfig,
} from "openclaw/plugin-sdk/provider-onboard";
import { createModelCatalogPresetAppliers } from "openclaw/plugin-sdk/provider-onboard";
import {
TOKENHUB_BASE_URL,
TOKENHUB_MODEL_CATALOG,
@@ -19,9 +16,9 @@ export const TOKENHUB_DEFAULT_MODEL_REF = readManifestProviderDefaultModelRef(
TOKENHUB_PROVIDER_ID,
)!;
const tokenHubPresetAppliers = createModelCatalogPresetAppliers({
export const { applyConfig: applyTokenHubConfig } = createModelCatalogPresetAppliers<[]>({
primaryModelRef: TOKENHUB_DEFAULT_MODEL_REF,
resolveParams: (_cfg: OpenClawConfig) => ({
resolveParams: () => ({
providerId: TOKENHUB_PROVIDER_ID,
api: "openai-completions",
baseUrl: TOKENHUB_BASE_URL,
@@ -33,18 +30,14 @@ const tokenHubPresetAppliers = createModelCatalogPresetAppliers({
}),
});
export function applyTokenHubConfig(cfg: OpenClawConfig): OpenClawConfig {
return tokenHubPresetAppliers.applyConfig(cfg);
}
export const TOKENPLAN_DEFAULT_MODEL_REF = readManifestProviderDefaultModelRef(
manifest,
TOKENPLAN_PROVIDER_ID,
)!;
const tokenPlanPresetAppliers = createModelCatalogPresetAppliers({
export const { applyConfig: applyTokenPlanConfig } = createModelCatalogPresetAppliers<[]>({
primaryModelRef: TOKENPLAN_DEFAULT_MODEL_REF,
resolveParams: (_cfg: OpenClawConfig) => ({
resolveParams: () => ({
providerId: TOKENPLAN_PROVIDER_ID,
api: "openai-completions",
baseUrl: TOKENPLAN_BASE_URL,
@@ -52,7 +45,3 @@ const tokenPlanPresetAppliers = createModelCatalogPresetAppliers({
aliases: [{ modelRef: TOKENPLAN_DEFAULT_MODEL_REF, alias: "Hy3 (TokenPlan)" }],
}),
});
export function applyTokenPlanConfig(cfg: OpenClawConfig): OpenClawConfig {
return tokenPlanPresetAppliers.applyConfig(cfg);
}

View File

@@ -1,11 +1,9 @@
import { defineChannelSetupContract } from "openclaw/plugin-sdk/channel-setup";
// Whatsapp plugin module implements setup core behavior.
import {
applyAccountNameToChannelSection,
createPatchedAccountSetupAdapter,
type ChannelSetupAdapter,
type ChannelSetupInput,
migrateBaseNameToDefaultAccount,
normalizeAccountId,
} from "openclaw/plugin-sdk/setup";
const channel = "whatsapp" as const;
@@ -15,49 +13,12 @@ type WhatsAppSetupInput = ChannelSetupInput & {
};
export const whatsappSetupAdapter: ChannelSetupAdapter = {
...createPatchedAccountSetupAdapter<WhatsAppSetupInput>({
channelKey: channel,
alwaysUseAccounts: true,
buildPatch: (input) => (input.authDir ? { authDir: input.authDir } : {}),
}),
singleAccountKeysToMove: ["authDir"],
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg,
channelKey: channel,
accountId,
name,
alwaysUseAccounts: true,
}),
applyAccountConfig: ({ cfg, accountId, input }) => {
const setupInput = input as WhatsAppSetupInput;
const namedConfig = applyAccountNameToChannelSection({
cfg,
channelKey: channel,
accountId,
name: setupInput.name,
alwaysUseAccounts: true,
});
const next = migrateBaseNameToDefaultAccount({
cfg: namedConfig,
channelKey: channel,
alwaysUseAccounts: true,
});
const entry = {
...next.channels?.whatsapp?.accounts?.[accountId],
...(setupInput.authDir ? { authDir: setupInput.authDir } : {}),
enabled: true,
};
return {
...next,
channels: {
...next.channels,
whatsapp: {
...next.channels?.whatsapp,
accounts: {
...next.channels?.whatsapp?.accounts,
[accountId]: entry,
},
},
},
};
},
};
export const whatsappSetupContract = defineChannelSetupContract({

View File

@@ -17,24 +17,25 @@ import {
export const XIAOMI_DEFAULT_MODEL_REF = `${XIAOMI_PROVIDER_ID}/${XIAOMI_DEFAULT_MODEL_ID}`;
export const XIAOMI_TOKEN_PLAN_DEFAULT_MODEL_REF = `${XIAOMI_TOKEN_PLAN_PROVIDER_ID}/${XIAOMI_TOKEN_PLAN_DEFAULT_MODEL_ID}`;
const xiaomiPresetAppliers = createDefaultModelsPresetAppliers({
primaryModelRef: XIAOMI_DEFAULT_MODEL_REF,
resolveParams: (_cfg: OpenClawConfig) => {
const defaultProvider = buildXiaomiProvider();
return {
providerId: XIAOMI_PROVIDER_ID,
api: defaultProvider.api ?? "openai-completions",
baseUrl: defaultProvider.baseUrl,
defaultModels: defaultProvider.models ?? [],
defaultModelId: XIAOMI_DEFAULT_MODEL_ID,
aliases: [{ modelRef: XIAOMI_DEFAULT_MODEL_REF, alias: "Xiaomi" }],
};
},
});
export const { applyConfig: applyXiaomiConfig, applyProviderConfig: applyXiaomiProviderConfig } =
createDefaultModelsPresetAppliers<[]>({
primaryModelRef: XIAOMI_DEFAULT_MODEL_REF,
resolveParams: () => {
const defaultProvider = buildXiaomiProvider();
return {
providerId: XIAOMI_PROVIDER_ID,
api: defaultProvider.api ?? "openai-completions",
baseUrl: defaultProvider.baseUrl,
defaultModels: defaultProvider.models ?? [],
defaultModelId: XIAOMI_DEFAULT_MODEL_ID,
aliases: [{ modelRef: XIAOMI_DEFAULT_MODEL_REF, alias: "Xiaomi" }],
};
},
});
const xiaomiTokenPlanPresetAppliers = createDefaultModelsPresetAppliers({
const xiaomiTokenPlanPresetAppliers = createDefaultModelsPresetAppliers<[]>({
primaryModelRef: XIAOMI_TOKEN_PLAN_DEFAULT_MODEL_REF,
resolveParams: (_cfg: OpenClawConfig) => {
resolveParams: () => {
const defaultProvider = buildXiaomiTokenPlanProvider();
return {
providerId: XIAOMI_TOKEN_PLAN_PROVIDER_ID,
@@ -78,14 +79,6 @@ function withProviderBaseUrl(
} as OpenClawConfig;
}
export function applyXiaomiProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
return xiaomiPresetAppliers.applyProviderConfig(cfg);
}
export function applyXiaomiConfig(cfg: OpenClawConfig): OpenClawConfig {
return xiaomiPresetAppliers.applyConfig(cfg);
}
export function applyXiaomiTokenPlanConfig(
cfg: OpenClawConfig,
region: XiaomiTokenPlanRegion,

View File

@@ -7,7 +7,9 @@ import {
createSetupInputPresenceValidator,
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
patchScopedAccountConfig,
createSetupTranslator,
setSetupChannelEnabled,
type ChannelSetupWizard,
} from "openclaw/plugin-sdk/setup";
import { resolveDefaultZaloAccountId, resolveZaloAccount } from "./accounts.js";
@@ -17,12 +19,6 @@ const t = createSetupTranslator();
const channel = "zalo" as const;
type ZaloAccountSetupConfig = {
enabled?: boolean;
dmPolicy?: string;
allowFrom?: Array<string | number> | ReadonlyArray<string | number>;
};
export const zaloSetupAdapter = {
...createPatchedAccountSetupAdapter({
channelKey: channel,
@@ -77,42 +73,8 @@ export const zaloDmPolicy = createChannelDmPolicy({
: resolveDefaultZaloAccountId(cfg);
return resolveZaloAccount({ cfg, accountId: resolvedAccountId });
},
applyPatch: ({ cfg, account, patch }) => {
if (account.accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
zalo: {
...cfg.channels?.zalo,
enabled: true,
...patch,
},
},
};
}
const currentAccount = cfg.channels?.zalo?.accounts?.[account.accountId] as
| ZaloAccountSetupConfig
| undefined;
return {
...cfg,
channels: {
...cfg.channels,
zalo: {
...cfg.channels?.zalo,
enabled: true,
accounts: {
...cfg.channels?.zalo?.accounts,
[account.accountId]: {
...currentAccount,
enabled: currentAccount?.enabled ?? true,
...patch,
},
},
},
},
};
},
applyPatch: ({ cfg, account, patch }) =>
patchScopedAccountConfig({ cfg, channelKey: channel, accountId: account.accountId, patch }),
promptAllowFrom: async ({ cfg, prompter, accountId }) =>
promptZaloAllowFrom({
cfg,
@@ -138,15 +100,6 @@ export function createZaloSetupWizardProxy(
credentials: [],
delegateFinalize: true,
dmPolicy: zaloDmPolicy,
disable: (cfg) => ({
...cfg,
channels: {
...cfg.channels,
zalo: {
...cfg.channels?.zalo,
enabled: false,
},
},
}),
disable: (cfg) => setSetupChannelEnabled(cfg, channel, false),
});
}

View File

@@ -962,6 +962,17 @@ export function collectDatabaseFirstLegacyStoreViolations(
return `${objectName}.${propertyName}`;
}
function shadowObjectPropertyScopes(scopes, objectName, value, targetScope = lastScope(scopes)) {
const prefix = `${objectName}.`;
for (const scope of scopes) {
for (const name of scope.keys()) {
if (name.startsWith(prefix)) {
targetScope.set(name, value);
}
}
}
}
function resolveLegacyPathIdentifier(name) {
return scopeForRead(legacyPathScopes, name)?.get(name) === true;
}
@@ -1598,35 +1609,15 @@ export function collectDatabaseFirstLegacyStoreViolations(
if (resolveFsSafeStoreFactoryAlias(bindingName)) {
lastScope(fsSafeStoreFactoryAliasScopes).set(bindingName, null);
}
const prefix = `${bindingName}.`;
for (const scope of fsSafeStoreFactoryAliasScopes) {
for (const alias of scope.keys()) {
if (alias.startsWith(prefix)) {
lastScope(fsSafeStoreFactoryAliasScopes).set(alias, null);
}
}
}
shadowObjectPropertyScopes(fsSafeStoreFactoryAliasScopes, bindingName, null);
if (resolveFsSafeStore(bindingName)) {
lastScope(fsSafeStoreScopes).set(bindingName, false);
}
if (resolveFsSafeJsonStore(bindingName)) {
lastScope(fsSafeJsonStoreScopes).set(bindingName, false);
}
const storePrefix = `${bindingName}.`;
for (const scope of fsSafeStoreScopes) {
for (const alias of scope.keys()) {
if (alias.startsWith(storePrefix)) {
lastScope(fsSafeStoreScopes).set(alias, false);
}
}
}
for (const scope of fsSafeJsonStoreScopes) {
for (const alias of scope.keys()) {
if (alias.startsWith(storePrefix)) {
lastScope(fsSafeJsonStoreScopes).set(alias, false);
}
}
}
shadowObjectPropertyScopes(fsSafeStoreScopes, bindingName, false);
shadowObjectPropertyScopes(fsSafeJsonStoreScopes, bindingName, false);
}
}
@@ -1707,24 +1698,11 @@ export function collectDatabaseFirstLegacyStoreViolations(
}
function clearFsWriteObjectAliases(scope, objectName) {
const prefix = `${objectName}.`;
for (const name of scope.keys()) {
if (name.startsWith(prefix)) {
scope.set(name, null);
}
}
shadowObjectPropertyScopes([scope], objectName, null, scope);
}
function shadowVisibleFsWriteObjectAliases(objectName) {
const prefix = `${objectName}.`;
const currentScope = lastScope(fsWriteAliasScopes);
for (const scope of fsWriteAliasScopes) {
for (const name of scope.keys()) {
if (name.startsWith(prefix)) {
currentScope.set(name, null);
}
}
}
shadowObjectPropertyScopes(fsWriteAliasScopes, objectName, null);
}
function setFsWriteObjectAlias(scope, name, writeName, conditionalWrite) {
@@ -1770,37 +1748,13 @@ export function collectDatabaseFirstLegacyStoreViolations(
}
function clearFsSafeStoreObjectAliases(storeScope, jsonStoreScope, objectName) {
const prefix = `${objectName}.`;
for (const name of storeScope.keys()) {
if (name.startsWith(prefix)) {
storeScope.set(name, false);
}
}
for (const name of jsonStoreScope.keys()) {
if (name.startsWith(prefix)) {
jsonStoreScope.set(name, false);
}
}
shadowObjectPropertyScopes([storeScope], objectName, false, storeScope);
shadowObjectPropertyScopes([jsonStoreScope], objectName, false, jsonStoreScope);
}
function shadowVisibleFsSafeStoreObjectAliases(objectName) {
const prefix = `${objectName}.`;
const currentStoreScope = lastScope(fsSafeStoreScopes);
const currentJsonStoreScope = lastScope(fsSafeJsonStoreScopes);
for (const scope of fsSafeStoreScopes) {
for (const name of scope.keys()) {
if (name.startsWith(prefix)) {
currentStoreScope.set(name, false);
}
}
}
for (const scope of fsSafeJsonStoreScopes) {
for (const name of scope.keys()) {
if (name.startsWith(prefix)) {
currentJsonStoreScope.set(name, false);
}
}
}
shadowObjectPropertyScopes(fsSafeStoreScopes, objectName, false);
shadowObjectPropertyScopes(fsSafeJsonStoreScopes, objectName, false);
}
function setFsSafeStoreObjectAlias(
@@ -1929,13 +1883,8 @@ export function collectDatabaseFirstLegacyStoreViolations(
}
function clearFsModuleObjectProperties(scope, objectName) {
const prefix = `${objectName}.`;
scope.set(objectName, false);
for (const name of scope.keys()) {
if (name.startsWith(prefix)) {
scope.set(name, false);
}
}
shadowObjectPropertyScopes([scope], objectName, false, scope);
}
function registerFsModuleObjectProperties(
@@ -4043,23 +3992,11 @@ export function collectDatabaseFirstLegacyStoreViolations(
}
function resolveBodyFsWriteAlias(name) {
for (let index = bodyFsWriteAliasScopes.length - 1; index >= 0; index--) {
const scope = bodyFsWriteAliasScopes[index];
if (scope.has(name)) {
return scope.get(name) ?? null;
}
}
return null;
return scopeForRead(bodyFsWriteAliasScopes, name)?.get(name) ?? null;
}
function resolveBodyFsModuleBinding(name) {
for (let index = bodyFsModuleBindingScopes.length - 1; index >= 0; index--) {
const scope = bodyFsModuleBindingScopes[index];
if (scope.has(name)) {
return scope.get(name) === true;
}
}
return false;
return scopeForRead(bodyFsModuleBindingScopes, name)?.get(name) === true;
}
function resolveBodyFsModuleProperty(pathParts) {
@@ -4080,12 +4017,7 @@ export function collectDatabaseFirstLegacyStoreViolations(
}
function isFsModuleShadowed(name) {
for (let index = fsModuleShadowScopes.length - 1; index >= 0; index--) {
if (fsModuleShadowScopes[index].has(name)) {
return true;
}
}
return false;
return fsModuleShadowScopes.some((scope) => scope.has(name));
}
function isWrapperRequireName(name) {
@@ -4139,34 +4071,15 @@ export function collectDatabaseFirstLegacyStoreViolations(
}
function resolveBodyRequireAlias(name) {
for (let index = bodyRequireAliasScopes.length - 1; index >= 0; index--) {
const scope = bodyRequireAliasScopes[index];
if (scope.has(name)) {
return scope.get(name) === true;
}
}
return false;
return scopeForRead(bodyRequireAliasScopes, name)?.get(name) === true;
}
function shadowVisibleBodyFsWriteObjectAliases(objectName) {
const prefix = `${objectName}.`;
const currentScope = lastScope(bodyFsWriteAliasScopes);
for (const scope of bodyFsWriteAliasScopes) {
for (const name of scope.keys()) {
if (name.startsWith(prefix)) {
currentScope.set(name, null);
}
}
}
shadowObjectPropertyScopes(bodyFsWriteAliasScopes, objectName, null);
}
function clearBodyFsWriteObjectAliases(scope, objectName) {
const prefix = `${objectName}.`;
for (const name of scope.keys()) {
if (name.startsWith(prefix)) {
scope.set(name, null);
}
}
shadowObjectPropertyScopes([scope], objectName, null, scope);
}
function setBodyFsWriteObjectAlias(scope, name, writeName) {
@@ -4229,12 +4142,7 @@ export function collectDatabaseFirstLegacyStoreViolations(
}
function isFsAliasShadowed(name) {
for (let index = fsAliasShadowScopes.length - 1; index >= 0; index--) {
if (fsAliasShadowScopes[index].has(name)) {
return true;
}
}
return false;
return fsAliasShadowScopes.some((scope) => scope.has(name));
}
function isWrapperFsModuleExpression(expression) {
@@ -4490,24 +4398,11 @@ export function collectDatabaseFirstLegacyStoreViolations(
}
function clearNestedWrapperObjectMethods(scope, objectName) {
const prefix = `${objectName}.`;
for (const name of scope.keys()) {
if (name.startsWith(prefix)) {
scope.set(name, null);
}
}
shadowObjectPropertyScopes([scope], objectName, null, scope);
}
function shadowVisibleNestedWrapperObjectMethods(objectName) {
const prefix = `${objectName}.`;
const currentScope = lastScope(nestedWrapperFunctionScopes);
for (const scope of nestedWrapperFunctionScopes) {
for (const name of scope.keys()) {
if (name.startsWith(prefix)) {
currentScope.set(name, null);
}
}
}
shadowObjectPropertyScopes(nestedWrapperFunctionScopes, objectName, null);
}
function markNestedWrapperObjectUnknown(
@@ -6345,12 +6240,7 @@ export function collectDatabaseFirstLegacyStoreViolations(
}
function clearWrapperObjectMethods(scope, objectName) {
const prefix = `${objectName}.`;
for (const name of scope.keys()) {
if (name.startsWith(prefix)) {
scope.set(name, null);
}
}
shadowObjectPropertyScopes([scope], objectName, null, scope);
}
function clearWrapperObjectMethod(scope, methodName) {
@@ -6359,15 +6249,7 @@ export function collectDatabaseFirstLegacyStoreViolations(
}
function shadowVisibleWrapperObjectMethods(objectName) {
const prefix = `${objectName}.`;
const currentScope = lastScope(wrapperFunctionScopes);
for (const scope of wrapperFunctionScopes) {
for (const name of scope.keys()) {
if (name.startsWith(prefix)) {
currentScope.set(name, null);
}
}
}
shadowObjectPropertyScopes(wrapperFunctionScopes, objectName, null);
}
function copyWrapperObjectMethods(

View File

@@ -2067,102 +2067,75 @@ describe("subagent registry seam flow", () => {
expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledTimes(1);
});
it("prefers explicit run timeout over late restored agent.wait success", async () => {
const startedAt = Date.parse("2026-03-24T11:59:00Z");
vi.setSystemTime(startedAt + 61_000);
mocks.resolveAgentTimeoutMs.mockReturnValue(60_000);
mocks.restoreSubagentRunsFromDisk.mockImplementation(((params: {
runs: Map<string, unknown>;
mergeOnly?: boolean;
}) => {
params.runs.set(
"run-resumed-late-success",
createSubagentRunRecord({
runId: "run-resumed-late-success",
task: "resume after explicit timeout",
runTimeoutSeconds: 60,
createdAt: startedAt,
startedAt,
sessionStartedAt: startedAt,
}),
);
return 1;
}) as never);
mockGatewayMethods(mocks.callGateway, {
"agent.wait": {
status: "ok",
startedAt,
endedAt: startedAt + 61_000,
},
});
mod.initSubagentRegistry();
await waitForFast(() => {
const completedRun = findRequesterRun("run-resumed-late-success");
expect(completedRun?.endedAt).toBe(startedAt + 60_000);
expectRecordFields(
completedRun?.outcome,
{
status: "timeout",
startedAt,
endedAt: startedAt + 60_000,
elapsedMs: 60_000,
},
"late restored wait success timeout outcome",
);
});
expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledTimes(1);
});
it("uses observed agent.wait start time when applying explicit run deadline", async () => {
const createdAt = Date.parse("2026-03-24T11:59:00Z");
const observedStartedAt = createdAt + 10_000;
vi.setSystemTime(createdAt + 65_000);
mocks.resolveAgentTimeoutMs.mockReturnValue(60_000);
mocks.restoreSubagentRunsFromDisk.mockImplementation(((params: {
runs: Map<string, unknown>;
mergeOnly?: boolean;
}) => {
params.runs.set(
"run-resumed-observed-start",
createSubagentRunRecord({
runId: "run-resumed-observed-start",
task: "respect observed start",
runTimeoutSeconds: 60,
createdAt,
startedAt: createdAt,
sessionStartedAt: createdAt,
}),
);
return 1;
}) as never);
mockGatewayMethods(mocks.callGateway, {
"agent.wait": {
status: "ok",
startedAt: observedStartedAt,
endedAt: createdAt + 65_000,
},
});
mod.initSubagentRegistry();
await waitForFast(() => {
const completedRun = findRequesterRun("run-resumed-observed-start");
expect(completedRun?.endedAt).toBe(createdAt + 65_000);
expectRecordFields(
completedRun?.outcome,
{
it.each([
{
name: "prefers explicit run timeout over late restored agent.wait success",
runId: "run-resumed-late-success",
task: "resume after explicit timeout",
waitStartedAfterMs: 0,
waitEndedAfterMs: 61_000,
expected: { status: "timeout", startedAfterMs: 0, endedAfterMs: 60_000, elapsedMs: 60_000 },
label: "late restored wait success timeout outcome",
},
{
name: "uses observed agent.wait start time when applying explicit run deadline",
runId: "run-resumed-observed-start",
task: "respect observed start",
waitStartedAfterMs: 10_000,
waitEndedAfterMs: 65_000,
expected: { status: "ok", startedAfterMs: 10_000, endedAfterMs: 65_000, elapsedMs: 55_000 },
label: "observed start success outcome",
},
] as const)(
"$name",
async ({ runId, task, waitStartedAfterMs, waitEndedAfterMs, expected, label }) => {
const createdAt = Date.parse("2026-03-24T11:59:00Z");
vi.setSystemTime(createdAt + waitEndedAfterMs);
mocks.resolveAgentTimeoutMs.mockReturnValue(60_000);
mocks.restoreSubagentRunsFromDisk.mockImplementation(((params: {
runs: Map<string, unknown>;
mergeOnly?: boolean;
}) => {
params.runs.set(
runId,
createSubagentRunRecord({
runId,
task,
runTimeoutSeconds: 60,
createdAt,
startedAt: createdAt,
sessionStartedAt: createdAt,
}),
);
return 1;
}) as never);
mockGatewayMethods(mocks.callGateway, {
"agent.wait": {
status: "ok",
startedAt: observedStartedAt,
endedAt: createdAt + 65_000,
elapsedMs: 55_000,
startedAt: createdAt + waitStartedAfterMs,
endedAt: createdAt + waitEndedAfterMs,
},
"observed start success outcome",
);
});
expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledTimes(1);
});
});
mod.initSubagentRegistry();
await waitForFast(() => {
const completedRun = findRequesterRun(runId);
expect(completedRun?.endedAt).toBe(createdAt + expected.endedAfterMs);
expectRecordFields(
completedRun?.outcome,
{
status: expected.status,
startedAt: createdAt + expected.startedAfterMs,
endedAt: createdAt + expected.endedAfterMs,
elapsedMs: expected.elapsedMs,
},
label,
);
});
expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledTimes(1);
},
);
it("uses session-store start time for successful agent.wait results without a start", async () => {
const createdAt = Date.parse("2026-03-24T11:59:00Z");
@@ -2210,119 +2183,103 @@ describe("subagent registry seam flow", () => {
expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledTimes(1);
});
it("does not terminally time out plain agent.wait timeouts before the observed run deadline", async () => {
const createdAt = Date.parse("2026-03-24T11:59:00Z");
const observedStartedAt = createdAt + 10_000;
vi.setSystemTime(createdAt + 61_000);
let waitAttempts = 0;
mocks.callGateway.mockImplementation(async (request: { method?: string }) => {
if (request.method === "agent.wait") {
waitAttempts += 1;
return {
status: "timeout",
startedAt: observedStartedAt,
};
}
return {};
});
mocks.loadSessionStore.mockReturnValue(
createSessionStore({
updatedAt: createdAt,
status: "running",
}),
);
mod.registerSubagentRun({
it.each([
{
name: "does not terminally time out plain agent.wait timeouts before the observed run deadline",
runId: "run-plain-timeout-observed-start",
task: "do not timeout before observed start deadline",
runTimeoutSeconds: 60,
});
let run = findRequesterRun("run-plain-timeout-observed-start");
await waitForFast(() => {
expect(waitAttempts).toBeGreaterThanOrEqual(1);
run = findRequesterRun("run-plain-timeout-observed-start");
expect(run?.endedAt).toBeUndefined();
expect(run?.outcome).toBeUndefined();
expect(run?.startedAt).toBe(observedStartedAt);
});
vi.setSystemTime(observedStartedAt + 60_000);
await vi.advanceTimersByTimeAsync(5_000);
await waitForFast(() => {
run = findRequesterRun("run-plain-timeout-observed-start");
expect(run?.endedAt).toBe(observedStartedAt + 60_000);
expectRecordFields(
run?.outcome,
{
status: "timeout",
startedAt: observedStartedAt,
endedAt: observedStartedAt + 60_000,
elapsedMs: 60_000,
},
"observed start plain wait timeout outcome",
);
});
expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledTimes(1);
});
it("uses running session-store start time for plain agent.wait timeouts", async () => {
const createdAt = Date.parse("2026-03-24T11:59:00Z");
const sessionStartedAt = createdAt + 10_000;
vi.setSystemTime(createdAt);
let waitAttempts = 0;
mocks.callGateway.mockImplementation(async (request: { method?: string }) => {
if (request.method === "agent.wait") {
waitAttempts += 1;
if (waitAttempts === 1) {
vi.setSystemTime(createdAt + 61_000);
}
return { status: "timeout" };
}
return {};
});
mocks.loadSessionStore.mockReturnValue(
createSessionStore({
updatedAt: createdAt + 61_000,
status: "running",
startedAt: sessionStartedAt,
}),
);
mod.registerSubagentRun({
initialNowAfterMs: 61_000,
waitStartedAfterMs: 10_000,
sessionStartedAfterMs: undefined,
observedStartedAfterMs: 10_000,
sessionUpdatedAfterMs: 0,
advanceOnFirstWait: false,
label: "observed start plain wait timeout outcome",
},
{
name: "uses running session-store start time for plain agent.wait timeouts",
runId: "run-plain-timeout-session-store-start",
task: "do not timeout before session store start deadline",
runTimeoutSeconds: 60,
});
await waitForFast(() => {
const run = findRequesterRun("run-plain-timeout-session-store-start");
expect(waitAttempts).toBeGreaterThanOrEqual(1);
expect(run?.endedAt).toBeUndefined();
expect(run?.outcome).toBeUndefined();
expect(run?.startedAt).toBe(sessionStartedAt);
});
vi.setSystemTime(sessionStartedAt + 60_000);
await vi.advanceTimersByTimeAsync(5_000);
await waitForFast(() => {
const run = findRequesterRun("run-plain-timeout-session-store-start");
expect(run?.endedAt).toBe(sessionStartedAt + 60_000);
expectRecordFields(
run?.outcome,
{
initialNowAfterMs: 0,
waitStartedAfterMs: undefined,
sessionStartedAfterMs: 10_000,
observedStartedAfterMs: 10_000,
sessionUpdatedAfterMs: 61_000,
advanceOnFirstWait: true,
label: "session store start plain wait timeout outcome",
},
] as const)(
"$name",
async ({
runId,
task,
initialNowAfterMs,
waitStartedAfterMs,
sessionStartedAfterMs,
observedStartedAfterMs,
sessionUpdatedAfterMs,
advanceOnFirstWait,
label,
}) => {
const createdAt = Date.parse("2026-03-24T11:59:00Z");
const observedStartedAt = createdAt + observedStartedAfterMs;
vi.setSystemTime(createdAt + initialNowAfterMs);
let waitAttempts = 0;
mocks.callGateway.mockImplementation(async (request: { method?: string }) => {
if (request.method !== "agent.wait") {
return {};
}
waitAttempts += 1;
if (advanceOnFirstWait && waitAttempts === 1) {
vi.setSystemTime(createdAt + 61_000);
}
return {
status: "timeout",
startedAt: sessionStartedAt,
endedAt: sessionStartedAt + 60_000,
elapsedMs: 60_000,
},
"session store start plain wait timeout outcome",
...(waitStartedAfterMs === undefined
? {}
: { startedAt: createdAt + waitStartedAfterMs }),
};
});
mocks.loadSessionStore.mockReturnValue(
createSessionStore({
status: "running",
updatedAt: createdAt + sessionUpdatedAfterMs,
...(sessionStartedAfterMs === undefined
? {}
: { startedAt: createdAt + sessionStartedAfterMs }),
}),
);
});
expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledTimes(1);
});
mod.registerSubagentRun({ runId, task, runTimeoutSeconds: 60 });
await waitForFast(() => {
const run = findRequesterRun(runId);
expect(waitAttempts).toBeGreaterThanOrEqual(1);
expect(run?.endedAt).toBeUndefined();
expect(run?.outcome).toBeUndefined();
expect(run?.startedAt).toBe(observedStartedAt);
});
vi.setSystemTime(observedStartedAt + 60_000);
await vi.advanceTimersByTimeAsync(5_000);
await waitForFast(() => {
const run = findRequesterRun(runId);
expect(run?.endedAt).toBe(observedStartedAt + 60_000);
expectRecordFields(
run?.outcome,
{
status: "timeout",
startedAt: observedStartedAt,
endedAt: observedStartedAt + 60_000,
elapsedMs: 60_000,
},
label,
);
});
expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledTimes(1);
},
);
it.each([
{
@@ -2516,87 +2473,74 @@ describe("subagent registry seam flow", () => {
// this test pins the audit-relevant ordering (outcome + endedAt) only.
});
it("caps terminal agent.wait timeouts to the explicit run deadline", async () => {
const startedAt = Date.now();
mockGatewayMethods(mocks.callGateway, {
"agent.wait": {
status: "timeout",
startedAt,
endedAt: startedAt + 2_000,
stopReason: "rpc",
},
});
mocks.loadSessionStore.mockReturnValue(
createSessionStore({
updatedAt: startedAt,
status: "running",
}),
);
mod.registerSubagentRun({
it.each([
{
name: "caps terminal agent.wait timeouts to the explicit run deadline",
runId: "run-terminal-timeout-capped",
task: "cap terminal timeout",
initialNowAfterMs: 60_000,
waitStartedAfterMs: 60_000,
waitEndedAfterMs: 62_000,
sessionUpdatedAfterMs: 60_000,
runTimeoutSeconds: 1,
});
await waitForFast(() => {
const run = findRequesterRun("run-terminal-timeout-capped");
expect(run?.endedAt).toBe(startedAt + 1_000);
expectRecordFields(
run?.outcome,
{
status: "timeout",
startedAt,
endedAt: startedAt + 1_000,
elapsedMs: 1_000,
},
"capped terminal timeout outcome",
);
});
expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledTimes(1);
});
it("uses observed agent.wait start time when capping terminal timeout", async () => {
const createdAt = Date.parse("2026-03-24T11:59:00Z");
const observedStartedAt = createdAt + 10_000;
vi.setSystemTime(createdAt + 75_000);
mockGatewayMethods(mocks.callGateway, {
"agent.wait": {
status: "timeout",
startedAt: observedStartedAt,
endedAt: createdAt + 75_000,
stopReason: "rpc",
},
});
mocks.loadSessionStore.mockReturnValue(
createSessionStore({
updatedAt: createdAt,
status: "running",
}),
);
mod.registerSubagentRun({
label: "capped terminal timeout outcome",
},
{
name: "uses observed agent.wait start time when capping terminal timeout",
runId: "run-terminal-timeout-observed-start",
task: "cap timeout using observed start",
initialNowAfterMs: 75_000,
waitStartedAfterMs: 10_000,
waitEndedAfterMs: 75_000,
sessionUpdatedAfterMs: 0,
runTimeoutSeconds: 60,
});
await waitForFast(() => {
const run = findRequesterRun("run-terminal-timeout-observed-start");
expect(run?.endedAt).toBe(observedStartedAt + 60_000);
expectRecordFields(
run?.outcome,
{
label: "observed start capped terminal timeout outcome",
},
] as const)(
"$name",
async ({
runId,
task,
initialNowAfterMs,
waitStartedAfterMs,
waitEndedAfterMs,
sessionUpdatedAfterMs,
runTimeoutSeconds,
label,
}) => {
const createdAt = Date.parse("2026-03-24T11:59:00Z");
const startedAt = createdAt + waitStartedAfterMs;
const elapsedMs = runTimeoutSeconds * 1_000;
vi.setSystemTime(createdAt + initialNowAfterMs);
mockGatewayMethods(mocks.callGateway, {
"agent.wait": {
status: "timeout",
startedAt: observedStartedAt,
endedAt: observedStartedAt + 60_000,
elapsedMs: 60_000,
startedAt,
endedAt: createdAt + waitEndedAfterMs,
stopReason: "rpc",
},
"observed start capped terminal timeout outcome",
});
mocks.loadSessionStore.mockReturnValue(
createSessionStore({
updatedAt: createdAt + sessionUpdatedAfterMs,
status: "running",
}),
);
});
expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledTimes(1);
});
mod.registerSubagentRun({ runId, task, runTimeoutSeconds });
await waitForFast(() => {
const run = findRequesterRun(runId);
expect(run?.endedAt).toBe(startedAt + elapsedMs);
expectRecordFields(
run?.outcome,
{ status: "timeout", startedAt, endedAt: startedAt + elapsedMs, elapsedMs },
label,
);
});
expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledTimes(1);
},
);
it("ignores stale terminal session-store rows from older child runs", async () => {
let waitAttempts = 0;
@@ -2943,89 +2887,63 @@ describe("subagent registry seam flow", () => {
expect(mocks.runSubagentAnnounceFlow).not.toHaveBeenCalled();
});
it("announces blocked agent.wait snapshots as errors instead of success", async () => {
mockGatewayMethods(mocks.callGateway, {
"agent.wait": {
status: "ok",
startedAt: 100,
endedAt: 250,
livenessState: "blocked",
error: "Context overflow: prompt too large for the model.",
},
});
mod.registerSubagentRun({
it.each([
{
name: "announces blocked agent.wait snapshots as errors instead of success",
runId: "run-blocked-wait",
task: "overflow wait",
expectsCompletionMessage: true,
});
await waitForFast(() => {
expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledTimes(1);
});
const announceParams = expectRecordFields(
getMockCallArg(mocks.runSubagentAnnounceFlow, 0, 0, "blocked wait announce"),
{ childRunId: "run-blocked-wait" },
"blocked wait announce params",
);
expectRecordFields(
announceParams.outcome,
{
wait: {
status: "ok",
error: "Context overflow: prompt too large for the model.",
},
expectedOutcome: {
status: "error",
error: "Context overflow: prompt too large for the model.",
startedAt: 100,
endedAt: 250,
elapsedMs: 150,
},
"blocked wait announce outcome",
);
const run = findRequesterRun("run-blocked-wait");
expect(run?.endedReason).toBe("subagent-error");
expect(run?.outcome?.status).toBe("error");
});
it("announces provider hard timeout wait snapshots as timeouts despite blocked metadata", async () => {
mockGatewayMethods(mocks.callGateway, {
"agent.wait": {
expectedReason: "subagent-error",
label: "blocked wait announce",
},
{
name: "announces provider hard timeout wait snapshots as timeouts despite blocked metadata",
runId: "run-blocked-hard-timeout-wait",
task: "provider timeout wait",
wait: {
status: "error",
startedAt: 100,
endedAt: 250,
livenessState: "blocked",
timeoutPhase: "provider",
providerStarted: true,
error: "model timed out",
},
expectedOutcome: { status: "timeout" },
expectedReason: "subagent-complete",
label: "hard timeout wait announce",
},
] as const)("$name", async ({ runId, task, wait, expectedOutcome, expectedReason, label }) => {
mockGatewayMethods(mocks.callGateway, {
"agent.wait": {
startedAt: 100,
endedAt: 250,
livenessState: "blocked",
...wait,
},
});
mod.registerSubagentRun({
runId: "run-blocked-hard-timeout-wait",
task: "provider timeout wait",
expectsCompletionMessage: true,
});
mod.registerSubagentRun({ runId, task, expectsCompletionMessage: true });
await waitForFast(() => {
expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledTimes(1);
});
await waitForFast(() => expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledTimes(1));
const announceParams = expectRecordFields(
getMockCallArg(mocks.runSubagentAnnounceFlow, 0, 0, "hard timeout wait announce"),
{ childRunId: "run-blocked-hard-timeout-wait" },
"hard timeout wait announce params",
getMockCallArg(mocks.runSubagentAnnounceFlow, 0, 0, label),
{ childRunId: runId },
`${label} params`,
);
expectRecordFields(
announceParams.outcome,
{
status: "timeout",
startedAt: 100,
endedAt: 250,
elapsedMs: 150,
},
"hard timeout wait announce outcome",
{ ...expectedOutcome, startedAt: 100, endedAt: 250, elapsedMs: 150 },
`${label} outcome`,
);
const run = findRequesterRun("run-blocked-hard-timeout-wait");
expect(run?.endedReason).toBe("subagent-complete");
expect(run?.outcome?.status).toBe("timeout");
const run = findRequesterRun(runId);
expect(run?.endedReason).toBe(expectedReason);
expect(run?.outcome?.status).toBe(expectedOutcome.status);
});
it("publishes aborted agent.wait snapshots only after killed reconciliation", async () => {

View File

@@ -4259,116 +4259,6 @@ describe("persistSessionUsageUpdate", () => {
await writeSessionStoreFast(storePath, { [sessionKey]: entry });
}
it("uses lastCallUsage for totalTokens when provided", async () => {
const storePath = await createStorePath("openclaw-usage-");
const sessionKey = "main";
await seedSessionStore(storePath, sessionKey, {
sessionId: "s1",
updatedAt: Date.now(),
totalTokens: 100_000,
});
const accumulatedUsage = { input: 180_000, output: 10_000, total: 190_000 };
const lastCallUsage = { input: 12_000, output: 2_000, total: 14_000 };
await persistSessionUsageUpdate({
storePath,
sessionKey,
usage: accumulatedUsage,
lastCallUsage,
contextTokensUsed: 200_000,
});
const stored = readSessionStoreFast(storePath);
expect(expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").totalTokens).toBe(
12_000,
);
expect(
expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").totalTokensFresh,
).toBe(true);
expect(expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").inputTokens).toBe(
180_000,
);
expect(
expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").outputTokens,
).toBe(10_000);
});
it("keeps the prior total stale when last-call context is unavailable", async () => {
const storePath = await createStorePath("openclaw-usage-unavailable-context-");
const sessionKey = "main";
await seedSessionStore(storePath, sessionKey, {
sessionId: "s1",
updatedAt: Date.now(),
totalTokens: 148_874,
totalTokensFresh: true,
});
await persistSessionUsageUpdate({
storePath,
sessionKey,
usage: { input: 12, output: 15_104, cacheRead: 819_661, cacheWrite: 93_130 },
lastCallUsage: {
input: 12,
output: 15_104,
cacheRead: 819_661,
cacheWrite: 93_130,
contextUsage: { state: "unavailable" },
total: 927_907,
},
contextTokensUsed: 200_000,
});
const stored = readSessionStoreFast(storePath);
expect(expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").totalTokens).toBe(
148_874,
);
expect(
expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").totalTokensFresh,
).toBe(false);
expect(expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").inputTokens).toBe(
12,
);
expect(expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").cacheRead).toBe(
819_661,
);
});
it.each([
{
name: "marks a fresh zero stale when a completed run has no context snapshot",
totalTokens: 0,
preserve: false,
expectedFresh: false,
},
{
name: "preserves fresh post-compaction totalTokens across model-only updates",
totalTokens: 42_000,
preserve: true,
expectedFresh: true,
},
])("$name", async ({ totalTokens, preserve, expectedFresh }) => {
const storePath = await createStorePath("openclaw-usage-no-snapshot-");
const sessionKey = "main";
await seedSessionStore(storePath, sessionKey, {
sessionId: "s1",
updatedAt: Date.now(),
totalTokens,
totalTokensFresh: true,
});
await persistSessionUsageUpdate({
storePath,
sessionKey,
modelUsed: "claude-sonnet-4-6",
contextTokensUsed: 200_000,
preserveFreshTotalTokensOnStaleUsage: preserve,
});
const stored = expectDefined(readSessionStoreFast(storePath)[sessionKey], "stored session");
expect(stored.totalTokens).toBe(totalTokens);
expect(stored.totalTokensFresh).toBe(expectedFresh);
});
it("accounts exhausted-run usage without committing its model and persists CLI binding", async () => {
const storePath = await createStorePath("openclaw-usage-exhausted-");
const sessionKey = "main";
@@ -4470,6 +4360,218 @@ describe("persistSessionUsageUpdate", () => {
});
it.each([
{
name: "treats CLI usage as a fresh context snapshot when requested",
seed: {},
update: {
usage: { input: 24_000, output: 2_000, cacheRead: 8_000 },
usageIsContextSnapshot: true,
providerUsed: "claude-cli",
cliSessionBinding: {
sessionId: "cli-session-1",
authProfileId: "anthropic:default",
extraSystemPromptHash: "prompt-hash",
mcpConfigHash: "mcp-hash",
},
},
expected: {
totalTokens: 32_000,
totalTokensFresh: true,
cliSessionIds: { "claude-cli": "cli-session-1" },
cliSessionBindings: {
"claude-cli": {
sessionId: "cli-session-1",
authProfileId: "anthropic:default",
extraSystemPromptHash: "prompt-hash",
mcpConfigHash: "mcp-hash",
},
},
},
},
{
name: "clears stale CLI binding when usage update reports an unflushed replacement",
seed: {
cliSessionIds: { "claude-cli": "stale-cli-session", "codex-cli": "codex-session" },
cliSessionBindings: {
"claude-cli": { sessionId: "stale-cli-session", authProfileId: "anthropic:old" },
"codex-cli": { sessionId: "codex-session" },
},
claudeCliSessionId: "stale-cli-session",
},
update: {
usage: { input: 24_000, output: 2_000, cacheRead: 8_000 },
usageIsContextSnapshot: true,
providerUsed: "claude-cli",
clearCliSessionBinding: true,
},
expected: {
cliSessionIds: { "codex-cli": "codex-session" },
cliSessionBindings: { "codex-cli": { sessionId: "codex-session" } },
claudeCliSessionId: undefined,
},
},
{
name: "prefers fresh final usage over zero compactionTokensAfter",
seed: {
totalTokens: 1_794_391,
totalTokensFresh: true,
inputTokens: 20,
outputTokens: 10_855,
cacheRead: 1_761_324,
cacheWrite: 33_047,
},
update: {
usage: { input: 20, output: 10_855, cacheRead: 1_761_324, cacheWrite: 33_047 },
lastCallUsage: { input: 20, output: 10_855, cacheRead: 1_761_324, cacheWrite: 33_047 },
usageIsContextSnapshot: true,
providerUsed: "claude-cli",
contextTokensUsed: 1_048_576,
compactionTokensAfter: 0,
},
expected: {
totalTokens: 1_794_391,
totalTokensFresh: true,
inputTokens: 20,
outputTokens: 10_855,
cacheRead: 1_761_324,
cacheWrite: 33_047,
},
},
{
name: "prefers fresh lastCallUsage over positive compactionTokensAfter",
seed: { totalTokens: 180_000, totalTokensFresh: true },
update: {
usage: { input: 100_000, output: 3_000, cacheRead: 20_000 },
lastCallUsage: { input: 91_000, output: 1_000, cacheRead: 4_000 },
providerUsed: "openai",
compactionTokensAfter: 80_000,
},
expected: {
totalTokens: 95_000,
totalTokensFresh: true,
inputTokens: 100_000,
outputTokens: 3_000,
cacheRead: 4_000,
},
},
{
name: "uses positive compactionTokensAfter when final usage has no prompt total",
seed: {
totalTokens: 180_000,
totalTokensFresh: true,
inputTokens: 5_000,
outputTokens: 2_000,
cacheRead: 50_000,
contextBudgetStatus: {
schemaVersion: 1,
source: "pre-prompt-estimate",
updatedAt: 1,
provider: "claude-cli",
model: "claude-opus-4-7",
route: "compact_only",
shouldCompact: true,
estimatedPromptTokens: 180_000,
contextTokenBudget: 1_048_576,
promptBudgetBeforeReserve: 1_044_480,
reserveTokens: 4_096,
effectiveReserveTokens: 4_096,
remainingPromptBudgetTokens: 864_480,
overflowTokens: 0,
toolResultReducibleChars: 0,
messageCount: 0,
unwindowedMessageCount: 0,
},
},
update: {
usage: { output: 125 },
lastCallUsage: { output: 125 },
providerUsed: "claude-cli",
contextTokensUsed: undefined,
compactionTokensAfter: 80_000,
},
expected: {
totalTokens: 80_000,
totalTokensFresh: true,
inputTokens: undefined,
outputTokens: undefined,
cacheRead: undefined,
contextBudgetStatus: undefined,
},
},
{
name: "persists totalTokens from promptTokens when usage is unavailable",
seed: { inputTokens: 1_234, outputTokens: 456 },
update: { usage: undefined, promptTokens: 39_000 },
expected: {
totalTokens: 39_000,
totalTokensFresh: true,
inputTokens: 1_234,
outputTokens: 456,
},
},
{
name: "keeps non-clamped lastCallUsage totalTokens when exceeding context window",
seed: {},
update: {
usage: { input: 300_000, output: 10_000, total: 310_000 },
lastCallUsage: { input: 250_000, output: 5_000, total: 255_000 },
contextTokensUsed: 200_000,
},
expected: { totalTokens: 250_000, totalTokensFresh: true },
},
{
name: "uses lastCallUsage for totalTokens when provided",
seed: { totalTokens: 100_000 },
update: {
usage: { input: 180_000, output: 10_000, total: 190_000 },
lastCallUsage: { input: 12_000, output: 2_000, total: 14_000 },
},
expected: {
totalTokens: 12_000,
totalTokensFresh: true,
inputTokens: 180_000,
outputTokens: 10_000,
},
},
{
name: "keeps the prior total stale when last-call context is unavailable",
seed: { totalTokens: 148_874, totalTokensFresh: true },
update: {
usage: { input: 12, output: 15_104, cacheRead: 819_661, cacheWrite: 93_130 },
lastCallUsage: {
input: 12,
output: 15_104,
cacheRead: 819_661,
cacheWrite: 93_130,
contextUsage: { state: "unavailable" },
total: 927_907,
},
},
expected: {
totalTokens: 148_874,
totalTokensFresh: false,
inputTokens: 12,
cacheRead: 819_661,
},
},
{
name: "marks a fresh zero stale when a completed run has no context snapshot",
seed: { totalTokens: 0, totalTokensFresh: true },
update: {
modelUsed: "claude-sonnet-4-6",
preserveFreshTotalTokensOnStaleUsage: false,
},
expected: { totalTokens: 0, totalTokensFresh: false },
},
{
name: "preserves fresh post-compaction totalTokens across model-only updates",
seed: { totalTokens: 42_000, totalTokensFresh: true },
update: {
modelUsed: "claude-sonnet-4-6",
preserveFreshTotalTokensOnStaleUsage: true,
},
expected: { totalTokens: 42_000, totalTokensFresh: true },
},
{
name: "uses lastCallUsage cache counters when available",
seed: {},
@@ -4506,7 +4608,12 @@ describe("persistSessionUsageUpdate", () => {
update: { usage: { input: 50_000, output: 5_000, total: 55_000 }, promptTokens: 42_000 },
expected: { totalTokens: 42_000, totalTokensFresh: true },
},
])("$name", async ({ seed, update, expected, name }) => {
] satisfies Array<{
name: string;
seed: Partial<SessionEntry>;
update: Omit<Parameters<typeof persistSessionUsageUpdate>[0], "storePath" | "sessionKey">;
expected: Partial<SessionEntry>;
}>)("$name", async ({ seed, update, expected, name }) => {
const storePath = await createStorePath("openclaw-usage-");
const sessionKey = "main";
await seedSessionStore(storePath, sessionKey, {
@@ -4528,311 +4635,6 @@ describe("persistSessionUsageUpdate", () => {
name,
);
});
it("treats CLI usage as a fresh context snapshot when requested", async () => {
const storePath = await createStorePath("openclaw-usage-cli-");
const sessionKey = "main";
await seedSessionStore(storePath, sessionKey, { sessionId: "s1", updatedAt: Date.now() });
await persistSessionUsageUpdate({
storePath,
sessionKey,
usage: { input: 24_000, output: 2_000, cacheRead: 8_000 },
usageIsContextSnapshot: true,
providerUsed: "claude-cli",
cliSessionBinding: {
sessionId: "cli-session-1",
authProfileId: "anthropic:default",
extraSystemPromptHash: "prompt-hash",
mcpConfigHash: "mcp-hash",
},
contextTokensUsed: 200_000,
});
const stored = readSessionStoreFast(storePath);
expect(expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").totalTokens).toBe(
32_000,
);
expect(
expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").totalTokensFresh,
).toBe(true);
expect(
expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").cliSessionIds?.[
"claude-cli"
],
).toBe("cli-session-1");
expect(
expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").cliSessionBindings?.[
"claude-cli"
],
).toEqual({
sessionId: "cli-session-1",
authProfileId: "anthropic:default",
extraSystemPromptHash: "prompt-hash",
mcpConfigHash: "mcp-hash",
});
});
it("clears stale CLI binding when usage update reports an unflushed replacement", async () => {
const storePath = await createStorePath("openclaw-usage-cli-clear-");
const sessionKey = "main";
await seedSessionStore(storePath, sessionKey, {
sessionId: "s1",
updatedAt: Date.now(),
cliSessionIds: {
"claude-cli": "stale-cli-session",
"codex-cli": "codex-session",
},
cliSessionBindings: {
"claude-cli": {
sessionId: "stale-cli-session",
authProfileId: "anthropic:old",
},
"codex-cli": {
sessionId: "codex-session",
},
},
claudeCliSessionId: "stale-cli-session",
});
await persistSessionUsageUpdate({
storePath,
sessionKey,
usage: { input: 24_000, output: 2_000, cacheRead: 8_000 },
usageIsContextSnapshot: true,
providerUsed: "claude-cli",
clearCliSessionBinding: true,
contextTokensUsed: 200_000,
});
const stored = readSessionStoreFast(storePath);
expect(
expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").cliSessionIds?.[
"claude-cli"
],
).toBeUndefined();
expect(
expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").cliSessionIds?.[
"codex-cli"
],
).toBe("codex-session");
expect(
expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").cliSessionBindings?.[
"claude-cli"
],
).toBeUndefined();
expect(
expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").cliSessionBindings?.[
"codex-cli"
],
).toEqual({
sessionId: "codex-session",
});
expect(
expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").claudeCliSessionId,
).toBeUndefined();
});
it("prefers fresh final usage over zero compactionTokensAfter", async () => {
const storePath = await createStorePath("openclaw-usage-compaction-reset-");
const sessionKey = "main";
await seedSessionStore(storePath, sessionKey, {
sessionId: "s1",
updatedAt: Date.now(),
totalTokens: 1_794_391,
totalTokensFresh: true,
inputTokens: 20,
outputTokens: 10_855,
cacheRead: 1_761_324,
cacheWrite: 33_047,
});
await persistSessionUsageUpdate({
storePath,
sessionKey,
usage: { input: 20, output: 10_855, cacheRead: 1_761_324, cacheWrite: 33_047 },
lastCallUsage: { input: 20, output: 10_855, cacheRead: 1_761_324, cacheWrite: 33_047 },
usageIsContextSnapshot: true,
providerUsed: "claude-cli",
contextTokensUsed: 1_048_576,
compactionTokensAfter: 0,
});
const stored = readSessionStoreFast(storePath);
expect(expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").totalTokens).toBe(
1_794_391,
);
expect(
expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").totalTokensFresh,
).toBe(true);
expect(expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").inputTokens).toBe(
20,
);
expect(
expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").outputTokens,
).toBe(10_855);
expect(expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").cacheRead).toBe(
1_761_324,
);
expect(expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").cacheWrite).toBe(
33_047,
);
});
it("prefers fresh lastCallUsage over positive compactionTokensAfter", async () => {
const storePath = await createStorePath("openclaw-usage-compaction-positive-");
const sessionKey = "main";
await seedSessionStore(storePath, sessionKey, {
sessionId: "s1",
updatedAt: Date.now(),
totalTokens: 180_000,
totalTokensFresh: true,
});
await persistSessionUsageUpdate({
storePath,
sessionKey,
usage: { input: 100_000, output: 3_000, cacheRead: 20_000 },
lastCallUsage: { input: 91_000, output: 1_000, cacheRead: 4_000 },
providerUsed: "openai",
contextTokensUsed: 200_000,
compactionTokensAfter: 80_000,
});
const stored = readSessionStoreFast(storePath);
expect(expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").totalTokens).toBe(
95_000,
);
expect(
expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").totalTokensFresh,
).toBe(true);
expect(expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").inputTokens).toBe(
100_000,
);
expect(
expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").outputTokens,
).toBe(3_000);
expect(expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").cacheRead).toBe(
4_000,
);
});
it("uses positive compactionTokensAfter when final usage has no prompt total", async () => {
const storePath = await createStorePath("openclaw-usage-compaction-fallback-");
const sessionKey = "main";
await seedSessionStore(storePath, sessionKey, {
sessionId: "s1",
updatedAt: Date.now(),
totalTokens: 180_000,
totalTokensFresh: true,
inputTokens: 5_000,
outputTokens: 2_000,
cacheRead: 50_000,
contextBudgetStatus: {
schemaVersion: 1,
source: "pre-prompt-estimate",
updatedAt: 1,
provider: "claude-cli",
model: "claude-opus-4-7",
route: "compact_only",
shouldCompact: true,
estimatedPromptTokens: 180_000,
contextTokenBudget: 1_048_576,
promptBudgetBeforeReserve: 1_044_480,
reserveTokens: 4_096,
effectiveReserveTokens: 4_096,
remainingPromptBudgetTokens: 864_480,
overflowTokens: 0,
toolResultReducibleChars: 0,
messageCount: 0,
unwindowedMessageCount: 0,
},
});
await persistSessionUsageUpdate({
storePath,
sessionKey,
usage: { output: 125 },
lastCallUsage: { output: 125 },
providerUsed: "claude-cli",
compactionTokensAfter: 80_000,
});
const stored = readSessionStoreFast(storePath);
expect(expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").totalTokens).toBe(
80_000,
);
expect(
expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").totalTokensFresh,
).toBe(true);
expect(
expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").inputTokens,
).toBeUndefined();
expect(
expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").outputTokens,
).toBeUndefined();
expect(
expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").cacheRead,
).toBeUndefined();
expect(
expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").contextBudgetStatus,
).toBeUndefined();
});
it("persists totalTokens from promptTokens when usage is unavailable", async () => {
const storePath = await createStorePath("openclaw-usage-");
const sessionKey = "main";
await seedSessionStore(storePath, sessionKey, {
sessionId: "s1",
updatedAt: Date.now(),
inputTokens: 1_234,
outputTokens: 456,
});
await persistSessionUsageUpdate({
storePath,
sessionKey,
usage: undefined,
promptTokens: 39_000,
contextTokensUsed: 200_000,
});
const stored = readSessionStoreFast(storePath);
expect(expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").totalTokens).toBe(
39_000,
);
expect(
expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").totalTokensFresh,
).toBe(true);
expect(expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").inputTokens).toBe(
1_234,
);
expect(
expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").outputTokens,
).toBe(456);
});
it("keeps non-clamped lastCallUsage totalTokens when exceeding context window", async () => {
const storePath = await createStorePath("openclaw-usage-");
const sessionKey = "main";
await seedSessionStore(storePath, sessionKey, { sessionId: "s1", updatedAt: Date.now() });
await persistSessionUsageUpdate({
storePath,
sessionKey,
usage: { input: 300_000, output: 10_000, total: 310_000 },
lastCallUsage: { input: 250_000, output: 5_000, total: 255_000 },
contextTokensUsed: 200_000,
});
const stored = readSessionStoreFast(storePath);
expect(expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").totalTokens).toBe(
250_000,
);
expect(
expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").totalTokensFresh,
).toBe(true);
});
it("snapshots estimatedCostUsd instead of accumulating (fixes #69347)", async () => {
const storePath = await createStorePath("openclaw-usage-cost-");
const sessionKey = "main";

View File

@@ -263,22 +263,84 @@ async function prepareTelegramSessionBindingContract() {
await api.resetTelegramThreadBindingsForTests();
}
const sessionBindingContractEntries: Record<
SessionBindingContractChannelId,
Omit<SessionBindingContractEntry, "id">
> = {
discord: {
type SessionBindingContractFixture = {
id: SessionBindingContractChannelId;
accountId: string;
conversationId: string;
parentConversationId?: string;
targetSessionKey: string;
targetKind: SessionBindingRecord["targetKind"];
label: string;
placements: SessionBindingCapabilities["placements"];
preload: () => Promise<unknown>;
beforeEach: () => Promise<void>;
ensureManager: () => Promise<void>;
stopManager?: () => Promise<void>;
};
function createSessionBindingContractEntry(
fixture: SessionBindingContractFixture,
): Omit<SessionBindingContractEntry, "id"> {
const conversation = {
channel: fixture.id,
accountId: fixture.accountId,
conversationId: fixture.conversationId,
...(fixture.parentConversationId ? { parentConversationId: fixture.parentConversationId } : {}),
};
return {
preload: async () => {
await getContractApi<DiscordContractApi>("discord");
await fixture.preload();
},
beforeEach: fixture.beforeEach,
expectedCapabilities: {
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: fixture.placements,
},
getCapabilities: async () => {
await fixture.ensureManager();
return getSessionBindingService().getCapabilities({
channel: fixture.id,
accountId: fixture.accountId,
});
},
bindAndResolve: async () => {
await fixture.ensureManager();
const binding = await getSessionBindingService().bind({
targetSessionKey: fixture.targetSessionKey,
targetKind: fixture.targetKind,
conversation,
placement: "current",
metadata: { agentId: fixture.id, label: fixture.label },
});
expectResolvedSessionBinding({
...conversation,
targetSessionKey: fixture.targetSessionKey,
});
return binding;
},
unbindAndVerify: unbindAndExpectClearedSessionBinding,
cleanup: async () => {
await fixture.stopManager?.();
expectClearedSessionBinding(conversation);
},
};
}
const sessionBindingContractEntries = {
discord: createSessionBindingContractEntry({
id: "discord",
accountId: "default",
conversationId: "channel:123456789012345678",
targetSessionKey: "agent:discord:child:thread-1",
targetKind: "subagent",
label: "discord-child",
placements: ["current", "child"],
preload: () => getContractApi<DiscordContractApi>("discord"),
beforeEach: prepareDiscordSessionBindingContract,
expectedCapabilities: {
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current", "child"],
},
getCapabilities: async () => {
ensureManager: async () => {
const { createThreadBindingManager } = await getContractApi<DiscordContractApi>("discord");
createThreadBindingManager({
accountId: "default",
@@ -286,247 +348,80 @@ const sessionBindingContractEntries: Record<
persist: false,
enableSweeper: false,
});
return getSessionBindingService().getCapabilities({
channel: "discord",
accountId: "default",
});
},
bindAndResolve: async () => {
const { createThreadBindingManager } = await getContractApi<DiscordContractApi>("discord");
createThreadBindingManager({
accountId: "default",
cfg: baseSessionBindingCfg,
persist: false,
enableSweeper: false,
});
const service = getSessionBindingService();
const binding = await service.bind({
targetSessionKey: "agent:discord:child:thread-1",
targetKind: "subagent",
conversation: {
channel: "discord",
accountId: "default",
conversationId: "channel:123456789012345678",
},
placement: "current",
metadata: {
agentId: "discord",
label: "discord-child",
},
});
expectResolvedSessionBinding({
channel: "discord",
accountId: "default",
conversationId: "channel:123456789012345678",
targetSessionKey: "agent:discord:child:thread-1",
});
return binding;
},
unbindAndVerify: unbindAndExpectClearedSessionBinding,
cleanup: async () => {
expectClearedSessionBinding({
channel: "discord",
accountId: "default",
conversationId: "channel:123456789012345678",
});
},
},
feishu: {
preload: async () => {
await getContractApi<FeishuContractApi>("feishu");
},
}),
feishu: createSessionBindingContractEntry({
id: "feishu",
accountId: "default",
conversationId: "oc_group_chat:topic:om_topic_root",
parentConversationId: "oc_group_chat",
targetSessionKey: "agent:feishu:child:thread-1",
targetKind: "subagent",
label: "feishu-child",
placements: ["current"],
preload: () => getContractApi<FeishuContractApi>("feishu"),
beforeEach: prepareFeishuSessionBindingContract,
expectedCapabilities: {
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current"],
},
getCapabilities: async () => {
ensureManager: async () => {
const { createFeishuThreadBindingManager } =
await getContractApi<FeishuContractApi>("feishu");
createFeishuThreadBindingManager({
accountId: "default",
cfg: baseSessionBindingCfg,
});
return getSessionBindingService().getCapabilities({
channel: "feishu",
accountId: "default",
});
},
bindAndResolve: async () => {
const { createFeishuThreadBindingManager } =
await getContractApi<FeishuContractApi>("feishu");
createFeishuThreadBindingManager({
accountId: "default",
cfg: baseSessionBindingCfg,
});
const service = getSessionBindingService();
const binding = await service.bind({
targetSessionKey: "agent:feishu:child:thread-1",
targetKind: "subagent",
conversation: {
channel: "feishu",
accountId: "default",
conversationId: "oc_group_chat:topic:om_topic_root",
parentConversationId: "oc_group_chat",
},
placement: "current",
metadata: {
agentId: "feishu",
label: "feishu-child",
},
});
expectResolvedSessionBinding({
channel: "feishu",
accountId: "default",
conversationId: "oc_group_chat:topic:om_topic_root",
parentConversationId: "oc_group_chat",
targetSessionKey: "agent:feishu:child:thread-1",
});
return binding;
},
unbindAndVerify: unbindAndExpectClearedSessionBinding,
cleanup: async () => {
expectClearedSessionBinding({
channel: "feishu",
accountId: "default",
conversationId: "oc_group_chat:topic:om_topic_root",
});
},
},
imessage: {
preload: async () => {
await getContractApi<IMessageContractApi>("imessage");
},
}),
imessage: createSessionBindingContractEntry({
id: "imessage",
accountId: "default",
conversationId: "+15555550124",
targetSessionKey: "agent:imessage:current",
targetKind: "session",
label: "imessage-main",
placements: ["current"],
preload: () => getContractApi<IMessageContractApi>("imessage"),
beforeEach: prepareIMessageSessionBindingContract,
expectedCapabilities: {
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current"],
},
getCapabilities: () => {
void createContractChannelConversationBindingManager({
channelId: "imessage",
cfg: baseSessionBindingCfg,
accountId: "default",
});
return getSessionBindingService().getCapabilities({
channel: "imessage",
accountId: "default",
});
},
bindAndResolve: async () => {
ensureManager: async () => {
await createContractChannelConversationBindingManager({
channelId: "imessage",
cfg: baseSessionBindingCfg,
accountId: "default",
});
const service = getSessionBindingService();
const binding = await service.bind({
targetSessionKey: "agent:imessage:current",
targetKind: "session",
conversation: {
channel: "imessage",
accountId: "default",
conversationId: "+15555550124",
},
placement: "current",
metadata: {
agentId: "imessage",
label: "imessage-main",
},
});
expectResolvedSessionBinding({
channel: "imessage",
accountId: "default",
conversationId: "+15555550124",
targetSessionKey: "agent:imessage:current",
});
return binding;
},
unbindAndVerify: unbindAndExpectClearedSessionBinding,
cleanup: async () => {
stopManager: async () => {
const manager = await createContractChannelConversationBindingManager({
channelId: "imessage",
cfg: baseSessionBindingCfg,
accountId: "default",
});
await manager?.stop();
expectClearedSessionBinding({
channel: "imessage",
accountId: "default",
conversationId: "+15555550124",
});
},
},
matrix: {
preload: async () => {
await getContractApi<MatrixContractApi>("matrix");
},
}),
matrix: createSessionBindingContractEntry({
id: "matrix",
accountId: matrixSessionBindingAuth.accountId,
conversationId: "$thread",
parentConversationId: "!room:example.org",
targetSessionKey: "agent:matrix:thread",
targetKind: "subagent",
label: "matrix-thread",
placements: ["current", "child"],
preload: () => getContractApi<MatrixContractApi>("matrix"),
beforeEach: prepareMatrixSessionBindingContract,
expectedCapabilities: {
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current", "child"],
},
getCapabilities: async () => {
ensureManager: async () => {
await createContractMatrixThreadBindingManager();
return getSessionBindingService().getCapabilities({
channel: "matrix",
accountId: matrixSessionBindingAuth.accountId,
});
},
bindAndResolve: async () => {
await createContractMatrixThreadBindingManager();
const service = getSessionBindingService();
const binding = await service.bind({
targetSessionKey: "agent:matrix:thread",
targetKind: "subagent",
conversation: {
channel: "matrix",
accountId: matrixSessionBindingAuth.accountId,
conversationId: "$thread",
parentConversationId: "!room:example.org",
},
placement: "current",
metadata: {
agentId: "matrix",
label: "matrix-thread",
},
});
expectResolvedSessionBinding({
channel: "matrix",
accountId: matrixSessionBindingAuth.accountId,
conversationId: "$thread",
parentConversationId: "!room:example.org",
targetSessionKey: "agent:matrix:thread",
});
return binding;
},
unbindAndVerify: unbindAndExpectClearedSessionBinding,
cleanup: async () => {
expectClearedSessionBinding({
channel: "matrix",
accountId: matrixSessionBindingAuth.accountId,
conversationId: "$thread",
});
},
},
telegram: {
preload: async () => {
await getContractApi<TelegramContractApi>("telegram");
},
}),
telegram: createSessionBindingContractEntry({
id: "telegram",
accountId: "default",
conversationId: "-100200300:topic:77",
targetSessionKey: "agent:telegram:child:thread-1",
targetKind: "subagent",
label: "telegram-topic",
placements: ["current", "child"],
preload: () => getContractApi<TelegramContractApi>("telegram"),
beforeEach: prepareTelegramSessionBindingContract,
expectedCapabilities: {
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current", "child"],
},
getCapabilities: async () => {
ensureManager: async () => {
const { createTelegramThreadBindingManager } =
await getContractApi<TelegramContractApi>("telegram");
createTelegramThreadBindingManager({
@@ -534,52 +429,9 @@ const sessionBindingContractEntries: Record<
persist: false,
enableSweeper: false,
});
return getSessionBindingService().getCapabilities({
channel: "telegram",
accountId: "default",
});
},
bindAndResolve: async () => {
const { createTelegramThreadBindingManager } =
await getContractApi<TelegramContractApi>("telegram");
createTelegramThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
});
const service = getSessionBindingService();
const binding = await service.bind({
targetSessionKey: "agent:telegram:child:thread-1",
targetKind: "subagent",
conversation: {
channel: "telegram",
accountId: "default",
conversationId: "-100200300:topic:77",
},
placement: "current",
metadata: {
agentId: "telegram",
label: "telegram-topic",
},
});
expectResolvedSessionBinding({
channel: "telegram",
accountId: "default",
conversationId: "-100200300:topic:77",
targetSessionKey: "agent:telegram:child:thread-1",
});
return binding;
},
unbindAndVerify: unbindAndExpectClearedSessionBinding,
cleanup: async () => {
expectClearedSessionBinding({
channel: "telegram",
accountId: "default",
conversationId: "-100200300:topic:77",
});
},
},
};
}),
} satisfies Record<SessionBindingContractChannelId, Omit<SessionBindingContractEntry, "id">>;
let sessionBindingContractRegistryCache: SessionBindingContractEntry[] | undefined;

View File

@@ -513,6 +513,36 @@ describe("update-cli", () => {
legacyIssues: [],
};
const clawHubRiskWarning =
"╭─ WARNING - ClawHub found security risks in this release ─╮\n" +
"│ • Security scan: suspicious │\n" +
"╰───────────────────────────────────────────────────────────────────────╯";
const clawHubSuspiciousPayloadWarning =
"╭─ WARNING - ClawHub found security risks in this release ─╮\n" +
"│ • Security scan: suspicious │\n" +
"│ • Finding: suspicious payload strings │\n" +
"╰───────────────────────────────────────────────────────────────────────╯";
const clawHubSyncRiskError =
"Failed to update demo: Update cancelled; rerun with --acknowledge-clawhub-risk to continue after reviewing the warning. (ClawHub clawhub:demo@1.2.4).";
const createClawHubRiskRequest = (
overrides: Partial<ClawHubRiskAcknowledgementRequest> = {},
): ClawHubRiskAcknowledgementRequest => ({
packageName: "demo",
version: "1.2.3",
trust: {
scanStatus: "suspicious",
moderationState: null,
blockedFromDownload: false,
reasons: ["payload_strings"],
pending: false,
stale: false,
},
acknowledgementKind: "confirm",
warning: clawHubRiskWarning,
...overrides,
});
const setTty = (value: boolean | undefined) => {
Object.defineProperty(process.stdin, "isTTY", {
value,
@@ -952,7 +982,11 @@ describe("update-cli", () => {
});
};
const pluginSyncResult = (config: OpenClawConfig, changed = false) => ({
const pluginSyncResult = (
config: OpenClawConfig,
changed = false,
overrides: { warnings?: string[]; errors?: string[] } = {},
) => ({
changed,
config,
summary: {
@@ -961,6 +995,7 @@ describe("update-cli", () => {
switchedToNpm: [],
warnings: [],
errors: [],
...overrides,
},
});
@@ -1306,22 +1341,8 @@ describe("update-cli", () => {
formatPortDiagnostics.mockReturnValue(["Port 18789 is already in use."]);
mockGatewayProbe("1.0.0", "conn-test");
pathExists.mockResolvedValue(false);
syncPluginsForUpdateChannel.mockResolvedValue({
changed: false,
config: baseConfig,
summary: {
switchedToBundled: [],
switchedToClawHub: [],
switchedToNpm: [],
warnings: [],
errors: [],
},
});
updateNpmInstalledPlugins.mockResolvedValue({
changed: false,
config: baseConfig,
outcomes: [],
});
syncPluginsForUpdateChannel.mockResolvedValue(pluginSyncResult(baseConfig));
updateNpmInstalledPlugins.mockResolvedValue(npmPluginUpdateResult(baseConfig));
checkShellCompletionStatus.mockResolvedValue({
shell: "zsh",
profileInstalled: false,
@@ -2393,10 +2414,7 @@ describe("update-cli", () => {
vi.mocked(resolveGatewayInstallEntrypoint).mockResolvedValueOnce(
"/tmp/openclaw-updated-entry.mjs",
);
const trustWarning =
"╭─ WARNING - ClawHub found security risks in this release ─╮\n" +
"│ • Security scan: suspicious │\n" +
"╰───────────────────────────────────────────────────────────────────────╯";
const trustWarning = clawHubRiskWarning;
const coloredTrustWarning = `\u001b[33m${trustWarning}\u001b[39m`;
updateNpmInstalledPlugins.mockImplementationOnce(
async (params: {
@@ -2432,24 +2450,13 @@ describe("update-cli", () => {
});
it("includes failed ClawHub sync trust warnings in json post-core plugin output", async () => {
const trustWarning =
"╭─ WARNING - ClawHub found security risks in this release ─╮\n" +
"│ • Security scan: suspicious │\n" +
"│ • Finding: suspicious payload strings │\n" +
"╰───────────────────────────────────────────────────────────────────────╯";
syncPluginsForUpdateChannel.mockResolvedValueOnce({
changed: false,
config: baseConfig,
summary: {
switchedToBundled: [],
switchedToClawHub: [],
switchedToNpm: [],
const trustWarning = clawHubSuspiciousPayloadWarning;
syncPluginsForUpdateChannel.mockResolvedValueOnce(
pluginSyncResult(baseConfig, false, {
warnings: [trustWarning],
errors: [
"Failed to update demo: Update cancelled; rerun with --acknowledge-clawhub-risk to continue after reviewing the warning. (ClawHub clawhub:demo@1.2.4).",
],
},
});
errors: [clawHubSyncRiskError],
}),
);
vi.mocked(defaultRuntime.writeJson).mockClear();
await updateCommand({ json: true, restart: false });
@@ -2457,33 +2464,18 @@ describe("update-cli", () => {
const jsonOutput = lastWriteJsonCall() as UpdateRunResult | undefined;
expect(jsonOutput?.postUpdate?.plugins?.status).toBe("warning");
expect(jsonOutput?.postUpdate?.plugins?.sync.warnings).toEqual([trustWarning]);
expect(jsonOutput?.postUpdate?.plugins?.sync.errors).toEqual([
"Failed to update demo: Update cancelled; rerun with --acknowledge-clawhub-risk to continue after reviewing the warning. (ClawHub clawhub:demo@1.2.4).",
]);
expect(jsonOutput?.postUpdate?.plugins?.sync.errors).toEqual([clawHubSyncRiskError]);
});
it("does not print duplicate failed ClawHub sync trust warnings in human post-core output", async () => {
const trustWarning =
"╭─ WARNING - ClawHub found security risks in this release ─╮\n" +
"│ • Security scan: suspicious │\n" +
"│ • Finding: suspicious payload strings │\n" +
"╰───────────────────────────────────────────────────────────────────────╯";
const trustWarning = clawHubSuspiciousPayloadWarning;
syncPluginsForUpdateChannel.mockImplementationOnce(
async (params: { config: OpenClawConfig; logger?: { warn?: (message: string) => void } }) => {
params.logger?.warn?.(trustWarning);
return {
changed: false,
config: params.config,
summary: {
switchedToBundled: [],
switchedToClawHub: [],
switchedToNpm: [],
warnings: [trustWarning],
errors: [
"Failed to update demo: Update cancelled; rerun with --acknowledge-clawhub-risk to continue after reviewing the warning. (ClawHub clawhub:demo@1.2.4).",
],
},
};
return pluginSyncResult(params.config, false, {
warnings: [trustWarning],
errors: [clawHubSyncRiskError],
});
},
);
@@ -2494,11 +2486,7 @@ describe("update-cli", () => {
});
it("does not print duplicate ClawHub update trust warnings in human post-core output", async () => {
const trustWarning =
"╭─ WARNING - ClawHub found security risks in this release ─╮\n" +
"│ • Security scan: suspicious │\n" +
"│ • Finding: suspicious payload strings │\n" +
"╰───────────────────────────────────────────────────────────────────────╯";
const trustWarning = clawHubSuspiciousPayloadWarning;
updateNpmInstalledPlugins.mockImplementationOnce(
async (params: { config: OpenClawConfig; logger?: { warn?: (message: string) => void } }) => {
params.logger?.warn?.(trustWarning);
@@ -2547,17 +2535,7 @@ describe("update-cli", () => {
installPath,
},
});
syncPluginsForUpdateChannel.mockResolvedValueOnce({
changed: false,
config,
summary: {
switchedToBundled: [],
switchedToClawHub: [],
switchedToNpm: [],
warnings: [],
errors: [],
},
});
syncPluginsForUpdateChannel.mockResolvedValueOnce(pluginSyncResult(config));
pathExists.mockImplementation(async (candidate: string) => candidate === installPath);
vi.mocked(defaultRuntime.writeJson).mockClear();
@@ -2630,11 +2608,7 @@ describe("update-cli", () => {
});
it("marks unacknowledged ClawHub risk skips as post-update warnings", async () => {
const trustWarning =
"╭─ WARNING - ClawHub found security risks in this release ─╮\n" +
"│ • Security scan: suspicious │\n" +
"│ • Finding: suspicious payload strings │\n" +
"╰───────────────────────────────────────────────────────────────────────╯";
const trustWarning = clawHubSuspiciousPayloadWarning;
updateNpmInstalledPlugins.mockResolvedValueOnce({
changed: false,
config: baseConfig,
@@ -5508,23 +5482,18 @@ describe("update-cli", () => {
config: postDoctorConfig,
hash: "post-doctor-hash",
});
syncPluginsForUpdateChannel.mockImplementation(async ({ config }) => ({
changed: true,
config: {
...config,
plugins: {
...config.plugins,
load: { paths: ["/tmp/openclaw-updated-plugin"] },
syncPluginsForUpdateChannel.mockImplementation(async ({ config }) =>
pluginSyncResult(
{
...config,
plugins: {
...config.plugins,
load: { paths: ["/tmp/openclaw-updated-plugin"] },
},
},
},
summary: {
switchedToBundled: [],
switchedToClawHub: [],
switchedToNpm: [],
warnings: [],
errors: [],
},
}));
true,
),
);
updateNpmInstalledPlugins.mockImplementation(async ({ config }) =>
npmPluginUpdateResult(config),
);
@@ -5911,22 +5880,8 @@ describe("update-cli", () => {
},
} as OpenClawConfig,
});
syncPluginsForUpdateChannel.mockResolvedValue({
changed: false,
config: sourceConfig,
summary: {
switchedToBundled: [],
switchedToClawHub: [],
switchedToNpm: [],
warnings: [],
errors: [],
},
});
updateNpmInstalledPlugins.mockResolvedValue({
changed: false,
config: sourceConfig,
outcomes: [],
});
syncPluginsForUpdateChannel.mockResolvedValue(pluginSyncResult(sourceConfig));
updateNpmInstalledPlugins.mockResolvedValue(npmPluginUpdateResult(sourceConfig));
await updateCommand({ channel: "beta", yes: true });
@@ -5995,20 +5950,13 @@ describe("update-cli", () => {
confirm.mockClear();
confirm.mockResolvedValueOnce(true);
await syncCall.onClawHubRisk({
packageName: "demo\npkg",
version: "1.2.3\u001b[2K",
trust: {
scanStatus: "suspicious",
moderationState: null,
blockedFromDownload: false,
reasons: ["payload_strings"],
pending: false,
stale: false,
},
acknowledgementKind: "confirm",
warning: "warning",
});
await syncCall.onClawHubRisk(
createClawHubRiskRequest({
packageName: "demo\npkg",
version: "1.2.3\u001b[2K",
warning: "warning",
}),
);
const message = getConfirmMessage();
expect(message).toContain("Update ClawHub package");
@@ -6018,10 +5966,7 @@ describe("update-cli", () => {
});
it("prints ClawHub risk warnings before interactive post-update acknowledgement prompts", async () => {
const warning =
"╭─ WARNING - ClawHub found security risks in this release ─╮\n" +
"│ • Security scan: suspicious │\n" +
"╰───────────────────────────────────────────────────────────────────────╯";
const warning = clawHubRiskWarning;
const syncCall = await setupInteractiveClawHubRisk();
confirm.mockImplementationOnce(async () => {
@@ -6029,27 +5974,11 @@ describe("update-cli", () => {
expect(logs.some((line) => line.includes(warning))).toBe(true);
return true;
});
await syncCall.onClawHubRisk({
packageName: "demo",
version: "1.2.3",
trust: {
scanStatus: "suspicious",
moderationState: null,
blockedFromDownload: false,
reasons: ["payload_strings"],
pending: false,
stale: false,
},
acknowledgementKind: "confirm",
warning,
});
await syncCall.onClawHubRisk(createClawHubRiskRequest({ warning }));
});
it("does not duplicate ClawHub risk warnings already printed before prompts", async () => {
const warning =
"╭─ WARNING - ClawHub found security risks in this release ─╮\n" +
"│ • Security scan: suspicious │\n" +
"╰───────────────────────────────────────────────────────────────────────╯";
const warning = clawHubRiskWarning;
const syncCall = await setupInteractiveClawHubRisk();
const logger = syncCall.logger;
if (
@@ -6064,20 +5993,7 @@ describe("update-cli", () => {
logger.warn(`\u001b[33m${warning}\u001b[39m`);
confirm.mockResolvedValueOnce(true);
await syncCall.onClawHubRisk({
packageName: "demo",
version: "1.2.3",
trust: {
scanStatus: "suspicious",
moderationState: null,
blockedFromDownload: false,
reasons: ["payload_strings"],
pending: false,
stale: false,
},
acknowledgementKind: "confirm",
warning,
});
await syncCall.onClawHubRisk(createClawHubRiskRequest({ warning }));
const output = getLogOutput();
const occurrences = output.split(warning).length - 1;
@@ -6777,22 +6693,14 @@ describe("update-cli", () => {
update: { channel: "beta" },
plugins: { entries: { post: { enabled: true } } },
} as OpenClawConfig;
const preDoctorSnapshot: ConfigFileSnapshot = {
...baseSnapshot,
sourceConfig: preDoctorConfig,
resolved: preDoctorConfig,
runtimeConfig: preDoctorConfig,
config: preDoctorConfig,
const preDoctorSnapshot = configSnapshot(preDoctorConfig, {
parsed: baseSnapshot.parsed,
hash: "pre-doctor",
};
const postDoctorSnapshot: ConfigFileSnapshot = {
...baseSnapshot,
sourceConfig: postDoctorConfig,
resolved: postDoctorConfig,
runtimeConfig: postDoctorConfig,
config: postDoctorConfig,
});
const postDoctorSnapshot = configSnapshot(postDoctorConfig, {
parsed: baseSnapshot.parsed,
hash: "post-doctor",
};
});
const postDoctorRecords = {
"post-plugin": {
source: "npm",
@@ -6805,17 +6713,8 @@ describe("update-cli", () => {
.mockResolvedValueOnce(postDoctorSnapshot);
loadInstalledPluginIndexInstallRecords.mockResolvedValueOnce(postDoctorRecords);
syncPluginsForUpdateChannel.mockImplementationOnce(
async (params: { config?: OpenClawConfig }) => ({
changed: true,
config: params.config ?? baseConfig,
summary: {
switchedToBundled: [],
switchedToClawHub: [],
switchedToNpm: [],
warnings: [],
errors: [],
},
}),
async (params: { config?: OpenClawConfig }) =>
pluginSyncResult(params.config ?? baseConfig, true),
);
await updateFinalizeCommand({ json: true, timeout: "9", restart: false });
@@ -6874,14 +6773,10 @@ describe("update-cli", () => {
const postDoctorConfig = {
meta: { lastTouchedVersion: "2026.6.18" },
} as OpenClawConfig;
const postDoctorSnapshot: ConfigFileSnapshot = {
...baseSnapshot,
sourceConfig: postDoctorConfig,
resolved: postDoctorConfig,
runtimeConfig: postDoctorConfig,
config: postDoctorConfig,
const postDoctorSnapshot = configSnapshot(postDoctorConfig, {
parsed: baseSnapshot.parsed,
hash: "post-doctor",
};
});
await fs.mkdir(tempDir, { recursive: true });
await fs.writeFile(
sourceConfigPath,
@@ -6913,22 +6808,14 @@ describe("update-cli", () => {
it("updateFinalizeCommand reapplies requested channel against post-doctor config", async () => {
const preDoctorConfig = { update: { channel: "stable" } } as OpenClawConfig;
const postDoctorConfig = { update: { channel: "beta" } } as OpenClawConfig;
const preDoctorSnapshot: ConfigFileSnapshot = {
...baseSnapshot,
sourceConfig: preDoctorConfig,
resolved: preDoctorConfig,
runtimeConfig: preDoctorConfig,
config: preDoctorConfig,
const preDoctorSnapshot = configSnapshot(preDoctorConfig, {
parsed: baseSnapshot.parsed,
hash: "pre-doctor",
};
const postDoctorSnapshot: ConfigFileSnapshot = {
...baseSnapshot,
sourceConfig: postDoctorConfig,
resolved: postDoctorConfig,
runtimeConfig: postDoctorConfig,
config: postDoctorConfig,
});
const postDoctorSnapshot = configSnapshot(postDoctorConfig, {
parsed: baseSnapshot.parsed,
hash: "post-doctor",
};
});
vi.mocked(readConfigFileSnapshot)
.mockResolvedValueOnce(preDoctorSnapshot)
.mockResolvedValueOnce(preDoctorSnapshot)
@@ -6947,14 +6834,10 @@ describe("update-cli", () => {
it("updateFinalizeCommand converges on the effective channel from env without persisting update.channel", async () => {
const noChannelConfig = {} as OpenClawConfig;
const noChannelSnapshot: ConfigFileSnapshot = {
...baseSnapshot,
sourceConfig: noChannelConfig,
resolved: noChannelConfig,
runtimeConfig: noChannelConfig,
config: noChannelConfig,
const noChannelSnapshot = configSnapshot(noChannelConfig, {
parsed: baseSnapshot.parsed,
hash: "no-channel",
};
});
vi.mocked(readConfigFileSnapshot).mockResolvedValue(noChannelSnapshot);
const priorEffective = process.env.OPENCLAW_UPDATE_EFFECTIVE_CHANNEL;
// Simulate a no-config git/source update whose effective channel is dev.

View File

@@ -6,10 +6,7 @@ import {
type LegacyConfigMigrationSpec,
type LegacyConfigRule,
} from "../../../config/legacy.shared.js";
function hasOwnKey(target: Record<string, unknown>, key: string): boolean {
return Object.hasOwn(target, key);
}
import { hasOwnKey, visitChannelEntries } from "./legacy-config-record-shared.js";
function cleanupEmptyRecord(parent: Record<string, unknown>, key: string): void {
const value = getRecord(parent[key]);
@@ -257,31 +254,13 @@ function hasLegacyThreadBindingSpawnSplit(value: unknown): boolean {
);
}
function hasLegacyThreadBindingTtlInAccounts(value: unknown): boolean {
const accounts = getRecord(value);
if (!accounts) {
return false;
}
return Object.values(accounts).some((entry) =>
hasLegacyThreadBindingTtl(getRecord(entry)?.threadBindings),
);
}
function hasLegacyThreadBindingSpawnSplitInAccounts(value: unknown): boolean {
const accounts = getRecord(value);
if (!accounts) {
return false;
}
return Object.values(accounts).some((entry) =>
hasLegacyThreadBindingSpawnSplit(getRecord(entry)?.threadBindings),
);
}
function migrateThreadBindingsTtlHoursForPath(params: {
type ThreadBindingMigrationParams = {
owner: Record<string, unknown>;
pathPrefix: string;
changes: string[];
}): boolean {
};
function migrateThreadBindingsTtlHoursForPath(params: ThreadBindingMigrationParams): boolean {
const threadBindings = getRecord(params.owner.threadBindings);
if (!threadBindings || !hasOwnKey(threadBindings, "ttlHours")) {
return false;
@@ -322,11 +301,7 @@ function resolveMigratedSpawnSessions(
return subagentBool && acpBool;
}
function migrateThreadBindingsSpawnSessionsForPath(params: {
owner: Record<string, unknown>;
pathPrefix: string;
changes: string[];
}): boolean {
function migrateThreadBindingsSpawnSessionsForPath(params: ThreadBindingMigrationParams): boolean {
const threadBindings = getRecord(params.owner.threadBindings);
if (!threadBindings || !hasLegacyThreadBindingSpawnSplit(threadBindings)) {
return false;
@@ -363,24 +338,15 @@ function migrateThreadBindingsSpawnSessionsForPath(params: {
return true;
}
function hasLegacyThreadBindingTtlInAnyChannel(value: unknown): boolean {
const channels = getRecord(value);
if (!channels) {
return false;
}
return Object.values(channels).some((entry) => {
const channel = getRecord(entry);
if (!channel) {
return false;
}
return (
hasLegacyThreadBindingTtl(channel.threadBindings) ||
hasLegacyThreadBindingTtlInAccounts(channel.accounts)
);
});
function migrateThreadBindingsForPath(params: ThreadBindingMigrationParams): void {
migrateThreadBindingsTtlHoursForPath(params);
migrateThreadBindingsSpawnSessionsForPath(params);
}
function hasLegacyThreadBindingSpawnSplitInAnyChannel(value: unknown): boolean {
function hasLegacyThreadBindingInAnyChannel(
value: unknown,
matcher: (value: unknown) => boolean,
): boolean {
const channels = getRecord(value);
if (!channels) {
return false;
@@ -391,8 +357,10 @@ function hasLegacyThreadBindingSpawnSplitInAnyChannel(value: unknown): boolean {
return false;
}
return (
hasLegacyThreadBindingSpawnSplit(channel.threadBindings) ||
hasLegacyThreadBindingSpawnSplitInAccounts(channel.accounts)
matcher(channel.threadBindings) ||
Object.values(getRecord(channel.accounts) ?? {}).some((account) =>
matcher(getRecord(account)?.threadBindings),
)
);
});
}
@@ -408,7 +376,7 @@ const THREAD_BINDING_RULES: LegacyConfigRule[] = [
path: ["channels"],
message:
'channels.<id>.threadBindings.ttlHours was renamed to channels.<id>.threadBindings.idleHours. Run "openclaw doctor --fix".',
match: (value) => hasLegacyThreadBindingTtlInAnyChannel(value),
match: (value) => hasLegacyThreadBindingInAnyChannel(value, hasLegacyThreadBindingTtl),
},
{
path: ["session", "threadBindings"],
@@ -420,7 +388,7 @@ const THREAD_BINDING_RULES: LegacyConfigRule[] = [
path: ["channels"],
message:
'channels.<id>.threadBindings.spawnSubagentSessions/spawnAcpSessions were replaced by channels.<id>.threadBindings.spawnSessions. Run "openclaw doctor --fix".',
match: (value) => hasLegacyThreadBindingSpawnSplitInAnyChannel(value),
match: (value) => hasLegacyThreadBindingInAnyChannel(value, hasLegacyThreadBindingSpawnSplit),
},
];
@@ -486,9 +454,7 @@ export const LEGACY_CONFIG_MIGRATIONS_CHANNELS: LegacyConfigMigrationSpec[] = [
id: "channels.webchat-remove",
describe: "Remove retired WebChat channel config",
legacyRules: WEBCHAT_CHANNEL_RULES,
apply: (raw, changes) => {
migrateRetiredWebchatChannelConfig(raw, changes);
},
apply: migrateRetiredWebchatChannelConfig,
}),
defineLegacyConfigMigration({
id: "legacy-group-routing->channel-groups",
@@ -505,9 +471,7 @@ export const LEGACY_CONFIG_MIGRATIONS_CHANNELS: LegacyConfigMigrationSpec[] = [
id: "feishu.accounts.botName->name",
describe: "Move legacy Feishu account botName config to account name",
legacyRules: FEISHU_ACCOUNT_RULES,
apply: (raw, changes) => {
migrateFeishuAccountBotName(raw, changes);
},
apply: migrateFeishuAccountBotName,
}),
defineLegacyConfigMigration({
id: "thread-bindings.ttlHours->idleHours",
@@ -517,17 +481,7 @@ export const LEGACY_CONFIG_MIGRATIONS_CHANNELS: LegacyConfigMigrationSpec[] = [
apply: (raw, changes) => {
const session = getRecord(raw.session);
if (session) {
migrateThreadBindingsTtlHoursForPath({
owner: session,
pathPrefix: "session",
changes,
});
migrateThreadBindingsSpawnSessionsForPath({
owner: session,
pathPrefix: "session",
changes,
});
raw.session = session;
migrateThreadBindingsForPath({ owner: session, pathPrefix: "session", changes });
}
const channels = getRecord(raw.channels);
@@ -535,46 +489,11 @@ export const LEGACY_CONFIG_MIGRATIONS_CHANNELS: LegacyConfigMigrationSpec[] = [
return;
}
for (const [channelId, channelRaw] of Object.entries(channels)) {
const channel = getRecord(channelRaw);
if (!channel) {
continue;
}
migrateThreadBindingsTtlHoursForPath({
owner: channel,
pathPrefix: `channels.${channelId}`,
changes,
for (const channelId of Object.keys(channels)) {
visitChannelEntries(raw, channelId, (owner, pathPrefix) => {
migrateThreadBindingsForPath({ owner, pathPrefix, changes });
});
migrateThreadBindingsSpawnSessionsForPath({
owner: channel,
pathPrefix: `channels.${channelId}`,
changes,
});
const accounts = getRecord(channel.accounts);
if (accounts) {
for (const [accountId, accountRaw] of Object.entries(accounts)) {
const account = getRecord(accountRaw);
if (!account) {
continue;
}
migrateThreadBindingsTtlHoursForPath({
owner: account,
pathPrefix: `channels.${channelId}.accounts.${accountId}`,
changes,
});
migrateThreadBindingsSpawnSessionsForPath({
owner: account,
pathPrefix: `channels.${channelId}.accounts.${accountId}`,
changes,
});
accounts[accountId] = account;
}
channel.accounts = accounts;
}
channels[channelId] = channel;
}
raw.channels = channels;
},
}),
];

View File

@@ -31,10 +31,6 @@ function hasLegacyTtsProviderKeys(value: unknown): boolean {
return Boolean(providers && Object.hasOwn(providers, "edge"));
}
function hasLegacyPluginEntryTtsProviderKeys(value: unknown): boolean {
return hasLegacyTtsInPluginLocations(value, hasLegacyTtsProviderKeys);
}
function hasLegacyTtsEnabled(value: unknown): boolean {
return typeof getRecord(value)?.enabled === "boolean";
}
@@ -158,30 +154,6 @@ function hasLegacyTtsInPluginLocations(value: unknown, matcher: LegacyTtsMatcher
});
}
function hasLegacyTtsSpeakerSelectionInAgentLocations(value: unknown): boolean {
return hasLegacyTtsInAgentLocations(value, hasLegacyTtsSpeakerSelection);
}
function hasLegacyTtsSpeakerSelectionInChannelLocations(value: unknown): boolean {
return hasLegacyTtsInChannelLocations(value, hasLegacyTtsSpeakerSelection);
}
function hasLegacyTtsSpeakerSelectionInPluginLocations(value: unknown): boolean {
return hasLegacyTtsInPluginLocations(value, hasLegacyTtsSpeakerSelection);
}
function hasLegacyTtsEnabledInAgentLocations(value: unknown): boolean {
return hasLegacyTtsInAgentLocations(value, hasLegacyTtsEnabled);
}
function hasLegacyTtsEnabledInChannelLocations(value: unknown): boolean {
return hasLegacyTtsInChannelLocations(value, hasLegacyTtsEnabled);
}
function hasLegacyTtsEnabledInPluginLocations(value: unknown): boolean {
return hasLegacyTtsInPluginLocations(value, hasLegacyTtsEnabled);
}
function getOrCreateTtsProviders(tts: Record<string, unknown>): Record<string, unknown> {
const providers = getRecord(tts.providers) ?? {};
tts.providers = providers;
@@ -192,35 +164,19 @@ function mergeLegacyTtsProviderConfig(
tts: Record<string, unknown>,
legacyKey: string,
providerId: string,
source: "tts" | "providers" = "tts",
): boolean {
const legacyValue = getRecord(tts[legacyKey]);
if (!legacyValue) {
const legacyOwner = source === "providers" ? getRecord(tts.providers) : tts;
const legacyValue = getRecord(legacyOwner?.[legacyKey]);
if (!legacyOwner || !legacyValue) {
return false;
}
const providers = getOrCreateTtsProviders(tts);
const providers = source === "providers" ? legacyOwner : getOrCreateTtsProviders(tts);
const existing = getRecord(providers[providerId]) ?? {};
const merged = structuredClone(existing);
mergeMissing(merged, legacyValue);
providers[providerId] = merged;
delete tts[legacyKey];
return true;
}
function mergeLegacyTtsProviderAliasConfig(
tts: Record<string, unknown>,
aliasKey: string,
providerId: string,
): boolean {
const providers = getRecord(tts.providers);
const aliasValue = getRecord(providers?.[aliasKey]);
if (!providers || !aliasValue) {
return false;
}
const existing = getRecord(providers[providerId]) ?? {};
const merged = structuredClone(existing);
mergeMissing(merged, aliasValue);
providers[providerId] = merged;
delete providers[aliasKey];
delete legacyOwner[legacyKey];
return true;
}
@@ -236,26 +192,19 @@ function migrateLegacyTtsConfig(
tts.provider = "microsoft";
changes.push(`Moved ${pathLabel}.provider "edge" → "microsoft".`);
}
const movedOpenAI = mergeLegacyTtsProviderConfig(tts, "openai", "openai");
const movedElevenLabs = mergeLegacyTtsProviderConfig(tts, "elevenlabs", "elevenlabs");
const movedMicrosoft = mergeLegacyTtsProviderConfig(tts, "microsoft", "microsoft");
const movedProviderEdge = mergeLegacyTtsProviderAliasConfig(tts, "edge", "microsoft");
const movedEdge = mergeLegacyTtsProviderConfig(tts, "edge", "microsoft");
if (movedOpenAI) {
changes.push(`Moved ${pathLabel}.openai → ${pathLabel}.providers.openai.`);
}
if (movedElevenLabs) {
changes.push(`Moved ${pathLabel}.elevenlabs → ${pathLabel}.providers.elevenlabs.`);
}
if (movedMicrosoft) {
changes.push(`Moved ${pathLabel}.microsoft → ${pathLabel}.providers.microsoft.`);
}
if (movedProviderEdge) {
changes.push(`Moved ${pathLabel}.providers.edge → ${pathLabel}.providers.microsoft.`);
}
if (movedEdge) {
changes.push(`Moved ${pathLabel}.edge → ${pathLabel}.providers.microsoft.`);
for (const [legacyKey, providerId, source] of [
["openai", "openai", "tts"],
["elevenlabs", "elevenlabs", "tts"],
["microsoft", "microsoft", "tts"],
["edge", "microsoft", "providers"],
["edge", "microsoft", "tts"],
] as const) {
if (!mergeLegacyTtsProviderConfig(tts, legacyKey, providerId, source)) {
continue;
}
const sourcePath =
source === "providers" ? `${pathLabel}.providers.${legacyKey}` : `${pathLabel}.${legacyKey}`;
changes.push(`Moved ${sourcePath}${pathLabel}.providers.${providerId}.`);
}
}
@@ -282,36 +231,23 @@ function migrateLegacySpeakerSelectionConfig(
pathLabel: string,
changes: string[],
): void {
if (Object.hasOwn(providerConfig, "voice")) {
if (providerConfig.speakerVoice === undefined) {
providerConfig.speakerVoice = providerConfig.voice;
changes.push(`Moved ${pathLabel}.voice → ${pathLabel}.speakerVoice.`);
} else {
changes.push(`Removed ${pathLabel}.voice because ${pathLabel}.speakerVoice is already set.`);
for (const [legacyKey, canonicalKey] of [
["voice", "speakerVoice"],
["voiceName", "speakerVoice"],
["voiceId", "speakerVoiceId"],
] as const) {
if (!Object.hasOwn(providerConfig, legacyKey)) {
continue;
}
delete providerConfig.voice;
}
if (Object.hasOwn(providerConfig, "voiceName")) {
if (providerConfig.speakerVoice === undefined) {
providerConfig.speakerVoice = providerConfig.voiceName;
changes.push(`Moved ${pathLabel}.voiceName → ${pathLabel}.speakerVoice.`);
if (providerConfig[canonicalKey] === undefined) {
providerConfig[canonicalKey] = providerConfig[legacyKey];
changes.push(`Moved ${pathLabel}.${legacyKey}${pathLabel}.${canonicalKey}.`);
} else {
changes.push(
`Removed ${pathLabel}.voiceName because ${pathLabel}.speakerVoice is already set.`,
`Removed ${pathLabel}.${legacyKey} because ${pathLabel}.${canonicalKey} is already set.`,
);
}
delete providerConfig.voiceName;
}
if (Object.hasOwn(providerConfig, "voiceId")) {
if (providerConfig.speakerVoiceId === undefined) {
providerConfig.speakerVoiceId = providerConfig.voiceId;
changes.push(`Moved ${pathLabel}.voiceId → ${pathLabel}.speakerVoiceId.`);
} else {
changes.push(
`Removed ${pathLabel}.voiceId because ${pathLabel}.speakerVoiceId is already set.`,
);
}
delete providerConfig.voiceId;
delete providerConfig[legacyKey];
}
}
@@ -443,7 +379,7 @@ const LEGACY_TTS_PROVIDER_RULES: LegacyConfigRule[] = [
path: ["plugins", "entries"],
message:
'plugins.entries.voice-call.config.tts legacy provider aliases/keys are legacy; use provider: "microsoft" and plugins.entries.voice-call.config.tts.providers.<provider>. Run "openclaw doctor --fix".',
match: (value) => hasLegacyPluginEntryTtsProviderKeys(value),
match: (value) => hasLegacyTtsInPluginLocations(value, hasLegacyTtsProviderKeys),
},
];
@@ -457,19 +393,19 @@ const LEGACY_TTS_ENABLED_RULES: LegacyConfigRule[] = [
path: ["agents"],
message:
'agents.list[].tts.enabled is legacy; use agents.list[].tts.auto. Run "openclaw doctor --fix".',
match: (value) => hasLegacyTtsEnabledInAgentLocations(value),
match: (value) => hasLegacyTtsInAgentLocations(value, hasLegacyTtsEnabled),
},
{
path: ["channels"],
message:
'supported channel TTS enabled fields are legacy; use the same TTS block auto field. Run "openclaw doctor --fix".',
match: (value) => hasLegacyTtsEnabledInChannelLocations(value),
match: (value) => hasLegacyTtsInChannelLocations(value, hasLegacyTtsEnabled),
},
{
path: ["plugins", "entries"],
message:
'plugins.entries.voice-call.config.tts.enabled is legacy; use plugins.entries.voice-call.config.tts.auto. Run "openclaw doctor --fix".',
match: (value) => hasLegacyTtsEnabledInPluginLocations(value),
match: (value) => hasLegacyTtsInPluginLocations(value, hasLegacyTtsEnabled),
},
];
@@ -484,19 +420,19 @@ const LEGACY_TTS_SPEAKER_SELECTION_RULES: LegacyConfigRule[] = [
path: ["agents"],
message:
'agents.list[].tts speaker selection fields voice/voiceName/voiceId are legacy; use speakerVoice or speakerVoiceId. Run "openclaw doctor --fix".',
match: (value) => hasLegacyTtsSpeakerSelectionInAgentLocations(value),
match: (value) => hasLegacyTtsInAgentLocations(value, hasLegacyTtsSpeakerSelection),
},
{
path: ["channels"],
message:
'supported channel TTS speaker selection fields voice/voiceName/voiceId are legacy; use speakerVoice or speakerVoiceId. Run "openclaw doctor --fix".',
match: (value) => hasLegacyTtsSpeakerSelectionInChannelLocations(value),
match: (value) => hasLegacyTtsInChannelLocations(value, hasLegacyTtsSpeakerSelection),
},
{
path: ["plugins", "entries"],
message:
'plugins.entries.voice-call.config.tts speaker selection fields voice/voiceName/voiceId are legacy; use speakerVoice or speakerVoiceId. Run "openclaw doctor --fix".',
match: (value) => hasLegacyTtsSpeakerSelectionInPluginLocations(value),
match: (value) => hasLegacyTtsInPluginLocations(value, hasLegacyTtsSpeakerSelection),
},
];

View File

@@ -1,7 +1,6 @@
// Filesystem session history readers.
// Parses transcript JSONL files for messages, previews, counts, and usage metadata.
import fs from "node:fs";
import { StringDecoder } from "node:string_decoder";
import { expectDefined } from "@openclaw/normalization-core";
import {
resolveIntegerOption,
@@ -16,6 +15,7 @@ import {
} from "../agents/usage.js";
import { materializeSessionArchiveForRead } from "../config/sessions/archive-compression.js";
import type { TranscriptEvent } from "../config/sessions/session-accessor.js";
import { streamSessionTranscriptLines } from "../config/sessions/transcript-stream.js";
import { selectSessionTranscriptActiveEntries } from "../config/sessions/transcript-tree.js";
import { readFileWindowFully } from "../infra/file-read.js";
import { jsonUtf8Bytes } from "../infra/json-utf8-bytes.js";
@@ -41,52 +41,6 @@ import {
} from "./session-transcript-json.js";
import type { SessionPreviewItem } from "./session-utils.types.js";
const transcriptMessageCountCache = new Map<
string,
{
mtimeMs: number;
size: number;
count: number;
}
>();
const MAX_TRANSCRIPT_MESSAGE_COUNT_CACHE_ENTRIES = 5000;
const TRANSCRIPT_ASYNC_READ_CHUNK_BYTES = 64 * 1024;
function getCachedTranscriptMessageCount(filePath: string, stat: fs.Stats): number | null {
const cached = transcriptMessageCountCache.get(filePath);
if (!cached) {
return null;
}
if (cached.mtimeMs !== stat.mtimeMs || cached.size !== stat.size) {
transcriptMessageCountCache.delete(filePath);
return null;
}
transcriptMessageCountCache.delete(filePath);
transcriptMessageCountCache.set(filePath, cached);
return cached.count;
}
function setCachedTranscriptMessageCount(filePath: string, stat: fs.Stats, count: number): void {
transcriptMessageCountCache.set(filePath, {
mtimeMs: stat.mtimeMs,
size: stat.size,
count,
});
while (transcriptMessageCountCache.size > MAX_TRANSCRIPT_MESSAGE_COUNT_CACHE_ENTRIES) {
const oldestKey = transcriptMessageCountCache.keys().next().value;
if (typeof oldestKey !== "string" || !oldestKey) {
break;
}
transcriptMessageCountCache.delete(oldestKey);
}
}
async function yieldTranscriptScan(): Promise<void> {
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
}
/** Attach OpenClaw metadata to a transcript message without dropping existing metadata. */
export function attachOpenClawTranscriptMeta(
message: unknown,
@@ -344,37 +298,6 @@ function parseRecentTranscriptTailSnapshot(
};
}
async function visitTranscriptLinesAsync(
filePath: string,
visit: (line: string) => void,
): Promise<void> {
const handle = await fs.promises.open(filePath, "r");
try {
const decoder = new StringDecoder("utf8");
const buffer = Buffer.allocUnsafe(TRANSCRIPT_ASYNC_READ_CHUNK_BYTES);
let carry = "";
while (true) {
const { bytesRead } = await handle.read(buffer, 0, buffer.length, null);
if (bytesRead <= 0) {
break;
}
const text = carry + decoder.write(buffer.subarray(0, bytesRead));
const lines = text.split(/\r?\n/);
carry = lines.pop() ?? "";
for (const line of lines) {
visit(line);
}
await yieldTranscriptScan();
}
const tail = carry + decoder.end();
if (tail) {
visit(tail);
}
} finally {
await handle.close();
}
}
export async function readSessionMessagesAsync(
sessionId: string,
storePath: string | undefined,
@@ -555,7 +478,8 @@ export async function readRecentSessionMessagesWithStatsAsync(
findExistingTranscriptPath(sessionId, storePath, sessionFile, agentId) !== filePath
? "reset-archive"
: "active";
const totalMessages = await readSessionMessageCountFromPathAsync(filePath);
// The canonical index already caches and deduplicates scans by path, mtime, and size.
const totalMessages = (await readSessionTranscriptIndex(filePath))?.entries.length ?? 0;
const snapshot = await readRecentSessionSnapshotFromPathAsync(
filePath,
normalizeRecentSessionReadOptions(opts),
@@ -747,25 +671,6 @@ export async function resolveSessionHistoryTranscriptPathAsync(
: findExistingTranscriptPath(sessionId, storePath, sessionFile, opts?.agentId);
}
async function readSessionMessageCountFromPathAsync(filePath: string): Promise<number> {
let stat: fs.Stats | null = null;
try {
stat = await fs.promises.stat(filePath);
const cached = getCachedTranscriptMessageCount(filePath, stat);
if (typeof cached === "number") {
return cached;
}
} catch {
// Count from the transcript index below when stat metadata is unavailable.
}
const index = await readSessionTranscriptIndex(filePath);
const count = index?.entries.length ?? 0;
if (stat) {
setCachedTranscriptMessageCount(filePath, stat, count);
}
return count;
}
export type SessionTranscriptUsageSnapshot = {
modelProvider?: string;
model?: string;
@@ -1085,11 +990,9 @@ export async function readLatestSessionUsageFromTranscriptAsync(
return null;
}
const lines: string[] = [];
await visitTranscriptLinesAsync(filePath, (line) => {
if (line.trim()) {
lines.push(line);
}
});
for await (const line of streamSessionTranscriptLines(filePath)) {
lines.push(line);
}
return extractAggregateUsageFromTranscriptLines(lines);
} catch {
return null;

View File

@@ -176,10 +176,48 @@ async function startRuntime(
};
}
async function createWatchNodeFixture(
prefix: string,
options?: Parameters<typeof startRuntime>[1],
) {
const baseDir = await tempDirs.make(prefix);
const identity = loadOrCreateDeviceIdentity({
path: path.join(baseDir, "watch-identity.sqlite"),
});
const issued = await issueDeviceBootstrapToken({
baseDir,
profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
});
return { baseDir, identity, issued, ...(await startRuntime(baseDir, options)) };
}
async function readJson(response: Response): Promise<Record<string, unknown>> {
return (await response.json()) as Record<string, unknown>;
}
async function connectWatchNode(params: {
baseUrl: string;
identity: ReturnType<typeof loadOrCreateDeviceIdentity>;
bootstrapToken?: string;
deviceToken?: string;
permissions?: ConnectParams["permissions"];
}): Promise<Response> {
const challenge = await readJson(await fetch(`${params.baseUrl}/challenge`));
return await fetch(`${params.baseUrl}/connect`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(
makeConnectParams({
identity: params.identity,
nonce: String(challenge.nonce),
bootstrapToken: params.bootstrapToken,
deviceToken: params.deviceToken,
permissions: params.permissions,
}),
),
});
}
function startPartialJsonRequest(params: { url: string; authorization: string }): {
request: ClientRequest;
response: Promise<{ statusCode: number; body: string }>;
@@ -220,15 +258,9 @@ async function waitForLastConnectedMetadata(baseDir: string, nodeId: string): Pr
describe("watch node HTTP transport", () => {
it("rejects capabilities and identities outside the bounded watch surface", async () => {
const baseDir = await tempDirs.make("openclaw-watch-node-surface-");
const identity = loadOrCreateDeviceIdentity({
path: path.join(baseDir, "watch-identity.sqlite"),
});
const issued = await issueDeviceBootstrapToken({
baseDir,
profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
});
const { baseUrl, runtime } = await startRuntime(baseDir);
const { identity, issued, baseUrl, runtime } = await createWatchNodeFixture(
"openclaw-watch-node-surface-",
);
const variants: Array<(nonce: string) => ConnectParams> = [
(nonce) =>
makeConnectParams({
@@ -279,27 +311,14 @@ describe("watch node HTTP transport", () => {
});
it("accepts a supported notification permission set to false", async () => {
const baseDir = await tempDirs.make("openclaw-watch-node-permissions-");
const identity = loadOrCreateDeviceIdentity({
path: path.join(baseDir, "watch-identity.sqlite"),
});
const issued = await issueDeviceBootstrapToken({
baseDir,
profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
});
const { baseUrl, runtime } = await startRuntime(baseDir);
const challenge = await readJson(await fetch(`${baseUrl}/challenge`));
const response = await fetch(`${baseUrl}/connect`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(
makeConnectParams({
identity,
nonce: String(challenge.nonce),
bootstrapToken: issued.token,
permissions: { notifications: false },
}),
),
const { identity, issued, baseUrl, runtime } = await createWatchNodeFixture(
"openclaw-watch-node-permissions-",
);
const response = await connectWatchNode({
baseUrl,
identity,
bootstrapToken: issued.token,
permissions: { notifications: false },
});
expect(response.status).toBe(200);
@@ -308,17 +327,12 @@ describe("watch node HTTP transport", () => {
});
it("does not let attacker challenges evict another client nonce", async () => {
const baseDir = await tempDirs.make("openclaw-watch-node-challenge-eviction-");
const identity = loadOrCreateDeviceIdentity({
path: path.join(baseDir, "watch-identity.sqlite"),
});
const issued = await issueDeviceBootstrapToken({
baseDir,
profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
});
const { baseUrl, runtime } = await startRuntime(baseDir, {
config: { gateway: { trustedProxies: ["127.0.0.1"] } },
});
const { identity, issued, baseUrl, runtime } = await createWatchNodeFixture(
"openclaw-watch-node-challenge-eviction-",
{
config: { gateway: { trustedProxies: ["127.0.0.1"] } },
},
);
const legitimateHeaders = { "x-forwarded-for": "203.0.113.10" };
const legitimate = await readJson(
await fetch(`${baseUrl}/challenge`, { headers: legitimateHeaders }),
@@ -347,28 +361,13 @@ describe("watch node HTTP transport", () => {
});
it("requires an authenticated disconnect and emits one lifecycle teardown", async () => {
const baseDir = await tempDirs.make("openclaw-watch-node-disconnect-");
const identity = loadOrCreateDeviceIdentity({
path: path.join(baseDir, "watch-identity.sqlite"),
});
const issued = await issueDeviceBootstrapToken({
baseDir,
profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
});
const { nodeRegistry, connectedNodes, disconnectedNodes, runtime, baseUrl } =
await startRuntime(baseDir);
const { identity, issued, nodeRegistry, connectedNodes, disconnectedNodes, runtime, baseUrl } =
await createWatchNodeFixture("openclaw-watch-node-disconnect-");
const challenge = await readJson(await fetch(`${baseUrl}/challenge`));
const connectResponse = await fetch(`${baseUrl}/connect`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(
makeConnectParams({
identity,
nonce: String(challenge.nonce),
bootstrapToken: issued.token,
}),
),
const connectResponse = await connectWatchNode({
baseUrl,
identity,
bootstrapToken: issued.token,
});
expect(connectResponse.status).toBe(200);
const connected = await readJson(connectResponse);
@@ -407,26 +406,12 @@ describe("watch node HTTP transport", () => {
});
it("rejects an HTTP node session after an external reapproval changes its generation", async () => {
const baseDir = await tempDirs.make("openclaw-watch-node-reapproval-");
const identity = loadOrCreateDeviceIdentity({
path: path.join(baseDir, "watch-identity.sqlite"),
});
const issued = await issueDeviceBootstrapToken({
baseDir,
profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
});
const { nodeRegistry, disconnectedNodes, runtime, baseUrl } = await startRuntime(baseDir);
const challenge = await readJson(await fetch(`${baseUrl}/challenge`));
const connectResponse = await fetch(`${baseUrl}/connect`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(
makeConnectParams({
identity,
nonce: String(challenge.nonce),
bootstrapToken: issued.token,
}),
),
const { baseDir, identity, issued, nodeRegistry, disconnectedNodes, runtime, baseUrl } =
await createWatchNodeFixture("openclaw-watch-node-reapproval-");
const connectResponse = await connectWatchNode({
baseUrl,
identity,
bootstrapToken: issued.token,
});
const connected = await readJson(connectResponse);
const paired = await getPairedDevice(identity.deviceId, baseDir);
@@ -456,26 +441,12 @@ describe("watch node HTTP transport", () => {
});
it("rejects an invoke result when pairing changes during body upload", async () => {
const baseDir = await tempDirs.make("openclaw-watch-node-result-generation-");
const identity = loadOrCreateDeviceIdentity({
path: path.join(baseDir, "watch-identity.sqlite"),
});
const issued = await issueDeviceBootstrapToken({
baseDir,
profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
});
const { nodeRegistry, disconnectedNodes, runtime, baseUrl } = await startRuntime(baseDir);
const challenge = await readJson(await fetch(`${baseUrl}/challenge`));
const connectResponse = await fetch(`${baseUrl}/connect`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(
makeConnectParams({
identity,
nonce: String(challenge.nonce),
bootstrapToken: issued.token,
}),
),
const { baseDir, identity, issued, nodeRegistry, disconnectedNodes, runtime, baseUrl } =
await createWatchNodeFixture("openclaw-watch-node-result-generation-");
const connectResponse = await connectWatchNode({
baseUrl,
identity,
bootstrapToken: issued.token,
});
const connected = await readJson(connectResponse);
const invoke = nodeRegistry.invoke({
@@ -529,15 +500,9 @@ describe("watch node HTTP transport", () => {
});
it("rejects empty shadow credentials without consuming the challenge", async () => {
const baseDir = await tempDirs.make("openclaw-watch-node-auth-fields-");
const identity = loadOrCreateDeviceIdentity({
path: path.join(baseDir, "watch-identity.sqlite"),
});
const issued = await issueDeviceBootstrapToken({
baseDir,
profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
});
const { baseUrl, runtime } = await startRuntime(baseDir);
const { baseDir, identity, issued, baseUrl, runtime } = await createWatchNodeFixture(
"openclaw-watch-node-auth-fields-",
);
const challenge = await readJson(await fetch(`${baseUrl}/challenge`));
const connect = makeConnectParams({
@@ -624,17 +589,10 @@ describe("watch node HTTP transport", () => {
const completedRuntime = await startRuntime(completedBaseDir, {
rateLimiter: completedLimiter,
});
const challenge = await readJson(await fetch(`${completedRuntime.baseUrl}/challenge`));
const connectResponse = await fetch(`${completedRuntime.baseUrl}/connect`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(
makeConnectParams({
identity: completedIdentity,
nonce: String(challenge.nonce),
bootstrapToken: completedBootstrap.token,
}),
),
const connectResponse = await connectWatchNode({
baseUrl: completedRuntime.baseUrl,
identity: completedIdentity,
bootstrapToken: completedBootstrap.token,
});
expect(connectResponse.status).toBe(200);
await readJson(connectResponse);
@@ -649,28 +607,22 @@ describe("watch node HTTP transport", () => {
});
it("bootstraps, registers, polls an invoke, and accepts its result", async () => {
const baseDir = await tempDirs.make("openclaw-watch-node-http-");
const identity = loadOrCreateDeviceIdentity({
path: path.join(baseDir, "watch-identity.sqlite"),
});
const issued = await issueDeviceBootstrapToken({
const {
baseDir,
profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
});
const { nodeRegistry, broadcasts, connectedNodes, disconnectedNodes, runtime, baseUrl } =
await startRuntime(baseDir);
identity,
issued,
nodeRegistry,
broadcasts,
connectedNodes,
disconnectedNodes,
runtime,
baseUrl,
} = await createWatchNodeFixture("openclaw-watch-node-http-");
const challenge = await readJson(await fetch(`${baseUrl}/challenge`));
const connectResponse = await fetch(`${baseUrl}/connect`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(
makeConnectParams({
identity,
nonce: String(challenge.nonce),
bootstrapToken: issued.token,
}),
),
const connectResponse = await connectWatchNode({
baseUrl,
identity,
bootstrapToken: issued.token,
});
expect(connectResponse.status).toBe(200);
const connected = await readJson(connectResponse);
@@ -685,17 +637,10 @@ describe("watch node HTTP transport", () => {
expect(broadcasts.map((entry) => entry.event)).toContain("node.pair.resolved");
expect(connectedNodes).toEqual([identity.deviceId]);
const reconnectChallenge = await readJson(await fetch(`${baseUrl}/challenge`));
const reconnectResponse = await fetch(`${baseUrl}/connect`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(
makeConnectParams({
identity,
nonce: String(reconnectChallenge.nonce),
deviceToken: String(connected.deviceToken),
}),
),
const reconnectResponse = await connectWatchNode({
baseUrl,
identity,
deviceToken: String(connected.deviceToken),
});
expect(reconnectResponse.status).toBe(200);
const reconnected = await readJson(reconnectResponse);
@@ -778,17 +723,10 @@ describe("watch node HTTP transport", () => {
baseDir,
profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
});
const replacementChallenge = await readJson(await fetch(`${baseUrl}/challenge`));
const replacementResponse = await fetch(`${baseUrl}/connect`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(
makeConnectParams({
identity,
nonce: String(replacementChallenge.nonce),
bootstrapToken: replacementBootstrap.token,
}),
),
const replacementResponse = await connectWatchNode({
baseUrl,
identity,
bootstrapToken: replacementBootstrap.token,
});
expect(replacementResponse.status).toBe(200);
const replacement = await readJson(replacementResponse);
@@ -796,17 +734,10 @@ describe("watch node HTTP transport", () => {
expect(replacement.deviceToken).not.toBe(connected.deviceToken);
expect(connectedNodes).toEqual([identity.deviceId, identity.deviceId, identity.deviceId]);
const replayChallenge = await readJson(await fetch(`${baseUrl}/challenge`));
const replayResponse = await fetch(`${baseUrl}/connect`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(
makeConnectParams({
identity,
nonce: String(replayChallenge.nonce),
bootstrapToken: replacementBootstrap.token,
}),
),
const replayResponse = await connectWatchNode({
baseUrl,
identity,
bootstrapToken: replacementBootstrap.token,
});
expect(replayResponse.status).toBe(401);