refactor: remove 3,543 lines of redundant runtime and tests (#115961)

* refactor: remove 3,543 lines of redundant runtime and tests

* refactor: ratchet production environment variable budget
This commit is contained in:
Peter Steinberger
2026-07-29 11:43:41 -04:00
committed by GitHub
parent 190307137a
commit 5bfc65d7f4
99 changed files with 4128 additions and 7671 deletions
-2
View File
@@ -144,7 +144,6 @@ extensions/matrix/src/matrix/monitor/handler.test.ts
extensions/matrix/src/matrix/sdk.test.ts
extensions/matrix/src/matrix/sdk/verification-manager.ts
extensions/matrix/src/matrix/send.test.ts
extensions/matrix/src/onboarding.ts
extensions/mattermost/src/channel.test.ts
extensions/mattermost/src/channel.ts
extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts
@@ -519,7 +518,6 @@ src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts
src/auto-reply/reply/commands-acp.test.ts
src/auto-reply/reply/commands-approve.test.ts
src/auto-reply/reply/commands-models.ts
src/auto-reply/reply/commands-session.ts
src/auto-reply/reply/commands-status.test.ts
src/auto-reply/reply/directive-handling.impl.ts
src/auto-reply/reply/directive-handling.model.test.ts
+15 -47
View File
@@ -225,27 +225,16 @@ async function buildTimeoutRecallResult(params: {
: undefined;
const searchDebug =
params.searchDebug ?? subagentPartialData.searchDebug ?? transcriptState?.searchDebug;
if (
const cannotUsePartial =
summary.length === 0 ||
isUnavailableMemorySearchDebug(searchDebug) ||
!subagentPartialData.settled ||
params.hasUnavailableMemorySearchResult ||
subagentPartialData.hasUnavailableMemorySearchResult ||
transcriptState?.hasUnavailableMemorySearchResult
) {
return {
status: "timeout",
elapsedMs: params.elapsedMs,
summary: null,
searchDebug,
};
}
return {
status: "timeout_partial",
elapsedMs: params.elapsedMs,
summary,
searchDebug,
};
transcriptState?.hasUnavailableMemorySearchResult;
return cannotUsePartial
? { status: "timeout", elapsedMs: params.elapsedMs, summary: null, searchDebug }
: { status: "timeout_partial", elapsedMs: params.elapsedMs, summary, searchDebug };
}
function buildSubagentRecallResult(params: {
@@ -261,39 +250,18 @@ function buildSubagentRecallResult(params: {
const hasUsableMemoryResult =
params.subagentResult.hasUsableMemoryResult === true ||
params.fallbackHasUsableMemoryResult === true;
const hasUnavailableMemorySearchResult =
params.subagentResult.hasUnavailableMemorySearchResult === true;
const canUseSummary = hasUsableMemoryResult;
return summary.length > 0 && canUseSummary
? {
status: "ok",
elapsedMs: params.elapsedMs,
rawReply,
summary,
searchDebug,
}
: resultStatus === "failed"
? {
status: "failed",
elapsedMs: params.elapsedMs,
summary: null,
searchDebug,
}
if (summary.length > 0 && hasUsableMemoryResult) {
return { status: "ok", elapsedMs: params.elapsedMs, rawReply, summary, searchDebug };
}
const status =
resultStatus === "failed"
? "failed"
: resultStatus === "unavailable" ||
isUnavailableMemorySearchDebug(searchDebug) ||
hasUnavailableMemorySearchResult
? {
status: "unavailable",
elapsedMs: params.elapsedMs,
summary: null,
searchDebug,
}
: {
status: "no_relevant_memory",
elapsedMs: params.elapsedMs,
summary: null,
searchDebug,
};
params.subagentResult.hasUnavailableMemorySearchResult === true
? "unavailable"
: "no_relevant_memory";
return { status, elapsedMs: params.elapsedMs, summary: null, searchDebug };
}
function resetActiveMemoryTranscriptForTests(): void {
+16 -39
View File
@@ -175,25 +175,21 @@ async function streamActiveMemoryTranscriptRecords(params: {
});
}
function resolveToolResultMessage(value: unknown): Record<string, unknown> | undefined {
const record = asRecord(value);
const message = asRecord(record?.message) ?? (record?.role === "toolResult" ? record : undefined);
return message && normalizeOptionalString(message.role) === "toolResult" ? message : undefined;
}
function extractActiveMemorySearchDebugFromSessionRecord(
value: unknown,
): ActiveMemorySearchDebug | undefined {
const record = asRecord(value);
const nestedMessage = asRecord(record?.message);
const recordToolName = normalizeLowercaseStringOrEmpty(record?.toolName);
const topLevelMessage =
record?.role === "toolResult" ||
recordToolName === "memory_search" ||
recordToolName === "memory_recall"
? record
: undefined;
const message = nestedMessage ?? topLevelMessage;
const message = resolveToolResultMessage(value);
if (!message) {
return undefined;
}
const role = normalizeOptionalString(message.role);
const toolName = normalizeLowercaseStringOrEmpty(message.toolName);
if (role !== "toolResult" || (toolName !== "memory_search" && toolName !== "memory_recall")) {
if (toolName !== "memory_search" && toolName !== "memory_recall") {
return undefined;
}
const details = asRecord(message.details);
@@ -221,16 +217,12 @@ function extractActiveMemorySearchDebugFromSessionRecord(
}
function extractToolResultNameFromSessionRecord(value: unknown): string | undefined {
const record = asRecord(value);
const nestedMessage = asRecord(record?.message);
const topLevelMessage = record?.role === "toolResult" ? record : undefined;
const message = nestedMessage ?? topLevelMessage;
const message = resolveToolResultMessage(value);
if (!message) {
return undefined;
}
const role = normalizeOptionalString(message.role);
const toolName = normalizeLowercaseStringOrEmpty(message.toolName);
return role === "toolResult" && toolName ? toolName : undefined;
return toolName || undefined;
}
function hasUnavailableMemoryResultInSessionRecord(
@@ -240,11 +232,8 @@ function hasUnavailableMemoryResultInSessionRecord(
...LANCEDB_ACTIVE_MEMORY_TOOLS_ALLOW,
],
): boolean {
const record = asRecord(value);
const nestedMessage = asRecord(record?.message);
const topLevelMessage = record?.role === "toolResult" ? record : undefined;
const message = nestedMessage ?? topLevelMessage;
if (!message || normalizeOptionalString(message.role) !== "toolResult") {
const message = resolveToolResultMessage(value);
if (!message) {
return false;
}
const toolName = normalizeLowercaseStringOrEmpty(message.toolName);
@@ -263,11 +252,8 @@ function hasTerminalUnavailableMemoryResultInSessionRecord(
value: unknown,
toolsAllow: readonly string[],
): boolean {
const record = asRecord(value);
const nestedMessage = asRecord(record?.message);
const topLevelMessage = record?.role === "toolResult" ? record : undefined;
const message = nestedMessage ?? topLevelMessage;
if (!message || normalizeOptionalString(message.role) !== "toolResult") {
const message = resolveToolResultMessage(value);
if (!message) {
return false;
}
const toolName = normalizeLowercaseStringOrEmpty(message.toolName);
@@ -328,17 +314,8 @@ function hasUsableMemoryResultInSessionRecord(
...LANCEDB_ACTIVE_MEMORY_TOOLS_ALLOW,
],
): boolean {
const record = asRecord(value);
const nestedMessage = asRecord(record?.message);
const recordToolName = normalizeLowercaseStringOrEmpty(record?.toolName);
const topLevelMessage =
record?.role === "toolResult" ||
recordToolName === "memory_search" ||
recordToolName === "memory_recall"
? record
: undefined;
const message = nestedMessage ?? topLevelMessage;
if (!message || normalizeOptionalString(message.role) !== "toolResult") {
const message = resolveToolResultMessage(value);
if (!message) {
return false;
}
const toolName = normalizeLowercaseStringOrEmpty(message.toolName);
+28 -34
View File
@@ -109,30 +109,27 @@ function canonicalizeKnownClaudeCliModelId(modelId: string): string | null {
}
function upgradeOldClaudeModelId(normalized: string): string | null {
if (hasRetiredVersionPrefix(normalized, "claude-opus-5")) {
return null;
}
if (normalized.startsWith("claude-opus-4-8") || normalized.startsWith("claude-opus-4.8")) {
return null;
}
if (normalized.startsWith("claude-opus-4-7") || normalized.startsWith("claude-opus-4.7")) {
return null;
}
if (normalized.startsWith("claude-opus-4-6") || normalized.startsWith("claude-opus-4.6")) {
return null;
}
if (normalized.startsWith("claude-sonnet-4-6") || normalized.startsWith("claude-sonnet-4.6")) {
return null;
}
// claude-haiku-4-5 is a current production model and must not be migrated.
if (normalized.startsWith("claude-haiku-4-5") || normalized.startsWith("claude-haiku-4.5")) {
// Current Claude families, including Haiku, must never be migrated.
if (
hasRetiredVersionPrefix(normalized, "claude-opus-5") ||
[
"claude-opus-4-8",
"claude-opus-4.8",
"claude-opus-4-7",
"claude-opus-4.7",
"claude-opus-4-6",
"claude-opus-4.6",
"claude-sonnet-4-6",
"claude-sonnet-4.6",
"claude-haiku-4-5",
"claude-haiku-4.5",
].some((prefix) => normalized.startsWith(prefix))
) {
return null;
}
if (
normalized === "claude-opus-4" ||
hasAnyRetiredVersionPrefix(normalized, [
"claude-opus-4-7",
"claude-opus-4.7",
"claude-opus-4-5",
"claude-opus-4.5",
"claude-opus-4-1",
@@ -167,24 +164,21 @@ function upgradeOldClaudeModelId(normalized: string): string | null {
) {
return "claude-sonnet-4-6";
}
if (
normalized === "opus-4.5" ||
normalized === "opus-4.1" ||
normalized === "opus-4" ||
normalized === "opus-3"
) {
if (["opus-4.5", "opus-4.1", "opus-4", "opus-3"].includes(normalized)) {
return "claude-opus-5";
}
if (
normalized === "sonnet-4.5" ||
normalized === "sonnet-4.1" ||
normalized === "sonnet-4.0" ||
normalized === "sonnet-4" ||
normalized === "sonnet-3.7" ||
normalized === "sonnet-3.5" ||
normalized === "sonnet-3" ||
normalized === "haiku-3.5" ||
normalized === "haiku-3"
[
"sonnet-4.5",
"sonnet-4.1",
"sonnet-4.0",
"sonnet-4",
"sonnet-3.7",
"sonnet-3.5",
"sonnet-3",
"haiku-3.5",
"haiku-3",
].includes(normalized)
) {
return "claude-sonnet-4-6";
}
+25 -54
View File
@@ -208,61 +208,32 @@ function modelEntryWithClaudeCliRuntime(entry: unknown): Record<string, unknown>
return base;
}
function collectClaudeCliRuntimeRefs(
model: string | { primary?: string; fallbacks?: string[] } | undefined,
): string[] {
const refs = new Set<string>();
if (typeof model === "string") {
for (const ref of resolveClaudeCliAnthropicModelRefs(model)?.runtimeRefs ?? []) {
refs.add(ref);
}
return [...refs];
}
if (typeof model?.primary === "string") {
for (const ref of resolveClaudeCliAnthropicModelRefs(model.primary)?.runtimeRefs ?? []) {
refs.add(ref);
}
}
for (const fallback of model?.fallbacks ?? []) {
for (const ref of resolveClaudeCliAnthropicModelRefs(fallback)?.runtimeRefs ?? []) {
refs.add(ref);
}
}
return [...refs];
}
function collectClaudeCliRuntimeRefsFromModelMap(
models: Record<string, unknown> | undefined,
): string[] {
const refs = new Set<string>();
for (const key of Object.keys(models ?? {})) {
for (const ref of resolveClaudeCliAnthropicModelRefs(key)?.runtimeRefs ?? []) {
refs.add(ref);
}
}
return [...refs];
}
function collectClaudeCliRuntimeRefsFromConfig(config: OpenClawConfig): string[] {
const refs = new Set<string>(
collectClaudeCliRuntimeRefs(
config.agents?.defaults?.model as
| string
| { primary?: string; fallbacks?: string[] }
| undefined,
),
);
for (const ref of collectClaudeCliRuntimeRefsFromModelMap(config.agents?.defaults?.models)) {
refs.add(ref);
}
for (const agent of config.agents?.list ?? []) {
for (const ref of collectClaudeCliRuntimeRefs(
agent.model as string | { primary?: string; fallbacks?: string[] } | undefined,
)) {
refs.add(ref);
}
for (const ref of collectClaudeCliRuntimeRefsFromModelMap(agent.models)) {
refs.add(ref);
type ClaudeCliModelSelection = string | { primary?: string; fallbacks?: string[] } | undefined;
const selections: Array<{
model: ClaudeCliModelSelection;
models: Record<string, unknown> | undefined;
}> = [
{
model: config.agents?.defaults?.model as ClaudeCliModelSelection,
models: config.agents?.defaults?.models,
},
...(config.agents?.list ?? []).map((agent) => ({
model: agent.model as ClaudeCliModelSelection,
models: agent.models,
})),
];
const refs = new Set<string>();
for (const { model, models } of selections) {
const selected =
typeof model === "string" ? [model] : [model?.primary, ...(model?.fallbacks ?? [])];
for (const rawRef of [...selected, ...Object.keys(models ?? {})]) {
if (typeof rawRef !== "string") {
continue;
}
for (const ref of resolveClaudeCliAnthropicModelRefs(rawRef)?.runtimeRefs ?? []) {
refs.add(ref);
}
}
}
return [...refs];
+45 -107
View File
@@ -120,40 +120,14 @@ describe("anthropic provider replay hooks", () => {
expect(registerSessionCatalog).not.toHaveBeenCalled();
});
it("publishes Claude Opus 5 CLI metadata without downgrading its API contract", () => {
expect(
buildClaudeCliCatalogEntries().find((model) => model.id === "claude-opus-5"),
).toMatchObject({
id: "claude-opus-5",
name: "Claude Opus 5 (Claude CLI)",
contextWindow: 1_000_000,
maxTokens: 128_000,
mediaInput: {
image: { maxSidePx: 2576, preferredSidePx: 2576, tokenMode: "provider" },
},
});
});
it("publishes Claude Sonnet 5 CLI metadata without downgrading its API contract", () => {
expect(
buildClaudeCliCatalogEntries().find((model) => model.id === "claude-sonnet-5"),
).toMatchObject({
id: "claude-sonnet-5",
name: "Claude Sonnet 5 (Claude CLI)",
contextWindow: 1_000_000,
maxTokens: 128_000,
mediaInput: {
image: { maxSidePx: 2576, preferredSidePx: 2576, tokenMode: "provider" },
},
});
});
it("publishes Claude Fable 5 CLI metadata without downgrading its API contract", () => {
expect(
buildClaudeCliCatalogEntries().find((model) => model.id === "claude-fable-5"),
).toMatchObject({
id: "claude-fable-5",
name: "Claude Fable 5 (Claude CLI)",
it.each([
["Opus", "claude-opus-5"],
["Sonnet", "claude-sonnet-5"],
["Fable", "claude-fable-5"],
])("publishes Claude %s 5 CLI metadata without downgrading its API contract", (family, id) => {
expect(buildClaudeCliCatalogEntries().find((model) => model.id === id)).toMatchObject({
id,
name: `Claude ${family} 5 (Claude CLI)`,
contextWindow: 1_000_000,
maxTokens: 128_000,
mediaInput: {
@@ -281,29 +255,15 @@ describe("anthropic provider replay hooks", () => {
});
});
it("defaults provider api through plugin config normalization", async () => {
it.each([
["provider", "anthropic"],
["Claude CLI provider", "claude-cli"],
])("defaults %s api through plugin config normalization", async (_label, providerId) => {
const provider = await registerSingleProviderPlugin(anthropicPlugin);
expect(
requireRecord(
provider.normalizeConfig?.({
provider: "anthropic",
providerConfig: {
models: [{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }],
},
} as never),
"normalized config",
).api,
).toBe("anthropic-messages");
});
it("defaults Claude CLI provider api through plugin config normalization", async () => {
const provider = await registerSingleProviderPlugin(anthropicPlugin);
expect(
requireRecord(
provider.normalizeConfig?.({
provider: "claude-cli",
provider: providerId,
providerConfig: {
models: [{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }],
},
@@ -1348,7 +1308,29 @@ describe("anthropic provider replay hooks", () => {
}
});
it("preflights non-interactive setup-token input without writing credentials", async () => {
it.each([
{
name: "preflights non-interactive setup-token input without writing credentials",
opts: {},
},
{
name: "rejects setup-token ref storage during non-interactive preflight",
opts: { secretInputMode: "ref" }, // pragma: allowlist secret
error:
"Anthropic setup-token input cannot be stored with --secret-input-mode ref. Use --secret-input-mode plaintext.",
},
{
name: "rejects invalid setup-token expiry during non-interactive preflight",
opts: { tokenExpiresIn: "nope" },
error: "Invalid --token-expires-in",
partialError: true,
},
] as Array<{
name: string;
opts: Record<string, string>;
error?: string;
partialError?: boolean;
}>)("$name", async ({ opts, error, partialError }) => {
const provider = await registerSingleProviderPlugin(anthropicPlugin);
const setupTokenAuth = provider.auth.find((entry) => entry.id === "setup-token");
if (!setupTokenAuth?.validateNonInteractive) {
@@ -1360,64 +1342,20 @@ describe("anthropic provider replay hooks", () => {
authChoice: "setup-token",
config: {},
baseConfig: {},
opts: { token: ANTHROPIC_SETUP_TOKEN },
opts: { token: ANTHROPIC_SETUP_TOKEN, ...opts },
runtime,
resolveApiKey: vi.fn(async () => null),
});
expect(valid).toBe(true);
expect(runtime.error).not.toHaveBeenCalled();
});
it("rejects setup-token ref storage during non-interactive preflight", async () => {
const provider = await registerSingleProviderPlugin(anthropicPlugin);
const setupTokenAuth = provider.auth.find((entry) => entry.id === "setup-token");
if (!setupTokenAuth?.validateNonInteractive) {
throw new Error("expected setup-token reset preflight");
expect(valid).toBe(!error);
if (error) {
expect(runtime.error).toHaveBeenCalledWith(
partialError ? expect.stringContaining(error) : error,
);
expect(runtime.exit).toHaveBeenCalledWith(1);
} else {
expect(runtime.error).not.toHaveBeenCalled();
}
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
const valid = await setupTokenAuth.validateNonInteractive({
authChoice: "setup-token",
config: {},
baseConfig: {},
opts: {
token: ANTHROPIC_SETUP_TOKEN,
secretInputMode: "ref", // pragma: allowlist secret
},
runtime,
resolveApiKey: vi.fn(async () => null),
});
expect(valid).toBe(false);
expect(runtime.error).toHaveBeenCalledWith(
"Anthropic setup-token input cannot be stored with --secret-input-mode ref. Use --secret-input-mode plaintext.",
);
expect(runtime.exit).toHaveBeenCalledWith(1);
});
it("rejects invalid setup-token expiry during non-interactive preflight", async () => {
const provider = await registerSingleProviderPlugin(anthropicPlugin);
const setupTokenAuth = provider.auth.find((entry) => entry.id === "setup-token");
if (!setupTokenAuth?.validateNonInteractive) {
throw new Error("expected setup-token reset preflight");
}
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
const valid = await setupTokenAuth.validateNonInteractive({
authChoice: "setup-token",
config: {},
baseConfig: {},
opts: { token: ANTHROPIC_SETUP_TOKEN, tokenExpiresIn: "nope" },
runtime,
resolveApiKey: vi.fn(async () => null),
});
expect(valid).toBe(false);
expect(runtime.error).toHaveBeenCalledWith(
expect.stringContaining("Invalid --token-expires-in"),
);
expect(runtime.exit).toHaveBeenCalledWith(1);
});
it("omits setup-token expiry when duration overflows the Date range", async () => {
@@ -498,35 +498,18 @@ describe("Google image-generation provider", () => {
expect(postJsonRequestOptions(postJsonRequestSpy).allowPrivateNetwork).toBe(true);
});
it("normalizes a configured bare Google host to the v1beta API root", async () => {
mockGoogleApiKeyAuth();
const fetchMock = installGoogleFetchMock();
const provider = buildGoogleImageGenerationProvider();
await provider.generateImage({
provider: "google",
model: "gemini-3-pro-image",
it.each([
{
name: "normalizes a configured bare Google host to the v1beta API root",
baseUrl: "https://generativelanguage.googleapis.com",
prompt: "draw a cat",
cfg: {
models: {
providers: {
google: {
baseUrl: "https://generativelanguage.googleapis.com",
models: [],
},
},
},
},
});
const request = fetchRequest(fetchMock);
expect(request.url).toBe(
"https://generativelanguage.googleapis.com/v1beta/models/gemini-3-pro-image:generateContent",
);
expect(typeof request.method).toBe("string");
});
it("strips a configured /openai suffix before calling the native Gemini image API", async () => {
},
{
name: "strips a configured /openai suffix before calling the native Gemini image API",
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
prompt: "draw a fox",
},
])("$name", async ({ baseUrl, prompt }) => {
mockGoogleApiKeyAuth();
const fetchMock = installGoogleFetchMock();
@@ -534,12 +517,12 @@ describe("Google image-generation provider", () => {
await provider.generateImage({
provider: "google",
model: "gemini-3-pro-image",
prompt: "draw a fox",
prompt,
cfg: {
models: {
providers: {
google: {
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
baseUrl,
models: [],
},
},
+16 -33
View File
@@ -681,42 +681,20 @@ describe("Google speech provider", () => {
);
});
it("honors configured private-network opt-in for Google TTS", async () => {
installGoogleTtsRequestMock();
const provider = buildGoogleSpeechProvider();
await provider.synthesize({
text: "hello",
cfg: {
models: {
providers: {
google: {
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
request: { allowPrivateNetwork: true },
models: [],
},
},
},
},
providerConfig: { apiKey: "google-test-key" },
it.each([
{
name: "honors configured private-network opt-in for Google TTS",
target: "audio-file",
timeoutMs: 12_345,
});
const requestConfig = expectRecordFields(
requireFirstRecordArg(resolveProviderHttpRequestConfigMock, "Google TTS HTTP config request"),
{
allowPrivateNetwork: true,
},
);
expectRecordFields(requestConfig.request, { allowPrivateNetwork: true });
});
it("honors configured private-network opt-in for Google telephony TTS", async () => {
},
{
name: "honors configured private-network opt-in for Google telephony TTS",
target: "telephony",
},
] as const)("$name", async ({ target }) => {
installGoogleTtsRequestMock();
const provider = buildGoogleSpeechProvider();
await provider.synthesizeTelephony?.({
const request = {
text: "hello",
cfg: {
models: {
@@ -731,7 +709,12 @@ describe("Google speech provider", () => {
},
providerConfig: { apiKey: "google-test-key" },
timeoutMs: 12_345,
});
};
if (target === "telephony") {
await provider.synthesizeTelephony?.(request);
} else {
await provider.synthesize({ ...request, target });
}
const requestConfig = expectRecordFields(
requireFirstRecordArg(resolveProviderHttpRequestConfigMock, "Google TTS HTTP config request"),
+23 -110
View File
@@ -1,6 +1,5 @@
// Matrix setup module handles plugin onboarding behavior.
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
import { createChannelDmPolicy } from "openclaw/plugin-sdk/channel-dm-policy";
import {
type ChannelSetupWizardAdapter,
formatDocsLink,
@@ -16,6 +15,7 @@ import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
normalizeStringifiedOptionalString,
normalizeUniqueStringEntries,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { requiresExplicitMatrixDefaultAccount } from "./account-selection.js";
import {
@@ -30,11 +30,11 @@ import {
resolveValidatedMatrixHomeserverUrl,
validateMatrixHomeserverUrl,
} from "./matrix/client/url-validation.js";
import { resolveMatrixConfigFieldPath, updateMatrixAccountConfig } from "./matrix/config-update.js";
import { updateMatrixAccountConfig } from "./matrix/config-update.js";
import { ensureMatrixSdkInstalled, isMatrixSdkAvailable } from "./matrix/deps.js";
import type { RuntimeEnv, WizardPrompter } from "./runtime-api.js";
import { moveSingleMatrixAccountConfigToNamedAccount } from "./setup-config.js";
import { resolveMatrixSetupDmAllowFrom } from "./setup-dm-policy.js";
import { createMatrixSetupDmPolicy } from "./setup-dm-policy.js";
import type { CoreConfig, MatrixConfig } from "./types.js";
const channel = "matrix" as const;
@@ -61,16 +61,6 @@ function isMatrixInviteAutoJoinTarget(entry: string): boolean {
);
}
function normalizeMatrixInviteAutoJoinTargets(entries: string[]): string[] {
return [
...new Set(
entries
.map((entry) => normalizeOptionalString(entry))
.filter((entry): entry is string => Boolean(entry)),
),
];
}
function resolveMatrixOnboardingAccountId(cfg: CoreConfig, accountId?: string): string {
return normalizeAccountId(
normalizeOptionalString(accountId) || resolveDefaultMatrixAccountId(cfg) || DEFAULT_ACCOUNT_ID,
@@ -181,24 +171,6 @@ async function promptMatrixAllowFrom(params: {
}
}
function setMatrixGroupPolicy(
cfg: CoreConfig,
groupPolicy: "open" | "allowlist" | "disabled",
accountId?: string,
) {
return updateMatrixAccountConfig(cfg, resolveMatrixOnboardingAccountId(cfg, accountId), {
groupPolicy,
});
}
function setMatrixGroupRooms(cfg: CoreConfig, roomKeys: string[], accountId?: string) {
const groups = Object.fromEntries(roomKeys.map((key) => [key, { enabled: true }]));
return updateMatrixAccountConfig(cfg, resolveMatrixOnboardingAccountId(cfg, accountId), {
groups,
rooms: null,
});
}
function setMatrixAutoJoin(
cfg: CoreConfig,
autoJoin: MatrixInviteAutoJoinPolicy,
@@ -276,7 +248,7 @@ async function configureMatrixInviteAutoJoin(params: {
return entries.length > 0 ? undefined : "Required";
},
});
const allowlist = normalizeMatrixInviteAutoJoinTargets(splitSetupEntries(rawAllowlist));
const allowlist = normalizeUniqueStringEntries(splitSetupEntries(rawAllowlist));
const invalidEntries = allowlist.filter((entry) => !isMatrixInviteAutoJoinTarget(entry));
if (allowlist.length === 0 || invalidEntries.length > 0) {
await params.prompter.note(
@@ -325,7 +297,9 @@ async function configureMatrixAccessPrompts(params: {
});
if (accessConfig) {
if (accessConfig.policy !== "allowlist") {
next = setMatrixGroupPolicy(next, accessConfig.policy, params.accountId);
next = updateMatrixAccountConfig(next, params.accountId, {
groupPolicy: accessConfig.policy,
});
} else {
let roomKeys = accessConfig.entries;
if (accessConfig.entries.length > 0) {
@@ -387,8 +361,11 @@ async function configureMatrixAccessPrompts(params: {
);
}
}
next = setMatrixGroupPolicy(next, "allowlist", params.accountId);
next = setMatrixGroupRooms(next, roomKeys, params.accountId);
next = updateMatrixAccountConfig(next, params.accountId, {
groupPolicy: "allowlist",
groups: Object.fromEntries(roomKeys.map((key) => [key, { enabled: true }])),
rooms: null,
});
}
}
@@ -399,43 +376,7 @@ async function configureMatrixAccessPrompts(params: {
});
}
const dmPolicy = createChannelDmPolicy({
label: "Matrix",
channel,
policyPath: "dm.policy",
allowFromPath: "dm.allowFrom",
resolveAccount: (cfg, accountId) => {
const accountIdResolved = resolveMatrixOnboardingAccountId(cfg as CoreConfig, accountId);
const config = resolveMatrixAccountConfig({
cfg: cfg as CoreConfig,
accountId: accountIdResolved,
});
return {
accountId: accountIdResolved,
config: { dmPolicy: config.dm?.policy, allowFrom: config.dm?.allowFrom, dm: config.dm },
};
},
resolveConfigKeys: ({ cfg, account }) => ({
policyKey: resolveMatrixConfigFieldPath(cfg as CoreConfig, account.accountId, "dm.policy"),
allowFromKey: resolveMatrixConfigFieldPath(
cfg as CoreConfig,
account.accountId,
"dm.allowFrom",
),
}),
resolveAllowFrom: ({ policy, account }) =>
resolveMatrixSetupDmAllowFrom(policy, account.config.allowFrom),
buildPatch: ({ account, policy, allowFrom }) => ({
dm: { ...account.config.dm, policy, allowFrom },
}),
applyPatch: ({ cfg, account, patch }) =>
updateMatrixAccountConfig(
cfg as CoreConfig,
account.accountId,
patch as Parameters<typeof updateMatrixAccountConfig>[2],
),
promptAllowFrom: promptMatrixAllowFrom,
});
const dmPolicy = createMatrixSetupDmPolicy(promptMatrixAllowFrom);
type MatrixConfigureIntent = "update" | "add-account";
@@ -690,44 +631,21 @@ export const matrixOnboardingAdapter: ChannelSetupWizardAdapter = {
selectionHint: !sdkReady ? "install Matrix deps" : configured ? "configured" : "needs auth",
};
},
configure: async ({
cfg,
runtime,
prompter,
forceAllowFrom,
accountOverrides,
shouldPromptAccountIds,
}) =>
configure: async (params) =>
await runMatrixConfigure({
cfg: cfg as CoreConfig,
runtime,
prompter,
forceAllowFrom,
accountOverrides,
shouldPromptAccountIds,
...params,
cfg: params.cfg as CoreConfig,
intent: "update",
}),
configureInteractive: async ({
cfg,
runtime,
prompter,
forceAllowFrom,
accountOverrides,
shouldPromptAccountIds,
configured,
}) => {
if (!configured) {
configureInteractive: async (params) => {
if (!params.configured) {
return await runMatrixConfigure({
cfg: cfg as CoreConfig,
runtime,
prompter,
forceAllowFrom,
accountOverrides,
shouldPromptAccountIds,
...params,
cfg: params.cfg as CoreConfig,
intent: "update",
});
}
const action = await prompter.select({
const action = await params.prompter.select({
message: "Matrix already configured. What do you want to do?",
options: [
{ value: "update", label: "Modify settings" },
@@ -740,12 +658,8 @@ export const matrixOnboardingAdapter: ChannelSetupWizardAdapter = {
return "skip";
}
return await runMatrixConfigure({
cfg: cfg as CoreConfig,
runtime,
prompter,
forceAllowFrom,
accountOverrides,
shouldPromptAccountIds,
...params,
cfg: params.cfg as CoreConfig,
intent: action === "add-account" ? "add-account" : "update",
});
},
@@ -767,4 +681,3 @@ export const matrixOnboardingAdapter: ChannelSetupWizardAdapter = {
},
}),
};
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
+4 -50
View File
@@ -1,7 +1,5 @@
import { createChannelDmPolicy } from "openclaw/plugin-sdk/channel-dm-policy";
import { defineChannelSetupContract } from "openclaw/plugin-sdk/channel-setup";
// Matrix plugin module implements setup core behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
@@ -9,15 +7,13 @@ import {
type ChannelSetupAdapter,
type ChannelSetupWizardAdapter,
} from "openclaw/plugin-sdk/setup";
import { resolveDefaultMatrixAccountId, resolveMatrixAccountConfig } from "./matrix/accounts.js";
import { resolveMatrixConfigFieldPath, updateMatrixAccountConfig } from "./matrix/config-update.js";
import { applyMatrixSetupAccountConfig, validateMatrixSetupInput } from "./setup-config.js";
import {
namedAccountPromotionKeys,
resolveSingleAccountPromotionTarget,
singleAccountKeysToMove,
} from "./setup-contract.js";
import { resolveMatrixSetupDmAllowFrom } from "./setup-dm-policy.js";
import { createMatrixSetupDmPolicy } from "./setup-dm-policy.js";
import type { CoreConfig } from "./types.js";
const channel = "matrix" as const;
@@ -27,12 +23,6 @@ function resolveMatrixSetupAccountId(params: { accountId?: string; name?: string
return normalizeAccountId(params.accountId?.trim() || params.name?.trim() || DEFAULT_ACCOUNT_ID);
}
function resolveMatrixSetupWizardAccountId(cfg: CoreConfig, accountId?: string): string {
return normalizeAccountId(
accountId?.trim() || resolveDefaultMatrixAccountId(cfg) || DEFAULT_ACCOUNT_ID,
);
}
export function createMatrixSetupWizardProxy(
loadWizardModule: () => Promise<MatrixSetupWizardModule>,
): ChannelSetupWizardAdapter {
@@ -58,45 +48,9 @@ export function createMatrixSetupWizardProxy(
)(ctx);
},
afterConfigWritten: async (ctx) => await (await loadWizard()).afterConfigWritten?.(ctx),
dmPolicy: createChannelDmPolicy({
label: "Matrix",
channel,
policyPath: "dm.policy",
allowFromPath: "dm.allowFrom",
resolveAccount: (cfg, accountId) => {
const accountIdResolved = resolveMatrixSetupWizardAccountId(cfg as CoreConfig, accountId);
const config = resolveMatrixAccountConfig({
cfg: cfg as CoreConfig,
accountId: accountIdResolved,
});
return {
accountId: accountIdResolved,
config: { dmPolicy: config.dm?.policy, allowFrom: config.dm?.allowFrom, dm: config.dm },
};
},
resolveConfigKeys: ({ cfg, account }) => ({
policyKey: resolveMatrixConfigFieldPath(cfg as CoreConfig, account.accountId, "dm.policy"),
allowFromKey: resolveMatrixConfigFieldPath(
cfg as CoreConfig,
account.accountId,
"dm.allowFrom",
),
}),
resolveAllowFrom: ({ policy, account }) =>
resolveMatrixSetupDmAllowFrom(policy, account.config.allowFrom),
buildPatch: ({ account, policy, allowFrom }) => ({
dm: { ...account.config.dm, policy, allowFrom },
}),
applyPatch: ({ cfg, account, patch }) =>
updateMatrixAccountConfig(
cfg as CoreConfig,
account.accountId,
patch as Parameters<typeof updateMatrixAccountConfig>[2],
) as OpenClawConfig,
promptAllowFrom: async (params) => {
const promptAllowFrom = (await loadWizard()).dmPolicy?.promptAllowFrom;
return promptAllowFrom ? await promptAllowFrom(params) : params.cfg;
},
dmPolicy: createMatrixSetupDmPolicy(async (params) => {
const promptAllowFrom = (await loadWizard()).dmPolicy?.promptAllowFrom;
return promptAllowFrom ? await promptAllowFrom(params) : params.cfg;
}),
disable: (cfg) => ({
...(cfg as CoreConfig),
+57 -6
View File
@@ -1,16 +1,67 @@
// Matrix plugin module implements setup dm policy behavior.
import { createChannelDmPolicy } from "openclaw/plugin-sdk/channel-dm-policy";
import type { DmPolicy } from "openclaw/plugin-sdk/config-contracts";
import { addWildcardAllowFrom, normalizeAllowFromEntries } from "openclaw/plugin-sdk/setup";
import type { MatrixConfig } from "./types.js";
import {
addWildcardAllowFrom,
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
normalizeAllowFromEntries,
type ChannelSetupDmPolicy,
} from "openclaw/plugin-sdk/setup";
import { resolveDefaultMatrixAccountId, resolveMatrixAccountConfig } from "./matrix/accounts.js";
import { resolveMatrixConfigFieldPath, updateMatrixAccountConfig } from "./matrix/config-update.js";
import type { CoreConfig, MatrixConfig } from "./types.js";
type MatrixDmAllowFrom = NonNullable<MatrixConfig["dm"]>["allowFrom"];
export function resolveMatrixSetupDmAllowFrom(
policy: DmPolicy,
allowFrom: MatrixDmAllowFrom,
): string[] {
function resolveMatrixSetupDmAllowFrom(policy: DmPolicy, allowFrom: MatrixDmAllowFrom): string[] {
if (policy === "open") {
return addWildcardAllowFrom(allowFrom);
}
return normalizeAllowFromEntries(allowFrom ?? []).filter((entry) => entry !== "*");
}
export function createMatrixSetupDmPolicy(
promptAllowFrom: NonNullable<ChannelSetupDmPolicy["promptAllowFrom"]>,
): ChannelSetupDmPolicy {
return createChannelDmPolicy({
label: "Matrix",
channel: "matrix",
policyPath: "dm.policy",
allowFromPath: "dm.allowFrom",
resolveAccount: (cfg, accountId) => {
const resolvedCfg = cfg as CoreConfig;
const resolvedAccountId = normalizeAccountId(
accountId?.trim() || resolveDefaultMatrixAccountId(resolvedCfg) || DEFAULT_ACCOUNT_ID,
);
const config = resolveMatrixAccountConfig({
cfg: resolvedCfg,
accountId: resolvedAccountId,
});
return {
accountId: resolvedAccountId,
config: { dmPolicy: config.dm?.policy, allowFrom: config.dm?.allowFrom, dm: config.dm },
};
},
resolveConfigKeys: ({ cfg, account }) => ({
policyKey: resolveMatrixConfigFieldPath(cfg as CoreConfig, account.accountId, "dm.policy"),
allowFromKey: resolveMatrixConfigFieldPath(
cfg as CoreConfig,
account.accountId,
"dm.allowFrom",
),
}),
resolveAllowFrom: ({ policy, account }) =>
resolveMatrixSetupDmAllowFrom(policy, account.config.allowFrom),
buildPatch: ({ account, policy, allowFrom }) => ({
dm: { ...account.config.dm, policy, allowFrom },
}),
applyPatch: ({ cfg, account, patch }) =>
updateMatrixAccountConfig(
cfg as CoreConfig,
account.accountId,
patch as Parameters<typeof updateMatrixAccountConfig>[2],
),
promptAllowFrom,
});
}
@@ -20,6 +20,10 @@ import {
openOpenClawAgentDatabase,
} from "openclaw/plugin-sdk/sqlite-runtime-testing";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
configureMemoryCoreDreamingStateForTests,
resetMemoryCoreDreamingStateForTests,
} from "../test-helpers.js";
import "./test-runtime-mocks.js";
import type { MemoryIndexManager } from "./index.js";
import { closeAllMemorySearchManagers, getMemorySearchManager } from "./index.js";
@@ -314,6 +318,7 @@ describe("memory index", () => {
await closeAllMemorySearchManagers();
closeOpenClawAgentDatabasesForTest();
closeOpenClawStateDatabaseForTest();
resetMemoryCoreDreamingStateForTests();
clearRegistry();
managersForCleanup.clear();
restoreMemoryIndexStateDir();
@@ -344,6 +349,7 @@ describe("memory index", () => {
rmSync(workspaceDir, { recursive: true, force: true });
mkdirSync(memoryDir, { recursive: true });
setMemoryIndexStateDir(path.join(workspaceDir, ".state-memory-index"));
await configureMemoryCoreDreamingStateForTests();
await fs.writeFile(
path.join(memoryDir, "2026-01-12.md"),
"# Log\nAlpha memory line.\nZebra memory line.",
@@ -593,67 +593,13 @@ export abstract class MemoryManagerEmbeddingOps extends MemoryManagerSyncOps {
texts: string[],
generation?: MemorySemanticProviderGeneration,
): Promise<number[][]> {
if (texts.length === 0) {
return [];
}
const provider = generation?.provider ?? this.provider;
if (!provider) {
throw new Error("Cannot embed batch in FTS-only mode (no embedding provider)");
}
try {
return await this.withProviderUse(
provider,
async () =>
await runMemoryEmbeddingBatchRetryWithSplit({
items: texts,
run: async (batchTexts) => {
const timeoutMs = this.resolveEmbeddingTimeout(
"batch",
provider,
generation?.runtime,
);
log.debug("memory embeddings: batch start", {
provider: provider.id,
items: batchTexts.length,
timeoutMs,
});
const result = await runEmbeddingOperationWithTimeout({
timeoutMs,
message: `memory embeddings batch timed out after ${Math.round(timeoutMs / 1000)}s`,
run: async (signal) => await provider.embedBatch(batchTexts, { signal }),
});
log.debug("memory embeddings: batch completed", {
provider: provider.id,
items: batchTexts.length,
});
return result;
},
isRetryable: isRetryableMemoryEmbeddingError,
isSplittable: isSplittableMemoryEmbeddingTransportError,
waitForRetry: async (delayMs) => {
await this.waitForEmbeddingRetry(delayMs, "retrying");
},
maxAttempts: EMBEDDING_RETRY_MAX_ATTEMPTS,
baseDelayMs: EMBEDDING_RETRY_BASE_DELAY_MS,
onSplit: ({ itemCount, splitAt }) => {
log.warn(
`memory embeddings transport failed after retries; splitting batch of ${itemCount} into ${splitAt} + ${itemCount - splitAt}`,
);
},
}),
);
} catch (err) {
log.debug("memory embeddings: batch failed", {
provider: provider.id,
error: formatErrorMessage(err),
});
this.markLocalEmbeddingProviderDegraded(err);
throw createMemoryEmbeddingOperationError({
operation: "batch",
providerId: provider.id,
cause: err,
});
}
return await this.runProviderBatchWithRetry({
items: texts,
generation,
operation: "batch",
run: async (provider, batchTexts, signal) =>
await provider.embedBatch(batchTexts, { signal }),
});
}
protected async embedBatchInputsWithRetry(
@@ -671,47 +617,87 @@ export abstract class MemoryManagerEmbeddingOps extends MemoryManagerSyncOps {
generation,
);
}
return await this.runProviderBatchWithRetry({
items: inputs,
generation,
operation: "structured-batch",
run: async (_provider, batchInputs, signal) =>
await embedBatchInputs(batchInputs, { signal }),
});
}
private async runProviderBatchWithRetry<T>(params: {
items: T[];
generation?: MemorySemanticProviderGeneration;
operation: "batch" | "structured-batch";
run: (provider: EmbeddingProvider, items: T[], signal: AbortSignal) => Promise<number[][]>;
}): Promise<number[][]> {
if (params.items.length === 0) {
return [];
}
const provider = params.generation?.provider ?? this.provider;
if (!provider) {
throw new Error("Cannot embed batch in FTS-only mode (no embedding provider)");
}
const structured = params.operation === "structured-batch";
const label = structured ? "structured batch" : "batch";
try {
return await this.withProviderUse(
provider,
async () =>
await runMemoryEmbeddingBatchRetryWithSplit({
items: inputs,
run: async (batchInputs) => {
items: params.items,
run: async (batchItems) => {
const timeoutMs = this.resolveEmbeddingTimeout(
"batch",
provider,
generation?.runtime,
params.generation?.runtime,
);
log.debug("memory embeddings: structured batch start", {
log.debug(`memory embeddings: ${label} start`, {
provider: provider.id,
items: batchInputs.length,
items: batchItems.length,
timeoutMs,
});
return await runEmbeddingOperationWithTimeout({
const result = await runEmbeddingOperationWithTimeout({
timeoutMs,
message: `memory embeddings batch timed out after ${Math.round(timeoutMs / 1000)}s`,
run: async (signal) => await embedBatchInputs(batchInputs, { signal }),
run: async (signal) => await params.run(provider, batchItems, signal),
});
if (!structured) {
log.debug("memory embeddings: batch completed", {
provider: provider.id,
items: batchItems.length,
});
}
return result;
},
isRetryable: isRetryableMemoryEmbeddingError,
isSplittable: isSplittableMemoryEmbeddingTransportError,
waitForRetry: async (delayMs) => {
await this.waitForEmbeddingRetry(delayMs, "retrying structured batch");
await this.waitForEmbeddingRetry(
delayMs,
structured ? "retrying structured batch" : "retrying",
);
},
maxAttempts: EMBEDDING_RETRY_MAX_ATTEMPTS,
baseDelayMs: EMBEDDING_RETRY_BASE_DELAY_MS,
onSplit: ({ itemCount, splitAt }) => {
log.warn(
`memory embeddings transport failed after retries; splitting structured batch of ${itemCount} into ${splitAt} + ${itemCount - splitAt}`,
`memory embeddings transport failed after retries; splitting ${label} of ${itemCount} into ${splitAt} + ${itemCount - splitAt}`,
);
},
}),
);
} catch (err) {
if (!structured) {
log.debug("memory embeddings: batch failed", {
provider: provider.id,
error: formatErrorMessage(err),
});
}
this.markLocalEmbeddingProviderDegraded(err);
throw createMemoryEmbeddingOperationError({
operation: "structured-batch",
operation: params.operation,
providerId: provider.id,
cause: err,
});
+11 -22
View File
@@ -1498,27 +1498,8 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
activeProjectKeys?: string[];
}): Promise<MemorySearchResult[]> {
const limit = Math.max(1, Math.min(512, Math.floor(opts?.limit ?? 512)));
return readCuratedMemoryTriggerCandidates(this.db, limit, opts?.activeProjectKeys).map(
(row) => {
const result: MemorySearchResult = {
path: row.path,
startLine: row.start_line,
endLine: row.end_line,
score: 0,
snippet: row.text,
source: "memory",
};
if (typeof row.importance === "number") {
result.importance = row.importance;
}
if (typeof row.triggers === "string" && row.triggers.trim()) {
result.triggers = row.triggers.trim();
}
if (typeof row.project_key === "string" && row.project_key.trim()) {
result.projectKey = row.project_key.trim();
}
return result;
},
return this.toCuratedMemorySearchResults(
readCuratedMemoryTriggerCandidates(this.db, limit, opts?.activeProjectKeys),
);
}
@@ -1527,7 +1508,15 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
limit?: number;
}): Promise<MemorySearchResult[]> {
const limit = Math.max(1, Math.min(512, Math.floor(opts.limit ?? 48)));
return readCuratedProjectMemoryCandidates(this.db, limit, opts.activeProjectKeys).map((row) => {
return this.toCuratedMemorySearchResults(
readCuratedProjectMemoryCandidates(this.db, limit, opts.activeProjectKeys),
);
}
private toCuratedMemorySearchResults(
rows: ReturnType<typeof readCuratedMemoryTriggerCandidates>,
): MemorySearchResult[] {
return rows.map((row) => {
const result: MemorySearchResult = {
path: row.path,
startLine: row.start_line,
@@ -1,13 +1,13 @@
// Memory Core QMD runtime cache helpers.
import { createHash } from "node:crypto";
import type { PluginStateKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import { memoryCoreWorkspaceEntryKey, openMemoryCoreStateStore } from "../dreaming-state.js";
const QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_NAMESPACE = "qmd-runtime-cache.collection-validation";
const QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE =
"qmd-runtime-cache.multi-collection-probe";
const QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_MAX_ENTRIES = 1_000;
const QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_MAX_ENTRIES = 1_000;
const QMD_RUNTIME_CACHE_MAX_ENTRIES = 1_000;
const QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_TTL_MS = 5 * 60_000;
const QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_TTL_MS = 10 * 60_000;
@@ -39,11 +39,14 @@ export type QmdRuntimeMultiCollectionProbeCacheContext = QmdRuntimeCacheContextB
sources: readonly string[];
};
type QmdRuntimeCacheCollectionValidationEntry = {
type QmdRuntimeCacheEntryBase = {
version: 1;
createdAtMs: number;
expiresAtMs: number;
keyHash: string;
};
type QmdRuntimeCacheCollectionValidationEntry = QmdRuntimeCacheEntryBase & {
validation: {
ok: true;
collectionConfigHash: string;
@@ -51,11 +54,7 @@ type QmdRuntimeCacheCollectionValidationEntry = {
};
};
type QmdRuntimeCacheMultiCollectionProbeEntry = {
version: 1;
createdAtMs: number;
expiresAtMs: number;
keyHash: string;
type QmdRuntimeCacheMultiCollectionProbeEntry = QmdRuntimeCacheEntryBase & {
multiCollectionProbe: {
supported: boolean;
};
@@ -72,15 +71,6 @@ function normalizeText(value: string): string {
return value.trim();
}
function normalizeCollection(collection: QmdRuntimeManagedCollection) {
return {
name: normalizeText(collection.name),
kind: collection.kind,
pathHash: normalizePathIdentity(collection.path),
pattern: normalizeText(collection.pattern),
};
}
function hashText(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
@@ -98,7 +88,10 @@ function sortedUnique(values: readonly string[]): string[] {
function buildCollectionConfigHash(collections: readonly QmdRuntimeManagedCollection[]): string {
const normalized = collections
.map((collection) => ({
...normalizeCollection(collection),
name: normalizeText(collection.name),
kind: collection.kind,
pathHash: normalizePathIdentity(collection.path),
pattern: normalizeText(collection.pattern),
}))
.toSorted(
(left, right) =>
@@ -126,59 +119,32 @@ function buildRuntimeCacheContextRecord(
};
}
function buildCollectionValidationCacheContextInput(
params: QmdRuntimeCollectionValidationCacheContext,
): string {
return JSON.stringify({
...buildRuntimeCacheContextRecord(params),
collectionConfigHash: buildCollectionConfigHash(params.collections),
});
}
function buildMultiCollectionProbeCacheContextInput(
params: QmdRuntimeMultiCollectionProbeCacheContext,
): string {
return JSON.stringify(buildRuntimeCacheContextRecord(params));
}
function buildQmdCollectionValidationCacheContextHash(
params: QmdRuntimeCollectionValidationCacheContext,
): string {
return hashText(buildCollectionValidationCacheContextInput(params));
return hashText(
JSON.stringify({
...buildRuntimeCacheContextRecord(params),
collectionConfigHash: buildCollectionConfigHash(params.collections),
}),
);
}
function buildQmdMultiCollectionProbeCacheContextHash(
params: QmdRuntimeMultiCollectionProbeCacheContext,
): string {
return hashText(buildMultiCollectionProbeCacheContextInput(params));
return hashText(JSON.stringify(buildRuntimeCacheContextRecord(params)));
}
function collectionValidationStore(): PluginStateKeyedStore<QmdRuntimeCacheCollectionValidationEntry> {
return openMemoryCoreStateStore<QmdRuntimeCacheCollectionValidationEntry>({
namespace: QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_NAMESPACE,
maxEntries: QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_MAX_ENTRIES,
});
}
function multiCollectionProbeStore(): PluginStateKeyedStore<QmdRuntimeCacheMultiCollectionProbeEntry> {
return openMemoryCoreStateStore<QmdRuntimeCacheMultiCollectionProbeEntry>({
namespace: QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE,
maxEntries: QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_MAX_ENTRIES,
});
}
function collectionValidationEntryKey(params: QmdRuntimeCollectionValidationCacheContext): string {
return memoryCoreWorkspaceEntryKey(
params.workspaceDir,
`qmd-runtime-cache.collection-validation:${buildQmdCollectionValidationCacheContextHash(params)}`,
);
}
function multiCollectionProbeEntryKey(params: QmdRuntimeMultiCollectionProbeCacheContext): string {
return memoryCoreWorkspaceEntryKey(
params.workspaceDir,
`qmd-runtime-cache.multi-collection-probe:${buildQmdMultiCollectionProbeCacheContextHash(params)}`,
);
function resolveQmdRuntimeCache<T extends QmdRuntimeCacheEntryBase>(
workspaceDir: string,
namespace: string,
keyHash: string,
): { key: string; store: PluginStateKeyedStore<T> } {
return {
key: memoryCoreWorkspaceEntryKey(workspaceDir, `${namespace}:${keyHash}`),
store: openMemoryCoreStateStore<T>({ namespace, maxEntries: QMD_RUNTIME_CACHE_MAX_ENTRIES }),
};
}
type QmdRuntimeCacheEnvelope = {
@@ -194,11 +160,8 @@ function normalizeCacheEntryEnvelope(
nowMs: number,
expectedKeyHash: string,
): QmdRuntimeCacheEnvelope | undefined {
if (typeof value !== "object" || value === null) {
return undefined;
}
const record = value as Record<string, unknown>;
if (record.version !== QMD_RUNTIME_CACHE_ENTRY_VERSION) {
const record = asOptionalRecord(value);
if (!record || record.version !== QMD_RUNTIME_CACHE_ENTRY_VERSION) {
return undefined;
}
@@ -238,18 +201,13 @@ function normalizeCollectionValidationEntry(
}
const { record, createdAtMs, expiresAtMs, keyHash } = envelope;
const validation = record.validation;
if (typeof validation !== "object" || validation === null) {
return undefined;
}
const validationRecord = validation as Record<string, unknown>;
if (validationRecord.ok !== true) {
return undefined;
}
if (typeof validationRecord.collectionConfigHash !== "string") {
return undefined;
}
if (typeof validationRecord.collectionCount !== "number") {
const validation = asOptionalRecord(record.validation);
if (
!validation ||
validation.ok !== true ||
typeof validation.collectionConfigHash !== "string" ||
typeof validation.collectionCount !== "number"
) {
return undefined;
}
@@ -260,8 +218,8 @@ function normalizeCollectionValidationEntry(
keyHash,
validation: {
ok: true,
collectionConfigHash: normalizeText(validationRecord.collectionConfigHash),
collectionCount: Math.max(0, Math.floor(validationRecord.collectionCount)),
collectionConfigHash: normalizeText(validation.collectionConfigHash),
collectionCount: Math.max(0, Math.floor(validation.collectionCount)),
},
};
}
@@ -277,12 +235,8 @@ function normalizeMultiCollectionProbeEntry(
}
const { record, createdAtMs, expiresAtMs, keyHash } = envelope;
const probe = record.multiCollectionProbe;
if (typeof probe !== "object" || probe === null) {
return undefined;
}
const probeRecord = probe as Record<string, unknown>;
if (typeof probeRecord.supported !== "boolean") {
const probe = asOptionalRecord(record.multiCollectionProbe);
if (!probe || typeof probe.supported !== "boolean") {
return undefined;
}
@@ -292,55 +246,57 @@ function normalizeMultiCollectionProbeEntry(
expiresAtMs,
keyHash,
multiCollectionProbe: {
supported: probeRecord.supported,
supported: probe.supported,
},
};
}
export async function readQmdCollectionValidationCache(
params: QmdRuntimeCollectionValidationCacheContext,
nowMs = Date.now(),
): Promise<QmdRuntimeCacheResult<QmdRuntimeCacheCollectionValidationEntry>> {
async function readQmdRuntimeCache<T extends QmdRuntimeCacheEntryBase>(params: {
workspaceDir: string;
namespace: string;
keyHash: string;
nowMs: number;
normalize: (value: unknown, nowMs: number, keyHash: string) => T | undefined;
}): Promise<QmdRuntimeCacheResult<T>> {
try {
const store = collectionValidationStore();
const key = collectionValidationEntryKey(params);
const expectedKeyHash = buildQmdCollectionValidationCacheContextHash(params);
const { store, key } = resolveQmdRuntimeCache<T>(
params.workspaceDir,
params.namespace,
params.keyHash,
);
const raw = await store.lookup(key);
if (!raw) {
return { state: "miss" };
}
const validated = normalizeCollectionValidationEntry(raw, nowMs, expectedKeyHash);
const validated = raw && params.normalize(raw, params.nowMs, params.keyHash);
return validated ? { state: "hit", value: validated } : { state: "miss" };
} catch {
return { state: "miss" };
}
}
export async function writeQmdCollectionValidationCache(
params: QmdRuntimeCollectionValidationCacheContext,
nowMs = Date.now(),
): Promise<boolean> {
async function writeQmdRuntimeCache<T extends QmdRuntimeCacheEntryBase>(params: {
workspaceDir: string;
namespace: string;
keyHash: string;
nowMs: number;
ttlMs: number;
payload: Omit<T, keyof QmdRuntimeCacheEntryBase>;
}): Promise<boolean> {
try {
const key = collectionValidationEntryKey(params);
const keyHash = buildQmdCollectionValidationCacheContextHash(params);
const collectionConfigHash = buildCollectionConfigHash(params.collections);
const createdAtMs = Math.max(0, Math.floor(nowMs));
const ttlMs = QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_TTL_MS;
const store = collectionValidationStore();
const { store, key } = resolveQmdRuntimeCache<T>(
params.workspaceDir,
params.namespace,
params.keyHash,
);
const createdAtMs = Math.max(0, Math.floor(params.nowMs));
await store.register(
key,
{
version: QMD_RUNTIME_CACHE_ENTRY_VERSION,
createdAtMs,
expiresAtMs: createdAtMs + ttlMs,
keyHash,
validation: {
ok: true,
collectionConfigHash,
collectionCount: params.collections.length,
},
},
{ ttlMs },
expiresAtMs: createdAtMs + params.ttlMs,
keyHash: params.keyHash,
...params.payload,
} as T,
{ ttlMs: params.ttlMs },
);
return true;
} catch {
@@ -348,23 +304,50 @@ export async function writeQmdCollectionValidationCache(
}
}
export async function readQmdCollectionValidationCache(
params: QmdRuntimeCollectionValidationCacheContext,
nowMs = Date.now(),
): Promise<QmdRuntimeCacheResult<QmdRuntimeCacheCollectionValidationEntry>> {
return await readQmdRuntimeCache({
workspaceDir: params.workspaceDir,
namespace: QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_NAMESPACE,
keyHash: buildQmdCollectionValidationCacheContextHash(params),
nowMs,
normalize: normalizeCollectionValidationEntry,
});
}
export async function writeQmdCollectionValidationCache(
params: QmdRuntimeCollectionValidationCacheContext,
nowMs = Date.now(),
): Promise<boolean> {
return await writeQmdRuntimeCache<QmdRuntimeCacheCollectionValidationEntry>({
workspaceDir: params.workspaceDir,
namespace: QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_NAMESPACE,
keyHash: buildQmdCollectionValidationCacheContextHash(params),
nowMs,
ttlMs: QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_TTL_MS,
payload: {
validation: {
ok: true,
collectionConfigHash: buildCollectionConfigHash(params.collections),
collectionCount: params.collections.length,
},
},
});
}
export async function readQmdMultiCollectionProbeCache(
params: QmdRuntimeMultiCollectionProbeCacheContext,
nowMs = Date.now(),
): Promise<QmdRuntimeCacheResult<QmdRuntimeCacheMultiCollectionProbeEntry>> {
try {
const store = multiCollectionProbeStore();
const key = multiCollectionProbeEntryKey(params);
const expectedKeyHash = buildQmdMultiCollectionProbeCacheContextHash(params);
const raw = await store.lookup(key);
if (!raw) {
return { state: "miss" };
}
const validated = normalizeMultiCollectionProbeEntry(raw, nowMs, expectedKeyHash);
return validated ? { state: "hit", value: validated } : { state: "miss" };
} catch {
return { state: "miss" };
}
return await readQmdRuntimeCache({
workspaceDir: params.workspaceDir,
namespace: QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE,
keyHash: buildQmdMultiCollectionProbeCacheContextHash(params),
nowMs,
normalize: normalizeMultiCollectionProbeEntry,
});
}
export async function writeQmdMultiCollectionProbeCache(
@@ -372,37 +355,26 @@ export async function writeQmdMultiCollectionProbeCache(
supported: boolean,
nowMs = Date.now(),
): Promise<boolean> {
try {
const key = multiCollectionProbeEntryKey(params);
const keyHash = buildQmdMultiCollectionProbeCacheContextHash(params);
const createdAtMs = Math.max(0, Math.floor(nowMs));
const ttlMs = QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_TTL_MS;
const store = multiCollectionProbeStore();
await store.register(
key,
{
version: QMD_RUNTIME_CACHE_ENTRY_VERSION,
createdAtMs,
expiresAtMs: createdAtMs + ttlMs,
keyHash,
multiCollectionProbe: {
supported,
},
},
{ ttlMs },
);
return true;
} catch {
return false;
}
return await writeQmdRuntimeCache<QmdRuntimeCacheMultiCollectionProbeEntry>({
workspaceDir: params.workspaceDir,
namespace: QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE,
keyHash: buildQmdMultiCollectionProbeCacheContextHash(params),
nowMs,
ttlMs: QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_TTL_MS,
payload: { multiCollectionProbe: { supported } },
});
}
export async function clearQmdMultiCollectionProbeCache(
params: QmdRuntimeMultiCollectionProbeCacheContext,
): Promise<void> {
try {
const store = multiCollectionProbeStore();
await store.delete(multiCollectionProbeEntryKey(params));
const { store, key } = resolveQmdRuntimeCache<QmdRuntimeCacheMultiCollectionProbeEntry>(
params.workspaceDir,
QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE,
buildQmdMultiCollectionProbeCacheContextHash(params),
);
await store.delete(key);
} catch {
// fail open
}
@@ -19,8 +19,6 @@ import {
resolveMemoryBackendConfig,
type MemoryEmbeddingProbeResult,
type MemorySearchManager,
type MemorySearchRuntimeDebug,
type MemorySource,
type MemorySyncParams,
type ResolvedQmdConfig,
} from "openclaw/plugin-sdk/memory-core-host-engine-storage";
@@ -40,6 +38,7 @@ import {
const MEMORY_SEARCH_MANAGER_CACHE_KEY = Symbol.for("openclaw.memorySearchManagerCache");
type Maybe<T> = T | null;
type MemoryManagerSearchOptions = Parameters<MemorySearchManager["search"]>[1];
type QmdManagerRuntimeConfig = {
workspaceDir: string;
syncSettings: ReturnType<typeof resolveMemorySearchSyncConfig>;
@@ -642,20 +641,7 @@ class BorrowedMemoryManager implements MemorySearchManager {
}
}
async search(
query: string,
opts?: {
maxResults?: number;
minScore?: number;
sessionKey?: string;
lexicalOnly?: boolean;
activeProjectKeys?: string[];
qmdSearchModeOverride?: "query" | "search" | "vsearch";
onDebug?: (debug: MemorySearchRuntimeDebug) => void;
sources?: MemorySource[];
signal?: AbortSignal;
},
) {
async search(query: string, opts?: MemoryManagerSearchOptions) {
return await this.inner.search(query, opts);
}
@@ -824,20 +810,7 @@ class FallbackMemoryManager implements MemorySearchManager {
private readonly onClose?: () => void,
) {}
async search(
query: string,
opts?: {
maxResults?: number;
minScore?: number;
sessionKey?: string;
lexicalOnly?: boolean;
activeProjectKeys?: string[];
qmdSearchModeOverride?: "query" | "search" | "vsearch";
onDebug?: (debug: MemorySearchRuntimeDebug) => void;
sources?: MemorySource[];
signal?: AbortSignal;
},
) {
async search(query: string, opts?: MemoryManagerSearchOptions) {
this.ensureOpen();
if (!this.primaryFailed) {
try {
@@ -913,26 +886,13 @@ class FallbackMemoryManager implements MemorySearchManager {
if (!this.primaryFailed) {
return this.deps.primary.status();
}
const fallbackStatus = this.fallback?.status();
const fallbackStatus = this.fallback?.status() ?? this.deps.primary.status();
const fallbackInfo = { from: "qmd", reason: this.lastError ?? "unknown" };
if (fallbackStatus) {
const custom = fallbackStatus.custom ?? {};
return {
...fallbackStatus,
fallback: fallbackInfo,
custom: {
...custom,
fallback: { disabled: true, reason: this.lastError ?? "unknown" },
},
};
}
const primaryStatus = this.deps.primary.status();
const custom = primaryStatus.custom ?? {};
return {
...primaryStatus,
...fallbackStatus,
fallback: fallbackInfo,
custom: {
...custom,
...fallbackStatus.custom,
fallback: { disabled: true, reason: this.lastError ?? "unknown" },
},
};
@@ -0,0 +1,49 @@
import type { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import type { fetchWithTimeoutGuarded, postJsonRequest } from "openclaw/plugin-sdk/provider-http";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
export const DEFAULT_MINIMAX_MEDIA_BASE_URL = "https://api.minimax.io";
export type MinimaxBaseResp = {
status_code?: number;
status_msg?: string;
};
export type MinimaxRequestPolicy = Pick<
Parameters<typeof postJsonRequest>[0],
"allowPrivateNetwork" | "dispatcherPolicy"
>;
export function resolveMinimaxMediaBaseUrl(
cfg: Parameters<typeof resolveApiKeyForProvider>[0]["cfg"],
providerId: string,
): string {
const configured = normalizeOptionalString(cfg?.models?.providers?.[providerId]?.baseUrl);
try {
return configured ? new URL(configured).origin : DEFAULT_MINIMAX_MEDIA_BASE_URL;
} catch {
return DEFAULT_MINIMAX_MEDIA_BASE_URL;
}
}
export function assertMinimaxBaseResp(
baseResp: MinimaxBaseResp | undefined,
context: string,
): void {
if (baseResp && typeof baseResp.status_code === "number" && baseResp.status_code !== 0) {
throw new Error(
`${context} (${baseResp.status_code}): ${baseResp.status_msg ?? "unknown error"}`,
);
}
}
export function resolveMinimaxGuardedRequestOptions(
policy: MinimaxRequestPolicy,
): Parameters<typeof fetchWithTimeoutGuarded>[4] | undefined {
return policy.allowPrivateNetwork || policy.dispatcherPolicy
? {
...(policy.allowPrivateNetwork ? { ssrfPolicy: { allowPrivateNetwork: true } } : {}),
...(policy.dispatcherPolicy ? { dispatcherPolicy: policy.dispatcherPolicy } : {}),
}
: undefined;
}
+10 -49
View File
@@ -21,19 +21,21 @@ import {
} from "openclaw/plugin-sdk/provider-http";
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
assertMinimaxBaseResp,
DEFAULT_MINIMAX_MEDIA_BASE_URL,
resolveMinimaxGuardedRequestOptions,
resolveMinimaxMediaBaseUrl,
type MinimaxBaseResp,
type MinimaxRequestPolicy,
} from "./media-provider-runtime.js";
const DEFAULT_MINIMAX_MUSIC_BASE_URL = "https://api.minimax.io";
const DEFAULT_MINIMAX_MUSIC_MODEL = "music-2.6";
const DEFAULT_TIMEOUT_MS = 120_000;
const DEFAULT_OPERATION_TIMEOUT_MS = 300_000;
const STREAM_ENVELOPE_MAX_BYTES_MULTIPLIER = 5;
const STREAM_ENVELOPE_OVERHEAD_BYTES = 64 * 1024;
type MinimaxBaseResp = {
status_code?: number;
status_msg?: string;
};
type MinimaxMusicCreateResponse = {
task_id?: string;
audio?: string;
@@ -55,47 +57,6 @@ type MinimaxMusicStreamFrame = {
base_resp?: MinimaxBaseResp;
};
type MinimaxRequestPolicy = Pick<
Parameters<typeof postJsonRequest>[0],
"allowPrivateNetwork" | "dispatcherPolicy"
>;
function resolveMinimaxMusicBaseUrl(
cfg: Parameters<typeof resolveApiKeyForProvider>[0]["cfg"],
providerId: string,
): string {
const direct = normalizeOptionalString(cfg?.models?.providers?.[providerId]?.baseUrl);
if (!direct) {
return DEFAULT_MINIMAX_MUSIC_BASE_URL;
}
try {
return new URL(direct).origin;
} catch {
return DEFAULT_MINIMAX_MUSIC_BASE_URL;
}
}
function assertMinimaxBaseResp(baseResp: MinimaxBaseResp | undefined, context: string): void {
if (!baseResp || typeof baseResp.status_code !== "number" || baseResp.status_code === 0) {
return;
}
throw new Error(
`${context} (${baseResp.status_code}): ${baseResp.status_msg ?? "unknown error"}`,
);
}
function resolveMinimaxGuardedRequestOptions(
policy: MinimaxRequestPolicy,
): Parameters<typeof fetchWithTimeoutGuarded>[4] | undefined {
if (!policy.allowPrivateNetwork && !policy.dispatcherPolicy) {
return undefined;
}
return {
...(policy.allowPrivateNetwork ? { ssrfPolicy: { allowPrivateNetwork: true } } : {}),
...(policy.dispatcherPolicy ? { dispatcherPolicy: policy.dispatcherPolicy } : {}),
};
}
function decodeHexAudioWithLimit(data: string, maxBytes: number): Buffer {
const trimmed = data.trim();
if (!/^[0-9a-f]+$/iu.test(trimmed) || trimmed.length % 2 !== 0) {
@@ -338,8 +299,8 @@ function buildMinimaxMusicProvider(providerId: string): MusicGenerationProvider
});
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
resolveProviderHttpRequestConfig({
baseUrl: resolveMinimaxMusicBaseUrl(req.cfg, providerId),
defaultBaseUrl: DEFAULT_MINIMAX_MUSIC_BASE_URL,
baseUrl: resolveMinimaxMediaBaseUrl(req.cfg, providerId),
defaultBaseUrl: DEFAULT_MINIMAX_MEDIA_BASE_URL,
defaultHeaders: {
Authorization: `Bearer ${auth.apiKey}`,
},
+10 -49
View File
@@ -27,8 +27,15 @@ import type {
VideoGenerationProvider,
VideoGenerationRequest,
} from "openclaw/plugin-sdk/video-generation";
import {
assertMinimaxBaseResp,
DEFAULT_MINIMAX_MEDIA_BASE_URL,
resolveMinimaxGuardedRequestOptions,
resolveMinimaxMediaBaseUrl,
type MinimaxBaseResp,
type MinimaxRequestPolicy,
} from "./media-provider-runtime.js";
const DEFAULT_MINIMAX_VIDEO_BASE_URL = "https://api.minimax.io";
const DEFAULT_MINIMAX_VIDEO_MODEL = "MiniMax-Hailuo-2.3";
const DEFAULT_TIMEOUT_MS = 120_000;
const DEFAULT_OPERATION_TIMEOUT_MS = 1_200_000;
@@ -45,11 +52,6 @@ const MINIMAX_MODEL_ALLOWED_RESOLUTIONS: Readonly<Record<string, readonly string
};
const MINIMAX_RESOLUTION_ORDER = ["480P", "720P", "768P", "1080P"] as const;
type MinimaxBaseResp = {
status_code?: number;
status_msg?: string;
};
type MinimaxCreateResponse = {
task_id?: string;
base_resp?: MinimaxBaseResp;
@@ -71,40 +73,11 @@ type MinimaxFileRetrieveResponse = {
base_resp?: MinimaxBaseResp;
};
type MinimaxRequestPolicy = Pick<
Parameters<typeof postJsonRequest>[0],
"allowPrivateNetwork" | "dispatcherPolicy"
>;
type MinimaxResponseHandle = {
response: Response;
release: () => Promise<void>;
};
function resolveMinimaxVideoBaseUrl(
cfg: Parameters<typeof resolveApiKeyForProvider>[0]["cfg"],
providerId: string,
): string {
const direct = normalizeOptionalString(cfg?.models?.providers?.[providerId]?.baseUrl);
if (!direct) {
return DEFAULT_MINIMAX_VIDEO_BASE_URL;
}
try {
return new URL(direct).origin;
} catch {
return DEFAULT_MINIMAX_VIDEO_BASE_URL;
}
}
function assertMinimaxBaseResp(baseResp: MinimaxBaseResp | undefined, context: string): void {
if (!baseResp || typeof baseResp.status_code !== "number" || baseResp.status_code === 0) {
return;
}
throw new Error(
`${context} (${baseResp.status_code}): ${baseResp.status_msg ?? "unknown error"}`,
);
}
function resolveMinimaxRequestTimeoutMs(
timeoutMs: ProviderOperationTimeoutMs | undefined,
): number | undefined {
@@ -114,18 +87,6 @@ function resolveMinimaxRequestTimeoutMs(
: undefined;
}
function resolveMinimaxGuardedRequestOptions(
policy: MinimaxRequestPolicy,
): Parameters<typeof fetchWithTimeoutGuarded>[4] | undefined {
if (!policy.allowPrivateNetwork && !policy.dispatcherPolicy) {
return undefined;
}
return {
...(policy.allowPrivateNetwork ? { ssrfPolicy: { allowPrivateNetwork: true } } : {}),
...(policy.dispatcherPolicy ? { dispatcherPolicy: policy.dispatcherPolicy } : {}),
};
}
async function fetchMinimaxResponse(params: {
stage: ProviderOperationRetryStage;
url: string;
@@ -489,8 +450,8 @@ function buildMinimaxVideoProvider(providerId: string): VideoGenerationProvider
});
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
resolveProviderHttpRequestConfig({
baseUrl: resolveMinimaxVideoBaseUrl(req.cfg, providerId),
defaultBaseUrl: DEFAULT_MINIMAX_VIDEO_BASE_URL,
baseUrl: resolveMinimaxMediaBaseUrl(req.cfg, providerId),
defaultBaseUrl: DEFAULT_MINIMAX_MEDIA_BASE_URL,
defaultHeaders: {
Authorization: `Bearer ${auth.apiKey}`,
"Content-Type": "application/json",
+15 -31
View File
@@ -5,6 +5,7 @@ import {
createStandardChannelSetupStatus,
DEFAULT_ACCOUNT_ID,
createSetupTranslator,
setSetupChannelEnabled,
type ChannelSetupAdapter,
type ChannelSetupWizard,
type WizardPrompter,
@@ -14,19 +15,11 @@ import { normalizeSecretInputString } from "./secret-input.js";
import { hasConfiguredMSTeamsCredentials, resolveMSTeamsCredentials } from "./token.js";
const t = createSetupTranslator();
const channel = "msteams" as const;
export const msteamsSetupAdapter: ChannelSetupAdapter = {
resolveAccountId: () => DEFAULT_ACCOUNT_ID,
applyAccountConfig: ({ cfg }) => ({
...cfg,
channels: {
...cfg.channels,
msteams: {
...cfg.channels?.msteams,
enabled: true,
},
},
}),
applyAccountConfig: ({ cfg }) => setSetupChannelEnabled(cfg, channel, true),
};
export const msteamsSetupContract = defineChannelSetupContract({
@@ -34,32 +27,23 @@ export const msteamsSetupContract = defineChannelSetupContract({
legacyAdapter: msteamsSetupAdapter,
});
const channel = "msteams" as const;
async function promptMSTeamsCredentials(prompter: WizardPrompter): Promise<{
appId: string;
appPassword: string;
tenantId: string;
}> {
const appId = (
await prompter.text({
message: t("wizard.msteams.appIdPrompt"),
validate: (value) => (value?.trim() ? undefined : t("common.required")),
})
).trim();
const appPassword = (
await prompter.text({
message: t("wizard.msteams.appPasswordPrompt"),
validate: (value) => (value?.trim() ? undefined : t("common.required")),
})
).trim();
const tenantId = (
await prompter.text({
message: t("wizard.msteams.tenantIdPrompt"),
validate: (value) => (value?.trim() ? undefined : t("common.required")),
})
).trim();
return { appId, appPassword, tenantId };
const promptRequired = async (message: string) =>
(
await prompter.text({
message,
validate: (value) => (value?.trim() ? undefined : t("common.required")),
})
).trim();
return {
appId: await promptRequired(t("wizard.msteams.appIdPrompt")),
appPassword: await promptRequired(t("wizard.msteams.appPasswordPrompt")),
tenantId: await promptRequired(t("wizard.msteams.tenantIdPrompt")),
};
}
async function noteMSTeamsCredentialHelp(prompter: WizardPrompter): Promise<void> {
+2 -12
View File
@@ -4,6 +4,7 @@ import {
createTopLevelChannelDmPolicy,
createTopLevelChannelGroupPolicySetter,
mergeAllowFromEntries,
patchTopLevelChannelConfigSection,
setSetupChannelEnabled,
splitSetupEntries,
createSetupTranslator,
@@ -12,7 +13,6 @@ import {
type OpenClawConfig,
type WizardPrompter,
} from "openclaw/plugin-sdk/setup";
import type { MSTeamsTeamConfig } from "../runtime-api.js";
import { formatUnknownError } from "./errors.js";
import {
parseMSTeamsTeamEntry,
@@ -131,17 +131,7 @@ function setMSTeamsTeamsAllowlist(
teams[teamKey] = existing;
}
}
return {
...cfg,
channels: {
...cfg.channels,
msteams: {
...cfg.channels?.msteams,
enabled: true,
teams: teams as Record<string, MSTeamsTeamConfig>,
},
},
};
return patchTopLevelChannelConfigSection({ cfg, channel, enabled: true, patch: { teams } });
}
function listMSTeamsGroupEntries(cfg: OpenClawConfig): string[] {
+1 -6
View File
@@ -14,6 +14,7 @@ import {
getOpenRouterModelCapabilities,
loadOpenRouterModelCapabilities,
} from "openclaw/plugin-sdk/provider-stream-family";
import { asOptionalRecord as readRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import { buildOpenRouterImageGenerationProvider } from "./image-generation-provider.js";
import { openrouterMediaUnderstandingProvider } from "./media-understanding-provider.js";
@@ -88,12 +89,6 @@ function normalizeOpenRouterResolvedModel<T extends ProviderRuntimeModel>(model:
};
}
function readRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function sanitizePromptModelId(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
+27 -49
View File
@@ -7,6 +7,11 @@ import type {
ModelDefinitionConfig,
ModelProviderConfig,
} from "openclaw/plugin-sdk/provider-model-shared";
import {
asOptionalRecord,
asPositiveSafeInteger,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
export const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
const OPENROUTER_MODELS_ENDPOINT = `${OPENROUTER_BASE_URL}/models`;
@@ -35,12 +40,8 @@ const OPENROUTER_KIMI_K2_5_COST = {
cacheWrite: 0,
};
function normalizeBaseUrl(baseUrl: string | undefined): string {
return (baseUrl ?? "").trim().replace(/\/+$/, "");
}
export function normalizeOpenRouterBaseUrl(baseUrl: string | undefined): string | undefined {
const normalized = normalizeBaseUrl(baseUrl);
const normalized = baseUrl?.trim().replace(/\/+$/, "");
if (!normalized) {
return undefined;
}
@@ -97,25 +98,6 @@ export function buildOpenrouterProvider(): ModelProviderConfig {
};
}
function readRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function readString(record: Record<string, unknown> | undefined, key: string): string | undefined {
const value = record?.[key];
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function readPositiveInteger(
record: Record<string, unknown> | undefined,
key: string,
): number | undefined {
const value = record?.[key];
return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined;
}
function readStringArray(record: Record<string, unknown> | undefined, key: string): string[] {
const value = record?.[key];
return Array.isArray(value)
@@ -138,7 +120,7 @@ function readOpenRouterModalities(
if (explicit.length > 0) {
return explicit;
}
const modality = readString(architecture, "modality");
const modality = normalizeOptionalString(architecture?.modality);
if (!modality) {
return [];
}
@@ -147,20 +129,20 @@ function readOpenRouterModalities(
}
function buildOpenRouterLiveModel(row: unknown): ModelDefinitionConfig | undefined {
const record = readRecord(row);
const id = readString(record, "id");
const architecture = readRecord(record?.architecture);
const record = asOptionalRecord(row);
const id = normalizeOptionalString(record?.id);
const architecture = asOptionalRecord(record?.architecture);
const outputModalities = readOpenRouterModalities(architecture, "output");
if (!id || (outputModalities.length > 0 && !outputModalities.includes("text"))) {
return undefined;
}
const inputModalities = readOpenRouterModalities(architecture, "input");
const supportedParameters = readStringArray(record, "supported_parameters");
const topProvider = readRecord(record?.top_provider);
const pricing = readRecord(record?.pricing);
const topProvider = asOptionalRecord(record?.top_provider);
const pricing = asOptionalRecord(record?.pricing);
return {
id,
name: readString(record, "name") ?? id,
name: normalizeOptionalString(record?.name) ?? id,
reasoning:
supportedParameters.includes("reasoning") ||
supportedParameters.includes("include_reasoning"),
@@ -172,24 +154,17 @@ function buildOpenRouterLiveModel(row: unknown): ModelDefinitionConfig | undefin
cacheWrite: readTokenPrice(pricing, "input_cache_write"),
},
contextWindow:
readPositiveInteger(topProvider, "context_length") ??
readPositiveInteger(record, "context_length") ??
asPositiveSafeInteger(topProvider?.context_length) ??
asPositiveSafeInteger(record?.context_length) ??
OPENROUTER_DEFAULT_CONTEXT_WINDOW,
maxTokens:
readPositiveInteger(topProvider, "max_completion_tokens") ??
readPositiveInteger(record, "max_completion_tokens") ??
readPositiveInteger(record, "max_output_tokens") ??
asPositiveSafeInteger(topProvider?.max_completion_tokens) ??
asPositiveSafeInteger(record?.max_completion_tokens) ??
asPositiveSafeInteger(record?.max_output_tokens) ??
OPENROUTER_DEFAULT_MAX_TOKENS,
};
}
function parseOpenRouterLiveModels(rows: readonly unknown[]): ModelDefinitionConfig[] {
const models = rows
.map(buildOpenRouterLiveModel)
.filter((model): model is ModelDefinitionConfig => Boolean(model));
return [...new Map(models.map((model) => [model.id, model])).values()];
}
export async function buildOpenrouterLiveProvider(params: {
apiKey?: string;
discoveryApiKey?: string;
@@ -212,15 +187,18 @@ export async function buildOpenrouterLiveProvider(params: {
ttlMs: OPENROUTER_MODELS_CACHE_TTL_MS,
auditContext: "openrouter-model-discovery",
projectRows: (rows, fallbackProvider) => {
const liveModels = parseOpenRouterLiveModels(rows);
const liveModels = rows.flatMap((row) => {
const model = buildOpenRouterLiveModel(row);
return model ? [model] : [];
});
if (liveModels.length === 0) {
return [];
}
const models = new Map(fallbackProvider.models.map((model) => [model.id, model]));
for (const model of liveModels) {
models.set(model.id, model);
}
return [...models.values()].toSorted((a, b) => a.id.localeCompare(b.id));
return [
...new Map(
[...fallbackProvider.models, ...liveModels].map((model) => [model.id, model]),
).values(),
].toSorted((a, b) => a.id.localeCompare(b.id));
},
});
}
@@ -68,6 +68,45 @@ function streamedJsonResponse(payload: unknown): Response {
);
}
function mockPixVerseVideoSubmit(videoId = 123) {
postJsonRequestMock.mockResolvedValue({
response: streamedJsonResponse({
ErrCode: 0,
ErrMsg: "success",
Resp: { video_id: videoId },
}),
release: vi.fn(async () => {}),
});
}
function mockPixVerseVideoTask(
params: {
videoId?: number;
videoUrl?: string;
seed?: number;
outputWidth?: number;
outputHeight?: number;
} = {},
) {
const videoId = params.videoId ?? 123;
mockPixVerseVideoSubmit(videoId);
fetchWithTimeoutMock.mockResolvedValueOnce({
json: async () => ({
ErrCode: 0,
ErrMsg: "success",
Resp: {
id: videoId,
status: 1,
url: params.videoUrl ?? "https://media.pixverse.ai/out.mp4",
...(params.seed === undefined ? {} : { seed: params.seed }),
...(params.outputWidth === undefined ? {} : { outputWidth: params.outputWidth }),
...(params.outputHeight === undefined ? {} : { outputHeight: params.outputHeight }),
},
}),
headers: new Headers(),
});
}
// Drives an unbounded JSON body (>16 MiB, no Content-Length) so the bounded
// reader has to cancel the stream instead of buffering it all. A hard ceiling
// guards the test from hanging if the reader ever fails to cancel.
@@ -105,29 +144,7 @@ describe("pixverse video generation provider", () => {
});
it("submits text-to-video, polls status, and returns the output URL", async () => {
postJsonRequestMock.mockResolvedValue({
response: streamedJsonResponse({
ErrCode: 0,
ErrMsg: "success",
Resp: { video_id: 123 },
}),
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock.mockResolvedValueOnce({
json: async () => ({
ErrCode: 0,
ErrMsg: "success",
Resp: {
id: 123,
status: 1,
url: "https://media.pixverse.ai/out.mp4",
seed: 42,
outputWidth: 960,
outputHeight: 540,
},
}),
headers: new Headers(),
});
mockPixVerseVideoTask({ seed: 42, outputWidth: 960, outputHeight: 540 });
const provider = buildPixVerseVideoGenerationProvider();
const result = await provider.generateVideo({
@@ -191,22 +208,7 @@ describe("pixverse video generation provider", () => {
});
it("drops malformed seed values before creating videos", async () => {
postJsonRequestMock.mockResolvedValue({
response: streamedJsonResponse({
ErrCode: 0,
ErrMsg: "success",
Resp: { video_id: 123 },
}),
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock.mockResolvedValueOnce({
json: async () => ({
ErrCode: 0,
ErrMsg: "success",
Resp: { id: 123, status: 1, url: "https://media.pixverse.ai/out.mp4" },
}),
headers: new Headers(),
});
mockPixVerseVideoTask();
const provider = buildPixVerseVideoGenerationProvider();
await provider.generateVideo({
@@ -223,27 +225,7 @@ describe("pixverse video generation provider", () => {
});
it("drops malformed response seed metadata", async () => {
postJsonRequestMock.mockResolvedValue({
response: streamedJsonResponse({
ErrCode: 0,
ErrMsg: "success",
Resp: { video_id: 123 },
}),
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock.mockResolvedValueOnce({
json: async () => ({
ErrCode: 0,
ErrMsg: "success",
Resp: {
id: 123,
status: 1,
url: "https://media.pixverse.ai/out.mp4",
seed: 1.5,
},
}),
headers: new Headers(),
});
mockPixVerseVideoTask({ seed: 1.5 });
const provider = buildPixVerseVideoGenerationProvider();
const result = await provider.generateVideo({
@@ -263,14 +245,7 @@ describe("pixverse video generation provider", () => {
});
it("rejects fractional video ids before polling", async () => {
postJsonRequestMock.mockResolvedValue({
response: streamedJsonResponse({
ErrCode: 0,
ErrMsg: "success",
Resp: { video_id: 123.5 },
}),
release: vi.fn(async () => {}),
});
mockPixVerseVideoSubmit(123.5);
const provider = buildPixVerseVideoGenerationProvider();
await expect(
@@ -338,22 +313,7 @@ describe("pixverse video generation provider", () => {
}),
release: vi.fn(async () => {}),
});
postJsonRequestMock.mockResolvedValue({
response: streamedJsonResponse({
ErrCode: 0,
ErrMsg: "success",
Resp: { video_id: 789 },
}),
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock.mockResolvedValueOnce({
json: async () => ({
ErrCode: 0,
ErrMsg: "success",
Resp: { id: 789, status: 1, url: "https://media.pixverse.ai/i2v.mp4" },
}),
headers: new Headers(),
});
mockPixVerseVideoTask({ videoId: 789, videoUrl: "https://media.pixverse.ai/i2v.mp4" });
const provider = buildPixVerseVideoGenerationProvider();
await provider.generateVideo({
@@ -398,22 +358,7 @@ describe("pixverse video generation provider", () => {
}),
release: vi.fn(async () => {}),
});
postJsonRequestMock.mockResolvedValue({
response: streamedJsonResponse({
ErrCode: 0,
ErrMsg: "success",
Resp: { video_id: 222 },
}),
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock.mockResolvedValueOnce({
json: async () => ({
ErrCode: 0,
ErrMsg: "success",
Resp: { id: 222, status: 1, url: "https://media.pixverse.ai/remote.mp4" },
}),
headers: new Headers(),
});
mockPixVerseVideoTask({ videoId: 222, videoUrl: "https://media.pixverse.ai/remote.mp4" });
const provider = buildPixVerseVideoGenerationProvider();
await provider.generateVideo({
@@ -452,14 +397,7 @@ describe("pixverse video generation provider", () => {
});
it("reports PixVerse moderation failures from status polling", async () => {
postJsonRequestMock.mockResolvedValue({
response: streamedJsonResponse({
ErrCode: 0,
ErrMsg: "success",
Resp: { video_id: 333 },
}),
release: vi.fn(async () => {}),
});
mockPixVerseVideoSubmit(333);
fetchWithTimeoutMock.mockResolvedValueOnce({
json: async () => ({
ErrCode: 0,
@@ -480,44 +418,44 @@ describe("pixverse video generation provider", () => {
).rejects.toThrow("PixVerse video generation failed content moderation");
});
it("uses configured baseUrl", async () => {
postJsonRequestMock.mockResolvedValue({
response: streamedJsonResponse({
ErrCode: 0,
ErrMsg: "success",
Resp: { video_id: 123 },
}),
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock.mockResolvedValueOnce({
json: async () => ({
ErrCode: 0,
ErrMsg: "success",
Resp: { id: 123, status: 1, url: "https://media.pixverse.ai/out.mp4" },
}),
headers: new Headers(),
});
it.each([
{
name: "uses configured baseUrl",
providerConfig: { baseUrl: "https://proxy.example/openapi/v2" },
expectedBaseUrl: "https://proxy.example/openapi/v2",
prompt: "custom base",
},
{
name: "uses the configured CN API region",
providerConfig: { region: "cn" },
expectedBaseUrl: "https://app-api.pixverseai.cn/openapi/v2",
prompt: "cn endpoint",
},
{
name: "prefers configured baseUrl over API region",
providerConfig: { baseUrl: "https://proxy.example/openapi/v2", region: "cn" },
expectedBaseUrl: "https://proxy.example/openapi/v2",
prompt: "custom base",
},
])("$name", async ({ providerConfig, expectedBaseUrl, prompt }) => {
mockPixVerseVideoTask();
const provider = buildPixVerseVideoGenerationProvider();
await provider.generateVideo({
provider: "pixverse",
model: "v6",
prompt: "custom base",
prompt,
cfg: {
models: {
providers: {
pixverse: {
baseUrl: "https://proxy.example/openapi/v2",
},
pixverse: providerConfig,
},
},
} as never,
});
expect(firstPostJsonRequest().url).toBe("https://proxy.example/openapi/v2/video/text/generate");
expect(fetchWithTimeoutMock.mock.calls[0]?.[0]).toBe(
"https://proxy.example/openapi/v2/video/result/123",
);
expect(firstPostJsonRequest().url).toBe(`${expectedBaseUrl}/video/text/generate`);
expect(fetchWithTimeoutMock.mock.calls[0]?.[0]).toBe(`${expectedBaseUrl}/video/result/123`);
});
it("uses the guarded provider transport for status polling", async () => {
@@ -528,22 +466,7 @@ describe("pixverse video generation provider", () => {
headers: new Headers({ "API-KEY": "provider-key", "X-Proxy": "enabled" }),
dispatcherPolicy,
} as never);
postJsonRequestMock.mockResolvedValue({
response: streamedJsonResponse({
ErrCode: 0,
ErrMsg: "success",
Resp: { video_id: 123 },
}),
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock.mockResolvedValueOnce({
json: async () => ({
ErrCode: 0,
ErrMsg: "success",
Resp: { id: 123, status: 1, url: "https://media.pixverse.ai/out.mp4" },
}),
headers: new Headers(),
});
mockPixVerseVideoTask();
const provider = buildPixVerseVideoGenerationProvider();
await provider.generateVideo({
@@ -569,22 +492,7 @@ describe("pixverse video generation provider", () => {
allowPrivateNetwork: true,
headers: { "X-Proxy": "enabled" },
};
postJsonRequestMock.mockResolvedValue({
response: streamedJsonResponse({
ErrCode: 0,
ErrMsg: "success",
Resp: { video_id: 123 },
}),
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock.mockResolvedValueOnce({
json: async () => ({
ErrCode: 0,
ErrMsg: "success",
Resp: { id: 123, status: 1, url: "https://media.pixverse.ai/out.mp4" },
}),
headers: new Headers(),
});
mockPixVerseVideoTask();
const provider = buildPixVerseVideoGenerationProvider();
await provider.generateVideo({
@@ -609,14 +517,7 @@ describe("pixverse video generation provider", () => {
});
it("uses a fresh trace id for each status poll", async () => {
postJsonRequestMock.mockResolvedValue({
response: streamedJsonResponse({
ErrCode: 0,
ErrMsg: "success",
Resp: { video_id: 123 },
}),
release: vi.fn(async () => {}),
});
mockPixVerseVideoSubmit();
fetchWithTimeoutMock
.mockResolvedValueOnce({
json: async () => ({
@@ -649,87 +550,4 @@ describe("pixverse video generation provider", () => {
expect(secondHeaders?.get("Ai-trace-id")).toMatch(/^[0-9a-f-]{36}$/u);
expect(secondHeaders?.get("Ai-trace-id")).not.toBe(firstHeaders?.get("Ai-trace-id"));
});
it("uses the configured CN API region", async () => {
postJsonRequestMock.mockResolvedValue({
response: streamedJsonResponse({
ErrCode: 0,
ErrMsg: "success",
Resp: { video_id: 123 },
}),
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock.mockResolvedValueOnce({
json: async () => ({
ErrCode: 0,
ErrMsg: "success",
Resp: { id: 123, status: 1, url: "https://media.pixverse.ai/out.mp4" },
}),
headers: new Headers(),
});
const provider = buildPixVerseVideoGenerationProvider();
await provider.generateVideo({
provider: "pixverse",
model: "v6",
prompt: "cn endpoint",
cfg: {
models: {
providers: {
pixverse: {
region: "cn",
},
},
},
} as never,
});
expect(firstPostJsonRequest().url).toBe(
"https://app-api.pixverseai.cn/openapi/v2/video/text/generate",
);
expect(fetchWithTimeoutMock.mock.calls[0]?.[0]).toBe(
"https://app-api.pixverseai.cn/openapi/v2/video/result/123",
);
});
it("prefers configured baseUrl over API region", async () => {
postJsonRequestMock.mockResolvedValue({
response: streamedJsonResponse({
ErrCode: 0,
ErrMsg: "success",
Resp: { video_id: 123 },
}),
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock.mockResolvedValueOnce({
json: async () => ({
ErrCode: 0,
ErrMsg: "success",
Resp: { id: 123, status: 1, url: "https://media.pixverse.ai/out.mp4" },
}),
headers: new Headers(),
});
const provider = buildPixVerseVideoGenerationProvider();
await provider.generateVideo({
provider: "pixverse",
model: "v6",
prompt: "custom base",
cfg: {
models: {
providers: {
pixverse: {
baseUrl: "https://proxy.example/openapi/v2",
region: "cn",
},
},
},
} as never,
});
expect(firstPostJsonRequest().url).toBe("https://proxy.example/openapi/v2/video/text/generate");
expect(fetchWithTimeoutMock.mock.calls[0]?.[0]).toBe(
"https://proxy.example/openapi/v2/video/result/123",
);
});
});
+4 -12
View File
@@ -135,18 +135,10 @@ function createSlackTokenCredential(params: {
allowEnv: ({ accountId }: { accountId: string }) =>
Boolean(params.preferredEnvVar) && accountId === DEFAULT_ACCOUNT_ID,
resolveAccount: ({ cfg, accountId }) => inspectSlackAccount({ cfg, accountId }),
resolvedValue: (account) => {
if (params.inputKey === "botToken") {
return normalizeOptionalString(account.botToken);
}
if (params.inputKey === "appToken") {
return normalizeOptionalString(account.appToken);
}
if (params.inputKey === "userToken") {
return normalizeOptionalString(account.userToken);
}
return normalizeSecretInputString(account.config.signingSecret);
},
resolvedValue: (account) =>
params.inputKey === "signingSecret"
? normalizeSecretInputString(account.config.signingSecret)
: normalizeOptionalString(account[params.inputKey]),
envValue: ({ accountId }) =>
params.preferredEnvVar && accountId === DEFAULT_ACCOUNT_ID
? normalizeOptionalString(process.env[params.preferredEnvVar])
+54 -127
View File
@@ -110,6 +110,34 @@ function requireFetchInitCall(index: number): {
};
}
function mockXaiVideoRequest(requestId: string) {
postJsonRequestMock.mockResolvedValue({
response: { json: async () => ({ request_id: requestId }) },
release: vi.fn(async () => {}),
});
}
function mockXaiVideoTask(params: {
requestId: string;
videoUrl: string;
videoBytes: string;
mimeType?: string;
}) {
mockXaiVideoRequest(params.requestId);
fetchWithTimeoutMock
.mockResolvedValueOnce({
json: async () => ({
request_id: params.requestId,
status: "done",
video: { url: params.videoUrl },
}),
})
.mockResolvedValueOnce({
headers: new Headers({ "content-type": params.mimeType ?? "video/mp4" }),
arrayBuffer: async () => Buffer.from(params.videoBytes),
});
}
function streamedVideoResponse(bytes: string, contentType = "video/mp4"): Response {
return new Response(
new ReadableStream({
@@ -316,26 +344,12 @@ describe("xai video generation provider", () => {
});
it("creates, polls, and downloads a generated video", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({
request_id: "req_123",
}),
},
release: vi.fn(async () => {}),
mockXaiVideoTask({
requestId: "req_123",
videoUrl: "https://cdn.x.ai/video.mp4",
videoBytes: "webm-bytes",
mimeType: "video/webm",
});
fetchWithTimeoutMock
.mockResolvedValueOnce({
json: async () => ({
request_id: "req_123",
status: "done",
video: { url: "https://cdn.x.ai/video.mp4" },
}),
})
.mockResolvedValueOnce({
headers: new Headers({ "content-type": "video/webm" }),
arrayBuffer: async () => Buffer.from("webm-bytes"),
});
const provider = buildXaiVideoGenerationProvider();
const result = await provider.generateVideo({
@@ -383,26 +397,11 @@ describe("xai video generation provider", () => {
}),
dispatcherPolicy: dispatcherPolicy as never,
}));
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({
request_id: "req_policy",
}),
},
release: vi.fn(async () => {}),
mockXaiVideoTask({
requestId: "req_policy",
videoUrl: "https://cdn.x.ai/policy.mp4",
videoBytes: "video-bytes",
});
fetchWithTimeoutMock
.mockResolvedValueOnce({
json: async () => ({
request_id: "req_policy",
status: "done",
video: { url: "https://cdn.x.ai/policy.mp4" },
}),
})
.mockResolvedValueOnce({
headers: new Headers({ "content-type": "video/mp4" }),
arrayBuffer: async () => Buffer.from("video-bytes"),
});
await buildXaiVideoGenerationProvider().generateVideo({
provider: "xai",
@@ -466,12 +465,7 @@ describe("xai video generation provider", () => {
});
it("rejects generated video downloads that exceed the configured media cap", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({ request_id: "req_too_large" }),
},
release: vi.fn(async () => {}),
});
mockXaiVideoRequest("req_too_large");
fetchWithTimeoutMock
.mockResolvedValueOnce({
json: async () => ({
@@ -575,12 +569,7 @@ describe("xai video generation provider", () => {
});
it("treats unknown xAI poll statuses as continue-polling and returns when terminal", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({ request_id: "req_unknown_then_done" }),
},
release: vi.fn(async () => {}),
});
mockXaiVideoRequest("req_unknown_then_done");
fetchWithTimeoutMock
.mockResolvedValueOnce({
json: async () => ({
@@ -619,12 +608,7 @@ describe("xai video generation provider", () => {
});
it("treats `cancelled` as a terminal failure", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({ request_id: "req_cancelled" }),
},
release: vi.fn(async () => {}),
});
mockXaiVideoRequest("req_cancelled");
fetchWithTimeoutMock.mockResolvedValueOnce({
json: async () => ({
request_id: "req_cancelled",
@@ -644,12 +628,7 @@ describe("xai video generation provider", () => {
});
it("rejects completed xAI poll responses without output URLs as malformed", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({ request_id: "req_no_video" }),
},
release: vi.fn(async () => {}),
});
mockXaiVideoRequest("req_no_video");
fetchWithTimeoutMock.mockResolvedValueOnce({
json: async () => ({
request_id: "req_no_video",
@@ -670,14 +649,7 @@ describe("xai video generation provider", () => {
});
it("normalizes the xAI 'pending' poll status to 'processing' and keeps polling until done", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({
request_id: "req_pending",
}),
},
release: vi.fn(async () => {}),
});
mockXaiVideoRequest("req_pending");
fetchWithTimeoutMock
// First poll: in-progress payload mirroring xAI's real shape
.mockResolvedValueOnce({
@@ -720,26 +692,11 @@ describe("xai video generation provider", () => {
});
it("sends a single unroled image as xAI first-frame image-to-video", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({
request_id: "req_image",
}),
},
release: vi.fn(async () => {}),
mockXaiVideoTask({
requestId: "req_image",
videoUrl: "https://cdn.x.ai/image-video.mp4",
videoBytes: "image-video-bytes",
});
fetchWithTimeoutMock
.mockResolvedValueOnce({
json: async () => ({
request_id: "req_image",
status: "done",
video: { url: "https://cdn.x.ai/image-video.mp4" },
}),
})
.mockResolvedValueOnce({
headers: new Headers({ "content-type": "video/mp4" }),
arrayBuffer: async () => Buffer.from("image-video-bytes"),
});
const provider = buildXaiVideoGenerationProvider();
const result = await provider.generateVideo({
@@ -762,26 +719,11 @@ describe("xai video generation provider", () => {
});
it("sends reference_image roles through xAI reference_images mode", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({
request_id: "req_refs",
}),
},
release: vi.fn(async () => {}),
mockXaiVideoTask({
requestId: "req_refs",
videoUrl: "https://cdn.x.ai/reference-video.mp4",
videoBytes: "reference-video-bytes",
});
fetchWithTimeoutMock
.mockResolvedValueOnce({
json: async () => ({
request_id: "req_refs",
status: "done",
video: { url: "https://cdn.x.ai/reference-video.mp4" },
}),
})
.mockResolvedValueOnce({
headers: new Headers({ "content-type": "video/mp4" }),
arrayBuffer: async () => Buffer.from("reference-video-bytes"),
});
const provider = buildXaiVideoGenerationProvider();
const result = await provider.generateVideo({
@@ -833,26 +775,11 @@ describe("xai video generation provider", () => {
});
it("routes video inputs to the extension endpoint when duration is set", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
json: async () => ({
request_id: "req_extend",
}),
},
release: vi.fn(async () => {}),
mockXaiVideoTask({
requestId: "req_extend",
videoUrl: "https://cdn.x.ai/extended.mp4",
videoBytes: "extended-bytes",
});
fetchWithTimeoutMock
.mockResolvedValueOnce({
json: async () => ({
request_id: "req_extend",
status: "done",
video: { url: "https://cdn.x.ai/extended.mp4" },
}),
})
.mockResolvedValueOnce({
headers: new Headers({ "content-type": "video/mp4" }),
arrayBuffer: async () => Buffer.from("extended-bytes"),
});
const provider = buildXaiVideoGenerationProvider();
await provider.generateVideo({
+51 -114
View File
@@ -973,6 +973,28 @@ export function collectDatabaseFirstLegacyStoreViolations(
}
}
function visitObjectLiteralProperties(objectName, initializer, onProperty, onSpread = null) {
const objectLiteral = unwrapExpression(initializer);
if (!ts.isObjectLiteralExpression(objectLiteral)) {
return;
}
for (const property of objectLiteral.properties) {
if (ts.isSpreadAssignment(property)) {
onSpread?.(unwrapExpression(property.expression));
continue;
}
const isAssignment = ts.isPropertyAssignment(property);
const name = isAssignment
? propertyNameText(property.name)
: ts.isShorthandPropertyAssignment(property)
? property.name.text
: null;
if (name) {
onProperty(`${objectName}.${name}`, isAssignment ? property.initializer : property.name);
}
}
}
function resolveLegacyPathIdentifier(name) {
return scopeForRead(legacyPathScopes, name)?.get(name) === true;
}
@@ -1719,32 +1741,9 @@ export function collectDatabaseFirstLegacyStoreViolations(
scope = lastScope(fsWriteAliasScopes),
conditionalWrite = false,
) {
const objectLiteral = unwrapExpression(initializer);
if (!ts.isObjectLiteralExpression(objectLiteral)) {
return;
}
for (const property of objectLiteral.properties) {
if (ts.isPropertyAssignment(property)) {
const name = propertyNameText(property.name);
if (name) {
setFsWriteObjectAlias(
scope,
`${objectName}.${name}`,
legacyFsWriteName(property.initializer),
conditionalWrite,
);
}
continue;
}
if (ts.isShorthandPropertyAssignment(property)) {
setFsWriteObjectAlias(
scope,
`${objectName}.${property.name.text}`,
resolveFsWriteAlias(property.name.text),
conditionalWrite,
);
}
}
visitObjectLiteralProperties(objectName, initializer, (name, value) => {
setFsWriteObjectAlias(scope, name, legacyFsWriteName(value), conditionalWrite);
});
}
function clearFsSafeStoreObjectAliases(storeScope, jsonStoreScope, objectName) {
@@ -1813,47 +1812,29 @@ export function collectDatabaseFirstLegacyStoreViolations(
jsonStoreScope = lastScope(fsSafeJsonStoreScopes),
conditionalWrite = false,
) {
const objectLiteral = unwrapExpression(initializer);
if (!ts.isObjectLiteralExpression(objectLiteral)) {
return;
}
for (const property of objectLiteral.properties) {
if (ts.isPropertyAssignment(property)) {
const name = propertyNameText(property.name);
if (name) {
setFsSafeStoreObjectAlias(
storeScope,
jsonStoreScope,
`${objectName}.${name}`,
isFsSafeStoreExpression(property.initializer),
expressionContainsFsSafeJsonStoreLegacyPath(property.initializer),
conditionalWrite,
);
if (ts.isObjectLiteralExpression(unwrapExpression(property.initializer))) {
registerFsSafeStoreObjectAliases(
`${objectName}.${name}`,
property.initializer,
storeScope,
jsonStoreScope,
conditionalWrite,
);
}
}
continue;
}
if (ts.isShorthandPropertyAssignment(property)) {
visitObjectLiteralProperties(
objectName,
initializer,
(name, value) => {
setFsSafeStoreObjectAlias(
storeScope,
jsonStoreScope,
`${objectName}.${property.name.text}`,
resolveFsSafeStore(property.name.text),
resolveFsSafeJsonStore(property.name.text),
name,
isFsSafeStoreExpression(value),
expressionContainsFsSafeJsonStoreLegacyPath(value),
conditionalWrite,
);
continue;
}
if (ts.isSpreadAssignment(property)) {
const spreadExpression = unwrapExpression(property.expression);
if (ts.isObjectLiteralExpression(unwrapExpression(value))) {
registerFsSafeStoreObjectAliases(
name,
value,
storeScope,
jsonStoreScope,
conditionalWrite,
);
}
},
(spreadExpression) => {
if (ts.isIdentifier(spreadExpression)) {
copyFsSafeStoreObjectAliases(
objectName,
@@ -1870,8 +1851,8 @@ export function collectDatabaseFirstLegacyStoreViolations(
conditionalWrite,
);
}
}
}
},
);
}
function setFsModuleObjectProperty(scope, name, isFsModule, conditionalWrite) {
@@ -1893,32 +1874,9 @@ export function collectDatabaseFirstLegacyStoreViolations(
scope = lastScope(fsModulePropertyScopes),
conditionalWrite = false,
) {
const objectLiteral = unwrapExpression(initializer);
if (!ts.isObjectLiteralExpression(objectLiteral)) {
return;
}
for (const property of objectLiteral.properties) {
if (ts.isPropertyAssignment(property)) {
const name = propertyNameText(property.name);
if (name) {
setFsModuleObjectProperty(
scope,
`${objectName}.${name}`,
isFsModuleExpression(property.initializer),
conditionalWrite,
);
}
continue;
}
if (ts.isShorthandPropertyAssignment(property)) {
setFsModuleObjectProperty(
scope,
`${objectName}.${property.name.text}`,
resolveFsModuleBinding(property.name.text),
conditionalWrite,
);
}
}
visitObjectLiteralProperties(objectName, initializer, (name, value) => {
setFsModuleObjectProperty(scope, name, isFsModuleExpression(value), conditionalWrite);
});
}
function collectFsModuleBindingsFromBinding(node) {
@@ -4091,30 +4049,9 @@ export function collectDatabaseFirstLegacyStoreViolations(
initializer,
scope = lastScope(bodyFsWriteAliasScopes),
) {
const objectLiteral = unwrapExpression(initializer);
if (!ts.isObjectLiteralExpression(objectLiteral)) {
return;
}
for (const property of objectLiteral.properties) {
if (ts.isPropertyAssignment(property)) {
const name = propertyNameText(property.name);
if (name) {
setBodyFsWriteObjectAlias(
scope,
`${objectName}.${name}`,
legacyWrapperFsWriteName(property.initializer),
);
}
continue;
}
if (ts.isShorthandPropertyAssignment(property)) {
setBodyFsWriteObjectAlias(
scope,
`${objectName}.${property.name.text}`,
resolveBodyFsWriteAlias(property.name.text),
);
}
}
visitObjectLiteralProperties(objectName, initializer, (name, value) => {
setBodyFsWriteObjectAlias(scope, name, legacyWrapperFsWriteName(value));
});
}
function isWrapperFsBindingExpression(expression) {
+46 -65
View File
@@ -364,59 +364,51 @@ async function killSubagentRun(params: {
};
}
async function cascadeKillChildren(params: {
async function killSubagentRunTree(params: {
cfg: OpenClawConfig;
parentChildSessionKey: string;
runs: Iterable<SubagentRunRecord>;
cache: Map<string, Record<string, SessionEntry>>;
seenChildSessionKeys?: Set<string>;
seenChildSessionKeys: Set<string>;
controllerSessionKey?: string;
}): Promise<{ killed: number; labels: string[] }> {
const childRunsBySessionKey = new Map<string, SubagentRunRecord>();
for (const run of listSubagentRunsForController(params.parentChildSessionKey)) {
const childKey = run.childSessionKey?.trim();
if (!childKey) {
continue;
}
const latest = getLatestSubagentRunByChildSessionKey(childKey);
const latestControllerSessionKey =
latest?.controllerSessionKey?.trim() || latest?.requesterSessionKey?.trim();
if (
!latest ||
latest.runId !== run.runId ||
latestControllerSessionKey !== params.parentChildSessionKey
) {
continue;
}
childRunsBySessionKey.set(childKey, run);
}
const childRuns = Array.from(childRunsBySessionKey.values());
const seenChildSessionKeys = params.seenChildSessionKeys ?? new Set<string>();
let killed = 0;
const labels: string[] = [];
for (const run of childRuns) {
for (const run of params.runs) {
const childKey = run.childSessionKey?.trim();
if (!childKey || seenChildSessionKeys.has(childKey)) {
if (!childKey || params.seenChildSessionKeys.has(childKey)) {
continue;
}
seenChildSessionKeys.add(childKey);
const latest = getLatestSubagentRunByChildSessionKey(childKey);
if (!latest || latest.runId !== run.runId) {
continue;
}
const latestControllerSessionKey =
latest.controllerSessionKey?.trim() || latest.requesterSessionKey?.trim();
if (params.controllerSessionKey && latestControllerSessionKey !== params.controllerSessionKey) {
continue;
}
params.seenChildSessionKeys.add(childKey);
const entry = params.controllerSessionKey ? run : latest;
if (!run.endedAt || run.pauseReason === "sessions_yield") {
if (!entry.endedAt || entry.pauseReason === "sessions_yield") {
const stopResult = await killSubagentRun({
cfg: params.cfg,
entry: run,
entry,
cache: params.cache,
});
if (stopResult.killed) {
killed += 1;
labels.push(resolveSubagentLabel(run));
labels.push(resolveSubagentLabel(entry));
}
}
const cascade = await cascadeKillChildren({
const cascade = await killSubagentRunTree({
cfg: params.cfg,
parentChildSessionKey: childKey,
runs: listSubagentRunsForController(childKey),
cache: params.cache,
seenChildSessionKeys,
seenChildSessionKeys: params.seenChildSessionKeys,
controllerSessionKey: childKey,
});
killed += cascade.killed;
labels.push(...cascade.labels);
@@ -425,6 +417,21 @@ async function cascadeKillChildren(params: {
return { killed, labels };
}
async function cascadeKillChildren(params: {
cfg: OpenClawConfig;
parentChildSessionKey: string;
cache: Map<string, Record<string, SessionEntry>>;
seenChildSessionKeys?: Set<string>;
}): Promise<{ killed: number; labels: string[] }> {
return killSubagentRunTree({
cfg: params.cfg,
runs: listSubagentRunsForController(params.parentChildSessionKey),
cache: params.cache,
seenChildSessionKeys: params.seenChildSessionKeys ?? new Set<string>(),
controllerSessionKey: params.parentChildSessionKey,
});
}
/** Kills every currently controlled child run and its descendants. */
export async function killAllControlledSubagentRuns(params: {
cfg: OpenClawConfig;
@@ -439,39 +446,13 @@ export async function killAllControlledSubagentRuns(params: {
labels: [],
};
}
const cache = new Map<string, Record<string, SessionEntry>>();
const seenChildSessionKeys = new Set<string>();
const killedLabels: string[] = [];
let killed = 0;
for (const entry of params.runs) {
const childKey = entry.childSessionKey?.trim();
if (!childKey || seenChildSessionKeys.has(childKey)) {
continue;
}
const currentEntry = getLatestSubagentRunByChildSessionKey(childKey);
if (!currentEntry || currentEntry.runId !== entry.runId) {
continue;
}
seenChildSessionKeys.add(childKey);
if (!currentEntry.endedAt || currentEntry.pauseReason === "sessions_yield") {
const stopResult = await killSubagentRun({ cfg: params.cfg, entry: currentEntry, cache });
if (stopResult.killed) {
killed += 1;
killedLabels.push(resolveSubagentLabel(currentEntry));
}
}
const cascade = await cascadeKillChildren({
cfg: params.cfg,
parentChildSessionKey: childKey,
cache,
seenChildSessionKeys,
});
killed += cascade.killed;
killedLabels.push(...cascade.labels);
}
return { status: "ok" as const, killed, labels: killedLabels };
const result = await killSubagentRunTree({
cfg: params.cfg,
runs: params.runs,
cache: new Map<string, Record<string, SessionEntry>>(),
seenChildSessionKeys: new Set<string>(),
});
return { status: "ok" as const, ...result };
}
/** Kills one controlled subagent run and any active descendants. */
+7 -12
View File
@@ -10,13 +10,13 @@ import {
countActiveDescendantRunsFromRuns,
countActiveRunsForSessionFromRuns,
countPendingDescendantRunsFromRuns,
getLatestSubagentRunByChildSessionKeyFromRuns,
getSubagentRunByChildSessionKeyFromRuns,
listDescendantRunsForRequesterFromRuns,
listRunsForControllerFromRuns,
} from "./subagent-registry-queries.js";
import { markRequesterTurnYieldedInRuns } from "./subagent-registry-requester-yield.js";
import type { SubagentRunRecord, SwarmStructuredOutputState } from "./subagent-registry.types.js";
import { compareSubagentRunGeneration } from "./subagent-run-generation.js";
export function createSubagentRegistryPublicApi(config: {
runs: Map<string, SubagentRunRecord>;
@@ -256,17 +256,12 @@ export function createSubagentRegistryPublicApi(config: {
return null;
}
let latest: SubagentRunRecord | null = null;
for (const entry of deps().getSubagentRunsSnapshotForChildSession(runs, key).values()) {
if (entry.childSessionKey !== key) {
continue;
}
if (!latest || compareSubagentRunGeneration(entry, latest) > 0) {
latest = entry;
}
}
return latest;
return (
getLatestSubagentRunByChildSessionKeyFromRuns(
deps().getSubagentRunsSnapshotForChildSession(runs, key),
key,
) ?? null
);
}
/** Re-admits a delivered child batch after its requester explicitly yields. */
+38 -86
View File
@@ -139,13 +139,7 @@ export function buildSubagentRunReadIndexFromRuns(params: {
}): SubagentRunReadIndex {
const { runs } = params;
const now = params.now ?? Date.now();
const inMemoryDisplayByChildSessionKey = new Map<
string,
{
latestInMemoryActive: SubagentRunRecord | null;
latestInMemoryEnded: SubagentRunRecord | null;
}
>();
const inMemoryDisplayByChildSessionKey = new Map<string, SubagentRunRecord>();
const latestSnapshotActiveByChildSessionKey = new Map<string, SubagentRunRecord>();
const latestSnapshotEndedByChildSessionKey = new Map<string, SubagentRunRecord>();
const latestRunByChildSessionKey = new Map<string, LatestRunPair>();
@@ -158,26 +152,7 @@ export function buildSubagentRunReadIndexFromRuns(params: {
if (!childSessionKey) {
continue;
}
let display = inMemoryDisplayByChildSessionKey.get(childSessionKey);
if (!display) {
display = { latestInMemoryActive: null, latestInMemoryEnded: null };
inMemoryDisplayByChildSessionKey.set(childSessionKey, display);
}
if (hasSubagentRunEnded(entry)) {
if (
!display.latestInMemoryEnded ||
compareSubagentRunGeneration(entry, display.latestInMemoryEnded) > 0
) {
display.latestInMemoryEnded = entry;
}
continue;
}
if (
!display.latestInMemoryActive ||
compareSubagentRunGeneration(entry, display.latestInMemoryActive) > 0
) {
display.latestInMemoryActive = entry;
}
rememberLatestRunEntry(inMemoryDisplayByChildSessionKey, childSessionKey, entry);
}
for (const [runId, entry] of runs.entries()) {
@@ -218,22 +193,8 @@ export function buildSubagentRunReadIndexFromRuns(params: {
if (!key) {
return null;
}
const inMemoryDisplay = inMemoryDisplayByChildSessionKey.get(key);
if (inMemoryDisplay) {
const latestInMemoryEnded = inMemoryDisplay.latestInMemoryEnded;
const latestInMemoryActive = inMemoryDisplay.latestInMemoryActive;
if (latestInMemoryEnded || latestInMemoryActive) {
if (
latestInMemoryEnded &&
(!latestInMemoryActive ||
compareSubagentRunGeneration(latestInMemoryEnded, latestInMemoryActive) > 0)
) {
return latestInMemoryEnded;
}
return latestInMemoryActive ?? latestInMemoryEnded;
}
}
return (
inMemoryDisplayByChildSessionKey.get(key) ??
latestSnapshotActiveByChildSessionKey.get(key) ??
latestSnapshotEndedByChildSessionKey.get(key) ??
null
@@ -290,7 +251,8 @@ export function buildSubagentRunReadIndexFromRuns(params: {
};
}
function findLatestRunForChildSession(
/** Returns the latest-generation run for a child session. */
export function getLatestSubagentRunByChildSessionKeyFromRuns(
runs: Map<string, SubagentRunRecord>,
childSessionKey: string,
): SubagentRunRecord | undefined {
@@ -315,7 +277,7 @@ export function isSubagentSessionRunActiveFromRuns(
runs: Map<string, SubagentRunRecord>,
childSessionKey: string,
): boolean {
const latest = findLatestRunForChildSession(runs, childSessionKey);
const latest = getLatestSubagentRunByChildSessionKeyFromRuns(runs, childSessionKey);
return Boolean(latest && isLiveUnendedSubagentRun(latest));
}
@@ -357,7 +319,7 @@ export function resolveRequesterForChildSessionFromRuns(
requesterSessionKey: string;
requesterOrigin?: DeliveryContext;
} | null {
const latest = findLatestRunForChildSession(runs, childSessionKey);
const latest = getLatestSubagentRunByChildSessionKeyFromRuns(runs, childSessionKey);
if (!latest) {
return null;
}
@@ -372,7 +334,7 @@ export function shouldIgnorePostCompletionAnnounceForSessionFromRuns(
runs: Map<string, SubagentRunRecord>,
childSessionKey: string,
): boolean {
const latest = findLatestRunForChildSession(runs, childSessionKey);
const latest = getLatestSubagentRunByChildSessionKeyFromRuns(runs, childSessionKey);
return Boolean(
latest &&
latest.spawnMode !== "session" &&
@@ -436,10 +398,10 @@ function forEachDescendantRun(
runs: Map<string, SubagentRunRecord>,
rootSessionKey: string,
visitor: (runId: string, entry: SubagentRunRecord) => void | boolean,
): boolean {
): void {
const root = rootSessionKey.trim();
if (!root) {
return false;
return;
}
const pending = [root];
const visited = new Set<string>([root]);
@@ -459,7 +421,10 @@ function forEachDescendantRun(
}
}
for (const [runId, entry] of latestByChildSessionKey.values()) {
const latestForChildSession = findLatestRunForChildSession(runs, entry.childSessionKey);
const latestForChildSession = getLatestSubagentRunByChildSessionKeyFromRuns(
runs,
entry.childSessionKey,
);
if (
!latestForChildSession ||
latestForChildSession.runId !== runId ||
@@ -469,7 +434,7 @@ function forEachDescendantRun(
}
// A visitor may stop the traversal early by returning true.
if (visitor(runId, entry) === true) {
return true;
return;
}
const childKey = entry.childSessionKey.trim();
if (!childKey || visited.has(childKey)) {
@@ -479,7 +444,6 @@ function forEachDescendantRun(
pending.push(childKey);
}
}
return true;
}
/** Counts live descendants under a requester/session tree. */
@@ -488,15 +452,11 @@ export function countActiveDescendantRunsFromRuns(
rootSessionKey: string,
): number {
let count = 0;
if (
!forEachDescendantRun(runs, rootSessionKey, (_runId, entry) => {
if (isLiveUnendedSubagentRun(entry)) {
count += 1;
}
})
) {
return 0;
}
forEachDescendantRun(runs, rootSessionKey, (_runId, entry) => {
if (isLiveUnendedSubagentRun(entry)) {
count += 1;
}
});
return count;
}
@@ -511,26 +471,22 @@ function countPendingDescendantRunsInternal(
): number {
const excludedRunId = options?.excludeRunId?.trim();
let count = 0;
if (
!forEachDescendantRun(runs, rootSessionKey, (runId, entry) => {
if (runId === excludedRunId) {
return undefined;
forEachDescendantRun(runs, rootSessionKey, (runId, entry) => {
if (runId === excludedRunId) {
return false;
}
const runPending = hasSubagentRunEnded(entry)
? typeof entry.cleanupCompletedAt !== "number" &&
!(options?.treatSuspendedDeliveryAsSettled === true && isDeliverySuspended(entry))
: isLiveUnendedSubagentRun(entry);
if (runPending) {
count += 1;
if (options?.stopAtFirst === true) {
return true;
}
const runPending = hasSubagentRunEnded(entry)
? typeof entry.cleanupCompletedAt !== "number" &&
!(options?.treatSuspendedDeliveryAsSettled === true && isDeliverySuspended(entry))
: isLiveUnendedSubagentRun(entry);
if (runPending) {
count += 1;
if (options?.stopAtFirst === true) {
return true;
}
}
return undefined;
})
) {
return 0;
}
}
return false;
});
return count;
}
@@ -577,12 +533,8 @@ export function listDescendantRunsForRequesterFromRuns(
rootSessionKey: string,
): SubagentRunRecord[] {
const descendants: SubagentRunRecord[] = [];
if (
!forEachDescendantRun(runs, rootSessionKey, (_runId, entry) => {
descendants.push(entry);
})
) {
return [];
}
forEachDescendantRun(runs, rootSessionKey, (_runId, entry) => {
descendants.push(entry);
});
return descendants;
}
@@ -259,4 +259,36 @@ describe("subagent registry read index", () => {
expect(index.getDisplaySubagentRun(childSessionKey)?.runId).toBe("run-memory-older-ended");
});
it.each([
{ label: "active over active", olderEndedAt: undefined, newerEndedAt: undefined },
{ label: "ended over active", olderEndedAt: undefined, newerEndedAt: 250 },
{ label: "active over ended", olderEndedAt: 150, newerEndedAt: undefined },
{ label: "ended over ended", olderEndedAt: 150, newerEndedAt: 250 },
])("selects the latest in-memory generation: $label", ({ olderEndedAt, newerEndedAt }) => {
const childSessionKey = "agent:main:subagent:display-generation";
const persisted = makeRun({
runId: "run-persisted",
childSessionKey,
generation: 3,
});
const older = makeRun({
runId: "run-memory-older",
childSessionKey,
generation: 1,
endedAt: olderEndedAt,
});
const newer = makeRun({
runId: "run-memory-newer",
childSessionKey,
generation: 2,
endedAt: newerEndedAt,
});
const index = buildSubagentRunReadIndexFromRuns({
runs: toRunMap([persisted]),
inMemoryRuns: [newer, older],
});
expect(index.getDisplaySubagentRun(childSessionKey)).toBe(newer);
});
});
+17 -37
View File
@@ -9,6 +9,7 @@ import {
buildLatestSubagentRunReadIndexFromRuns,
buildSubagentRunReadIndexFromRuns,
countActiveDescendantRunsFromRuns,
getLatestSubagentRunByChildSessionKeyFromRuns,
getSubagentRunByChildSessionKeyFromRuns,
listDescendantRunsForRequesterFromRuns,
listRunsForControllerFromRuns,
@@ -86,38 +87,22 @@ export function getSessionDisplaySubagentRunByChildSessionKey(
return null;
}
let latestInMemoryActive: SubagentRunRecord | null = null;
let latestInMemoryEnded: SubagentRunRecord | null = null;
let latestInMemory: SubagentRunRecord | null = null;
for (const entry of subagentRuns.values()) {
if (entry.childSessionKey !== key) {
continue;
}
if (typeof entry.endedAt === "number") {
if (!latestInMemoryEnded || compareSubagentRunGeneration(entry, latestInMemoryEnded) > 0) {
latestInMemoryEnded = entry;
}
continue;
}
if (!latestInMemoryActive || compareSubagentRunGeneration(entry, latestInMemoryActive) > 0) {
latestInMemoryActive = entry;
if (!latestInMemory || compareSubagentRunGeneration(entry, latestInMemory) > 0) {
latestInMemory = entry;
}
}
if (latestInMemoryEnded || latestInMemoryActive) {
// Fresh in-memory terminal state is more accurate than an older active snapshot row.
if (
latestInMemoryEnded &&
(!latestInMemoryActive ||
compareSubagentRunGeneration(latestInMemoryEnded, latestInMemoryActive) > 0)
) {
return latestInMemoryEnded;
}
return latestInMemoryActive ?? latestInMemoryEnded;
}
return getSubagentRunByChildSessionKeyFromRuns(
getSubagentRunsSnapshotForChildSession(subagentRuns, key),
key,
// Fresh in-memory terminal state is more accurate than an older active snapshot row.
return (
latestInMemory ??
getSubagentRunByChildSessionKeyFromRuns(
getSubagentRunsSnapshotForChildSession(subagentRuns, key),
key,
)
);
}
@@ -130,15 +115,10 @@ export function getLatestSubagentRunByChildSessionKey(
return null;
}
let latest: SubagentRunRecord | null = null;
for (const entry of getSubagentRunsSnapshotForChildSession(subagentRuns, key).values()) {
if (entry.childSessionKey !== key) {
continue;
}
if (!latest || compareSubagentRunGeneration(entry, latest) > 0) {
latest = entry;
}
}
return latest;
return (
getLatestSubagentRunByChildSessionKeyFromRuns(
getSubagentRunsSnapshotForChildSession(subagentRuns, key),
key,
) ?? null
);
}
+50 -79
View File
@@ -22,10 +22,6 @@ import { resolveSwarmConfig } from "./swarm-config.js";
import { validateStructuredOutputSchema } from "./swarm-output-schema.js";
import { reserveSwarmRun } from "./swarm-scheduler.js";
function resolveConfiguredAgentIds(cfg: OpenClawConfig): string[] {
return listAgentIds(cfg);
}
type ResolvedSubagentSpawnRequest = {
request: {
taskName?: string;
@@ -63,6 +59,13 @@ type ResolveSubagentSpawnRequestResult =
| { ok: false; result: SpawnSubagentResult }
| { ok: true; resolved: ResolvedSubagentSpawnRequest };
function rejectSubagentSpawnRequest(
status: "error" | "forbidden",
error: string,
): ResolveSubagentSpawnRequestResult {
return { ok: false, result: { status, error } };
}
export function resolveSubagentSpawnRequest(
params: SpawnSubagentParams,
ctx: SpawnSubagentContext,
@@ -73,13 +76,7 @@ export function resolveSubagentSpawnRequest(
): ResolveSubagentSpawnRequestResult {
const taskNameResult = normalizeSubagentTaskName(params.taskName);
if (taskNameResult.error) {
return {
ok: false,
result: {
status: "error",
error: taskNameResult.error,
},
};
return rejectSubagentSpawnRequest("error", taskNameResult.error);
}
const taskName = taskNameResult.taskName;
const requestedAgentId = requestedAgent.initial;
@@ -89,13 +86,10 @@ export function resolveSubagentSpawnRequest(
// through normalizeAgentId and become "agent-not-found--xyz", which later
// creates ghost workspace directories and triggers cascading cron loops (#31311).
if (requestedAgentId && !isValidAgentId(requestedAgentId)) {
return {
ok: false,
result: {
status: "error",
error: `Invalid agentId "${requestedAgentId}". Agent IDs must match [a-z0-9][a-z0-9_-]{0,63}. Use agents_list to discover valid targets.`,
},
};
return rejectSubagentSpawnRequest(
"error",
`Invalid agentId "${requestedAgentId}". Agent IDs must match [a-z0-9][a-z0-9_-]{0,63}. Use agents_list to discover valid targets.`,
);
}
const requestThreadBinding = params.thread === true;
const spawnMode = resolveSpawnMode({
@@ -103,24 +97,17 @@ export function resolveSubagentSpawnRequest(
threadRequested: requestThreadBinding,
});
if (params.collect && (requestThreadBinding || spawnMode === "session")) {
return {
ok: false,
result: {
status: "error",
error: "sessions_spawn collect=true requires mode=run and thread=false.",
},
};
return rejectSubagentSpawnRequest(
"error",
"sessions_spawn collect=true requires mode=run and thread=false.",
);
}
if (spawnMode === "session" && !requestThreadBinding) {
return {
ok: false,
result: {
status: "error",
error:
'sessions_spawn(mode="session") requires thread=true so the subagent can stay bound to a channel thread. ' +
'Retry with { mode: "session", thread: true } on a channel that supports threads, use mode="run" for one-shot work, or use sessions_send(sessionKey=...) to keep talking to a persistent session without thread binding.',
},
};
return rejectSubagentSpawnRequest(
"error",
'sessions_spawn(mode="session") requires thread=true so the subagent can stay bound to a channel thread. ' +
'Retry with { mode: "session", thread: true } on a channel that supports threads, use mode="run" for one-shot work, or use sessions_send(sessionKey=...) to keep talking to a persistent session without thread binding.',
);
}
const cleanup =
spawnMode === "session"
@@ -175,30 +162,24 @@ export function resolveSubagentSpawnRequest(
params.fastMode !== undefined ||
params.groupId !== undefined;
if (hasSwarmParams && !swarmConfig.enabled) {
return {
ok: false,
result: {
status: "forbidden",
error: "sessions_spawn swarm parameters require tools.swarm.enabled=true.",
},
};
return rejectSubagentSpawnRequest(
"forbidden",
"sessions_spawn swarm parameters require tools.swarm.enabled=true.",
);
}
if (params.outputSchema && !params.collect) {
return {
ok: false,
result: { status: "error", error: "sessions_spawn outputSchema requires collect=true." },
};
return rejectSubagentSpawnRequest(
"error",
"sessions_spawn outputSchema requires collect=true.",
);
}
if (params.groupId !== undefined && !params.collect) {
return {
ok: false,
result: { status: "error", error: "sessions_spawn groupId requires collect=true." },
};
return rejectSubagentSpawnRequest("error", "sessions_spawn groupId requires collect=true.");
}
if (params.outputSchema) {
const schemaError = validateStructuredOutputSchema(params.outputSchema);
if (schemaError) {
return { ok: false, result: { status: "error", error: schemaError } };
return rejectSubagentSpawnRequest("error", schemaError);
}
}
@@ -209,19 +190,16 @@ export function resolveSubagentSpawnRequest(
: requestedAgentId;
if (usingDefaultAgentId) {
if (!isValidAgentId(effectiveRequestedAgentId)) {
return {
ok: false,
result: {
status: "error",
error: `tools.swarm.defaultAgentId contains invalid agentId "${effectiveRequestedAgentId}".`,
},
};
return rejectSubagentSpawnRequest(
"error",
`tools.swarm.defaultAgentId contains invalid agentId "${effectiveRequestedAgentId}".`,
);
}
}
const targetAgentId = effectiveRequestedAgentId
? normalizeAgentId(effectiveRequestedAgentId)
: requesterAgentId;
const configuredAgentIds = resolveConfiguredAgentIds(cfg);
const configuredAgentIds = listAgentIds(cfg);
const explicitSwarmGroupId = normalizeOptionalString(params.groupId);
const requesterRunId = normalizeOptionalString(ctx.requesterRunId);
const swarmGroupId = params.collect
@@ -256,25 +234,18 @@ export function resolveSubagentSpawnRequest(
};
const admission = resolveAdmission();
if (!admission.ok) {
return {
ok: false,
result: {
status: "forbidden",
error:
usingDefaultAgentId && !admission.governingCap?.startsWith("tools.swarm.")
? `tools.swarm.defaultAgentId is unavailable: ${admission.error}`
: admission.error,
},
};
return rejectSubagentSpawnRequest(
"forbidden",
usingDefaultAgentId && !admission.governingCap?.startsWith("tools.swarm.")
? `tools.swarm.defaultAgentId is unavailable: ${admission.error}`
: admission.error,
);
}
if (params.collect && !swarmGroupId) {
return {
ok: false,
result: {
status: "error",
error: "sessions_spawn collect=true requires a requesting run id when groupId is omitted.",
},
};
return rejectSubagentSpawnRequest(
"error",
"sessions_spawn collect=true requires a requesting run id when groupId is omitted.",
);
}
const childDepth = admission.childSessionPatch?.spawnDepth ?? 1;
const maxSpawnDepth = admission.maxSpawnDepth ?? childDepth;
@@ -300,10 +271,10 @@ export function resolveSubagentSpawnRequest(
.map((entry) => entry.schedulerSlotId ?? entry.runId),
})
) {
return {
ok: false,
result: { status: "error", error: "sessions_spawn could not reserve swarm FIFO order." },
};
return rejectSubagentSpawnRequest(
"error",
"sessions_spawn could not reserve swarm FIFO order.",
);
}
reservationPending = true;
}
+13 -46
View File
@@ -67,8 +67,6 @@ import { activateSwarmRun, removeQueuedSwarmRun } from "./swarm-scheduler.js";
export { SUBAGENT_SPAWN_CONTEXT_MODES, SUBAGENT_SPAWN_MODES } from "./subagent-spawn.types.js";
const SUBAGENT_CONTROL_GATEWAY_TIMEOUT_MS = 60_000;
function sanitizeMountPathHint(value?: string): string | undefined {
const trimmed = normalizeOptionalString(value);
if (!trimmed) {
@@ -220,15 +218,10 @@ export async function spawnSubagentDirect(
resolvedModel,
});
if (runtimeModelPersistError) {
try {
await callSubagentGateway({
method: "sessions.delete",
params: { key: childSessionKey, emitLifecycleHooks: false },
timeoutMs: SUBAGENT_CONTROL_GATEWAY_TIMEOUT_MS,
});
} catch {
// Best-effort cleanup only.
}
await cleanupProvisionalSession(childSessionKey, {
emitLifecycleHooks: false,
deleteTranscript: true,
});
return {
status: "error",
error: runtimeModelPersistError,
@@ -253,15 +246,10 @@ export async function spawnSubagentDirect(
},
});
if (bindResult.status === "error") {
try {
await callSubagentGateway({
method: "sessions.delete",
params: { key: childSessionKey, deleteTranscript: true, emitLifecycleHooks: false },
timeoutMs: SUBAGENT_CONTROL_GATEWAY_TIMEOUT_MS,
});
} catch {
// Best-effort cleanup only.
}
await cleanupProvisionalSession(childSessionKey, {
emitLifecycleHooks: false,
deleteTranscript: true,
});
return {
status: "error",
error: bindResult.error,
@@ -544,6 +532,7 @@ export async function spawnSubagentDirect(
};
}
childRunId = pipelineResult.runId;
let collectorSessionKey: string | undefined;
if (params.collect && swarmGroupId && swarmSchedulerGroupKey) {
let launchTerminationConfirmed = false;
activateSwarmRun({
@@ -614,34 +603,11 @@ export async function spawnSubagentDirect(
},
});
swarmReservationPending = false;
emitSessionLifecycleEvent({
sessionKey: childSessionKey,
reason: "create",
parentSessionKey: requesterInternalKey,
label: label || undefined,
});
const acceptedNote = resolveSubagentSpawnAcceptedNote({
spawnMode,
agentSessionKey: ctx.agentSessionKey,
});
return {
status: "accepted",
childSessionKey,
sessionKey: childSessionKey,
runId: childRunId,
mode: spawnMode,
taskName,
note: preparedSpawnContext.forkFallbackNote
? `${acceptedNote} ${preparedSpawnContext.forkFallbackNote}`
: acceptedNote,
...resolvedModelMetadata,
modelApplied: resolvedModel ? modelApplied : undefined,
attachments: attachmentsReceipt,
};
collectorSessionKey = childSessionKey;
} else {
await emitSpawnLifecycleHooks(childRunId);
}
await emitSpawnLifecycleHooks(childRunId);
// Emit lifecycle event so the gateway can broadcast sessions.changed to SSE subscribers.
emitSessionLifecycleEvent({
sessionKey: childSessionKey,
@@ -657,6 +623,7 @@ export async function spawnSubagentDirect(
return {
status: "accepted",
childSessionKey,
...(collectorSessionKey ? { sessionKey: collectorSessionKey } : {}),
runId: childRunId,
mode: spawnMode,
taskName,
@@ -18,6 +18,20 @@ type ResolveCommandConversationParams = {
fallbackTo?: string;
};
type IdleLifecycleScenario = {
name: string;
createParams: (commandBody: string) => HandleCommandsParams;
createBinding: () => SessionBindingRecord;
updateBinding: ReturnType<typeof vi.fn>;
expectedConversation?: {
channel: string;
accountId: string;
conversationId: string;
parentConversationId: string;
threadId: string;
};
};
function firstText(values: Array<string | undefined>): string | undefined {
return values.map((value) => value?.trim() ?? "").find(Boolean) || undefined;
}
@@ -118,59 +132,49 @@ const hoisted = vi.hoisted(() => {
const setTelegramThreadBindingIdleTimeoutBySessionKeyMock = vi.fn();
const setTelegramThreadBindingMaxAgeBySessionKeyMock = vi.fn();
const sessionBindingResolveByConversationMock = vi.fn();
function createRuntimeChannel(
id: string,
resolveCommandConversation: (params: ResolveCommandConversationParams) => {
conversationId: string;
parentConversationId?: string;
} | null,
setIdleTimeoutBySessionKey: typeof setThreadBindingIdleTimeoutBySessionKeyMock,
setMaxAgeBySessionKey: typeof setThreadBindingMaxAgeBySessionKeyMock,
) {
return {
plugin: {
id,
meta: {},
config: { hasPersistedAuthState: () => false },
bindings: { resolveCommandConversation },
conversationBindings: {
supportsCurrentConversationBinding: true,
setIdleTimeoutBySessionKey,
setMaxAgeBySessionKey,
},
},
};
}
const runtimeChannelRegistry = {
channels: [
{
plugin: {
id: threadChannel,
meta: {},
config: {
hasPersistedAuthState: () => false,
},
bindings: {
resolveCommandConversation: resolveThreadCommandConversation,
},
conversationBindings: {
supportsCurrentConversationBinding: true,
setIdleTimeoutBySessionKey: setThreadBindingIdleTimeoutBySessionKeyMock,
setMaxAgeBySessionKey: setThreadBindingMaxAgeBySessionKeyMock,
},
},
},
{
plugin: {
id: roomChannel,
meta: {},
config: {
hasPersistedAuthState: () => false,
},
bindings: {
resolveCommandConversation: resolveRoomCommandConversation,
},
conversationBindings: {
supportsCurrentConversationBinding: true,
setIdleTimeoutBySessionKey: setMatrixThreadBindingIdleTimeoutBySessionKeyMock,
setMaxAgeBySessionKey: setMatrixThreadBindingMaxAgeBySessionKeyMock,
},
},
},
{
plugin: {
id: topicChannel,
meta: {},
config: {
hasPersistedAuthState: () => false,
},
bindings: {
resolveCommandConversation: resolveTopicCommandConversation,
},
conversationBindings: {
supportsCurrentConversationBinding: true,
setIdleTimeoutBySessionKey: setTelegramThreadBindingIdleTimeoutBySessionKeyMock,
setMaxAgeBySessionKey: setTelegramThreadBindingMaxAgeBySessionKeyMock,
},
},
},
createRuntimeChannel(
threadChannel,
resolveThreadCommandConversation,
setThreadBindingIdleTimeoutBySessionKeyMock,
setThreadBindingMaxAgeBySessionKeyMock,
),
createRuntimeChannel(
roomChannel,
resolveRoomCommandConversation,
setMatrixThreadBindingIdleTimeoutBySessionKeyMock,
setMatrixThreadBindingMaxAgeBySessionKeyMock,
),
createRuntimeChannel(
topicChannel,
resolveTopicCommandConversation,
setTelegramThreadBindingIdleTimeoutBySessionKeyMock,
setTelegramThreadBindingMaxAgeBySessionKeyMock,
),
],
};
return {
@@ -214,28 +218,15 @@ vi.mock("../../channels/plugins/conversation-bindings.js", () => ({
accountId?: string | null;
idleTimeoutMs: number;
}) => {
if (params.channelId === THREAD_CHANNEL) {
return hoisted.setThreadBindingIdleTimeoutBySessionKeyMock({
targetSessionKey: params.targetSessionKey,
accountId: params.accountId,
idleTimeoutMs: params.idleTimeoutMs,
});
}
if (params.channelId === ROOM_CHANNEL) {
return hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock({
targetSessionKey: params.targetSessionKey,
accountId: params.accountId,
idleTimeoutMs: params.idleTimeoutMs,
});
}
if (params.channelId === TOPIC_CHANNEL) {
return hoisted.setTelegramThreadBindingIdleTimeoutBySessionKeyMock({
targetSessionKey: params.targetSessionKey,
accountId: params.accountId,
idleTimeoutMs: params.idleTimeoutMs,
});
}
return [];
return (
hoisted.runtimeChannelRegistry.channels
.find((entry) => entry.plugin.id === params.channelId)
?.plugin.conversationBindings.setIdleTimeoutBySessionKey({
targetSessionKey: params.targetSessionKey,
accountId: params.accountId,
idleTimeoutMs: params.idleTimeoutMs,
}) ?? []
);
},
setChannelConversationBindingMaxAgeBySessionKey: (params: {
channelId: string;
@@ -243,28 +234,15 @@ vi.mock("../../channels/plugins/conversation-bindings.js", () => ({
accountId?: string | null;
maxAgeMs: number;
}) => {
if (params.channelId === THREAD_CHANNEL) {
return hoisted.setThreadBindingMaxAgeBySessionKeyMock({
targetSessionKey: params.targetSessionKey,
accountId: params.accountId,
maxAgeMs: params.maxAgeMs,
});
}
if (params.channelId === ROOM_CHANNEL) {
return hoisted.setMatrixThreadBindingMaxAgeBySessionKeyMock({
targetSessionKey: params.targetSessionKey,
accountId: params.accountId,
maxAgeMs: params.maxAgeMs,
});
}
if (params.channelId === TOPIC_CHANNEL) {
return hoisted.setTelegramThreadBindingMaxAgeBySessionKeyMock({
targetSessionKey: params.targetSessionKey,
accountId: params.accountId,
maxAgeMs: params.maxAgeMs,
});
}
return [];
return (
hoisted.runtimeChannelRegistry.channels
.find((entry) => entry.plugin.id === params.channelId)
?.plugin.conversationBindings.setMaxAgeBySessionKey({
targetSessionKey: params.targetSessionKey,
accountId: params.accountId,
maxAgeMs: params.maxAgeMs,
}) ?? []
);
},
}));
@@ -398,72 +376,60 @@ function createRoomCommandParams(commandBody: string, overrides?: Record<string,
});
}
function createThreadBinding(overrides?: Partial<SessionBindingRecord>): SessionBindingRecord {
function createLifecycleBinding(
conversation: SessionBindingRecord["conversation"],
overrides?: Partial<SessionBindingRecord>,
): SessionBindingRecord {
return {
bindingId: "default:thread-1",
bindingId: `default:${conversation.conversationId}`,
targetSessionKey: "agent:main:subagent:child",
targetKind: "subagent",
conversation: {
conversation,
status: "active",
boundAt: Date.now(),
metadata: {
boundBy: "user-1",
lastActivityAt: Date.now(),
idleTimeoutMs: 24 * 60 * 60 * 1000,
maxAgeMs: 0,
},
...overrides,
};
}
function createThreadBinding(overrides?: Partial<SessionBindingRecord>): SessionBindingRecord {
return createLifecycleBinding(
{
channel: THREAD_CHANNEL,
accountId: "default",
conversationId: "thread-1",
parentConversationId: "thread-1",
},
status: "active",
boundAt: Date.now(),
metadata: {
boundBy: "user-1",
lastActivityAt: Date.now(),
idleTimeoutMs: 24 * 60 * 60 * 1000,
maxAgeMs: 0,
},
...overrides,
};
overrides,
);
}
function createTopicBinding(overrides?: Partial<SessionBindingRecord>): SessionBindingRecord {
return {
bindingId: "default:-100200300:topic:77",
targetSessionKey: "agent:main:subagent:child",
targetKind: "subagent",
conversation: {
return createLifecycleBinding(
{
channel: TOPIC_CHANNEL,
accountId: "default",
conversationId: "-100200300:topic:77",
},
status: "active",
boundAt: Date.now(),
metadata: {
boundBy: "user-1",
lastActivityAt: Date.now(),
idleTimeoutMs: 24 * 60 * 60 * 1000,
maxAgeMs: 0,
},
...overrides,
};
overrides,
);
}
function createRoomBinding(overrides?: Partial<SessionBindingRecord>): SessionBindingRecord {
return {
bindingId: "default:$thread-1",
targetSessionKey: "agent:main:subagent:child",
targetKind: "subagent",
conversation: {
return createLifecycleBinding(
{
channel: ROOM_CHANNEL,
accountId: "default",
conversationId: "$thread-1",
parentConversationId: "!room:example.org",
},
status: "active",
boundAt: Date.now(),
metadata: {
boundBy: "user-1",
lastActivityAt: Date.now(),
idleTimeoutMs: 24 * 60 * 60 * 1000,
maxAgeMs: 0,
},
...overrides,
};
overrides,
);
}
function createRoomTriggerBinding(overrides?: Partial<SessionBindingRecord>): SessionBindingRecord {
@@ -510,30 +476,67 @@ describe("/session idle and /session max-age", () => {
vi.useRealTimers();
});
it("sets idle timeout for the focused thread-chat session", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(createThreadBinding());
hoisted.setThreadBindingIdleTimeoutBySessionKeyMock.mockReturnValue([
{
targetSessionKey: "agent:main:subagent:child",
boundAt: Date.now(),
lastActivityAt: Date.now(),
idleTimeoutMs: 2 * 60 * 60 * 1000,
it.each([
{
name: "sets idle timeout for the focused thread-chat session",
createParams: createThreadCommandParams,
createBinding: createThreadBinding,
updateBinding: hoisted.setThreadBindingIdleTimeoutBySessionKeyMock,
},
{
name: "sets idle timeout for focused topic-chat conversations",
createParams: createTopicCommandParams,
createBinding: createTopicBinding,
updateBinding: hoisted.setTelegramThreadBindingIdleTimeoutBySessionKeyMock,
},
{
name: "sets idle timeout for focused room-chat threads",
createParams: createRoomThreadCommandParams,
createBinding: createRoomBinding,
updateBinding: hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock,
},
{
name: "sets idle timeout for the triggering room-chat always-thread turn",
createParams: createRoomTriggerThreadCommandParams,
createBinding: createRoomTriggerBinding,
updateBinding: hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock,
expectedConversation: {
channel: ROOM_CHANNEL,
accountId: "default",
conversationId: "$root",
parentConversationId: "!room:example.org",
threadId: "$root",
},
]);
},
] satisfies IdleLifecycleScenario[])(
"$name",
async ({
createParams,
createBinding,
updateBinding,
expectedConversation,
}: IdleLifecycleScenario) => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(createBinding());
updateBinding.mockReturnValue([
{
targetSessionKey: "agent:main:subagent:child",
boundAt: Date.now(),
lastActivityAt: Date.now(),
idleTimeoutMs: 2 * 60 * 60 * 1000,
},
]);
const result = await handleSessionCommand(createThreadCommandParams("/session idle 2h"), true);
const text = result?.reply?.text ?? "";
expectIdleTimeoutSetReply(
hoisted.setThreadBindingIdleTimeoutBySessionKeyMock,
text,
2 * 60 * 60 * 1000,
"2h",
);
});
const result = await handleSessionCommand(createParams("/session idle 2h"), true);
if (expectedConversation) {
expect(hoisted.sessionBindingResolveByConversationMock).toHaveBeenCalledWith(
expectedConversation,
);
}
expectIdleTimeoutSetReply(updateBinding, result?.reply?.text ?? "", 2 * 60 * 60 * 1000, "2h");
},
);
it("rejects unsafe bare-hour lifecycle durations", async () => {
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(createThreadBinding());
@@ -607,183 +610,62 @@ describe("/session idle and /session max-age", () => {
);
});
it("sets max age for the focused thread-chat session", async () => {
it.each([
{
name: "sets max age for the focused thread-chat session",
createParams: createThreadCommandParams,
createBinding: createThreadBinding,
updateBinding: hoisted.setThreadBindingMaxAgeBySessionKeyMock,
boundAt: undefined,
expiry: "2026-02-20T03:00:00.000Z",
},
{
name: "sets max age for focused room-chat threads",
createParams: createRoomThreadCommandParams,
createBinding: createRoomBinding,
updateBinding: hoisted.setMatrixThreadBindingMaxAgeBySessionKeyMock,
boundAt: "2026-02-19T22:00:00.000Z",
expiry: "2026-02-20T01:00:00.000Z",
},
{
name: "reports topic-chat max-age expiry from the original bind time",
createParams: createTopicCommandParams,
createBinding: createTopicBinding,
updateBinding: hoisted.setTelegramThreadBindingMaxAgeBySessionKeyMock,
boundAt: "2026-02-19T22:00:00.000Z",
expiry: "2026-02-20T01:00:00.000Z",
},
] satisfies Array<{
name: string;
createParams: (commandBody: string) => HandleCommandsParams;
createBinding: (overrides?: Partial<SessionBindingRecord>) => SessionBindingRecord;
updateBinding: ReturnType<typeof vi.fn>;
boundAt: string | undefined;
expiry: string;
}>)("$name", async ({ createParams, createBinding, updateBinding, boundAt, expiry }) => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(createThreadBinding());
hoisted.setThreadBindingMaxAgeBySessionKeyMock.mockReturnValue([
{
targetSessionKey: "agent:main:subagent:child",
boundAt: Date.now(),
lastActivityAt: Date.now(),
maxAgeMs: 3 * 60 * 60 * 1000,
},
]);
const result = await handleSessionCommand(
createThreadCommandParams("/session max-age 3h"),
true,
);
const text = result?.reply?.text ?? "";
expect(hoisted.setThreadBindingMaxAgeBySessionKeyMock).toHaveBeenCalledWith({
targetSessionKey: "agent:main:subagent:child",
accountId: "default",
maxAgeMs: 3 * 60 * 60 * 1000,
});
expect(text).toContain("Max age set to 3h");
expect(text).toContain("2026-02-20T03:00:00.000Z");
});
it("sets idle timeout for focused topic-chat conversations", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(createTopicBinding());
hoisted.setTelegramThreadBindingIdleTimeoutBySessionKeyMock.mockReturnValue([
{
targetSessionKey: "agent:main:subagent:child",
boundAt: Date.now(),
lastActivityAt: Date.now(),
idleTimeoutMs: 2 * 60 * 60 * 1000,
},
]);
const result = await handleSessionCommand(createTopicCommandParams("/session idle 2h"), true);
const text = result?.reply?.text ?? "";
expectIdleTimeoutSetReply(
hoisted.setTelegramThreadBindingIdleTimeoutBySessionKeyMock,
text,
2 * 60 * 60 * 1000,
"2h",
);
});
it("sets idle timeout for focused room-chat threads", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(createRoomBinding());
hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock.mockReturnValue([
{
targetSessionKey: "agent:main:subagent:child",
boundAt: Date.now(),
lastActivityAt: Date.now(),
idleTimeoutMs: 2 * 60 * 60 * 1000,
},
]);
const result = await handleSessionCommand(
createRoomThreadCommandParams("/session idle 2h"),
true,
);
const text = result?.reply?.text ?? "";
expectIdleTimeoutSetReply(
hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock,
text,
2 * 60 * 60 * 1000,
"2h",
);
});
it("sets idle timeout for the triggering room-chat always-thread turn", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(createRoomTriggerBinding());
hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock.mockReturnValue([
{
targetSessionKey: "agent:main:subagent:child",
boundAt: Date.now(),
lastActivityAt: Date.now(),
idleTimeoutMs: 2 * 60 * 60 * 1000,
},
]);
const result = await handleSessionCommand(
createRoomTriggerThreadCommandParams("/session idle 2h"),
true,
);
const text = result?.reply?.text ?? "";
expect(hoisted.sessionBindingResolveByConversationMock).toHaveBeenCalledWith({
channel: ROOM_CHANNEL,
accountId: "default",
conversationId: "$root",
parentConversationId: "!room:example.org",
threadId: "$root",
});
expectIdleTimeoutSetReply(
hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock,
text,
2 * 60 * 60 * 1000,
"2h",
);
});
it("sets max age for focused room-chat threads", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
const boundAt = Date.parse("2026-02-19T22:00:00.000Z");
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(createRoomBinding({ boundAt }));
hoisted.setMatrixThreadBindingMaxAgeBySessionKeyMock.mockReturnValue([
{
targetSessionKey: "agent:main:subagent:child",
boundAt,
lastActivityAt: Date.now(),
maxAgeMs: 3 * 60 * 60 * 1000,
},
]);
const result = await handleSessionCommand(
createRoomThreadCommandParams("/session max-age 3h"),
true,
);
const text = result?.reply?.text ?? "";
expect(hoisted.setMatrixThreadBindingMaxAgeBySessionKeyMock).toHaveBeenCalledWith({
targetSessionKey: "agent:main:subagent:child",
accountId: "default",
maxAgeMs: 3 * 60 * 60 * 1000,
});
expect(text).toContain("Max age set to 3h");
expect(text).toContain("2026-02-20T01:00:00.000Z");
});
it("reports topic-chat max-age expiry from the original bind time", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
const boundAt = Date.parse("2026-02-19T22:00:00.000Z");
const bindingTime = boundAt ? Date.parse(boundAt) : Date.now();
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(
createTopicBinding({ boundAt }),
createBinding({ boundAt: bindingTime }),
);
hoisted.setTelegramThreadBindingMaxAgeBySessionKeyMock.mockReturnValue([
updateBinding.mockReturnValue([
{
targetSessionKey: "agent:main:subagent:child",
boundAt,
boundAt: bindingTime,
lastActivityAt: Date.now(),
maxAgeMs: 3 * 60 * 60 * 1000,
},
]);
const result = await handleSessionCommand(
createTopicCommandParams("/session max-age 3h"),
true,
);
const text = result?.reply?.text ?? "";
expect(hoisted.setTelegramThreadBindingMaxAgeBySessionKeyMock).toHaveBeenCalledWith({
const result = await handleSessionCommand(createParams("/session max-age 3h"), true);
expect(updateBinding).toHaveBeenCalledWith({
targetSessionKey: "agent:main:subagent:child",
accountId: "default",
maxAgeMs: 3 * 60 * 60 * 1000,
});
expect(text).toContain("Max age set to 3h");
expect(text).toContain("2026-02-20T01:00:00.000Z");
expect(result?.reply?.text).toContain("Max age set to 3h");
expect(result?.reply?.text).toContain(expiry);
});
it("disables max age when set to off", async () => {
@@ -39,6 +39,16 @@ type FastModeStateMockResult = {
source: "session" | "agent" | "config" | "default";
fastAutoOnSeconds?: number;
};
type UsageFooterScenario = {
name: string;
command: string;
targetUsage?: "off" | "tokens" | "full";
wrapperUsage?: "off" | "tokens" | "full";
configDefault?: "off" | "tokens" | "full";
shareTargetEntry?: boolean;
expectedUsage: "off" | "tokens" | "full" | undefined;
expectedText: string;
};
const resolveFastModeStateMock = vi.hoisted(() =>
vi.fn<() => FastModeStateMockResult>(() => ({
mode: true,
@@ -220,146 +230,92 @@ describe("handleUsageCommand", () => {
});
});
it("prefers the target session entry from sessionStore for /usage footer mode", async () => {
it.each([
{
name: "prefers the target session entry from sessionStore for /usage footer mode",
command: "/usage",
targetUsage: "tokens",
wrapperUsage: "off",
expectedUsage: "full",
expectedText: "⚙️ Usage footer: full.",
},
{
name: "updates usage footer mode as a session preference",
command: "/usage tokens",
targetUsage: "full",
shareTargetEntry: true,
expectedUsage: "tokens",
expectedText: "⚙️ Usage footer: tokens.",
},
{
name: "persists an explicit /usage off so a configured default cannot re-enable it",
command: "/usage off",
targetUsage: "tokens",
expectedUsage: "off",
expectedText: "⚙️ Usage footer: off.",
},
{
name: "no-arg toggle uses the effective mode (config default) when session is unset",
command: "/usage",
configDefault: "tokens",
expectedUsage: "full",
expectedText: "⚙️ Usage footer: full.",
},
{
name: "/usage reset clears the session override so the config default takes over",
command: "/usage reset",
targetUsage: "off",
expectedUsage: undefined,
expectedText: "⚙️ Usage footer: reset to default.",
},
{
name: "/usage inherit (alias) clears the session override",
command: "/usage inherit",
targetUsage: "full",
expectedUsage: undefined,
expectedText: "⚙️ Usage footer: reset to default.",
},
{
name: "explicit off is stored and not treated as unset — config default cannot override it",
command: "/usage",
targetUsage: "off",
configDefault: "tokens",
expectedUsage: "tokens",
expectedText: "⚙️ Usage footer: tokens.",
},
] satisfies UsageFooterScenario[])("$name", async (scenario: UsageFooterScenario) => {
const params = buildUsageParams();
params.command.commandBodyNormalized = "/usage";
params.sessionEntry = {
sessionId: "wrapper-session",
updatedAt: Date.now(),
responseUsage: "off",
};
params.sessionStore = {
[params.sessionKey]: {
sessionId: "target-session",
updatedAt: Date.now(),
responseUsage: "tokens",
},
};
const result = await handleUsageCommand(params, true);
expect(result?.shouldContinue).toBe(false);
expect(result?.reply?.text).toBe("⚙️ Usage footer: full.");
});
it("updates usage footer mode as a session preference", async () => {
const params = buildUsageParams();
params.command.commandBodyNormalized = "/usage tokens";
params.sessionEntry = {
params.command.commandBodyNormalized = scenario.command;
if (scenario.configDefault) {
params.cfg = { ...params.cfg, messages: { responseUsage: scenario.configDefault } };
}
const targetEntry: NonNullable<HandleCommandsParams["sessionEntry"]> = {
sessionId: "target-session",
updatedAt: Date.now(),
responseUsage: "full",
...(scenario.targetUsage ? { responseUsage: scenario.targetUsage } : {}),
};
params.sessionStore = { [params.sessionKey]: params.sessionEntry };
const result = await handleUsageCommand(params, true);
expect(result?.reply?.text).toBe("⚙️ Usage footer: tokens.");
expect(params.sessionEntry.responseUsage).toBe("tokens");
});
it("persists an explicit /usage off so a configured default cannot re-enable it", async () => {
const params = buildUsageParams();
params.command.commandBodyNormalized = "/usage off";
params.sessionStore = {
[params.sessionKey]: {
sessionId: "target-session",
params.sessionStore = { [params.sessionKey]: targetEntry };
if (scenario.shareTargetEntry) {
params.sessionEntry = targetEntry;
} else if (scenario.wrapperUsage) {
params.sessionEntry = {
sessionId: "wrapper-session",
updatedAt: Date.now(),
responseUsage: "tokens",
},
};
responseUsage: scenario.wrapperUsage,
};
}
const result = await handleUsageCommand(params, true);
expect(result?.shouldContinue).toBe(false);
expect(result?.reply?.text).toBe("⚙️ Usage footer: off.");
expect(params.sessionStore[params.sessionKey]?.responseUsage).toBe("off");
});
it("no-arg toggle uses the effective mode (config default) when session is unset", async () => {
// When session has no override, the effective mode is the config default.
// The toggle should cycle from that effective value, not from "off".
const params = buildUsageParams();
params.command.commandBodyNormalized = "/usage";
params.cfg = {
...params.cfg,
messages: { responseUsage: "tokens" },
} as OpenClawConfig;
params.sessionStore = {
[params.sessionKey]: {
sessionId: "target-session",
updatedAt: Date.now(),
// responseUsage is absent — session inherits config default "tokens"
},
};
const result = await handleUsageCommand(params, true);
expect(result?.shouldContinue).toBe(false);
// Effective current = "tokens" (from config), so cycle → "full"
expect(result?.reply?.text).toBe("⚙️ Usage footer: full.");
expect(params.sessionStore[params.sessionKey]?.responseUsage).toBe("full");
});
it("/usage reset clears the session override so the config default takes over", async () => {
const params = buildUsageParams();
params.command.commandBodyNormalized = "/usage reset";
params.sessionStore = {
[params.sessionKey]: {
sessionId: "target-session",
updatedAt: Date.now(),
responseUsage: "off",
},
};
const result = await handleUsageCommand(params, true);
expect(result?.shouldContinue).toBe(false);
expect(result?.reply?.text).toBe("⚙️ Usage footer: reset to default.");
// responseUsage is deleted (undefined) — session now inherits the config default
expect(params.sessionStore[params.sessionKey]?.responseUsage).toBeUndefined();
});
it("/usage inherit (alias) clears the session override", async () => {
const params = buildUsageParams();
params.command.commandBodyNormalized = "/usage inherit";
params.sessionStore = {
[params.sessionKey]: {
sessionId: "target-session",
updatedAt: Date.now(),
responseUsage: "full",
},
};
const result = await handleUsageCommand(params, true);
expect(result?.shouldContinue).toBe(false);
expect(result?.reply?.text).toBe("⚙️ Usage footer: reset to default.");
expect(params.sessionStore[params.sessionKey]?.responseUsage).toBeUndefined();
});
it("explicit off is stored and not treated as unset — config default cannot override it", async () => {
// This verifies the three-state distinction: "off" vs undefined.
// When session has explicit "off", the effective value is "off" regardless of config.
const params = buildUsageParams();
params.command.commandBodyNormalized = "/usage";
params.cfg = {
...params.cfg,
messages: { responseUsage: "tokens" },
} as OpenClawConfig;
params.sessionStore = {
[params.sessionKey]: {
sessionId: "target-session",
updatedAt: Date.now(),
responseUsage: "off", // explicit off — stays off despite config default "tokens"
},
};
const result = await handleUsageCommand(params, true);
expect(result?.shouldContinue).toBe(false);
// Effective current = "off" (explicit, not inherited), so cycle → "tokens"
expect(result?.reply?.text).toBe("⚙️ Usage footer: tokens.");
expect(result?.reply?.text).toBe(scenario.expectedText);
expect(params.sessionStore[params.sessionKey]?.responseUsage).toBe(scenario.expectedUsage);
if (scenario.shareTargetEntry) {
expect(params.sessionEntry?.responseUsage).toBe(scenario.expectedUsage);
}
if (scenario.wrapperUsage) {
expect(params.sessionEntry?.responseUsage).toBe(scenario.wrapperUsage);
}
});
});
+82 -183
View File
@@ -55,7 +55,11 @@ import {
persistSessionEntry,
sessionEntryPersistenceConflictReply,
} from "./commands-session-store.js";
import type { CommandHandler, HandleCommandsParams } from "./commands-types.js";
import type {
CommandHandler,
CommandHandlerResult,
HandleCommandsParams,
} from "./commands-types.js";
import { resolveConversationBindingContextFromAcpCommand } from "./conversation-binding-input.js";
const SESSION_COMMAND_PREFIX = "/session";
@@ -63,6 +67,10 @@ const SESSION_DURATION_OFF_VALUES = new Set(["off", "disable", "disabled", "none
const SESSION_ACTION_IDLE = "idle";
const SESSION_ACTION_MAX_AGE = "max-age";
function sessionCommandReply(text: string): CommandHandlerResult {
return { shouldContinue: false, reply: { text } };
}
function buildRestartCommandSentinel(params: HandleCommandsParams): RestartSentinelPayload | null {
const sessionKey = normalizeOptionalString(params.sessionKey);
if (!sessionKey) {
@@ -214,10 +222,7 @@ export const handleActivationCommand: CommandHandler = async (params, allowTextC
return null;
}
if (!params.isGroup) {
return {
shouldContinue: false,
reply: { text: "⚙️ Group activation only applies to group chats." },
};
return sessionCommandReply("⚙️ Group activation only applies to group chats.");
}
const unauthorizedResult = rejectUnauthorizedCommand(params, "/activation");
if (unauthorizedResult) {
@@ -228,10 +233,7 @@ export const handleActivationCommand: CommandHandler = async (params, allowTextC
return nonOwnerResult;
}
if (!activationCommand.mode) {
return {
shouldContinue: false,
reply: { text: "⚙️ Usage: /activation mention|always" },
};
return sessionCommandReply("⚙️ Usage: /activation mention|always");
}
if (params.sessionEntry && params.sessionStore && params.sessionKey) {
params.sessionEntry.groupActivation = activationCommand.mode;
@@ -245,12 +247,7 @@ export const handleActivationCommand: CommandHandler = async (params, allowTextC
return sessionEntryPersistenceConflictReply();
}
}
return {
shouldContinue: false,
reply: {
text: `⚙️ Group activation set to ${activationCommand.mode}.`,
},
};
return sessionCommandReply(`⚙️ Group activation set to ${activationCommand.mode}.`);
};
export const handleSendPolicyCommand: CommandHandler = async (params, allowTextCommands) => {
@@ -270,10 +267,7 @@ export const handleSendPolicyCommand: CommandHandler = async (params, allowTextC
return nonOwnerResult;
}
if (!sendPolicyCommand.mode) {
return {
shouldContinue: false,
reply: { text: "⚙️ Usage: /send on|off|inherit" },
};
return sessionCommandReply("⚙️ Usage: /send on|off|inherit");
}
if (params.sessionEntry && params.sessionStore && params.sessionKey) {
if (sendPolicyCommand.mode === "inherit") {
@@ -291,10 +285,7 @@ export const handleSendPolicyCommand: CommandHandler = async (params, allowTextC
: sendPolicyCommand.mode === "allow"
? "on"
: "off";
return {
shouldContinue: false,
reply: { text: `⚙️ Send policy set to ${label}.` },
};
return sessionCommandReply(`⚙️ Send policy set to ${label}.`);
};
export const handleUsageCommand: CommandHandler = async (params, allowTextCommands) => {
@@ -375,19 +366,13 @@ export const handleUsageCommand: CommandHandler = async (params, allowTextComman
const last30Suffix = last30Missing > 0 ? " (partial)" : "";
const last30Line = `Last 30d ${last30Cost ?? "n/a"}${last30Suffix}`;
return {
shouldContinue: false,
reply: { text: `💸 Usage cost\n${sessionLine}\n${todayLine}\n${last30Line}` },
};
return sessionCommandReply(`💸 Usage cost\n${sessionLine}\n${todayLine}\n${last30Line}`);
}
const isReset = rawArgs ? isSessionDefaultDirectiveValue(rawArgs) : false;
if (rawArgs && !requested && !isReset) {
return {
shouldContinue: false,
reply: { text: "⚙️ Usage: /usage off|tokens|full|reset|cost" },
};
return sessionCommandReply("⚙️ Usage: /usage off|tokens|full|reset|cost");
}
const targetSessionEntry = params.sessionStore?.[params.sessionKey] ?? params.sessionEntry;
@@ -406,10 +391,7 @@ export const handleUsageCommand: CommandHandler = async (params, allowTextComman
return sessionEntryPersistenceConflictReply();
}
}
return {
shouldContinue: false,
reply: { text: "⚙️ Usage footer: reset to default." },
};
return sessionCommandReply("⚙️ Usage footer: reset to default.");
}
const replyChannel = params.command.channel;
@@ -435,12 +417,7 @@ export const handleUsageCommand: CommandHandler = async (params, allowTextComman
}
}
return {
shouldContinue: false,
reply: {
text: `⚙️ Usage footer: ${next}.`,
},
};
return sessionCommandReply(`⚙️ Usage footer: ${next}.`);
};
export const handleFastCommand: CommandHandler = async (params, allowTextCommands) => {
@@ -472,17 +449,14 @@ export const handleFastCommand: CommandHandler = async (params, allowTextCommand
agentId: sessionAgentId,
sessionEntry: targetSessionEntry,
});
return {
shouldContinue: false,
reply: {
text: formatFastModeCurrentStatus({
mode: state.mode,
source: state.source,
fastAutoOnSeconds: state.fastAutoOnSeconds,
label: "⚙️ Current fast mode",
}),
},
};
return sessionCommandReply(
formatFastModeCurrentStatus({
mode: state.mode,
source: state.source,
fastAutoOnSeconds: state.fastAutoOnSeconds,
label: "⚙️ Current fast mode",
}),
);
}
const targetSessionEntry = params.sessionStore?.[params.sessionKey] ?? params.sessionEntry;
@@ -502,15 +476,9 @@ export const handleFastCommand: CommandHandler = async (params, allowTextCommand
return sessionEntryPersistenceConflictReply();
}
}
return {
shouldContinue: false,
reply: { text: "⚙️ Fast mode reset to default." },
};
return sessionCommandReply("⚙️ Fast mode reset to default.");
}
return {
shouldContinue: false,
reply: { text: "⚙️ Usage: /fast status|auto|on|off|default" },
};
return sessionCommandReply("⚙️ Usage: /fast status|auto|on|off|default");
}
if (targetSessionEntry && params.sessionStore && params.sessionKey) {
@@ -526,15 +494,11 @@ export const handleFastCommand: CommandHandler = async (params, allowTextCommand
}
}
return {
shouldContinue: false,
reply: {
text:
nextMode === "auto"
? "⚙️ Fast mode set to auto."
: `⚙️ Fast mode ${nextMode ? "enabled" : "disabled"}.`,
},
};
return sessionCommandReply(
nextMode === "auto"
? "⚙️ Fast mode set to auto."
: `⚙️ Fast mode ${nextMode ? "enabled" : "disabled"}.`,
);
};
export const handleSessionCommand: CommandHandler = async (params, allowTextCommands) => {
@@ -556,10 +520,7 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
const tokens = rest.split(/\s+/).filter(Boolean);
const action = normalizeOptionalLowercaseString(tokens[0]);
if (action !== SESSION_ACTION_IDLE && action !== SESSION_ACTION_MAX_AGE) {
return {
shouldContinue: false,
reply: { text: resolveSessionCommandUsage() },
};
return sessionCommandReply(resolveSessionCommandUsage());
}
const channelId =
@@ -583,19 +544,13 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
!commandSupportsCurrentConversationBinding ||
!commandSupportsLifecycleUpdate
) {
return {
shouldContinue: false,
reply: {
text: "⚠️ /session idle and /session max-age are currently available only on channels that support focused conversation bindings.",
},
};
return sessionCommandReply(
"⚠️ /session idle and /session max-age are currently available only on channels that support focused conversation bindings.",
);
}
return {
shouldContinue: false,
reply: {
text: "⚠️ /session idle and /session max-age must be run inside a focused conversation.",
},
};
return sessionCommandReply(
"⚠️ /session idle and /session max-age must be run inside a focused conversation.",
);
}
const resolvedChannelId = bindingContext.channel || channelId;
const conversationBindings = resolvedChannelId
@@ -609,22 +564,16 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
? typeof conversationBindings?.setIdleTimeoutBySessionKey === "function"
: typeof conversationBindings?.setMaxAgeBySessionKey === "function";
if (!resolvedChannelId || !supportsCurrentConversationBinding || !supportsLifecycleUpdate) {
return {
shouldContinue: false,
reply: {
text: "⚠️ /session idle and /session max-age are currently available only on channels that support focused conversation bindings.",
},
};
return sessionCommandReply(
"⚠️ /session idle and /session max-age are currently available only on channels that support focused conversation bindings.",
);
}
const sessionBindingService = getSessionBindingService();
const activeBinding = sessionBindingService.resolveByConversation(bindingContext);
if (!activeBinding) {
return {
shouldContinue: false,
reply: { text: "️ This conversation is not currently focused." },
};
return sessionCommandReply("️ This conversation is not currently focused.");
}
const idleTimeoutMs = resolveSessionBindingDurationMs(
@@ -647,17 +596,11 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
Number.isFinite(idleExpiresAt) &&
idleExpiresAt > Date.now()
) {
return {
shouldContinue: false,
reply: {
text: `️ Idle timeout active (${formatThreadBindingDurationLabel(idleTimeoutMs)}, next auto-unfocus at ${formatSessionExpiry(idleExpiresAt)}).`,
},
};
return sessionCommandReply(
`️ Idle timeout active (${formatThreadBindingDurationLabel(idleTimeoutMs)}, next auto-unfocus at ${formatSessionExpiry(idleExpiresAt)}).`,
);
}
return {
shouldContinue: false,
reply: { text: "️ Idle timeout is currently disabled for this focused session." },
};
return sessionCommandReply("️ Idle timeout is currently disabled for this focused session.");
}
if (
@@ -665,38 +608,26 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
Number.isFinite(maxAgeExpiresAt) &&
maxAgeExpiresAt > Date.now()
) {
return {
shouldContinue: false,
reply: {
text: `️ Max age active (${formatThreadBindingDurationLabel(maxAgeMs)}, hard auto-unfocus at ${formatSessionExpiry(maxAgeExpiresAt)}).`,
},
};
return sessionCommandReply(
`️ Max age active (${formatThreadBindingDurationLabel(maxAgeMs)}, hard auto-unfocus at ${formatSessionExpiry(maxAgeExpiresAt)}).`,
);
}
return {
shouldContinue: false,
reply: { text: "️ Max age is currently disabled for this focused session." },
};
return sessionCommandReply("️ Max age is currently disabled for this focused session.");
}
const senderId = normalizeOptionalString(params.command.senderId) ?? "";
const boundBy = resolveSessionBindingBoundBy(activeBinding);
if (boundBy && boundBy !== "system" && senderId && senderId !== boundBy) {
return {
shouldContinue: false,
reply: {
text: `⚠️ Only ${boundBy} can update session lifecycle settings for this conversation.`,
},
};
return sessionCommandReply(
`⚠️ Only ${boundBy} can update session lifecycle settings for this conversation.`,
);
}
let durationMs: number;
try {
durationMs = parseSessionDurationMs(durationArgRaw);
} catch {
return {
shouldContinue: false,
reply: { text: resolveSessionCommandUsage() },
};
return sessionCommandReply(resolveSessionCommandUsage());
}
const updatedBindings =
@@ -714,27 +645,19 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
maxAgeMs: durationMs,
});
if (updatedBindings.length === 0) {
return {
shouldContinue: false,
reply: {
text:
action === SESSION_ACTION_IDLE
? "⚠️ Failed to update idle timeout for the current binding."
: "⚠️ Failed to update max age for the current binding.",
},
};
return sessionCommandReply(
action === SESSION_ACTION_IDLE
? "⚠️ Failed to update idle timeout for the current binding."
: "⚠️ Failed to update max age for the current binding.",
);
}
if (durationMs <= 0) {
return {
shouldContinue: false,
reply: {
text:
action === SESSION_ACTION_IDLE
? `✅ Idle timeout disabled for ${updatedBindings.length} binding${updatedBindings.length === 1 ? "" : "s"}.`
: `✅ Max age disabled for ${updatedBindings.length} binding${updatedBindings.length === 1 ? "" : "s"}.`,
},
};
return sessionCommandReply(
action === SESSION_ACTION_IDLE
? `✅ Idle timeout disabled for ${updatedBindings.length} binding${updatedBindings.length === 1 ? "" : "s"}.`
: `✅ Max age disabled for ${updatedBindings.length} binding${updatedBindings.length === 1 ? "" : "s"}.`,
);
}
const nextExpiry = resolveUpdatedBindingExpiry({
@@ -746,15 +669,11 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
? formatSessionExpiry(nextExpiry)
: "n/a";
return {
shouldContinue: false,
reply: {
text:
action === SESSION_ACTION_IDLE
? `✅ Idle timeout set to ${formatThreadBindingDurationLabel(durationMs)} for ${updatedBindings.length} binding${updatedBindings.length === 1 ? "" : "s"} (next auto-unfocus at ${expiryLabel}).`
: `✅ Max age set to ${formatThreadBindingDurationLabel(durationMs)} for ${updatedBindings.length} binding${updatedBindings.length === 1 ? "" : "s"} (hard auto-unfocus at ${expiryLabel}).`,
},
};
return sessionCommandReply(
action === SESSION_ACTION_IDLE
? `✅ Idle timeout set to ${formatThreadBindingDurationLabel(durationMs)} for ${updatedBindings.length} binding${updatedBindings.length === 1 ? "" : "s"} (next auto-unfocus at ${expiryLabel}).`
: `✅ Max age set to ${formatThreadBindingDurationLabel(durationMs)} for ${updatedBindings.length} binding${updatedBindings.length === 1 ? "" : "s"} (hard auto-unfocus at ${expiryLabel}).`,
);
};
export const handleRestartCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) {
@@ -774,12 +693,7 @@ export const handleRestartCommand: CommandHandler = async (params, allowTextComm
return nonOwner;
}
if (!isRestartEnabled(params.cfg)) {
return {
shouldContinue: false,
reply: {
text: "⚠️ /restart is disabled (commands.restart=false).",
},
};
return sessionCommandReply("⚠️ /restart is disabled (commands.restart=false).");
}
const hasSigusr1Listener = process.listenerCount("SIGUSR1") > 0;
const sentinelPayload = buildRestartCommandSentinel(params);
@@ -805,12 +719,9 @@ export const handleRestartCommand: CommandHandler = async (params, allowTextComm
}
: undefined,
});
return {
shouldContinue: false,
reply: {
text: "⚙️ Restarting OpenClaw in-process (SIGUSR1); back in a few seconds.",
},
};
return sessionCommandReply(
"⚙️ Restarting OpenClaw in-process (SIGUSR1); back in a few seconds.",
);
}
let sentinelWritten = false;
try {
@@ -820,12 +731,9 @@ export const handleRestartCommand: CommandHandler = async (params, allowTextComm
}
} catch (err) {
logVerbose(`failed to write /restart sentinel: ${String(err)}`);
return {
shouldContinue: false,
reply: {
text: "⚠️ Restart failed: could not persist the post-restart acknowledgement.",
},
};
return sessionCommandReply(
"⚠️ Restart failed: could not persist the post-restart acknowledgement.",
);
}
const restartMethod = triggerOpenClawRestart();
if (!restartMethod.ok) {
@@ -833,20 +741,11 @@ export const handleRestartCommand: CommandHandler = async (params, allowTextComm
await clearRestartSentinel();
}
const detail = restartMethod.detail ? ` Details: ${restartMethod.detail}` : "";
return {
shouldContinue: false,
reply: {
text: `⚠️ Restart failed (${restartMethod.method}).${detail}`,
},
};
return sessionCommandReply(`⚠️ Restart failed (${restartMethod.method}).${detail}`);
}
return {
shouldContinue: false,
reply: {
text: `⚙️ Restarting OpenClaw via ${restartMethod.method}; give me a few seconds to come back online.`,
},
};
return sessionCommandReply(
`⚙️ Restarting OpenClaw via ${restartMethod.method}; give me a few seconds to come back online.`,
);
};
export { handleAbortTrigger, handleStopCommand };
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
@@ -158,4 +158,27 @@ describe("projectSafeChannelAccountSnapshotFields", () => {
const withoutFlag = projectSafeChannelAccountSnapshotFields({ connected: false });
expect(withoutFlag).not.toHaveProperty("terminalDisconnect");
});
it("preserves false, zero, and nullable fields without exposing invalid credential metadata", () => {
const snapshot = projectSafeChannelAccountSnapshotFields({
running: false,
connected: false,
reconnectAttempts: 0,
lastConnectedAt: null,
lastOutboundAt: null,
ingressUnavailable: false,
activeRuns: 0,
token: "must-not-leak",
tokenStatus: "unexpected-status",
});
expect(snapshot).toStrictEqual({
running: false,
connected: false,
reconnectAttempts: 0,
lastConnectedAt: null,
lastOutboundAt: null,
activeRuns: 0,
});
});
});
+82 -116
View File
@@ -45,23 +45,21 @@ export function redactChannelAccountSnapshotBaseUrl<T extends Partial<ChannelAcc
return redactChannelStatusSummaryBaseUrl(snapshot);
}
function readBoolean(record: Record<string, unknown>, key: string): boolean | undefined {
return asBoolean(record[key]);
}
function readNumber(record: Record<string, unknown>, key: string): number | undefined {
const value = record[key];
return asFiniteNumber(value);
}
function readNullableNumber(
record: Record<string, unknown>,
key: string,
): number | null | undefined {
if (record[key] === null) {
return null;
return record[key] === null ? null : asFiniteNumber(record[key]);
}
function setSnapshotField<TKey extends keyof ChannelAccountSnapshot>(
snapshot: Partial<ChannelAccountSnapshot>,
key: TKey,
value: ChannelAccountSnapshot[TKey],
): void {
if (value !== undefined) {
snapshot[key] = value;
}
return readNumber(record, key);
}
function readStringArray(record: Record<string, unknown>, key: string): string[] | undefined {
@@ -195,34 +193,32 @@ export function projectCredentialSnapshotFields(
if (!record) {
return {};
}
const tokenSource = normalizeOptionalString(record.tokenSource);
const botTokenSource = normalizeOptionalString(record.botTokenSource);
const appTokenSource = normalizeOptionalString(record.appTokenSource);
const signingSecretSource = normalizeOptionalString(record.signingSecretSource);
// Only project source/status fields. Token-like values stay out of account snapshots even when
// callers pass full runtime account objects.
return {
...(tokenSource ? { tokenSource } : {}),
...(botTokenSource ? { botTokenSource } : {}),
...(appTokenSource ? { appTokenSource } : {}),
...(signingSecretSource ? { signingSecretSource } : {}),
...(readCredentialStatus(record, "tokenStatus")
? { tokenStatus: readCredentialStatus(record, "tokenStatus") }
: {}),
...(readCredentialStatus(record, "botTokenStatus")
? { botTokenStatus: readCredentialStatus(record, "botTokenStatus") }
: {}),
...(readCredentialStatus(record, "appTokenStatus")
? { appTokenStatus: readCredentialStatus(record, "appTokenStatus") }
: {}),
...(readCredentialStatus(record, "signingSecretStatus")
? { signingSecretStatus: readCredentialStatus(record, "signingSecretStatus") }
: {}),
...(readCredentialStatus(record, "userTokenStatus")
? { userTokenStatus: readCredentialStatus(record, "userTokenStatus") }
: {}),
};
type CredentialSnapshotFields = Pick<
Partial<ChannelAccountSnapshot>,
| "tokenSource"
| "botTokenSource"
| "appTokenSource"
| "signingSecretSource"
| "tokenStatus"
| "botTokenStatus"
| "appTokenStatus"
| "signingSecretStatus"
| "userTokenStatus"
>;
const snapshot: CredentialSnapshotFields = {};
// Only the explicit source/status allowlist crosses the credential boundary.
for (const key of [
"tokenSource",
"botTokenSource",
"appTokenSource",
"signingSecretSource",
] as const) {
setSnapshotField(snapshot, key, normalizeOptionalString(record[key]));
}
for (const key of CREDENTIAL_STATUS_KEYS) {
setSnapshotField(snapshot, key, readCredentialStatus(record, key));
}
return snapshot;
}
/**
@@ -238,81 +234,51 @@ export function projectSafeChannelAccountSnapshotFields(
if (!record) {
return {};
}
const name = normalizeOptionalString(record.name);
const statusState = normalizeOptionalString(record.statusState);
const healthState = normalizeOptionalString(record.healthState);
const mode = normalizeOptionalString(record.mode);
const dmPolicy = normalizeOptionalString(record.dmPolicy);
const baseUrl = normalizeOptionalString(record.baseUrl);
const cliPath = normalizeOptionalString(record.cliPath);
const dbPath = normalizeOptionalString(record.dbPath);
const snapshot: Partial<ChannelAccountSnapshot> = {};
setSnapshotField(snapshot, "name", normalizeOptionalString(record.name));
for (const key of ["linked", "running", "connected", "restartPending"] as const) {
setSnapshotField(snapshot, key, asBoolean(record[key]));
}
setSnapshotField(snapshot, "reconnectAttempts", asFiniteNumber(record.reconnectAttempts));
setSnapshotField(snapshot, "lastConnectedAt", readNullableNumber(record, "lastConnectedAt"));
setSnapshotField(snapshot, "lastInboundAt", asFiniteNumber(record.lastInboundAt));
for (const key of ["lastOutboundAt", "lastMessageAt", "lastEventAt"] as const) {
setSnapshotField(snapshot, key, readNullableNumber(record, key));
}
setSnapshotField(
snapshot,
"lastTransportActivityAt",
asFiniteNumber(record.lastTransportActivityAt),
);
for (const key of ["statusState", "healthState"] as const) {
setSnapshotField(snapshot, key, normalizeOptionalString(record[key]));
}
// False or absent ingress means unknown, never evidence that ingress is healthy.
if (asBoolean(record.ingressUnavailable) === true) {
snapshot.ingressUnavailable = true;
}
for (const key of ["terminalDisconnect", "busy"] as const) {
setSnapshotField(snapshot, key, asBoolean(record[key]));
}
setSnapshotField(snapshot, "activeRuns", asFiniteNumber(record.activeRuns));
for (const key of ["lastRunActivityAt", "activeRunStartedAt"] as const) {
setSnapshotField(snapshot, key, readNullableNumber(record, key));
}
for (const key of ["mode", "dmPolicy"] as const) {
setSnapshotField(snapshot, key, normalizeOptionalString(record[key]));
}
setSnapshotField(snapshot, "allowFrom", readStringArray(record, "allowFrom"));
Object.assign(snapshot, projectCredentialSnapshotFields(account));
return {
...(name ? { name } : {}),
...(readBoolean(record, "linked") !== undefined
? { linked: readBoolean(record, "linked") }
: {}),
...(readBoolean(record, "running") !== undefined
? { running: readBoolean(record, "running") }
: {}),
...(readBoolean(record, "connected") !== undefined
? { connected: readBoolean(record, "connected") }
: {}),
...(readBoolean(record, "restartPending") !== undefined
? { restartPending: readBoolean(record, "restartPending") }
: {}),
...(readNumber(record, "reconnectAttempts") !== undefined
? { reconnectAttempts: readNumber(record, "reconnectAttempts") }
: {}),
...(readNullableNumber(record, "lastConnectedAt") !== undefined
? { lastConnectedAt: readNullableNumber(record, "lastConnectedAt") }
: {}),
...(readNumber(record, "lastInboundAt") !== undefined
? { lastInboundAt: readNumber(record, "lastInboundAt") }
: {}),
...(readNullableNumber(record, "lastOutboundAt") !== undefined
? { lastOutboundAt: readNullableNumber(record, "lastOutboundAt") }
: {}),
...(readNullableNumber(record, "lastMessageAt") !== undefined
? { lastMessageAt: readNullableNumber(record, "lastMessageAt") }
: {}),
...(readNullableNumber(record, "lastEventAt") !== undefined
? { lastEventAt: readNullableNumber(record, "lastEventAt") }
: {}),
...(readNumber(record, "lastTransportActivityAt") !== undefined
? { lastTransportActivityAt: readNumber(record, "lastTransportActivityAt") }
: {}),
...(statusState ? { statusState } : {}),
...(healthState ? { healthState } : {}),
// Only a proven-dead ingress crosses this boundary; `false`/absent both mean
// "nothing known", so never project a negative and invent a healthy claim.
...(readBoolean(record, "ingressUnavailable") === true ? { ingressUnavailable: true } : {}),
...(readBoolean(record, "terminalDisconnect") !== undefined
? { terminalDisconnect: readBoolean(record, "terminalDisconnect") }
: {}),
...(readBoolean(record, "busy") !== undefined ? { busy: readBoolean(record, "busy") } : {}),
...(readNumber(record, "activeRuns") !== undefined
? { activeRuns: readNumber(record, "activeRuns") }
: {}),
...(readNullableNumber(record, "lastRunActivityAt") !== undefined
? { lastRunActivityAt: readNullableNumber(record, "lastRunActivityAt") }
: {}),
...(readNullableNumber(record, "activeRunStartedAt") !== undefined
? { activeRunStartedAt: readNullableNumber(record, "activeRunStartedAt") }
: {}),
...(mode ? { mode } : {}),
...(dmPolicy ? { dmPolicy } : {}),
...(readStringArray(record, "allowFrom")
? { allowFrom: readStringArray(record, "allowFrom") }
: {}),
...projectCredentialSnapshotFields(account),
// Base URLs are useful diagnostics, but embedded credentials must not cross this boundary.
...(baseUrl ? { baseUrl: stripUrlUserInfo(redactSensitiveUrlLikeString(baseUrl)) } : {}),
...(readBoolean(record, "allowUnmentionedGroups") !== undefined
? { allowUnmentionedGroups: readBoolean(record, "allowUnmentionedGroups") }
: {}),
...(cliPath ? { cliPath } : {}),
...(dbPath ? { dbPath } : {}),
...(readNumber(record, "port") !== undefined ? { port: readNumber(record, "port") } : {}),
};
const baseUrl = normalizeOptionalString(record.baseUrl);
if (baseUrl) {
// Preserve diagnostics without allowing URL-embedded credentials to escape.
snapshot.baseUrl = stripUrlUserInfo(redactSensitiveUrlLikeString(baseUrl));
}
setSnapshotField(snapshot, "allowUnmentionedGroups", asBoolean(record.allowUnmentionedGroups));
for (const key of ["cliPath", "dbPath"] as const) {
setSnapshotField(snapshot, key, normalizeOptionalString(record[key]));
}
setSnapshotField(snapshot, "port", asFiniteNumber(record.port));
return snapshot;
}
@@ -105,6 +105,22 @@ function seedChannelPkg(
fs.writeFileSync(path.join(pluginDir, "index.js"), "export default { register() {} };\n", "utf8");
}
function seedGeneratedChannelCatalog(
root: string,
params: {
packageName: string;
id: string;
label: string;
docsPath: string;
blurb: string;
},
): void {
const { packageName, ...channel } = params;
writeJsonFile(path.join(root, "dist", "channel-catalog.json"), {
entries: [{ name: packageName, openclaw: { channel } }],
});
}
describe("listBundledChannelCatalogEntries", () => {
it("reads bundled channel metadata from the extensions dir returned by resolveBundledPluginsDir", () => {
// Regression gate for the onboard crash on globally installed CLI: in a
@@ -144,20 +160,12 @@ describe("listBundledChannelCatalogEntries", () => {
docsPath: "/channels/telegram",
label: "Telegram",
});
writeJsonFile(path.join(root, "dist", "channel-catalog.json"), {
entries: [
{
name: "@openclaw/qqbot",
openclaw: {
channel: {
id: "qqbot",
label: "QQ Bot",
docsPath: "/channels/qqbot",
blurb: "downloadable channel",
},
},
},
],
seedGeneratedChannelCatalog(root, {
packageName: "@openclaw/qqbot",
id: "qqbot",
label: "QQ Bot",
docsPath: "/channels/qqbot",
blurb: "downloadable channel",
});
useBundledPluginsDir(extensionsRoot);
@@ -176,20 +184,12 @@ describe("listBundledChannelCatalogEntries", () => {
label: "Matrix",
markdownCapable: true,
});
writeJsonFile(path.join(root, "dist", "channel-catalog.json"), {
entries: [
{
name: "@openclaw/matrix",
openclaw: {
channel: {
id: "matrix",
label: "Matrix",
docsPath: "/channels/matrix",
blurb: "stale generated entry",
},
},
},
],
seedGeneratedChannelCatalog(root, {
packageName: "@openclaw/matrix",
id: "matrix",
label: "Matrix",
docsPath: "/channels/matrix",
blurb: "stale generated entry",
});
useBundledPluginsDir(extensionsRoot);
@@ -203,20 +203,12 @@ describe("listBundledChannelCatalogEntries", () => {
// that case the loader should consult the shipped channel-catalog.json
// rather than report zero bundled channels.
const root = seedRoot("bcr-fallback-undefined-");
writeJsonFile(path.join(root, "dist", "channel-catalog.json"), {
entries: [
{
name: "@openclaw/fallback",
openclaw: {
channel: {
id: "fallback-channel",
label: "Fallback",
docsPath: "/channels/fallback",
blurb: "fallback blurb",
},
},
},
],
seedGeneratedChannelCatalog(root, {
packageName: "@openclaw/fallback",
id: "fallback-channel",
label: "Fallback",
docsPath: "/channels/fallback",
blurb: "fallback blurb",
});
useBundledPluginsDir(undefined);
@@ -232,20 +224,12 @@ describe("listBundledChannelCatalogEntries", () => {
const root = seedRoot("bcr-fallback-empty-");
const extensionsRoot = path.join(root, "dist", "extensions");
fs.mkdirSync(extensionsRoot, { recursive: true });
writeJsonFile(path.join(root, "dist", "channel-catalog.json"), {
entries: [
{
name: "@openclaw/fallback",
openclaw: {
channel: {
id: "fallback-channel",
label: "Fallback",
docsPath: "/channels/fallback",
blurb: "fallback blurb",
},
},
},
],
seedGeneratedChannelCatalog(root, {
packageName: "@openclaw/fallback",
id: "fallback-channel",
label: "Fallback",
docsPath: "/channels/fallback",
blurb: "fallback blurb",
});
useBundledPluginsDir(extensionsRoot);
+20 -39
View File
@@ -42,40 +42,24 @@ export function createAccountListHelpers<
function hasImplicitDefaultAccount(cfg: OpenClawConfig): boolean {
// Legacy single-account configs and env-only setup imply the default account even when
// channels.<id>.accounts is absent.
if (options?.hasImplicitDefaultAccount?.(cfg)) {
return true;
}
const channel = cfg.channels?.[channelKey] as Record<string, unknown> | undefined;
for (const key of options?.implicitDefaultAccount?.channelKeys ?? []) {
if (hasConfiguredAccountValue(channel?.[key])) {
return true;
}
}
for (const key of options?.implicitDefaultAccount?.envVars ?? []) {
if (hasConfiguredAccountValue(process.env[key])) {
return true;
}
}
return false;
return Boolean(
options?.hasImplicitDefaultAccount?.(cfg) ||
options?.implicitDefaultAccount?.channelKeys?.some((key) =>
hasConfiguredAccountValue(channel?.[key]),
) ||
options?.implicitDefaultAccount?.envVars?.some((key) =>
hasConfiguredAccountValue(process.env[key]),
),
);
}
function resolveConfiguredDefaultAccountId(cfg: OpenClawConfig): string | undefined {
const channel = cfg.channels?.[channelKey] as Record<string, unknown> | undefined;
const preferred = normalizeOptionalAccountId(
// The canonical default resolver validates this preference against the same listed ids.
return normalizeOptionalAccountId(
typeof channel?.defaultAccount === "string" ? channel.defaultAccount : undefined,
);
if (!preferred) {
return undefined;
}
const ids = listAccountIds(cfg);
if (options?.allowUnlistedDefaultAccount) {
return preferred;
}
// Reject stale defaultAccount values unless the channel explicitly supports external ids.
if (ids.some((id) => normalizeAccountId(id) === preferred)) {
return preferred;
}
return undefined;
}
function listConfiguredAccountIds(cfg: OpenClawConfig): string[] {
@@ -159,20 +143,17 @@ export function listCombinedAccountIds(params: {
fallbackAccountIdWhenEmpty?: string | undefined;
}): string[] {
const ids = new Set<string>();
for (const id of params.configuredAccountIds) {
if (id) {
ids.add(id);
for (const accountIds of [
params.configuredAccountIds,
params.additionalAccountIds ?? [],
params.implicitAccountId ? [params.implicitAccountId] : [],
]) {
for (const accountId of accountIds) {
if (accountId) {
ids.add(accountId);
}
}
}
for (const id of params.additionalAccountIds ?? []) {
if (id) {
ids.add(id);
}
}
if (params.implicitAccountId) {
ids.add(params.implicitAccountId);
}
if (ids.size === 0 && params.fallbackAccountIdWhenEmpty) {
return [params.fallbackAccountIdWhenEmpty];
@@ -71,6 +71,38 @@ function mockAlphaDistExtensionRuntime() {
}));
}
function writeAlphaSdkAliasDistFixture(pluginDir: string, label: string) {
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(
path.join(pluginDir, "index.js"),
[
'import { defineBundledChannelEntry } from "openclaw/plugin-sdk/channel-entry-contract";',
"export default defineBundledChannelEntry({",
" id: 'alpha',",
" name: 'Alpha',",
" description: 'Alpha',",
" importMetaUrl: import.meta.url,",
" plugin: { specifier: './plugin.js', exportName: 'plugin' },",
"});",
"",
].join("\n"),
"utf8",
);
fs.writeFileSync(
path.join(pluginDir, "plugin.js"),
[
"export const plugin = {",
" id: 'alpha',",
` meta: { id: 'alpha', label: '${label}' },`,
" capabilities: {},",
" config: {},",
"};",
"",
].join("\n"),
"utf8",
);
}
function collectBundledChannelEntrypointOffenders(
bundledPluginRoots: string[],
isOffender: (source: string, filePath: string) => boolean,
@@ -403,36 +435,8 @@ describe("bundled channel entry shape guards", () => {
it("falls back through the cached loader for package-local dist entries needing SDK aliases", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-package-dist-"));
const pluginDir = path.join(root, "extensions", "alpha", "dist");
fs.mkdirSync(pluginDir, { recursive: true });
writeAlphaSdkAliasDistFixture(pluginDir, "Package dist Alpha");
fs.writeFileSync(path.join(root, "package.json"), '{"type":"module"}\n', "utf8");
fs.writeFileSync(
path.join(pluginDir, "index.js"),
[
'import { defineBundledChannelEntry } from "openclaw/plugin-sdk/channel-entry-contract";',
"export default defineBundledChannelEntry({",
" id: 'alpha',",
" name: 'Alpha',",
" description: 'Alpha',",
" importMetaUrl: import.meta.url,",
" plugin: { specifier: './plugin.js', exportName: 'plugin' },",
"});",
"",
].join("\n"),
"utf8",
);
fs.writeFileSync(
path.join(pluginDir, "plugin.js"),
[
"export const plugin = {",
" id: 'alpha',",
" meta: { id: 'alpha', label: 'Package dist Alpha' },",
" capabilities: {},",
" config: {},",
"};",
"",
].join("\n"),
"utf8",
);
vi.doMock("./bundled-root.js", () => ({
resolveBundledChannelRootScope: () => ({
@@ -470,36 +474,8 @@ describe("bundled channel entry shape guards", () => {
const previousBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
const pluginsRoot = path.join(root, "bundled-plugins");
const pluginDir = path.join(pluginsRoot, "alpha", "dist");
fs.mkdirSync(pluginDir, { recursive: true });
writeAlphaSdkAliasDistFixture(pluginDir, "Direct dist Alpha");
fs.writeFileSync(path.join(pluginsRoot, "package.json"), '{"type":"module"}\n', "utf8");
fs.writeFileSync(
path.join(pluginDir, "index.js"),
[
'import { defineBundledChannelEntry } from "openclaw/plugin-sdk/channel-entry-contract";',
"export default defineBundledChannelEntry({",
" id: 'alpha',",
" name: 'Alpha',",
" description: 'Alpha',",
" importMetaUrl: import.meta.url,",
" plugin: { specifier: './plugin.js', exportName: 'plugin' },",
"});",
"",
].join("\n"),
"utf8",
);
fs.writeFileSync(
path.join(pluginDir, "plugin.js"),
[
"export const plugin = {",
" id: 'alpha',",
" meta: { id: 'alpha', label: 'Direct dist Alpha' },",
" capabilities: {},",
" config: {},",
"};",
"",
].join("\n"),
"utf8",
);
vi.doMock("../../plugins/bundled-channel-runtime.js", () => ({
listBundledChannelPluginMetadata: () => [alphaChannelMetadata()],
+14 -40
View File
@@ -201,28 +201,6 @@ function loadCatalogEntriesFromPaths(
return entries;
}
function loadOfficialCatalogEntriesFromPaths(paths: Iterable<string>): ExternalCatalogEntry[] {
const entries: ExternalCatalogEntry[] = [];
for (const resolvedPath of paths) {
const cached = officialCatalogEntriesByPath.get(resolvedPath);
if (cached !== undefined) {
if (cached) {
entries.push(...cached);
}
continue;
}
const payload = tryReadJsonSync(resolvedPath);
if (payload === null) {
officialCatalogEntriesByPath.set(resolvedPath, null);
continue;
}
const parsed = parseCatalogEntries(payload);
officialCatalogEntriesByPath.set(resolvedPath, parsed);
entries.push(...parsed);
}
return entries;
}
function resolveOfficialCatalogPaths(options: CatalogOptions): string[] {
if (options.officialCatalogPaths && options.officialCatalogPaths.length > 0) {
return normalizeStringEntries(options.officialCatalogPaths);
@@ -251,10 +229,12 @@ function resolveOfficialCatalogPaths(options: CatalogOptions): string[] {
function loadOfficialCatalogEntries(options: CatalogOptions): ChannelPluginCatalogEntry[] {
const builtInEntries = listOfficialExternalChannelCatalogEntries();
const officialPaths = resolveOfficialCatalogPaths(options);
const fileEntries =
const fileEntries = loadCatalogEntriesFromPaths(
officialPaths,
options.officialCatalogPaths && options.officialCatalogPaths.length > 0
? loadCatalogEntriesFromPaths(officialPaths)
: loadOfficialCatalogEntriesFromPaths(officialPaths);
? undefined
: officialCatalogEntriesByPath,
);
return [...builtInEntries, ...fileEntries]
.map((entry) => buildExternalCatalogEntry(entry, { trustedSourceLinkedOfficialInstall: true }))
.filter((entry): entry is ChannelPluginCatalogEntry => Boolean(entry));
@@ -473,6 +453,12 @@ export function listRawChannelPluginCatalogEntries(
discovery: options.discovery,
});
const resolved = new Map<string, { entry: ChannelPluginCatalogEntry; priority: number }>();
const rememberCatalogEntry = (entry: ChannelPluginCatalogEntry, priority: number) => {
const existing = resolved.get(entry.id);
if (!existing || priority < existing.priority) {
resolved.set(entry.id, { entry, priority });
}
};
for (const candidate of manifestEntries) {
if (
@@ -493,19 +479,11 @@ export function listRawChannelPluginCatalogEntries(
if (!entry) {
continue;
}
const priority = ORIGIN_PRIORITY[candidate.origin] ?? 99;
const existing = resolved.get(entry.id);
if (!existing || priority < existing.priority) {
resolved.set(entry.id, { entry, priority });
}
rememberCatalogEntry(entry, ORIGIN_PRIORITY[candidate.origin] ?? 99);
}
for (const entry of loadOfficialCatalogEntries(options)) {
const priority = FALLBACK_CATALOG_PRIORITY;
const existing = resolved.get(entry.id);
if (!existing || priority < existing.priority) {
resolved.set(entry.id, { entry, priority });
}
rememberCatalogEntry(entry, FALLBACK_CATALOG_PRIORITY);
}
const externalEntries = loadExternalCatalogEntries(options)
@@ -514,11 +492,7 @@ export function listRawChannelPluginCatalogEntries(
for (const entry of externalEntries) {
// External catalogs are the supported override seam for shipped fallback
// metadata, but discovered plugins should still win when they are present.
const priority = EXTERNAL_CATALOG_PRIORITY;
const existing = resolved.get(entry.id);
if (!existing || priority < existing.priority) {
resolved.set(entry.id, { entry, priority });
}
rememberCatalogEntry(entry, EXTERNAL_CATALOG_PRIORITY);
}
return Array.from(resolved.values())
@@ -53,20 +53,6 @@ export function toDirectoryEntries(kind: "user" | "group", ids: string[]): Chann
return entries;
}
function collectDirectoryIdsFromEntries(params: {
entries?: readonly unknown[];
normalizeId?: (entry: string) => string | null | undefined;
}): string[] {
return collectDirectoryIds(params.entries ?? [], params.normalizeId);
}
function collectDirectoryIdsFromMapKeys(params: {
groups?: Record<string, unknown>;
normalizeId?: (entry: string) => string | null | undefined;
}): string[] {
return collectDirectoryIds(Object.keys(params.groups ?? {}), params.normalizeId);
}
function collectDirectoryIds(
values: Iterable<unknown>,
normalizeId?: (entry: string) => string | null | undefined,
@@ -225,12 +211,7 @@ export function listDirectoryUserEntriesFromAllowFrom(params: {
limit?: number | null;
normalizeId?: (entry: string) => string | null | undefined;
}): ChannelDirectoryEntry[] {
const ids = uniqueStrings(
collectDirectoryIdsFromEntries({
entries: params.allowFrom,
normalizeId: params.normalizeId,
}),
);
const ids = uniqueStrings(collectDirectoryIds(params.allowFrom ?? [], params.normalizeId));
return toDirectoryEntries("user", applyDirectoryQueryAndLimit(ids, params));
}
@@ -246,14 +227,8 @@ export function listDirectoryUserEntriesFromAllowFromAndMapKeys(params: {
normalizeMapKeyId?: (entry: string) => string | null | undefined;
}): ChannelDirectoryEntry[] {
const ids = uniqueStrings([
...collectDirectoryIdsFromEntries({
entries: params.allowFrom,
normalizeId: params.normalizeAllowFromId,
}),
...collectDirectoryIdsFromMapKeys({
groups: params.map,
normalizeId: params.normalizeMapKeyId,
}),
...collectDirectoryIds(params.allowFrom ?? [], params.normalizeAllowFromId),
...collectDirectoryIds(Object.keys(params.map ?? {}), params.normalizeMapKeyId),
]);
return toDirectoryEntries("user", applyDirectoryQueryAndLimit(ids, params));
}
@@ -268,10 +243,7 @@ export function listDirectoryGroupEntriesFromMapKeys(params: {
normalizeId?: (entry: string) => string | null | undefined;
}): ChannelDirectoryEntry[] {
const ids = uniqueStrings(
collectDirectoryIdsFromMapKeys({
groups: params.groups,
normalizeId: params.normalizeId,
}),
collectDirectoryIds(Object.keys(params.groups ?? {}), params.normalizeId),
);
return toDirectoryEntries("group", applyDirectoryQueryAndLimit(ids, params));
}
@@ -288,14 +260,8 @@ export function listDirectoryGroupEntriesFromMapKeysAndAllowFrom(params: {
normalizeAllowFromId?: (entry: string) => string | null | undefined;
}): ChannelDirectoryEntry[] {
const ids = uniqueStrings([
...collectDirectoryIdsFromMapKeys({
groups: params.groups,
normalizeId: params.normalizeMapKeyId,
}),
...collectDirectoryIdsFromEntries({
entries: params.allowFrom,
normalizeId: params.normalizeAllowFromId,
}),
...collectDirectoryIds(Object.keys(params.groups ?? {}), params.normalizeMapKeyId),
...collectDirectoryIds(params.allowFrom ?? [], params.normalizeAllowFromId),
]);
return toDirectoryEntries("group", applyDirectoryQueryAndLimit(ids, params));
}
@@ -318,51 +318,6 @@ export function listCrossChannelSchemaSupportedMessageActions(
return resolved.actions.filter((action) => !schemaBlockedActions.has(action));
}
/**
* Lists message capabilities advertised across registered channel plugins.
*/
function listChannelMessageCapabilities(params: {
cfg: OpenClawConfig;
preparedMessageToolCatalog?: PreparedMessageToolCatalog;
}): ChannelMessageCapability[] {
const capabilities = new Set<ChannelMessageCapability>();
const channels = listMessageActionDiscoveryChannels(params.preparedMessageToolCatalog);
for (const plugin of channels) {
for (const capability of resolveMessageActionDiscoveryForPlugin({
pluginId: plugin.id,
actions: plugin.actions,
context: { cfg: params.cfg },
includeCapabilities: true,
}).capabilities) {
capabilities.add(capability);
}
}
return Array.from(capabilities);
}
/**
* Lists message capabilities advertised by the current channel.
*/
function listChannelMessageCapabilitiesForChannel(
params: ChannelMessageActionDiscoveryParams,
): ChannelMessageCapability[] {
const pluginActions = resolveCurrentChannelMessageToolDiscoveryAdapter(
params.channel,
params.preparedMessageToolCatalog,
);
if (!pluginActions) {
return [];
}
return Array.from(
resolveMessageActionDiscoveryForPlugin({
pluginId: pluginActions.pluginId,
actions: pluginActions.actions,
context: createMessageActionDiscoveryContext(params),
includeCapabilities: true,
}).capabilities,
);
}
/**
* Merges schema properties while preserving the first plugin to define a key.
*/
@@ -474,10 +429,18 @@ export function channelSupportsMessageCapability(
capability: ChannelMessageCapability,
preparedMessageToolCatalog?: PreparedMessageToolCatalog,
): boolean {
return listChannelMessageCapabilities({
cfg,
preparedMessageToolCatalog,
}).includes(capability);
const discoveredCapabilities = listMessageActionDiscoveryChannels(preparedMessageToolCatalog).map(
(plugin) =>
resolveMessageActionDiscoveryForPlugin({
pluginId: plugin.id,
actions: plugin.actions,
context: { cfg },
includeCapabilities: true,
}).capabilities,
);
return discoveredCapabilities.some((pluginCapabilities) =>
pluginCapabilities.includes(capability),
);
}
/**
@@ -487,5 +450,17 @@ export function channelSupportsMessageCapabilityForChannel(
params: ChannelMessageActionDiscoveryParams,
capability: ChannelMessageCapability,
): boolean {
return listChannelMessageCapabilitiesForChannel(params).includes(capability);
const pluginActions = resolveCurrentChannelMessageToolDiscoveryAdapter(
params.channel,
params.preparedMessageToolCatalog,
);
if (!pluginActions) {
return false;
}
return resolveMessageActionDiscoveryForPlugin({
pluginId: pluginActions.pluginId,
actions: pluginActions.actions,
context: createMessageActionDiscoveryContext(params),
includeCapabilities: true,
}).capabilities.includes(capability);
}
+127 -244
View File
@@ -71,6 +71,23 @@ function activateMessageActionTestRegistry() {
);
}
function activateDiscoveredMessageActionPlugin(params: {
id: ChannelPlugin["id"];
label: string;
describeMessageTool: NonNullable<ChannelPlugin["actions"]>["describeMessageTool"];
}) {
const plugin: ChannelPlugin = {
...createChannelTestPluginBase({
id: params.id,
label: params.label,
capabilities: { chatTypes: ["direct", "group"] },
config: { listAccountIds: () => ["default"] },
}),
actions: { describeMessageTool: params.describeMessageTool },
};
setActivePluginRegistry(createTestRegistry([{ pluginId: params.id, source: "test", plugin }]));
}
describe("message action capability checks", () => {
const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => undefined);
@@ -116,73 +133,39 @@ describe("message action capability checks", () => {
setActivePluginRegistry(createTestRegistry([{ pluginId: plugin.id, source: "test", plugin }]));
const preparedMessageToolCatalog = getPreparedMessageToolCatalog();
const cfg = {} as OpenClawConfig;
const supportsAccountCapability = (accountId: string, capability: ChannelMessageCapability) =>
channelSupportsMessageCapabilityForChannel(
{ cfg, channel: plugin.id, accountId, preparedMessageToolCatalog },
capability,
);
expect(
channelSupportsMessageCapabilityForChannel(
{ cfg, channel: plugin.id, accountId: "first", preparedMessageToolCatalog },
"presentation",
),
).toBe(true);
expect(
channelSupportsMessageCapabilityForChannel(
{ cfg, channel: plugin.id, accountId: "second", preparedMessageToolCatalog },
"presentation",
),
).toBe(false);
expect(
channelSupportsMessageCapabilityForChannel(
{ cfg, channel: plugin.id, accountId: "second", preparedMessageToolCatalog },
"delivery-pin",
),
).toBe(true);
expect(supportsAccountCapability("first", "presentation")).toBe(true);
expect(supportsAccountCapability("second", "presentation")).toBe(false);
expect(supportsAccountCapability("second", "delivery-pin")).toBe(true);
});
it("checks per-channel capabilities", () => {
activateMessageActionTestRegistry();
const cfg = {} as OpenClawConfig;
const supportsCapability = (
channel: string | undefined,
capability: ChannelMessageCapability,
) => channelSupportsMessageCapabilityForChannel({ cfg, channel }, capability);
expect(
channelSupportsMessageCapabilityForChannel(
{ cfg: {} as OpenClawConfig, channel: "demo-buttons" },
"presentation",
),
).toBe(true);
expect(
channelSupportsMessageCapabilityForChannel(
{ cfg: {} as OpenClawConfig, channel: "demo-cards" },
"presentation",
),
).toBe(false);
expect(
channelSupportsMessageCapabilityForChannel(
{ cfg: {} as OpenClawConfig, channel: "demo-buttons" },
"delivery-pin",
),
).toBe(false);
expect(
channelSupportsMessageCapabilityForChannel(
{ cfg: {} as OpenClawConfig, channel: "demo-cards" },
"delivery-pin",
),
).toBe(true);
expect(
channelSupportsMessageCapabilityForChannel({ cfg: {} as OpenClawConfig }, "delivery-pin"),
).toBe(false);
expect(supportsCapability("demo-buttons", "presentation")).toBe(true);
expect(supportsCapability("demo-cards", "presentation")).toBe(false);
expect(supportsCapability("demo-buttons", "delivery-pin")).toBe(false);
expect(supportsCapability("demo-cards", "delivery-pin")).toBe(true);
expect(supportsCapability(undefined, "delivery-pin")).toBe(false);
});
it("normalizes channel aliases for per-channel capability checks", () => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "demo-cards",
source: "test",
plugin: createMessageActionsPlugin({
id: "demo-cards",
aliases: ["demo-cards-alias"],
capabilities: ["delivery-pin"],
}),
},
]),
);
const plugin = createMessageActionsPlugin({
id: "demo-cards",
aliases: ["demo-cards-alias"],
capabilities: ["delivery-pin"],
});
setActivePluginRegistry(createTestRegistry([{ pluginId: plugin.id, source: "test", plugin }]));
expect(
channelSupportsMessageCapabilityForChannel(
@@ -193,30 +176,19 @@ describe("message action capability checks", () => {
});
it("uses unified message tool discovery for actions, capabilities, and schema", () => {
const unifiedPlugin: ChannelPlugin = {
...createChannelTestPluginBase({
id: "demo-unified",
label: "Demo Unified",
capabilities: { chatTypes: ["direct", "group"] },
config: {
listAccountIds: () => ["default"],
activateDiscoveredMessageActionPlugin({
id: "demo-unified",
label: "Demo Unified",
describeMessageTool: () => ({
actions: ["react"],
capabilities: ["presentation"],
schema: {
properties: {
components: Type.Array(Type.String()),
},
},
}),
actions: {
describeMessageTool: () => ({
actions: ["react"],
capabilities: ["presentation"],
schema: {
properties: {
components: Type.Array(Type.String()),
},
},
}),
},
};
setActivePluginRegistry(
createTestRegistry([{ pluginId: "demo-unified", source: "test", plugin: unifiedPlugin }]),
);
});
expect(channelSupportsMessageCapability({} as OpenClawConfig, "presentation")).toBe(true);
expect(
@@ -228,36 +200,23 @@ describe("message action capability checks", () => {
});
it("keeps contributed schema properties optional so only action stays required", () => {
const contributingPlugin: ChannelPlugin = {
...createChannelTestPluginBase({
id: "demo-contrib",
label: "Demo Contrib",
capabilities: { chatTypes: ["direct", "group"] },
config: {
listAccountIds: () => ["default"],
activateDiscoveredMessageActionPlugin({
id: "demo-contrib",
label: "Demo Contrib",
describeMessageTool: () => ({
actions: ["send"],
schema: {
properties: {
// Non-optional TypeBox schema: plugin forgot Type.Optional.
components: Type.Array(Type.String()),
// Cloning strips typebox's non-enumerable `~optional` marker;
// mirrors serialized/external plugin contributions.
chatRef: structuredClone(Type.Optional(Type.String())),
media: Type.Optional(Type.String()),
},
},
}),
actions: {
describeMessageTool: () => ({
actions: ["send"],
schema: {
properties: {
// Non-optional TypeBox schema: plugin forgot Type.Optional.
components: Type.Array(Type.String()),
// Cloning strips typebox's non-enumerable `~optional` marker;
// mirrors serialized/external plugin contributions.
chatRef: structuredClone(Type.Optional(Type.String())),
media: Type.Optional(Type.String()),
},
},
}),
},
};
setActivePluginRegistry(
createTestRegistry([
{ pluginId: "demo-contrib", source: "test", plugin: contributingPlugin },
]),
);
});
const properties = resolveChannelMessageToolSchemaProperties({
cfg: {} as OpenClawConfig,
@@ -270,32 +229,19 @@ describe("message action capability checks", () => {
});
it("filters only actions that depend on current-channel-only schema", () => {
const scopedSchemaPlugin: ChannelPlugin = {
...createChannelTestPluginBase({
id: "demo-scoped-schema",
label: "Demo Scoped Schema",
capabilities: { chatTypes: ["direct", "group"] },
config: {
listAccountIds: () => ["default"],
activateDiscoveredMessageActionPlugin({
id: "demo-scoped-schema",
label: "Demo Scoped Schema",
describeMessageTool: () => ({
actions: ["read", "list-pins", "unpin"],
schema: {
actions: ["unpin"],
properties: {
pinnedMessageId: Type.Optional(Type.String()),
},
},
}),
actions: {
describeMessageTool: () => ({
actions: ["read", "list-pins", "unpin"],
schema: {
actions: ["unpin"],
properties: {
pinnedMessageId: Type.Optional(Type.String()),
},
},
}),
},
};
setActivePluginRegistry(
createTestRegistry([
{ pluginId: "demo-scoped-schema", source: "test", plugin: scopedSchemaPlugin },
]),
);
});
expect(
listCrossChannelSchemaSupportedMessageActions({
@@ -306,31 +252,18 @@ describe("message action capability checks", () => {
});
it("keeps unscoped current-channel schema conservative for cross-channel actions", () => {
const unscopedSchemaPlugin: ChannelPlugin = {
...createChannelTestPluginBase({
id: "demo-unscoped-schema",
label: "Demo Unscoped Schema",
capabilities: { chatTypes: ["direct", "group"] },
config: {
listAccountIds: () => ["default"],
activateDiscoveredMessageActionPlugin({
id: "demo-unscoped-schema",
label: "Demo Unscoped Schema",
describeMessageTool: () => ({
actions: ["read", "unpin"],
schema: {
properties: {
pinnedMessageId: Type.Optional(Type.String()),
},
},
}),
actions: {
describeMessageTool: () => ({
actions: ["read", "unpin"],
schema: {
properties: {
pinnedMessageId: Type.Optional(Type.String()),
},
},
}),
},
};
setActivePluginRegistry(
createTestRegistry([
{ pluginId: "demo-unscoped-schema", source: "test", plugin: unscopedSchemaPlugin },
]),
);
});
expect(
listCrossChannelSchemaSupportedMessageActions({
@@ -341,36 +274,19 @@ describe("message action capability checks", () => {
});
it("treats empty current-channel schema action lists as blocking no cross-channel actions", () => {
const emptyScopedSchemaPlugin: ChannelPlugin = {
...createChannelTestPluginBase({
id: "demo-empty-scoped-schema",
label: "Demo Empty Scoped Schema",
capabilities: { chatTypes: ["direct", "group"] },
config: {
listAccountIds: () => ["default"],
activateDiscoveredMessageActionPlugin({
id: "demo-empty-scoped-schema",
label: "Demo Empty Scoped Schema",
describeMessageTool: () => ({
actions: ["read", "list-pins"],
schema: {
actions: [],
properties: {
optionalChannelOnlyValue: Type.Optional(Type.String()),
},
},
}),
actions: {
describeMessageTool: () => ({
actions: ["read", "list-pins"],
schema: {
actions: [],
properties: {
optionalChannelOnlyValue: Type.Optional(Type.String()),
},
},
}),
},
};
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "demo-empty-scoped-schema",
source: "test",
plugin: emptyScopedSchemaPlugin,
},
]),
);
});
expect(
listCrossChannelSchemaSupportedMessageActions({
@@ -381,34 +297,23 @@ describe("message action capability checks", () => {
});
it("derives plugin-owned media-source params for the current action", () => {
const mediaPlugin: ChannelPlugin = {
...createChannelTestPluginBase({
id: "demo-media",
label: "Demo Media",
capabilities: { chatTypes: ["direct", "group"] },
config: {
listAccountIds: () => ["default"],
activateDiscoveredMessageActionPlugin({
id: "demo-media",
label: "Demo Media",
describeMessageTool: () => ({
actions: ["send", "set-profile"],
mediaSourceParams: {
"set-profile": ["avatarUrl", "avatarPath"],
},
schema: {
properties: {
avatarUrl: Type.Optional(Type.String({ description: "Remote avatar URL" })),
avatarPath: Type.Optional(Type.String({ description: "Local avatar path" })),
displayName: Type.Optional(Type.String()),
},
},
}),
actions: {
describeMessageTool: () => ({
actions: ["send", "set-profile"],
mediaSourceParams: {
"set-profile": ["avatarUrl", "avatarPath"],
},
schema: {
properties: {
avatarUrl: Type.Optional(Type.String({ description: "Remote avatar URL" })),
avatarPath: Type.Optional(Type.String({ description: "Local avatar path" })),
displayName: Type.Optional(Type.String()),
},
},
}),
},
};
setActivePluginRegistry(
createTestRegistry([{ pluginId: "demo-media", source: "test", plugin: mediaPlugin }]),
);
});
expect(
resolveChannelMessageToolMediaSourceParamKeys({
@@ -427,25 +332,14 @@ describe("message action capability checks", () => {
});
it("keeps flat media-source param discovery for backward compatibility", () => {
const mediaPlugin: ChannelPlugin = {
...createChannelTestPluginBase({
id: "demo-media-flat",
label: "Demo Media Flat",
capabilities: { chatTypes: ["direct", "group"] },
config: {
listAccountIds: () => ["default"],
},
activateDiscoveredMessageActionPlugin({
id: "demo-media-flat",
label: "Demo Media Flat",
describeMessageTool: () => ({
actions: ["set-profile"],
mediaSourceParams: ["avatarUrl", "avatarPath"],
}),
actions: {
describeMessageTool: () => ({
actions: ["set-profile"],
mediaSourceParams: ["avatarUrl", "avatarPath"],
}),
},
};
setActivePluginRegistry(
createTestRegistry([{ pluginId: "demo-media-flat", source: "test", plugin: mediaPlugin }]),
);
});
expect(
resolveChannelMessageToolMediaSourceParamKeys({
@@ -457,24 +351,13 @@ describe("message action capability checks", () => {
});
it("skips crashing action/capability discovery paths and logs once", () => {
const crashingPlugin: ChannelPlugin = {
...createChannelTestPluginBase({
id: "demo-crashing",
label: "Demo Crashing",
capabilities: { chatTypes: ["direct", "group"] },
config: {
listAccountIds: () => ["default"],
},
}),
actions: {
describeMessageTool: () => {
throw new Error("boom");
},
activateDiscoveredMessageActionPlugin({
id: "demo-crashing",
label: "Demo Crashing",
describeMessageTool: () => {
throw new Error("boom");
},
};
setActivePluginRegistry(
createTestRegistry([{ pluginId: "demo-crashing", source: "test", plugin: crashingPlugin }]),
);
});
expect(channelSupportsMessageCapability({} as OpenClawConfig, "presentation")).toBe(false);
expect(errorSpy).toHaveBeenCalledTimes(1);
+83 -199
View File
@@ -34,6 +34,26 @@ function pluginIds(plugins: ReturnType<typeof listReadOnlyChannelPluginsForConfi
return plugins.map((entry) => entry.id);
}
function createExternalChannelTestConfig(params: {
pluginDir: string;
pluginId?: string;
channels?: Record<string, Record<string, unknown>> | null;
}): Parameters<typeof listReadOnlyChannelPluginsForConfig>[0] {
return {
...(params.channels === null
? {}
: {
channels: params.channels ?? {
"external-chat": { token: "configured" },
},
}),
plugins: {
load: { paths: [params.pluginDir] },
allow: [params.pluginId ?? "external-chat"],
},
};
}
function modulePathEndsWith(modulePath: string, suffix: string): boolean {
const normalized = modulePath.startsWith("file:") ? fileURLToPath(modulePath) : modulePath;
return normalized.replace(/\\/g, "/").endsWith(suffix);
@@ -507,15 +527,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
it("uses package channel metadata without loading setup or full runtime", () => {
const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin();
const plugins = listReadOnlyChannelPluginsForConfig(
{
channels: {
"external-chat": { token: "configured" },
},
plugins: {
load: { paths: [pluginDir] },
allow: ["external-chat"],
},
} as never,
createExternalChannelTestConfig({ pluginDir }),
{
env: { ...process.env },
includePersistedAuthState: false,
@@ -530,15 +542,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
it("loads configured external channel setup metadata without importing full runtime", () => {
const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin();
const plugins = listReadOnlyChannelPluginsForConfig(
{
channels: {
"external-chat": { token: "configured" },
},
plugins: {
load: { paths: [pluginDir] },
allow: ["external-chat"],
},
} as never,
createExternalChannelTestConfig({ pluginDir }),
{
env: { ...process.env },
includePersistedAuthState: false,
@@ -560,25 +564,12 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
it("uses activation source config to discover channel setup metadata after secret stripping", () => {
const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin();
const plugins = listReadOnlyChannelPluginsForConfig(
createExternalChannelTestConfig({
pluginDir,
channels: { "external-chat": {} },
}),
{
channels: {
"external-chat": {},
},
plugins: {
load: { paths: [pluginDir] },
allow: ["external-chat"],
},
} as never,
{
activationSourceConfig: {
channels: {
"external-chat": { token: "configured" },
},
plugins: {
load: { paths: [pluginDir] },
allow: ["external-chat"],
},
} as never,
activationSourceConfig: createExternalChannelTestConfig({ pluginDir }),
env: { ...process.env },
includePersistedAuthState: false,
includeSetupFallbackPlugins: true,
@@ -590,15 +581,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
it("reuses default read-only channel plugin resolution for the same config", () => {
const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin();
const cfg = {
channels: {
"external-chat": { token: "configured" },
},
plugins: {
load: { paths: [pluginDir] },
allow: ["external-chat"],
},
} as never;
const cfg = createExternalChannelTestConfig({ pluginDir });
const first = listReadOnlyChannelPluginsForConfig(cfg, {
includePersistedAuthState: false,
@@ -688,12 +671,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin({
manifestChannelConfig: true,
});
const cfg = {
plugins: {
load: { paths: [pluginDir] },
allow: ["external-chat"],
},
} as never;
const cfg = createExternalChannelTestConfig({ pluginDir, channels: null });
const first = listReadOnlyChannelPluginsForConfig(cfg, {
includePersistedAuthState: false,
@@ -712,15 +690,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
it("clears cached read-only channel plugin resolution with plugin metadata lifecycle caches", () => {
const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin();
const cfg = {
channels: {
"external-chat": { token: "configured" },
},
plugins: {
load: { paths: [pluginDir] },
allow: ["external-chat"],
},
} as never;
const cfg = createExternalChannelTestConfig({ pluginDir });
const first = listReadOnlyChannelPluginsForConfig(cfg, {
includePersistedAuthState: false,
@@ -750,15 +720,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
setupChannelId: "external-chat-plugin",
});
const plugins = listReadOnlyChannelPluginsForConfig(
{
channels: {
"external-chat": { token: "configured" },
},
plugins: {
load: { paths: [pluginDir] },
allow: ["external-chat-plugin"],
},
} as never,
createExternalChannelTestConfig({ pluginDir, pluginId: "external-chat-plugin" }),
{
env: { ...process.env },
includePersistedAuthState: false,
@@ -781,16 +743,14 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
setupChannelId: "alpha-chat",
});
const plugins = listReadOnlyChannelPluginsForConfig(
{
createExternalChannelTestConfig({
pluginDir,
pluginId: "external-chat-plugin",
channels: {
"alpha-chat": { token: "configured" },
"beta-chat": { token: "configured" },
},
plugins: {
load: { paths: [pluginDir] },
allow: ["external-chat-plugin"],
},
} as never,
}),
{
env: { ...process.env },
includePersistedAuthState: false,
@@ -829,15 +789,11 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
setupChannelId: "alpha-chat",
});
const plugins = listReadOnlyChannelPluginsForConfig(
{
channels: {
"beta-chat": { token: "beta-token" },
},
plugins: {
load: { paths: [pluginDir] },
allow: ["external-chat-plugin"],
},
} as never,
createExternalChannelTestConfig({
pluginDir,
pluginId: "external-chat-plugin",
channels: { "beta-chat": { token: "beta-token" } },
}),
{
env: { ...process.env },
includePersistedAuthState: false,
@@ -869,15 +825,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
setupEntry: false,
});
const plugins = listReadOnlyChannelPluginsForConfig(
{
channels: {
"external-chat": { token: "configured" },
},
plugins: {
load: { paths: [pluginDir] },
allow: ["external-chat"],
},
} as never,
createExternalChannelTestConfig({ pluginDir }),
{
env: { ...process.env },
includePersistedAuthState: false,
@@ -907,7 +855,9 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
channelId: "external-chat",
manifestChannelConfig: true,
});
const cfg = {
const cfg = createExternalChannelTestConfig({
pluginDir,
pluginId: "external-chat-plugin",
channels: {
"external-chat": {
defaultAccount: "Ops Team",
@@ -917,11 +867,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
},
},
},
plugins: {
load: { paths: [pluginDir] },
allow: ["external-chat-plugin"],
},
} as never;
});
const plugins = listReadOnlyChannelPluginsForConfig(cfg, {
env: { ...process.env },
includePersistedAuthState: false,
@@ -943,15 +889,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
setupRequiresRuntime: false,
});
const plugins = listReadOnlyChannelPluginsForConfig(
{
channels: {
"external-chat": { token: "configured" },
},
plugins: {
load: { paths: [pluginDir] },
allow: ["external-chat-plugin"],
},
} as never,
createExternalChannelTestConfig({ pluginDir, pluginId: "external-chat-plugin" }),
{
env: { ...process.env },
includePersistedAuthState: false,
@@ -994,15 +932,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
manifestChannelDescription: "manifest\u001b[2K config",
});
const plugins = listReadOnlyChannelPluginsForConfig(
{
channels: {
"external-chat": { token: "configured" },
},
plugins: {
load: { paths: [pluginDir] },
allow: ["external-chat-plugin"],
},
} as never,
createExternalChannelTestConfig({ pluginDir, pluginId: "external-chat-plugin" }),
{
env: { ...process.env },
includePersistedAuthState: false,
@@ -1025,13 +955,11 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
manifestChannelConfig: true,
});
const plugins = listReadOnlyChannelPluginsForConfig(
{
createExternalChannelTestConfig({
pluginDir,
pluginId: "external-chat-plugin",
channels: Object.fromEntries([[unsafeChannelId, { token: "configured" }]]),
plugins: {
load: { paths: [pluginDir] },
allow: ["external-chat-plugin"],
},
} as never,
}),
{
env: { ...process.env },
includePersistedAuthState: false,
@@ -1055,17 +983,11 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
}) as Record<string, unknown>;
inheritedAccounts.default = { token: "default-token" };
inheritedAccounts.named = { token: "named-token" };
const cfg = {
channels: {
"external-chat": {
accounts: inheritedAccounts,
},
},
plugins: {
load: { paths: [pluginDir] },
allow: ["external-chat-plugin"],
},
} as never;
const cfg = createExternalChannelTestConfig({
pluginDir,
pluginId: "external-chat-plugin",
channels: { "external-chat": { accounts: inheritedAccounts } },
});
const plugin = listReadOnlyChannelPluginsForConfig(cfg, {
env: { ...process.env },
includePersistedAuthState: false,
@@ -1090,21 +1012,15 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
channelId: "external-chat",
manifestChannelConfig: true,
});
const cfg = {
const cfg = createExternalChannelTestConfig({
pluginDir,
pluginId: "external-chat-plugin",
channels: {
"external-chat": {
accounts: {
"constructor ": {
token: "blocked-token",
},
},
accounts: { "constructor ": { token: "blocked-token" } },
},
},
plugins: {
load: { paths: [pluginDir] },
allow: ["external-chat-plugin"],
},
} as never;
});
const plugin = listReadOnlyChannelPluginsForConfig(cfg, {
env: { ...process.env },
includePersistedAuthState: false,
@@ -1126,7 +1042,9 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
channelId: "external-chat",
manifestChannelConfig: true,
});
const cfg = {
const cfg = createExternalChannelTestConfig({
pluginDir,
pluginId: "external-chat-plugin",
channels: {
"external-chat": {
accounts: {
@@ -1137,11 +1055,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
},
},
},
plugins: {
load: { paths: [pluginDir] },
allow: ["external-chat-plugin"],
},
} as never;
});
const plugin = listReadOnlyChannelPluginsForConfig(cfg, {
env: { ...process.env },
includePersistedAuthState: false,
@@ -1165,15 +1079,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
manifestChannelConfig: true,
});
const plugins = listReadOnlyChannelPluginsForConfig(
{
channels: {
"external-chat": { token: "configured" },
},
plugins: {
load: { paths: [pluginDir] },
allow: ["external-chat-plugin"],
},
} as never,
createExternalChannelTestConfig({ pluginDir, pluginId: "external-chat-plugin" }),
{
env: { ...process.env },
includePersistedAuthState: false,
@@ -1193,12 +1099,11 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
channelId: "external-chat",
});
const plugins = listReadOnlyChannelPluginsForConfig(
{
plugins: {
load: { paths: [pluginDir] },
allow: ["external-chat-plugin"],
},
} as never,
createExternalChannelTestConfig({
pluginDir,
pluginId: "external-chat-plugin",
channels: null,
}),
{
env: { ...process.env, EXTERNAL_CHAT_TOKEN: "configured" },
includePersistedAuthState: false,
@@ -1215,15 +1120,11 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
channelId: "external-chat",
});
const plugins = listReadOnlyChannelPluginsForConfig(
{
channels: {
"external-chat": { enabled: false },
},
plugins: {
load: { paths: [pluginDir] },
allow: ["external-chat-plugin"],
},
} as never,
createExternalChannelTestConfig({
pluginDir,
pluginId: "external-chat-plugin",
channels: { "external-chat": { enabled: false } },
}),
{
env: { ...process.env, EXTERNAL_CHAT_TOKEN: "configured" },
includePersistedAuthState: false,
@@ -1336,12 +1237,11 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
channelId: "external-chat",
});
const plugins = listReadOnlyChannelPluginsForConfig(
{
plugins: {
load: { paths: [pluginDir] },
allow: ["external-chat-plugin"],
},
} as never,
createExternalChannelTestConfig({
pluginDir,
pluginId: "external-chat-plugin",
channels: null,
}),
{
env: {
...process.env,
@@ -1404,15 +1304,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
setupChannelId: "spoofed-chat",
});
const plugins = listReadOnlyChannelPluginsForConfig(
{
channels: {
"external-chat": { token: "configured" },
},
plugins: {
load: { paths: [pluginDir] },
allow: ["external-chat-plugin"],
},
} as never,
createExternalChannelTestConfig({ pluginDir, pluginId: "external-chat-plugin" }),
{
env: { ...process.env },
includeSetupFallbackPlugins: true,
@@ -1437,15 +1329,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
);
const result = resolveReadOnlyChannelPluginsForConfig(
{
channels: {
"external-chat": { token: "configured" },
},
plugins: {
load: { paths: [pluginDir] },
allow: ["external-chat-plugin"],
},
} as never,
createExternalChannelTestConfig({ pluginDir, pluginId: "external-chat-plugin" }),
{
env: { ...process.env },
includeSetupFallbackPlugins: true,
+6 -29
View File
@@ -745,23 +745,6 @@ function addSetupChannelPlugins(
if (!ownedMissingChannelIds || ownedMissingChannelIds.length === 0) {
continue;
}
if (ownedMissingChannelIds.includes(setup.plugin.id)) {
addChannelPlugins(byId, [setup.plugin], {
onlyIds: new Set(ownedMissingChannelIds),
allowOverwrite: false,
});
addChannelPlugins(
byId,
ownedMissingChannelIds
.filter((channelId) => channelId !== setup.plugin.id)
.map((channelId) => cloneChannelPluginForChannelId(setup.plugin, channelId)),
{
onlyIds: new Set(ownedMissingChannelIds),
allowOverwrite: false,
},
);
continue;
}
const ownedChannelIds = (options.ownedChannelIdsByPluginId.get(setup.pluginId) ?? []).filter(
isSafeManifestChannelId,
);
@@ -831,17 +814,6 @@ function listBundledChannelManifestRecords(
return records.filter((plugin) => plugin.origin === "bundled" && plugin.channels.length > 0);
}
function listPluginIdsForChannels(
records: readonly PluginManifestRecord[],
channelIds: readonly string[],
): string[] {
const requestedChannelIds = new Set(channelIds);
return records
.filter((plugin) => plugin.channels.some((channelId) => requestedChannelIds.has(channelId)))
.map((plugin) => plugin.id)
.toSorted((left, right) => left.localeCompare(right));
}
function resolveExternalReadOnlyChannelPluginIds(params: {
cfg: OpenClawConfig;
activationSourceConfig?: OpenClawConfig;
@@ -965,9 +937,14 @@ export function resolveReadOnlyChannelPluginsForConfig(
const bundledManifestMissingChannelIds = configuredChannelIds.filter(
(channelId) => !byId.has(channelId),
);
const bundledManifestMissingChannelIdSet = new Set(bundledManifestMissingChannelIds);
addManifestChannelPlugins(byId, bundledManifestRecords, {
pluginIds: new Set(
listPluginIdsForChannels(bundledManifestRecords, bundledManifestMissingChannelIds),
bundledManifestRecords.flatMap((record) =>
record.channels.some((channelId) => bundledManifestMissingChannelIdSet.has(channelId))
? [record.id]
: [],
),
),
channelIds: bundledManifestMissingChannelIds,
});
+2 -10
View File
@@ -25,22 +25,14 @@ export function listChannelPlugins(): ChannelPlugin[] {
* Returns a loaded channel plugin without falling back to bundled metadata.
*/
export function getLoadedChannelPlugin(id: ChannelId): ChannelPlugin | undefined {
const resolvedId = normalizeOptionalString(id) ?? "";
if (!resolvedId) {
return undefined;
}
return getLoadedChannelPluginById(resolvedId) as ChannelPlugin | undefined;
return getLoadedChannelPluginById(id) as ChannelPlugin | undefined;
}
/**
* Returns the package/install origin for a loaded channel plugin.
*/
export function getLoadedChannelPluginOrigin(id: ChannelId): string | undefined {
const resolvedId = normalizeOptionalString(id) ?? "";
if (!resolvedId) {
return undefined;
}
return normalizeOptionalString(getLoadedChannelPluginEntryById(resolvedId)?.origin) ?? undefined;
return normalizeOptionalString(getLoadedChannelPluginEntryById(id)?.origin);
}
/**
+3 -16
View File
@@ -100,33 +100,20 @@ function projectChannelAccountState(state: ChannelAccountState): {
lastError: state.failure,
};
case "unconfigured":
return {
configured: false,
running: false,
stateReason: state.reason,
lastError: state.failure,
};
case "unlinked":
return {
configured: true,
linked: false,
configured: state.kind === "unlinked",
...(state.kind === "unlinked" ? { linked: false } : {}),
running: false,
stateReason: state.reason,
lastError: state.failure,
};
case "running":
return {
configured: true,
...(state.linked ? { linked: true } : {}),
running: true,
...(typeof state.connected === "boolean" ? { connected: state.connected } : {}),
lastError: state.failure,
};
case "stopped":
return {
configured: true,
...(state.linked ? { linked: true } : {}),
running: false,
running: state.kind === "running",
...(typeof state.connected === "boolean" ? { connected: state.connected } : {}),
lastError: state.failure,
};
+37 -130
View File
@@ -243,6 +243,29 @@ function primeSuccessfulPluginPersistence(pluginId = "demo") {
return { cfg, enabledCfg };
}
function primeSuccessfulClawHubPluginInstall(
params: {
explicitVersion?: boolean;
trust?: Parameters<typeof createClawHubInstallResult>[0]["trust"];
} = {},
) {
const result = primeSuccessfulPluginPersistence("demo");
parseClawHubPluginSpec.mockReturnValue({
name: "demo",
...(params.explicitVersion ? { version: "1.2.3" } : {}),
});
installPluginFromClawHub.mockResolvedValue(
createClawHubInstallResult({
pluginId: "demo",
packageName: "demo",
version: "1.2.3",
channel: "official",
...(params.trust ? { trust: params.trust } : {}),
}),
);
return result;
}
function createPathHookPackInstalledConfig(tmpRoot: string): OpenClawConfig {
return {
hooks: {
@@ -1543,16 +1566,7 @@ describe("plugins cli install", () => {
);
it("does not show the non-ClawHub warning for explicit ClawHub installs", async () => {
primeSuccessfulPluginPersistence("demo");
parseClawHubPluginSpec.mockReturnValue({ name: "demo" });
installPluginFromClawHub.mockResolvedValue(
createClawHubInstallResult({
pluginId: "demo",
packageName: "demo",
version: "1.2.3",
channel: "official",
}),
);
primeSuccessfulClawHubPluginInstall();
await runPluginsCommand(["plugins", "install", "clawhub:demo"]);
expect(runtimeLogsContain("outside ClawHub review")).toBe(false);
@@ -1560,27 +1574,7 @@ describe("plugins cli install", () => {
});
it("installs ClawHub plugins and persists source metadata", async () => {
const cfg = {
plugins: {
entries: {},
},
} as OpenClawConfig;
const enabledCfg = createEnabledPluginConfig("demo");
loadConfig.mockReturnValue(cfg);
parseClawHubPluginSpec.mockReturnValue({ name: "demo" });
installPluginFromClawHub.mockResolvedValue(
createClawHubInstallResult({
pluginId: "demo",
packageName: "demo",
version: "1.2.3",
channel: "official",
}),
);
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
applyExclusiveSlotSelection.mockReturnValue({
config: enabledCfg,
warnings: [],
});
const { enabledCfg } = primeSuccessfulClawHubPluginInstall();
await runPluginsCommand(["plugins", "install", "clawhub:demo"]);
@@ -1613,21 +1607,7 @@ describe("plugins cli install", () => {
});
it("does not report a ClawHub install when durable persistence fails", async () => {
loadConfig.mockReturnValue(createEmptyPluginConfig());
parseClawHubPluginSpec.mockReturnValue({ name: "demo" });
installPluginFromClawHub.mockResolvedValue(
createClawHubInstallResult({
pluginId: "demo",
packageName: "demo",
version: "1.2.3",
channel: "official",
}),
);
enablePluginInConfig.mockReturnValue({ config: createEnabledPluginConfig("demo") });
applyExclusiveSlotSelection.mockReturnValue({
config: createEnabledPluginConfig("demo"),
warnings: [],
});
primeSuccessfulClawHubPluginInstall();
writeConfigFile.mockRejectedValueOnce(new Error("persistence failed"));
await expect(runPluginsCommand(["plugins", "install", "clawhub:demo"])).rejects.toThrow(
@@ -1638,27 +1618,14 @@ describe("plugins cli install", () => {
});
it("passes ClawHub risk acknowledgement to explicit ClawHub installs", async () => {
loadConfig.mockReturnValue(createEmptyPluginConfig());
parseClawHubPluginSpec.mockReturnValue({ name: "demo" });
installPluginFromClawHub.mockResolvedValue(
createClawHubInstallResult({
pluginId: "demo",
packageName: "demo",
version: "1.2.3",
channel: "official",
trust: {
disposition: "review-required",
scanStatus: "suspicious",
reasons: ["payload_strings"],
checkedAt: "2026-05-14T18:00:00.000Z",
acknowledgedAt: "2026-05-14T18:00:03.000Z",
},
}),
);
enablePluginInConfig.mockReturnValue({ config: createEnabledPluginConfig("demo") });
applyExclusiveSlotSelection.mockReturnValue({
config: createEnabledPluginConfig("demo"),
warnings: [],
primeSuccessfulClawHubPluginInstall({
trust: {
disposition: "review-required",
scanStatus: "suspicious",
reasons: ["payload_strings"],
checkedAt: "2026-05-14T18:00:00.000Z",
acknowledgedAt: "2026-05-14T18:00:03.000Z",
},
});
await runPluginsCommand(["plugins", "install", "clawhub:demo", "--acknowledge-clawhub-risk"]);
@@ -1714,24 +1681,7 @@ describe("plugins cli install", () => {
it("passes the active profile extensions dir to ClawHub installs", async () => {
const extensionsDir = useProfileExtensionsDir();
const cfg = createEmptyPluginConfig();
const enabledCfg = createEnabledPluginConfig("demo");
loadConfig.mockReturnValue(cfg);
parseClawHubPluginSpec.mockReturnValue({ name: "demo" });
installPluginFromClawHub.mockResolvedValue(
createClawHubInstallResult({
pluginId: "demo",
packageName: "demo",
version: "1.2.3",
channel: "official",
}),
);
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
applyExclusiveSlotSelection.mockReturnValue({
config: enabledCfg,
warnings: [],
});
primeSuccessfulClawHubPluginInstall();
await runPluginsCommand(["plugins", "install", "clawhub:demo"]);
@@ -1859,29 +1809,7 @@ describe("plugins cli install", () => {
});
it("passes force through as overwrite mode for ClawHub installs", async () => {
const cfg = {
plugins: {
entries: {},
},
} as OpenClawConfig;
const enabledCfg = createEnabledPluginConfig("demo");
loadConfig.mockReturnValue(cfg);
parseClawHubPluginSpec.mockReturnValue({ name: "demo" });
installPluginFromClawHub.mockResolvedValue(
createClawHubInstallResult({
pluginId: "demo",
packageName: "demo",
version: "1.2.3",
channel: "official",
}),
);
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
recordPluginInstall.mockReturnValue(enabledCfg);
applyExclusiveSlotSelection.mockReturnValue({
config: enabledCfg,
warnings: [],
});
primeSuccessfulClawHubPluginInstall();
await runPluginsCommand(["plugins", "install", "clawhub:demo", "--force"]);
@@ -1890,28 +1818,7 @@ describe("plugins cli install", () => {
});
it("keeps explicit ClawHub versions pinned in install records", async () => {
const cfg = {
plugins: {
entries: {},
},
} as OpenClawConfig;
const enabledCfg = createEnabledPluginConfig("demo");
loadConfig.mockReturnValue(cfg);
parseClawHubPluginSpec.mockReturnValue({ name: "demo", version: "1.2.3" });
installPluginFromClawHub.mockResolvedValue(
createClawHubInstallResult({
pluginId: "demo",
packageName: "demo",
version: "1.2.3",
channel: "official",
}),
);
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
applyExclusiveSlotSelection.mockReturnValue({
config: enabledCfg,
warnings: [],
});
primeSuccessfulClawHubPluginInstall({ explicitVersion: true });
await runPluginsCommand(["plugins", "install", "clawhub:demo@1.2.3"]);
+47 -79
View File
@@ -3,6 +3,7 @@ import { mkdtemp, readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createHostedMarketplaceFeedFixture } from "./plugins-marketplace-feed.test-support.js";
const mocks = vi.hoisted(() => {
const defaultRuntime = {
@@ -72,53 +73,26 @@ describe("plugins marketplace entries", () => {
it("lists entries from an explicitly selected marketplace feed as JSON", async () => {
const config = {};
mocks.getRuntimeConfig.mockReturnValue(config);
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue({
source: "hosted-snapshot",
entries: [
{
name: "@acme/calendar",
version: "1.2.3",
kind: "plugin",
state: "available",
publisher: { trust: "official" },
install: {
candidates: [{ sourceRef: "public-npm", package: "@acme/calendar", version: "1.2.3" }],
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue(
createHostedMarketplaceFeedFixture({
source: "hosted-snapshot",
entries: [
{
name: "@acme/calendar",
version: "1.2.3",
kind: "plugin",
state: "available",
publisher: { trust: "official" },
install: {
candidates: [
{ sourceRef: "public-npm", package: "@acme/calendar", version: "1.2.3" },
],
},
openclaw: { plugin: { id: "acme-calendar", label: "Acme Calendar" } },
},
openclaw: {
plugin: { id: "acme-calendar", label: "Acme Calendar" },
},
},
],
feed: {
schemaVersion: 1,
id: "acme-marketplace",
generatedAt: "2026-06-23T00:00:00.000Z",
sequence: 7,
entries: [],
},
metadata: {
url: "https://packages.acme.example/openclaw/feed",
status: 200,
checksum: "feed-sha",
},
snapshot: {
body: "{}",
metadata: {
url: "https://packages.acme.example/openclaw/feed",
status: 200,
checksum: "feed-sha",
},
savedAt: "2026-06-23T01:02:03.000Z",
},
trust: {
mode: "signed",
signedBy: "acme-root-2026",
signatureCount: 1,
threshold: 1,
verifiedAt: "2026-06-23T01:02:03.000Z",
},
error: "hosted catalog feed offline mode",
});
],
}),
);
const { runPluginMarketplaceEntriesCommand } = await import("./plugins-cli.runtime.js");
await runPluginMarketplaceEntriesCommand({ feedProfile: "acme", offline: true, json: true });
@@ -190,6 +164,30 @@ describe("plugins marketplace entries", () => {
expect(output).not.toContain("#frag");
});
it("keeps replacement metacharacters literal while redacting feed URLs", async () => {
const publicUrl = ["https://", "feed.example.invalid", "/$&"].join("");
const privateQuery = ["marker=", ["test", "-", "secret"].join("")].join("");
const rawUrl = [publicUrl, "?", privateQuery].join("");
mocks.getRuntimeConfig.mockReturnValue({});
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue({
source: "bundled-fallback",
entries: [],
error: `hosted catalog feed fetch failed for ${rawUrl}`,
metadata: { url: rawUrl, status: 503 },
});
const { runPluginMarketplaceEntriesCommand } = await import("./plugins-cli.runtime.js");
await runPluginMarketplaceEntriesCommand({ feedUrl: rawUrl, json: true });
expect(mocks.defaultRuntime.writeJson).toHaveBeenCalledWith(
expect.objectContaining({
metadata: expect.objectContaining({ url: publicUrl }),
error: `hosted catalog feed fetch failed for ${publicUrl}`,
}),
);
expect(JSON.stringify(mocks.defaultRuntime.writeJson.mock.calls)).not.toContain(privateQuery);
});
it("prints bundled fallback entries without failing", async () => {
mocks.getRuntimeConfig.mockReturnValue({});
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue({
@@ -224,39 +222,9 @@ describe("plugins marketplace entries", () => {
it("prints bounded signed feed trust state in text output", async () => {
mocks.getRuntimeConfig.mockReturnValue({});
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue({
source: "hosted-snapshot",
entries: [],
feed: {
schemaVersion: 1,
id: "acme-marketplace",
generatedAt: "2026-06-23T00:00:00.000Z",
sequence: 7,
entries: [],
},
metadata: {
url: "https://packages.acme.example/openclaw/feed",
status: 200,
checksum: "feed-sha",
},
snapshot: {
body: "{}",
metadata: {
url: "https://packages.acme.example/openclaw/feed",
status: 200,
checksum: "feed-sha",
},
savedAt: "2026-06-23T01:02:03.000Z",
},
trust: {
mode: "signed",
signedBy: "acme-root-2026",
signatureCount: 1,
threshold: 1,
verifiedAt: "2026-06-23T01:02:03.000Z",
},
error: "hosted catalog feed offline mode",
});
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue(
createHostedMarketplaceFeedFixture({ source: "hosted-snapshot" }),
);
const { runPluginMarketplaceEntriesCommand } = await import("./plugins-cli.runtime.js");
await runPluginMarketplaceEntriesCommand({ offline: true });
+20 -83
View File
@@ -3,6 +3,7 @@ import { mkdtemp, readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createHostedMarketplaceFeedFixture } from "./plugins-marketplace-feed.test-support.js";
const mocks = vi.hoisted(() => {
const defaultRuntime = {
@@ -67,30 +68,12 @@ describe("plugins marketplace refresh", () => {
it("refreshes an explicitly selected marketplace feed and prints JSON", async () => {
const config = {};
mocks.getRuntimeConfig.mockReturnValue(config);
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue({
source: "hosted",
entries: [{ name: "@acme/calendar" }, { name: "@acme/docs" }],
feed: {
schemaVersion: 1,
id: "acme-marketplace",
generatedAt: "2026-06-23T00:00:00.000Z",
sequence: 7,
entries: [],
},
metadata: {
url: "https://packages.acme.example/openclaw/feed",
status: 200,
checksum: "feed-sha",
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue(
createHostedMarketplaceFeedFixture({
entries: [{ name: "@acme/calendar" }, { name: "@acme/docs" }],
etag: '"abc"',
},
trust: {
mode: "signed",
signedBy: "acme-root-2026",
signatureCount: 1,
threshold: 1,
verifiedAt: "2026-06-23T00:01:02.000Z",
},
});
}),
);
const { runPluginMarketplaceRefreshCommand } = await import("./plugins-cli.runtime.js");
await runPluginMarketplaceRefreshCommand({
@@ -130,29 +113,9 @@ describe("plugins marketplace refresh", () => {
it("prints bounded signed feed trust state in text output", async () => {
mocks.getRuntimeConfig.mockReturnValue({});
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue({
source: "hosted",
entries: [{ name: "@acme/calendar" }],
feed: {
schemaVersion: 1,
id: "acme-marketplace",
generatedAt: "2026-06-23T00:00:00.000Z",
sequence: 7,
entries: [],
},
metadata: {
url: "https://packages.acme.example/openclaw/feed",
status: 200,
checksum: "feed-sha",
},
trust: {
mode: "signed",
signedBy: "acme-root-2026",
signatureCount: 1,
threshold: 1,
verifiedAt: "2026-06-23T00:01:02.000Z",
},
});
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue(
createHostedMarketplaceFeedFixture({ entries: [{ name: "@acme/calendar" }] }),
);
const { runPluginMarketplaceRefreshCommand } = await import("./plugins-cli.runtime.js");
await runPluginMarketplaceRefreshCommand({});
@@ -168,22 +131,13 @@ describe("plugins marketplace refresh", () => {
it("normalizes bare SHA-256 pins before refreshing", async () => {
const config = {};
mocks.getRuntimeConfig.mockReturnValue(config);
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue({
source: "hosted",
entries: [{ name: "@acme/calendar" }],
feed: {
schemaVersion: 1,
id: "acme-marketplace",
generatedAt: "2026-06-23T00:00:00.000Z",
sequence: 7,
entries: [],
},
metadata: {
url: "https://packages.acme.example/openclaw/feed",
status: 200,
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue(
createHostedMarketplaceFeedFixture({
entries: [{ name: "@acme/calendar" }],
checksum: "sha256:abcdef",
},
});
includeTrust: false,
}),
);
const { runPluginMarketplaceRefreshCommand } = await import("./plugins-cli.runtime.js");
await runPluginMarketplaceRefreshCommand({
@@ -307,30 +261,13 @@ describe("plugins marketplace refresh", () => {
diagnostics: { flags: ["timeline"] },
};
mocks.getRuntimeConfig.mockReturnValue(config);
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue({
source: "hosted",
entries: [{ name: "@acme/calendar" }, { name: "@acme/docs" }],
feed: {
schemaVersion: 1,
id: "acme-marketplace",
generatedAt: "2026-06-23T00:00:00.000Z",
sequence: 7,
entries: [],
},
metadata: {
mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue(
createHostedMarketplaceFeedFixture({
entries: [{ name: "@acme/calendar" }, { name: "@acme/docs" }],
url: "https://user:secret@packages.acme.example/openclaw/feed?token=leak#frag",
status: 200,
checksum: "feed-sha",
etag: '"abc"',
},
trust: {
mode: "signed",
signedBy: "acme-root-2026",
signatureCount: 1,
threshold: 1,
verifiedAt: "2026-06-23T00:01:02.000Z",
},
});
}),
);
const { runPluginMarketplaceRefreshCommand } = await import("./plugins-cli.runtime.js");
await runPluginMarketplaceRefreshCommand({
+46 -86
View File
@@ -53,13 +53,7 @@ function countEnabledPlugins(plugins: readonly { enabled: boolean }[]): number {
}
function formatRegistryState(state: "missing" | "fresh" | "stale"): string {
if (state === "fresh") {
return theme.success(state);
}
if (state === "stale") {
return theme.warn(state);
}
return theme.warn(state);
return state === "fresh" ? theme.success(state) : theme.warn(state);
}
function reportMissingPlugin(id: string) {
@@ -67,10 +61,6 @@ function reportMissingPlugin(id: string) {
return defaultRuntime.exit(1);
}
function matchesPluginId(plugin: { id: string }, id: string) {
return plugin.id === id;
}
function isConfigSelectedShadowDiagnostic(entry: { level?: string; message?: string }): boolean {
return (
entry.level === "warn" &&
@@ -207,7 +197,7 @@ async function runPluginsEnableCommandUnlocked(idInput: string): Promise<void> {
const cfg = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig;
const report = buildPluginRegistrySnapshotReport({ config: cfg });
id = normalizePluginId(id);
if (!report.plugins.some((plugin) => matchesPluginId(plugin, id))) {
if (!report.plugins.some((plugin) => plugin.id === id)) {
return reportMissingPlugin(id);
}
const enableResult = enableExplicitlySelectedPluginInConfig(cfg, id, {
@@ -272,7 +262,7 @@ async function runPluginsDisableCommandUnlocked(idInput: string): Promise<void>
const cfg = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig;
const report = buildPluginRegistrySnapshotReport({ config: cfg });
id = normalizePluginId(id);
if (!report.plugins.some((plugin) => matchesPluginId(plugin, id))) {
if (!report.plugins.some((plugin) => plugin.id === id)) {
return reportMissingPlugin(id);
}
const next = setPluginEnabledInConfig(cfg, id, false, {
@@ -540,25 +530,15 @@ function classifyMarketplaceFeedFallback(error: string | undefined): string | un
if (!text) {
return undefined;
}
if (text.includes("offline mode")) {
return "offline";
}
if (text.includes("checksum mismatch")) {
return "checksum_mismatch";
}
if (text.includes("schema")) {
return "schema";
}
if (/http\s+304/u.test(text)) {
return "not_modified";
}
if (/http\s+\d{3}/u.test(text)) {
return "http_error";
}
if (text.includes("timed out") || text.includes("timeout")) {
return "timeout";
}
return "error";
const categories = [
[/offline mode/u, "offline"],
[/checksum mismatch/u, "checksum_mismatch"],
[/schema/u, "schema"],
[/http\s+304/u, "not_modified"],
[/http\s+\d{3}/u, "http_error"],
[/timed out|timeout/u, "timeout"],
] as const;
return categories.find(([pattern]) => pattern.test(text))?.[1] ?? "error";
}
function emitMarketplaceFeedTelemetry(params: {
@@ -678,10 +658,6 @@ function redactMarketplaceFeedUrl(value: string): string {
}
}
function replaceAllLiteral(value: string, search: string, replacement: string): string {
return search ? value.split(search).join(replacement) : value;
}
function redactMarketplaceOutputText(
value: string,
rawUrls: readonly (string | undefined)[],
@@ -691,7 +667,7 @@ function redactMarketplaceOutputText(
if (!rawUrl) {
continue;
}
redacted = replaceAllLiteral(redacted, rawUrl, redactMarketplaceFeedUrl(rawUrl));
redacted = redacted.replaceAll(rawUrl, () => redactMarketplaceFeedUrl(rawUrl));
}
return redacted;
}
@@ -742,6 +718,37 @@ function formatMarketplaceFeedTrust(trust: MarketplaceFeedTrustPayload): string
return `${trust.mode} by ${trust.signedBy} (${trust.signatureCount}/${trust.threshold}) verified ${trust.verifiedAt}`;
}
function formatMarketplaceFeedLines(
payload: MarketplaceRefreshPayload,
options: { includeChecksum?: boolean } = {},
): string[] {
const lines = [
`${theme.muted("Source:")} ${formatMarketplaceRefreshSource(payload.source)}`,
`${theme.muted("Entries:")} ${payload.entries}`,
];
if (payload.feed) {
lines.push(
`${theme.muted("Feed:")} ${payload.feed.id} ${theme.muted(`sequence ${payload.feed.sequence}`)}`,
);
}
if (payload.metadata?.url) {
lines.push(`${theme.muted("URL:")} ${payload.metadata.url}`);
}
if (options.includeChecksum && payload.metadata?.checksum) {
lines.push(`${theme.muted("SHA-256:")} ${payload.metadata.checksum}`);
}
if (payload.snapshot?.savedAt) {
lines.push(`${theme.muted("Snapshot:")} ${payload.snapshot.savedAt}`);
}
if (payload.trust) {
lines.push(`${theme.muted("Trust:")} ${formatMarketplaceFeedTrust(payload.trust)}`);
}
if (payload.error) {
lines.push(`${theme.muted("Fallback reason:")} ${payload.error}`);
}
return lines;
}
function shouldFailPinnedMarketplaceRefresh(params: {
expectedSha256?: string;
source: MarketplaceRefreshPayload["source"];
@@ -818,31 +825,7 @@ export async function runPluginMarketplaceEntriesCommand(
return;
}
const lines = [
theme.muted("Source:") + " " + formatMarketplaceRefreshSource(summary.source),
theme.muted("Entries:") + " " + String(entries.length),
];
if (summary.feed) {
lines.push(
theme.muted("Feed:") +
" " +
summary.feed.id +
" " +
theme.muted("sequence " + String(summary.feed.sequence)),
);
}
if (summary.metadata?.url) {
lines.push(theme.muted("URL:") + " " + summary.metadata.url);
}
if (summary.snapshot?.savedAt) {
lines.push(theme.muted("Snapshot:") + " " + summary.snapshot.savedAt);
}
if (summary.trust) {
lines.push(theme.muted("Trust:") + " " + formatMarketplaceFeedTrust(summary.trust));
}
if (summary.error) {
lines.push(theme.muted("Fallback reason:") + " " + summary.error);
}
const lines = formatMarketplaceFeedLines(summary);
if (entries.length > 0) {
lines.push("");
lines.push(...entries.map(formatMarketplaceEntryLine));
@@ -892,30 +875,7 @@ export async function runPluginMarketplaceRefreshCommand(
return;
}
const lines = [
`${theme.muted("Source:")} ${formatMarketplaceRefreshSource(payload.source)}`,
`${theme.muted("Entries:")} ${payload.entries}`,
];
if (payload.feed) {
lines.push(
`${theme.muted("Feed:")} ${payload.feed.id} ${theme.muted(`sequence ${payload.feed.sequence}`)}`,
);
}
if (payload.metadata?.url) {
lines.push(`${theme.muted("URL:")} ${payload.metadata.url}`);
}
if (payload.metadata?.checksum) {
lines.push(`${theme.muted("SHA-256:")} ${payload.metadata.checksum}`);
}
if (payload.snapshot?.savedAt) {
lines.push(`${theme.muted("Snapshot:")} ${payload.snapshot.savedAt}`);
}
if (payload.trust) {
lines.push(`${theme.muted("Trust:")} ${formatMarketplaceFeedTrust(payload.trust)}`);
}
if (payload.error) {
lines.push(`${theme.muted("Fallback reason:")} ${payload.error}`);
}
const lines = formatMarketplaceFeedLines(payload, { includeChecksum: true });
defaultRuntime.log(lines.join("\n"));
if (failedPinnedRefresh) {
defaultRuntime.error(formatPinnedMarketplaceRefreshFailure(payload));
+56 -105
View File
@@ -3,6 +3,7 @@ import path from "node:path";
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { ClawHubTrustErrorCode } from "../infra/clawhub-install-trust.js";
import { resolveRegistryUpdateChannel } from "../infra/update-channels.js";
import { CLAWHUB_INSTALL_ERROR_CODE } from "../plugins/clawhub-error-codes.js";
import { VERSION } from "../version.js";
@@ -182,6 +183,45 @@ function primeBravePluginRecordUpdate(config: OpenClawConfig) {
return { previousRecords, nextRecords };
}
async function expectSkippedClawHubPluginUpdate(params: {
code: ClawHubTrustErrorCode;
message: string;
expectedLog: string;
spec?: string;
}): Promise<void> {
const config = {
plugins: {
installs: {
demo: {
source: "clawhub",
spec: params.spec ?? "clawhub:@openclaw/plugin-demo",
clawhubPackage: "@openclaw/plugin-demo",
},
},
},
} as OpenClawConfig;
loadConfig.mockReturnValue(config);
setInstalledPluginIndexInstallRecords(config.plugins?.installs ?? {});
updateNpmInstalledPlugins.mockResolvedValue({
outcomes: [
{
pluginId: "demo",
status: "skipped",
code: params.code,
message: params.message,
},
],
changed: false,
config,
});
updateNpmInstalledHookPacks.mockResolvedValue({ outcomes: [], changed: false, config });
await expect(runPluginsCommand(["plugins", "update", "demo"])).rejects.toThrow("__exit__:1");
expect(writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled();
expect(runtimeLogs.at(-1)).toContain(params.expectedLog);
}
describe("plugins cli update", () => {
beforeEach(() => {
resetPluginsCliTestState();
@@ -1363,120 +1403,31 @@ describe("plugins cli update", () => {
});
it("exits non-zero when a ClawHub update is skipped for missing risk acknowledgement", async () => {
const cfg = {
plugins: {
installs: {
demo: {
source: "clawhub",
spec: "clawhub:@openclaw/plugin-demo@1.0.0",
clawhubPackage: "@openclaw/plugin-demo",
},
},
},
} as OpenClawConfig;
loadConfig.mockReturnValue(cfg);
setInstalledPluginIndexInstallRecords(cfg.plugins?.installs ?? {});
updateNpmInstalledPlugins.mockResolvedValue({
outcomes: [
{
pluginId: "demo",
status: "skipped",
code: CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED,
message:
"Skipped demo ClawHub update: Update cancelled; rerun with --acknowledge-clawhub-risk to continue after reviewing the warning. Existing installed plugin left unchanged.",
},
],
changed: false,
config: cfg,
await expectSkippedClawHubPluginUpdate({
code: CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED,
spec: "clawhub:@openclaw/plugin-demo@1.0.0",
message:
"Skipped demo ClawHub update: Update cancelled; rerun with --acknowledge-clawhub-risk to continue after reviewing the warning. Existing installed plugin left unchanged.",
expectedLog: "--acknowledge-clawhub-risk",
});
updateNpmInstalledHookPacks.mockResolvedValue({
outcomes: [],
changed: false,
config: cfg,
});
await expect(runPluginsCommand(["plugins", "update", "demo"])).rejects.toThrow("__exit__:1");
expect(writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled();
expect(runtimeLogs.at(-1)).toContain("--acknowledge-clawhub-risk");
});
it("exits non-zero when a ClawHub update is skipped because the target release is blocked", async () => {
const cfg = {
plugins: {
installs: {
demo: {
source: "clawhub",
spec: "clawhub:@openclaw/plugin-demo",
clawhubPackage: "@openclaw/plugin-demo",
},
},
},
} as OpenClawConfig;
loadConfig.mockReturnValue(cfg);
setInstalledPluginIndexInstallRecords(cfg.plugins?.installs ?? {});
updateNpmInstalledPlugins.mockResolvedValue({
outcomes: [
{
pluginId: "demo",
status: "skipped",
code: "clawhub_download_blocked",
message:
"Skipped demo ClawHub update: ClawHub blocked this release; update was not started. Existing installed plugin left unchanged.",
},
],
changed: false,
config: cfg,
await expectSkippedClawHubPluginUpdate({
code: "clawhub_download_blocked",
message:
"Skipped demo ClawHub update: ClawHub blocked this release; update was not started. Existing installed plugin left unchanged.",
expectedLog: "ClawHub blocked this release",
});
updateNpmInstalledHookPacks.mockResolvedValue({
outcomes: [],
changed: false,
config: cfg,
});
await expect(runPluginsCommand(["plugins", "update", "demo"])).rejects.toThrow("__exit__:1");
expect(writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled();
expect(runtimeLogs.at(-1)).toContain("ClawHub blocked this release");
});
it("exits non-zero when a ClawHub update is skipped because security data is unavailable", async () => {
const cfg = {
plugins: {
installs: {
demo: {
source: "clawhub",
spec: "clawhub:@openclaw/plugin-demo",
clawhubPackage: "@openclaw/plugin-demo",
},
},
},
} as OpenClawConfig;
loadConfig.mockReturnValue(cfg);
setInstalledPluginIndexInstallRecords(cfg.plugins?.installs ?? {});
updateNpmInstalledPlugins.mockResolvedValue({
outcomes: [
{
pluginId: "demo",
status: "skipped",
code: "clawhub_security_unavailable",
message:
'Skipped demo ClawHub update: ClawHub security data for "@openclaw/plugin-demo@1.1.0" is unavailable, so OpenClaw left the existing installed plugin unchanged. Try again later or choose a different version.',
},
],
changed: false,
config: cfg,
await expectSkippedClawHubPluginUpdate({
code: "clawhub_security_unavailable",
message:
'Skipped demo ClawHub update: ClawHub security data for "@openclaw/plugin-demo@1.1.0" is unavailable, so OpenClaw left the existing installed plugin unchanged. Try again later or choose a different version.',
expectedLog: "security data",
});
updateNpmInstalledHookPacks.mockResolvedValue({
outcomes: [],
changed: false,
config: cfg,
});
await expect(runPluginsCommand(["plugins", "update", "demo"])).rejects.toThrow("__exit__:1");
expect(writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled();
expect(runtimeLogs.at(-1)).toContain("security data");
});
it("exits non-zero when a hook pack update reports an error", async () => {
@@ -0,0 +1,51 @@
type HostedMarketplaceFeedFixtureOptions = {
source?: "hosted" | "hosted-snapshot";
entries?: Record<string, unknown>[];
url?: string;
checksum?: string;
etag?: string;
includeTrust?: boolean;
};
export function createHostedMarketplaceFeedFixture(
options: HostedMarketplaceFeedFixtureOptions = {},
) {
const source = options.source ?? "hosted";
const metadata = {
url: options.url ?? "https://packages.acme.example/openclaw/feed",
status: 200,
checksum: options.checksum ?? "feed-sha",
...(options.etag ? { etag: options.etag } : {}),
};
const verifiedAt =
source === "hosted-snapshot" ? "2026-06-23T01:02:03.000Z" : "2026-06-23T00:01:02.000Z";
return {
source,
entries: options.entries ?? [],
feed: {
schemaVersion: 1,
id: "acme-marketplace",
generatedAt: "2026-06-23T00:00:00.000Z",
sequence: 7,
entries: [],
},
metadata,
...(source === "hosted-snapshot"
? {
snapshot: { body: "{}", metadata, savedAt: verifiedAt },
error: "hosted catalog feed offline mode",
}
: {}),
...(options.includeTrust !== false
? {
trust: {
mode: "signed" as const,
signedBy: "acme-root-2026",
signatureCount: 1,
threshold: 1,
verifiedAt,
},
}
: {}),
};
}
@@ -3,12 +3,19 @@ import { listRawChannelPluginCatalogEntries } from "../../../channels/plugins/ca
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import type { PluginInstallRecord } from "../../../config/types.plugins.js";
import { compareOpenClawReleaseVersions } from "../../../infra/npm-registry-spec.js";
import type { UpdateChannel } from "../../../infra/update-channels.js";
import {
normalizeUpdateChannel,
resolveRegistryUpdateChannel,
type UpdateChannel,
} from "../../../infra/update-channels.js";
import {
resolveDefaultPluginExtensionsDir,
resolvePluginInstallDir,
} from "../../../plugins/install-paths.js";
import { loadInstalledPluginIndexInstallRecords } from "../../../plugins/installed-plugin-index-records.js";
import { loadInstalledPluginIndex } from "../../../plugins/installed-plugin-index.js";
import { readLegacyNpmPluginDeclaration } from "../../../plugins/legacy-npm-declaration.js";
import { loadManifestMetadataSnapshot } from "../../../plugins/manifest-contract-eligibility.js";
import type { PluginPackageInstall } from "../../../plugins/manifest.js";
import {
listOfficialExternalPluginCatalogEntries,
@@ -27,6 +34,7 @@ import {
import {
collectConfiguredChannelIds,
collectConfiguredPluginIds,
collectEffectiveConfiguredChannelOwnerPluginIds,
} from "./missing-configured-plugin-install.ids.js";
export type DownloadableInstallCandidate = {
@@ -44,6 +52,92 @@ export type BundledPluginPackageDescriptor = {
packageName?: string;
};
/** Keep doctor diagnostics and actual package repair on the same discovery snapshot. */
export async function resolveConfiguredPluginInstallContext(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
configuredPluginIds: ReadonlySet<string>;
configuredChannelIds: ReadonlySet<string>;
blockedPluginIds?: ReadonlySet<string>;
baselineRecords?: Record<string, PluginInstallRecord>;
}) {
const snapshot = loadManifestMetadataSnapshot({ config: params.cfg, env: params.env });
const currentBundledPlugins = loadInstalledPluginIndex({
config: params.cfg,
env: params.env,
installRecords: {},
}).plugins.filter((plugin) => plugin.origin === "bundled");
const knownIds = new Set([
...snapshot.plugins.map((plugin) => plugin.id),
...currentBundledPlugins.map((plugin) => plugin.pluginId),
]);
const configuredChannelOwnerPluginIds = collectEffectiveConfiguredChannelOwnerPluginIds({
cfg: params.cfg,
env: params.env,
snapshot,
configuredChannelIds: params.configuredChannelIds,
});
const bundledPluginsById = new Map<string, BundledPluginPackageDescriptor>([
...snapshot.plugins
.filter((plugin) => plugin.origin === "bundled")
.map((plugin) => [plugin.id, plugin] as const),
...currentBundledPlugins.map(
(plugin) => [plugin.pluginId, { packageName: plugin.packageName }] as const,
),
]);
const configuredPluginIdsWithStaleDescriptors =
collectConfiguredPluginIdsWithMissingChannelConfigDescriptors({
snapshot,
configuredPluginIds: params.configuredPluginIds,
configuredChannelIds: params.configuredChannelIds,
});
const records =
params.baselineRecords ?? (await loadInstalledPluginIndexInstallRecords({ env: params.env }));
const updateChannel = resolveRegistryUpdateChannel({
configChannel: normalizeUpdateChannel(params.cfg.update?.channel),
currentVersion: VERSION,
});
const installedPluginIdsWithRepairablePackageDiagnostics =
collectInstalledPluginIdsWithRepairablePackageDiagnostics({
snapshot,
installRecords: records,
});
const installedPluginIdsWithStaleVersionBoundRuntimePackages =
collectInstalledPluginIdsWithStaleVersionBoundRuntimePackages({
snapshot,
installRecords: records,
configuredPluginIds: params.configuredPluginIds,
updateChannel,
});
const installedPluginIdsWithRepairablePackages = new Set([
...installedPluginIdsWithRepairablePackageDiagnostics,
...installedPluginIdsWithStaleVersionBoundRuntimePackages,
]);
const officialReplacementPluginIds = new Set(
collectOfficialReplacementInstallCandidates({
cfg: params.cfg,
env: params.env,
repairablePluginIds: installedPluginIdsWithRepairablePackages,
configuredPluginIds: params.configuredPluginIds,
configuredChannelIds: params.configuredChannelIds,
configuredChannelOwnerPluginIds,
blockedPluginIds: params.blockedPluginIds,
}).keys(),
);
return {
knownIds,
configuredChannelOwnerPluginIds,
bundledPluginsById,
configuredPluginIdsWithStaleDescriptors,
records,
updateChannel,
installedPluginIdsWithRepairablePackageDiagnostics,
installedPluginIdsWithStaleVersionBoundRuntimePackages,
installedPluginIdsWithRepairablePackages,
officialReplacementPluginIds,
};
}
const MISSING_CHANNEL_CONFIG_DESCRIPTOR_DIAGNOSTIC = "without channelConfigs metadata";
const REPAIRABLE_PACKAGE_ENTRY_DIAGNOSTIC_MARKERS = [
"extension entry escapes package directory",
@@ -62,6 +156,33 @@ function resolveCandidateClawHubSpec(install: PluginPackageInstall): string | un
return undefined;
}
function setDownloadableInstallCandidate(params: {
candidates: Map<string, DownloadableInstallCandidate>;
pluginId: string;
label: string;
install: PluginPackageInstall;
trustedSourceLinkedOfficialInstall?: boolean;
}): void {
const npmSpec = params.install.npmSpec?.trim();
const clawhubSpec = resolveCandidateClawHubSpec(params.install);
if (!npmSpec && !clawhubSpec) {
return;
}
params.candidates.set(params.pluginId, {
pluginId: params.pluginId,
label: params.label,
...(npmSpec ? { npmSpec } : {}),
...(clawhubSpec ? { clawhubSpec } : {}),
...(params.install.expectedIntegrity
? { expectedIntegrity: params.install.expectedIntegrity }
: {}),
...(params.trustedSourceLinkedOfficialInstall
? { trustedSourceLinkedOfficialInstall: true }
: {}),
...(params.install.defaultChoice ? { defaultChoice: params.install.defaultChoice } : {}),
});
}
export function collectDownloadableInstallCandidates(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
@@ -110,23 +231,12 @@ export function collectDownloadableInstallCandidates(params: {
) {
continue;
}
const npmSpec = entry.install.npmSpec?.trim();
const clawhubSpec = resolveCandidateClawHubSpec(entry.install);
if (!npmSpec && !clawhubSpec) {
continue;
}
candidates.set(pluginId, {
setDownloadableInstallCandidate({
candidates,
pluginId,
label: entry.meta.label,
...(npmSpec ? { npmSpec } : {}),
...(clawhubSpec ? { clawhubSpec } : {}),
...(entry.install.expectedIntegrity
? { expectedIntegrity: entry.install.expectedIntegrity }
: {}),
...(entry.trustedSourceLinkedOfficialInstall
? { trustedSourceLinkedOfficialInstall: true }
: {}),
...(entry.install.defaultChoice ? { defaultChoice: entry.install.defaultChoice } : {}),
install: entry.install,
trustedSourceLinkedOfficialInstall: entry.trustedSourceLinkedOfficialInstall,
});
}
@@ -141,21 +251,12 @@ export function collectDownloadableInstallCandidates(params: {
if (params.blockedPluginIds?.has(entry.pluginId)) {
continue;
}
const npmSpec = entry.install.npmSpec?.trim();
const clawhubSpec = resolveCandidateClawHubSpec(entry.install);
if (!npmSpec && !clawhubSpec) {
continue;
}
candidates.set(entry.pluginId, {
setDownloadableInstallCandidate({
candidates,
pluginId: entry.pluginId,
label: entry.label,
...(npmSpec ? { npmSpec } : {}),
...(clawhubSpec ? { clawhubSpec } : {}),
...(entry.install.expectedIntegrity
? { expectedIntegrity: entry.install.expectedIntegrity }
: {}),
...(entry.origin === "bundled" ? { trustedSourceLinkedOfficialInstall: true } : {}),
...(entry.install.defaultChoice ? { defaultChoice: entry.install.defaultChoice } : {}),
install: entry.install,
trustedSourceLinkedOfficialInstall: entry.origin === "bundled",
});
}
@@ -171,19 +272,12 @@ export function collectDownloadableInstallCandidates(params: {
if (!install) {
continue;
}
const npmSpec = install.npmSpec?.trim();
const clawhubSpec = resolveCandidateClawHubSpec(install);
if (!npmSpec && !clawhubSpec) {
continue;
}
candidates.set(pluginId, {
setDownloadableInstallCandidate({
candidates,
pluginId,
label: resolveOfficialExternalPluginLabel(entry),
...(npmSpec ? { npmSpec } : {}),
...(clawhubSpec ? { clawhubSpec } : {}),
...(install.expectedIntegrity ? { expectedIntegrity: install.expectedIntegrity } : {}),
install,
trustedSourceLinkedOfficialInstall: true,
...(install.defaultChoice ? { defaultChoice: install.defaultChoice } : {}),
});
}
@@ -314,7 +408,7 @@ export function collectUpdateDeferredPluginIds(params: {
return pluginIds;
}
export function collectConfiguredPluginIdsWithMissingChannelConfigDescriptors(params: {
function collectConfiguredPluginIdsWithMissingChannelConfigDescriptors(params: {
snapshot: PluginMetadataSnapshot;
configuredPluginIds: ReadonlySet<string>;
configuredChannelIds: ReadonlySet<string>;
@@ -337,7 +431,7 @@ export function collectConfiguredPluginIdsWithMissingChannelConfigDescriptors(pa
return stalePluginIds;
}
export function collectInstalledPluginIdsWithRepairablePackageDiagnostics(params: {
function collectInstalledPluginIdsWithRepairablePackageDiagnostics(params: {
snapshot: PluginMetadataSnapshot;
installRecords: Record<string, PluginInstallRecord>;
}): Set<string> {
@@ -404,7 +498,7 @@ function betaCompanionMatchesCurrentStableVersion(params: {
return Boolean(installedBase && currentBase && installedBase === currentBase);
}
export function collectInstalledPluginIdsWithStaleVersionBoundRuntimePackages(params: {
function collectInstalledPluginIdsWithStaleVersionBoundRuntimePackages(params: {
snapshot: PluginMetadataSnapshot;
installRecords: Record<string, PluginInstallRecord>;
configuredPluginIds: ReadonlySet<string>;
@@ -464,7 +558,7 @@ function isConfiguredPluginRepairTarget(params: {
return false;
}
export function collectOfficialReplacementInstallCandidates(params: {
function collectOfficialReplacementInstallCandidates(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
repairablePluginIds: ReadonlySet<string>;
@@ -1,28 +1,16 @@
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import type { PluginInstallRecord } from "../../../config/types.plugins.js";
import type { HealthFinding, HealthRepairEffect } from "../../../flows/health-checks.js";
import { resolveCompatibilityHostVersion } from "../../../version.js";
import {
normalizeUpdateChannel,
resolveRegistryUpdateChannel,
} from "../../../infra/update-channels.js";
import { loadInstalledPluginIndexInstallRecords } from "../../../plugins/installed-plugin-index-records.js";
import { loadInstalledPluginIndex } from "../../../plugins/installed-plugin-index.js";
import { loadManifestMetadataSnapshot } from "../../../plugins/manifest-contract-eligibility.js";
import { resolveCompatibilityHostVersion, VERSION } from "../../../version.js";
import {
type BundledPluginPackageDescriptor,
collectDownloadableInstallCandidates,
collectConfiguredPluginIdsWithMissingChannelConfigDescriptors,
collectInstalledPluginIdsWithRepairablePackageDiagnostics,
collectInstalledPluginIdsWithStaleVersionBoundRuntimePackages,
collectOfficialReplacementInstallCandidates,
collectUpdateDeferredPluginIds,
resolveConfiguredPluginInstallContext,
} from "./missing-configured-plugin-install.candidates.js";
import {
collectBlockedPluginIds,
collectConfiguredChannelIds,
collectConfiguredPluginIds,
collectEffectiveConfiguredChannelOwnerPluginIds,
} from "./missing-configured-plugin-install.ids.js";
import {
resolveCandidateInstallSpec,
@@ -103,75 +91,25 @@ export async function detectConfiguredPluginInstallHealthIssues(params: {
const pluginIds = collectConfiguredPluginIds(params.cfg, env);
const channelIds = collectConfiguredChannelIds(params.cfg, env);
const blockedPluginIds = collectBlockedPluginIds(params.cfg);
const snapshot = loadManifestMetadataSnapshot({
config: params.cfg,
env,
});
const currentBundledPlugins = loadInstalledPluginIndex({
config: params.cfg,
env,
installRecords: {},
}).plugins.filter((plugin) => plugin.origin === "bundled");
const knownIds = new Set([
...snapshot.plugins.map((plugin) => plugin.id),
...currentBundledPlugins.map((plugin) => plugin.pluginId),
]);
const configuredChannelOwnerPluginIds = collectEffectiveConfiguredChannelOwnerPluginIds({
cfg: params.cfg,
env,
snapshot,
configuredChannelIds: channelIds,
});
const bundledPluginsById = new Map<string, BundledPluginPackageDescriptor>([
...snapshot.plugins
.filter((plugin) => plugin.origin === "bundled")
.map((plugin) => [plugin.id, plugin] as const),
...currentBundledPlugins.map(
(plugin) =>
[
plugin.pluginId,
{
packageName: plugin.packageName,
},
] as const,
),
]);
const staleDescriptorPluginIds = collectConfiguredPluginIdsWithMissingChannelConfigDescriptors({
snapshot,
configuredPluginIds: pluginIds,
configuredChannelIds: channelIds,
});
const records = params.baselineRecords ?? (await loadInstalledPluginIndexInstallRecords({ env }));
const updateChannel = resolveRegistryUpdateChannel({
configChannel: normalizeUpdateChannel(params.cfg.update?.channel),
currentVersion: VERSION,
});
const repairablePackageDiagnosticPluginIds =
collectInstalledPluginIdsWithRepairablePackageDiagnostics({
snapshot,
installRecords: records,
});
const staleVersionBoundRuntimePluginIds =
collectInstalledPluginIdsWithStaleVersionBoundRuntimePackages({
snapshot,
installRecords: records,
configuredPluginIds: pluginIds,
updateChannel,
});
const repairableInstalledPluginIds = new Set([
...repairablePackageDiagnosticPluginIds,
...staleVersionBoundRuntimePluginIds,
]);
const officialReplacementInstallCandidates = collectOfficialReplacementInstallCandidates({
cfg: params.cfg,
env,
repairablePluginIds: repairableInstalledPluginIds,
configuredPluginIds: pluginIds,
configuredChannelIds: channelIds,
const {
knownIds,
configuredChannelOwnerPluginIds,
bundledPluginsById,
configuredPluginIdsWithStaleDescriptors: staleDescriptorPluginIds,
records,
updateChannel,
installedPluginIdsWithRepairablePackageDiagnostics: repairablePackageDiagnosticPluginIds,
installedPluginIdsWithStaleVersionBoundRuntimePackages: staleVersionBoundRuntimePluginIds,
installedPluginIdsWithRepairablePackages: repairableInstalledPluginIds,
officialReplacementPluginIds,
} = await resolveConfiguredPluginInstallContext({
cfg: params.cfg,
env,
configuredPluginIds: pluginIds,
configuredChannelIds: channelIds,
blockedPluginIds,
baselineRecords: params.baselineRecords,
});
const officialReplacementPluginIds = new Set(officialReplacementInstallCandidates.keys());
const deferredPluginIds = new Set<string>();
const reportedPluginIds = new Set<string>();
const issues: ConfiguredPluginInstallHealthIssue[] = [];
@@ -336,114 +274,83 @@ export async function detectConfiguredPluginInstallHealthIssues(params: {
return issues.toSorted((left, right) => left.pluginId.localeCompare(right.pluginId));
}
const CONFIGURED_PLUGIN_INSTALL_ISSUE_DETAILS = {
"missing-install-record": {
message: (pluginId: string) => `Configured plugin ${pluginId} is not installed.`,
fixHint: "",
action: "would-install-configured-plugin",
dryRunSafe: false,
},
"missing-installed-payload": {
message: (pluginId: string) =>
`Configured plugin ${pluginId} has an install record but its package payload is missing.`,
fixHint: "Run `openclaw doctor --fix` to reinstall the configured plugin package.",
action: "would-reinstall-configured-plugin",
dryRunSafe: false,
},
"repairable-installed-plugin": {
message: (pluginId: string) =>
`Configured plugin ${pluginId} has a repairable package install problem.`,
fixHint: "Run `openclaw doctor --fix` to repair the configured plugin package.",
action: "would-repair-configured-plugin-install",
dryRunSafe: false,
},
"stale-version-bound-runtime": {
message: (pluginId: string) =>
`Configured runtime plugin ${pluginId} is older than this OpenClaw version.`,
fixHint: "Run `openclaw doctor --fix` to refresh the configured runtime plugin.",
action: "would-refresh-configured-runtime-plugin",
dryRunSafe: false,
},
"stale-channel-config-descriptor": {
message: (pluginId: string) =>
`Configured plugin ${pluginId} has stale channel config metadata.`,
fixHint: "Run `openclaw doctor --fix` to repair the configured plugin install metadata.",
action: "would-repair-configured-plugin-install",
dryRunSafe: false,
},
"deferred-package-manager-repair": {
message: (pluginId: string) =>
`Configured plugin ${pluginId} package repair is deferred until the package update finishes.`,
fixHint: "Rerun `openclaw doctor --fix` after the package update completes.",
action: "would-defer-configured-plugin-install-repair",
dryRunSafe: true,
},
} as const satisfies Record<
ConfiguredPluginInstallHealthIssue["kind"],
{
message: (pluginId: string) => string;
fixHint: string;
action: string;
dryRunSafe: boolean;
}
>;
export function configuredPluginInstallIssueToHealthFinding(
issue: ConfiguredPluginInstallHealthIssue,
): HealthFinding {
const target = issue.pluginId;
switch (issue.kind) {
case "missing-install-record":
return {
checkId: CONFIGURED_PLUGIN_INSTALLS_CHECK_ID,
severity: "warning",
message: `Configured plugin ${issue.pluginId} is not installed.`,
target,
fixHint: `Run \`openclaw doctor --fix\` to install ${issue.installSpec}.`,
};
case "missing-installed-payload":
return {
checkId: CONFIGURED_PLUGIN_INSTALLS_CHECK_ID,
severity: "warning",
message: `Configured plugin ${issue.pluginId} has an install record but its package payload is missing.`,
target,
...(issue.installPath ? { path: issue.installPath } : {}),
fixHint: "Run `openclaw doctor --fix` to reinstall the configured plugin package.",
};
case "repairable-installed-plugin":
return {
checkId: CONFIGURED_PLUGIN_INSTALLS_CHECK_ID,
severity: "warning",
message: `Configured plugin ${issue.pluginId} has a repairable package install problem.`,
target,
...(issue.installPath ? { path: issue.installPath } : {}),
fixHint: "Run `openclaw doctor --fix` to repair the configured plugin package.",
};
case "stale-version-bound-runtime":
return {
checkId: CONFIGURED_PLUGIN_INSTALLS_CHECK_ID,
severity: "warning",
message: `Configured runtime plugin ${issue.pluginId} is older than this OpenClaw version.`,
target,
...(issue.installPath ? { path: issue.installPath } : {}),
fixHint: "Run `openclaw doctor --fix` to refresh the configured runtime plugin.",
};
case "stale-channel-config-descriptor":
return {
checkId: CONFIGURED_PLUGIN_INSTALLS_CHECK_ID,
severity: "warning",
message: `Configured plugin ${issue.pluginId} has stale channel config metadata.`,
target,
...(issue.installPath ? { path: issue.installPath } : {}),
fixHint: "Run `openclaw doctor --fix` to repair the configured plugin install metadata.",
};
case "deferred-package-manager-repair":
return {
checkId: CONFIGURED_PLUGIN_INSTALLS_CHECK_ID,
severity: "warning",
message: `Configured plugin ${issue.pluginId} package repair is deferred until the package update finishes.`,
target,
...(issue.installPath ? { path: issue.installPath } : {}),
fixHint: "Rerun `openclaw doctor --fix` after the package update completes.",
};
}
return assertNeverConfiguredPluginInstallIssue(issue);
const detail = CONFIGURED_PLUGIN_INSTALL_ISSUE_DETAILS[issue.kind];
return {
checkId: CONFIGURED_PLUGIN_INSTALLS_CHECK_ID,
severity: "warning",
message: detail.message(issue.pluginId),
target: issue.pluginId,
...("installPath" in issue && issue.installPath ? { path: issue.installPath } : {}),
fixHint:
issue.kind === "missing-install-record"
? `Run \`openclaw doctor --fix\` to install ${issue.installSpec}.`
: detail.fixHint,
};
}
export function configuredPluginInstallIssueToRepairEffect(
issue: ConfiguredPluginInstallHealthIssue,
): HealthRepairEffect {
switch (issue.kind) {
case "missing-install-record":
return {
kind: "package",
action: "would-install-configured-plugin",
target: issue.pluginId,
dryRunSafe: false,
};
case "missing-installed-payload":
return {
kind: "package",
action: "would-reinstall-configured-plugin",
target: issue.pluginId,
dryRunSafe: false,
};
case "repairable-installed-plugin":
case "stale-channel-config-descriptor":
return {
kind: "package",
action: "would-repair-configured-plugin-install",
target: issue.pluginId,
dryRunSafe: false,
};
case "stale-version-bound-runtime":
return {
kind: "package",
action: "would-refresh-configured-runtime-plugin",
target: issue.pluginId,
dryRunSafe: false,
};
case "deferred-package-manager-repair":
return {
kind: "package",
action: "would-defer-configured-plugin-install-repair",
target: issue.pluginId,
dryRunSafe: true,
};
}
return assertNeverConfiguredPluginInstallIssue(issue);
}
function assertNeverConfiguredPluginInstallIssue(issue: never): never {
throw new Error(
`Unhandled configured plugin install issue kind: ${String((issue as { kind?: unknown }).kind)}`,
);
const detail = CONFIGURED_PLUGIN_INSTALL_ISSUE_DETAILS[issue.kind];
return {
kind: "package",
action: detail.action,
target: issue.pluginId,
dryRunSafe: detail.dryRunSafe,
};
}
@@ -2,34 +2,20 @@ import { rm } from "node:fs/promises";
import { stripAnsi } from "../../../../packages/terminal-core/src/ansi.js";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import type { PluginInstallRecord } from "../../../config/types.plugins.js";
import {
normalizeUpdateChannel,
resolveRegistryUpdateChannel,
} from "../../../infra/update-channels.js";
import type { ClawHubRiskAcknowledgementRequest } from "../../../plugins/clawhub.js";
import {
loadInstalledPluginIndexInstallRecords,
writePersistedInstalledPluginIndexInstallRecords,
} from "../../../plugins/installed-plugin-index-records.js";
import { loadInstalledPluginIndex } from "../../../plugins/installed-plugin-index.js";
import { loadManifestMetadataSnapshot } from "../../../plugins/manifest-contract-eligibility.js";
import { writePersistedInstalledPluginIndexInstallRecords } from "../../../plugins/installed-plugin-index-records.js";
import { updateNpmInstalledPlugins } from "../../../plugins/update.js";
import { resolveUserPath } from "../../../utils.js";
import { resolveCompatibilityHostVersion, VERSION } from "../../../version.js";
import { resolveCompatibilityHostVersion } from "../../../version.js";
import {
type BundledPluginPackageDescriptor,
collectConfiguredPluginIdsWithMissingChannelConfigDescriptors,
collectDownloadableInstallCandidates,
collectInstalledPluginIdsWithRepairablePackageDiagnostics,
collectInstalledPluginIdsWithStaleVersionBoundRuntimePackages,
collectOfficialReplacementInstallCandidates,
collectUpdateDeferredPluginIds,
resolveConfiguredPluginInstallContext,
} from "./missing-configured-plugin-install.candidates.js";
import {
collectBlockedPluginIds,
collectConfiguredChannelIds,
collectConfiguredPluginIds,
collectEffectiveConfiguredChannelOwnerPluginIds,
} from "./missing-configured-plugin-install.ids.js";
import {
appendClawHubRiskAcknowledgementGuidance,
@@ -147,76 +133,25 @@ async function repairMissingPluginInstalls(params: {
onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => boolean | Promise<boolean>;
}): Promise<RepairMissingPluginInstallsResult> {
const env = params.env ?? process.env;
const snapshot = loadManifestMetadataSnapshot({
config: params.cfg,
env,
});
const currentBundledPlugins = loadInstalledPluginIndex({
config: params.cfg,
env,
installRecords: {},
}).plugins.filter((plugin) => plugin.origin === "bundled");
const knownIds = new Set([
...snapshot.plugins.map((plugin) => plugin.id),
...currentBundledPlugins.map((plugin) => plugin.pluginId),
]);
const configuredChannelOwnerPluginIds = collectEffectiveConfiguredChannelOwnerPluginIds({
const {
knownIds,
configuredChannelOwnerPluginIds,
bundledPluginsById,
configuredPluginIdsWithStaleDescriptors,
records,
updateChannel,
installedPluginIdsWithRepairablePackageDiagnostics,
installedPluginIdsWithStaleVersionBoundRuntimePackages,
installedPluginIdsWithRepairablePackages,
officialReplacementPluginIds,
} = await resolveConfiguredPluginInstallContext({
cfg: params.cfg,
env,
snapshot,
configuredChannelIds: params.channelIds,
});
const bundledPluginsById = new Map<string, BundledPluginPackageDescriptor>([
...snapshot.plugins
.filter((plugin) => plugin.origin === "bundled")
.map((plugin) => [plugin.id, plugin] as const),
...currentBundledPlugins.map(
(plugin) =>
[
plugin.pluginId,
{
packageName: plugin.packageName,
},
] as const,
),
]);
const configuredPluginIdsWithStaleDescriptors =
collectConfiguredPluginIdsWithMissingChannelConfigDescriptors({
snapshot,
configuredPluginIds: params.pluginIds,
configuredChannelIds: params.channelIds,
});
const records = params.baselineRecords ?? (await loadInstalledPluginIndexInstallRecords({ env }));
const updateChannel = resolveRegistryUpdateChannel({
configChannel: normalizeUpdateChannel(params.cfg.update?.channel),
currentVersion: VERSION,
});
const installedPluginIdsWithRepairablePackageDiagnostics =
collectInstalledPluginIdsWithRepairablePackageDiagnostics({
snapshot,
installRecords: records,
});
const installedPluginIdsWithStaleVersionBoundRuntimePackages =
collectInstalledPluginIdsWithStaleVersionBoundRuntimePackages({
snapshot,
installRecords: records,
configuredPluginIds: params.pluginIds,
updateChannel,
});
const installedPluginIdsWithRepairablePackages = new Set([
...installedPluginIdsWithRepairablePackageDiagnostics,
...installedPluginIdsWithStaleVersionBoundRuntimePackages,
]);
const officialReplacementInstallCandidates = collectOfficialReplacementInstallCandidates({
cfg: params.cfg,
env,
repairablePluginIds: installedPluginIdsWithRepairablePackages,
configuredPluginIds: params.pluginIds,
configuredChannelIds: params.channelIds,
configuredChannelOwnerPluginIds,
blockedPluginIds: params.blockedPluginIds,
baselineRecords: params.baselineRecords,
});
const officialReplacementPluginIds = new Set(officialReplacementInstallCandidates.keys());
const changes: string[] = [];
const notices: string[] = [];
const warnings: string[] = [];
@@ -4,7 +4,7 @@ import os from "node:os";
import path from "node:path";
import { expectDefined } from "@openclaw/normalization-core";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../../config/types.js";
import type { OpenClawConfig, PluginsConfig } from "../../../config/types.js";
import { resolveRegistryUpdateChannel } from "../../../infra/update-channels.js";
import { CLAWHUB_INSTALL_ERROR_CODE } from "../../../plugins/clawhub-error-codes.js";
import {
@@ -1028,52 +1028,6 @@ describe("repairMissingConfiguredPluginInstalls", () => {
]);
});
it("installs missing configured non-channel plugins from the official external catalog", async () => {
mocks.installPluginFromNpmSpec.mockResolvedValueOnce(
successfulInstall({
pluginId: "diagnostics-otel",
npmSpec: "@openclaw/diagnostics-otel",
version: "2026.5.2",
resolution: {
integrity: "sha512-otel",
},
}),
);
mocks.listOfficialExternalPluginCatalogEntries.mockReturnValue([
{
id: "diagnostics-otel",
label: "Diagnostics OpenTelemetry",
install: {
clawhubSpec: "clawhub:@openclaw/diagnostics-otel",
npmSpec: "@openclaw/diagnostics-otel",
defaultChoice: "npm",
},
},
]);
const { repairMissingConfiguredPluginInstalls } =
await import("./missing-configured-plugin-install.js");
const result = await repairMissingConfiguredPluginInstalls({
cfg: {
plugins: {
entries: {
"diagnostics-otel": { enabled: true },
},
},
},
env: {},
});
expect(mocks.installPluginFromClawHub).not.toHaveBeenCalled();
expectRecordFields(mockCallArg(mocks.installPluginFromNpmSpec), {
spec: expectedNpmInstallSpec("@openclaw/diagnostics-otel"),
expectedPluginId: "diagnostics-otel",
});
expect(result.changes).toEqual([
`Installed missing configured plugin "diagnostics-otel" from ${expectedNpmInstallSpec("@openclaw/diagnostics-otel")}.`,
]);
});
it("repairs official plugins at the exact extended-stable core version", async () => {
mocks.installPluginFromNpmSpec.mockResolvedValueOnce(
successfulInstall({
@@ -1117,103 +1071,6 @@ describe("repairMissingConfiguredPluginInstalls", () => {
});
});
it("installs the official llama.cpp plugin for configured local memory embeddings", async () => {
mocks.installPluginFromNpmSpec.mockResolvedValueOnce(
successfulInstall({
pluginId: "llama-cpp",
npmSpec: "@openclaw/llama-cpp-provider",
version: "2026.6.2",
resolution: {
resolvedAt: "2026-06-08T00:00:00.000Z",
},
}),
);
mocks.listOfficialExternalPluginCatalogEntries.mockReturnValue([
{
id: "llama-cpp",
label: "llama.cpp Provider",
openclaw: {
plugin: { id: "llama-cpp", label: "llama.cpp Provider" },
contracts: { embeddingProviders: ["local"] },
install: {
npmSpec: "@openclaw/llama-cpp-provider",
defaultChoice: "npm",
},
},
install: {
npmSpec: "@openclaw/llama-cpp-provider",
defaultChoice: "npm",
},
},
]);
const { repairMissingConfiguredPluginInstalls } =
await import("./missing-configured-plugin-install.js");
const result = await repairMissingConfiguredPluginInstalls({
cfg: {
memory: {
search: {
provider: "local",
},
},
agents: {
defaults: {},
},
},
env: {},
});
expect(mocks.installPluginFromClawHub).not.toHaveBeenCalled();
expectRecordFields(mockCallArg(mocks.installPluginFromNpmSpec), {
spec: expectedNpmInstallSpec("@openclaw/llama-cpp-provider"),
expectedPluginId: "llama-cpp",
});
expect(result.changes).toEqual([
`Installed missing configured plugin "llama-cpp" from ${expectedNpmInstallSpec("@openclaw/llama-cpp-provider")}.`,
]);
});
it("does not let runtime fallback metadata override official catalog install specs", async () => {
mocks.installPluginFromNpmSpec.mockResolvedValueOnce(
successfulInstall({
pluginId: "acpx",
npmSpec: "@openclaw/acpx",
version: "2026.5.2-beta.2",
}),
);
mocks.listOfficialExternalPluginCatalogEntries.mockReturnValue([
{
id: "acpx",
label: "ACPX Runtime",
install: {
npmSpec: "@openclaw/acpx",
defaultChoice: "npm",
},
},
]);
const { repairMissingConfiguredPluginInstalls } =
await import("./missing-configured-plugin-install.js");
const result = await repairMissingConfiguredPluginInstalls({
cfg: {
acp: {
backend: "acpx",
},
},
env: {},
});
expectRecordFields(mockCallArg(mocks.installPluginFromNpmSpec), {
spec: expectedNpmInstallSpec("@openclaw/acpx"),
expectedPluginId: "acpx",
trustedSourceLinkedOfficialInstall: true,
});
expect(result.changes).toEqual([
`Installed missing configured plugin "acpx" from ${expectedNpmInstallSpec("@openclaw/acpx")}.`,
]);
});
it("does not install disabled configured plugin entries", async () => {
mocks.listOfficialExternalPluginCatalogEntries.mockReturnValue([
{
@@ -1251,6 +1108,13 @@ describe("repairMissingConfiguredPluginInstalls", () => {
"disabled configured channel",
{ channels: { matrix: { enabled: false, homeserver: "https://matrix.example.org" } } },
],
[
"matching disabled plugin entry",
{
plugins: { entries: { matrix: { enabled: false } } },
channels: { matrix: { homeserver: "https://matrix.example.org" } },
},
],
])("does not install channel plugins for a %s", async (_label, cfg) => {
mocks.listChannelPluginCatalogEntries.mockReturnValue([
{
@@ -1276,40 +1140,6 @@ describe("repairMissingConfiguredPluginInstalls", () => {
expect(result).toEqual({ changes: [], warnings: [], records: {} });
});
it("does not install channel plugins when the matching plugin entry is disabled", async () => {
mocks.listChannelPluginCatalogEntries.mockReturnValue([
{
id: "matrix",
pluginId: "matrix",
meta: { label: "Matrix" },
install: {
npmSpec: "@openclaw/plugin-matrix@1.2.3",
},
},
]);
const { repairMissingConfiguredPluginInstalls } =
await import("./missing-configured-plugin-install.js");
const result = await repairMissingConfiguredPluginInstalls({
cfg: {
plugins: {
entries: {
matrix: { enabled: false },
},
},
channels: {
matrix: { homeserver: "https://matrix.example.org" },
},
},
env: {},
});
expect(mocks.installPluginFromClawHub).not.toHaveBeenCalled();
expect(mocks.installPluginFromNpmSpec).not.toHaveBeenCalled();
expect(mocks.writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled();
expect(result).toEqual({ changes: [], warnings: [], records: {} });
});
it("does not download configured channel plugins that are still bundled", async () => {
mocks.listChannelPluginCatalogEntries.mockReturnValue([
{
@@ -2985,7 +2815,31 @@ describe("repairMissingConfiguredPluginInstalls", () => {
expect(result).toEqual({ changes: [], warnings: [], records: {} });
});
it("does not install a channel catalog plugin when a configured plugin already owns that channel", async () => {
it.each<{ name: string; plugins: PluginsConfig; installs: boolean }>([
{
name: "does not install a channel catalog plugin when a configured plugin already owns that channel",
plugins: { entries: { "openclaw-lark": { enabled: true } } },
installs: false,
},
{
name: "still installs a channel catalog plugin when the configured owner is blocked by the allowlist",
plugins: {
allow: ["some-other-plugin"],
entries: { "openclaw-lark": { enabled: true } },
},
installs: true,
},
{
name: "still installs a channel catalog plugin when that plugin is explicitly configured",
plugins: {
entries: {
feishu: { enabled: true },
"openclaw-lark": { enabled: true },
},
},
installs: true,
},
])("$name", async ({ plugins, installs }) => {
mocks.loadPluginMetadataSnapshot.mockReturnValue({
plugins: [
{
@@ -3014,167 +2868,35 @@ describe("repairMissingConfiguredPluginInstalls", () => {
trustedSourceLinkedOfficialInstall: true,
},
]);
if (installs) {
mocks.installPluginFromNpmSpec.mockResolvedValueOnce(
successfulInstall({
pluginId: "feishu",
npmSpec: "@openclaw/feishu",
version: "2026.5.2",
}),
);
}
const { repairMissingConfiguredPluginInstalls } =
await import("./missing-configured-plugin-install.js");
const result = await repairMissingConfiguredPluginInstalls({
cfg: {
plugins: {
entries: {
"openclaw-lark": {
enabled: true,
},
},
},
plugins,
channels: {
feishu: {
footer: {
model: false,
},
},
feishu: { footer: { model: false } },
},
},
env: {},
});
expect(mocks.installPluginFromClawHub).not.toHaveBeenCalled();
expect(mocks.installPluginFromNpmSpec).not.toHaveBeenCalled();
expect(mocks.writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled();
expect(result).toEqual({ changes: [], warnings: [], records: {} });
});
it("still installs a channel catalog plugin when the configured owner is blocked by the allowlist", async () => {
mocks.loadPluginMetadataSnapshot.mockReturnValue({
plugins: [
{
id: "openclaw-lark",
origin: "config",
channels: ["feishu"],
channelConfigs: {
feishu: {
schema: {
type: "object",
},
},
},
},
],
diagnostics: [],
});
mocks.listChannelPluginCatalogEntries.mockReturnValue([
{
id: "feishu",
pluginId: "feishu",
meta: { label: "Feishu" },
install: {
npmSpec: "@openclaw/feishu",
},
trustedSourceLinkedOfficialInstall: true,
},
]);
mocks.installPluginFromNpmSpec.mockResolvedValueOnce(
successfulInstall({
pluginId: "feishu",
npmSpec: "@openclaw/feishu",
version: "2026.5.2",
}),
);
const { repairMissingConfiguredPluginInstalls } =
await import("./missing-configured-plugin-install.js");
const result = await repairMissingConfiguredPluginInstalls({
cfg: {
plugins: {
allow: ["some-other-plugin"],
entries: {
"openclaw-lark": {
enabled: true,
},
},
},
channels: {
feishu: {
footer: {
model: false,
},
},
},
},
env: {},
});
expectRecordFields(mockCallArg(mocks.installPluginFromNpmSpec), {
spec: expectedNpmInstallSpec("@openclaw/feishu"),
expectedPluginId: "feishu",
trustedSourceLinkedOfficialInstall: true,
});
expect(result.changes).toEqual([
`Installed missing configured plugin "feishu" from ${expectedNpmInstallSpec("@openclaw/feishu")}.`,
]);
});
it("still installs a channel catalog plugin when that plugin is explicitly configured", async () => {
mocks.loadPluginMetadataSnapshot.mockReturnValue({
plugins: [
{
id: "openclaw-lark",
origin: "config",
channels: ["feishu"],
channelConfigs: {
feishu: {
schema: {
type: "object",
},
},
},
},
],
diagnostics: [],
});
mocks.listChannelPluginCatalogEntries.mockReturnValue([
{
id: "feishu",
pluginId: "feishu",
meta: { label: "Feishu" },
install: {
npmSpec: "@openclaw/feishu",
},
trustedSourceLinkedOfficialInstall: true,
},
]);
mocks.installPluginFromNpmSpec.mockResolvedValueOnce(
successfulInstall({
pluginId: "feishu",
npmSpec: "@openclaw/feishu",
version: "2026.5.2",
}),
);
const { repairMissingConfiguredPluginInstalls } =
await import("./missing-configured-plugin-install.js");
const result = await repairMissingConfiguredPluginInstalls({
cfg: {
plugins: {
entries: {
feishu: {
enabled: true,
},
"openclaw-lark": {
enabled: true,
},
},
},
channels: {
feishu: {
footer: {
model: false,
},
},
},
},
env: {},
});
if (!installs) {
expect(mocks.installPluginFromNpmSpec).not.toHaveBeenCalled();
expect(mocks.writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled();
expect(result).toEqual({ changes: [], warnings: [], records: {} });
return;
}
expectRecordFields(mockCallArg(mocks.installPluginFromNpmSpec), {
spec: expectedNpmInstallSpec("@openclaw/feishu"),
expectedPluginId: "feishu",
@@ -3423,11 +3145,23 @@ describe("repairMissingConfiguredPluginInstalls", () => {
expect(result.warnings).toStrictEqual([]);
});
it("adds actionable acknowledgement guidance for risky persisted ClawHub repair failures", async () => {
it.each([
{
name: "adds actionable acknowledgement guidance for risky persisted ClawHub repair failures",
spec: "clawhub:@openclaw/plugin-demo@1.0.0",
release: "@openclaw/plugin-demo@1.0.0",
expectedSpec: "clawhub:@openclaw/plugin-demo@1.0.0",
},
{
name: "prefixes legacy persisted ClawHub package records in acknowledgement guidance",
release: "@openclaw/plugin-demo@latest",
expectedSpec: "clawhub:@openclaw/plugin-demo",
},
])("$name", async ({ spec, release, expectedSpec }) => {
const records = {
demo: {
source: "clawhub",
spec: "clawhub:@openclaw/plugin-demo@1.0.0",
...(spec ? { spec } : {}),
clawhubPackage: "@openclaw/plugin-demo",
installPath: "/missing/demo",
},
@@ -3441,8 +3175,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
pluginId: "demo",
status: "skipped",
code: CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED,
message:
'Skipped demo ClawHub update: ClawHub release "@openclaw/plugin-demo@1.0.0" has trust warnings. Review the package and rerun with --acknowledge-clawhub-risk to continue. Existing installed plugin left unchanged.',
message: `Skipped demo ClawHub update: ClawHub release "${release}" has trust warnings. Review the package and rerun with --acknowledge-clawhub-risk to continue. Existing installed plugin left unchanged.`,
},
],
});
@@ -3461,48 +3194,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
});
expect(result.warnings[0]).toContain(
"openclaw plugins install clawhub:@openclaw/plugin-demo@1.0.0 --acknowledge-clawhub-risk",
);
});
it("prefixes legacy persisted ClawHub package records in acknowledgement guidance", async () => {
const records = {
demo: {
source: "clawhub",
clawhubPackage: "@openclaw/plugin-demo",
installPath: "/missing/demo",
},
};
mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue(records);
mocks.updateNpmInstalledPlugins.mockResolvedValue({
changed: false,
config: { plugins: { installs: records } },
outcomes: [
{
pluginId: "demo",
status: "skipped",
code: CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED,
message:
'Skipped demo ClawHub update: ClawHub release "@openclaw/plugin-demo@latest" has trust warnings. Review the package and rerun with --acknowledge-clawhub-risk to continue. Existing installed plugin left unchanged.',
},
],
});
const { repairMissingConfiguredPluginInstalls } =
await import("./missing-configured-plugin-install.js");
const result = await repairMissingConfiguredPluginInstalls({
cfg: {
plugins: {
entries: {
demo: { enabled: true },
},
},
},
env: {},
});
expect(result.warnings[0]).toContain(
"openclaw plugins install clawhub:@openclaw/plugin-demo --acknowledge-clawhub-risk",
`openclaw plugins install ${expectedSpec} --acknowledge-clawhub-risk`,
);
});
@@ -4086,6 +3778,60 @@ describe("repairMissingConfiguredPluginInstalls", () => {
});
it.each([
{
name: "installs missing configured non-channel plugins from the official external catalog",
pluginId: "diagnostics-otel",
npmSpec: "@openclaw/diagnostics-otel",
version: "2026.5.2",
entry: {
id: "diagnostics-otel",
label: "Diagnostics OpenTelemetry",
install: {
clawhubSpec: "clawhub:@openclaw/diagnostics-otel",
npmSpec: "@openclaw/diagnostics-otel",
defaultChoice: "npm" as const,
},
},
cfg: { plugins: { entries: { "diagnostics-otel": { enabled: true } } } },
useManifestResolvers: false,
},
{
name: "installs the official llama.cpp plugin for configured local memory embeddings",
pluginId: "llama-cpp",
npmSpec: "@openclaw/llama-cpp-provider",
version: "2026.6.2",
entry: {
id: "llama-cpp",
label: "llama.cpp Provider",
openclaw: {
plugin: { id: "llama-cpp", label: "llama.cpp Provider" },
contracts: { embeddingProviders: ["local"] },
install: {
npmSpec: "@openclaw/llama-cpp-provider",
defaultChoice: "npm" as const,
},
},
install: {
npmSpec: "@openclaw/llama-cpp-provider",
defaultChoice: "npm" as const,
},
},
cfg: { memory: { search: { provider: "local" } }, agents: { defaults: {} } },
useManifestResolvers: false,
},
{
name: "does not let runtime fallback metadata override official catalog install specs",
pluginId: "acpx",
npmSpec: "@openclaw/acpx",
version: "2026.5.2-beta.2",
entry: {
id: "acpx",
label: "ACPX Runtime",
install: { npmSpec: "@openclaw/acpx", defaultChoice: "npm" as const },
},
cfg: { acp: { backend: "acpx" } },
useManifestResolvers: false,
},
{
name: "installs a configured external web search plugin from provider-only config",
pluginId: "brave",
@@ -4174,6 +3920,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
expectedPluginId: pluginId,
trustedSourceLinkedOfficialInstall: true,
});
expect(mocks.installPluginFromClawHub).not.toHaveBeenCalled();
expect(result.changes).toEqual([
`Installed missing configured plugin "${pluginId}" from ${expectedNpmInstallSpec(npmSpec)}.`,
]);
+4 -14
View File
@@ -1,6 +1,7 @@
// Applies immutable path removals to config-like objects.
import { isDeepStrictEqual } from "node:util";
import { expectDefined } from "@openclaw/normalization-core";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { isBlockedObjectKey } from "../infra/prototype-keys.js";
import { parseConfigPathArrayIndex } from "../shared/path-array-index.js";
import type { OpenClawConfig } from "./types.js";
@@ -8,17 +9,6 @@ import type { OpenClawConfig } from "./types.js";
const MANAGED_CONFIG_UNSET_PATHS = [["plugins", "installs"]] as const;
const WRITE_PRUNED_OBJECT = Symbol("write-pruned-object");
function isWritePlainObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function coerceConfig(value: unknown): OpenClawConfig {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
return value as OpenClawConfig;
}
function unsetPathForWriteAt(
value: unknown,
pathSegments: string[],
@@ -53,7 +43,7 @@ function unsetPathForWriteAt(
return { changed: true, value: next };
}
if (isBlockedObjectKey(segment) || !isWritePlainObject(value) || !Object.hasOwn(value, segment)) {
if (isBlockedObjectKey(segment) || !isRecord(value) || !Object.hasOwn(value, segment)) {
return { changed: false, value };
}
if (isLeaf) {
@@ -95,8 +85,8 @@ function unsetPathForWrite(
if (result.value === WRITE_PRUNED_OBJECT) {
return { changed: true, next: {} };
}
if (isWritePlainObject(result.value)) {
return { changed: true, next: coerceConfig(result.value) };
if (isRecord(result.value)) {
return { changed: true, next: result.value as OpenClawConfig };
}
return { changed: false, next: root };
}
+5 -11
View File
@@ -1,4 +1,5 @@
// Detects dangerous config names used by validation and warnings.
import { asNullableRecord } from "@openclaw/normalization-core/record-coerce";
import { asBoolean } from "../utils/boolean.js";
import type { OpenClawConfig } from "./config.js";
@@ -18,13 +19,6 @@ type DangerousNameMatchingResolverInput = {
accountConfig?: DangerousNameMatchingConfig | null | undefined;
};
function asObjectRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
/** Returns true only for the explicit dangerous name-matching opt-in flag. */
export function isDangerousNameMatchingEnabled(
config: DangerousNameMatchingConfig | null | undefined,
@@ -48,12 +42,12 @@ export function collectProviderDangerousNameMatchingScopes(
provider: string,
): ProviderDangerousNameMatchingScope[] {
const scopes: ProviderDangerousNameMatchingScope[] = [];
const channels = asObjectRecord(cfg.channels);
const channels = asNullableRecord(cfg.channels);
if (!channels) {
return scopes;
}
const providerCfg = asObjectRecord(channels[provider]);
const providerCfg = asNullableRecord(channels[provider]);
if (!providerCfg) {
return scopes;
}
@@ -69,13 +63,13 @@ export function collectProviderDangerousNameMatchingScopes(
dangerousFlagPath: providerDangerousFlagPath,
});
const accounts = asObjectRecord(providerCfg.accounts);
const accounts = asNullableRecord(providerCfg.accounts);
if (!accounts) {
return scopes;
}
for (const key of Object.keys(accounts)) {
const account = asObjectRecord(accounts[key]);
const account = asNullableRecord(accounts[key]);
if (!account) {
continue;
}
+4 -7
View File
@@ -1,6 +1,7 @@
// Loads gateway dispatch config from runtime state and files.
import fs from "node:fs";
import path from "node:path";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { parseJsonWithJson5Fallback } from "../utils/parse-json-compat.js";
import { applyConfigEnvVars } from "./config-env-vars.js";
import { resolveConfigEnvVars } from "./env-substitution.js";
@@ -29,15 +30,11 @@ type GatewayDispatchConfigReadOptions = {
logger?: Pick<Console, "warn" | "error">;
};
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function cloneConfigValue(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map((entry) => cloneConfigValue(entry));
}
if (!isPlainRecord(value)) {
if (!isRecord(value)) {
return value;
}
const out: Record<string, unknown> = {};
@@ -48,7 +45,7 @@ function cloneConfigValue(value: unknown): unknown {
}
function projectGatewayDispatchConfig(value: unknown): OpenClawConfig {
if (!isPlainRecord(value)) {
if (!isRecord(value)) {
return {};
}
const projected: Record<string, unknown> = {};
@@ -95,7 +92,7 @@ function resolveIncludesForGatewayDispatch(
}
function resolveGatewayDispatchEnvVars(config: unknown, env: NodeJS.ProcessEnv): unknown {
if (isPlainRecord(config) && Object.hasOwn(config, "env")) {
if (isRecord(config) && Object.hasOwn(config, "env")) {
applyConfigEnvVars(config as OpenClawConfig, env);
}
return resolveConfigEnvVars(config, env, { onMissing: () => undefined });
+2 -5
View File
@@ -2,12 +2,12 @@ import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { asOptionalRecord, isRecord } from "@openclaw/normalization-core/record-coerce";
import JSON5 from "json5";
import { loadDotEnv } from "../infra/dotenv.js";
import { isTruthyEnvValue } from "../infra/env.js";
import { collectErrorGraphCandidates, extractErrorCode } from "../infra/errors.js";
import { resolveRequiredHomeDir } from "../infra/home-dir.js";
import { isRecord } from "../utils.js";
import { parseJsonWithJson5Fallback } from "../utils/parse-json-compat.js";
import {
applyConfigEnvVars,
@@ -59,10 +59,7 @@ export function resolveConfigSnapshotHash(snapshot: {
}
export function coerceConfig(value: unknown): OpenClawConfig {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
return value as OpenClawConfig;
return (asOptionalRecord(value) ?? {}) as OpenClawConfig;
}
export function hasConfigMeta(value: unknown): boolean {
+33 -61
View File
@@ -2,6 +2,11 @@
import { collectConfiguredModelRefs } from "@openclaw/model-catalog-core/configured-model-refs";
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
import { expectDefined } from "@openclaw/normalization-core";
import {
asOptionalObjectRecord,
asOptionalRecord,
isRecord,
} from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
import { listAgentEntries } from "../agents/agent-scope-config.js";
import { collectConfiguredAgentHarnessRuntimes } from "../agents/harness-runtimes.js";
@@ -30,7 +35,6 @@ import {
collectConfiguredWorkerProviderIds,
listBundledWorkerProviderOwners,
} from "../plugins/worker-provider-registry.js";
import { isRecord } from "../utils.js";
import { isChannelConfigured } from "./channel-configured.js";
import { shouldSkipPreferredPluginAutoEnable } from "./plugin-auto-enable.prefer-over.js";
import type {
@@ -342,17 +346,6 @@ function isAutoEnableConfiguredChannelSignal(params: {
return isChannelConfigured(params.cfg, params.channelId, params.env);
}
function hasConfiguredWebSearchPluginEntry(cfg: OpenClawConfig): boolean {
const entries = cfg.plugins?.entries;
return (
Boolean(entries) &&
typeof entries === "object" &&
Object.values(entries).some(
(entry) => isRecord(entry) && isRecord(entry.config) && isRecord(entry.config.webSearch),
)
);
}
function hasConfiguredWebSearchProviderSelection(cfg: OpenClawConfig): boolean {
const provider = cfg.tools?.web?.search?.provider;
return (
@@ -362,27 +355,23 @@ function hasConfiguredWebSearchProviderSelection(cfg: OpenClawConfig): boolean {
);
}
function hasConfiguredWebFetchPluginEntry(cfg: OpenClawConfig): boolean {
const entries = cfg.plugins?.entries;
return (
Boolean(entries) &&
typeof entries === "object" &&
Object.values(entries).some(
(entry) => isRecord(entry) && isRecord(entry.config) && isRecord(entry.config.webFetch),
)
);
}
function hasConfiguredSpeechProviderSelection(cfg: OpenClawConfig): boolean {
return collectConfiguredSpeechProviderIds(cfg).size > 0;
}
function hasConfiguredPluginConfigEntry(cfg: OpenClawConfig): boolean {
const entries = cfg.plugins?.entries;
function hasConfiguredPluginConfigEntry(
cfg: OpenClawConfig,
configKey?: "webSearch" | "webFetch",
): boolean {
const entries = asOptionalObjectRecord(cfg.plugins?.entries);
return (
Boolean(entries) &&
typeof entries === "object" &&
Object.values(entries).some((entry) => isRecord(entry) && isRecord(entry.config))
entries !== undefined &&
Object.values(entries).some(
(entry) =>
isRecord(entry) &&
isRecord(entry.config) &&
(configKey === undefined || isRecord(entry.config[configKey])),
)
);
}
@@ -409,8 +398,8 @@ function hasBrowserToolReference(cfg: OpenClawConfig): boolean {
}
function collectConfiguredPluginEntryIds(cfg: OpenClawConfig): string[] {
const entries = cfg.plugins?.entries;
if (!entries || typeof entries !== "object") {
const entries = asOptionalObjectRecord(cfg.plugins?.entries);
if (!entries) {
return [];
}
return Object.keys(entries)
@@ -419,8 +408,8 @@ function collectConfiguredPluginEntryIds(cfg: OpenClawConfig): string[] {
}
function hasOwnPluginEntry(cfg: OpenClawConfig, pluginId: string): boolean {
const entries = cfg.plugins?.entries;
return Boolean(entries) && typeof entries === "object" && Object.hasOwn(entries, pluginId);
const entries = asOptionalObjectRecord(cfg.plugins?.entries);
return entries !== undefined && Object.hasOwn(entries, pluginId);
}
function isPluginEntryExplicitlyDisabled(cfg: OpenClawConfig, pluginId: string): boolean {
@@ -497,8 +486,8 @@ function hasSetupAutoEnableRelevantConfig(cfg: OpenClawConfig): boolean {
}
function hasPluginEntries(cfg: OpenClawConfig): boolean {
const entries = cfg.plugins?.entries;
return Boolean(entries) && typeof entries === "object" && Object.keys(entries).length > 0;
const entries = asOptionalObjectRecord(cfg.plugins?.entries);
return entries !== undefined && Object.keys(entries).length > 0;
}
function hasPluginAllowlistWithMaterialEntries(cfg: OpenClawConfig): boolean {
@@ -509,8 +498,8 @@ function hasPluginAllowlistWithMaterialEntries(cfg: OpenClawConfig): boolean {
) {
return false;
}
const entries = cfg.plugins?.entries;
if (!entries || typeof entries !== "object") {
const entries = asOptionalObjectRecord(cfg.plugins?.entries);
if (!entries) {
return false;
}
return Object.values(entries).some(hasMaterialPluginEntryConfig);
@@ -605,8 +594,8 @@ export function resolvePluginAutoEnableReadiness(
}
if (
hasConfiguredWebSearchProviderSelection(cfg) ||
hasConfiguredWebSearchPluginEntry(cfg) ||
hasConfiguredWebFetchPluginEntry(cfg)
hasConfiguredPluginConfigEntry(cfg, "webSearch") ||
hasConfiguredPluginConfigEntry(cfg, "webFetch")
) {
return { mayNeedAutoEnable: true, configuredChannelIds };
}
@@ -808,13 +797,7 @@ function isPluginExplicitlyDisabled(cfg: OpenClawConfig, pluginId: string): bool
const builtInChannelId = normalizeChatChannelId(pluginId);
if (builtInChannelId) {
const channels = cfg.channels as Record<string, unknown> | undefined;
const channelConfig = channels?.[builtInChannelId];
if (
channelConfig &&
typeof channelConfig === "object" &&
!Array.isArray(channelConfig) &&
(channelConfig as { enabled?: unknown }).enabled === false
) {
if (asOptionalRecord(channels?.[builtInChannelId])?.enabled === false) {
return true;
}
}
@@ -857,7 +840,7 @@ function disableImplicitPreferredOverPlugin(params: {
entries: {
...params.config.plugins?.entries,
[params.pluginId]: {
...(existingEntry && typeof existingEntry === "object" ? existingEntry : {}),
...asOptionalObjectRecord(existingEntry),
enabled: false,
},
},
@@ -867,13 +850,7 @@ function disableImplicitPreferredOverPlugin(params: {
function isBuiltInChannelAlreadyEnabled(cfg: OpenClawConfig, channelId: string): boolean {
const channels = cfg.channels as Record<string, unknown> | undefined;
const channelConfig = channels?.[channelId];
return (
Boolean(channelConfig) &&
typeof channelConfig === "object" &&
!Array.isArray(channelConfig) &&
(channelConfig as { enabled?: unknown }).enabled === true
);
return asOptionalRecord(channels?.[channelId])?.enabled === true;
}
function resolveAutoEnableChannelId(params: {
@@ -919,17 +896,12 @@ function registerPluginEntry(
const builtInChannelId = resolveAutoEnableChannelId({ entry, manifestRegistry });
if (builtInChannelId) {
const channels = cfg.channels as Record<string, unknown> | undefined;
const existing = channels?.[builtInChannelId];
const existingRecord =
existing && typeof existing === "object" && !Array.isArray(existing)
? (existing as Record<string, unknown>)
: {};
return {
...cfg,
channels: {
...cfg.channels,
[builtInChannelId]: {
...existingRecord,
...asOptionalRecord(channels?.[builtInChannelId]),
enabled: true,
},
},
@@ -983,8 +955,8 @@ function materializeConfiguredPluginEntryAllowlist(params: {
}): OpenClawConfig {
let next = params.config;
const allow = next.plugins?.allow;
const entries = next.plugins?.entries;
if (!Array.isArray(allow) || allow.length === 0 || !entries || typeof entries !== "object") {
const entries = asOptionalObjectRecord(next.plugins?.entries);
if (!Array.isArray(allow) || allow.length === 0 || !entries) {
return next;
}
+14 -25
View File
@@ -10,30 +10,19 @@ import {
mapSensitivePaths,
} from "./schema.hints.js";
import { FIELD_LABELS } from "./schema.labels.js";
import { asSchemaObject, cloneSchema } from "./schema.shared.js";
import {
asSchemaObject,
cloneSchema,
type ConfigJsonSchemaObject as JsonSchemaObject,
} from "./schema.shared.js";
import { applyDerivedTags } from "./schema.tags.js";
import { applyResolvedConfigTierHints } from "./schema.tiers.js";
import { OpenClawSchema } from "./zod-schema.js";
type ConfigSchema = Record<string, unknown>;
type JsonSchemaObject = Record<string, unknown> & {
title?: string;
description?: string;
properties?: Record<string, JsonSchemaObject>;
required?: string[];
additionalProperties?: JsonSchemaObject | boolean;
items?: JsonSchemaObject | JsonSchemaObject[];
anyOf?: JsonSchemaObject[];
oneOf?: JsonSchemaObject[];
allOf?: JsonSchemaObject[];
};
const LEGACY_HIDDEN_PUBLIC_PATHS = ["hooks.internal.handlers"] as const;
const asJsonSchemaObject = (value: unknown): JsonSchemaObject | null =>
asSchemaObject(value) as JsonSchemaObject | null;
/**
* Recursively walk a JSON Schema object and apply field docs using dot-path
* matching. Existing titles/descriptions (for example from Zod metadata) are
@@ -43,7 +32,7 @@ function applyFieldDocumentation(node: JsonSchemaObject, prefixes: readonly stri
const props = node.properties;
if (props) {
for (const [key, child] of Object.entries(props)) {
const childObj = asJsonSchemaObject(child);
const childObj = asSchemaObject(child);
if (!childObj) {
continue;
}
@@ -54,7 +43,7 @@ function applyFieldDocumentation(node: JsonSchemaObject, prefixes: readonly stri
}
// Handle additionalProperties (wildcard keys like "models.providers.*")
if (node.additionalProperties && typeof node.additionalProperties === "object") {
const addObj = asJsonSchemaObject(node.additionalProperties);
const addObj = asSchemaObject(node.additionalProperties);
if (addObj) {
const wildcardPrefixes = prefixes.map((prefix) => (prefix ? `${prefix}.*` : "*"));
applyNodeDocumentation(addObj, wildcardPrefixes);
@@ -64,7 +53,7 @@ function applyFieldDocumentation(node: JsonSchemaObject, prefixes: readonly stri
// Handle array items. Help/labels may use either "[]" notation
// (bindings[].type) or wildcard "*" notation (agents.list.*.skills).
if (node.items) {
const itemsObj = asJsonSchemaObject(node.items);
const itemsObj = asSchemaObject(node.items);
if (itemsObj) {
const itemPrefixes = Array.from(
new Set(
@@ -85,7 +74,7 @@ function applyFieldDocumentation(node: JsonSchemaObject, prefixes: readonly stri
const branches = node[keyword];
if (Array.isArray(branches)) {
for (const branch of branches) {
const branchObj = asJsonSchemaObject(branch);
const branchObj = asSchemaObject(branch);
if (branchObj) {
applyFieldDocumentation(branchObj, prefixes);
}
@@ -118,7 +107,7 @@ type BaseConfigSchemaStablePayload = Omit<BaseConfigSchemaResponse, "generatedAt
function stripChannelSchema(schema: ConfigSchema): ConfigSchema {
const next = cloneSchema(schema);
const root = asJsonSchemaObject(next);
const root = asSchemaObject(next);
if (!root || !root.properties) {
return next;
}
@@ -128,7 +117,7 @@ function stripChannelSchema(schema: ConfigSchema): ConfigSchema {
if (Array.isArray(root.required)) {
root.required = root.required.filter((key) => key !== "$schema");
}
const channelsNode = asJsonSchemaObject(root.properties.channels);
const channelsNode = asSchemaObject(root.properties.channels);
if (channelsNode) {
channelsNode.properties = {};
channelsNode.required = [];
@@ -138,14 +127,14 @@ function stripChannelSchema(schema: ConfigSchema): ConfigSchema {
}
function stripObjectPropertyPath(schema: ConfigSchema, path: readonly string[]): void {
const root = asJsonSchemaObject(schema);
const root = asSchemaObject(schema);
if (!root || path.length === 0) {
return;
}
let current: JsonSchemaObject | null = root;
for (const segment of path.slice(0, -1)) {
current = asJsonSchemaObject(current?.properties?.[segment]);
current = asSchemaObject(current?.properties?.[segment]);
if (!current) {
return;
}
@@ -197,7 +186,7 @@ function computeBaseConfigSchemaStablePayload(): BaseConfigSchemaStablePayload {
unrepresentable: "any",
});
schema.title = "OpenClawConfig";
const schemaRoot = asJsonSchemaObject(schema);
const schemaRoot = asSchemaObject(schema);
if (schemaRoot) {
applyFieldDocumentation(schemaRoot);
}
+15 -13
View File
@@ -1,12 +1,17 @@
// Provides shared JSON schema helpers for generated config metadata.
type JsonSchemaObject = {
import { asNullableRecord } from "@openclaw/normalization-core/record-coerce";
export type ConfigJsonSchemaObject = Record<string, unknown> & {
type?: string | string[];
properties?: Record<string, JsonSchemaObject>;
additionalProperties?: JsonSchemaObject | boolean;
items?: JsonSchemaObject | JsonSchemaObject[];
anyOf?: JsonSchemaObject[];
allOf?: JsonSchemaObject[];
oneOf?: JsonSchemaObject[];
title?: string;
description?: string;
properties?: Record<string, ConfigJsonSchemaObject>;
required?: string[];
additionalProperties?: ConfigJsonSchemaObject | boolean;
items?: ConfigJsonSchemaObject | ConfigJsonSchemaObject[];
anyOf?: ConfigJsonSchemaObject[];
allOf?: ConfigJsonSchemaObject[];
oneOf?: ConfigJsonSchemaObject[];
};
/** Deep-clone schema payloads before callers mutate plugin or base schema fragments. */
@@ -15,15 +20,12 @@ export function cloneSchema<T>(value: T): T {
}
/** Narrow unknown JSON-schema fragments to non-array objects. */
export function asSchemaObject(value: unknown): object | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value;
export function asSchemaObject(value: unknown): ConfigJsonSchemaObject | null {
return asNullableRecord(value);
}
/** Return whether a schema node exposes nested fields through properties, items, or unions. */
export function schemaHasChildren(schema: JsonSchemaObject): boolean {
export function schemaHasChildren(schema: ConfigJsonSchemaObject): boolean {
if (schema.properties && Object.keys(schema.properties).length > 0) {
return true;
}
+48 -77
View File
@@ -1,15 +1,5 @@
import type { ConfigUiHints } from "../shared/config-ui-hints-types.js";
import { asSchemaObject } from "./schema.shared.js";
type JsonSchemaObject = Record<string, unknown> & {
type?: string | string[];
properties?: Record<string, JsonSchemaObject>;
additionalProperties?: JsonSchemaObject | boolean;
items?: JsonSchemaObject | JsonSchemaObject[];
anyOf?: JsonSchemaObject[];
oneOf?: JsonSchemaObject[];
allOf?: JsonSchemaObject[];
};
import { asSchemaObject, type ConfigJsonSchemaObject } from "./schema.shared.js";
const ROOT_TIER_PATHS = `
accessGroups acp agents approvals attachments auth bindings broadcast browser channels
@@ -235,7 +225,7 @@ function createTierMatcher(hints: ConfigUiHints): (path: string) => boolean | un
};
}
function isNumericSchema(schema: JsonSchemaObject): boolean {
function isNumericSchema(schema: ConfigJsonSchemaObject): boolean {
const types = Array.isArray(schema.type) ? schema.type : [schema.type];
return types.includes("number") || types.includes("integer");
}
@@ -256,6 +246,46 @@ function mergeTierHint(hints: ConfigUiHints, path: string, advanced: boolean): v
hints[path] = current ? { ...current, advanced } : { advanced };
}
function visitSchemaNodes<T>(
schema: unknown,
initialState: T,
visit: (node: ConfigJsonSchemaObject, path: string, state: T) => T,
): void {
const visited = new WeakMap<object, Set<string>>();
const walk = (value: unknown, path: string, state: T): void => {
const node = asSchemaObject(value);
if (!node) {
return;
}
const previousPaths = visited.get(node);
if (previousPaths?.has(path)) {
return;
}
if (previousPaths) {
previousPaths.add(path);
} else {
visited.set(node, new Set([path]));
}
const nextState = visit(node, path, state);
for (const [key, child] of Object.entries(node.properties ?? {})) {
walk(child, path ? `${path}.${key}` : key, nextState);
}
if (node.additionalProperties && typeof node.additionalProperties === "object") {
walk(node.additionalProperties, path ? `${path}.*` : "*", nextState);
}
const items = Array.isArray(node.items) ? node.items : node.items ? [node.items] : [];
for (const item of items) {
walk(item, path ? `${path}.*` : "*", nextState);
}
for (const branches of [node.anyOf, node.oneOf, node.allOf]) {
for (const branch of branches ?? []) {
walk(branch, path, nextState);
}
}
};
walk(schema, "", initialState);
}
/** Add authored common/advanced tier boundaries to the base hint map. */
export function applyConfigTierHints(
hints: ConfigUiHints,
@@ -283,21 +313,7 @@ function applyNumericTuningTierHints(
): ConfigUiHints {
const next = { ...hints };
const authoredTier = createTierMatcher(hints);
const visited = new WeakMap<object, Set<string>>();
const visit = (value: unknown, path: string): void => {
const node = asSchemaObject(value) as JsonSchemaObject | null;
if (!node) {
return;
}
const prior = visited.get(node);
if (prior?.has(path)) {
return;
}
if (prior) {
prior.add(path);
} else {
visited.set(node, new Set([path]));
}
visitSchemaNodes(schema, undefined, (node, path) => {
if (
path &&
isNumericSchema(node) &&
@@ -306,23 +322,8 @@ function applyNumericTuningTierHints(
) {
mergeTierHint(next, path, true);
}
for (const [key, child] of Object.entries(node.properties ?? {})) {
visit(child, path ? `${path}.${key}` : key);
}
if (node.additionalProperties && typeof node.additionalProperties === "object") {
visit(node.additionalProperties, path ? `${path}.*` : "*");
}
const items = Array.isArray(node.items) ? node.items : node.items ? [node.items] : [];
for (const item of items) {
visit(item, path ? `${path}.*` : "*");
}
for (const branches of [node.anyOf, node.oneOf, node.allOf]) {
for (const branch of branches ?? []) {
visit(branch, path);
}
}
};
visit(schema, "");
return undefined;
});
return next;
}
@@ -334,22 +335,8 @@ export function applyResolvedConfigTierHints(
const tierHints = applyNumericTuningTierHints(schema, hints);
const next = { ...tierHints };
const matchTier = createTierMatcher(tierHints);
const visited = new WeakMap<object, Set<string>>();
const visit = (value: unknown, path: string, inheritedTier: boolean): void => {
const node = asSchemaObject(value) as JsonSchemaObject | null;
if (!node) {
return;
}
const previousPaths = visited.get(node);
if (previousPaths?.has(path)) {
return;
}
if (previousPaths) {
previousPaths.add(path);
} else {
visited.set(node, new Set([path]));
}
visitSchemaNodes(schema, true, (_node, path, inheritedTier) => {
const advanced = path
? resolveTier({
inheritedTier,
@@ -359,23 +346,7 @@ export function applyResolvedConfigTierHints(
if (path) {
mergeTierHint(next, path, advanced);
}
for (const [key, child] of Object.entries(node.properties ?? {})) {
visit(child, path ? `${path}.${key}` : key, advanced);
}
if (node.additionalProperties && typeof node.additionalProperties === "object") {
visit(node.additionalProperties, path ? `${path}.*` : "*", advanced);
}
const items = Array.isArray(node.items) ? node.items : node.items ? [node.items] : [];
for (const item of items) {
visit(item, path ? `${path}.*` : "*", advanced);
}
for (const branches of [node.anyOf, node.oneOf, node.allOf]) {
for (const branch of branches ?? []) {
visit(branch, path, advanced);
}
}
};
visit(schema, "", true);
return advanced;
});
return next;
}
+15 -28
View File
@@ -11,6 +11,7 @@ import { applySensitiveHints, applySensitiveUrlHints } from "./schema.hints.js";
import {
asSchemaObject,
cloneSchema,
type ConfigJsonSchemaObject as JsonSchemaObject,
findWildcardHintMatch,
schemaHasChildren,
} from "./schema.shared.js";
@@ -21,20 +22,6 @@ type ConfigSchema = Record<string, unknown>;
type JsonSchemaNode = Record<string, unknown>;
type JsonSchemaObject = JsonSchemaNode & {
type?: string | string[];
properties?: Record<string, JsonSchemaObject>;
required?: string[];
additionalProperties?: JsonSchemaObject | boolean;
items?: JsonSchemaObject | JsonSchemaObject[];
anyOf?: JsonSchemaObject[];
oneOf?: JsonSchemaObject[];
allOf?: JsonSchemaObject[];
};
const asJsonSchemaObject = (value: unknown): JsonSchemaObject | null =>
asSchemaObject(value) as JsonSchemaObject | null;
const FORBIDDEN_LOOKUP_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
const LOOKUP_SCHEMA_STRING_KEYS = new Set([
"$id",
@@ -240,7 +227,7 @@ function collectExtensionHintKeys(
};
const collectSchemaKeys = (schema: unknown, basePath: string) => {
const node = asJsonSchemaObject(schema);
const node = asSchemaObject(schema);
if (!node) {
return;
}
@@ -407,14 +394,14 @@ function applyHeartbeatTargetHints(
function applyPluginSchemas(schema: ConfigSchema, plugins: PluginUiMetadata[]): ConfigSchema {
const next = cloneSchema(schema);
const root = asJsonSchemaObject(next);
const pluginsNode = asJsonSchemaObject(root?.properties?.plugins);
const entriesNode = asJsonSchemaObject(pluginsNode?.properties?.entries);
const root = asSchemaObject(next);
const pluginsNode = asSchemaObject(root?.properties?.plugins);
const entriesNode = asSchemaObject(pluginsNode?.properties?.entries);
if (!entriesNode) {
return next;
}
const entryBase = asJsonSchemaObject(entriesNode.additionalProperties);
const entryBase = asSchemaObject(entriesNode.additionalProperties);
const entryProperties = entriesNode.properties ?? {};
entriesNode.properties = entryProperties;
@@ -425,9 +412,9 @@ function applyPluginSchemas(schema: ConfigSchema, plugins: PluginUiMetadata[]):
const entrySchema = entryBase
? cloneSchema(entryBase)
: ({ type: "object" } as JsonSchemaObject);
const entryObject = asJsonSchemaObject(entrySchema) ?? ({ type: "object" } as JsonSchemaObject);
const baseConfigSchema = asJsonSchemaObject(entryObject.properties?.config);
const pluginSchema = asJsonSchemaObject(plugin.configSchema);
const entryObject = asSchemaObject(entrySchema) ?? ({ type: "object" } as JsonSchemaObject);
const baseConfigSchema = asSchemaObject(entryObject.properties?.config);
const pluginSchema = asSchemaObject(plugin.configSchema);
const nextConfigSchema =
baseConfigSchema &&
pluginSchema &&
@@ -448,8 +435,8 @@ function applyPluginSchemas(schema: ConfigSchema, plugins: PluginUiMetadata[]):
function applyChannelSchemas(schema: ConfigSchema, channels: ChannelUiMetadata[]): ConfigSchema {
const next = cloneSchema(schema);
const root = asJsonSchemaObject(next);
const channelsNode = asJsonSchemaObject(root?.properties?.channels);
const root = asSchemaObject(next);
const channelsNode = asSchemaObject(root?.properties?.channels);
if (!channelsNode) {
return next;
}
@@ -460,8 +447,8 @@ function applyChannelSchemas(schema: ConfigSchema, channels: ChannelUiMetadata[]
if (!channel.configSchema) {
continue;
}
const existing = asJsonSchemaObject(channelProps[channel.id]);
const incoming = asJsonSchemaObject(channel.configSchema);
const existing = asSchemaObject(channelProps[channel.id]);
const incoming = asSchemaObject(channel.configSchema);
if (existing && incoming && isObjectSchema(existing) && isObjectSchema(incoming)) {
channelProps[channel.id] = mergeObjectSchema(existing, incoming);
} else {
@@ -677,7 +664,7 @@ function resolveLookupChildSchema(
const properties = schema.properties;
if (properties && Object.hasOwn(properties, segment)) {
return asJsonSchemaObject(properties[segment]);
return asSchemaObject(properties[segment]);
}
const itemIndex = parseConfigPathArrayIndex(segment);
@@ -836,7 +823,7 @@ export function lookupConfigSchema(
return null;
}
let current = asJsonSchemaObject(response.schema);
let current = asSchemaObject(response.schema);
if (!current) {
return null;
}
+12 -24
View File
@@ -1,3 +1,4 @@
import { asNullableObjectRecord, isRecord } from "@openclaw/normalization-core/record-coerce";
import { unsupportedSecretRefSurfacePolicy } from "../secrets/unsupported-surface-policy.js";
import { appendAllowedValuesHint, summarizeAllowedValues } from "./allowed-values.js";
import type { ConfigValidationIssue } from "./types.js";
@@ -16,13 +17,6 @@ type JsonSchemaLike = Record<string, unknown>;
const CUSTOM_EXPECTED_ONE_OF_RE = /expected one of ((?:"[^"]+"(?:\|"?[^"]+"?)*)+)/i;
const SECRETREF_POLICY_DOC_URL = "https://docs.openclaw.ai/reference/secretref-credential-surface";
function toIssueRecord(value: unknown): UnknownIssueRecord | null {
if (!value || typeof value !== "object") {
return null;
}
return value as UnknownIssueRecord;
}
function toConfigPathSegments(path: unknown): ConfigPathSegment[] {
if (!Array.isArray(path)) {
return [];
@@ -48,15 +42,11 @@ export function withConfigIssuePath(
return issue;
}
function asJsonSchemaLike(value: unknown): JsonSchemaLike | null {
return value && typeof value === "object" ? (value as JsonSchemaLike) : null;
}
function lookupJsonSchemaNode(
schema: unknown,
pathSegments: readonly ConfigPathSegment[],
): JsonSchemaLike | null {
let current = asJsonSchemaLike(schema);
let current = asNullableObjectRecord(schema);
for (const segment of pathSegments) {
if (!current) {
return null;
@@ -64,23 +54,23 @@ function lookupJsonSchemaNode(
if (typeof segment === "number") {
const items = current.items;
if (Array.isArray(items)) {
current = asJsonSchemaLike(items[segment] ?? items[0]);
current = asNullableObjectRecord(items[segment] ?? items[0]);
continue;
}
current = asJsonSchemaLike(items);
current = asNullableObjectRecord(items);
continue;
}
const properties = asJsonSchemaLike(current.properties);
const properties = asNullableObjectRecord(current.properties);
const next =
(properties && asJsonSchemaLike(properties[segment])) ||
asJsonSchemaLike(current.additionalProperties);
(properties && asNullableObjectRecord(properties[segment])) ||
asNullableObjectRecord(current.additionalProperties);
current = next;
}
return current;
}
function collectAllowedValuesFromJsonSchemaNode(schema: unknown): AllowedValuesCollection {
const node = asJsonSchemaLike(schema);
const node = asNullableObjectRecord(schema);
if (!node) {
return { values: [], incomplete: false, hasValues: false };
}
@@ -173,7 +163,7 @@ function appendNumericBoundHint(message: string, record: UnknownIssueRecord): st
}
function collectAllowedValuesFromIssue(issue: unknown): AllowedValuesCollection {
const record = toIssueRecord(issue);
const record = asNullableObjectRecord(issue);
if (!record) {
return { values: [], incomplete: false, hasValues: false };
}
@@ -266,7 +256,7 @@ function extractBindingsSpecificUnionIssue(
if (!Array.isArray(errGroup)) {
continue;
}
const branch = errGroup.map(toIssueRecord).filter(Boolean) as UnknownIssueRecord[];
const branch = errGroup.map(asNullableObjectRecord).filter(Boolean) as UnknownIssueRecord[];
if (branch.length === 0) {
continue;
}
@@ -319,7 +309,7 @@ function extractBindingsSpecificUnionIssue(
}
export function mapZodIssueToConfigIssue(issue: unknown): ConfigValidationIssue {
const record = toIssueRecord(issue);
const record = asNullableObjectRecord(issue);
const pathSegments = toConfigPathSegments(record?.path);
const path = formatConfigPath(pathSegments);
const message = typeof record?.message === "string" ? record.message : "Invalid input";
@@ -350,9 +340,7 @@ export function mapZodIssueToConfigIssue(issue: unknown): ConfigValidationIssue
}
function isObjectSecretRefCandidate(value: unknown): boolean {
return Boolean(
value && typeof value === "object" && !Array.isArray(value) && coerceSecretRef(value),
);
return isRecord(value) && Boolean(coerceSecretRef(value));
}
function formatUnsupportedMutableSecretRefMessage(path: string): string {
+141 -429
View File
@@ -273,440 +273,152 @@ describe("applyJobPatch", () => {
expect(job.delivery?.accountId).toBeUndefined();
});
it("persists agentTurn payload.lightContext updates when editing existing jobs", () => {
const job = createIsolatedAgentTurnJob("job-light-context", {
mode: "announce",
channel: "telegram",
});
job.payload = {
kind: "agentTurn",
message: "do it",
lightContext: true,
};
it.each([
{
name: "persists agentTurn payload.lightContext updates when editing existing jobs",
id: "job-light-context",
initial: { lightContext: true },
patch: { lightContext: false },
expected: { lightContext: false },
},
{
name: "persists agentTurn payload.fallbacks updates when editing existing jobs",
id: "job-fallbacks",
initial: { fallbacks: ["openrouter/gpt-4.1-mini"] },
patch: { fallbacks: ["anthropic/claude-haiku-3-5", "openai/gpt-5"] },
expected: { fallbacks: ["anthropic/claude-haiku-3-5", "openai/gpt-5"] },
},
{
name: "clears agentTurn payload.fallbacks when patch requests null",
id: "job-fallbacks-clear",
initial: { fallbacks: ["openrouter/gpt-4.1-mini"] },
patch: { fallbacks: null },
expected: { fallbacks: undefined },
},
{
name: "omits null payload.fallbacks when replacing a non-agent payload",
id: "job-fallbacks-kind-switch",
replace: true,
patch: { fallbacks: null },
expected: { fallbacks: undefined },
},
{
name: "persists agentTurn payload.toolsAllow updates when editing existing jobs",
id: "job-tools",
initial: { toolsAllow: ["exec"] },
patch: { toolsAllow: ["read", "write"] },
expected: { toolsAllow: ["read", "write"] },
},
{
name: "clears the default toolsAllow flag when editing to an explicit restriction",
id: "job-tools-explicit",
initial: { toolsAllow: ["exec", "read"], toolsAllowIsDefault: true },
patch: { toolsAllow: ["read"], toolsAllowIsDefault: true },
expected: { toolsAllow: ["read"], toolsAllowIsDefault: undefined },
},
{
name: "preserves the default toolsAllow flag when a full payload edit keeps the default list",
id: "job-tools-default-edit",
initial: { toolsAllow: ["exec", "read"], toolsAllowIsDefault: true },
patch: { message: "do it later", toolsAllow: ["exec", "read"], toolsAllowIsDefault: true },
expected: { message: "do it later", toolsAllow: ["exec", "read"], toolsAllowIsDefault: true },
},
{
name: "preserves the default toolsAllow flag when a self-edit echoes the default list",
id: "job-tools-default-echo",
initial: { toolsAllow: ["exec", "read"], toolsAllowIsDefault: true },
patch: { message: "do it later", toolsAllow: ["exec", "read"] },
expected: { message: "do it later", toolsAllow: ["exec", "read"], toolsAllowIsDefault: true },
},
{
name: "stores an explicit wildcard when a patch clears agentTurn payload.toolsAllow",
id: "job-tools-clear",
initial: { toolsAllow: ["exec", "read"], toolsAllowIsDefault: true },
patch: { toolsAllow: null },
expected: { toolsAllow: ["*"], toolsAllowIsDefault: undefined },
},
{
name: "clears agentTurn payload.model when patch requests null",
id: "job-model-clear",
initial: { model: "openai/gpt-5.5" },
patch: { model: null },
expected: { message: "do it", model: undefined },
},
{
name: "omits null model when patch builds a replacement agentTurn payload",
id: "job-model-replace",
replaceMain: true,
patch: { model: null },
expected: { message: "do it", model: undefined },
},
{
name: "persists agentTurn payload.thinking updates when editing existing jobs",
id: "job-thinking",
initial: { thinking: "high" },
patch: { thinking: "low" },
expected: { thinking: "low" },
},
{
name: "clears agentTurn payload.thinking when patch requests null",
id: "job-thinking-clear",
initial: { thinking: "high" },
patch: { thinking: null },
expected: { message: "do it", thinking: undefined },
},
{
name: "omits null thinking when patch builds a replacement agentTurn payload",
id: "job-thinking-replace",
replaceMain: true,
patch: { thinking: null },
expected: { message: "do it", thinking: undefined },
},
{
name: "applies payload.lightContext when replacing payload kind via patch",
id: "job-light-context-switch",
replace: true,
patch: { lightContext: true },
expected: { lightContext: true },
},
{
name: "carries payload.fallbacks when replacing payload kind via patch",
id: "job-fallbacks-switch",
replace: true,
patch: { fallbacks: ["anthropic/claude-haiku-3-5", "openai/gpt-5"] },
expected: { fallbacks: ["anthropic/claude-haiku-3-5", "openai/gpt-5"] },
},
{
name: "carries payload.toolsAllow when replacing payload kind via patch",
id: "job-tools-switch",
replace: true,
patch: { toolsAllow: ["exec", "read"] },
expected: { toolsAllow: ["exec", "read"] },
},
{
name: "carries payload.toolsAllow default flag when replacing payload kind via patch",
id: "job-tools-default-switch",
replace: true,
patch: { toolsAllow: ["exec", "read"], toolsAllowIsDefault: true },
expected: { toolsAllow: ["exec", "read"], toolsAllowIsDefault: true },
},
])("$name", ({ id, initial, patch, expected, replace, replaceMain }) => {
const job = replaceMain
? createMainSystemEventJob(id, { mode: "none" })
: createIsolatedAgentTurnJob(id, { mode: "announce", channel: "telegram" });
job.payload =
replace || replaceMain
? { kind: "systemEvent", text: replaceMain ? "ping" : "tick" }
: { kind: "agentTurn", message: "do it", ...initial };
applyJobPatch(job, {
payload: {
kind: "agentTurn",
message: "do it",
lightContext: false,
},
});
...(replaceMain ? { sessionTarget: "isolated" } : {}),
payload: { kind: "agentTurn", message: "do it", ...patch },
} as CronJobPatch);
expect(job.payload.kind).toBe("agentTurn");
if (job.payload.kind === "agentTurn") {
expect(job.payload.lightContext).toBe(false);
}
});
it("persists agentTurn payload.fallbacks updates when editing existing jobs", () => {
const job = createIsolatedAgentTurnJob("job-fallbacks", {
mode: "announce",
channel: "telegram",
});
job.payload = {
kind: "agentTurn",
message: "do it",
fallbacks: ["openrouter/gpt-4.1-mini"],
};
applyJobPatch(job, {
payload: {
kind: "agentTurn",
message: "do it",
fallbacks: ["anthropic/claude-haiku-3-5", "openai/gpt-5"],
},
});
expect(job.payload.kind).toBe("agentTurn");
if (job.payload.kind === "agentTurn") {
expect(job.payload.fallbacks).toEqual(["anthropic/claude-haiku-3-5", "openai/gpt-5"]);
}
});
it("clears agentTurn payload.fallbacks when patch requests null", () => {
const job = createIsolatedAgentTurnJob("job-fallbacks-clear", {
mode: "announce",
channel: "telegram",
});
job.payload = {
kind: "agentTurn",
message: "do it",
fallbacks: ["openrouter/gpt-4.1-mini"],
};
applyJobPatch(job, {
payload: {
kind: "agentTurn",
message: "do it",
fallbacks: null,
},
});
expect(job.payload.kind).toBe("agentTurn");
if (job.payload.kind === "agentTurn") {
expect(job.payload.fallbacks).toBeUndefined();
}
});
it("omits null payload.fallbacks when replacing a non-agent payload", () => {
const job = createIsolatedAgentTurnJob("job-fallbacks-kind-switch", {
mode: "announce",
channel: "telegram",
});
job.payload = { kind: "systemEvent", text: "tick" };
applyJobPatch(job, {
payload: {
kind: "agentTurn",
message: "do it",
fallbacks: null,
},
});
const payload = job.payload as CronJob["payload"];
expect(payload.kind).toBe("agentTurn");
if (payload.kind === "agentTurn") {
expect(payload.fallbacks).toBeUndefined();
}
});
it("persists agentTurn payload.toolsAllow updates when editing existing jobs", () => {
const job = createIsolatedAgentTurnJob("job-tools", {
mode: "announce",
channel: "telegram",
});
job.payload = {
kind: "agentTurn",
message: "do it",
toolsAllow: ["exec"],
};
applyJobPatch(job, {
payload: {
kind: "agentTurn",
message: "do it",
toolsAllow: ["read", "write"],
},
});
expect(job.payload.kind).toBe("agentTurn");
if (job.payload.kind === "agentTurn") {
expect(job.payload.toolsAllow).toEqual(["read", "write"]);
}
});
it("clears the default toolsAllow flag when editing to an explicit restriction", () => {
const job = createIsolatedAgentTurnJob("job-tools-explicit", {
mode: "announce",
channel: "telegram",
});
job.payload = {
kind: "agentTurn",
message: "do it",
toolsAllow: ["exec", "read"],
toolsAllowIsDefault: true,
};
applyJobPatch(job, {
payload: {
kind: "agentTurn",
message: "do it",
toolsAllow: ["read"],
toolsAllowIsDefault: true,
},
});
expect(job.payload.kind).toBe("agentTurn");
if (job.payload.kind === "agentTurn") {
expect(job.payload.toolsAllow).toEqual(["read"]);
expect(job.payload.toolsAllowIsDefault).toBeUndefined();
}
});
it("preserves the default toolsAllow flag when a full payload edit keeps the default list", () => {
const job = createIsolatedAgentTurnJob("job-tools-default-edit", {
mode: "announce",
channel: "telegram",
});
job.payload = {
kind: "agentTurn",
message: "do it",
toolsAllow: ["exec", "read"],
toolsAllowIsDefault: true,
};
applyJobPatch(job, {
payload: {
kind: "agentTurn",
message: "do it later",
toolsAllow: ["exec", "read"],
toolsAllowIsDefault: true,
},
});
expect(job.payload.kind).toBe("agentTurn");
if (job.payload.kind === "agentTurn") {
expect(job.payload.message).toBe("do it later");
expect(job.payload.toolsAllow).toEqual(["exec", "read"]);
expect(job.payload.toolsAllowIsDefault).toBe(true);
}
});
it("preserves the default toolsAllow flag when a self-edit echoes the default list", () => {
const job = createIsolatedAgentTurnJob("job-tools-default-echo", {
mode: "announce",
channel: "telegram",
});
job.payload = {
kind: "agentTurn",
message: "do it",
toolsAllow: ["exec", "read"],
toolsAllowIsDefault: true,
};
applyJobPatch(job, {
payload: {
kind: "agentTurn",
message: "do it later",
toolsAllow: ["exec", "read"],
},
});
expect(job.payload.kind).toBe("agentTurn");
if (job.payload.kind === "agentTurn") {
expect(job.payload.message).toBe("do it later");
expect(job.payload.toolsAllow).toEqual(["exec", "read"]);
expect(job.payload.toolsAllowIsDefault).toBe(true);
}
});
it("stores an explicit wildcard when a patch clears agentTurn payload.toolsAllow", () => {
const job = createIsolatedAgentTurnJob("job-tools-clear", {
mode: "announce",
channel: "telegram",
});
job.payload = {
kind: "agentTurn",
message: "do it",
toolsAllow: ["exec", "read"],
toolsAllowIsDefault: true,
};
applyJobPatch(job, {
payload: {
kind: "agentTurn",
message: "do it",
toolsAllow: null,
},
});
expect(job.payload.kind).toBe("agentTurn");
if (job.payload.kind === "agentTurn") {
expect(job.payload.toolsAllow).toEqual(["*"]);
expect(job.payload.toolsAllowIsDefault).toBeUndefined();
}
});
it("clears agentTurn payload.model when patch requests null", () => {
const job = createIsolatedAgentTurnJob("job-model-clear", {
mode: "announce",
channel: "telegram",
});
job.payload = {
kind: "agentTurn",
message: "do it",
model: "openai/gpt-5.5",
};
applyJobPatch(job, {
payload: {
kind: "agentTurn",
model: null,
},
});
expect(job.payload.kind).toBe("agentTurn");
if (job.payload.kind === "agentTurn") {
expect(job.payload.message).toBe("do it");
expect(job.payload.model).toBeUndefined();
}
});
it("omits null model when patch builds a replacement agentTurn payload", () => {
const job = createMainSystemEventJob("job-model-replace", { mode: "none" });
applyJobPatch(job, {
sessionTarget: "isolated",
payload: {
kind: "agentTurn",
message: "do it",
model: null,
},
});
expect(job.payload.kind).toBe("agentTurn");
if (job.payload.kind === "agentTurn") {
expect(job.payload.message).toBe("do it");
expect(job.payload.model).toBeUndefined();
}
});
it("persists agentTurn payload.thinking updates when editing existing jobs", () => {
const job = createIsolatedAgentTurnJob("job-thinking", {
mode: "announce",
channel: "telegram",
});
job.payload = {
kind: "agentTurn",
message: "do it",
thinking: "high",
};
applyJobPatch(job, {
payload: {
kind: "agentTurn",
message: "do it",
thinking: "low",
},
});
expect(job.payload.kind).toBe("agentTurn");
if (job.payload.kind === "agentTurn") {
expect(job.payload.thinking).toBe("low");
}
});
it("clears agentTurn payload.thinking when patch requests null", () => {
const job = createIsolatedAgentTurnJob("job-thinking-clear", {
mode: "announce",
channel: "telegram",
});
job.payload = {
kind: "agentTurn",
message: "do it",
thinking: "high",
};
applyJobPatch(job, {
payload: {
kind: "agentTurn",
thinking: null,
},
});
expect(job.payload.kind).toBe("agentTurn");
if (job.payload.kind === "agentTurn") {
expect(job.payload.message).toBe("do it");
expect(job.payload.thinking).toBeUndefined();
}
});
it("omits null thinking when patch builds a replacement agentTurn payload", () => {
const job = createMainSystemEventJob("job-thinking-replace", { mode: "none" });
applyJobPatch(job, {
sessionTarget: "isolated",
payload: {
kind: "agentTurn",
message: "do it",
thinking: null,
},
});
expect(job.payload.kind).toBe("agentTurn");
if (job.payload.kind === "agentTurn") {
expect(job.payload.message).toBe("do it");
expect(job.payload.thinking).toBeUndefined();
}
});
it("applies payload.lightContext when replacing payload kind via patch", () => {
const job = createIsolatedAgentTurnJob("job-light-context-switch", {
mode: "announce",
channel: "telegram",
});
job.payload = { kind: "systemEvent", text: "ping" };
applyJobPatch(job, {
payload: {
kind: "agentTurn",
message: "do it",
lightContext: true,
},
});
const payload = job.payload as CronJob["payload"];
expect(payload.kind).toBe("agentTurn");
if (payload.kind === "agentTurn") {
expect(payload.lightContext).toBe(true);
}
});
it("carries payload.fallbacks when replacing payload kind via patch", () => {
const job = createIsolatedAgentTurnJob("job-fallbacks-switch", {
mode: "announce",
channel: "telegram",
});
job.payload = { kind: "systemEvent", text: "ping" };
applyJobPatch(job, {
payload: {
kind: "agentTurn",
message: "do it",
fallbacks: ["anthropic/claude-haiku-3-5", "openai/gpt-5"],
},
});
const payload = job.payload as CronJob["payload"];
expect(payload.kind).toBe("agentTurn");
if (payload.kind === "agentTurn") {
expect(payload.fallbacks).toEqual(["anthropic/claude-haiku-3-5", "openai/gpt-5"]);
}
});
it("carries payload.toolsAllow when replacing payload kind via patch", () => {
const job = createIsolatedAgentTurnJob("job-tools-switch", {
mode: "announce",
channel: "telegram",
});
job.payload = { kind: "systemEvent", text: "ping" };
applyJobPatch(job, {
payload: {
kind: "agentTurn",
message: "do it",
toolsAllow: ["exec", "read"],
},
});
const payload = job.payload as CronJob["payload"];
expect(payload.kind).toBe("agentTurn");
if (payload.kind === "agentTurn") {
expect(payload.toolsAllow).toEqual(["exec", "read"]);
}
});
it("carries payload.toolsAllow default flag when replacing payload kind via patch", () => {
const job = createIsolatedAgentTurnJob("job-tools-default-switch", {
mode: "announce",
channel: "telegram",
});
job.payload = { kind: "systemEvent", text: "ping" };
applyJobPatch(job, {
payload: {
kind: "agentTurn",
message: "do it",
toolsAllow: ["exec", "read"],
toolsAllowIsDefault: true,
},
});
const payload = job.payload as CronJob["payload"];
expect(payload.kind).toBe("agentTurn");
if (payload.kind === "agentTurn") {
expect(payload.toolsAllow).toEqual(["exec", "read"]);
expect(payload.toolsAllowIsDefault).toBe(true);
for (const [key, value] of Object.entries(expected)) {
expect(job.payload[key as keyof typeof job.payload]).toEqual(value);
}
}
});
+24 -47
View File
@@ -453,21 +453,16 @@ function normalizeJobTickState(params: { state: CronServiceState; job: CronJob;
}
if (!isJobEnabled(job)) {
if (job.state.startupCatchupAtMs !== undefined) {
job.state.startupCatchupAtMs = undefined;
changed = true;
}
if (job.state.pacedNextRunAtMs !== undefined) {
job.state.pacedNextRunAtMs = undefined;
changed = true;
}
if (job.state.forcePreservedNextRunAtMs !== undefined) {
job.state.forcePreservedNextRunAtMs = undefined;
changed = true;
}
if (job.state.nextRunAtMs !== undefined) {
job.state.nextRunAtMs = undefined;
changed = true;
for (const key of [
"startupCatchupAtMs",
"pacedNextRunAtMs",
"forcePreservedNextRunAtMs",
"nextRunAtMs",
] as const) {
if (job.state[key] !== undefined) {
job.state[key] = undefined;
changed = true;
}
}
if (
job.state.queuedAtMs !== undefined &&
@@ -611,7 +606,6 @@ function recomputeJobNextRunAtMs(params: {
/** Recomputes missing, due, or repairable next-run timestamps for all schedulable jobs. */
export function recomputeNextRuns(state: CronServiceState): boolean {
return walkSchedulableJobs(state, ({ job, nowMs: now }) => {
let changed = false;
// Only recompute if nextRunAtMs is missing or already past-due.
// Preserving a still-future nextRunAtMs avoids accidentally advancing
// a job that hasn't fired yet (e.g. during restart recovery).
@@ -621,15 +615,11 @@ export function recomputeNextRuns(state: CronServiceState): boolean {
hasScheduledNextRunAtMs(nextRun) &&
job.state.forcePreservedNextRunAtMs === nextRun;
const isDueOrMissing = !hasScheduledNextRunAtMs(nextRun) || now >= nextRun;
if (
return (
!hasForcePreservedNextRun &&
(isDueOrMissing || shouldRepairFutureCronNextRunAtMs({ state, job, nowMs: now }))
) {
if (recomputeJobNextRunAtMs({ state, job, nowMs: now })) {
changed = true;
}
}
return changed;
(isDueOrMissing || shouldRepairFutureCronNextRunAtMs({ state, job, nowMs: now })) &&
recomputeJobNextRunAtMs({ state, job, nowMs: now })
);
});
}
@@ -693,9 +683,7 @@ export function recomputeNextRunsForMaintenance(
}
if (!hasScheduledNextRunAtMs(job.state.nextRunAtMs)) {
if (recomputeJob(job, now)) {
changed = true;
}
changed = recomputeJob(job, now) || changed;
} else if (
repairFutureCronNextRunAtMs &&
!hasPendingStartupCatchup &&
@@ -703,9 +691,7 @@ export function recomputeNextRunsForMaintenance(
!hasForcePreservedNextRun &&
shouldRepairFutureCronNextRunAtMs({ state, job, nowMs: now })
) {
if (recomputeJob(job, now)) {
changed = true;
}
changed = recomputeJob(job, now) || changed;
} else if (
recomputeExpired &&
!hasForcePreservedNextRun &&
@@ -727,9 +713,7 @@ export function recomputeNextRunsForMaintenance(
now < backoffUntilMs &&
job.state.nextRunAtMs < backoffUntilMs;
if (alreadyExecutedSlot || isStaleBackoffSlot) {
if (recomputeJob(job, now)) {
changed = true;
}
changed = recomputeJob(job, now) || changed;
}
}
return changed;
@@ -740,21 +724,14 @@ export function recomputeNextRunsForMaintenance(
/** Returns the next enabled wake timestamp from the in-memory cron store. */
export function nextWakeAtMs(state: CronServiceState) {
const jobs = state.store?.jobs ?? [];
const enabled = jobs.filter(
(j) => isJobEnabled(j) && hasScheduledNextRunAtMs(j.state.nextRunAtMs),
);
if (enabled.length === 0) {
return undefined;
let nextWake: number | undefined;
for (const job of state.store?.jobs ?? []) {
const nextRun = job.state.nextRunAtMs;
if (isJobEnabled(job) && hasScheduledNextRunAtMs(nextRun)) {
nextWake = nextWake === undefined ? nextRun : Math.min(nextWake, nextRun);
}
}
const first = enabled[0]?.state.nextRunAtMs;
if (!hasScheduledNextRunAtMs(first)) {
return undefined;
}
return enabled.reduce((min, j) => {
const next = j.state.nextRunAtMs;
return hasScheduledNextRunAtMs(next) ? Math.min(min, next) : min;
}, first);
return nextWake;
}
/** Applies one canonical server-authored authority envelope to a tool-bearing job. */
+5 -6
View File
@@ -5,7 +5,7 @@ import { readCronJobScratchState, writeCronJobScratch } from "../scratch-store.j
import { createCronStreamSourceIdentity } from "../stream-schedule.js";
import type { CronJob } from "../types.js";
import { failureNotificationDeliveryFromJobState } from "./failure-alerts.js";
import { findJobOrThrow, isJobEnabled, nextWakeAtMs } from "./jobs.js";
import { findJobOrThrow, isJobEnabled, nextWakeAtMs, resolveJobLastRunStatus } from "./jobs.js";
import { sortCronJobs } from "./list-page-sort.js";
import type {
CronJobsEnabledFilter,
@@ -251,10 +251,6 @@ function resolveLastRunStatusFilter(opts?: CronListPageOptions): CronJobsLastRun
return "all";
}
function resolveJobLastRunStatus(job: CronJob): CronJobsLastRunStatusFilter {
return job.state.lastRunStatus ?? job.state.lastStatus ?? "unknown";
}
/** Lists a filtered, sorted, bounded page of cron jobs for CLI/RPC callers. */
export async function listPage(state: CronServiceState, opts?: CronListPageOptions) {
return await locked(state, async () => {
@@ -283,7 +279,10 @@ export async function listPage(state: CronServiceState, opts?: CronListPageOptio
if (scheduleKindFilter !== "all" && job.schedule.kind !== scheduleKindFilter) {
return false;
}
if (lastRunStatusFilter !== "all" && resolveJobLastRunStatus(job) !== lastRunStatusFilter) {
if (
lastRunStatusFilter !== "all" &&
(resolveJobLastRunStatus(job) ?? "unknown") !== lastRunStatusFilter
) {
return false;
}
if (!query) {
+6 -6
View File
@@ -109,11 +109,7 @@ export function armTimer(state: CronServiceState) {
// Intentionally avoid an `async` timer callback:
// Vitest's fake-timer helpers can await async callbacks, which would block
// tests that simulate long-running jobs. Runtime behavior is unchanged.
state.timer = setTimeout(() => {
void onTimer(state).catch((err: unknown) => {
state.deps.log.error({ err: String(err) }, "cron: timer tick failed");
});
}, clampedDelay);
setCronTimer(state, clampedDelay);
state.deps.log.debug(
{ nextAt, delayMs: clampedDelay, clamped: delay > MAX_TIMER_DELAY_MS },
"cron: timer armed",
@@ -127,11 +123,15 @@ function armRunningRecheckTimer(state: CronServiceState) {
if (state.timer) {
clearTimeout(state.timer);
}
setCronTimer(state, MAX_TIMER_DELAY_MS);
}
function setCronTimer(state: CronServiceState, delayMs: number): void {
state.timer = setTimeout(() => {
void onTimer(state).catch((err: unknown) => {
state.deps.log.error({ err: String(err) }, "cron: timer tick failed");
});
}, MAX_TIMER_DELAY_MS);
}, delayMs);
}
/** Handles one cron timer tick under the process-wide root work admission. */
+11 -15
View File
@@ -56,14 +56,6 @@ export function resolveCronNextRunWithLowerBound(params: {
return Math.max(params.naturalNext, params.lowerBoundMs);
}
function resolveRetryConfig() {
return {
maxAttempts: DEFAULT_MAX_TRANSIENT_RETRIES,
backoffMs: DEFAULT_ERROR_BACKOFF_SCHEDULE_MS.slice(0, 3),
retryOn: undefined,
};
}
export function resolveTransientCronRetryDecision(params: {
cronConfig?: CronConfig;
error: string | undefined;
@@ -72,7 +64,6 @@ export function resolveTransientCronRetryDecision(params: {
executionStarted?: boolean;
consecutiveErrors: number | undefined;
}): TransientCronRetryDecision {
const retryConfig = resolveRetryConfig();
if (params.errorClassification?.kind === "permanent") {
return {
retryable: false,
@@ -82,7 +73,7 @@ export function resolveTransientCronRetryDecision(params: {
}
const retryHint = resolveCronExecutionRetryHint({
error: params.error,
retryOn: retryConfig.retryOn,
retryOn: undefined,
classifiedReason:
params.errorClassification?.kind === "reason"
? params.errorClassification.reason
@@ -98,7 +89,7 @@ export function resolveTransientCronRetryDecision(params: {
reason: "permanent error",
};
}
if (consecutiveErrors > retryConfig.maxAttempts) {
if (consecutiveErrors > DEFAULT_MAX_TRANSIENT_RETRIES) {
return {
retryable: false,
consecutiveErrors,
@@ -110,7 +101,10 @@ export function resolveTransientCronRetryDecision(params: {
retryable: true,
consecutiveErrors,
retryCategory: retryHint.category,
backoffMs: errorBackoffMs(consecutiveErrors, retryConfig.backoffMs),
backoffMs: errorBackoffMs(
consecutiveErrors,
DEFAULT_ERROR_BACKOFF_SCHEDULE_MS.slice(0, DEFAULT_MAX_TRANSIENT_RETRIES),
),
reason: "transient retry",
};
}
@@ -119,9 +113,8 @@ export function resolveDisabledHeartbeatOneShotRetryDecision(params: {
cronConfig?: CronConfig;
consecutiveSkipped: number | undefined;
}): DisabledHeartbeatOneShotRetryDecision {
const retryConfig = resolveRetryConfig();
const consecutiveSkipped = params.consecutiveSkipped ?? 0;
if (consecutiveSkipped > retryConfig.maxAttempts) {
if (consecutiveSkipped > DEFAULT_MAX_TRANSIENT_RETRIES) {
return {
retryable: false,
consecutiveSkipped,
@@ -131,7 +124,10 @@ export function resolveDisabledHeartbeatOneShotRetryDecision(params: {
return {
retryable: true,
consecutiveSkipped,
backoffMs: errorBackoffMs(consecutiveSkipped, retryConfig.backoffMs),
backoffMs: errorBackoffMs(
consecutiveSkipped,
DEFAULT_ERROR_BACKOFF_SCHEDULE_MS.slice(0, DEFAULT_MAX_TRANSIENT_RETRIES),
),
reason: "disabled heartbeat retry",
};
}
+74 -85
View File
@@ -987,6 +987,60 @@ export function createGatewayHttpServer(opts: {
return httpServer;
}
function handleBudgetedGatewayWebSocketUpgrade(params: {
req: IncomingMessage;
socket: import("node:stream").Duplex;
head: Buffer;
wss: WebSocketServer;
preauthConnectionBudget: PreauthConnectionBudget;
preauthBudgetKey: string | undefined;
ingressName: "Gateway" | "Worker";
prepareSocket?: (socket: GatewayIngressWebSocket) => void;
}): void {
const { req, socket, head, wss, preauthConnectionBudget, preauthBudgetKey, ingressName } = params;
if (isGatewayWorkAdmissionClosed()) {
writeGatewayUpgradeServiceUnavailable(socket, `${ingressName} websocket admission closed`);
socket.destroy();
return;
}
if (wss.listenerCount("connection") === 0) {
writeGatewayUpgradeServiceUnavailable(socket, `${ingressName} websocket handlers unavailable`);
socket.destroy();
return;
}
if (!preauthConnectionBudget.acquire(preauthBudgetKey)) {
writeGatewayUpgradeServiceUnavailable(socket, "Too many unauthenticated sockets");
socket.destroy();
return;
}
let budgetTransferred = false;
// The upgrade owns its budget until the connection handler explicitly claims the socket.
const releaseUpgradeBudget = () => {
if (!budgetTransferred) {
budgetTransferred = true;
preauthConnectionBudget.release(preauthBudgetKey);
}
};
socket.once("close", releaseUpgradeBudget);
try {
wss.handleUpgrade(req, socket, head, (ws) => {
const ingressSocket = ws as GatewayIngressWebSocket;
ingressSocket["__openclawPreauthBudgetKey"] = preauthBudgetKey;
params.prepareSocket?.(ingressSocket);
wss.emit("connection", ws, req);
if (ingressSocket["__openclawPreauthBudgetClaimed"]) {
budgetTransferred = true;
socket.off("close", releaseUpgradeBudget);
}
});
} catch (error) {
socket.off("close", releaseUpgradeBudget);
releaseUpgradeBudget();
throw error;
}
}
/** Attaches WebSocket and plugin-upgrade routing to an already-created HTTP server. */
export function attachGatewayUpgradeHandler(opts: {
httpServer: HttpServer;
@@ -1104,57 +1158,17 @@ export function attachGatewayUpgradeHandler(opts: {
// Plugin-owned upgrade routes have already had the opportunity to claim the socket.
// Core Gateway upgrades must stop at the HTTP boundary so a client cannot hold an
// untracked pre-connect socket after suspension or restart admission closes.
if (isGatewayWorkAdmissionClosed()) {
writeGatewayUpgradeServiceUnavailable(socket, "Gateway websocket admission closed");
socket.destroy();
return;
}
const preauthBudgetKey = requestClientIp;
if (wss.listenerCount("connection") === 0) {
writeGatewayUpgradeServiceUnavailable(socket, "Gateway websocket handlers unavailable");
socket.destroy();
return;
}
if (!preauthConnectionBudget.acquire(preauthBudgetKey)) {
writeGatewayUpgradeServiceUnavailable(socket, "Too many unauthenticated sockets");
socket.destroy();
return;
}
let budgetTransferred = false;
// The socket owns the preauth budget until the WebSocket connection handler claims it;
// close/error paths release here to avoid leaking unauthenticated connection slots.
const releaseUpgradeBudget = () => {
if (budgetTransferred) {
return;
}
budgetTransferred = true;
preauthConnectionBudget.release(preauthBudgetKey);
};
socket.once("close", releaseUpgradeBudget);
try {
wss.handleUpgrade(req, socket, head, (ws) => {
(
ws as unknown as import("ws").WebSocket & {
__openclawPreauthBudgetClaimed?: boolean;
__openclawPreauthBudgetKey?: string;
}
)["__openclawPreauthBudgetKey"] = preauthBudgetKey;
wss.emit("connection", ws, req);
const budgetClaimed = Boolean(
(
ws as unknown as import("ws").WebSocket & {
__openclawPreauthBudgetClaimed?: boolean;
}
)["__openclawPreauthBudgetClaimed"],
);
if (budgetClaimed) {
budgetTransferred = true;
socket.off("close", releaseUpgradeBudget);
}
handleBudgetedGatewayWebSocketUpgrade({
req,
socket,
head,
wss,
preauthConnectionBudget,
preauthBudgetKey: requestClientIp,
ingressName: "Gateway",
});
} catch {
socket.off("close", releaseUpgradeBudget);
releaseUpgradeBudget();
throw new Error("gateway websocket upgrade failed");
}
}).catch((err: unknown) => {
@@ -1174,46 +1188,21 @@ export function attachWorkerGatewayUpgradeHandler(params: {
log?: { warn: (message: string) => void };
}): void {
params.httpServer.on("upgrade", (req, socket, head) => {
if (isGatewayWorkAdmissionClosed()) {
writeGatewayUpgradeServiceUnavailable(socket, "Worker websocket admission closed");
socket.destroy();
return;
}
const preauthBudgetKey = req.socket.remoteAddress;
if (params.wss.listenerCount("connection") === 0) {
writeGatewayUpgradeServiceUnavailable(socket, "Worker websocket handlers unavailable");
socket.destroy();
return;
}
if (!params.preauthConnectionBudget.acquire(preauthBudgetKey)) {
writeGatewayUpgradeServiceUnavailable(socket, "Too many unauthenticated sockets");
socket.destroy();
return;
}
let budgetTransferred = false;
const releaseUpgradeBudget = () => {
if (budgetTransferred) {
return;
}
budgetTransferred = true;
params.preauthConnectionBudget.release(preauthBudgetKey);
};
socket.once("close", releaseUpgradeBudget);
try {
params.wss.handleUpgrade(req, socket, head, (ws) => {
const workerSocket = ws as GatewayIngressWebSocket;
workerSocket[GATEWAY_WS_CONNECTION_KIND_PROPERTY] = "worker";
workerSocket[GATEWAY_WS_PREAUTH_BUDGET_PROPERTY] = params.preauthConnectionBudget;
workerSocket["__openclawPreauthBudgetKey"] = preauthBudgetKey;
params.wss.emit("connection", ws, req);
if (workerSocket["__openclawPreauthBudgetClaimed"]) {
budgetTransferred = true;
socket.off("close", releaseUpgradeBudget);
}
handleBudgetedGatewayWebSocketUpgrade({
req,
socket,
head,
wss: params.wss,
preauthConnectionBudget: params.preauthConnectionBudget,
preauthBudgetKey: req.socket.remoteAddress,
ingressName: "Worker",
prepareSocket: (workerSocket) => {
workerSocket[GATEWAY_WS_CONNECTION_KIND_PROPERTY] = "worker";
workerSocket[GATEWAY_WS_PREAUTH_BUDGET_PROPERTY] = params.preauthConnectionBudget;
},
});
} catch (error) {
socket.off("close", releaseUpgradeBudget);
releaseUpgradeBudget();
params.log?.warn(
`worker websocket upgrade failed: ${error instanceof Error ? error.message : String(error)}`,
);
@@ -1090,6 +1090,60 @@ async function runNonStreamingChatSend(params: {
return chatCall?.[1] as Record<string, any> | undefined;
}
async function expectUnpersistedAgentRunFinal(params: {
transcriptPrefix: string;
idempotencyKey: string;
payload: (typeof mockState.dispatchedReplies)[number]["payload"];
staleAudio?: boolean;
}) {
const transcriptDir = await createTranscriptFixture(params.transcriptPrefix);
const staleAudioPath = path.join(transcriptDir, "stale.mp3");
mockState.config = { agents: { defaults: { workspace: transcriptDir } } };
mockState.triggerAgentRunStart = true;
mockState.dispatchedReplies = [
{
kind: "final",
payload: {
...params.payload,
...(params.staleAudio
? {
mediaUrl: staleAudioPath,
mediaUrls: [staleAudioPath],
trustedLocalMedia: true,
}
: {}),
},
},
];
const { send } = createChatRequestFixture();
await send({ idempotencyKey: params.idempotencyKey, expectBroadcast: false, waitFor: "dedupe" });
// Agent-run delivery is a live projection; message_end alone owns persisted assistant turns.
expect(findAssistantTranscriptUpdates()).toStrictEqual([]);
expect(
readTranscriptJsonLines(mockState.transcriptPath).filter(
(entry) =>
(entry as { message?: { role?: string } }).message?.role === "assistant" ||
(entry as { role?: string }).role === "assistant",
),
).toStrictEqual([]);
}
async function expectImageOnlyFinal(params: {
transcriptPrefix: string;
idempotencyKey: string;
finalPayload: NonNullable<typeof mockState.finalPayload>;
}) {
await createTranscriptFixture(params.transcriptPrefix);
mockState.finalPayload = params.finalPayload;
const { send } = createChatRequestFixture();
const payload = await send({ idempotencyKey: params.idempotencyKey });
const content = getMessageContent(payload);
expect(getMessage(payload)?.role).toBe("assistant");
expect(content[0]).toEqual({ type: "text", text: "Image reply" });
expect(content[1]).toEqual({ type: "input_image", image_url: "data:image/png;base64,cG5n" });
}
describe("chat directive tag stripping for non-streaming final payloads", () => {
afterEach(() => {
mockState.config = {};
@@ -2111,85 +2165,22 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
});
it("does not mirror agent-run stale media final text from live delivery", async () => {
const transcriptDir = await createTranscriptFixture("openclaw-chat-send-agent-stale-tts-");
const staleAudioPath = path.join(transcriptDir, "stale.mp3");
mockState.config = {
agents: {
defaults: {
workspace: transcriptDir,
},
},
};
mockState.triggerAgentRunStart = true;
mockState.dispatchedReplies = [
{
kind: "final",
payload: {
text: "Text-only test: one clean reply, no TTS, no media, no tool narration.",
mediaUrl: staleAudioPath,
mediaUrls: [staleAudioPath],
trustedLocalMedia: true,
},
},
];
const { send } = createChatRequestFixture();
await send({
await expectUnpersistedAgentRunFinal({
transcriptPrefix: "openclaw-chat-send-agent-stale-tts-",
idempotencyKey: "idem-stale-agent-media",
expectBroadcast: false,
waitFor: "dedupe",
payload: {
text: "Text-only test: one clean reply, no TTS, no media, no tool narration.",
},
staleAudio: true,
});
const assistantUpdates = findAssistantTranscriptUpdates();
// Agent-run delivery is a live projection; message_end owns persisted
// assistant transcript entries, including stale media/text final payloads.
expect(assistantUpdates).toStrictEqual([]);
const transcriptLines = readTranscriptJsonLines(mockState.transcriptPath);
const assistantEntries = transcriptLines.filter(
(entry) =>
(entry as { message?: { role?: string } }).message?.role === "assistant" ||
(entry as { role?: string }).role === "assistant",
);
expect(assistantEntries).toStrictEqual([]);
});
it("does not mirror normal agent-run final text from live delivery", async () => {
const transcriptDir = await createTranscriptFixture("openclaw-chat-send-agent-text-only-");
mockState.config = {
agents: {
defaults: {
workspace: transcriptDir,
},
},
};
mockState.triggerAgentRunStart = true;
mockState.dispatchedReplies = [
{
kind: "final",
payload: {
text: "It's 11:52 AM EDT.",
},
},
];
const { send } = createChatRequestFixture();
await send({
await expectUnpersistedAgentRunFinal({
transcriptPrefix: "openclaw-chat-send-agent-text-only-",
idempotencyKey: "idem-agent-text-only",
expectBroadcast: false,
waitFor: "dedupe",
payload: { text: "It's 11:52 AM EDT." },
});
const assistantUpdates = findAssistantTranscriptUpdates();
// Normal agent-run final text must not be mirrored into JSONL by WebChat;
// The agent runtime persists the model-visible assistant turn from message_end.
expect(assistantUpdates).toStrictEqual([]);
const transcriptLines = readTranscriptJsonLines(mockState.transcriptPath);
const assistantEntries = transcriptLines.filter(
(entry) =>
(entry as { message?: { role?: string } }).message?.role === "assistant" ||
(entry as { role?: string }).role === "assistant",
);
expect(assistantEntries).toStrictEqual([]);
});
it("broadcasts agent-run internal-ui source replies without duplicating transcript", async () => {
@@ -4819,54 +4810,27 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
});
it("preserves media-only final replies in the final broadcast message", async () => {
await createTranscriptFixture("openclaw-chat-send-media-only-final-");
mockState.finalPayload = { mediaUrl: "data:image/png;base64,cG5n" };
const { send } = createChatRequestFixture();
const payload = await send({
await expectImageOnlyFinal({
transcriptPrefix: "openclaw-chat-send-media-only-final-",
idempotencyKey: "idem-media-only-final",
finalPayload: { mediaUrl: "data:image/png;base64,cG5n" },
});
const content = getMessageContent(payload);
expect(getMessage(payload)?.role).toBe("assistant");
expect(content[0]).toEqual({ type: "text", text: "Image reply" });
expect(content[1]).toEqual({ type: "input_image", image_url: "data:image/png;base64,cG5n" });
});
it("strips NO_REPLY from transcript text when final replies only carry media", async () => {
await createTranscriptFixture("openclaw-chat-send-media-only-silent-final-");
mockState.finalPayload = {
text: "NO_REPLY",
mediaUrl: "data:image/png;base64,cG5n",
};
const { send } = createChatRequestFixture();
const payload = await send({
await expectImageOnlyFinal({
transcriptPrefix: "openclaw-chat-send-media-only-silent-final-",
idempotencyKey: "idem-media-only-silent-final",
finalPayload: { text: "NO_REPLY", mediaUrl: "data:image/png;base64,cG5n" },
});
const content = getMessageContent(payload);
expect(getMessage(payload)?.role).toBe("assistant");
expect(content[0]).toEqual({ type: "text", text: "Image reply" });
expect(content[1]).toEqual({ type: "input_image", image_url: "data:image/png;base64,cG5n" });
});
it("preserves reply tags in transcript updates for media replies while stripping them from the broadcast", async () => {
await createTranscriptFixture("openclaw-chat-send-media-reply-tags-");
mockState.finalPayload = {
replyToCurrent: true,
mediaUrl: "data:image/png;base64,cG5n",
};
const { send } = createChatRequestFixture();
const payload = await send({
await expectImageOnlyFinal({
transcriptPrefix: "openclaw-chat-send-media-reply-tags-",
idempotencyKey: "idem-media-reply-tags",
finalPayload: { replyToCurrent: true, mediaUrl: "data:image/png;base64,cG5n" },
});
const content = getMessageContent(payload);
expect(getMessage(payload)?.role).toBe("assistant");
expect(content[0]).toEqual({ type: "text", text: "Image reply" });
expect(content[1]).toEqual({ type: "input_image", image_url: "data:image/png;base64,cG5n" });
const transcriptUpdate = mockState.emittedTranscriptUpdates.find(
(update) =>
typeof update.message === "object" &&
+127 -188
View File
@@ -2802,6 +2802,97 @@ describe("exec approval handlers", () => {
return getRequestedExecApprovalPayload(broadcasts);
}
async function startAcceptedExecApproval(params: {
handlers: ExecApprovalHandlers;
respond: ReturnType<typeof vi.fn>;
context: ReturnType<typeof createExecApprovalFixture>["context"];
request: Record<string, unknown>;
client?: ExecApprovalRequestArgs["client"];
}) {
const requestPromise = requestExecApproval({
handlers: params.handlers,
respond: params.respond,
context: params.context,
params: params.request,
client: params.client,
});
await waitForFast(() => {
expect(params.respond.mock.calls.some((call) => call[1]?.status === "accepted")).toBe(true);
});
return { requestPromise };
}
async function expectRejectedExecApprovalRequest(
params: Record<string, unknown>,
message: string,
) {
const { handlers, respond, context } = createExecApprovalFixture();
await requestExecApproval({ handlers, respond, context, params });
expect(mockCallArg(respond)).toBe(false);
expect(mockCallArg(respond, 0, 1)).toBeUndefined();
expectRecordFields(mockCallArg(respond, 0, 2), { message });
}
async function expectUnavailableAllowAlways(
requestParams: Record<string, unknown>,
fallbackDecision: "allow-once" | "deny",
) {
const { handlers, broadcasts, respond, context } = createExecApprovalFixture();
const requestPromise = requestExecApproval({
handlers,
respond,
context,
params: requestParams,
});
const { id } = await waitForRequestedExecApprovalPayload(broadcasts);
const resolveRespond = vi.fn();
await resolveExecApproval({
handlers,
id,
decision: "allow-always",
respond: resolveRespond,
context,
});
expect(mockCallArg(resolveRespond)).toBe(false);
expect(mockCallArg(resolveRespond, 0, 1)).toBeUndefined();
expectRecordFields(mockCallArg(resolveRespond, 0, 2), {
message: "allow-always is unavailable for this command",
});
const fallbackRespond = vi.fn();
await resolveExecApproval({
handlers,
id,
decision: fallbackDecision,
respond: fallbackRespond,
context,
});
await requestPromise;
expect(fallbackRespond).toHaveBeenCalledWith(true, { ok: true }, undefined);
}
async function expectDroppedApprovalCommandSpans(config?: OpenClawConfig) {
const { handlers, broadcasts, respond, context } = createExecApprovalFixture(
config ? { config } : undefined,
);
await requestExecApproval({
handlers,
respond,
context,
params: {
timeoutMs: 10,
command: "ls | python -c 'print(1)'",
commandSpans: [
{ startIndex: 0, endIndex: 2 },
{ startIndex: 5, endIndex: 11 },
],
},
});
const { request } = getRequestedExecApprovalPayload(broadcasts);
expectRecordFields(request["commandAnalysis"], { commandCount: 1, nestedCommandCount: 0 });
expect(request["commandSpans"]).toBeUndefined();
}
function createForwardingExecApprovalFixture(opts?: {
iosPushDelivery?: {
handleRequested: ReturnType<typeof vi.fn>;
@@ -2879,55 +2970,29 @@ describe("exec approval handlers", () => {
});
it("rejects host=node approval requests without nodeId", async () => {
const { handlers, respond, context } = createExecApprovalFixture();
await requestExecApproval({
handlers,
respond,
context,
params: {
nodeId: undefined,
},
});
expect(mockCallArg(respond)).toBe(false);
expect(mockCallArg(respond, 0, 1)).toBeUndefined();
expectRecordFields(mockCallArg(respond, 0, 2), {
message: "nodeId is required for host=node",
});
await expectRejectedExecApprovalRequest(
{ nodeId: undefined },
"nodeId is required for host=node",
);
});
it("rejects host=node approval requests without systemRunPlan", async () => {
const { handlers, respond, context } = createExecApprovalFixture();
await requestExecApproval({
handlers,
respond,
context,
params: {
systemRunPlan: undefined,
},
});
expect(mockCallArg(respond)).toBe(false);
expect(mockCallArg(respond, 0, 1)).toBeUndefined();
expectRecordFields(mockCallArg(respond, 0, 2), {
message: "systemRunPlan is required for host=node",
});
await expectRejectedExecApprovalRequest(
{ systemRunPlan: undefined },
"systemRunPlan is required for host=node",
);
});
it("rejects whitespace-only approval commands without trimming display text", async () => {
const { handlers, respond, context } = createExecApprovalFixture();
await requestExecApproval({
handlers,
respond,
context,
params: {
await expectRejectedExecApprovalRequest(
{
command: " ",
host: "gateway",
nodeId: undefined,
systemRunPlan: undefined,
},
});
expect(mockCallArg(respond)).toBe(false);
expect(mockCallArg(respond, 0, 1)).toBeUndefined();
expectRecordFields(mockCallArg(respond, 0, 2), { message: "command is required" });
"command is required",
);
});
it("rejects approval requests when the command display would be truncated", async () => {
@@ -3128,11 +3193,11 @@ describe("exec approval handlers", () => {
it("lists pending exec approvals", async () => {
const { handlers, respond, context } = createExecApprovalFixture();
const requestPromise = requestExecApproval({
const { requestPromise } = await startAcceptedExecApproval({
handlers,
respond,
context,
params: {
request: {
id: "approval-list-1",
twoPhase: true,
host: "gateway",
@@ -3140,9 +3205,6 @@ describe("exec approval handlers", () => {
nodeId: undefined,
},
});
await waitForFast(() => {
expect(respond.mock.calls.some((call) => call[1]?.status === "accepted")).toBe(true);
});
const listRespond = vi.fn();
await listExecApprovals({ handlers, respond: listRespond });
@@ -3256,20 +3318,17 @@ describe("exec approval handlers", () => {
scopes: ["operator.approvals"],
});
const requestPromise = requestExecApproval({
const { requestPromise } = await startAcceptedExecApproval({
handlers,
respond,
context,
client: requesterClient,
params: {
request: {
id: "approval-reviewer-untrusted",
twoPhase: true,
approvalReviewerDeviceIds: ["device-ios-reviewer"],
},
});
await waitForFast(() => {
expect(respond.mock.calls.some((call) => call[1]?.status === "accepted")).toBe(true);
});
expect(
manager.getSnapshot("approval-reviewer-untrusted")?.approvalReviewerDeviceIds,
@@ -3317,20 +3376,17 @@ describe("exec approval handlers", () => {
scopes: ["operator.approvals"],
});
const requestPromise = requestExecApproval({
const { requestPromise } = await startAcceptedExecApproval({
handlers,
respond,
context,
client: requesterClient,
params: {
request: {
id: "approval-reviewer-runtime",
twoPhase: true,
approvalReviewerDeviceIds: ["device-ios-reviewer"],
},
});
await waitForFast(() => {
expect(respond.mock.calls.some((call) => call[1]?.status === "accepted")).toBe(true);
});
expect(manager.getSnapshot("approval-reviewer-runtime")?.approvalReviewerDeviceIds).toEqual([
"device-ios-reviewer",
@@ -3389,20 +3445,17 @@ describe("exec approval handlers", () => {
scopes: ["operator.admin"],
});
const requestPromise = requestExecApproval({
const { requestPromise } = await startAcceptedExecApproval({
handlers,
respond,
context,
client: requesterClient,
params: {
request: {
id: "approval-reviewer-runtime-admin",
twoPhase: true,
approvalReviewerDeviceIds: ["device-ios-reviewer"],
},
});
await waitForFast(() => {
expect(respond.mock.calls.some((call) => call[1]?.status === "accepted")).toBe(true);
});
const resolveRespond = vi.fn();
await resolveExecApproval({
@@ -3435,20 +3488,17 @@ describe("exec approval handlers", () => {
approvalRuntime: true,
});
const requestPromise = requestExecApproval({
const { requestPromise } = await startAcceptedExecApproval({
handlers,
respond,
context,
client: requesterClient,
params: {
request: {
id: "approval-reviewer-runtime-runtime",
twoPhase: true,
approvalReviewerDeviceIds: ["device-ios-reviewer"],
},
});
await waitForFast(() => {
expect(respond.mock.calls.some((call) => call[1]?.status === "accepted")).toBe(true);
});
const resolveRespond = vi.fn();
await resolveExecApproval({
@@ -3484,12 +3534,12 @@ describe("exec approval handlers", () => {
approvalRuntime: true,
agentRuntimeIdentity: { agentId: "main", sessionKey: "agent:main:main" },
});
const requestPromise = requestExecApproval({
const { requestPromise } = await startAcceptedExecApproval({
handlers,
respond,
context,
client: requesterClient,
params: {
request: {
id: "approval-auto-review",
twoPhase: true,
systemRunPlan: {
@@ -3498,9 +3548,6 @@ describe("exec approval handlers", () => {
},
},
});
await waitForFast(() => {
expect(respond.mock.calls.some((call) => call[1]?.status === "accepted")).toBe(true);
});
const resolveRespond = vi.fn();
await resolveExecApproval({
@@ -3535,15 +3582,12 @@ describe("exec approval handlers", () => {
approvalRuntime: true,
agentRuntimeIdentity: { agentId: "other", sessionKey: "agent:other:main" },
});
const requestPromise = requestExecApproval({
const { requestPromise } = await startAcceptedExecApproval({
handlers,
respond,
context,
client: requesterClient,
params: { id: "approval-auto-review-mismatch", twoPhase: true },
});
await waitForFast(() => {
expect(respond.mock.calls.some((call) => call[1]?.status === "accepted")).toBe(true);
request: { id: "approval-auto-review-mismatch", twoPhase: true },
});
const resolveRespond = vi.fn();
@@ -3582,20 +3626,17 @@ describe("exec approval handlers", () => {
scopes: ["operator.read"],
});
const requestPromise = requestExecApproval({
const { requestPromise } = await startAcceptedExecApproval({
handlers,
respond,
context,
client: requesterClient,
params: {
request: {
id: "approval-reviewer-runtime-no-scope",
twoPhase: true,
approvalReviewerDeviceIds: ["device-ios-reviewer"],
},
});
await waitForFast(() => {
expect(respond.mock.calls.some((call) => call[1]?.status === "accepted")).toBe(true);
});
const resolveRespond = vi.fn();
await resolveExecApproval({
@@ -3738,84 +3779,14 @@ describe("exec approval handlers", () => {
});
it("rejects allow-always when the request ask mode is always", async () => {
const { handlers, broadcasts, respond, context } = createExecApprovalFixture();
const requestPromise = requestExecApproval({
handlers,
respond,
context,
params: { twoPhase: true, ask: "always" },
});
const { id } = await waitForRequestedExecApprovalPayload(broadcasts);
const resolveRespond = vi.fn();
await resolveExecApproval({
handlers,
id,
decision: "allow-always",
respond: resolveRespond,
context,
});
expect(mockCallArg(resolveRespond)).toBe(false);
expect(mockCallArg(resolveRespond, 0, 1)).toBeUndefined();
expectRecordFields(mockCallArg(resolveRespond, 0, 2), {
message: "allow-always is unavailable for this command",
});
const denyRespond = vi.fn();
await resolveExecApproval({
handlers,
id,
decision: "deny",
respond: denyRespond,
context,
});
await requestPromise;
expect(denyRespond).toHaveBeenCalledWith(true, { ok: true }, undefined);
await expectUnavailableAllowAlways({ twoPhase: true, ask: "always" }, "deny");
});
it("rejects allow-always when the request marks it unavailable", async () => {
const { handlers, broadcasts, respond, context } = createExecApprovalFixture();
const requestPromise = requestExecApproval({
handlers,
respond,
context,
params: {
twoPhase: true,
unavailableDecisions: ["allow-always"],
},
});
const { id } = await waitForRequestedExecApprovalPayload(broadcasts);
const resolveRespond = vi.fn();
await resolveExecApproval({
handlers,
id,
decision: "allow-always",
respond: resolveRespond,
context,
});
expect(mockCallArg(resolveRespond)).toBe(false);
expect(mockCallArg(resolveRespond, 0, 1)).toBeUndefined();
expectRecordFields(mockCallArg(resolveRespond, 0, 2), {
message: "allow-always is unavailable for this command",
});
const allowOnceRespond = vi.fn();
await resolveExecApproval({
handlers,
id,
decision: "allow-once",
respond: allowOnceRespond,
context,
});
await requestPromise;
expect(allowOnceRespond).toHaveBeenCalledWith(true, { ok: true }, undefined);
await expectUnavailableAllowAlways(
{ twoPhase: true, unavailableDecisions: ["allow-always"] },
"allow-once",
);
});
it("keeps baseline decisions available when allow-always is unavailable", async () => {
@@ -4093,45 +4064,13 @@ describe("exec approval handlers", () => {
});
it("drops command spans by default", async () => {
const { handlers, broadcasts, respond, context } = createExecApprovalFixture();
await requestExecApproval({
handlers,
respond,
context,
params: {
timeoutMs: 10,
command: "ls | python -c 'print(1)'",
commandSpans: [
{ startIndex: 0, endIndex: 2 },
{ startIndex: 5, endIndex: 11 },
],
},
});
const { request } = getRequestedExecApprovalPayload(broadcasts);
expectRecordFields(request["commandAnalysis"], { commandCount: 1, nestedCommandCount: 0 });
expect(request["commandSpans"]).toBeUndefined();
await expectDroppedApprovalCommandSpans();
});
it("drops command spans when command highlighting is disabled", async () => {
const { handlers, broadcasts, respond, context } = createExecApprovalFixture({
config: { tools: { exec: { commandHighlighting: false } } },
await expectDroppedApprovalCommandSpans({
tools: { exec: { commandHighlighting: false } },
});
await requestExecApproval({
handlers,
respond,
context,
params: {
timeoutMs: 10,
command: "ls | python -c 'print(1)'",
commandSpans: [
{ startIndex: 0, endIndex: 2 },
{ startIndex: 5, endIndex: 11 },
],
},
});
const { request } = getRequestedExecApprovalPayload(broadcasts);
expectRecordFields(request["commandAnalysis"], { commandCount: 1, nestedCommandCount: 0 });
expect(request["commandSpans"]).toBeUndefined();
});
it("drops command spans when command display sanitization changes offsets", async () => {
+144 -159
View File
@@ -20,9 +20,105 @@ import {
loadAccessorSessionEntryForGatewayTarget,
requireSessionKey,
} from "./sessions-shared.js";
import type { GatewayRequestHandlers } from "./types.js";
import type { GatewayRequestContext, GatewayRequestHandlers, RespondFn } from "./types.js";
import { assertValidParams } from "./validation.js";
function respondInvalidWorkerSession(respond: RespondFn, message: string): void {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, message));
}
function resolveWorkerSessionTarget(params: {
key: string;
agentId?: string;
profileId?: string;
context: GatewayRequestContext;
respond: RespondFn;
}) {
const cfg = params.context.getRuntimeConfig();
const requestedAgent = resolveRequestedGlobalAgentId(cfg, params.key, params.agentId);
if (!requestedAgent.ok) {
params.respond(false, undefined, requestedAgent.error);
return undefined;
}
if (
params.profileId !== undefined &&
!Object.hasOwn(cfg.cloudWorkers?.profiles ?? {}, params.profileId)
) {
respondInvalidWorkerSession(
params.respond,
`cloud worker profile is not configured: ${params.profileId}`,
);
return undefined;
}
const target = loadAccessorSessionEntryForGatewayTarget({
key: params.key,
cfg,
agentId: requestedAgent.agentId,
});
const entry = target.entry;
const sessionId = normalizeOptionalString(entry?.sessionId);
if (!entry || !sessionId) {
respondInvalidWorkerSession(params.respond, `session not found: ${params.key}`);
return undefined;
}
return { cfg, target, entry, sessionId };
}
function hasManagedSessionWorktree(params: {
entry: NonNullable<ReturnType<typeof loadAccessorSessionEntryForGatewayTarget>["entry"]>;
sessionKey: string;
method: "sessions.dispatch" | "sessions.reclaim";
respond: RespondFn;
}): boolean {
const worktree = managedWorktrees.findLiveByOwner("session", params.sessionKey);
if (
params.entry.worktree?.id &&
worktree &&
worktree.id === params.entry.worktree.id &&
worktree.ownerId === params.sessionKey
) {
return true;
}
const article = params.method === "sessions.dispatch" ? "a" : "the";
respondInvalidWorkerSession(
params.respond,
`${params.method} requires ${article} session-owned managed worktree`,
);
return false;
}
function respondWorkerPlacement(params: {
respond: RespondFn;
key: string;
sessionId: string;
placement: Parameters<typeof projectWorkerSessionPlacement>[0];
}): void {
params.respond(
true,
{
ok: true,
key: params.key,
sessionId: params.sessionId,
placement: projectWorkerSessionPlacement(params.placement),
},
undefined,
);
}
function respondWorkerDispatchError(error: unknown, respond: RespondFn): void {
if (error instanceof SessionMutationAuthorizationChangedError) {
throw error;
}
respond(
false,
undefined,
errorShape(
isWorkerDispatchInputError(error) ? ErrorCodes.INVALID_REQUEST : ErrorCodes.UNAVAILABLE,
formatErrorMessage(error),
),
);
}
export const sessionDispatchHandlers: GatewayRequestHandlers = {
"sessions.dispatch": async ({ params, respond, context, sessionMutationAuthorization }) => {
if (!assertValidParams(params, validateSessionsDispatchParams, "sessions.dispatch", respond)) {
@@ -35,51 +131,22 @@ export const sessionDispatchHandlers: GatewayRequestHandlers = {
const dispatchService = context.workerPlacementDispatchService;
const placementReader = context.workerSessionPlacementService;
if (!dispatchService || !placementReader) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "cloud worker dispatch is not configured"),
);
respondInvalidWorkerSession(respond, "cloud worker dispatch is not configured");
return;
}
const cfg = context.getRuntimeConfig();
const requestedAgent = resolveRequestedGlobalAgentId(cfg, key, params.agentId);
if (!requestedAgent.ok) {
respond(false, undefined, requestedAgent.error);
return;
}
if (!Object.hasOwn(cfg.cloudWorkers?.profiles ?? {}, params.profileId)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`cloud worker profile is not configured: ${params.profileId}`,
),
);
return;
}
const target = loadAccessorSessionEntryForGatewayTarget({
const resolved = resolveWorkerSessionTarget({
key,
cfg,
agentId: requestedAgent.agentId,
agentId: params.agentId,
profileId: params.profileId,
context,
respond,
});
const entry = target.entry;
const sessionId = normalizeOptionalString(entry?.sessionId);
if (!entry || !sessionId) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `session not found: ${key}`),
);
if (!resolved) {
return;
}
const { cfg, target, entry, sessionId } = resolved;
if (entry.archivedAt !== undefined) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "cannot dispatch an archived session"),
);
respondInvalidWorkerSession(respond, "cannot dispatch an archived session");
return;
}
const sessionRuntime = resolveWorkerPlacementSessionRuntime({
@@ -89,13 +156,9 @@ export const sessionDispatchHandlers: GatewayRequestHandlers = {
sessionKey: target.canonicalKey,
});
if (!isWorkerPlacementSessionRuntimeSupported(sessionRuntime)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`cloud worker dispatch requires the OpenClaw runtime, not ${sessionRuntime}`,
),
respondInvalidWorkerSession(
respond,
`cloud worker dispatch requires the OpenClaw runtime, not ${sessionRuntime}`,
);
return;
}
@@ -105,31 +168,20 @@ export const sessionDispatchHandlers: GatewayRequestHandlers = {
existingPlacement.state !== "local" &&
existingPlacement.state !== "reclaimed"
) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`session cannot dispatch from placement ${existingPlacement.state}`,
),
respondInvalidWorkerSession(
respond,
`session cannot dispatch from placement ${existingPlacement.state}`,
);
return;
}
const worktree = managedWorktrees.findLiveByOwner("session", target.canonicalKey);
if (
!target.entry?.worktree?.id ||
!worktree ||
worktree.id !== target.entry.worktree.id ||
worktree.ownerId !== target.canonicalKey
!hasManagedSessionWorktree({
entry,
sessionKey: target.canonicalKey,
method: "sessions.dispatch",
respond,
})
) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"sessions.dispatch requires a session-owned managed worktree",
),
);
return;
}
try {
@@ -142,28 +194,9 @@ export const sessionDispatchHandlers: GatewayRequestHandlers = {
agentId: target.target.agentId,
profileId: params.profileId,
});
respond(
true,
{
ok: true,
key: target.canonicalKey,
sessionId,
placement: projectWorkerSessionPlacement(placement),
},
undefined,
);
respondWorkerPlacement({ respond, key: target.canonicalKey, sessionId, placement });
} catch (error) {
if (error instanceof SessionMutationAuthorizationChangedError) {
throw error;
}
respond(
false,
undefined,
errorShape(
isWorkerDispatchInputError(error) ? ErrorCodes.INVALID_REQUEST : ErrorCodes.UNAVAILABLE,
formatErrorMessage(error),
),
);
respondWorkerDispatchError(error, respond);
}
},
"sessions.reclaim": async ({ params, respond, context, sessionMutationAuthorization }) => {
@@ -177,73 +210,44 @@ export const sessionDispatchHandlers: GatewayRequestHandlers = {
const placementService = context.workerPlacementDispatchService;
const placementReader = context.workerSessionPlacementService;
if (!placementService?.reclaim || !placementReader) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "cloud worker stop is not configured"),
);
respondInvalidWorkerSession(respond, "cloud worker stop is not configured");
return;
}
const cfg = context.getRuntimeConfig();
const requestedAgent = resolveRequestedGlobalAgentId(cfg, key, params.agentId);
if (!requestedAgent.ok) {
respond(false, undefined, requestedAgent.error);
return;
}
const target = loadAccessorSessionEntryForGatewayTarget({
const resolved = resolveWorkerSessionTarget({
key,
cfg,
agentId: requestedAgent.agentId,
agentId: params.agentId,
context,
respond,
});
const sessionId = normalizeOptionalString(target.entry?.sessionId);
if (!target.entry || !sessionId) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `session not found: ${key}`),
);
if (!resolved) {
return;
}
const { target, entry, sessionId } = resolved;
const existingPlacement = placementReader.getMany([sessionId]).get(sessionId);
if (existingPlacement?.state === "reclaimed") {
respond(
true,
{
ok: true,
key: target.canonicalKey,
sessionId,
placement: projectWorkerSessionPlacement(existingPlacement),
},
undefined,
);
respondWorkerPlacement({
respond,
key: target.canonicalKey,
sessionId,
placement: existingPlacement,
});
return;
}
if (existingPlacement?.state !== "active") {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`session cannot stop cloud worker from placement ${existingPlacement?.state ?? "local"}`,
),
respondInvalidWorkerSession(
respond,
`session cannot stop cloud worker from placement ${existingPlacement?.state ?? "local"}`,
);
return;
}
const worktree = managedWorktrees.findLiveByOwner("session", target.canonicalKey);
if (
!target.entry.worktree?.id ||
!worktree ||
worktree.id !== target.entry.worktree.id ||
worktree.ownerId !== target.canonicalKey
!hasManagedSessionWorktree({
entry,
sessionKey: target.canonicalKey,
method: "sessions.reclaim",
respond,
})
) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"sessions.reclaim requires the session-owned managed worktree",
),
);
return;
}
try {
@@ -253,28 +257,9 @@ export const sessionDispatchHandlers: GatewayRequestHandlers = {
sessionKey: target.canonicalKey,
agentId: target.target.agentId,
});
respond(
true,
{
ok: true,
key: target.canonicalKey,
sessionId,
placement: projectWorkerSessionPlacement(placement),
},
undefined,
);
respondWorkerPlacement({ respond, key: target.canonicalKey, sessionId, placement });
} catch (error) {
if (error instanceof SessionMutationAuthorizationChangedError) {
throw error;
}
respond(
false,
undefined,
errorShape(
isWorkerDispatchInputError(error) ? ErrorCodes.INVALID_REQUEST : ErrorCodes.UNAVAILABLE,
formatErrorMessage(error),
),
);
respondWorkerDispatchError(error, respond);
}
},
};
+2 -2
View File
@@ -23,7 +23,7 @@ import {
writeSessionCostUsageRollup,
} from "./session-cost-usage-cache.sqlite.js";
import {
listUsageCountedTranscriptFiles,
listUsageCountedTranscriptStats,
resolveUsageCostTranscriptFile,
type UsageCostTranscriptFile,
} from "./session-cost-usage-collection.js";
@@ -590,7 +590,7 @@ export async function refreshCostUsageCacheForAgent(params: {
const rows = readSessionCostUsageRollupRows(params.agentId, databasePath);
const rawValues = new Map(rows.map((row) => [row.key, row.valueJson]));
const rollups = readUsageCostRollups(params.agentId, pricingFingerprint, databasePath, rows);
const discoveredFiles = await listUsageCountedTranscriptFiles(
const discoveredFiles = await listUsageCountedTranscriptStats(
params.agentId,
params.sessionsDir ? { sessionsDir: params.sessionsDir } : undefined,
);
@@ -16,7 +16,7 @@ import {
} from "./session-cost-usage-aggregation.js";
import { isSessionCostUsageRefreshRunning } from "./session-cost-usage-cache.sqlite.js";
import {
listUsageCountedTranscriptFiles,
listUsageCountedTranscriptStats,
resolveUsageCostTranscriptFile,
USAGE_COST_TRANSCRIPT_STAT_CONCURRENCY,
} from "./session-cost-usage-collection.js";
@@ -72,7 +72,7 @@ export async function loadCostUsageSummary(params: {
});
const pricingFingerprint = resolveUsageCostPricingFingerprint(params.config, agentDir);
const rollups = readUsageCostRollups(params.agentId, pricingFingerprint, databasePath);
const files = await listUsageCountedTranscriptFiles(params.agentId);
const files = await listUsageCountedTranscriptStats(params.agentId);
return buildCostUsageSummaryFromRollups({
rollups,
files,
@@ -99,7 +99,7 @@ export async function loadCostUsageSummaryFromCache(params: {
const databasePath = resolveUsageCostCacheDatabasePath(params.agentId);
const pricingFingerprint = resolveUsageCostPricingFingerprint(params.config, agentDir);
let rollups = readUsageCostRollups(params.agentId, pricingFingerprint, databasePath);
let files = await listUsageCountedTranscriptFiles(params.agentId);
let files = await listUsageCountedTranscriptStats(params.agentId);
const staleFiles = getUsageCostStaleRollupFiles({ rollups, files });
if (params.requestRefresh !== false && staleFiles.length > 0) {
const cachedFiles = countUsableUsageCostRollups({ rollups, files });
@@ -111,7 +111,7 @@ export async function loadCostUsageSummaryFromCache(params: {
startMs: params.startMs,
});
rollups = readUsageCostRollups(params.agentId, pricingFingerprint, databasePath);
files = await listUsageCountedTranscriptFiles(params.agentId);
files = await listUsageCountedTranscriptStats(params.agentId);
if (result === "refreshed" && getUsageCostStaleRollupFiles({ rollups, files }).length > 0) {
requestCostUsageCacheRefresh({ config: params.config, agentId: params.agentId });
}
+17 -96
View File
@@ -1,6 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import readline from "node:readline";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import {
materializeSessionArchiveForRead,
@@ -29,18 +29,12 @@ import {
readTranscriptStatsSync,
} from "../config/sessions/session-accessor.js";
import { resolveSqliteTargetFromSessionStorePath } from "../config/sessions/session-sqlite-target.js";
import { streamSessionTranscriptLines } from "../config/sessions/transcript-stream.js";
import { selectVisibleTranscriptEvents } from "../config/sessions/transcript-visible-events.js";
import type { SessionEntry } from "../config/sessions/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { parseAgentSessionKey } from "../routing/session-key.js";
import { resolveOpenClawAgentSqlitePath } from "../state/openclaw-agent-db.js";
import { runTasksWithConcurrency } from "../utils/run-with-concurrency.js";
import {
createUsageCostResolver,
parseUsageCostTranscriptEntry,
type UsageCostResolver,
} from "./session-cost-usage-pricing.js";
import type { ParsedUsageEntry } from "./session-cost-usage.types.js";
export const USAGE_COST_TRANSCRIPT_STAT_CONCURRENCY = 32;
@@ -181,13 +175,6 @@ function formatCanonicalUsageCostSqliteMarker(marker: SqliteSessionFileMarker):
return formatSqliteSessionFileMarker({ ...marker, storePath });
}
export async function listUsageCountedTranscriptFiles(
agentId: string,
params?: { sessionsDir?: string },
): Promise<UsageCostTranscriptFile[]> {
return await listUsageCountedTranscriptStats(agentId, params);
}
export async function listUsageCountedTranscriptStats(
agentId: string,
params?: { minMtimeMs?: number; sessionsDir?: string },
@@ -252,45 +239,6 @@ export async function resolveUsageCostTranscriptFile(
: undefined;
}
async function* readJsonlRecords(
filePath: string,
startOffset = 0,
endOffset?: number,
): AsyncGenerator<Record<string, unknown>> {
if (endOffset !== undefined && endOffset <= startOffset) {
return;
}
const streamOptions: Parameters<typeof fs.createReadStream>[1] = {
encoding: "utf-8",
start: Math.max(0, startOffset),
};
if (endOffset !== undefined) {
streamOptions.end = endOffset - 1;
}
const fileStream = fs.createReadStream(filePath, streamOptions);
const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
try {
for await (const line of rl) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
try {
const parsed = JSON.parse(trimmed) as unknown;
if (!parsed || typeof parsed !== "object") {
continue;
}
yield parsed as Record<string, unknown>;
} catch {
// Ignore malformed lines
}
}
} finally {
rl.close();
fileStream.destroy();
}
}
function loadSqliteUsageTranscriptEvents(
marker: SqliteSessionFileMarker,
): Record<string, unknown>[] {
@@ -300,16 +248,11 @@ function loadSqliteUsageTranscriptEvents(
sessionId: marker.sessionId,
storePath: marker.storePath,
}),
).filter(
(event): event is Record<string, unknown> =>
Boolean(event) && typeof event === "object" && !Array.isArray(event),
);
).filter(isRecord);
}
export async function* readTranscriptRecords(
filePath: string,
startOffset = 0,
endOffset?: number,
): AsyncGenerator<Record<string, unknown>> {
const marker = parseSqliteSessionFileMarker(filePath);
if (marker) {
@@ -318,14 +261,21 @@ export async function* readTranscriptRecords(
}
return;
}
// Discovery normalizes compressed archives to their materialized cache, so
// this branch only serves direct callers that pass a raw .zst path; those
// callers never carry persisted offsets, keeping the range space coherent.
if (filePath.endsWith(SESSION_ARCHIVE_ZSTD_SUFFIX)) {
yield* readJsonlRecords(materializeSessionArchiveForRead(filePath), startOffset, endOffset);
return;
// Durable byte-offset scans own their checkpoint reader. Diagnostic history
// shares the canonical transcript stream and materializes archive bytes once.
const transcriptPath = filePath.endsWith(SESSION_ARCHIVE_ZSTD_SUFFIX)
? materializeSessionArchiveForRead(filePath)
: filePath;
for await (const line of streamSessionTranscriptLines(transcriptPath)) {
try {
const parsed: unknown = JSON.parse(line);
if (isRecord(parsed)) {
yield parsed;
}
} catch {
// Historical transcripts can contain malformed records.
}
}
yield* readJsonlRecords(filePath, startOffset, endOffset);
}
export async function* readTranscriptRecordsBestEffort(
@@ -339,35 +289,6 @@ export async function* readTranscriptRecordsBestEffort(
}
}
export async function scanUsageFile(params: {
filePath: string;
config?: OpenClawConfig;
resolveCost?: UsageCostResolver;
startOffset?: number;
endOffset?: number;
onEntry: (entry: ParsedUsageEntry) => void;
}): Promise<void> {
const resolveCost = params.resolveCost ?? createUsageCostResolver({ config: params.config });
for await (const parsed of readTranscriptRecords(
params.filePath,
params.startOffset,
params.endOffset,
)) {
const entry = parseUsageCostTranscriptEntry(parsed, resolveCost);
if (!entry?.usage) {
continue;
}
params.onEntry({
usage: entry.usage,
costTotal: entry.costTotal,
costBreakdown: entry.costBreakdown,
provider: entry.provider,
model: entry.model,
timestamp: entry.timestamp,
});
}
}
export function resolveExistingUsageSessionFile(params: {
sessionId?: string;
sessionEntry?: SessionEntry;
+20 -27
View File
@@ -27,13 +27,13 @@ import {
readTranscriptRecordsBestEffort,
resolveExistingUsageSessionFile,
resolveUsageCostTranscriptFile,
scanUsageFile,
} from "./session-cost-usage-collection.js";
import {
computeUsageTokenTotals,
createUsageCostResolver,
extractCostBreakdown,
parseTimestamp,
parseUsageCostTranscriptEntry,
shouldRecomputeRecordedZeroCost,
} from "./session-cost-usage-pricing.js";
import { createUsageDayKeyFormatter } from "./session-cost-usage-projection.js";
@@ -234,32 +234,25 @@ export async function loadSessionUsageTimeSeries(params: {
const agentDir = resolveUsageCostAgentDir(params.config, params.agentId);
const resolveCost = createUsageCostResolver({ config: params.config, agentDir });
await scanUsageFile({
filePath: sessionFile,
config: params.config,
resolveCost,
onEntry: (entry) => {
const ts = entry.timestamp?.getTime();
if (!ts) {
return;
}
const { input, output, cacheRead, cacheWrite, totalTokens } = computeUsageTokenTotals(
entry.usage,
);
const cost = entry.costTotal ?? 0;
points.push({
timestamp: ts,
input,
output,
cacheRead,
cacheWrite,
totalTokens,
cost,
});
},
});
for await (const record of readTranscriptRecords(sessionFile)) {
const entry = parseUsageCostTranscriptEntry(record, resolveCost);
const timestamp = entry?.timestamp?.getTime();
if (!entry?.usage || !timestamp) {
continue;
}
const { input, output, cacheRead, cacheWrite, totalTokens } = computeUsageTokenTotals(
entry.usage,
);
points.push({
timestamp,
input,
output,
cacheRead,
cacheWrite,
totalTokens,
cost: entry.costTotal ?? 0,
});
}
// Sort by timestamp
let cumulativeTokens = 0;
-9
View File
@@ -8,15 +8,6 @@ import type {
export type CostBreakdown = Partial<Usage["cost"]>;
export type ParsedUsageEntry = {
usage: NormalizedUsage;
costTotal?: number;
costBreakdown?: CostBreakdown;
provider?: string;
model?: string;
timestamp?: Date;
};
export type ParsedTranscriptEntry = {
message: Record<string, unknown>;
role?: "user" | "assistant";
@@ -151,18 +151,23 @@ function normalizeRestartRecoveryFields(entry: SessionEntry): SessionEntry {
return next;
}
function normalizePluginExtensions(entry: SessionEntry): SessionEntry {
if (entry.pluginExtensions === undefined) {
function normalizeLegacyPluginState(
entry: SessionEntry,
key: "pluginExtensions" | "pluginExtensionSlotKeys",
normalizeValue: (value: unknown) => PluginJsonValue | undefined,
): SessionEntry {
const state = entry[key];
if (state === undefined) {
return entry;
}
if (!isRecord(entry.pluginExtensions)) {
if (!isRecord(state)) {
const next = { ...entry };
delete next.pluginExtensions;
delete next[key];
return next;
}
let changed = false;
const normalizedExtensions: Record<string, Record<string, PluginJsonValue>> = {};
for (const [rawPluginId, rawPluginState] of Object.entries(entry.pluginExtensions)) {
const normalizedState: Record<string, Record<string, PluginJsonValue>> = {};
for (const [rawPluginId, rawPluginState] of Object.entries(state)) {
const pluginId = normalizeRecordKey(rawPluginId);
if (!pluginId || !isRecord(rawPluginState)) {
changed = true;
@@ -172,76 +177,43 @@ function normalizePluginExtensions(entry: SessionEntry): SessionEntry {
const normalizedPluginState: Record<string, PluginJsonValue> = {};
for (const [rawNamespace, rawValue] of Object.entries(rawPluginState)) {
const namespace = normalizeRecordKey(rawNamespace);
if (!namespace || !isPluginJsonValue(rawValue)) {
const value = normalizeValue(rawValue);
if (!namespace || value === undefined) {
changed = true;
continue;
}
changed ||= namespace !== rawNamespace;
normalizedPluginState[namespace] = rawValue;
changed ||= namespace !== rawNamespace || value !== rawValue;
normalizedPluginState[namespace] = value;
}
if (Object.keys(normalizedPluginState).length === 0) {
changed = true;
continue;
}
normalizedExtensions[pluginId] = normalizedPluginState;
normalizedState[pluginId] = normalizedPluginState;
}
if (!changed) {
return entry;
}
const next = { ...entry };
if (Object.keys(normalizedExtensions).length > 0) {
next.pluginExtensions = normalizedExtensions;
if (Object.keys(normalizedState).length > 0) {
Object.assign(next, { [key]: normalizedState });
} else {
delete next.pluginExtensions;
delete next[key];
}
return next;
}
function normalizePluginExtensions(entry: SessionEntry): SessionEntry {
return normalizeLegacyPluginState(entry, "pluginExtensions", (value) =>
isPluginJsonValue(value) ? value : undefined,
);
}
function normalizePluginExtensionSlotKeys(entry: SessionEntry): SessionEntry {
if (entry.pluginExtensionSlotKeys === undefined) {
return entry;
}
if (!isRecord(entry.pluginExtensionSlotKeys)) {
const next = { ...entry };
delete next.pluginExtensionSlotKeys;
return next;
}
let changed = false;
const normalizedSlotKeys: Record<string, Record<string, string>> = {};
for (const [rawPluginId, rawPluginSlots] of Object.entries(entry.pluginExtensionSlotKeys)) {
const pluginId = normalizeRecordKey(rawPluginId);
if (!pluginId || !isRecord(rawPluginSlots)) {
changed = true;
continue;
}
changed ||= pluginId !== rawPluginId;
const normalizedPluginSlots: Record<string, string> = {};
for (const [rawNamespace, rawSlotKey] of Object.entries(rawPluginSlots)) {
const namespace = normalizeRecordKey(rawNamespace);
const slotKey = normalizeSessionEntrySlotKey(rawSlotKey);
if (!namespace || !slotKey.ok) {
changed = true;
continue;
}
changed ||= namespace !== rawNamespace || slotKey.key !== rawSlotKey;
normalizedPluginSlots[namespace] = slotKey.key;
}
if (Object.keys(normalizedPluginSlots).length === 0) {
changed = true;
continue;
}
normalizedSlotKeys[pluginId] = normalizedPluginSlots;
}
if (!changed) {
return entry;
}
const next = { ...entry };
if (Object.keys(normalizedSlotKeys).length > 0) {
next.pluginExtensionSlotKeys = normalizedSlotKeys;
} else {
delete next.pluginExtensionSlotKeys;
}
return next;
return normalizeLegacyPluginState(entry, "pluginExtensionSlotKeys", (value) => {
const slotKey = normalizeSessionEntrySlotKey(value);
return slotKey.ok ? slotKey.key : undefined;
});
}
function stripPersistedSkillsCache(entry: SessionEntry): SessionEntry {
@@ -674,6 +674,44 @@ describe("migrateOrphanedSessionKeys", () => {
});
});
it.each([
{
name: "commented JSON5 with trailing commas",
raw: `// operator-authored session history\n{\n 'agent:ops:work': { sessionId: 'abc-123', updatedAt: 1000, },\n}\n`,
},
{
name: "an escaped canonical session key",
raw: '{"agent:ops:w\\u006frk":{"sessionId":"abc-123","updatedAt":1000}}\n',
},
])("preserves the exact bytes of $name", async ({ raw }) => {
await withStateFixture(async ({ stateDir }) => {
const storePath = opsSessionStorePath(stateDir);
fs.mkdirSync(path.dirname(storePath), { recursive: true });
fs.writeFileSync(storePath, raw);
expect(await migrateFixtureState(stateDir)).toEqual({ changes: [], warnings: [] });
expect(fs.readFileSync(storePath, "utf-8")).toBe(raw);
});
});
it("canonicalizes an escaped legacy session key using the shared JSON5 parser", async () => {
await withStateFixture(async ({ stateDir }) => {
const storePath = opsSessionStorePath(stateDir);
fs.mkdirSync(path.dirname(storePath), { recursive: true });
fs.writeFileSync(
storePath,
'{"agent:main:m\\u0061in":{"sessionId":"escaped-legacy","updatedAt":1000}}\n',
);
const result = await migrateFixtureState(stateDir);
expect(result.changes).toHaveLength(1);
expect(requireStoreEntry(readStore(storePath), "agent:ops:work").sessionId).toBe(
"escaped-legacy",
);
});
});
it("handles missing store files gracefully", async () => {
await withStateFixture(async ({ stateDir }) => {
const result = await migrateFixtureState(stateDir);
+8 -160
View File
@@ -462,158 +462,16 @@ export function resolveStaleLegacySessionFile(params: {
}
}
function skipJson5Trivia(raw: string, index: number): number {
let i = index;
while (i < raw.length) {
const ch = raw[i];
if (ch === " " || ch === "\n" || ch === "\r" || ch === "\t") {
i++;
continue;
}
if (ch === "/" && raw[i + 1] === "/") {
i += 2;
while (i < raw.length && raw[i] !== "\n") {
i++;
}
continue;
}
if (ch === "/" && raw[i + 1] === "*") {
i += 2;
while (i < raw.length && !(raw[i] === "*" && raw[i + 1] === "/")) {
i++;
}
return i < raw.length ? i + 2 : i;
}
break;
}
return i;
}
function readJson5String(raw: string, index: number): { value: string; next: number } | null {
const quote = raw[index];
if (quote !== '"' && quote !== "'") {
return null;
}
let i = index + 1;
let value = "";
while (i < raw.length) {
const ch = raw[i];
if (ch === quote) {
return { value, next: i + 1 };
}
if (ch === "\\") {
return null;
}
value += ch;
i++;
}
return null;
}
function readJson5BareKey(raw: string, index: number): { value: string; next: number } | null {
let i = index;
while (i < raw.length) {
const ch = raw[i];
if (
ch === ":" ||
ch === " " ||
ch === "\n" ||
ch === "\r" ||
ch === "\t" ||
ch === "," ||
ch === "}" ||
ch === "{" ||
ch === "[" ||
ch === "]"
) {
break;
}
i++;
}
if (i === index) {
return null;
}
return { value: raw.slice(index, i), next: i };
}
function listTopLevelSessionStoreKeys(raw: string): string[] | null {
let i = skipJson5Trivia(raw, 0);
if (raw[i] !== "{") {
return null;
}
i++;
const keys: string[] = [];
let depth = 1;
let expectingKey = true;
while (i < raw.length) {
i = skipJson5Trivia(raw, i);
const ch = raw[i];
if (ch === undefined) {
return null;
}
if (depth === 1 && ch === "}") {
return keys;
}
if (depth === 1 && expectingKey) {
const key = ch === '"' || ch === "'" ? readJson5String(raw, i) : readJson5BareKey(raw, i);
if (!key) {
return null;
}
i = skipJson5Trivia(raw, key.next);
if (raw[i] !== ":") {
return null;
}
keys.push(key.value);
i++;
expectingKey = false;
continue;
}
if (ch === '"' || ch === "'") {
const str = readJson5String(raw, i);
if (!str) {
return null;
}
i = str.next;
continue;
}
if (ch === "{" || ch === "[") {
depth++;
i++;
continue;
}
if (ch === "}" || ch === "]") {
depth--;
i++;
if (depth < 1) {
return keys;
}
continue;
}
if (depth === 1 && ch === ",") {
expectingKey = true;
i++;
continue;
}
i++;
}
return null;
}
function sessionStoreTextMayNeedCanonicalization(params: {
raw: string;
function sessionStoreMayNeedCanonicalization(params: {
store: Record<string, SessionEntryLike>;
storeAgentIds: Iterable<string>;
mainKey: string;
scope?: SessionScope;
preserveForeignMainAliases?: boolean;
}): boolean {
const keys = listTopLevelSessionStoreKeys(params.raw);
if (!keys) {
return true;
}
const storeAgentIds = new Set([...params.storeAgentIds].map((id) => normalizeAgentId(id)));
const hasNonMainAgent = [...storeAgentIds].some((id) => id !== DEFAULT_AGENT_ID);
for (const key of keys) {
for (const key of Object.keys(params.store)) {
const rawKey = key.trim();
if (rawKey !== key) {
return true;
@@ -813,16 +671,17 @@ export async function migrateOrphanedSessionKeys(params: {
const pluginForeignMainAliasRisk = [...storeAgentIds].some(
(id) => pluginAgentIdSet.has(id) && id !== DEFAULT_AGENT_ID,
);
let raw: string;
let parsed: ReturnType<typeof parseSessionStoreJson5>;
try {
raw = fs.readFileSync(storePath, "utf-8");
parsed = parseSessionStoreJson5(fs.readFileSync(storePath, "utf-8"));
} catch (err) {
warnings.push(`Could not read ${storePath}: ${String(err)}`);
continue;
}
if (
!sessionStoreTextMayNeedCanonicalization({
raw,
!parsed.ok ||
!sessionStoreMayNeedCanonicalization({
store: parsed.store,
storeAgentIds,
mainKey,
scope,
@@ -831,17 +690,6 @@ export async function migrateOrphanedSessionKeys(params: {
) {
continue;
}
let parsed: ReturnType<typeof readSessionStoreJson5>;
try {
parsed = parseSessionStoreJson5(raw);
} catch (err) {
warnings.push(`Could not read ${storePath}: ${String(err)}`);
continue;
}
if (!parsed.ok) {
continue;
}
// A physical store can have several owners. Canonicalize valid scoped rows
// within their declared owner on every pass so iteration order cannot move
// one agent's history into another namespace.
+16
View File
@@ -1,6 +1,7 @@
// Covers plugin discovery threading and concurrency behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginDiscoveryResult } from "./discovery.js";
import * as installedPluginIndexRecordReader from "./installed-plugin-index-record-reader.js";
const discoverOpenClawPluginsMock = vi.fn();
@@ -54,4 +55,19 @@ describe("discovery threading", () => {
});
expect(discoverOpenClawPluginsMock).not.toHaveBeenCalled();
});
it("preserves explicit candidate diagnostics without loading persisted install records", () => {
const readInstallRecords = vi.spyOn(
installedPluginIndexRecordReader,
"loadInstalledPluginIndexInstallRecordsSync",
);
const diagnostics = [{ level: "warn" as const, message: "explicit candidate diagnostic" }];
const result = resolveInstalledPluginIndexRegistry({ candidates: [], diagnostics });
expect(result.registry.diagnostics).toEqual(diagnostics);
expect(result.discovery).toBeUndefined();
expect(readInstallRecords).not.toHaveBeenCalled();
expect(discoverOpenClawPluginsMock).not.toHaveBeenCalled();
});
});
+155 -280
View File
@@ -938,6 +938,153 @@ function shouldSkipIncompatiblePackagePluginApi(params: {
return true;
}
type PluginDirectoryDiscoveryParams = {
dir: string;
rootRealPath: string | undefined;
origin: PluginOrigin;
env: NodeJS.ProcessEnv;
ownershipUid?: number | null;
workspaceDir?: string;
requireBuiltRuntimeEntry?: boolean;
managedPluginDirs?: Set<string>;
candidates: PluginCandidate[];
diagnostics: PluginDiagnostic[];
seen: Set<string>;
realpathCache: Map<string, string>;
packageManifestCache?: Map<string, PackageManifest | null>;
};
function discoverPluginDirectory(params: PluginDirectoryDiscoveryParams): boolean {
const { dir, rootRealPath } = params;
const requireBuiltRuntimeEntry =
params.requireBuiltRuntimeEntry ??
isManagedPluginDir({
dir,
realpath: rootRealPath,
managedPluginDirs: params.managedPluginDirs,
realpathCache: params.realpathCache,
});
const rejectHardlinks = shouldRejectHardlinkedPluginFiles({
origin: params.origin,
rootDir: dir,
env: params.env,
realpathCache: params.realpathCache,
});
const manifest = readCandidatePackageManifest({
dir,
origin: params.origin,
rejectHardlinks,
...(rootRealPath !== undefined ? { rootRealPath } : {}),
packageManifestCache: params.packageManifestCache,
});
if (
shouldSkipIncompatiblePackagePluginApi({
origin: params.origin,
manifest,
packageDir: dir,
env: params.env,
diagnostics: params.diagnostics,
})
) {
return true;
}
const extensionResolution = resolvePackageExtensionEntries(manifest ?? undefined);
if (
pushInvalidPackageExtensionDiagnostic({
resolution: extensionResolution,
source: dir,
diagnostics: params.diagnostics,
})
) {
return true;
}
const extensions = extensionResolution.status === "ok" ? extensionResolution.entries : [];
const candidateManifest = resolveCandidateManifest(dir, rejectHardlinks, rootRealPath);
const manifestId = candidateManifest?.manifest.id;
const setupSource = resolvePackageSetupSource({
packageDir: dir,
...(rootRealPath !== undefined ? { packageRootRealPath: rootRealPath } : {}),
manifest,
origin: params.origin,
requireBuiltRuntimeEntry,
sourceLabel: dir,
diagnostics: params.diagnostics,
rejectHardlinks,
});
const addPackageCandidate = (source: string, idHint: string): void => {
addCandidate({
candidates: params.candidates,
diagnostics: params.diagnostics,
seen: params.seen,
idHint,
source,
...(setupSource ? { setupSource } : {}),
rootDir: dir,
origin: params.origin,
ownershipUid: params.ownershipUid,
workspaceDir: params.workspaceDir,
manifest,
packageDir: dir,
requiredPluginIds: candidateManifest?.manifest.requiresPlugins,
requiredPluginSource: candidateManifest?.manifestPath,
realpathCache: params.realpathCache,
});
};
if (extensions.length > 0) {
const resolvedRuntimeSources = resolvePackageRuntimeExtensionSources({
packageDir: dir,
...(rootRealPath !== undefined ? { packageRootRealPath: rootRealPath } : {}),
manifest,
extensions,
origin: params.origin,
pluginIdHint: derivePackagePluginIdHint({ manifestId, packageName: manifest?.name }),
requireBuiltRuntimeEntry,
sourceLabel: dir,
diagnostics: params.diagnostics,
rejectHardlinks,
});
for (const source of resolvedRuntimeSources) {
addPackageCandidate(
source,
deriveIdHint({
filePath: source,
manifestId,
packageName: manifest?.name,
hasMultipleExtensions: extensions.length > 1,
}),
);
}
return true;
}
if (
discoverBundleInRoot({
rootDir: dir,
origin: params.origin,
env: params.env,
ownershipUid: params.ownershipUid,
workspaceDir: params.workspaceDir,
manifest,
candidates: params.candidates,
diagnostics: params.diagnostics,
seen: params.seen,
realpathCache: params.realpathCache,
}) === "added"
) {
return true;
}
const indexFile = [...DEFAULT_PLUGIN_ENTRY_CANDIDATES]
.map((candidate) => path.join(dir, candidate))
.find((candidate) => fs.existsSync(candidate));
if (indexFile && isExtensionFile(indexFile)) {
addPackageCandidate(indexFile, manifestId ?? path.basename(dir));
return true;
}
return addLegacyNpmDeclarationDiagnostic({ pluginDir: dir, diagnostics: params.diagnostics });
}
function discoverInDirectory(params: {
dir: string;
origin: PluginOrigin;
@@ -1017,146 +1164,11 @@ function discoverInDirectory(params: {
if (params.skipRootDirKeys?.has(fullPathDirKey)) {
continue;
}
const requireBuiltRuntimeEntry =
params.requireBuiltRuntimeEntry ??
isManagedPluginDir({
if (
discoverPluginDirectory({
...params,
dir: fullPath,
realpath: fullPathRealPath,
managedPluginDirs: params.managedPluginDirs,
realpathCache: params.realpathCache,
});
const rejectHardlinks = shouldRejectHardlinkedPluginFiles({
origin: params.origin,
rootDir: fullPath,
env: params.env,
realpathCache: params.realpathCache,
});
const manifest = readCandidatePackageManifest({
dir: fullPath,
origin: params.origin,
rejectHardlinks,
...(fullPathRealPath !== undefined ? { rootRealPath: fullPathRealPath } : {}),
packageManifestCache: params.packageManifestCache,
});
if (
shouldSkipIncompatiblePackagePluginApi({
origin: params.origin,
manifest,
packageDir: fullPath,
env: params.env,
diagnostics: params.diagnostics,
})
) {
continue;
}
const extensionResolution = resolvePackageExtensionEntries(manifest ?? undefined);
if (
pushInvalidPackageExtensionDiagnostic({
resolution: extensionResolution,
source: fullPath,
diagnostics: params.diagnostics,
})
) {
continue;
}
const extensions = extensionResolution.status === "ok" ? extensionResolution.entries : [];
const candidateManifest = resolveCandidateManifest(fullPath, rejectHardlinks, fullPathRealPath);
const manifestId = candidateManifest?.manifest.id;
const setupSource = resolvePackageSetupSource({
packageDir: fullPath,
...(fullPathRealPath !== undefined ? { packageRootRealPath: fullPathRealPath } : {}),
manifest,
origin: params.origin,
requireBuiltRuntimeEntry,
sourceLabel: fullPath,
diagnostics: params.diagnostics,
rejectHardlinks,
});
if (extensions.length > 0) {
const resolvedRuntimeSources = resolvePackageRuntimeExtensionSources({
packageDir: fullPath,
...(fullPathRealPath !== undefined ? { packageRootRealPath: fullPathRealPath } : {}),
manifest,
extensions,
origin: params.origin,
pluginIdHint: derivePackagePluginIdHint({ manifestId, packageName: manifest?.name }),
requireBuiltRuntimeEntry,
sourceLabel: fullPath,
diagnostics: params.diagnostics,
rejectHardlinks,
});
for (const resolved of resolvedRuntimeSources) {
addCandidate({
candidates: params.candidates,
diagnostics: params.diagnostics,
seen: params.seen,
idHint: deriveIdHint({
filePath: resolved,
manifestId,
packageName: manifest?.name,
hasMultipleExtensions: extensions.length > 1,
}),
source: resolved,
...(setupSource ? { setupSource } : {}),
rootDir: fullPath,
origin: params.origin,
ownershipUid: params.ownershipUid,
workspaceDir: params.workspaceDir,
manifest,
packageDir: fullPath,
requiredPluginIds: candidateManifest?.manifest.requiresPlugins,
requiredPluginSource: candidateManifest?.manifestPath,
realpathCache: params.realpathCache,
});
}
continue;
}
const bundleDiscovery = discoverBundleInRoot({
rootDir: fullPath,
origin: params.origin,
env: params.env,
ownershipUid: params.ownershipUid,
workspaceDir: params.workspaceDir,
manifest,
candidates: params.candidates,
diagnostics: params.diagnostics,
seen: params.seen,
realpathCache: params.realpathCache,
});
if (bundleDiscovery === "added") {
continue;
}
const indexFile = [...DEFAULT_PLUGIN_ENTRY_CANDIDATES]
.map((candidate) => path.join(fullPath, candidate))
.find((candidate) => fs.existsSync(candidate));
if (indexFile && isExtensionFile(indexFile)) {
addCandidate({
candidates: params.candidates,
diagnostics: params.diagnostics,
seen: params.seen,
idHint: manifestId ?? entry.name,
source: indexFile,
...(setupSource ? { setupSource } : {}),
rootDir: fullPath,
origin: params.origin,
ownershipUid: params.ownershipUid,
workspaceDir: params.workspaceDir,
manifest,
packageDir: fullPath,
requiredPluginIds: candidateManifest?.manifest.requiresPlugins,
requiredPluginSource: candidateManifest?.manifestPath,
realpathCache: params.realpathCache,
});
continue;
}
if (
addLegacyNpmDeclarationDiagnostic({
pluginDir: fullPath,
diagnostics: params.diagnostics,
rootRealPath: fullPathRealPath,
})
) {
continue;
@@ -1294,148 +1306,11 @@ function discoverFromPath(params: {
}
if (stat.isDirectory()) {
const resolvedRealPath = safeRealpathSync(resolved, params.realpathCache) ?? undefined;
const requireBuiltRuntimeEntry =
params.requireBuiltRuntimeEntry ??
isManagedPluginDir({
if (
discoverPluginDirectory({
...params,
dir: resolved,
realpath: resolvedRealPath,
managedPluginDirs: params.managedPluginDirs,
realpathCache: params.realpathCache,
});
const rejectHardlinks = shouldRejectHardlinkedPluginFiles({
origin: params.origin,
rootDir: resolved,
env: params.env,
realpathCache: params.realpathCache,
});
const manifest = readCandidatePackageManifest({
dir: resolved,
origin: params.origin,
rejectHardlinks,
...(resolvedRealPath !== undefined ? { rootRealPath: resolvedRealPath } : {}),
packageManifestCache: params.packageManifestCache,
});
if (
shouldSkipIncompatiblePackagePluginApi({
origin: params.origin,
manifest,
packageDir: resolved,
env: params.env,
diagnostics: params.diagnostics,
})
) {
return;
}
const extensionResolution = resolvePackageExtensionEntries(manifest ?? undefined);
if (
pushInvalidPackageExtensionDiagnostic({
resolution: extensionResolution,
source: resolved,
diagnostics: params.diagnostics,
})
) {
return;
}
const extensions = extensionResolution.status === "ok" ? extensionResolution.entries : [];
const candidateManifest = resolveCandidateManifest(resolved, rejectHardlinks, resolvedRealPath);
const manifestId = candidateManifest?.manifest.id;
const setupSource = resolvePackageSetupSource({
packageDir: resolved,
...(resolvedRealPath !== undefined ? { packageRootRealPath: resolvedRealPath } : {}),
manifest,
origin: params.origin,
requireBuiltRuntimeEntry,
sourceLabel: resolved,
diagnostics: params.diagnostics,
rejectHardlinks,
});
if (extensions.length > 0) {
const resolvedRuntimeSources = resolvePackageRuntimeExtensionSources({
packageDir: resolved,
...(resolvedRealPath !== undefined ? { packageRootRealPath: resolvedRealPath } : {}),
manifest,
extensions,
origin: params.origin,
pluginIdHint: derivePackagePluginIdHint({ manifestId, packageName: manifest?.name }),
requireBuiltRuntimeEntry,
sourceLabel: resolved,
diagnostics: params.diagnostics,
rejectHardlinks,
});
for (const source of resolvedRuntimeSources) {
addCandidate({
candidates: params.candidates,
diagnostics: params.diagnostics,
seen: params.seen,
idHint: deriveIdHint({
filePath: source,
manifestId,
packageName: manifest?.name,
hasMultipleExtensions: extensions.length > 1,
}),
source,
...(setupSource ? { setupSource } : {}),
rootDir: resolved,
origin: params.origin,
ownershipUid: params.ownershipUid,
workspaceDir: params.workspaceDir,
manifest,
packageDir: resolved,
requiredPluginIds: candidateManifest?.manifest.requiresPlugins,
requiredPluginSource: candidateManifest?.manifestPath,
realpathCache: params.realpathCache,
});
}
return;
}
const bundleDiscovery = discoverBundleInRoot({
rootDir: resolved,
origin: params.origin,
env: params.env,
ownershipUid: params.ownershipUid,
workspaceDir: params.workspaceDir,
manifest,
candidates: params.candidates,
diagnostics: params.diagnostics,
seen: params.seen,
realpathCache: params.realpathCache,
});
if (bundleDiscovery === "added") {
return;
}
const indexFile = [...DEFAULT_PLUGIN_ENTRY_CANDIDATES]
.map((candidate) => path.join(resolved, candidate))
.find((candidate) => fs.existsSync(candidate));
if (indexFile && isExtensionFile(indexFile)) {
addCandidate({
candidates: params.candidates,
diagnostics: params.diagnostics,
seen: params.seen,
idHint: manifestId ?? path.basename(resolved),
source: indexFile,
...(setupSource ? { setupSource } : {}),
rootDir: resolved,
origin: params.origin,
ownershipUid: params.ownershipUid,
workspaceDir: params.workspaceDir,
manifest,
packageDir: resolved,
requiredPluginIds: candidateManifest?.manifest.requiresPlugins,
requiredPluginSource: candidateManifest?.manifestPath,
realpathCache: params.realpathCache,
});
return;
}
if (
addLegacyNpmDeclarationDiagnostic({
pluginDir: resolved,
diagnostics: params.diagnostics,
rootRealPath: safeRealpathSync(resolved, params.realpathCache) ?? undefined,
})
) {
return;
+12 -24
View File
@@ -15,34 +15,22 @@ export function resolveInstalledPluginIndexRegistry(params: LoadInstalledPluginI
candidates: readonly PluginCandidate[];
discovery?: PluginDiscoveryResult;
} {
if (params.candidates) {
return {
candidates: params.candidates,
registry: loadPluginManifestRegistry({
config: params.config,
const suppliedCandidates = params.candidates;
const installRecords = suppliedCandidates
? params.installRecords
: (params.installRecords ?? loadInstalledPluginIndexInstallRecordsSync({ env: params.env }));
const discovery = suppliedCandidates
? { candidates: suppliedCandidates, diagnostics: params.diagnostics ?? [] }
: (params.discovery ??
discoverOpenClawPlugins({
workspaceDir: params.workspaceDir,
extraPaths: normalizePluginsConfig(params.config?.plugins).loadPaths,
env: params.env,
candidates: params.candidates,
diagnostics: params.diagnostics,
installRecords: params.installRecords,
}),
};
}
const normalized = normalizePluginsConfig(params.config?.plugins);
const installRecords =
params.installRecords ?? loadInstalledPluginIndexInstallRecordsSync({ env: params.env });
const discovery =
params.discovery ??
discoverOpenClawPlugins({
workspaceDir: params.workspaceDir,
extraPaths: normalized.loadPaths,
env: params.env,
installRecords,
});
installRecords,
}));
return {
candidates: discovery.candidates,
discovery,
...(suppliedCandidates ? {} : { discovery }),
registry: loadPluginManifestRegistry({
config: params.config,
workspaceDir: params.workspaceDir,
+2 -8
View File
@@ -1,4 +1,3 @@
import type { PluginInstallRecord } from "../config/types.plugins.js";
import {
discoverOpenClawPlugins,
type PluginCandidate,
@@ -65,7 +64,8 @@ export function resolvePluginLoadDiscovery(params: {
env: context.env,
candidates: discovery.candidates,
diagnostics: discovery.diagnostics,
installRecords: nonEmptyInstallRecords(context.installRecords),
installRecords:
Object.keys(context.installRecords).length > 0 ? context.installRecords : undefined,
});
pushDiagnostics(params.diagnostics, manifestRegistry.diagnostics);
warnWhenAllowlistIsOpen({
@@ -108,9 +108,3 @@ export function resolvePluginLoadDiscovery(params: {
);
return { discovery, manifestRegistry, orderedCandidates, manifestBySource, provenance };
}
function nonEmptyInstallRecords(
records: Record<string, PluginInstallRecord>,
): Record<string, PluginInstallRecord> | undefined {
return Object.keys(records).length > 0 ? records : undefined;
}
+3 -16
View File
@@ -107,19 +107,6 @@ function freezeSnapshotValue<T>(value: T, seen = new WeakSet<object>()): T {
return Object.freeze(value);
}
function freezePluginMetadataSnapshot(snapshot: PluginMetadataSnapshot): PluginMetadataSnapshot {
return freezeSnapshotValue(snapshot);
}
function resolvePluginMetadataControlPlaneFingerprint(
params: Pick<LoadPluginMetadataSnapshotParams, "config" | "env" | "workspaceDir"> & {
index?: InstalledPluginIndex;
policyHash?: string;
},
): string {
return resolvePluginControlPlaneFingerprint(params);
}
function indexesMatch(
left: InstalledPluginIndex | undefined,
right: InstalledPluginIndex | undefined,
@@ -189,7 +176,7 @@ export function isPluginMetadataSnapshotCompatible(params: {
params.snapshot.policyHash === resolveInstalledPluginIndexPolicyHash(params.config) &&
(!params.snapshot.configFingerprint ||
params.snapshot.configFingerprint ===
resolvePluginMetadataControlPlaneFingerprint({
resolvePluginControlPlaneFingerprint({
config: params.config,
env,
index: params.index ?? params.snapshot.index,
@@ -317,7 +304,7 @@ export function loadPluginMetadataSnapshot(
);
return measureDiagnosticsTimelineSpanSync(
"plugins.metadata.freeze",
() => freezePluginMetadataSnapshot(snapshot),
() => freezeSnapshotValue(snapshot),
{
phase: activeTimelineSpan?.phase ?? "startup",
config: params.config,
@@ -411,7 +398,7 @@ function loadPluginMetadataSnapshotImpl(
return {
policyHash: index.policyHash,
registrySource: registryResult.source,
configFingerprint: resolvePluginMetadataControlPlaneFingerprint({
configFingerprint: resolvePluginControlPlaneFingerprint({
config: params.config,
env: params.env,
index,
+30 -53
View File
@@ -499,13 +499,13 @@ export function loadPluginRegistrySnapshotWithMetadata(
// never reuse security-sensitive symlink or plugin-root resolutions.
const realpathCache = new Map<string, string>();
const diagnostics: PluginRegistrySnapshotDiagnostic[] = [];
const disabledByCaller = params.preferPersisted === false;
const persistedReadsEnabled = !disabledByCaller;
const persistedInstallRecordReadsEnabled = persistedReadsEnabled;
let persistedIndex: InstalledPluginIndex | null;
if (persistedInstallRecordReadsEnabled) {
persistedIndex = readPersistedInstalledPluginIndexSync(params);
if (persistedReadsEnabled && persistedIndex) {
const persistedReadsEnabled = params.preferPersisted !== false;
const pushStaleSourceDiagnostic = (message: string): void => {
diagnostics.push({ level: "warn", code: "persisted-registry-stale-source", message });
};
if (persistedReadsEnabled) {
const persistedIndex = readPersistedInstalledPluginIndexSync(params);
if (persistedIndex) {
if (
params.config &&
persistedIndex.policyHash !== resolveInstalledPluginIndexPolicyHash(params.config)
@@ -517,49 +517,31 @@ export function loadPluginRegistrySnapshotWithMetadata(
"Persisted plugin registry policy does not match current config; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
});
} else if (hasMissingPersistedPluginSource(persistedIndex)) {
diagnostics.push({
level: "warn",
code: "persisted-registry-stale-source",
message:
"Persisted plugin registry points at missing plugin files; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
});
pushStaleSourceDiagnostic(
"Persisted plugin registry points at missing plugin files; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
);
} else if (hasMismatchedPersistedBundledPluginRoot(persistedIndex, env, realpathCache)) {
diagnostics.push({
level: "warn",
code: "persisted-registry-stale-source",
message:
"Persisted plugin registry points at a different bundled plugin tree; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
});
pushStaleSourceDiagnostic(
"Persisted plugin registry points at a different bundled plugin tree; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
);
} else if (
hasMismatchedPersistedConfigPathPlugins(persistedIndex, params, env, realpathCache)
) {
diagnostics.push({
level: "warn",
code: "persisted-registry-stale-source",
message:
"Persisted plugin registry does not match configured load-path plugins; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
});
pushStaleSourceDiagnostic(
"Persisted plugin registry does not match configured load-path plugins; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
);
} else if (hasStalePersistedPluginDiagnostics(persistedIndex)) {
diagnostics.push({
level: "warn",
code: "persisted-registry-stale-source",
message:
"Persisted plugin registry contains diagnostics referencing missing paths; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
});
pushStaleSourceDiagnostic(
"Persisted plugin registry contains diagnostics referencing missing paths; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
);
} else if (hasMissingConfigPathActivationMetadata(persistedIndex)) {
diagnostics.push({
level: "warn",
code: "persisted-registry-stale-source",
message:
"Persisted plugin registry is missing config-path startup metadata; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
});
pushStaleSourceDiagnostic(
"Persisted plugin registry is missing config-path startup metadata; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
);
} else if (hasStalePersistedPluginMetadata(persistedIndex, realpathCache)) {
diagnostics.push({
level: "warn",
code: "persisted-registry-stale-source",
message:
"Persisted plugin registry metadata no longer matches plugin manifest or package files; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
});
pushStaleSourceDiagnostic(
"Persisted plugin registry metadata no longer matches plugin manifest or package files; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
);
} else if (
hasRecoveredInstallRecordsMissingFromPersistedIndex(
persistedIndex,
@@ -567,12 +549,9 @@ export function loadPluginRegistrySnapshotWithMetadata(
env,
)
) {
diagnostics.push({
level: "warn",
code: "persisted-registry-stale-source",
message:
"Persisted plugin registry is missing recoverable managed npm plugins; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
});
pushStaleSourceDiagnostic(
"Persisted plugin registry is missing recoverable managed npm plugins; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
);
} else {
const persistedResult: PluginRegistrySnapshotResult = {
snapshot: persistedIndex,
@@ -581,7 +560,7 @@ export function loadPluginRegistrySnapshotWithMetadata(
};
return rememberPluginRegistrySnapshotMemo(memoKey, persistedResult);
}
} else if (persistedReadsEnabled) {
} else {
diagnostics.push({
level: "info",
code: "persisted-registry-missing",
@@ -592,9 +571,7 @@ export function loadPluginRegistrySnapshotWithMetadata(
const derived = loadInstalledPluginIndexWithDiscovery({
...params,
installRecords: persistedInstallRecordReadsEnabled
? params.installRecords
: (params.installRecords ?? {}),
installRecords: persistedReadsEnabled ? params.installRecords : (params.installRecords ?? {}),
});
return rememberPluginRegistrySnapshotMemo(memoKey, {
snapshot: derived.index,
File diff suppressed because it is too large Load Diff
@@ -1,11 +1,12 @@
/* @vitest-environment jsdom */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { i18n } from "../../i18n/index.ts";
import {
createClient,
createContext,
createGateway,
createPluginsRouteData,
createPluginsRouteLocation,
createResult,
mountPage,
@@ -29,20 +30,12 @@ describe("PluginsPage routing", () => {
it("applies the Discover path from route data", async () => {
const { client } = createClient(async () => createResult());
const harness = createGateway(client);
const routeData: PluginsRouteData = {
gateway: harness.gateway,
gatewaySnapshot: harness.gateway.snapshot,
result: createResult(),
error: null,
location: createPluginsRouteLocation("/settings/plugins/discover"),
};
const { page } = await mountPage(
createContext(
harness.gateway,
vi.fn(async () => undefined),
),
routeData,
const routeData: PluginsRouteData = createPluginsRouteData(
harness.gateway,
createResult(),
createPluginsRouteLocation("/settings/plugins/discover"),
);
const { page } = await mountPage(createContext(harness.gateway), routeData);
expect(page.activeTab).toBe("discover");
const tabGroup = page.querySelector<HTMLElement & { updateComplete: Promise<boolean> }>(
@@ -70,17 +63,12 @@ describe("PluginsPage routing", () => {
async (sourceUrl, expectedTab, expectedUrl) => {
const { client } = createClient(async () => createResult());
const harness = createGateway(client);
const context = createContext(
const context = createContext(harness.gateway);
const routeData: PluginsRouteData = createPluginsRouteData(
harness.gateway,
vi.fn(async () => undefined),
createResult(),
createPluginsRouteLocation(sourceUrl),
);
const routeData: PluginsRouteData = {
gateway: harness.gateway,
gatewaySnapshot: harness.gateway.snapshot,
result: createResult(),
error: null,
location: createPluginsRouteLocation(sourceUrl),
};
const { page } = await mountPage(context, routeData);
expect(page.activeTab).toBe(expectedTab);
@@ -117,17 +105,8 @@ describe("PluginsPage routing", () => {
it("pushes catalog tab paths and restores the tab from history", async () => {
const { client } = createClient(async () => createResult());
const harness = createGateway(client);
const context = createContext(
harness.gateway,
vi.fn(async () => undefined),
);
const routeData: PluginsRouteData = {
gateway: harness.gateway,
gatewaySnapshot: harness.gateway.snapshot,
location: createPluginsRouteLocation(),
result: createResult(),
error: null,
};
const context = createContext(harness.gateway);
const routeData: PluginsRouteData = createPluginsRouteData(harness.gateway);
const { page } = await mountPage(context, routeData);
clickHubTab(page, "skills");
@@ -72,6 +72,14 @@ export function createPluginsRouteLocation(url = "/settings/plugins"): RouteLoca
};
}
export function createPluginsRouteData(
gateway: ApplicationGateway,
result: PluginListResult | null = createResult(),
location = createPluginsRouteLocation(),
): PluginsRouteData {
return { gateway, gatewaySnapshot: gateway.snapshot, location, result, error: null };
}
export function createClient(handler: RequestHandler) {
const request = vi.fn(handler);
return {
@@ -186,7 +194,7 @@ export function createRuntimeConfigHarness(
export function createContext(
gateway: ApplicationGateway,
refreshConfig: ApplicationContext["runtimeConfig"]["refresh"],
refreshConfig: ApplicationContext["runtimeConfig"]["refresh"] = vi.fn(async () => undefined),
runtimeConfigState: RuntimeConfigTestState = {
configFormDirty: false,
lastError: null,
+34 -144
View File
@@ -11,6 +11,7 @@ import {
createContext,
createGateway,
createPlugin,
createPluginsRouteData,
createPluginsRouteLocation,
createResult,
createRuntimeConfigHarness,
@@ -38,21 +39,9 @@ describe("PluginsPage", () => {
const { client, request } = createClient(async () => createResult());
const harness = createGateway(client);
const result = createResult();
const routeData: PluginsRouteData = {
gateway: harness.gateway,
gatewaySnapshot: harness.gateway.snapshot,
location: createPluginsRouteLocation(),
result,
error: null,
};
const routeData: PluginsRouteData = createPluginsRouteData(harness.gateway, result);
const { page } = await mountPage(
createContext(
harness.gateway,
vi.fn(async () => undefined),
),
routeData,
);
const { page } = await mountPage(createContext(harness.gateway), routeData);
expect(page.result).toBe(result);
expect(request).not.toHaveBeenCalled();
@@ -65,12 +54,7 @@ describe("PluginsPage", () => {
throw new Error("catalog unavailable");
});
const harness = createGateway(client);
const { page } = await mountPage(
createContext(
harness.gateway,
vi.fn(async () => undefined),
),
);
const { page } = await mountPage(createContext(harness.gateway));
await waitForFast(() =>
expect(page.querySelector(".plugins-page-error")?.textContent).toContain(
@@ -121,21 +105,9 @@ describe("PluginsPage", () => {
const result = createResult(
createPlugin({ id: "remote-icon", name: "FireCrawl", hasIcon: true }),
);
const routeData: PluginsRouteData = {
gateway: harness.gateway,
gatewaySnapshot: harness.gateway.snapshot,
location: createPluginsRouteLocation(),
result,
error: null,
};
const routeData: PluginsRouteData = createPluginsRouteData(harness.gateway, result);
const { page } = await mountPage(
createContext(
harness.gateway,
vi.fn(async () => undefined),
),
routeData,
);
const { page } = await mountPage(createContext(harness.gateway), routeData);
await waitForFast(() => {
expect(
@@ -187,17 +159,8 @@ describe("PluginsPage", () => {
);
const { page } = await mountPage(
createContext(
harness.gateway,
vi.fn(async () => undefined),
),
{
gateway: harness.gateway,
gatewaySnapshot: harness.gateway.snapshot,
location: createPluginsRouteLocation(),
result,
error: null,
},
createContext(harness.gateway),
createPluginsRouteData(harness.gateway, result),
);
await waitForFast(() => expect(fetchMock).toHaveBeenCalledOnce());
@@ -216,20 +179,8 @@ describe("PluginsPage", () => {
throw new Error(`Unexpected method ${method}`);
});
const harness = createGateway(client);
const routeData: PluginsRouteData = {
gateway: harness.gateway,
gatewaySnapshot: harness.gateway.snapshot,
location: createPluginsRouteLocation(),
result: createResult(),
error: null,
};
const { page } = await mountPage(
createContext(
harness.gateway,
vi.fn(async () => undefined),
),
routeData,
);
const routeData: PluginsRouteData = createPluginsRouteData(harness.gateway);
const { page } = await mountPage(createContext(harness.gateway), routeData);
harness.emit(client, false);
harness.emit(client, true);
@@ -252,17 +203,8 @@ describe("PluginsPage", () => {
});
const harness = createGateway(client);
const { page } = await mountPage(
createContext(
harness.gateway,
vi.fn(async () => undefined),
),
{
gateway: harness.gateway,
gatewaySnapshot: harness.gateway.snapshot,
location: createPluginsRouteLocation(),
result: createResult(),
error: null,
},
createContext(harness.gateway),
createPluginsRouteData(harness.gateway),
);
clickHubTab(page, "discover");
@@ -298,17 +240,12 @@ describe("PluginsPage", () => {
});
const harness = createGateway(client);
const { page } = await mountPage(
createContext(
createContext(harness.gateway),
createPluginsRouteData(
harness.gateway,
vi.fn(async () => undefined),
createResult(),
createPluginsRouteLocation("/settings/plugins/discover"),
),
{
gateway: harness.gateway,
gatewaySnapshot: harness.gateway.snapshot,
location: createPluginsRouteLocation("/settings/plugins/discover"),
result: createResult(),
error: null,
},
);
const search = page.querySelector<HTMLInputElement>("#plugins-global-search")!;
search.value = "first";
@@ -364,13 +301,7 @@ describe("PluginsPage", () => {
});
const { page } = await mountPage(
createContext(harness.gateway, refreshConfig, runtimeConfigState),
{
gateway: harness.gateway,
gatewaySnapshot: harness.gateway.snapshot,
location: createPluginsRouteLocation(),
result: createResult(),
error: null,
},
createPluginsRouteData(harness.gateway),
);
await clickRowAction(page, '[data-plugin-id="workboard"]', "Enable");
@@ -393,17 +324,8 @@ describe("PluginsPage", () => {
});
const harness = createGateway(client);
const { page } = await mountPage(
createContext(
harness.gateway,
vi.fn(async () => undefined),
),
{
gateway: harness.gateway,
gatewaySnapshot: harness.gateway.snapshot,
location: createPluginsRouteLocation(),
result: createResult(),
error: null,
},
createContext(harness.gateway),
createPluginsRouteData(harness.gateway),
);
await clickRowAction(page, '[data-plugin-id="workboard"]', "Enable");
@@ -435,17 +357,8 @@ describe("PluginsPage", () => {
});
const harness = createGateway(client);
const { page } = await mountPage(
createContext(
harness.gateway,
vi.fn(async () => undefined),
),
{
gateway: harness.gateway,
gatewaySnapshot: harness.gateway.snapshot,
location: createPluginsRouteLocation(),
result: createResult(),
error: null,
},
createContext(harness.gateway),
createPluginsRouteData(harness.gateway),
);
clickHubTab(page, "discover");
@@ -485,17 +398,8 @@ describe("PluginsPage", () => {
});
const harness = createGateway(client);
const { page } = await mountPage(
createContext(
harness.gateway,
vi.fn(async () => undefined),
),
{
gateway: harness.gateway,
gatewaySnapshot: harness.gateway.snapshot,
location: createPluginsRouteLocation(),
result: createResult(),
error: null,
},
createContext(harness.gateway),
createPluginsRouteData(harness.gateway),
);
page.querySelector<HTMLButtonElement>(".plugins-refresh")?.click();
@@ -537,13 +441,7 @@ describe("PluginsPage", () => {
});
const { page } = await mountPage(
createContext(harness.gateway, refreshConfig, runtimeConfigState),
{
gateway: harness.gateway,
gatewaySnapshot: harness.gateway.snapshot,
location: createPluginsRouteLocation(),
result: createResult(),
error: null,
},
createPluginsRouteData(harness.gateway),
);
await clickRowAction(page, '[data-plugin-id="workboard"]', "Enable");
@@ -587,13 +485,10 @@ describe("PluginsPage", () => {
const refreshConfig = vi.fn(async () => {
await replacementClient.request("config.get", {});
});
const { page } = await mountPage(createContext(harness.gateway, refreshConfig), {
gateway: harness.gateway,
gatewaySnapshot: harness.gateway.snapshot,
location: createPluginsRouteLocation(),
result: disabledResult,
error: null,
});
const { page } = await mountPage(
createContext(harness.gateway, refreshConfig),
createPluginsRouteData(harness.gateway, disabledResult),
);
await clickRowAction(page, '[data-plugin-id="workboard"]', "Enable");
expect(page.busy["plugin:workboard"]).toBe(true);
@@ -638,17 +533,12 @@ describe("PluginsPage", () => {
});
const harness = createGateway(client);
const { page } = await mountPage(
createContext(
harness.gateway,
vi.fn(async () => undefined),
),
{
gateway: harness.gateway,
gatewaySnapshot: harness.gateway.snapshot,
location: createPluginsRouteLocation(),
result: { plugins: [createPlugin(), removable], diagnostics: [], mutationAllowed: true },
error: null,
},
createContext(harness.gateway),
createPluginsRouteData(harness.gateway, {
plugins: [createPlugin(), removable],
diagnostics: [],
mutationAllowed: true,
}),
);
await clickRowAction(page, '[data-plugin-id="community-thing"]', "Remove");
+93 -110
View File
@@ -716,7 +716,18 @@ class PluginsPage extends OpenClawLightDomElement {
return errors.length > 0 ? errors.join(" ") : null;
}
private async install(rowKey: string, request: PluginInstallRequest) {
private async runPluginMutation<Result>(
rowKey: string,
mutate: (client: GatewayBrowserClient) => Promise<Result>,
onSuccess: (
result: Result,
client: GatewayBrowserClient,
isCurrent: () => boolean,
) => Promise<void>,
onError: (error: unknown) => void = (error) => {
this.setMessage(rowKey, { kind: "error", text: errorMessage(error) });
},
): Promise<void> {
const client = this.client;
if (!client || !this.canMutate() || this.busy[rowKey]) {
return;
@@ -730,127 +741,99 @@ class PluginsPage extends OpenClawLightDomElement {
this.setBusy(rowKey, true);
this.setMessage(rowKey, null);
try {
const result = await installPlugin(client, request);
const result = await mutate(client);
if (!isCurrent()) {
return;
}
this.applyMutationResult(result);
this.setMessage(rowKey, {
kind: "success",
text: mutationSuccessMessage("installed", result),
});
await this.refreshAfterMutation(client);
await onSuccess(result, client, isCurrent);
} catch (error) {
if (!isCurrent()) {
return;
if (isCurrent()) {
onError(error);
}
const trust = readPluginInstallTrustError(error);
const packageName = request.source === "clawhub" ? request.packageName : null;
if (packageName && pluginInstallNeedsRiskAcknowledgement(error)) {
} finally {
if (this.mutationTokens.get(rowKey) === mutationToken) {
this.mutationTokens.delete(rowKey);
this.setBusy(rowKey, false);
}
}
}
private async install(rowKey: string, request: PluginInstallRequest): Promise<void> {
await this.runPluginMutation(
rowKey,
(client) => installPlugin(client, request),
async (result, client) => {
this.applyMutationResult(result);
this.setMessage(rowKey, {
kind: "error",
text: trust?.warning ?? t("pluginsPage.defaultRiskWarning"),
acknowledge: {
packageName,
...(trust?.version ? { version: trust.version } : {}),
},
kind: "success",
text: mutationSuccessMessage("installed", result),
});
} else {
await this.refreshAfterMutation(client);
},
(error) => {
const trust = readPluginInstallTrustError(error);
const packageName = request.source === "clawhub" ? request.packageName : null;
if (packageName && pluginInstallNeedsRiskAcknowledgement(error)) {
this.setMessage(rowKey, {
kind: "error",
text: trust?.warning ?? t("pluginsPage.defaultRiskWarning"),
acknowledge: {
packageName,
...(trust?.version ? { version: trust.version } : {}),
},
});
return;
}
this.setMessage(rowKey, { kind: "error", text: errorMessage(error) });
}
} finally {
if (this.mutationTokens.get(rowKey) === mutationToken) {
this.mutationTokens.delete(rowKey);
this.setBusy(rowKey, false);
}
}
},
);
}
private async updateEnabled(pluginId: string, enabled: boolean, key = pluginRowKey(pluginId)) {
const client = this.client;
if (!client || !this.canMutate() || this.busy[key]) {
return;
}
const sourceGeneration = this.sourceGeneration;
const mutationToken = ++this.mutationToken;
this.mutationTokens.set(key, mutationToken);
const isCurrent = () =>
this.isCurrentSource(client, sourceGeneration) &&
this.mutationTokens.get(key) === mutationToken;
this.setBusy(key, true);
this.setMessage(key, null);
try {
const result = await setPluginEnabled(client, pluginId, enabled);
if (!isCurrent()) {
return;
}
this.applyMutationResult(result);
this.setMessage(key, {
kind: "success",
text: mutationSuccessMessage(enabled ? "enabled" : "disabled", result),
});
if (enabled) {
this.pinEnabledPluginRoute(pluginId);
}
await this.refreshAfterMutation(client);
if (isCurrent() && !result.restartRequired) {
// Plugin-provided tabs are projected in the connection hello. Re-handshake
// after the registry refresh so sidebar navigation reflects this mutation.
this.context.gateway.connect();
}
} catch (error) {
if (isCurrent()) {
this.setMessage(key, { kind: "error", text: errorMessage(error) });
}
} finally {
if (this.mutationTokens.get(key) === mutationToken) {
this.mutationTokens.delete(key);
this.setBusy(key, false);
}
}
private async updateEnabled(
pluginId: string,
enabled: boolean,
key = pluginRowKey(pluginId),
): Promise<void> {
await this.runPluginMutation(
key,
(client) => setPluginEnabled(client, pluginId, enabled),
async (result, client, isCurrent) => {
this.applyMutationResult(result);
this.setMessage(key, {
kind: "success",
text: mutationSuccessMessage(enabled ? "enabled" : "disabled", result),
});
if (enabled) {
this.pinEnabledPluginRoute(pluginId);
}
await this.refreshAfterMutation(client);
if (isCurrent() && !result.restartRequired) {
// Plugin tabs come from hello; reconnect after the registry refresh.
this.context.gateway.connect();
}
},
);
}
private async uninstall(pluginId: string, rowKey: string) {
const client = this.client;
if (!client || !this.canMutate() || this.busy[rowKey]) {
return;
}
const sourceGeneration = this.sourceGeneration;
const mutationToken = ++this.mutationToken;
this.mutationTokens.set(rowKey, mutationToken);
const isCurrent = () =>
this.isCurrentSource(client, sourceGeneration) &&
this.mutationTokens.get(rowKey) === mutationToken;
this.setBusy(rowKey, true);
this.setMessage(rowKey, null);
try {
const result = await uninstallPlugin(client, pluginId);
if (!isCurrent()) {
return;
}
this.setPendingRemoval(rowKey, false);
// The uninstalled row disappears after refresh, so the restart reminder
// lives in the page-level notice instead of the vanishing row.
this.pageNotice = {
kind: "success",
text: [
t("pluginsPage.removedRestart", { name: result.pluginId }),
...(result.warnings ?? []),
]
.filter(Boolean)
.join("\n"),
};
await this.refreshAfterMutation(client);
} catch (error) {
if (isCurrent()) {
this.setMessage(rowKey, { kind: "error", text: errorMessage(error) });
}
} finally {
if (this.mutationTokens.get(rowKey) === mutationToken) {
this.mutationTokens.delete(rowKey);
this.setBusy(rowKey, false);
}
}
private async uninstall(pluginId: string, rowKey: string): Promise<void> {
await this.runPluginMutation(
rowKey,
(client) => uninstallPlugin(client, pluginId),
async (result, client) => {
this.setPendingRemoval(rowKey, false);
// Removal hides its row, so keep the restart reminder on the page.
this.pageNotice = {
kind: "success",
text: [
t("pluginsPage.removedRestart", { name: result.pluginId }),
...(result.warnings ?? []),
]
.filter(Boolean)
.join("\n"),
};
await this.refreshAfterMutation(client);
},
);
}
private async mutateMcpServers(params: {
+24 -92
View File
@@ -281,15 +281,16 @@ function renderArtTile(
name: string,
iconUrl?: string,
onIconError?: () => void,
className = "plugins-tile",
): TemplateResult {
const art = pluginArtPath(slug);
if (art) {
return html`<span class="plugins-tile">
return html`<span class=${className}>
<img src=${art} alt="" loading="lazy" decoding="async" />
</span>`;
}
if (iconUrl) {
return html`<span class="plugins-tile">
return html`<span class=${className}>
<img
class="plugins-icon"
src=${iconUrl}
@@ -303,7 +304,7 @@ function renderArtTile(
const [from, to] = pluginFallbackGradient(slug);
const monogram = pluginMonogram(name);
return html`<span
class="plugins-tile plugins-tile--fallback"
class=${`${className} ${className}--fallback`}
style=${`--plugins-art-a:${from};--plugins-art-b:${to}`}
aria-hidden="true"
>
@@ -573,7 +574,11 @@ function renderInstalledFilter(props: PluginsViewProps) {
});
}
function renderInstalledRow(plugin: PluginCatalogItem, props: PluginsViewProps): TemplateResult {
function renderPluginRow(
plugin: PluginCatalogItem,
props: PluginsViewProps,
includePackageName = false,
): TemplateResult {
const key = pluginRowKey(plugin.id);
const busy = props.busy[key] ?? false;
return html`
@@ -596,7 +601,9 @@ function renderInstalledRow(plugin: PluginCatalogItem, props: PluginsViewProps):
<h3 class="settings-row__title">
${plugin.name}
${plugin.version
? html`<span class="plugins-version">v${plugin.version}</span>`
? includePackageName
? html`<span class="plugins-version">v${plugin.version}</span>`
: html`<span class="plugins-version">v${plugin.version}</span>`
: nothing}
</h3>
<span class="settings-row__desc">
@@ -604,13 +611,14 @@ function renderInstalledRow(plugin: PluginCatalogItem, props: PluginsViewProps):
</span>
${renderMetaLine([
plugin.origin ? originLabel(plugin.origin) : nothing,
plugin.packageName
includePackageName && plugin.packageName
? html`<span class="plugins-meta__mono">${plugin.packageName}</span>`
: nothing,
])}
</div>
<div class="settings-row__control">
${rowStateStatus(plugin)} ${renderCatalogActions(plugin, props, busy, key)}
${plugin.installed ? rowStateStatus(plugin) : nothing}
${renderCatalogActions(plugin, props, busy, key)}
</div>
${plugin.error
? html`<div class="plugins-row-message plugins-row-message--error" role="alert">
@@ -732,7 +740,7 @@ function renderInstalled(props: PluginsViewProps) {
repeat(
group.plugins,
(plugin) => plugin.id,
(plugin) => renderInstalledRow(plugin, props),
(plugin) => renderPluginRow(plugin, props, true),
),
),
)}
@@ -742,51 +750,6 @@ function renderInstalled(props: PluginsViewProps) {
/* ---------------------------------- discover tab ---------------------------------- */
function renderCatalogRow(plugin: PluginCatalogItem, props: PluginsViewProps): TemplateResult {
const key = pluginRowKey(plugin.id);
const busy = props.busy[key] ?? false;
return html`
<article
class="settings-row plugins-item plugins-item--clickable"
data-plugin-id=${plugin.id}
data-plugin-source=${plugin.origin ?? "unknown"}
data-plugin-status=${plugin.state}
aria-busy=${busy ? "true" : "false"}
@click=${(event: Event) => {
if (!fromInteractiveChild(event)) {
props.onShowDetails(plugin.id);
}
}}
>
${renderArtTile(plugin.id, plugin.name, props.iconUrls[plugin.id], () =>
props.onIconError(plugin.id),
)}
<div class="settings-row__text">
<h3 class="settings-row__title">
${plugin.name}
${plugin.version
? html`<span class="plugins-version">v${plugin.version}</span>`
: nothing}
</h3>
<span class="settings-row__desc">
${plugin.description || t("pluginsPage.optionalCapability")}
</span>
${renderMetaLine([plugin.origin ? originLabel(plugin.origin) : nothing])}
</div>
<div class="settings-row__control">
${plugin.installed ? rowStateStatus(plugin) : nothing}
${renderCatalogActions(plugin, props, busy, key)}
</div>
${plugin.error
? html`<div class="plugins-row-message plugins-row-message--error" role="alert">
${plugin.error}
</div>`
: nothing}
${renderRowMessage(key, props.messages[key], busy, props)}
</article>
`;
}
function renderConnectorRow(
connector: ConnectorSuggestion,
props: PluginsViewProps,
@@ -978,8 +941,8 @@ function renderClawHubGroup(props: PluginsViewProps) {
function renderDiscover(props: PluginsViewProps) {
const shelves = discoverShelves(props.result?.plugins ?? [], props.query);
const featuredRows = shelves.featured.map((plugin) => renderCatalogRow(plugin, props));
const officialRows = shelves.official.map((plugin) => renderCatalogRow(plugin, props));
const featuredRows = shelves.featured.map((plugin) => renderPluginRow(plugin, props));
const officialRows = shelves.official.map((plugin) => renderPluginRow(plugin, props));
const clawHub = renderClawHubGroup(props);
if (!featuredRows.length && !officialRows.length && !shelves.connectors.length) {
return html`
@@ -1064,8 +1027,12 @@ function renderDetailOverlay(props: PluginsViewProps) {
>
${icons.x}
</button>
${renderDetailCover(plugin.id, plugin.name, props.iconUrls[plugin.id], () =>
props.onIconError(plugin.id),
${renderArtTile(
plugin.id,
plugin.name,
props.iconUrls[plugin.id],
() => props.onIconError(plugin.id),
"plugins-cover",
)}
<div class="plugins-detail__body">
<div class="plugins-detail__title">
@@ -1144,41 +1111,6 @@ function renderDetailOverlay(props: PluginsViewProps) {
`;
}
function renderDetailCover(
slug: string,
name: string,
iconUrl?: string,
onIconError?: () => void,
): TemplateResult {
const art = pluginArtPath(slug);
if (art) {
return html`<span class="plugins-cover">
<img src=${art} alt="" loading="lazy" decoding="async" />
</span>`;
}
if (iconUrl) {
return html`<span class="plugins-cover">
<img
class="plugins-icon"
src=${iconUrl}
alt=""
loading="lazy"
decoding="async"
@error=${onIconError}
/>
</span>`;
}
const [from, to] = pluginFallbackGradient(slug);
const monogram = pluginMonogram(name);
return html`<span
class="plugins-cover plugins-cover--fallback"
style=${`--plugins-art-a:${from};--plugins-art-b:${to}`}
aria-hidden="true"
>
${monogram ? html`<span>${monogram}</span>` : icons.puzzle}
</span>`;
}
/* ---------------------------------- page shell ---------------------------------- */
function renderEmpty(title: string, body: string, mood?: "sleepy" | "curious") {