From 5bfc65d7f46a3dd833072403d8f1e05fbbe9c487 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 29 Jul 2026 11:43:41 -0400 Subject: [PATCH] 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 --- config/max-lines-baseline.txt | 2 - extensions/active-memory/transcript-result.ts | 62 +- extensions/active-memory/transcript.ts | 55 +- extensions/anthropic/claude-model-refs.ts | 62 +- extensions/anthropic/config-defaults.ts | 79 +- extensions/anthropic/index.test.ts | 152 +-- .../google/image-generation-provider.test.ts | 43 +- extensions/google/speech-provider.test.ts | 49 +- extensions/matrix/src/onboarding.ts | 133 +- extensions/matrix/src/setup-core.ts | 54 +- extensions/matrix/src/setup-dm-policy.ts | 63 +- .../memory-core/src/memory/index.test.ts | 6 + .../src/memory/manager-embedding-ops.ts | 128 +- extensions/memory-core/src/memory/manager.ts | 33 +- .../src/memory/qmd-runtime-cache.ts | 282 ++-- .../memory-core/src/memory/search-manager.ts | 52 +- extensions/minimax/media-provider-runtime.ts | 49 + .../minimax/music-generation-provider.ts | 59 +- .../minimax/video-generation-provider.ts | 59 +- extensions/msteams/src/setup-core.ts | 46 +- extensions/msteams/src/setup-surface.ts | 14 +- extensions/openrouter/index.ts | 7 +- extensions/openrouter/provider-catalog.ts | 76 +- .../video-generation-provider.test.ts | 330 ++--- extensions/slack/src/setup-core.ts | 16 +- .../xai/video-generation-provider.test.ts | 181 +-- .../check-database-first-legacy-stores.mjs | 165 +-- src/agents/subagent-control.ts | 111 +- src/agents/subagent-registry-public-api.ts | 19 +- src/agents/subagent-registry-queries.ts | 124 +- .../subagent-registry-read-context.test.ts | 32 + src/agents/subagent-registry-read.ts | 54 +- src/agents/subagent-spawn-request.ts | 129 +- src/agents/subagent-spawn.ts | 59 +- .../reply/commands-session-lifecycle.test.ts | 526 +++----- .../reply/commands-session-usage.test.ts | 218 ++- src/auto-reply/reply/commands-session.ts | 265 ++-- src/channels/account-snapshot-fields.test.ts | 23 + src/channels/account-snapshot-fields.ts | 198 ++- .../bundled-channel-catalog-read.test.ts | 96 +- src/channels/plugins/account-helpers.ts | 59 +- .../plugins/bundled.shape-guard.test.ts | 92 +- src/channels/plugins/catalog.ts | 54 +- .../plugins/directory-config-helpers.ts | 46 +- .../plugins/message-action-discovery.ts | 75 +- src/channels/plugins/message-actions.test.ts | 371 ++---- src/channels/plugins/read-only.test.ts | 282 ++-- src/channels/plugins/read-only.ts | 35 +- src/channels/plugins/registry.ts | 12 +- src/channels/status/account-state.ts | 19 +- src/cli/plugins-cli.install.test.ts | 167 +-- .../plugins-cli.marketplace-entries.test.ts | 126 +- .../plugins-cli.marketplace-refresh.test.ts | 103 +- src/cli/plugins-cli.runtime.ts | 132 +- src/cli/plugins-cli.update.test.ts | 161 +-- .../plugins-marketplace-feed.test-support.ts | 51 + ...ng-configured-plugin-install.candidates.ts | 180 ++- ...issing-configured-plugin-install.health.ts | 271 ++-- ...issing-configured-plugin-install.repair.ts | 97 +- .../missing-configured-plugin-install.test.ts | 495 ++----- src/config/config-path-mutation.ts | 18 +- src/config/dangerous-name-matching.ts | 16 +- src/config/gateway-dispatch-config.ts | 11 +- src/config/io.read-helpers.ts | 7 +- src/config/plugin-auto-enable.shared.ts | 94 +- src/config/schema-base.ts | 39 +- src/config/schema.shared.ts | 28 +- src/config/schema.tiers.ts | 125 +- src/config/schema.ts | 43 +- src/config/validation-issues.ts | 36 +- src/cron/service.jobs.test.ts | 570 ++------ src/cron/service/jobs-scheduling.ts | 71 +- src/cron/service/ops-read.ts | 11 +- src/cron/service/timer-scheduler.ts | 12 +- src/cron/service/timer-trigger.ts | 26 +- src/gateway/server-http.ts | 159 ++- .../chat.directive-tags.test.ts | 180 +-- .../server-methods/server-methods.test.ts | 315 ++--- .../server-methods/sessions-dispatch.ts | 303 ++--- src/infra/session-cost-usage-aggregation.ts | 4 +- src/infra/session-cost-usage-cache-runtime.ts | 8 +- src/infra/session-cost-usage-collection.ts | 113 +- src/infra/session-cost-usage-reporting.ts | 47 +- src/infra/session-cost-usage.types.ts | 9 - .../state-migrations.legacy-session-store.ts | 86 +- .../state-migrations.orphan-keys.test.ts | 38 + src/infra/state-migrations.session-store.ts | 168 +-- src/plugins/discovery-threading.test.ts | 16 + src/plugins/discovery.ts | 435 +++--- .../installed-plugin-index-registry.ts | 36 +- src/plugins/loader-discovery.ts | 10 +- src/plugins/plugin-metadata-snapshot.ts | 19 +- src/plugins/plugin-registry-snapshot.ts | 83 +- ...check-database-first-legacy-stores.test.ts | 1170 +++++++---------- .../plugins/plugins-page.routing.test.ts | 47 +- .../plugins/plugins-page.test-support.ts | 10 +- ui/src/pages/plugins/plugins-page.test.ts | 178 +-- ui/src/pages/plugins/plugins-page.ts | 203 ++- ui/src/pages/plugins/view.ts | 116 +- 99 files changed, 4128 insertions(+), 7671 deletions(-) create mode 100644 extensions/minimax/media-provider-runtime.ts create mode 100644 src/cli/plugins-marketplace-feed.test-support.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 250eff502c58..111c593e387d 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -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 diff --git a/extensions/active-memory/transcript-result.ts b/extensions/active-memory/transcript-result.ts index 42553d2b4ccc..649437f0dc79 100644 --- a/extensions/active-memory/transcript-result.ts +++ b/extensions/active-memory/transcript-result.ts @@ -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 { diff --git a/extensions/active-memory/transcript.ts b/extensions/active-memory/transcript.ts index 306c73d4571c..4b12d7cec528 100644 --- a/extensions/active-memory/transcript.ts +++ b/extensions/active-memory/transcript.ts @@ -175,25 +175,21 @@ async function streamActiveMemoryTranscriptRecords(params: { }); } +function resolveToolResultMessage(value: unknown): Record | 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); diff --git a/extensions/anthropic/claude-model-refs.ts b/extensions/anthropic/claude-model-refs.ts index 758941bfd3b4..4306481a93b7 100644 --- a/extensions/anthropic/claude-model-refs.ts +++ b/extensions/anthropic/claude-model-refs.ts @@ -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"; } diff --git a/extensions/anthropic/config-defaults.ts b/extensions/anthropic/config-defaults.ts index b40573ca828f..b1f120e6ee07 100644 --- a/extensions/anthropic/config-defaults.ts +++ b/extensions/anthropic/config-defaults.ts @@ -208,61 +208,32 @@ function modelEntryWithClaudeCliRuntime(entry: unknown): Record return base; } -function collectClaudeCliRuntimeRefs( - model: string | { primary?: string; fallbacks?: string[] } | undefined, -): string[] { - const refs = new Set(); - 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 | undefined, -): string[] { - const refs = new Set(); - 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( - 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 | 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(); + 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]; diff --git a/extensions/anthropic/index.test.ts b/extensions/anthropic/index.test.ts index 653c90bf35ba..c7ba2b39e371 100644 --- a/extensions/anthropic/index.test.ts +++ b/extensions/anthropic/index.test.ts @@ -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; + 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 () => { diff --git a/extensions/google/image-generation-provider.test.ts b/extensions/google/image-generation-provider.test.ts index 8e5ce439d90a..ff690395e99d 100644 --- a/extensions/google/image-generation-provider.test.ts +++ b/extensions/google/image-generation-provider.test.ts @@ -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: [], }, }, diff --git a/extensions/google/speech-provider.test.ts b/extensions/google/speech-provider.test.ts index 0c9164509c7f..5aba70007711 100644 --- a/extensions/google/speech-provider.test.ts +++ b/extensions/google/speech-provider.test.ts @@ -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"), diff --git a/extensions/matrix/src/onboarding.ts b/extensions/matrix/src/onboarding.ts index f706631de06c..85de1d4ba3f3 100644 --- a/extensions/matrix/src/onboarding.ts +++ b/extensions/matrix/src/onboarding.ts @@ -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[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. */ diff --git a/extensions/matrix/src/setup-core.ts b/extensions/matrix/src/setup-core.ts index 8761c1556282..13372cacf50c 100644 --- a/extensions/matrix/src/setup-core.ts +++ b/extensions/matrix/src/setup-core.ts @@ -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, ): 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[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), diff --git a/extensions/matrix/src/setup-dm-policy.ts b/extensions/matrix/src/setup-dm-policy.ts index 10fbabcec859..806a8e132150 100644 --- a/extensions/matrix/src/setup-dm-policy.ts +++ b/extensions/matrix/src/setup-dm-policy.ts @@ -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["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 { + 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[2], + ), + promptAllowFrom, + }); +} diff --git a/extensions/memory-core/src/memory/index.test.ts b/extensions/memory-core/src/memory/index.test.ts index 65a346ed1361..c5f7bf0c92f9 100644 --- a/extensions/memory-core/src/memory/index.test.ts +++ b/extensions/memory-core/src/memory/index.test.ts @@ -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.", diff --git a/extensions/memory-core/src/memory/manager-embedding-ops.ts b/extensions/memory-core/src/memory/manager-embedding-ops.ts index 8067a8e38303..aa8c00a42d60 100644 --- a/extensions/memory-core/src/memory/manager-embedding-ops.ts +++ b/extensions/memory-core/src/memory/manager-embedding-ops.ts @@ -593,67 +593,13 @@ export abstract class MemoryManagerEmbeddingOps extends MemoryManagerSyncOps { texts: string[], generation?: MemorySemanticProviderGeneration, ): Promise { - 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(params: { + items: T[]; + generation?: MemorySemanticProviderGeneration; + operation: "batch" | "structured-batch"; + run: (provider: EmbeddingProvider, items: T[], signal: AbortSignal) => Promise; + }): Promise { + 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, }); diff --git a/extensions/memory-core/src/memory/manager.ts b/extensions/memory-core/src/memory/manager.ts index d2e378716c90..e1cb38cc14cd 100644 --- a/extensions/memory-core/src/memory/manager.ts +++ b/extensions/memory-core/src/memory/manager.ts @@ -1498,27 +1498,8 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem activeProjectKeys?: string[]; }): Promise { 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 { 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, + ): MemorySearchResult[] { + return rows.map((row) => { const result: MemorySearchResult = { path: row.path, startLine: row.start_line, diff --git a/extensions/memory-core/src/memory/qmd-runtime-cache.ts b/extensions/memory-core/src/memory/qmd-runtime-cache.ts index e041fff6d24c..f6ff5dae5453 100644 --- a/extensions/memory-core/src/memory/qmd-runtime-cache.ts +++ b/extensions/memory-core/src/memory/qmd-runtime-cache.ts @@ -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 { - return openMemoryCoreStateStore({ - namespace: QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_NAMESPACE, - maxEntries: QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_MAX_ENTRIES, - }); -} - -function multiCollectionProbeStore(): PluginStateKeyedStore { - return openMemoryCoreStateStore({ - 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( + workspaceDir: string, + namespace: string, + keyHash: string, +): { key: string; store: PluginStateKeyedStore } { + return { + key: memoryCoreWorkspaceEntryKey(workspaceDir, `${namespace}:${keyHash}`), + store: openMemoryCoreStateStore({ 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; - 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; - 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; - 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> { +async function readQmdRuntimeCache(params: { + workspaceDir: string; + namespace: string; + keyHash: string; + nowMs: number; + normalize: (value: unknown, nowMs: number, keyHash: string) => T | undefined; +}): Promise> { try { - const store = collectionValidationStore(); - const key = collectionValidationEntryKey(params); - const expectedKeyHash = buildQmdCollectionValidationCacheContextHash(params); + const { store, key } = resolveQmdRuntimeCache( + 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 { +async function writeQmdRuntimeCache(params: { + workspaceDir: string; + namespace: string; + keyHash: string; + nowMs: number; + ttlMs: number; + payload: Omit; +}): Promise { 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( + 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> { + 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 { + return await writeQmdRuntimeCache({ + 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> { - 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 { - 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({ + 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 { try { - const store = multiCollectionProbeStore(); - await store.delete(multiCollectionProbeEntryKey(params)); + const { store, key } = resolveQmdRuntimeCache( + params.workspaceDir, + QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE, + buildQmdMultiCollectionProbeCacheContextHash(params), + ); + await store.delete(key); } catch { // fail open } diff --git a/extensions/memory-core/src/memory/search-manager.ts b/extensions/memory-core/src/memory/search-manager.ts index f9cbb80d4cc3..2e1796bef22e 100644 --- a/extensions/memory-core/src/memory/search-manager.ts +++ b/extensions/memory-core/src/memory/search-manager.ts @@ -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 | null; +type MemoryManagerSearchOptions = Parameters[1]; type QmdManagerRuntimeConfig = { workspaceDir: string; syncSettings: ReturnType; @@ -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" }, }, }; diff --git a/extensions/minimax/media-provider-runtime.ts b/extensions/minimax/media-provider-runtime.ts new file mode 100644 index 000000000000..de469b197813 --- /dev/null +++ b/extensions/minimax/media-provider-runtime.ts @@ -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[0], + "allowPrivateNetwork" | "dispatcherPolicy" +>; + +export function resolveMinimaxMediaBaseUrl( + cfg: Parameters[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[4] | undefined { + return policy.allowPrivateNetwork || policy.dispatcherPolicy + ? { + ...(policy.allowPrivateNetwork ? { ssrfPolicy: { allowPrivateNetwork: true } } : {}), + ...(policy.dispatcherPolicy ? { dispatcherPolicy: policy.dispatcherPolicy } : {}), + } + : undefined; +} diff --git a/extensions/minimax/music-generation-provider.ts b/extensions/minimax/music-generation-provider.ts index 8b13f484a8fc..b3a9ee561cd7 100644 --- a/extensions/minimax/music-generation-provider.ts +++ b/extensions/minimax/music-generation-provider.ts @@ -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[0], - "allowPrivateNetwork" | "dispatcherPolicy" ->; - -function resolveMinimaxMusicBaseUrl( - cfg: Parameters[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[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}`, }, diff --git a/extensions/minimax/video-generation-provider.ts b/extensions/minimax/video-generation-provider.ts index 094fc8c9c839..e2f6b596057c 100644 --- a/extensions/minimax/video-generation-provider.ts +++ b/extensions/minimax/video-generation-provider.ts @@ -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[0], - "allowPrivateNetwork" | "dispatcherPolicy" ->; - type MinimaxResponseHandle = { response: Response; release: () => Promise; }; -function resolveMinimaxVideoBaseUrl( - cfg: Parameters[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[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", diff --git a/extensions/msteams/src/setup-core.ts b/extensions/msteams/src/setup-core.ts index 951e97d74c2c..688e4cc2580e 100644 --- a/extensions/msteams/src/setup-core.ts +++ b/extensions/msteams/src/setup-core.ts @@ -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 { diff --git a/extensions/msteams/src/setup-surface.ts b/extensions/msteams/src/setup-surface.ts index 8e87a63fd929..ec1191f58a97 100644 --- a/extensions/msteams/src/setup-surface.ts +++ b/extensions/msteams/src/setup-surface.ts @@ -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, - }, - }, - }; + return patchTopLevelChannelConfigSection({ cfg, channel, enabled: true, patch: { teams } }); } function listMSTeamsGroupEntries(cfg: OpenClawConfig): string[] { diff --git a/extensions/openrouter/index.ts b/extensions/openrouter/index.ts index da87aac9891f..d011c419fc18 100644 --- a/extensions/openrouter/index.ts +++ b/extensions/openrouter/index.ts @@ -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(model: }; } -function readRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function sanitizePromptModelId(value: unknown): string | undefined { if (typeof value !== "string") { return undefined; diff --git a/extensions/openrouter/provider-catalog.ts b/extensions/openrouter/provider-catalog.ts index 22752b5e296d..28849ef97f9a 100644 --- a/extensions/openrouter/provider-catalog.ts +++ b/extensions/openrouter/provider-catalog.ts @@ -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 | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - -function readString(record: Record | undefined, key: string): string | undefined { - const value = record?.[key]; - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - -function readPositiveInteger( - record: Record | undefined, - key: string, -): number | undefined { - const value = record?.[key]; - return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined; -} - function readStringArray(record: Record | 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)); }, }); } diff --git a/extensions/pixverse/video-generation-provider.test.ts b/extensions/pixverse/video-generation-provider.test.ts index 4dc5d25ef42e..a150559b49de 100644 --- a/extensions/pixverse/video-generation-provider.test.ts +++ b/extensions/pixverse/video-generation-provider.test.ts @@ -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", - ); - }); }); diff --git a/extensions/slack/src/setup-core.ts b/extensions/slack/src/setup-core.ts index f5a60b3fe40d..a55d16b9e00d 100644 --- a/extensions/slack/src/setup-core.ts +++ b/extensions/slack/src/setup-core.ts @@ -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]) diff --git a/extensions/xai/video-generation-provider.test.ts b/extensions/xai/video-generation-provider.test.ts index a328bb234d6e..31a4313312ed 100644 --- a/extensions/xai/video-generation-provider.test.ts +++ b/extensions/xai/video-generation-provider.test.ts @@ -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({ diff --git a/scripts/check-database-first-legacy-stores.mjs b/scripts/check-database-first-legacy-stores.mjs index fe101fc9cf93..64de0aa9bd17 100644 --- a/scripts/check-database-first-legacy-stores.mjs +++ b/scripts/check-database-first-legacy-stores.mjs @@ -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) { diff --git a/src/agents/subagent-control.ts b/src/agents/subagent-control.ts index 6d8f6e3857e1..6ed4e5a0ca12 100644 --- a/src/agents/subagent-control.ts +++ b/src/agents/subagent-control.ts @@ -364,59 +364,51 @@ async function killSubagentRun(params: { }; } -async function cascadeKillChildren(params: { +async function killSubagentRunTree(params: { cfg: OpenClawConfig; - parentChildSessionKey: string; + runs: Iterable; cache: Map>; - seenChildSessionKeys?: Set; + seenChildSessionKeys: Set; + controllerSessionKey?: string; }): Promise<{ killed: number; labels: string[] }> { - const childRunsBySessionKey = new Map(); - 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(); 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>; + seenChildSessionKeys?: Set; +}): Promise<{ killed: number; labels: string[] }> { + return killSubagentRunTree({ + cfg: params.cfg, + runs: listSubagentRunsForController(params.parentChildSessionKey), + cache: params.cache, + seenChildSessionKeys: params.seenChildSessionKeys ?? new Set(), + 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>(); - const seenChildSessionKeys = new Set(); - 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>(), + seenChildSessionKeys: new Set(), + }); + return { status: "ok" as const, ...result }; } /** Kills one controlled subagent run and any active descendants. */ diff --git a/src/agents/subagent-registry-public-api.ts b/src/agents/subagent-registry-public-api.ts index cf23c126f585..0013a8729604 100644 --- a/src/agents/subagent-registry-public-api.ts +++ b/src/agents/subagent-registry-public-api.ts @@ -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; @@ -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. */ diff --git a/src/agents/subagent-registry-queries.ts b/src/agents/subagent-registry-queries.ts index bd85ce4def83..1a4205cbe2fb 100644 --- a/src/agents/subagent-registry-queries.ts +++ b/src/agents/subagent-registry-queries.ts @@ -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(); const latestSnapshotActiveByChildSessionKey = new Map(); const latestSnapshotEndedByChildSessionKey = new Map(); const latestRunByChildSessionKey = new Map(); @@ -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, childSessionKey: string, ): SubagentRunRecord | undefined { @@ -315,7 +277,7 @@ export function isSubagentSessionRunActiveFromRuns( runs: Map, 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, 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, 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([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; } diff --git a/src/agents/subagent-registry-read-context.test.ts b/src/agents/subagent-registry-read-context.test.ts index ed9c17e64035..b53f25fffec9 100644 --- a/src/agents/subagent-registry-read-context.test.ts +++ b/src/agents/subagent-registry-read-context.test.ts @@ -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); + }); }); diff --git a/src/agents/subagent-registry-read.ts b/src/agents/subagent-registry-read.ts index 0a861b73e122..b05c9ef7914b 100644 --- a/src/agents/subagent-registry-read.ts +++ b/src/agents/subagent-registry-read.ts @@ -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 + ); } diff --git a/src/agents/subagent-spawn-request.ts b/src/agents/subagent-spawn-request.ts index bdbd216c1216..ec3811300674 100644 --- a/src/agents/subagent-spawn-request.ts +++ b/src/agents/subagent-spawn-request.ts @@ -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; } diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index 65a3acc3232a..6fc6289417fe 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -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, diff --git a/src/auto-reply/reply/commands-session-lifecycle.test.ts b/src/auto-reply/reply/commands-session-lifecycle.test.ts index 4ad575afdd54..f0d3401dd051 100644 --- a/src/auto-reply/reply/commands-session-lifecycle.test.ts +++ b/src/auto-reply/reply/commands-session-lifecycle.test.ts @@ -18,6 +18,20 @@ type ResolveCommandConversationParams = { fallbackTo?: string; }; +type IdleLifecycleScenario = { + name: string; + createParams: (commandBody: string) => HandleCommandsParams; + createBinding: () => SessionBindingRecord; + updateBinding: ReturnType; + expectedConversation?: { + channel: string; + accountId: string; + conversationId: string; + parentConversationId: string; + threadId: string; + }; +}; + function firstText(values: Array): 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): SessionBindingRecord { +function createLifecycleBinding( + conversation: SessionBindingRecord["conversation"], + overrides?: Partial, +): 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 { + 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 { - 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 { - 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 { @@ -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; + updateBinding: ReturnType; + 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 () => { diff --git a/src/auto-reply/reply/commands-session-usage.test.ts b/src/auto-reply/reply/commands-session-usage.test.ts index 24c6e6134461..91ddb369b8fa 100644 --- a/src/auto-reply/reply/commands-session-usage.test.ts +++ b/src/auto-reply/reply/commands-session-usage.test.ts @@ -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 = { 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); + } }); }); diff --git a/src/auto-reply/reply/commands-session.ts b/src/auto-reply/reply/commands-session.ts index b10d7f6c2cdc..a545f949728b 100644 --- a/src/auto-reply/reply/commands-session.ts +++ b/src/auto-reply/reply/commands-session.ts @@ -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. */ diff --git a/src/channels/account-snapshot-fields.test.ts b/src/channels/account-snapshot-fields.test.ts index 87bb9b91fc4c..492d5bc3d9fe 100644 --- a/src/channels/account-snapshot-fields.test.ts +++ b/src/channels/account-snapshot-fields.test.ts @@ -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, + }); + }); }); diff --git a/src/channels/account-snapshot-fields.ts b/src/channels/account-snapshot-fields.ts index d5381977b712..0789abc7bff9 100644 --- a/src/channels/account-snapshot-fields.ts +++ b/src/channels/account-snapshot-fields.ts @@ -45,23 +45,21 @@ export function redactChannelAccountSnapshotBaseUrl, key: string): boolean | undefined { - return asBoolean(record[key]); -} - -function readNumber(record: Record, key: string): number | undefined { - const value = record[key]; - return asFiniteNumber(value); -} - function readNullableNumber( record: Record, key: string, ): number | null | undefined { - if (record[key] === null) { - return null; + return record[key] === null ? null : asFiniteNumber(record[key]); +} + +function setSnapshotField( + snapshot: Partial, + key: TKey, + value: ChannelAccountSnapshot[TKey], +): void { + if (value !== undefined) { + snapshot[key] = value; } - return readNumber(record, key); } function readStringArray(record: Record, 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, + | "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 = {}; + 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; } diff --git a/src/channels/bundled-channel-catalog-read.test.ts b/src/channels/bundled-channel-catalog-read.test.ts index b8de1c3e1d18..03b0af221987 100644 --- a/src/channels/bundled-channel-catalog-read.test.ts +++ b/src/channels/bundled-channel-catalog-read.test.ts @@ -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); diff --git a/src/channels/plugins/account-helpers.ts b/src/channels/plugins/account-helpers.ts index 023a619447e1..1edc2dd3248b 100644 --- a/src/channels/plugins/account-helpers.ts +++ b/src/channels/plugins/account-helpers.ts @@ -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..accounts is absent. - if (options?.hasImplicitDefaultAccount?.(cfg)) { - return true; - } const channel = cfg.channels?.[channelKey] as Record | 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 | 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(); - - 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]; diff --git a/src/channels/plugins/bundled.shape-guard.test.ts b/src/channels/plugins/bundled.shape-guard.test.ts index f1a94b062f42..e73a867c8ad3 100644 --- a/src/channels/plugins/bundled.shape-guard.test.ts +++ b/src/channels/plugins/bundled.shape-guard.test.ts @@ -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()], diff --git a/src/channels/plugins/catalog.ts b/src/channels/plugins/catalog.ts index 3ddd0e2435fc..f30fbcf191a7 100644 --- a/src/channels/plugins/catalog.ts +++ b/src/channels/plugins/catalog.ts @@ -201,28 +201,6 @@ function loadCatalogEntriesFromPaths( return entries; } -function loadOfficialCatalogEntriesFromPaths(paths: Iterable): 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(); + 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()) diff --git a/src/channels/plugins/directory-config-helpers.ts b/src/channels/plugins/directory-config-helpers.ts index c26babcacbc8..77086d314a57 100644 --- a/src/channels/plugins/directory-config-helpers.ts +++ b/src/channels/plugins/directory-config-helpers.ts @@ -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; - normalizeId?: (entry: string) => string | null | undefined; -}): string[] { - return collectDirectoryIds(Object.keys(params.groups ?? {}), params.normalizeId); -} - function collectDirectoryIds( values: Iterable, 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)); } diff --git a/src/channels/plugins/message-action-discovery.ts b/src/channels/plugins/message-action-discovery.ts index 5f2765cc01f2..ae4068dbf52e 100644 --- a/src/channels/plugins/message-action-discovery.ts +++ b/src/channels/plugins/message-action-discovery.ts @@ -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(); - 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); } diff --git a/src/channels/plugins/message-actions.test.ts b/src/channels/plugins/message-actions.test.ts index d29ae8aa643f..022688b10cff 100644 --- a/src/channels/plugins/message-actions.test.ts +++ b/src/channels/plugins/message-actions.test.ts @@ -71,6 +71,23 @@ function activateMessageActionTestRegistry() { ); } +function activateDiscoveredMessageActionPlugin(params: { + id: ChannelPlugin["id"]; + label: string; + describeMessageTool: NonNullable["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); diff --git a/src/channels/plugins/read-only.test.ts b/src/channels/plugins/read-only.test.ts index 1b038ab1a116..832cf280f47e 100644 --- a/src/channels/plugins/read-only.test.ts +++ b/src/channels/plugins/read-only.test.ts @@ -34,6 +34,26 @@ function pluginIds(plugins: ReturnType entry.id); } +function createExternalChannelTestConfig(params: { + pluginDir: string; + pluginId?: string; + channels?: Record> | null; +}): Parameters[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; 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, diff --git a/src/channels/plugins/read-only.ts b/src/channels/plugins/read-only.ts index 7ac0bb457dda..7dd48df94b10 100644 --- a/src/channels/plugins/read-only.ts +++ b/src/channels/plugins/read-only.ts @@ -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, }); diff --git a/src/channels/plugins/registry.ts b/src/channels/plugins/registry.ts index c550a2955ee6..079f2f61b210 100644 --- a/src/channels/plugins/registry.ts +++ b/src/channels/plugins/registry.ts @@ -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); } /** diff --git a/src/channels/status/account-state.ts b/src/channels/status/account-state.ts index 082b5a15d6e8..e235c7c4d013 100644 --- a/src/channels/status/account-state.ts +++ b/src/channels/status/account-state.ts @@ -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, }; diff --git a/src/cli/plugins-cli.install.test.ts b/src/cli/plugins-cli.install.test.ts index 3fd7fc00b3ed..2dfa20f09bce 100644 --- a/src/cli/plugins-cli.install.test.ts +++ b/src/cli/plugins-cli.install.test.ts @@ -243,6 +243,29 @@ function primeSuccessfulPluginPersistence(pluginId = "demo") { return { cfg, enabledCfg }; } +function primeSuccessfulClawHubPluginInstall( + params: { + explicitVersion?: boolean; + trust?: Parameters[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"]); diff --git a/src/cli/plugins-cli.marketplace-entries.test.ts b/src/cli/plugins-cli.marketplace-entries.test.ts index ef4e21e51d5f..c38b7399bc21 100644 --- a/src/cli/plugins-cli.marketplace-entries.test.ts +++ b/src/cli/plugins-cli.marketplace-entries.test.ts @@ -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 }); diff --git a/src/cli/plugins-cli.marketplace-refresh.test.ts b/src/cli/plugins-cli.marketplace-refresh.test.ts index 8ea7a98c2f49..5f7306d62779 100644 --- a/src/cli/plugins-cli.marketplace-refresh.test.ts +++ b/src/cli/plugins-cli.marketplace-refresh.test.ts @@ -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({ diff --git a/src/cli/plugins-cli.runtime.ts b/src/cli/plugins-cli.runtime.ts index 7ffe0e78aae3..380efafea4d4 100644 --- a/src/cli/plugins-cli.runtime.ts +++ b/src/cli/plugins-cli.runtime.ts @@ -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 { 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 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)); diff --git a/src/cli/plugins-cli.update.test.ts b/src/cli/plugins-cli.update.test.ts index 2a4128504fe3..275f5796e80c 100644 --- a/src/cli/plugins-cli.update.test.ts +++ b/src/cli/plugins-cli.update.test.ts @@ -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 { + 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 () => { diff --git a/src/cli/plugins-marketplace-feed.test-support.ts b/src/cli/plugins-marketplace-feed.test-support.ts new file mode 100644 index 000000000000..02b6625633e6 --- /dev/null +++ b/src/cli/plugins-marketplace-feed.test-support.ts @@ -0,0 +1,51 @@ +type HostedMarketplaceFeedFixtureOptions = { + source?: "hosted" | "hosted-snapshot"; + entries?: Record[]; + 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, + }, + } + : {}), + }; +} diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.candidates.ts b/src/commands/doctor/shared/missing-configured-plugin-install.candidates.ts index 547790d632e6..78c5a2aad11b 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.candidates.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.candidates.ts @@ -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; + configuredChannelIds: ReadonlySet; + blockedPluginIds?: ReadonlySet; + baselineRecords?: Record; +}) { + 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([ + ...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; + 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; configuredChannelIds: ReadonlySet; @@ -337,7 +431,7 @@ export function collectConfiguredPluginIdsWithMissingChannelConfigDescriptors(pa return stalePluginIds; } -export function collectInstalledPluginIdsWithRepairablePackageDiagnostics(params: { +function collectInstalledPluginIdsWithRepairablePackageDiagnostics(params: { snapshot: PluginMetadataSnapshot; installRecords: Record; }): Set { @@ -404,7 +498,7 @@ function betaCompanionMatchesCurrentStableVersion(params: { return Boolean(installedBase && currentBase && installedBase === currentBase); } -export function collectInstalledPluginIdsWithStaleVersionBoundRuntimePackages(params: { +function collectInstalledPluginIdsWithStaleVersionBoundRuntimePackages(params: { snapshot: PluginMetadataSnapshot; installRecords: Record; configuredPluginIds: ReadonlySet; @@ -464,7 +558,7 @@ function isConfiguredPluginRepairTarget(params: { return false; } -export function collectOfficialReplacementInstallCandidates(params: { +function collectOfficialReplacementInstallCandidates(params: { cfg: OpenClawConfig; env: NodeJS.ProcessEnv; repairablePluginIds: ReadonlySet; diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.health.ts b/src/commands/doctor/shared/missing-configured-plugin-install.health.ts index 160ea31f55cc..d4dce5c6bad8 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.health.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.health.ts @@ -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([ - ...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(); const reportedPluginIds = new Set(); 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, + }; } diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.repair.ts b/src/commands/doctor/shared/missing-configured-plugin-install.repair.ts index b3d0c7e5993b..c30dce60ee9e 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.repair.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.repair.ts @@ -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; }): Promise { 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([ - ...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[] = []; diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts index aff2af6be8ec..cd7964f0ac8f 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts @@ -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)}.`, ]); diff --git a/src/config/config-path-mutation.ts b/src/config/config-path-mutation.ts index 79a3e55ada44..c56eecce41c2 100644 --- a/src/config/config-path-mutation.ts +++ b/src/config/config-path-mutation.ts @@ -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 { - 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 }; } diff --git a/src/config/dangerous-name-matching.ts b/src/config/dangerous-name-matching.ts index 3615de561dbe..474b1bd201e3 100644 --- a/src/config/dangerous-name-matching.ts +++ b/src/config/dangerous-name-matching.ts @@ -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 | null { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return null; - } - return value as Record; -} - /** 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; } diff --git a/src/config/gateway-dispatch-config.ts b/src/config/gateway-dispatch-config.ts index cc4f11f54411..fe6ba9c74b1e 100644 --- a/src/config/gateway-dispatch-config.ts +++ b/src/config/gateway-dispatch-config.ts @@ -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; }; -function isPlainRecord(value: unknown): value is Record { - 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 = {}; @@ -48,7 +45,7 @@ function cloneConfigValue(value: unknown): unknown { } function projectGatewayDispatchConfig(value: unknown): OpenClawConfig { - if (!isPlainRecord(value)) { + if (!isRecord(value)) { return {}; } const projected: Record = {}; @@ -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 }); diff --git a/src/config/io.read-helpers.ts b/src/config/io.read-helpers.ts index df1a5dce8987..b302be27154c 100644 --- a/src/config/io.read-helpers.ts +++ b/src/config/io.read-helpers.ts @@ -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 { diff --git a/src/config/plugin-auto-enable.shared.ts b/src/config/plugin-auto-enable.shared.ts index 2e5e0d28146f..f99fc1894d45 100644 --- a/src/config/plugin-auto-enable.shared.ts +++ b/src/config/plugin-auto-enable.shared.ts @@ -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 | 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 | 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 | undefined; - const existing = channels?.[builtInChannelId]; - const existingRecord = - existing && typeof existing === "object" && !Array.isArray(existing) - ? (existing as Record) - : {}; 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; } diff --git a/src/config/schema-base.ts b/src/config/schema-base.ts index 053d8de3717e..4f039d801be5 100644 --- a/src/config/schema-base.ts +++ b/src/config/schema-base.ts @@ -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; -type JsonSchemaObject = Record & { - title?: string; - description?: string; - properties?: Record; - 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 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); } diff --git a/src/config/schema.shared.ts b/src/config/schema.shared.ts index 4805d765b570..b6e274e472ea 100644 --- a/src/config/schema.shared.ts +++ b/src/config/schema.shared.ts @@ -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 & { type?: string | string[]; - properties?: Record; - additionalProperties?: JsonSchemaObject | boolean; - items?: JsonSchemaObject | JsonSchemaObject[]; - anyOf?: JsonSchemaObject[]; - allOf?: JsonSchemaObject[]; - oneOf?: JsonSchemaObject[]; + title?: string; + description?: string; + properties?: Record; + 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(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; } diff --git a/src/config/schema.tiers.ts b/src/config/schema.tiers.ts index 3d78ecb9188f..e68ec46991ab 100644 --- a/src/config/schema.tiers.ts +++ b/src/config/schema.tiers.ts @@ -1,15 +1,5 @@ import type { ConfigUiHints } from "../shared/config-ui-hints-types.js"; -import { asSchemaObject } from "./schema.shared.js"; - -type JsonSchemaObject = Record & { - type?: string | string[]; - properties?: Record; - 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( + schema: unknown, + initialState: T, + visit: (node: ConfigJsonSchemaObject, path: string, state: T) => T, +): void { + const visited = new WeakMap>(); + 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>(); - 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>(); - 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; } diff --git a/src/config/schema.ts b/src/config/schema.ts index 0fa83abeb672..8ca7b1951812 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -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; type JsonSchemaNode = Record; -type JsonSchemaObject = JsonSchemaNode & { - type?: string | string[]; - properties?: Record; - 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; } diff --git a/src/config/validation-issues.ts b/src/config/validation-issues.ts index bc56e309eb57..088c52bff85c 100644 --- a/src/config/validation-issues.ts +++ b/src/config/validation-issues.ts @@ -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; 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 { diff --git a/src/cron/service.jobs.test.ts b/src/cron/service.jobs.test.ts index 425266b8cf3c..3351b22b68f0 100644 --- a/src/cron/service.jobs.test.ts +++ b/src/cron/service.jobs.test.ts @@ -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); + } } }); diff --git a/src/cron/service/jobs-scheduling.ts b/src/cron/service/jobs-scheduling.ts index 4986ce7d820e..cdfabb179b2a 100644 --- a/src/cron/service/jobs-scheduling.ts +++ b/src/cron/service/jobs-scheduling.ts @@ -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. */ diff --git a/src/cron/service/ops-read.ts b/src/cron/service/ops-read.ts index a4e7de9ea0c3..c33510a27a16 100644 --- a/src/cron/service/ops-read.ts +++ b/src/cron/service/ops-read.ts @@ -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) { diff --git a/src/cron/service/timer-scheduler.ts b/src/cron/service/timer-scheduler.ts index 35c378a3a943..c4b3717deed1 100644 --- a/src/cron/service/timer-scheduler.ts +++ b/src/cron/service/timer-scheduler.ts @@ -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. */ diff --git a/src/cron/service/timer-trigger.ts b/src/cron/service/timer-trigger.ts index 45af505210a6..d905d95ac0ce 100644 --- a/src/cron/service/timer-trigger.ts +++ b/src/cron/service/timer-trigger.ts @@ -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", }; } diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 9bb84595b41d..d7c27362779b 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -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)}`, ); diff --git a/src/gateway/server-methods/chat.directive-tags.test.ts b/src/gateway/server-methods/chat.directive-tags.test.ts index 82004b09d749..16abf14c25b8 100644 --- a/src/gateway/server-methods/chat.directive-tags.test.ts +++ b/src/gateway/server-methods/chat.directive-tags.test.ts @@ -1090,6 +1090,60 @@ async function runNonStreamingChatSend(params: { return chatCall?.[1] as Record | 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; +}) { + 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" && diff --git a/src/gateway/server-methods/server-methods.test.ts b/src/gateway/server-methods/server-methods.test.ts index c2b3bc938bb9..c582604ad60c 100644 --- a/src/gateway/server-methods/server-methods.test.ts +++ b/src/gateway/server-methods/server-methods.test.ts @@ -2802,6 +2802,97 @@ describe("exec approval handlers", () => { return getRequestedExecApprovalPayload(broadcasts); } + async function startAcceptedExecApproval(params: { + handlers: ExecApprovalHandlers; + respond: ReturnType; + context: ReturnType["context"]; + request: Record; + 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, + 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, + 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; @@ -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 () => { diff --git a/src/gateway/server-methods/sessions-dispatch.ts b/src/gateway/server-methods/sessions-dispatch.ts index fd14b1fcb49e..58aeb336959f 100644 --- a/src/gateway/server-methods/sessions-dispatch.ts +++ b/src/gateway/server-methods/sessions-dispatch.ts @@ -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["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[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); } }, }; diff --git a/src/infra/session-cost-usage-aggregation.ts b/src/infra/session-cost-usage-aggregation.ts index 5fdc82bc5393..4c027ebfcaa2 100644 --- a/src/infra/session-cost-usage-aggregation.ts +++ b/src/infra/session-cost-usage-aggregation.ts @@ -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, ); diff --git a/src/infra/session-cost-usage-cache-runtime.ts b/src/infra/session-cost-usage-cache-runtime.ts index 0dfb06b66d19..71f2c02789c9 100644 --- a/src/infra/session-cost-usage-cache-runtime.ts +++ b/src/infra/session-cost-usage-cache-runtime.ts @@ -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 }); } diff --git a/src/infra/session-cost-usage-collection.ts b/src/infra/session-cost-usage-collection.ts index cdd110b6de17..952774707b5e 100644 --- a/src/infra/session-cost-usage-collection.ts +++ b/src/infra/session-cost-usage-collection.ts @@ -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 { - 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> { - if (endOffset !== undefined && endOffset <= startOffset) { - return; - } - const streamOptions: Parameters[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; - } catch { - // Ignore malformed lines - } - } - } finally { - rl.close(); - fileStream.destroy(); - } -} - function loadSqliteUsageTranscriptEvents( marker: SqliteSessionFileMarker, ): Record[] { @@ -300,16 +248,11 @@ function loadSqliteUsageTranscriptEvents( sessionId: marker.sessionId, storePath: marker.storePath, }), - ).filter( - (event): event is Record => - Boolean(event) && typeof event === "object" && !Array.isArray(event), - ); + ).filter(isRecord); } export async function* readTranscriptRecords( filePath: string, - startOffset = 0, - endOffset?: number, ): AsyncGenerator> { 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 { - 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; diff --git a/src/infra/session-cost-usage-reporting.ts b/src/infra/session-cost-usage-reporting.ts index 2dc7825e5f01..cdd0b328cd5d 100644 --- a/src/infra/session-cost-usage-reporting.ts +++ b/src/infra/session-cost-usage-reporting.ts @@ -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; diff --git a/src/infra/session-cost-usage.types.ts b/src/infra/session-cost-usage.types.ts index 5226d333648f..75d783538121 100644 --- a/src/infra/session-cost-usage.types.ts +++ b/src/infra/session-cost-usage.types.ts @@ -8,15 +8,6 @@ import type { export type CostBreakdown = Partial; -export type ParsedUsageEntry = { - usage: NormalizedUsage; - costTotal?: number; - costBreakdown?: CostBreakdown; - provider?: string; - model?: string; - timestamp?: Date; -}; - export type ParsedTranscriptEntry = { message: Record; role?: "user" | "assistant"; diff --git a/src/infra/state-migrations.legacy-session-store.ts b/src/infra/state-migrations.legacy-session-store.ts index bfe2cf98e155..04f364840bbf 100644 --- a/src/infra/state-migrations.legacy-session-store.ts +++ b/src/infra/state-migrations.legacy-session-store.ts @@ -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> = {}; - for (const [rawPluginId, rawPluginState] of Object.entries(entry.pluginExtensions)) { + const normalizedState: Record> = {}; + 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 = {}; 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> = {}; - 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 = {}; - 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 { diff --git a/src/infra/state-migrations.orphan-keys.test.ts b/src/infra/state-migrations.orphan-keys.test.ts index 845032336210..467214040143 100644 --- a/src/infra/state-migrations.orphan-keys.test.ts +++ b/src/infra/state-migrations.orphan-keys.test.ts @@ -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); diff --git a/src/infra/state-migrations.session-store.ts b/src/infra/state-migrations.session-store.ts index 117aecfcaa30..2946912945ff 100644 --- a/src/infra/state-migrations.session-store.ts +++ b/src/infra/state-migrations.session-store.ts @@ -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; storeAgentIds: Iterable; 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; 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; - 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. diff --git a/src/plugins/discovery-threading.test.ts b/src/plugins/discovery-threading.test.ts index 644190cecb91..4f2ea4a22f7e 100644 --- a/src/plugins/discovery-threading.test.ts +++ b/src/plugins/discovery-threading.test.ts @@ -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(); + }); }); diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index 78647110a3d7..f338d6fd9e6a 100644 --- a/src/plugins/discovery.ts +++ b/src/plugins/discovery.ts @@ -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; + candidates: PluginCandidate[]; + diagnostics: PluginDiagnostic[]; + seen: Set; + realpathCache: Map; + packageManifestCache?: Map; +}; + +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; diff --git a/src/plugins/installed-plugin-index-registry.ts b/src/plugins/installed-plugin-index-registry.ts index 412fc5e4e430..a2407fc67dfe 100644 --- a/src/plugins/installed-plugin-index-registry.ts +++ b/src/plugins/installed-plugin-index-registry.ts @@ -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, diff --git a/src/plugins/loader-discovery.ts b/src/plugins/loader-discovery.ts index bf7cb39896d0..56c7b0dce475 100644 --- a/src/plugins/loader-discovery.ts +++ b/src/plugins/loader-discovery.ts @@ -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, -): Record | undefined { - return Object.keys(records).length > 0 ? records : undefined; -} diff --git a/src/plugins/plugin-metadata-snapshot.ts b/src/plugins/plugin-metadata-snapshot.ts index b0103c00f542..d7f8f0602b62 100644 --- a/src/plugins/plugin-metadata-snapshot.ts +++ b/src/plugins/plugin-metadata-snapshot.ts @@ -107,19 +107,6 @@ function freezeSnapshotValue(value: T, seen = new WeakSet()): T { return Object.freeze(value); } -function freezePluginMetadataSnapshot(snapshot: PluginMetadataSnapshot): PluginMetadataSnapshot { - return freezeSnapshotValue(snapshot); -} - -function resolvePluginMetadataControlPlaneFingerprint( - params: Pick & { - 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, diff --git a/src/plugins/plugin-registry-snapshot.ts b/src/plugins/plugin-registry-snapshot.ts index a9a718e1076d..876cd1ddded3 100644 --- a/src/plugins/plugin-registry-snapshot.ts +++ b/src/plugins/plugin-registry-snapshot.ts @@ -499,13 +499,13 @@ export function loadPluginRegistrySnapshotWithMetadata( // never reuse security-sensitive symlink or plugin-root resolutions. const realpathCache = new Map(); 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, diff --git a/test/scripts/check-database-first-legacy-stores.test.ts b/test/scripts/check-database-first-legacy-stores.test.ts index 2d8adbaf459c..26b242702b5f 100644 --- a/test/scripts/check-database-first-legacy-stores.test.ts +++ b/test/scripts/check-database-first-legacy-stores.test.ts @@ -23,7 +23,7 @@ function filesystemWriteViolations(...lines: number[]): LegacyStoreViolations { function sourceCase(source: TemplateStringsArray) { return (filename: string, expected: LegacyStoreViolations): UnnamedViolationCase => ({ source: source.join(""), - filename, + filename: filename.includes("/") ? filename : `src/runtime/${filename}`, expected, }); } @@ -136,14 +136,14 @@ describe("check-database-first-legacy-stores", () => { return value; } normalize([]); - `("src/runtime/self-recursive-helper.ts", []), + `("self-recursive-helper.ts", []), "flags runtime writes to legacy sessions.json stores": sourceCase` import { promises as fs } from "node:fs"; import path from "node:path"; export async function save(dir: string) { await fs.writeFile(path.join(dir, "sessions.json"), "{}\\n", "utf8"); } - `("src/runtime/session-writer.ts", filesystemWriteViolations(5)), + `("session-writer.ts", filesystemWriteViolations(5)), }), )("$name", ({ source, filename, expected }) => { const violations = collectDatabaseFirstLegacyStoreViolations(source, filename); @@ -262,19 +262,19 @@ describe("check-database-first-legacy-stores", () => { const storePath = path.join(dir, "sessions.json"); await fs.writeFile(storePath, "{}\\n", "utf8"); } - `("src/runtime/session-writer.ts", filesystemWriteViolations(6)), + `("session-writer.ts", filesystemWriteViolations(6)), "flags writes through property access on legacy path variables": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; const storePath = "sessions.json"; await writeTextAtomic(storePath.toString(), "{}\\n"); - `("src/runtime/legacy-path-property-access.ts", filesystemWriteViolations(4)), + `("legacy-path-property-access.ts", filesystemWriteViolations(4)), "flags legacy paths split across path.join segments": sourceCase` import { promises as fs } from "node:fs"; import path from "node:path"; await fs.writeFile(path.join(stateDir, "cron", "jobs.json"), "{}\\n", "utf8"); const sidecarPath = path.join(root, "plugin-state", "state.sqlite"); await fs.writeFile(sidecarPath, ""); - `("src/runtime/legacy-state.ts", filesystemWriteViolations(4, 6)), + `("legacy-state.ts", filesystemWriteViolations(4, 6)), "flags runtime writes to the retired TUI last-session store": sourceCase` import { promises as fs } from "node:fs"; import path from "node:path"; @@ -345,18 +345,18 @@ describe("check-database-first-legacy-stores", () => { import { promises as fs } from "node:fs"; import path from "node:path"; await fs.writeFile(path.join(stateDir, "agents", agentId, "agent", "auth.json"), "{}\\n"); - `("src/runtime/dynamic-agent-auth.ts", filesystemWriteViolations(4)), + `("dynamic-agent-auth.ts", filesystemWriteViolations(4)), "flags legacy paths with dynamic segments and constant filenames": sourceCase` import { promises as fs } from "node:fs"; import path from "node:path"; const AUTH_FILE = "auth.json"; await fs.writeFile(path.join(stateDir, "agents", agentId, "agent", AUTH_FILE), "{}\\n"); - `("src/runtime/dynamic-agent-auth-constant.ts", filesystemWriteViolations(5)), + `("dynamic-agent-auth-constant.ts", filesystemWriteViolations(5)), "flags legacy JSONL paths with dynamic template filenames": sourceCase` import { promises as fs } from "node:fs"; import path from "node:path"; await fs.appendFile(path.join(stateDir, "cron", "runs", \`\${runId}.jsonl\`), "{}\\n"); - `("src/runtime/dynamic-cron-run.ts", filesystemWriteViolations(4)), + `("dynamic-cron-run.ts", filesystemWriteViolations(4)), "flags legacy paths assembled from filename constants": sourceCase` import { promises as fs } from "node:fs"; import path from "node:path"; @@ -367,14 +367,14 @@ describe("check-database-first-legacy-stores", () => { await fs.writeFile(storePath, "{}\\n", "utf8"); await fs.writeFile(path.join(stateDir, "cron", JOBS_FILE), "{}\\n"); await fs.writeFile(path.join(stateDir, "plugin-state", SQLITE_FILE), ""); - `("src/runtime/constant-session-store.ts", filesystemWriteViolations(8, 9, 10)), + `("constant-session-store.ts", filesystemWriteViolations(8, 9, 10)), "flags legacy paths assembled from template literal constants": sourceCase` import { promises as fs } from "node:fs"; const sessionBase = "sessions"; const cronRuns = "cron/runs"; await fs.writeFile(\`\${sessionBase}.json\`, "{}\\n"); await fs.appendFile(\`\${cronRuns}/job.jsonl\`, "{}\\n"); - `("src/runtime/template-constant-legacy-store.ts", filesystemWriteViolations(5, 6)), + `("template-constant-legacy-store.ts", filesystemWriteViolations(5, 6)), "does not leak conditional literal path constants": sourceCase` import { promises as fs } from "node:fs"; import path from "node:path"; @@ -384,7 +384,7 @@ describe("check-database-first-legacy-stores", () => { console.log(JOBS_FILE); } await fs.writeFile(path.join(stateDir, "cron", JOBS_FILE), "{}\\n"); - `("src/runtime/conditional-literal-shadow.ts", []), + `("conditional-literal-shadow.ts", []), "keeps conditional literal reassignment candidates": sourceCase` import { promises as fs } from "node:fs"; import path from "node:path"; @@ -393,7 +393,7 @@ describe("check-database-first-legacy-stores", () => { JOBS_FILE = "jobs.json"; } await fs.writeFile(path.join(stateDir, "cron", JOBS_FILE), "{}\\n"); - `("src/runtime/conditional-literal-reassignment.ts", filesystemWriteViolations(8)), + `("conditional-literal-reassignment.ts", filesystemWriteViolations(8)), "keeps known literal candidates when conditional reassignment is dynamic": sourceCase` import { promises as fs } from "node:fs"; import path from "node:path"; @@ -402,7 +402,7 @@ describe("check-database-first-legacy-stores", () => { JOBS_FILE = getJobsFile(); } await fs.writeFile(path.join(stateDir, "cron", JOBS_FILE), "{}\\n"); - `("src/runtime/conditional-dynamic-literal-reassignment.ts", filesystemWriteViolations(8)), + `("conditional-dynamic-literal-reassignment.ts", filesystemWriteViolations(8)), "drops stale literal candidates after exhaustive branch reassignment": sourceCase` import { promises as fs } from "node:fs"; import path from "node:path"; @@ -413,7 +413,7 @@ describe("check-database-first-legacy-stores", () => { JOBS_FILE = "active.json"; } await fs.writeFile(path.join(stateDir, "cron", JOBS_FILE), "{}\\n"); - `("src/runtime/exhaustive-literal-reassignment.ts", []), + `("exhaustive-literal-reassignment.ts", []), "keeps known literal candidates after exhaustive dynamic branch reassignment": sourceCase` import { promises as fs } from "node:fs"; import path from "node:path"; @@ -424,7 +424,7 @@ describe("check-database-first-legacy-stores", () => { JOBS_FILE = getJobsFile(); } await fs.writeFile(path.join(stateDir, "cron", JOBS_FILE), "{}\\n"); - `("src/runtime/exhaustive-dynamic-literal-reassignment.ts", filesystemWriteViolations(10)), + `("exhaustive-dynamic-literal-reassignment.ts", filesystemWriteViolations(10)), "drops stale literal candidates after exhaustive dynamic branch reassignment": sourceCase` import { promises as fs } from "node:fs"; import path from "node:path"; @@ -435,7 +435,7 @@ describe("check-database-first-legacy-stores", () => { JOBS_FILE = getJobsFile(); } await fs.writeFile(path.join(stateDir, "cron", JOBS_FILE), "{}\\n"); - `("src/runtime/exhaustive-dynamic-stale-literal-reassignment.ts", []), + `("exhaustive-dynamic-stale-literal-reassignment.ts", []), // Filesystem bindings, aliases, and helper writes. "flags imported and destructured fs write aliases": sourceCase` @@ -443,15 +443,15 @@ describe("check-database-first-legacy-stores", () => { const { appendFile: append } = fs; await persist("sessions.json", "{}\\n", "utf8"); await append("cron/runs/job.jsonl", "{}\\n"); - `("src/runtime/aliased-fs.ts", filesystemWriteViolations(4, 5)), + `("aliased-fs.ts", filesystemWriteViolations(4, 5)), "flags helper writes through namespace imports": sourceCase` import * as jsonFiles from "../infra/json-files.js"; await jsonFiles.writeJson("sessions.json", {}); - `("src/runtime/helper-namespace-write.ts", filesystemWriteViolations(3)), + `("helper-namespace-write.ts", filesystemWriteViolations(3)), "flags private file store writes to legacy paths": sourceCase` import { privateFileStore } from "openclaw/plugin-sdk/security-runtime"; await privateFileStore(stateDir).writeJson("thread-bindings.json", {}); - `("src/runtime/private-file-store-write.ts", filesystemWriteViolations(3)), + `("private-file-store-write.ts", filesystemWriteViolations(3)), "flags fs-safe factory aliases writing legacy paths": sourceCase` import { privateFileStore } from "openclaw/plugin-sdk/security-runtime"; import * as fsSafe from "openclaw/plugin-sdk/security-runtime"; @@ -461,18 +461,18 @@ describe("check-database-first-legacy-stores", () => { await makePrivateStore(stateDir).writeJson("thread-bindings.json", {}); await (await makeRoot(stateDir)).writeJson("plugin-binding-approvals.json", {}); await makeFromNamespace(stateDir).writeJson("gateway-restart-intent.json", {}); - `("src/runtime/fs-safe-factory-alias-write.ts", filesystemWriteViolations(7, 8, 9)), + `("fs-safe-factory-alias-write.ts", filesystemWriteViolations(7, 8, 9)), "flags fs-safe root writes to legacy paths": sourceCase` import { root } from "openclaw/plugin-sdk/security-runtime"; const state = await root(stateDir); await state.writeJson("plugin-binding-approvals.json", {}); await (await root(stateDir)).writeJson("thread-bindings.json", {}); - `("src/runtime/fs-safe-root-write.ts", filesystemWriteViolations(4, 5)), + `("fs-safe-root-write.ts", filesystemWriteViolations(4, 5)), "flags bare fs-safe package root writes to legacy paths": sourceCase` import { root } from "@openclaw/fs-safe"; const state = await root(stateDir); await state.writeJson("thread-bindings.json", {}); - `("src/runtime/bare-fs-safe-root-write.ts", filesystemWriteViolations(4)), + `("bare-fs-safe-root-write.ts", filesystemWriteViolations(4)), "flags file access runtime root writes to legacy paths": sourceCase` import { root } from "openclaw/plugin-sdk/file-access-runtime"; const state = await root(stateDir); @@ -483,26 +483,26 @@ describe("check-database-first-legacy-stores", () => { const state = await privateFileStore(stateDir).root(); await state.writeJson("thread-bindings.json", {}); await (await privateFileStore(stateDir).root()).writeJson("plugin-binding-approvals.json", {}); - `("src/runtime/fs-safe-store-root-write.ts", filesystemWriteViolations(4, 5)), + `("fs-safe-store-root-write.ts", filesystemWriteViolations(4, 5)), "allows fs-safe store reads from legacy paths": sourceCase` import { privateFileStore } from "openclaw/plugin-sdk/security-runtime"; const store = privateFileStore(stateDir); await store.readJson("thread-bindings.json"); - `("src/runtime/private-file-store-read.ts", []), + `("private-file-store-read.ts", []), "flags fs-safe JSON store writes to legacy paths": sourceCase` import { privateFileStore } from "openclaw/plugin-sdk/security-runtime"; await privateFileStore(stateDir).json("thread-bindings.json").write({}); const bindings = privateFileStore(stateDir).json("plugin-binding-approvals.json"); await bindings.update((current) => current ?? {}); await privateFileStore(stateDir).json("gateway-restart-intent.json").updateOr({}, (current) => current); - `("src/runtime/private-file-json-store-write.ts", filesystemWriteViolations(3, 5, 6)), + `("private-file-json-store-write.ts", filesystemWriteViolations(3, 5, 6)), "flags direct fs-safe package store writes to legacy paths": sourceCase` import { fileStore, jsonStore } from "@openclaw/fs-safe/store"; await fileStore({ rootDir: stateDir }).writeJson("thread-bindings.json", {}); const options = { filePath: "plugin-binding-approvals.json" }; await jsonStore(options).write({}); await jsonStore({ filePath: "gateway-restart-intent.json" }).update((current) => current ?? {}); - `("src/runtime/direct-fs-safe-store-write.ts", filesystemWriteViolations(3, 5, 6)), + `("direct-fs-safe-store-write.ts", filesystemWriteViolations(3, 5, 6)), "flags fs-safe store object aliases writing legacy paths": sourceCase` import { privateFileStore } from "openclaw/plugin-sdk/security-runtime"; const jsonBindings = privateFileStore(stateDir).json("plugin-binding-approvals.json"); @@ -516,7 +516,7 @@ describe("check-database-first-legacy-stores", () => { stores.bindings = privateFileStore(stateDir).json("gateway-restart-intent.json"); await stores.state.writeJson("thread-bindings.json", {}); await stores.bindings.update((current) => current ?? {}); - `("src/runtime/fs-safe-store-object-alias-write.ts", filesystemWriteViolations(8, 9, 13)), + `("fs-safe-store-object-alias-write.ts", filesystemWriteViolations(8, 9, 13)), "flags fs-safe store object aliases copied through spreads and nested objects": sourceCase` import { privateFileStore } from "openclaw/plugin-sdk/security-runtime"; const base = { state: privateFileStore(stateDir) }; @@ -524,13 +524,13 @@ describe("check-database-first-legacy-stores", () => { const nested = { inner: { bindings: privateFileStore(stateDir).json("plugin-binding-approvals.json") } }; await stores.state.writeJson("thread-bindings.json", {}); await nested.inner.bindings.write({}); - `("src/runtime/fs-safe-store-spread-object-alias-write.ts", filesystemWriteViolations(6, 7)), + `("fs-safe-store-spread-object-alias-write.ts", filesystemWriteViolations(6, 7)), "flags fs-safe store object aliases assigned through nested object properties": sourceCase` import { privateFileStore } from "openclaw/plugin-sdk/security-runtime"; const stores = {}; stores.inner = { bindings: privateFileStore(stateDir).json("thread-bindings.json") }; await stores.inner.bindings.write({}); - `("src/runtime/assigned-nested-fs-safe-store-object-alias.ts", filesystemWriteViolations(5)), + `("assigned-nested-fs-safe-store-object-alias.ts", filesystemWriteViolations(5)), "flags fs-safe store object aliases copied through destructuring": sourceCase` import { privateFileStore } from "openclaw/plugin-sdk/security-runtime"; const stores = { state: privateFileStore(stateDir) }; @@ -539,10 +539,7 @@ describe("check-database-first-legacy-stores", () => { const { inner: { bindings } } = nested; await state.writeJson("thread-bindings.json", {}); await bindings.write({}); - `( - "src/runtime/fs-safe-store-destructured-object-alias-write.ts", - filesystemWriteViolations(7, 8), - ), + `("fs-safe-store-destructured-object-alias-write.ts", filesystemWriteViolations(7, 8)), "clears fs-safe store object aliases after exhaustive property reassignment": sourceCase` import { privateFileStore } from "openclaw/plugin-sdk/security-runtime"; const stores = { state: privateFileStore(stateDir) }; @@ -552,7 +549,7 @@ describe("check-database-first-legacy-stores", () => { stores.state = customB; } await stores.state.writeJson("thread-bindings.json", {}); - `("src/runtime/exhaustive-fs-safe-store-property-reassignment.ts", []), + `("exhaustive-fs-safe-store-property-reassignment.ts", []), "clears nested fs-safe store object aliases after exhaustive property reassignment": sourceCase` import { privateFileStore } from "openclaw/plugin-sdk/security-runtime"; @@ -563,7 +560,7 @@ describe("check-database-first-legacy-stores", () => { stores.inner = { bindings: customB }; } await stores.inner.bindings.write({}); - `("src/runtime/exhaustive-nested-fs-safe-store-property-reassignment.ts", []), + `("exhaustive-nested-fs-safe-store-property-reassignment.ts", []), "keeps fs-safe store object aliases when one exhaustive property branch remains a store": sourceCase` import { privateFileStore } from "openclaw/plugin-sdk/security-runtime"; @@ -574,23 +571,20 @@ describe("check-database-first-legacy-stores", () => { stores.state = privateFileStore(stateDir); } await stores.state.writeJson("thread-bindings.json", {}); - `( - "src/runtime/exhaustive-fs-safe-store-property-partial-reassignment.ts", - filesystemWriteViolations(9), - ), + `("exhaustive-fs-safe-store-property-partial-reassignment.ts", filesystemWriteViolations(9)), "flags direct fs-safe package namespace store writes to legacy paths": sourceCase` import * as fsSafeStore from "@openclaw/fs-safe/store"; const store = fsSafeStore.fileStoreSync({ rootDir: stateDir }); store.writeJson("thread-bindings.json", {}); const bindings = fsSafeStore.jsonStore({ filePath: "plugin-binding-approvals.json" }); await bindings.write({}); - `("src/runtime/direct-fs-safe-store-namespace-write.ts", filesystemWriteViolations(4, 6)), + `("direct-fs-safe-store-namespace-write.ts", filesystemWriteViolations(4, 6)), "allows fs-safe JSON store reads from legacy paths": sourceCase` import { privateFileStore } from "openclaw/plugin-sdk/security-runtime"; const bindings = privateFileStore(stateDir).json("thread-bindings.json"); await bindings.read(); await privateFileStore(stateDir).json("plugin-binding-approvals.json").readOr({}); - `("src/runtime/private-file-json-store-read.ts", []), + `("private-file-json-store-read.ts", []), "clears fs-safe store aliases after exhaustive non-store reassignment": sourceCase` import { privateFileStore } from "openclaw/plugin-sdk/security-runtime"; let store = privateFileStore(stateDir); @@ -600,7 +594,7 @@ describe("check-database-first-legacy-stores", () => { store = customB; } await store.writeJson("thread-bindings.json", {}); - `("src/runtime/exhaustive-fs-safe-store-reassignment.ts", []), + `("exhaustive-fs-safe-store-reassignment.ts", []), "keeps fs-safe store aliases when one exhaustive branch remains a store": sourceCase` import { privateFileStore } from "openclaw/plugin-sdk/security-runtime"; let store = customStore; @@ -610,44 +604,41 @@ describe("check-database-first-legacy-stores", () => { store = privateFileStore(stateDir); } await store.writeJson("thread-bindings.json", {}); - `( - "src/runtime/exhaustive-fs-safe-store-partial-reassignment.ts", - filesystemWriteViolations(9), - ), + `("exhaustive-fs-safe-store-partial-reassignment.ts", filesystemWriteViolations(9)), "clears fs-safe namespace factory aliases after shadowing": sourceCase` import * as fsSafe from "openclaw/plugin-sdk/security-runtime"; async function save(fsSafe: { root(dir: string): Promise<{ writeJson(path: string): void }> }) { await (await fsSafe.root(stateDir)).writeJson("thread-bindings.json"); } - `("src/runtime/fs-safe-namespace-shadow.ts", []), + `("fs-safe-namespace-shadow.ts", []), "ignores helper-like namespace imports from unrelated modules": sourceCase` import * as runtime from "../runtime/json-output.js"; runtime.writeJson("sessions.json", {}); - `("src/runtime/unrelated-helper-namespace.ts", []), + `("unrelated-helper-namespace.ts", []), "ignores helper-like named imports from unrelated modules": sourceCase` import { writeJson } from "../runtime/json-output.js"; writeJson("sessions.json", {}); - `("src/runtime/unrelated-helper-named-import.ts", []), + `("unrelated-helper-named-import.ts", []), "clears namespace helper aliases after shadowing": sourceCase` import * as jsonFiles from "../infra/json-files.js"; function save(jsonFiles: { writeJson(path: string, value: unknown): void }) { jsonFiles.writeJson("sessions.json", {}); } save(customJsonFiles); - `("src/runtime/helper-namespace-shadow.ts", []), + `("helper-namespace-shadow.ts", []), "allows read-only fs open calls and flags write modes": sourceCase` import fs from "node:fs/promises"; await fs.open("sessions.json"); await fs.open("sessions.json", "r"); await fs.open("sessions.json", "r+"); await fs.open("sessions.json", "w"); - `("src/runtime/open-flags.ts", filesystemWriteViolations(5, 6)), + `("open-flags.ts", filesystemWriteViolations(5, 6)), "flags fs copy calls writing legacy store paths": sourceCase` import fs from "node:fs/promises"; import syncFs from "node:fs"; await fs.cp("source.json", "sessions.json"); syncFs.cpSync("source.json", "cron/jobs.json"); - `("src/runtime/fs-copy-legacy-store.ts", filesystemWriteViolations(4, 5)), + `("fs-copy-legacy-store.ts", filesystemWriteViolations(4, 5)), "allows fs copy calls reading from legacy store paths": sourceCase` import fs from "node:fs/promises"; import syncFs from "node:fs"; @@ -655,13 +646,13 @@ describe("check-database-first-legacy-stores", () => { await fs.cp("cron/jobs.json", "state/openclaw.sqlite.import"); syncFs.copyFileSync("auth-profiles.json", "state/openclaw.sqlite.import"); syncFs.cpSync("cache/models.json", "state/openclaw.sqlite.import"); - `("src/runtime/fs-copy-legacy-store-source.ts", []), + `("fs-copy-legacy-store-source.ts", []), "flags fs removal calls targeting legacy store paths": sourceCase` import fs from "node:fs/promises"; import syncFs from "node:fs"; await fs.rm("sessions.json", { force: true }); syncFs.unlinkSync("cron/jobs.json"); - `("src/runtime/fs-remove-legacy-store.ts", filesystemWriteViolations(4, 5)), + `("fs-remove-legacy-store.ts", filesystemWriteViolations(4, 5)), "flags legacy paths destructured from for-of tuple entries": sourceCase` import path from "node:path"; import { root as fsRoot } from "openclaw/plugin-sdk/security-runtime"; @@ -672,7 +663,7 @@ describe("check-database-first-legacy-stores", () => { const root = await fsRoot(rootDir); await root.write(relativePath, content); } - `("src/runtime/for-of-destructured-legacy-path.ts", filesystemWriteViolations(9)), + `("for-of-destructured-legacy-path.ts", filesystemWriteViolations(9)), "applies open write-mode checks inside wrappers": sourceCase` import fs from "node:fs/promises"; function read(path: string) { @@ -683,58 +674,58 @@ describe("check-database-first-legacy-stores", () => { } await read("sessions.json"); await write("sessions.json"); - `("src/runtime/open-wrapper-flags.ts", filesystemWriteViolations(10)), + `("open-wrapper-flags.ts", filesystemWriteViolations(10)), "flags string-literal fs write aliases from destructuring": sourceCase` import fs from "node:fs/promises"; const { "writeFile": persist } = fs; await persist("sessions.json", "{}\\n", "utf8"); - `("src/runtime/string-literal-fs-alias.ts", filesystemWriteViolations(4)), + `("string-literal-fs-alias.ts", filesystemWriteViolations(4)), "flags CommonJS fs write aliases": sourceCase` const fs = require("node:fs"); const { appendFileSync } = require("node:fs"); fs.writeFileSync("sessions.json", "{}\\n"); appendFileSync("cron/runs/job.jsonl", "{}\\n"); - `("src/runtime/commonjs-fs-aliases.ts", filesystemWriteViolations(4, 5)), + `("commonjs-fs-aliases.ts", filesystemWriteViolations(4, 5)), "does not treat local require bindings as CommonJS fs": sourceCase` function save(require: (specifier: string) => { writeFileSync(path: string, value: string): void }) { const fs = require("node:fs"); fs.writeFileSync("sessions.json", ""); } save(customRequire); - `("src/runtime/local-require-binding.ts", []), + `("local-require-binding.ts", []), "flags createRequire-backed CommonJS fs writes": sourceCase` import { createRequire } from "node:module"; const require = createRequire(import.meta.url); const fs = require("node:fs"); fs.writeFileSync("sessions.json", "{}\\n"); - `("src/runtime/create-require-fs.ts", filesystemWriteViolations(5)), + `("create-require-fs.ts", filesystemWriteViolations(5)), "flags createRequire alias-backed CommonJS fs writes": sourceCase` import { createRequire } from "node:module"; const req = createRequire(import.meta.url); const fs = req("node:fs"); fs.writeFileSync("sessions.json", "{}\\n"); - `("src/runtime/create-require-alias-fs.ts", filesystemWriteViolations(5)), + `("create-require-alias-fs.ts", filesystemWriteViolations(5)), "flags copied createRequire alias-backed CommonJS fs writes": sourceCase` import { createRequire } from "node:module"; const req = createRequire(import.meta.url); const req2 = req; const fs = req2("node:fs"); fs.writeFileSync("sessions.json", "{}\\n"); - `("src/runtime/copied-create-require-alias-fs.ts", filesystemWriteViolations(6)), + `("copied-create-require-alias-fs.ts", filesystemWriteViolations(6)), "flags reassigned createRequire alias-backed CommonJS fs writes": sourceCase` import { createRequire } from "node:module"; let req; req = createRequire(import.meta.url); const fs = req("node:fs"); fs.writeFileSync("sessions.json", "{}\\n"); - `("src/runtime/reassigned-create-require-alias-fs.ts", filesystemWriteViolations(6)), + `("reassigned-create-require-alias-fs.ts", filesystemWriteViolations(6)), "flags reassigned createRequire aliases named require": sourceCase` import { createRequire } from "node:module"; let require; require = createRequire(import.meta.url); const fs = require("node:fs"); fs.writeFileSync("sessions.json", "{}\\n"); - `("src/runtime/reassigned-create-require-name.ts", filesystemWriteViolations(6)), + `("reassigned-create-require-name.ts", filesystemWriteViolations(6)), "refreshes hoisted wrappers after createRequire alias reassignment": sourceCase` import { createRequire } from "node:module"; let req; @@ -744,10 +735,7 @@ describe("check-database-first-legacy-stores", () => { } req = createRequire(import.meta.url); persist("sessions.json"); - `( - "src/runtime/hoisted-wrapper-reassigned-create-require-alias.ts", - filesystemWriteViolations(9), - ), + `("hoisted-wrapper-reassigned-create-require-alias.ts", filesystemWriteViolations(9)), "refreshes hoisted wrappers after nested createRequire alias reassignment": sourceCase` import { createRequire } from "node:module"; let req; @@ -759,10 +747,7 @@ describe("check-database-first-legacy-stores", () => { req = createRequire(import.meta.url); } persist("sessions.json"); - `( - "src/runtime/hoisted-wrapper-nested-create-require-alias.ts", - filesystemWriteViolations(11), - ), + `("hoisted-wrapper-nested-create-require-alias.ts", filesystemWriteViolations(11)), "refreshes block-scoped wrappers after nested outer createRequire alias reassignment": sourceCase` import { createRequire } from "node:module"; @@ -777,7 +762,7 @@ describe("check-database-first-legacy-stores", () => { } persist("sessions.json"); } - `("src/runtime/block-wrapper-nested-create-require-alias.ts", filesystemWriteViolations(12)), + `("block-wrapper-nested-create-require-alias.ts", filesystemWriteViolations(12)), "refreshes escaped wrappers after outer createRequire alias reassignment": sourceCase` import { createRequire } from "node:module"; let req; @@ -791,7 +776,7 @@ describe("check-database-first-legacy-stores", () => { } req = createRequire(import.meta.url); persist("sessions.json"); - `("src/runtime/escaped-wrapper-create-require-alias.ts", filesystemWriteViolations(13)), + `("escaped-wrapper-create-require-alias.ts", filesystemWriteViolations(13)), "keeps escaped wrapper local require shadows after outer createRequire alias reassignment": sourceCase` import { createRequire } from "node:module"; @@ -807,7 +792,7 @@ describe("check-database-first-legacy-stores", () => { } req = createRequire(import.meta.url); persist("sessions.json"); - `("src/runtime/escaped-wrapper-local-require-shadow.ts", []), + `("escaped-wrapper-local-require-shadow.ts", []), "does not treat parameter shadows as createRequire aliases": sourceCase` import { createRequire } from "node:module"; const req = createRequire(import.meta.url); @@ -816,7 +801,7 @@ describe("check-database-first-legacy-stores", () => { fs.writeFileSync("sessions.json", ""); } save(customRequire); - `("src/runtime/create-require-parameter-shadow.ts", []), + `("create-require-parameter-shadow.ts", []), "does not treat shadowed createRequire bindings as Node require": sourceCase` import { createRequire } from "node:module"; function save(createRequire: (url: string) => (specifier: string) => { writeFileSync(path: string, value: string): void }) { @@ -825,7 +810,7 @@ describe("check-database-first-legacy-stores", () => { fs.writeFileSync("sessions.json", ""); } save(customCreateRequire); - `("src/runtime/shadowed-create-require.ts", []), + `("shadowed-create-require.ts", []), "does not treat hoisted function createRequire shadows as Node require": sourceCase` import { createRequire } from "node:module"; function run() { @@ -837,44 +822,44 @@ describe("check-database-first-legacy-stores", () => { fs.writeFileSync("sessions.json", "{}\\n"); } run(); - `("src/runtime/hoisted-create-require-shadow.ts", []), + `("hoisted-create-require-shadow.ts", []), "flags CommonJS fs promises aliases": sourceCase` const { promises: fs } = require("node:fs"); const { promises } = require("node:fs"); await fs.writeFile("sessions.json", "{}\\n"); await promises.appendFile("cron/runs/job.jsonl", "{}\\n"); - `("src/runtime/commonjs-fs-promises-aliases.ts", filesystemWriteViolations(4, 5)), + `("commonjs-fs-promises-aliases.ts", filesystemWriteViolations(4, 5)), "flags nested CommonJS fs promises write aliases": sourceCase` const { promises: { writeFile } } = require("node:fs"); await writeFile("sessions.json", "{}\\n"); - `("src/runtime/nested-commonjs-fs-promises-alias.ts", filesystemWriteViolations(3)), + `("nested-commonjs-fs-promises-alias.ts", filesystemWriteViolations(3)), "flags inline CommonJS fs writes": sourceCase` require("node:fs").writeFileSync("sessions.json", "{}\\n"); require("node:fs").promises.writeFile("cron/jobs.json", "{}\\n"); - `("src/runtime/inline-commonjs-fs-write.ts", filesystemWriteViolations(2, 3)), + `("inline-commonjs-fs-write.ts", filesystemWriteViolations(2, 3)), "flags bracketed fs writes": sourceCase` import fs from "node:fs"; await fs["writeFile"]("sessions.json", "{}\\n"); await fs.promises["writeFile"]("cron/runs/job.jsonl", "{}\\n"); require("node:fs")["writeFileSync"]("sessions.json", "{}\\n"); - `("src/runtime/bracketed-fs-writes.ts", filesystemWriteViolations(3, 4, 5)), + `("bracketed-fs-writes.ts", filesystemWriteViolations(3, 4, 5)), "flags dynamic fs import writes": sourceCase` const fs = await import("node:fs/promises"); const nodeFs = await import("node:fs"); await fs.writeFile("sessions.json", "{}\\n"); await nodeFs.promises.appendFile("cron/runs/job.jsonl", "{}\\n"); - `("src/runtime/dynamic-fs-import-write.ts", filesystemWriteViolations(4, 5)), + `("dynamic-fs-import-write.ts", filesystemWriteViolations(4, 5)), "flags dynamic fs import write aliases": sourceCase` const { writeFile } = await import("node:fs/promises"); const { promises } = await import("node:fs"); await writeFile("sessions.json", "{}\\n"); await promises.appendFile("cron/runs/job.jsonl", "{}\\n"); - `("src/runtime/dynamic-fs-import-aliases.ts", filesystemWriteViolations(4, 5)), + `("dynamic-fs-import-aliases.ts", filesystemWriteViolations(4, 5)), "flags dynamic fs import promise callback writes": sourceCase` await import("node:fs/promises").then((fs) => fs.writeFile("sessions.json", "{}\\n"), ); - `("src/runtime/dynamic-fs-import-promise-callback.ts", filesystemWriteViolations(3)), + `("dynamic-fs-import-promise-callback.ts", filesystemWriteViolations(3)), "flags destructured dynamic fs import promise callback writes": sourceCase` await import("node:fs/promises").then(({ writeFile }) => writeFile("sessions.json", "{}\\n"), @@ -885,10 +870,7 @@ describe("check-database-first-legacy-stores", () => { await import("node:fs").then(({ promises: { writeFile: persist } }) => persist("sessions.json", "{}\\n"), ); - `( - "src/runtime/destructured-dynamic-fs-import-promise-callback.ts", - filesystemWriteViolations(3, 6, 9), - ), + `("destructured-dynamic-fs-import-promise-callback.ts", filesystemWriteViolations(3, 6, 9)), "flags write aliases destructured from fs.promises": sourceCase` import * as fs from "node:fs"; const { writeFile: persist } = fs.promises; @@ -896,12 +878,12 @@ describe("check-database-first-legacy-stores", () => { const { appendFile } = fsp; await persist("sessions.json", "{}\\n", "utf8"); await appendFile("cron/runs/job.jsonl", "{}\\n"); - `("src/runtime/fs-promises-aliases.ts", filesystemWriteViolations(6, 7)), + `("fs-promises-aliases.ts", filesystemWriteViolations(6, 7)), "flags fs write method aliases": sourceCase` import fs from "node:fs/promises"; const persist = fs.writeFile; await persist("sessions.json", "{}\\n"); - `("src/runtime/fs-write-method-alias.ts", filesystemWriteViolations(4)), + `("fs-write-method-alias.ts", filesystemWriteViolations(4)), "flags write aliases destructured from local fs module aliases": sourceCase` import fs from "node:fs/promises"; { @@ -909,18 +891,18 @@ describe("check-database-first-legacy-stores", () => { const { writeFile } = storage; await writeFile("sessions.json", "{}\\n"); } - `("src/runtime/local-fs-module-alias.ts", filesystemWriteViolations(6)), + `("local-fs-module-alias.ts", filesystemWriteViolations(6)), "flags nested write aliases destructured from local fs module aliases": sourceCase` const nodeFs = require("node:fs"); const { promises: { writeFile } } = nodeFs; await writeFile("sessions.json", "{}\\n"); - `("src/runtime/nested-local-fs-module-alias.ts", filesystemWriteViolations(4)), + `("nested-local-fs-module-alias.ts", filesystemWriteViolations(4)), "clears fs module aliases after reassignment": sourceCase` import fs from "node:fs/promises"; let writer = fs; writer = customWriter; await writer.writeFile("sessions.json", "{}\\n"); - `("src/runtime/reassigned-fs-module-alias.ts", []), + `("reassigned-fs-module-alias.ts", []), "uses branch-local fs module aliases after conditional assignment": sourceCase` import fs from "node:fs/promises"; let writer; @@ -928,7 +910,7 @@ describe("check-database-first-legacy-stores", () => { writer = fs; await writer.writeFile("sessions.json", "{}\\n"); } - `("src/runtime/conditional-fs-module-alias.ts", filesystemWriteViolations(6)), + `("conditional-fs-module-alias.ts", filesystemWriteViolations(6)), "keeps fs module aliases after conditional assignment": sourceCase` import fs from "node:fs/promises"; let writer; @@ -936,7 +918,7 @@ describe("check-database-first-legacy-stores", () => { writer = fs; } await writer.writeFile("sessions.json", "{}\\n"); - `("src/runtime/conditional-retained-fs-module-alias.ts", filesystemWriteViolations(7)), + `("conditional-retained-fs-module-alias.ts", filesystemWriteViolations(7)), "keeps fs write aliases after conditional assignment": sourceCase` import fs from "node:fs/promises"; let persist; @@ -944,7 +926,7 @@ describe("check-database-first-legacy-stores", () => { persist = fs.writeFile; } await persist("sessions.json", "{}\\n"); - `("src/runtime/conditional-retained-fs-write-alias.ts", filesystemWriteViolations(7)), + `("conditional-retained-fs-write-alias.ts", filesystemWriteViolations(7)), "clears fs module aliases after exhaustive conditional reassignment": sourceCase` import fs from "node:fs/promises"; let writer = fs; @@ -954,7 +936,7 @@ describe("check-database-first-legacy-stores", () => { writer = otherWriter; } await writer.writeFile("sessions.json", "{}\\n"); - `("src/runtime/exhaustive-reassigned-fs-module-alias.ts", []), + `("exhaustive-reassigned-fs-module-alias.ts", []), "keeps uninitialized fs aliases assigned from nested blocks": sourceCase` import fs from "node:fs/promises"; let writer; @@ -965,46 +947,43 @@ describe("check-database-first-legacy-stores", () => { } await writer.writeFile("sessions.json", "{}\\n"); await persist("cron/jobs.json", "{}\\n"); - `( - "src/runtime/nested-assigned-uninitialized-fs-aliases.ts", - filesystemWriteViolations(9, 10), - ), + `("nested-assigned-uninitialized-fs-aliases.ts", filesystemWriteViolations(9, 10)), "flags fs write aliases stored on object properties": sourceCase` import fs from "node:fs/promises"; const writer = { writeFile: fs.writeFile }; await writer.writeFile("sessions.json", "{}\\n"); - `("src/runtime/object-fs-write-alias.ts", filesystemWriteViolations(4)), + `("object-fs-write-alias.ts", filesystemWriteViolations(4)), "flags fs module handles stored on object properties": sourceCase` import fs from "node:fs/promises"; const deps = { fs }; const io = { storage: fs }; await deps.fs.writeFile("sessions.json", "{}\\n"); await io.storage.appendFile("cron/runs/job.jsonl", "{}\\n"); - `("src/runtime/object-fs-module-alias.ts", filesystemWriteViolations(5, 6)), + `("object-fs-module-alias.ts", filesystemWriteViolations(5, 6)), "clears fs write object aliases after object reassignment": sourceCase` import fs from "node:fs/promises"; let writer = { writeFile: fs.writeFile }; writer = customWriter; await writer.writeFile("sessions.json", "{}\\n"); - `("src/runtime/reassigned-object-fs-write-alias.ts", []), + `("reassigned-object-fs-write-alias.ts", []), "clears fs module object aliases after object reassignment": sourceCase` import fs from "node:fs/promises"; let deps = { fs }; deps = customDeps; await deps.fs.writeFile("sessions.json", "{}\\n"); - `("src/runtime/reassigned-object-fs-module-alias.ts", []), + `("reassigned-object-fs-module-alias.ts", []), "flags fs write aliases assigned to object properties": sourceCase` import fs from "node:fs/promises"; const writer: any = {}; writer.writeFile = fs.writeFile; await writer.writeFile("sessions.json", "{}\\n"); - `("src/runtime/assigned-object-fs-write-alias.ts", filesystemWriteViolations(5)), + `("assigned-object-fs-write-alias.ts", filesystemWriteViolations(5)), "flags fs module handles assigned to object properties": sourceCase` import fs from "node:fs/promises"; const deps: any = {}; deps.fs = fs; await deps.fs.writeFile("sessions.json", "{}\\n"); - `("src/runtime/assigned-object-fs-module-alias.ts", filesystemWriteViolations(5)), + `("assigned-object-fs-module-alias.ts", filesystemWriteViolations(5)), "uses branch-local fs object aliases after conditional reassignment": sourceCase` import fs from "node:fs/promises"; const writer = { writeFile: fs.writeFile }; @@ -1012,7 +991,7 @@ describe("check-database-first-legacy-stores", () => { writer.writeFile = customSink; await writer.writeFile("sessions.json", "{}\\n"); } - `("src/runtime/conditional-object-fs-write-alias-reassignment.ts", []), + `("conditional-object-fs-write-alias-reassignment.ts", []), "does not leak local fs module aliases outside their scope": sourceCase` import fs from "node:fs/promises"; { @@ -1025,12 +1004,12 @@ describe("check-database-first-legacy-stores", () => { const { writeFile } = storage; await writeFile("sessions.json", "{}\\n"); } - `("src/runtime/local-fs-module-alias-scope.ts", []), + `("local-fs-module-alias-scope.ts", []), "flags legacy paths written through regular-file helpers": sourceCase` import { appendRegularFile as appendSafe } from "openclaw/plugin-sdk/security-runtime"; const filePath = "session.trajectory.jsonl"; await appendSafe({ filePath, content: "{}\\n" }); - `("src/runtime/regular-file-helper.ts", filesystemWriteViolations(4)), + `("regular-file-helper.ts", filesystemWriteViolations(4)), "flags legacy paths written through JSON and atomic helpers": sourceCase` import { writeJson, writeTextAtomic } from "../infra/json-files.js"; import { replaceFileAtomicSync } from "../infra/replace-file.js"; @@ -1040,7 +1019,7 @@ describe("check-database-first-legacy-stores", () => { replaceFileAtomicSync({ filePath: "plugin-state/state.sqlite", content: "" }); await writeJsonFileAtomically("thread-bindings.json", {}); saveJsonFile("plugin-binding-approvals.json", {}); - `("src/runtime/write-helper-regressions.ts", [ + `("write-helper-regressions.ts", [ { kind: "legacy restart sentinel reference", line: 5 }, { kind: "legacy store filesystem write", line: 5 }, { kind: "legacy store filesystem write", line: 6 }, @@ -1056,7 +1035,7 @@ describe("check-database-first-legacy-stores", () => { } const ledgerPath = path.join(stateDir, "acp", "event-ledger.json"); await persist({ filePath: ledgerPath }); - `("src/runtime/object-property-wrapper.ts", filesystemWriteViolations(8)), + `("object-property-wrapper.ts", filesystemWriteViolations(8)), "flags wrapper paths written through createRequire aliases": sourceCase` import { createRequire } from "node:module"; const req = createRequire(import.meta.url); @@ -1065,7 +1044,7 @@ describe("check-database-first-legacy-stores", () => { fs.writeFileSync(filePath, "{}\\n"); } persist("sessions.json"); - `("src/runtime/create-require-alias-wrapper.ts", filesystemWriteViolations(8)), + `("create-require-alias-wrapper.ts", filesystemWriteViolations(8)), "flags wrapper-local createRequire alias writes": sourceCase` import { createRequire } from "node:module"; function persist(filePath: string) { @@ -1074,7 +1053,7 @@ describe("check-database-first-legacy-stores", () => { fs.writeFileSync(filePath, "{}\\n"); } persist("sessions.json"); - `("src/runtime/wrapper-local-create-require-alias.ts", filesystemWriteViolations(8)), + `("wrapper-local-create-require-alias.ts", filesystemWriteViolations(8)), "flags wrapper-local copied createRequire aliases": sourceCase` import { createRequire } from "node:module"; function persist(filePath: string) { @@ -1084,7 +1063,7 @@ describe("check-database-first-legacy-stores", () => { fs.writeFileSync(filePath, "{}\\n"); } persist("sessions.json"); - `("src/runtime/wrapper-copied-create-require-alias.ts", filesystemWriteViolations(9)), + `("wrapper-copied-create-require-alias.ts", filesystemWriteViolations(9)), "flags wrapper-local createRequire aliases after local shadow reassignment": sourceCase` import { createRequire } from "node:module"; const req = createRequire(import.meta.url); @@ -1095,10 +1074,7 @@ describe("check-database-first-legacy-stores", () => { fs.writeFileSync(filePath, "{}\\n"); } persist("sessions.json"); - `( - "src/runtime/wrapper-shadowed-reassigned-create-require-alias.ts", - filesystemWriteViolations(10), - ), + `("wrapper-shadowed-reassigned-create-require-alias.ts", filesystemWriteViolations(10)), "flags wrapper-local reassigned createRequire aliases named require": sourceCase` import { createRequire } from "node:module"; function persist(filePath: string) { @@ -1108,7 +1084,7 @@ describe("check-database-first-legacy-stores", () => { fs.writeFileSync(filePath, "{}\\n"); } persist("sessions.json"); - `("src/runtime/wrapper-reassigned-create-require-name.ts", filesystemWriteViolations(9)), + `("wrapper-reassigned-create-require-name.ts", filesystemWriteViolations(9)), "flags wrapper-local createRequire alias assignments inside blocks": sourceCase` import { createRequire } from "node:module"; function persist(filePath: string) { @@ -1120,7 +1096,7 @@ describe("check-database-first-legacy-stores", () => { fs.writeFileSync(filePath, "{}\\n"); } persist("sessions.json"); - `("src/runtime/wrapper-block-create-require-assignment.ts", filesystemWriteViolations(11)), + `("wrapper-block-create-require-assignment.ts", filesystemWriteViolations(11)), "does not treat wrapper-shadowed createRequire parameters as Node createRequire": sourceCase` function persist( filePath: string, @@ -1131,7 +1107,7 @@ describe("check-database-first-legacy-stores", () => { fs.writeFileSync(filePath, "{}\\n"); } persist("sessions.json", customCreateRequire); - `("src/runtime/wrapper-shadowed-create-require.ts", []), + `("wrapper-shadowed-create-require.ts", []), "does not treat wrapper hoisted function createRequire shadows as Node createRequire": sourceCase` import { createRequire } from "node:module"; @@ -1144,7 +1120,7 @@ describe("check-database-first-legacy-stores", () => { fs.writeFileSync(filePath, "{}\\n"); } persist("sessions.json"); - `("src/runtime/wrapper-hoisted-create-require-shadow.ts", []), + `("wrapper-hoisted-create-require-shadow.ts", []), "keeps wrapper lexical createRequire aliases when call sites shadow them": sourceCase` import { createRequire } from "node:module"; const req = createRequire(import.meta.url); @@ -1156,7 +1132,7 @@ describe("check-database-first-legacy-stores", () => { const req = customRequire; persist("sessions.json"); } - `("src/runtime/wrapper-lexical-create-require-alias.ts", filesystemWriteViolations(10)), + `("wrapper-lexical-create-require-alias.ts", filesystemWriteViolations(10)), "keeps wrapper-local createRequire calls when call sites shadow createRequire": sourceCase` import { createRequire } from "node:module"; function persist(filePath: string) { @@ -1168,7 +1144,7 @@ describe("check-database-first-legacy-stores", () => { const createRequire = customCreateRequire; persist("sessions.json"); } - `("src/runtime/wrapper-create-require-call-site-shadow.ts", filesystemWriteViolations(10)), + `("wrapper-create-require-call-site-shadow.ts", filesystemWriteViolations(10)), "flags exhaustive conditional createRequire alias assignments": sourceCase` import { createRequire } from "node:module"; let req; @@ -1179,7 +1155,7 @@ describe("check-database-first-legacy-stores", () => { } const fs = req("node:fs"); fs.writeFileSync("sessions.json", "{}\\n"); - `("src/runtime/conditional-create-require-alias.ts", filesystemWriteViolations(10)), + `("conditional-create-require-alias.ts", filesystemWriteViolations(10)), "refreshes hoisted wrappers after exhaustive createRequire alias branches": sourceCase` import { createRequire } from "node:module"; let req; @@ -1193,10 +1169,7 @@ describe("check-database-first-legacy-stores", () => { req = createRequire(import.meta.url); } persist("sessions.json"); - `( - "src/runtime/hoisted-wrapper-conditional-create-require-alias.ts", - filesystemWriteViolations(13), - ), + `("hoisted-wrapper-conditional-create-require-alias.ts", filesystemWriteViolations(13)), "keeps wrapper conditional createRequire alias branches": sourceCase` import { createRequire } from "node:module"; function persist(filePath: string) { @@ -1210,7 +1183,7 @@ describe("check-database-first-legacy-stores", () => { fs.writeFileSync(filePath, "{}\\n"); } persist("sessions.json"); - `("src/runtime/wrapper-conditional-create-require-alias.ts", filesystemWriteViolations(13)), + `("wrapper-conditional-create-require-alias.ts", filesystemWriteViolations(13)), // Wrapper argument and default-value propagation. "flags legacy paths passed through named wrapper options": sourceCase` @@ -1221,7 +1194,7 @@ describe("check-database-first-legacy-stores", () => { const store = "sessions.json"; const params = { store }; await persist(params); - `("src/runtime/named-wrapper-options.ts", filesystemWriteViolations(8)), + `("named-wrapper-options.ts", filesystemWriteViolations(8)), "flags legacy paths read through chained wrapper option properties": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; function persist(params: { filePath: string }) { @@ -1229,28 +1202,28 @@ describe("check-database-first-legacy-stores", () => { } const options = { filePath: "sessions.json" }; await persist(options); - `("src/runtime/chained-wrapper-option-path.ts", filesystemWriteViolations(7)), + `("chained-wrapper-option-path.ts", filesystemWriteViolations(7)), "flags legacy paths passed through destructured wrapper options": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; function persist({ filePath }: { filePath: string }) { return writeTextAtomic(filePath, "{}\\n"); } await persist({ filePath: "sessions.json" }); - `("src/runtime/destructured-wrapper-options.ts", filesystemWriteViolations(6)), + `("destructured-wrapper-options.ts", filesystemWriteViolations(6)), "flags legacy paths passed through nested destructured wrapper options": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; function persist({ paths: { filePath } }: { paths: { filePath: string } }) { return writeTextAtomic(filePath, "{}\\n"); } await persist({ paths: { filePath: "sessions.json" } }); - `("src/runtime/nested-destructured-wrapper-options.ts", filesystemWriteViolations(6)), + `("nested-destructured-wrapper-options.ts", filesystemWriteViolations(6)), "flags legacy paths from nested destructured wrapper option defaults": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; function persist({ paths: { filePath = "sessions.json" } }: { paths: { filePath?: string } }) { return writeTextAtomic(filePath, "{}\\n"); } await persist({ paths: {} }); - `("src/runtime/nested-destructured-wrapper-option-default.ts", filesystemWriteViolations(6)), + `("nested-destructured-wrapper-option-default.ts", filesystemWriteViolations(6)), "flags nested parameter defaults from identifier-valued intermediate objects": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; const paths = { filePath: "sessions.json" }; @@ -1258,10 +1231,7 @@ describe("check-database-first-legacy-stores", () => { return writeTextAtomic(filePath, "{}\\n"); } await persist(); - `( - "src/runtime/nested-parameter-default-identifier-intermediate.ts", - filesystemWriteViolations(7), - ), + `("nested-parameter-default-identifier-intermediate.ts", filesystemWriteViolations(7)), "flags nested destructuring defaults from identifier-valued intermediate objects": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; const paths = {}; @@ -1269,10 +1239,7 @@ describe("check-database-first-legacy-stores", () => { return writeTextAtomic(filePath, "{}\\n"); } await persist(); - `( - "src/runtime/nested-destructuring-default-identifier-intermediate.ts", - filesystemWriteViolations(7), - ), + `("nested-destructuring-default-identifier-intermediate.ts", filesystemWriteViolations(7)), "flags nested destructuring defaults from aliased known object literals": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; function persist({ paths: { filePath = "sessions.json" } }: { paths: { filePath?: string } }) { @@ -1281,17 +1248,14 @@ describe("check-database-first-legacy-stores", () => { const source = { paths: {} }; const options = source; await persist(options); - `( - "src/runtime/nested-destructuring-default-aliased-known-object.ts", - filesystemWriteViolations(8), - ), + `("nested-destructuring-default-aliased-known-object.ts", filesystemWriteViolations(8)), "flags nested destructuring defaults from parent binding defaults": sourceCase` import { promises as fs } from "node:fs"; function persist({ paths: { filePath } = { filePath: "sessions.json" } }) { return fs.writeFile(filePath, "{}\\n"); } await persist({}); - `("src/runtime/nested-destructuring-parent-binding-default.ts", filesystemWriteViolations(6)), + `("nested-destructuring-parent-binding-default.ts", filesystemWriteViolations(6)), "does not force nested destructured defaults for unknown intermediate properties": sourceCase` import { promises as fs } from "node:fs"; declare function loadPaths(): { filePath?: string }; @@ -1300,7 +1264,7 @@ describe("check-database-first-legacy-stores", () => { } const options = { paths: loadPaths() }; await persist(options); - `("src/runtime/nested-destructured-wrapper-option-unknown-intermediate.ts", []), + `("nested-destructured-wrapper-option-unknown-intermediate.ts", []), "flags defaults referencing earlier nested destructured identifier parameters": sourceCase` import { promises as fs } from "node:fs"; function writePath({ paths: { filePath } }: { paths: { filePath: string } }, path = filePath) { @@ -1308,10 +1272,7 @@ describe("check-database-first-legacy-stores", () => { } const options = { paths: { filePath: "sessions.json" } }; await writePath(options); - `( - "src/runtime/nested-destructured-wrapper-earlier-identifier-default.ts", - filesystemWriteViolations(7), - ), + `("nested-destructured-wrapper-earlier-identifier-default.ts", filesystemWriteViolations(7)), "flags defaults referencing earlier nested object parameter properties": sourceCase` import { promises as fs } from "node:fs"; function writePath(options: { paths: { filePath: string } }, path = options.paths.filePath) { @@ -1319,17 +1280,14 @@ describe("check-database-first-legacy-stores", () => { } const options = { paths: { filePath: "sessions.json" } }; await writePath(options); - `( - "src/runtime/nested-object-wrapper-earlier-property-default.ts", - filesystemWriteViolations(7), - ), + `("nested-object-wrapper-earlier-property-default.ts", filesystemWriteViolations(7)), "flags legacy paths passed through positional wrapper parameters": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; function persist(filePath: string) { return writeTextAtomic(filePath, "{}\\n"); } await persist("sessions.json"); - `("src/runtime/positional-wrapper-path.ts", filesystemWriteViolations(6)), + `("positional-wrapper-path.ts", filesystemWriteViolations(6)), "flags defaulted wrapper parameters after optional safe assignments": sourceCase` import { promises as fs } from "node:fs"; let filePath; @@ -1338,10 +1296,7 @@ describe("check-database-first-legacy-stores", () => { return fs.writeFile(path, "{}\\n"); } await persist(filePath); - `( - "src/runtime/conditional-undefined-defaulted-wrapper-parameter.ts", - filesystemWriteViolations(8), - ), + `("conditional-undefined-defaulted-wrapper-parameter.ts", filesystemWriteViolations(8)), "flags legacy paths forwarded through nested wrapper helpers": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -1351,7 +1306,7 @@ describe("check-database-first-legacy-stores", () => { return inner(filePath); } await persist("sessions.json"); - `("src/runtime/nested-wrapper-path.ts", filesystemWriteViolations(9)), + `("nested-wrapper-path.ts", filesystemWriteViolations(9)), "flags legacy paths captured by nested wrapper helpers": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -1361,7 +1316,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json"); - `("src/runtime/nested-wrapper-closed-over-path.ts", filesystemWriteViolations(9)), + `("nested-wrapper-closed-over-path.ts", filesystemWriteViolations(9)), "flags legacy paths captured by nested helpers and forwarded to outer wrappers": sourceCase` import { promises as fs } from "node:fs"; function writePath(path: string) { @@ -1374,7 +1329,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json"); - `("src/runtime/nested-helper-forwarded-to-outer-wrapper.ts", filesystemWriteViolations(12)), + `("nested-helper-forwarded-to-outer-wrapper.ts", filesystemWriteViolations(12)), "keeps closed-over write aliases after loop-scoped shadows": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -1386,7 +1341,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json"); - `("src/runtime/nested-wrapper-loop-shadowed-write-alias.ts", filesystemWriteViolations(11)), + `("nested-wrapper-loop-shadowed-write-alias.ts", filesystemWriteViolations(11)), "flags legacy paths forwarded through nested helper parameter defaults": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -1396,7 +1351,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json"); - `("src/runtime/nested-helper-parameter-default.ts", filesystemWriteViolations(9)), + `("nested-helper-parameter-default.ts", filesystemWriteViolations(9)), "flags legacy paths written by callable nested helper parameter defaults": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -1406,7 +1361,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json"); - `("src/runtime/nested-helper-callable-parameter-default.ts", filesystemWriteViolations(9)), + `("nested-helper-callable-parameter-default.ts", filesystemWriteViolations(9)), "does not use callable nested helper parameter defaults when callbacks are provided": sourceCase` import { promises as fs } from "node:fs"; @@ -1417,7 +1372,7 @@ describe("check-database-first-legacy-stores", () => { return inner(async () => {}); } await persist("sessions.json"); - `("src/runtime/nested-helper-callable-parameter-default-provided.ts", []), + `("nested-helper-callable-parameter-default-provided.ts", []), "flags legacy paths forwarded through undefined nested helper arguments": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -1427,7 +1382,7 @@ describe("check-database-first-legacy-stores", () => { return inner(undefined); } await persist("sessions.json"); - `("src/runtime/nested-helper-undefined-parameter-default.ts", filesystemWriteViolations(9)), + `("nested-helper-undefined-parameter-default.ts", filesystemWriteViolations(9)), "flags legacy paths forwarded through void nested helper arguments": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -1437,7 +1392,7 @@ describe("check-database-first-legacy-stores", () => { return inner(void 0); } await persist("sessions.json"); - `("src/runtime/nested-helper-void-parameter-default.ts", filesystemWriteViolations(9)), + `("nested-helper-void-parameter-default.ts", filesystemWriteViolations(9)), "resolves nested helper parameter defaults in the helper scope": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -1447,7 +1402,7 @@ describe("check-database-first-legacy-stores", () => { return inner(currentSqlitePath); } await persist("sessions.json"); - `("src/runtime/nested-helper-default-parameter-shadow.ts", []), + `("nested-helper-default-parameter-shadow.ts", []), "does not resolve top-level helper parameter defaults in the caller scope": sourceCase` import { promises as fs } from "node:fs"; const defaultPath = "current-state.json"; @@ -1458,7 +1413,7 @@ describe("check-database-first-legacy-stores", () => { return writePath(); } await persist("sessions.json"); - `("src/runtime/top-level-helper-default-caller-shadow.ts", []), + `("top-level-helper-default-caller-shadow.ts", []), "does not resolve top-level helper object binding defaults in the caller scope": sourceCase` import { promises as fs } from "node:fs"; const defaultPath = "current-state.json"; @@ -1469,7 +1424,7 @@ describe("check-database-first-legacy-stores", () => { return writePath({}); } await persist("sessions.json"); - `("src/runtime/top-level-helper-object-binding-default-caller-shadow.ts", []), + `("top-level-helper-object-binding-default-caller-shadow.ts", []), "flags forwarded top-level helper object binding literal defaults": sourceCase` import { promises as fs } from "node:fs"; function writePath({ path = "sessions.json" } = {}) { @@ -1479,10 +1434,7 @@ describe("check-database-first-legacy-stores", () => { return writePath({}); } await persist(); - `( - "src/runtime/top-level-helper-object-binding-literal-default.ts", - filesystemWriteViolations(7), - ), + `("top-level-helper-object-binding-literal-default.ts", filesystemWriteViolations(7)), "does not resolve top-level helper expression defaults in the caller scope": sourceCase` import { promises as fs } from "node:fs"; const fallback = "current-state.json"; @@ -1493,7 +1445,7 @@ describe("check-database-first-legacy-stores", () => { return writePath(); } await persist("sessions.json"); - `("src/runtime/top-level-helper-expression-default-caller-shadow.ts", []), + `("top-level-helper-expression-default-caller-shadow.ts", []), "flags top-level helper expression defaults derived from earlier parameters": sourceCase` import { promises as fs } from "node:fs"; const fallback = "current-state.json"; @@ -1504,10 +1456,7 @@ describe("check-database-first-legacy-stores", () => { return writePath(filePath); } await persist("sessions.json"); - `( - "src/runtime/top-level-helper-earlier-parameter-expression-default.ts", - filesystemWriteViolations(10), - ), + `("top-level-helper-earlier-parameter-expression-default.ts", filesystemWriteViolations(10)), "flags direct top-level helper calls with defaults derived from earlier arguments": sourceCase` import { promises as fs } from "node:fs"; @@ -1516,10 +1465,7 @@ describe("check-database-first-legacy-stores", () => { return fs.writeFile(path, "{}\\n"); } await writePath("sessions.json"); - `( - "src/runtime/top-level-helper-direct-earlier-parameter-default.ts", - filesystemWriteViolations(7), - ), + `("top-level-helper-direct-earlier-parameter-default.ts", filesystemWriteViolations(7)), "flags direct top-level helper calls with defaults from earlier destructured arguments": sourceCase` import { promises as fs } from "node:fs"; @@ -1528,7 +1474,7 @@ describe("check-database-first-legacy-stores", () => { } await writePath({ filePath: "sessions.json" }); `( - "src/runtime/top-level-helper-direct-destructured-earlier-parameter-default.ts", + "top-level-helper-direct-destructured-earlier-parameter-default.ts", filesystemWriteViolations(6), ), "flags direct top-level helper calls with defaults from nested destructured arguments": @@ -1542,7 +1488,7 @@ describe("check-database-first-legacy-stores", () => { } await writePath({ paths: { filePath: "sessions.json" } }); `( - "src/runtime/top-level-helper-direct-nested-destructured-earlier-parameter-default.ts", + "top-level-helper-direct-nested-destructured-earlier-parameter-default.ts", filesystemWriteViolations(9), ), "does not flag safe defaults that only inspect earlier legacy arguments": sourceCase` @@ -1554,7 +1500,7 @@ describe("check-database-first-legacy-stores", () => { return fs.writeFile(path, "{}\\n"); } await writePath("sessions.json"); - `("src/runtime/top-level-helper-safe-conditional-default.ts", []), + `("top-level-helper-safe-conditional-default.ts", []), "flags direct top-level helper calls with method defaults from earlier arguments": sourceCase` import { promises as fs } from "node:fs"; function writePath(filePath: string, path = filePath.replace(/\\.json$/, ".json")) { @@ -1562,7 +1508,7 @@ describe("check-database-first-legacy-stores", () => { } await writePath("sessions.json"); `( - "src/runtime/top-level-helper-direct-method-earlier-parameter-default.ts", + "top-level-helper-direct-method-earlier-parameter-default.ts", filesystemWriteViolations(6), ), "flags direct top-level helper calls with comma defaults from earlier arguments": sourceCase` @@ -1572,10 +1518,7 @@ describe("check-database-first-legacy-stores", () => { return fs.writeFile(path, "{}\\n"); } await writePath("sessions.json"); - `( - "src/runtime/top-level-helper-direct-comma-earlier-parameter-default.ts", - filesystemWriteViolations(7), - ), + `("top-level-helper-direct-comma-earlier-parameter-default.ts", filesystemWriteViolations(7)), "flags direct top-level helper calls with assignment defaults from earlier arguments": sourceCase` import { promises as fs } from "node:fs"; @@ -1585,7 +1528,7 @@ describe("check-database-first-legacy-stores", () => { } await writePath("sessions.json"); `( - "src/runtime/top-level-helper-direct-assignment-earlier-parameter-default.ts", + "top-level-helper-direct-assignment-earlier-parameter-default.ts", filesystemWriteViolations(7), ), "flags top-level helper object binding expression defaults derived from earlier parameters": @@ -1600,7 +1543,7 @@ describe("check-database-first-legacy-stores", () => { } await persist("sessions.json"); `( - "src/runtime/top-level-helper-object-binding-earlier-parameter-expression-default.ts", + "top-level-helper-object-binding-earlier-parameter-expression-default.ts", filesystemWriteViolations(10), ), "flags direct top-level helper calls with object binding defaults from earlier arguments": @@ -1612,7 +1555,7 @@ describe("check-database-first-legacy-stores", () => { } await writePath("sessions.json"); `( - "src/runtime/top-level-helper-direct-object-binding-earlier-parameter-default.ts", + "top-level-helper-direct-object-binding-earlier-parameter-default.ts", filesystemWriteViolations(7), ), "flags object binding defaults from missing properties on identifier arguments": sourceCase` @@ -1623,7 +1566,7 @@ describe("check-database-first-legacy-stores", () => { } await writePath("sessions.json", options); `( - "src/runtime/top-level-helper-object-binding-missing-identifier-default.ts", + "top-level-helper-object-binding-missing-identifier-default.ts", filesystemWriteViolations(7), ), "flags object binding defaults from undefined properties on identifier arguments": sourceCase` @@ -1634,7 +1577,7 @@ describe("check-database-first-legacy-stores", () => { } await writePath("sessions.json", options); `( - "src/runtime/top-level-helper-object-binding-undefined-identifier-default.ts", + "top-level-helper-object-binding-undefined-identifier-default.ts", filesystemWriteViolations(7), ), "flags object binding defaults from undefined properties in parameter defaults": sourceCase` @@ -1644,7 +1587,7 @@ describe("check-database-first-legacy-stores", () => { } await writePath(); `( - "src/runtime/top-level-helper-object-binding-undefined-parameter-default.ts", + "top-level-helper-object-binding-undefined-parameter-default.ts", filesystemWriteViolations(6), ), "uses explicit safe properties on identifier arguments before object binding defaults": @@ -1655,7 +1598,7 @@ describe("check-database-first-legacy-stores", () => { return fs.writeFile(path, "{}\\n"); } await writePath("sessions.json", options); - `("src/runtime/top-level-helper-object-binding-safe-identifier-property.ts", []), + `("top-level-helper-object-binding-safe-identifier-property.ts", []), "does not force object binding defaults for unknown identifier arguments": sourceCase` import { promises as fs } from "node:fs"; declare function loadOptions(): { path?: string }; @@ -1664,7 +1607,7 @@ describe("check-database-first-legacy-stores", () => { return fs.writeFile(path, "{}\\n"); } await writePath("sessions.json", options); - `("src/runtime/top-level-helper-object-binding-unknown-identifier-default.ts", []), + `("top-level-helper-object-binding-unknown-identifier-default.ts", []), "does not force object binding defaults for identifier arguments with unknown spreads": sourceCase` import { promises as fs } from "node:fs"; @@ -1674,7 +1617,7 @@ describe("check-database-first-legacy-stores", () => { return fs.writeFile(path, "{}\\n"); } await writePath("sessions.json", options); - `("src/runtime/top-level-helper-object-binding-unknown-spread-default.ts", []), + `("top-level-helper-object-binding-unknown-spread-default.ts", []), "keeps explicit undefined object properties after exhaustive branch merges": sourceCase` import { promises as fs } from "node:fs"; function writePath(filePath: string, { path = filePath } = {}) { @@ -1688,7 +1631,7 @@ describe("check-database-first-legacy-stores", () => { } await writePath("sessions.json", options); `( - "src/runtime/top-level-helper-object-binding-branch-undefined-default.ts", + "top-level-helper-object-binding-branch-undefined-default.ts", filesystemWriteViolations(12), ), "keeps maybe undefined object properties after exhaustive branch merges": sourceCase` @@ -1704,7 +1647,7 @@ describe("check-database-first-legacy-stores", () => { } await writePath("sessions.json", options); `( - "src/runtime/top-level-helper-object-binding-branch-maybe-undefined-default.ts", + "top-level-helper-object-binding-branch-maybe-undefined-default.ts", filesystemWriteViolations(12), ), "keeps known nested object literals after exhaustive branch merges": sourceCase` @@ -1720,7 +1663,7 @@ describe("check-database-first-legacy-stores", () => { } await writePath(options); `( - "src/runtime/top-level-helper-object-binding-branch-known-nested-object.ts", + "top-level-helper-object-binding-branch-known-nested-object.ts", filesystemWriteViolations(12), ), "does not force object binding defaults after exhaustive unknown object branch merges": @@ -1737,7 +1680,7 @@ describe("check-database-first-legacy-stores", () => { options = loadOptions(); } await writePath("sessions.json", options); - `("src/runtime/top-level-helper-object-binding-branch-unknown-default.ts", []), + `("top-level-helper-object-binding-branch-unknown-default.ts", []), "does not force object binding defaults after optional unknown object rewrites": sourceCase` import { promises as fs } from "node:fs"; declare function loadOptions(): { path?: string }; @@ -1749,7 +1692,7 @@ describe("check-database-first-legacy-stores", () => { options = {}; } await writePath("sessions.json", options); - `("src/runtime/top-level-helper-object-binding-optional-unknown-rewrite-default.ts", []), + `("top-level-helper-object-binding-optional-unknown-rewrite-default.ts", []), "keeps known-missing object properties after exhaustive branch merges": sourceCase` import { promises as fs } from "node:fs"; function writePath(filePath: string, { path = filePath } = {}) { @@ -1763,7 +1706,7 @@ describe("check-database-first-legacy-stores", () => { } await writePath("sessions.json", options); `( - "src/runtime/top-level-helper-object-binding-branch-known-missing-default.ts", + "top-level-helper-object-binding-branch-known-missing-default.ts", filesystemWriteViolations(12), ), "flags object binding defaults from earlier destructured arguments": sourceCase` @@ -1773,7 +1716,7 @@ describe("check-database-first-legacy-stores", () => { } await writePath({ filePath: "sessions.json" }, {}); `( - "src/runtime/top-level-helper-object-binding-destructured-earlier-parameter-default.ts", + "top-level-helper-object-binding-destructured-earlier-parameter-default.ts", filesystemWriteViolations(6), ), "flags object binding defaults from nested destructured arguments": sourceCase` @@ -1786,7 +1729,7 @@ describe("check-database-first-legacy-stores", () => { } await writePath({ paths: { filePath: "sessions.json" } }, {}); `( - "src/runtime/top-level-helper-object-binding-nested-destructured-earlier-parameter-default.ts", + "top-level-helper-object-binding-nested-destructured-earlier-parameter-default.ts", filesystemWriteViolations(9), ), "does not scan unrelated object properties for earlier property defaults": sourceCase` @@ -1801,7 +1744,7 @@ describe("check-database-first-legacy-stores", () => { currentPath: "state/openclaw.sqlite", legacyPath: "sessions.json", }); - `("src/runtime/top-level-helper-property-default-unrelated-legacy-property.ts", []), + `("top-level-helper-property-default-unrelated-legacy-property.ts", []), "does not scan unrelated object properties for bracket defaults": sourceCase` import { promises as fs } from "node:fs"; function writePath( @@ -1814,7 +1757,7 @@ describe("check-database-first-legacy-stores", () => { currentPath: "state/openclaw.sqlite", legacyPath: "sessions.json", }); - `("src/runtime/top-level-helper-bracket-default-unrelated-legacy-property.ts", []), + `("top-level-helper-bracket-default-unrelated-legacy-property.ts", []), "flags direct top-level helper calls with nested property defaults": sourceCase` import { promises as fs } from "node:fs"; function writePath( @@ -1826,7 +1769,7 @@ describe("check-database-first-legacy-stores", () => { await writePath({ paths: { filePath: "sessions.json" }, }); - `("src/runtime/top-level-helper-nested-property-default.ts", filesystemWriteViolations(9)), + `("top-level-helper-nested-property-default.ts", filesystemWriteViolations(9)), "flags direct top-level helper calls with nested bracket property defaults": sourceCase` import { promises as fs } from "node:fs"; function writePath( @@ -1838,10 +1781,7 @@ describe("check-database-first-legacy-stores", () => { await writePath({ paths: { filePath: "sessions.json" }, }); - `( - "src/runtime/top-level-helper-nested-bracket-property-default.ts", - filesystemWriteViolations(9), - ), + `("top-level-helper-nested-bracket-property-default.ts", filesystemWriteViolations(9)), "does not crash on unknown spreads in nested property defaults": sourceCase` import { promises as fs } from "node:fs"; declare const defaults: { paths?: { filePath: string } }; @@ -1852,7 +1792,7 @@ describe("check-database-first-legacy-stores", () => { return fs.writeFile(path, "{}\\n"); } await writePath({ ...defaults }); - `("src/runtime/top-level-helper-nested-property-default-unknown-spread.ts", []), + `("top-level-helper-nested-property-default-unknown-spread.ts", []), "keeps nested legacy paths before unknown outer spreads": sourceCase` import { promises as fs } from "node:fs"; declare const options: { paths?: { filePath: string } }; @@ -1860,7 +1800,7 @@ describe("check-database-first-legacy-stores", () => { return fs.writeFile(filePath, "{}\\n"); } await persist({ paths: { filePath: "sessions.json" }, ...options }); - `("src/runtime/nested-wrapper-path-before-unknown-spread.ts", filesystemWriteViolations(7)), + `("nested-wrapper-path-before-unknown-spread.ts", filesystemWriteViolations(7)), "flags nested legacy paths passed through shorthand options": sourceCase` import { promises as fs } from "node:fs"; const paths = { filePath: "sessions.json" }; @@ -1868,7 +1808,7 @@ describe("check-database-first-legacy-stores", () => { return fs.writeFile(path, "{}\\n"); } await writePath({ paths }); - `("src/runtime/top-level-helper-nested-shorthand-options.ts", filesystemWriteViolations(7)), + `("top-level-helper-nested-shorthand-options.ts", filesystemWriteViolations(7)), "flags nested legacy paths forwarded through identifier-valued object properties": sourceCase` import { promises as fs } from "node:fs"; function persist({ paths: { filePath } }: { paths: { filePath: string } }) { @@ -1877,10 +1817,7 @@ describe("check-database-first-legacy-stores", () => { const paths = { filePath: "sessions.json" }; const options = { paths }; await persist(options); - `( - "src/runtime/nested-wrapper-identifier-valued-object-property.ts", - filesystemWriteViolations(8), - ), + `("nested-wrapper-identifier-valued-object-property.ts", filesystemWriteViolations(8)), "flags nested legacy paths hidden in intermediate option expressions": sourceCase` import { promises as fs } from "node:fs"; declare function makePaths(filePath: string): { filePath: string }; @@ -1888,17 +1825,14 @@ describe("check-database-first-legacy-stores", () => { return fs.writeFile(filePath, "{}\\n"); } await persist({ paths: makePaths("sessions.json") }); - `("src/runtime/nested-wrapper-path-intermediate-expression.ts", filesystemWriteViolations(7)), + `("nested-wrapper-path-intermediate-expression.ts", filesystemWriteViolations(7)), "flags direct top-level helper calls with chained literal parameter defaults": sourceCase` import { promises as fs } from "node:fs"; function writePath(filePath = "sessions.json", path = filePath) { return fs.writeFile(path, "{}\\n"); } await writePath(); - `( - "src/runtime/top-level-helper-chained-literal-parameter-default.ts", - filesystemWriteViolations(6), - ), + `("top-level-helper-chained-literal-parameter-default.ts", filesystemWriteViolations(6)), "flags direct top-level helper calls with nested object literal parameter defaults": sourceCase` import { promises as fs } from "node:fs"; @@ -1910,7 +1844,7 @@ describe("check-database-first-legacy-stores", () => { } await writePath(); `( - "src/runtime/top-level-helper-nested-object-literal-parameter-default.ts", + "top-level-helper-nested-object-literal-parameter-default.ts", filesystemWriteViolations(9), ), "flags direct top-level helper calls with nested spread parameter defaults": sourceCase` @@ -1923,10 +1857,7 @@ describe("check-database-first-legacy-stores", () => { return fs.writeFile(path, "{}\\n"); } await writePath(); - `( - "src/runtime/top-level-helper-nested-spread-parameter-default.ts", - filesystemWriteViolations(10), - ), + `("top-level-helper-nested-spread-parameter-default.ts", filesystemWriteViolations(10)), "does not resolve top-level helper nested spread defaults in the caller scope": sourceCase` import { promises as fs } from "node:fs"; const defaults = { paths: { filePath: "current-state.json" } }; @@ -1941,7 +1872,7 @@ describe("check-database-first-legacy-stores", () => { return writePath(); } await persist(); - `("src/runtime/top-level-helper-nested-spread-default-caller-shadow.ts", []), + `("top-level-helper-nested-spread-default-caller-shadow.ts", []), "does not resolve omitted earlier helper parameters in the caller scope": sourceCase` import { promises as fs } from "node:fs"; function writePath(filePath?: string, path = filePath) { @@ -1951,7 +1882,7 @@ describe("check-database-first-legacy-stores", () => { return writePath(); } await persist("sessions.json"); - `("src/runtime/top-level-helper-omitted-earlier-default.ts", []), + `("top-level-helper-omitted-earlier-default.ts", []), "does not resolve omitted earlier helper parameters in object binding defaults": sourceCase` import { promises as fs } from "node:fs"; function writePath(filePath?: string, { path = filePath } = {}) { @@ -1961,7 +1892,7 @@ describe("check-database-first-legacy-stores", () => { return writePath(undefined, {}); } await persist("sessions.json"); - `("src/runtime/top-level-helper-object-binding-omitted-earlier-default.ts", []), + `("top-level-helper-object-binding-omitted-earlier-default.ts", []), "flags default expressions that combine multiple earlier parameters": sourceCase` import { promises as fs } from "node:fs"; function writePath(prefix: string, filePath: string, path = prefix + filePath) { @@ -1971,10 +1902,7 @@ describe("check-database-first-legacy-stores", () => { return writePath("state/", filePath); } await persist("sessions.json"); - `( - "src/runtime/top-level-helper-multiple-earlier-parameter-default.ts", - filesystemWriteViolations(9), - ), + `("top-level-helper-multiple-earlier-parameter-default.ts", filesystemWriteViolations(9)), "does not resolve top-level helper defaults in closed-over caller scope": sourceCase` import { promises as fs } from "node:fs"; const defaultPath = "current-state.json"; @@ -1988,7 +1916,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json"); - `("src/runtime/top-level-helper-default-closed-over-caller-shadow.ts", []), + `("top-level-helper-default-closed-over-caller-shadow.ts", []), "does not treat top-level helper aliases as closed over nested helpers": sourceCase` import { promises as fs } from "node:fs"; const filePath = "current-state.json"; @@ -2003,7 +1931,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json"); - `("src/runtime/top-level-helper-alias-module-path-shadow.ts", []), + `("top-level-helper-alias-module-path-shadow.ts", []), "does not resolve aliased top-level helper defaults in the caller scope": sourceCase` import { promises as fs } from "node:fs"; const defaultPath = "current-state.json"; @@ -2015,7 +1943,7 @@ describe("check-database-first-legacy-stores", () => { return save(); } await persist("sessions.json"); - `("src/runtime/top-level-helper-alias-default-caller-shadow.ts", []), + `("top-level-helper-alias-default-caller-shadow.ts", []), // Nested helper capture and wrapper propagation. "flags legacy paths forwarded through nested helper object binding defaults": sourceCase` @@ -2027,7 +1955,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json"); - `("src/runtime/nested-helper-object-binding-default.ts", filesystemWriteViolations(9)), + `("nested-helper-object-binding-default.ts", filesystemWriteViolations(9)), "does not use nested helper object binding defaults when a spread may provide the property": sourceCase` import { promises as fs } from "node:fs"; @@ -2039,7 +1967,7 @@ describe("check-database-first-legacy-stores", () => { return inner({ ...options }); } await persist("sessions.json"); - `("src/runtime/nested-helper-object-binding-default-unknown-spread.ts", []), + `("nested-helper-object-binding-default-unknown-spread.ts", []), "flags nested helper object binding defaults after known-empty object spreads": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -2051,7 +1979,7 @@ describe("check-database-first-legacy-stores", () => { } await persist("sessions.json"); `( - "src/runtime/nested-helper-object-binding-default-known-empty-spread.ts", + "nested-helper-object-binding-default-known-empty-spread.ts", filesystemWriteViolations(10), ), "resolves nested helper object binding defaults in the helper scope": sourceCase` @@ -2063,7 +1991,7 @@ describe("check-database-first-legacy-stores", () => { return inner(currentSqlitePath, {}); } await persist("sessions.json"); - `("src/runtime/nested-helper-object-binding-default-shadow.ts", []), + `("nested-helper-object-binding-default-shadow.ts", []), "flags legacy paths forwarded through undefined nested helper object arguments": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -2073,10 +2001,7 @@ describe("check-database-first-legacy-stores", () => { return inner(undefined); } await persist("sessions.json"); - `( - "src/runtime/nested-helper-undefined-object-binding-default.ts", - filesystemWriteViolations(9), - ), + `("nested-helper-undefined-object-binding-default.ts", filesystemWriteViolations(9)), "flags legacy paths forwarded through explicit undefined nested helper object properties": sourceCase` import { promises as fs } from "node:fs"; @@ -2087,10 +2012,7 @@ describe("check-database-first-legacy-stores", () => { return inner({ path: undefined }); } await persist("sessions.json"); - `( - "src/runtime/nested-helper-undefined-object-property-default.ts", - filesystemWriteViolations(9), - ), + `("nested-helper-undefined-object-property-default.ts", filesystemWriteViolations(9)), "flags legacy paths captured by nested helpers with local fs aliases": sourceCase` function persist(filePath: string) { function inner() { @@ -2100,7 +2022,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } persist("sessions.json"); - `("src/runtime/nested-wrapper-closed-over-local-fs.ts", filesystemWriteViolations(9)), + `("nested-wrapper-closed-over-local-fs.ts", filesystemWriteViolations(9)), "does not treat named function expression self-bindings as captured write aliases": sourceCase` import { promises as fs } from "node:fs"; @@ -2112,7 +2034,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json"); - `("src/runtime/named-function-expression-write-alias-shadow.ts", []), + `("named-function-expression-write-alias-shadow.ts", []), "flags legacy paths captured by defaulted destructured nested helpers": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -2124,7 +2046,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json"); - `("src/runtime/defaulted-destructured-nested-helper.ts", filesystemWriteViolations(11)), + `("defaulted-destructured-nested-helper.ts", filesystemWriteViolations(11)), "does not use nested helper destructuring defaults when safe callbacks are present": sourceCase` import { promises as fs } from "node:fs"; @@ -2138,7 +2060,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json"); - `("src/runtime/present-safe-callback-nested-default.ts", []), + `("present-safe-callback-nested-default.ts", []), "uses nested helper destructuring defaults when properties are explicitly undefined": sourceCase` import { promises as fs } from "node:fs"; @@ -2151,7 +2073,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json"); - `("src/runtime/undefined-callback-nested-default.ts", filesystemWriteViolations(11)), + `("undefined-callback-nested-default.ts", filesystemWriteViolations(11)), "does not resolve outer object methods through local object shadows": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -2168,7 +2090,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json"); - `("src/runtime/local-object-shadow-nested-method.ts", []), + `("local-object-shadow-nested-method.ts", []), "keeps branch-only object methods inside closed-over nested helpers": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string, enabled: boolean) { @@ -2189,7 +2111,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json", true); - `("src/runtime/branch-only-closed-over-object-method.ts", filesystemWriteViolations(20)), + `("branch-only-closed-over-object-method.ts", filesystemWriteViolations(20)), "keeps branch-only property assigned methods inside closed-over nested helpers": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string, enabled: boolean) { @@ -2205,10 +2127,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json", true); - `( - "src/runtime/branch-only-property-closed-over-object-method.ts", - filesystemWriteViolations(15), - ), + `("branch-only-property-closed-over-object-method.ts", filesystemWriteViolations(15)), "flags legacy paths captured by nested helpers with branch-assigned write aliases": sourceCase` import { promises as fs } from "node:fs"; @@ -2225,7 +2144,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json", true); - `("src/runtime/nested-wrapper-branch-assigned-write-alias.ts", filesystemWriteViolations(15)), + `("nested-wrapper-branch-assigned-write-alias.ts", filesystemWriteViolations(15)), "flags legacy paths captured by conditionally assigned nested helpers": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string, json: boolean) { @@ -2241,7 +2160,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json", true); - `("src/runtime/conditionally-assigned-nested-helper.ts", filesystemWriteViolations(15)), + `("conditionally-assigned-nested-helper.ts", filesystemWriteViolations(15)), "keeps legacy nested helpers after braceless optional reassignment": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string, disabled: boolean) { @@ -2253,10 +2172,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json", false); - `( - "src/runtime/nested-wrapper-braceless-optional-reassignment.ts", - filesystemWriteViolations(11), - ), + `("nested-wrapper-braceless-optional-reassignment.ts", filesystemWriteViolations(11)), "flags legacy paths captured by nested helpers with destructured fs aliases": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -2267,7 +2183,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json"); - `("src/runtime/nested-wrapper-closed-over-destructured-fs.ts", filesystemWriteViolations(10)), + `("nested-wrapper-closed-over-destructured-fs.ts", filesystemWriteViolations(10)), "ignores nested helpers with shadowed local require aliases": sourceCase` function persist(filePath: string, customRequire: NodeRequire) { function inner() { @@ -2278,7 +2194,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } persist("sessions.json", customRequire); - `("src/runtime/nested-wrapper-shadowed-require.ts", []), + `("nested-wrapper-shadowed-require.ts", []), "uses nested helper createRequire shadows from the helper definition": sourceCase` import { createRequire } from "node:module"; function persist(filePath: string, customCreateRequire: typeof createRequire) { @@ -2293,10 +2209,7 @@ describe("check-database-first-legacy-stores", () => { } } persist("sessions.json", customCreateRequire); - `( - "src/runtime/nested-wrapper-create-require-definition-scope.ts", - filesystemWriteViolations(14), - ), + `("nested-wrapper-create-require-definition-scope.ts", filesystemWriteViolations(14)), "does not treat named nested function expressions as closed-over path parameters": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -2306,7 +2219,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json"); - `("src/runtime/nested-wrapper-named-function-expression.ts", []), + `("nested-wrapper-named-function-expression.ts", []), "does not resolve locally shadowed nested helper calls to outer wrappers": sourceCase` import { promises as fs } from "node:fs"; function helper(filePath: string) { @@ -2319,7 +2232,7 @@ describe("check-database-first-legacy-stores", () => { return inner(async () => {}); } await persist("sessions.json"); - `("src/runtime/nested-wrapper-shadowed-helper-call.ts", []), + `("nested-wrapper-shadowed-helper-call.ts", []), "flags legacy paths captured by branch-assigned nested helpers": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string, useJson: boolean) { @@ -2330,10 +2243,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json", true); - `( - "src/runtime/nested-wrapper-branch-assigned-closed-over-path.ts", - filesystemWriteViolations(10), - ), + `("nested-wrapper-branch-assigned-closed-over-path.ts", filesystemWriteViolations(10)), "flags legacy paths captured through nested helper chains": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -2346,10 +2256,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json"); - `( - "src/runtime/nested-wrapper-helper-chain-closed-over-path.ts", - filesystemWriteViolations(12), - ), + `("nested-wrapper-helper-chain-closed-over-path.ts", filesystemWriteViolations(12)), "flags legacy paths captured through hoisted nested helper chains": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -2362,10 +2269,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json"); - `( - "src/runtime/nested-wrapper-hoisted-helper-chain-closed-over-path.ts", - filesystemWriteViolations(12), - ), + `("nested-wrapper-hoisted-helper-chain-closed-over-path.ts", filesystemWriteViolations(12)), "flags hoisted nested helpers that use write aliases declared later": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -2379,7 +2283,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json"); - `("src/runtime/nested-wrapper-hoisted-helper-late-alias.ts", filesystemWriteViolations(13)), + `("nested-wrapper-hoisted-helper-late-alias.ts", filesystemWriteViolations(13)), "flags escaped nested helpers that use write aliases declared later": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -2392,7 +2296,7 @@ describe("check-database-first-legacy-stores", () => { return save?.(); } await persist("sessions.json"); - `("src/runtime/nested-wrapper-escaped-late-write-alias.ts", filesystemWriteViolations(12)), + `("nested-wrapper-escaped-late-write-alias.ts", filesystemWriteViolations(12)), "flags block-escaped nested helpers that use block write aliases declared later": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -2407,10 +2311,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json"); - `( - "src/runtime/nested-wrapper-block-escaped-late-write-alias.ts", - filesystemWriteViolations(14), - ), + `("nested-wrapper-block-escaped-late-write-alias.ts", filesystemWriteViolations(14)), "flags var nested helpers declared in blocks": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -2423,7 +2324,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json"); - `("src/runtime/nested-wrapper-var-block-helper.ts", filesystemWriteViolations(12)), + `("nested-wrapper-var-block-helper.ts", filesystemWriteViolations(12)), "flags var nested helper object methods declared in blocks": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -2440,10 +2341,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json"); - `( - "src/runtime/nested-wrapper-var-block-helper-object-method.ts", - filesystemWriteViolations(16), - ), + `("nested-wrapper-var-block-helper-object-method.ts", filesystemWriteViolations(16)), "flags nested helper defaults from object literal destructuring": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -2451,10 +2349,7 @@ describe("check-database-first-legacy-stores", () => { return save(); } await persist("sessions.json"); - `( - "src/runtime/nested-wrapper-object-literal-destructuring-default.ts", - filesystemWriteViolations(7), - ), + `("nested-wrapper-object-literal-destructuring-default.ts", filesystemWriteViolations(7)), "uses the last object literal property before nested helper destructuring defaults": sourceCase` import { promises as fs } from "node:fs"; @@ -2467,10 +2362,7 @@ describe("check-database-first-legacy-stores", () => { return save?.(); } await persist("sessions.json"); - `( - "src/runtime/nested-wrapper-object-literal-duplicate-default.ts", - filesystemWriteViolations(11), - ), + `("nested-wrapper-object-literal-duplicate-default.ts", filesystemWriteViolations(11)), "does not use nested helper destructuring defaults when the last duplicate is safe": sourceCase` import { promises as fs } from "node:fs"; @@ -2483,7 +2375,7 @@ describe("check-database-first-legacy-stores", () => { return save?.(); } await persist("sessions.json"); - `("src/runtime/nested-wrapper-object-literal-duplicate-safe.ts", []), + `("nested-wrapper-object-literal-duplicate-safe.ts", []), "does not use nested helper destructuring defaults when a spread may provide the property": sourceCase` import { promises as fs } from "node:fs"; @@ -2495,7 +2387,7 @@ describe("check-database-first-legacy-stores", () => { return save(); } await persist("sessions.json"); - `("src/runtime/nested-wrapper-object-literal-spread-safe.ts", []), + `("nested-wrapper-object-literal-spread-safe.ts", []), "does not use nested helper destructuring defaults for untracked identifier spreads": sourceCase` import { promises as fs } from "node:fs"; @@ -2507,7 +2399,7 @@ describe("check-database-first-legacy-stores", () => { return save?.(); } await persist("sessions.json"); - `("src/runtime/nested-wrapper-object-literal-unknown-spread-default.ts", []), + `("nested-wrapper-object-literal-unknown-spread-default.ts", []), "keeps earlier wrapper properties through known-missing object spreads": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -2520,10 +2412,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json"); - `( - "src/runtime/nested-wrapper-object-literal-known-missing-spread.ts", - filesystemWriteViolations(12), - ), + `("nested-wrapper-object-literal-known-missing-spread.ts", filesystemWriteViolations(12)), "uses nested helper destructuring defaults after known undefined object spreads": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -2534,10 +2423,7 @@ describe("check-database-first-legacy-stores", () => { return save(); } await persist("sessions.json"); - `( - "src/runtime/nested-wrapper-object-literal-undefined-spread-default.ts", - filesystemWriteViolations(10), - ), + `("nested-wrapper-object-literal-undefined-spread-default.ts", filesystemWriteViolations(10)), "flags var nested wrappers declared in blocks": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -2547,7 +2433,7 @@ describe("check-database-first-legacy-stores", () => { return inner(filePath); } await persist("sessions.json"); - `("src/runtime/nested-wrapper-var-block-declaration.ts", filesystemWriteViolations(9)), + `("nested-wrapper-var-block-declaration.ts", filesystemWriteViolations(9)), "merges var nested wrapper declarations inside exhaustive branches": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string, enabled: boolean) { @@ -2559,7 +2445,7 @@ describe("check-database-first-legacy-stores", () => { return inner(filePath); } await persist("sessions.json", true); - `("src/runtime/nested-wrapper-var-branch-declaration.ts", filesystemWriteViolations(11)), + `("nested-wrapper-var-branch-declaration.ts", filesystemWriteViolations(11)), "keeps prior var nested wrapper declarations after optional branch redeclarations": sourceCase` import { promises as fs } from "node:fs"; @@ -2571,10 +2457,7 @@ describe("check-database-first-legacy-stores", () => { return inner(filePath); } await persist("sessions.json", false); - `( - "src/runtime/nested-wrapper-var-optional-branch-declaration.ts", - filesystemWriteViolations(10), - ), + `("nested-wrapper-var-optional-branch-declaration.ts", filesystemWriteViolations(10)), "flags var nested wrapper destructuring defaults declared in blocks": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -2584,10 +2467,7 @@ describe("check-database-first-legacy-stores", () => { return save(filePath); } await persist("sessions.json"); - `( - "src/runtime/nested-wrapper-var-block-destructuring-default.ts", - filesystemWriteViolations(9), - ), + `("nested-wrapper-var-block-destructuring-default.ts", filesystemWriteViolations(9)), "flags legacy paths captured through sibling nested helper calls": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -2600,10 +2480,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json"); - `( - "src/runtime/nested-wrapper-sibling-helper-call-closed-over-path.ts", - filesystemWriteViolations(12), - ), + `("nested-wrapper-sibling-helper-call-closed-over-path.ts", filesystemWriteViolations(12)), "flags legacy paths forwarded through sibling nested helper parameters": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -2616,10 +2493,7 @@ describe("check-database-first-legacy-stores", () => { return inner(filePath); } await persist("sessions.json"); - `( - "src/runtime/nested-wrapper-sibling-helper-forwarded-path.ts", - filesystemWriteViolations(12), - ), + `("nested-wrapper-sibling-helper-forwarded-path.ts", filesystemWriteViolations(12)), "flags legacy paths captured through nested arrow helper chains": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -2630,10 +2504,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json"); - `( - "src/runtime/nested-wrapper-arrow-helper-chain-closed-over-path.ts", - filesystemWriteViolations(10), - ), + `("nested-wrapper-arrow-helper-chain-closed-over-path.ts", filesystemWriteViolations(10)), "flags legacy paths captured through nested helper aliases": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -2645,10 +2516,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json"); - `( - "src/runtime/nested-wrapper-helper-alias-closed-over-path.ts", - filesystemWriteViolations(11), - ), + `("nested-wrapper-helper-alias-closed-over-path.ts", filesystemWriteViolations(11)), "flags legacy paths captured through nested object helper methods": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -2663,10 +2531,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json"); - `( - "src/runtime/nested-wrapper-object-helper-closed-over-path.ts", - filesystemWriteViolations(14), - ), + `("nested-wrapper-object-helper-closed-over-path.ts", filesystemWriteViolations(14)), "flags legacy paths captured through nested object helper aliases": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -2678,10 +2543,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json"); - `( - "src/runtime/nested-wrapper-object-helper-alias-closed-over-path.ts", - filesystemWriteViolations(11), - ), + `("nested-wrapper-object-helper-alias-closed-over-path.ts", filesystemWriteViolations(11)), "does not treat nested function declaration shadows as captured write aliases": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -2693,7 +2555,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json"); - `("src/runtime/nested-wrapper-function-declaration-write-shadow.ts", []), + `("nested-wrapper-function-declaration-write-shadow.ts", []), "does not treat nested helper parameters as captured write aliases": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string, customWrite: (value: string) => void) { @@ -2704,7 +2566,7 @@ describe("check-database-first-legacy-stores", () => { return inner(customWrite); } await persist("sessions.json", customWrite); - `("src/runtime/nested-wrapper-parameter-write-alias-shadow.ts", []), + `("nested-wrapper-parameter-write-alias-shadow.ts", []), "flags legacy paths forwarded through nested arrow helpers": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -2712,7 +2574,7 @@ describe("check-database-first-legacy-stores", () => { return inner(filePath); } await persist("sessions.json"); - `("src/runtime/nested-arrow-wrapper-path.ts", filesystemWriteViolations(7)), + `("nested-arrow-wrapper-path.ts", filesystemWriteViolations(7)), "flags legacy paths forwarded through nested object helper methods": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -2724,7 +2586,7 @@ describe("check-database-first-legacy-stores", () => { return writer.inner(filePath); } await persist("sessions.json"); - `("src/runtime/nested-object-wrapper-path.ts", filesystemWriteViolations(11)), + `("nested-object-wrapper-path.ts", filesystemWriteViolations(11)), "flags legacy paths forwarded through nested helper aliases": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -2735,7 +2597,7 @@ describe("check-database-first-legacy-stores", () => { return save(filePath); } await persist("sessions.json"); - `("src/runtime/nested-wrapper-alias-path.ts", filesystemWriteViolations(10)), + `("nested-wrapper-alias-path.ts", filesystemWriteViolations(10)), "flags legacy paths forwarded through assignment-defined nested helper aliases": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -2747,10 +2609,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json"); - `( - "src/runtime/nested-wrapper-assigned-alias-closed-over-path.ts", - filesystemWriteViolations(11), - ), + `("nested-wrapper-assigned-alias-closed-over-path.ts", filesystemWriteViolations(11)), "flags enclosing helper assignments made inside nested helpers": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -2762,7 +2621,7 @@ describe("check-database-first-legacy-stores", () => { return save?.(); } await persist("sessions.json"); - `("src/runtime/nested-wrapper-enclosing-assigned-helper.ts", filesystemWriteViolations(11)), + `("nested-wrapper-enclosing-assigned-helper.ts", filesystemWriteViolations(11)), "flags legacy paths forwarded through extracted nested object helper methods": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -2775,7 +2634,7 @@ describe("check-database-first-legacy-stores", () => { return save(filePath); } await persist("sessions.json"); - `("src/runtime/nested-object-wrapper-alias-path.ts", filesystemWriteViolations(12)), + `("nested-object-wrapper-alias-path.ts", filesystemWriteViolations(12)), "flags legacy paths forwarded through assignment-defined nested object helpers": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -2787,10 +2646,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json"); - `( - "src/runtime/nested-object-wrapper-assigned-alias-closed-over-path.ts", - filesystemWriteViolations(11), - ), + `("nested-object-wrapper-assigned-alias-closed-over-path.ts", filesystemWriteViolations(11)), "merges closed-over var nested wrapper declarations inside exhaustive branches": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string, enabled: boolean) { @@ -2805,10 +2661,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json", true); - `( - "src/runtime/nested-wrapper-var-branch-declaration-closed-over-path.ts", - filesystemWriteViolations(14), - ), + `("nested-wrapper-var-branch-declaration-closed-over-path.ts", filesystemWriteViolations(14)), "merges closed-over var fs aliases declared inside optional branches": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string, enabled: boolean) { @@ -2821,10 +2674,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json", true); - `( - "src/runtime/nested-wrapper-var-fs-alias-branch-closed-over-path.ts", - filesystemWriteViolations(12), - ), + `("nested-wrapper-var-fs-alias-branch-closed-over-path.ts", filesystemWriteViolations(12)), "flags enclosing object helper assignments made inside nested helpers": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -2839,10 +2689,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json"); - `( - "src/runtime/nested-object-wrapper-enclosing-assigned-helper.ts", - filesystemWriteViolations(14), - ), + `("nested-object-wrapper-enclosing-assigned-helper.ts", filesystemWriteViolations(14)), "flags legacy paths forwarded through local nested object helper aliases": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -2859,7 +2706,7 @@ describe("check-database-first-legacy-stores", () => { } await persist("sessions.json"); `( - "src/runtime/nested-object-wrapper-local-method-alias-closed-over-path.ts", + "nested-object-wrapper-local-method-alias-closed-over-path.ts", filesystemWriteViolations(15), ), "flags closed-over nested object helper methods copied through destructuring aliases": @@ -2881,7 +2728,7 @@ describe("check-database-first-legacy-stores", () => { } await persist("sessions.json"); `( - "src/runtime/nested-object-wrapper-destructured-alias-closed-over-path.ts", + "nested-object-wrapper-destructured-alias-closed-over-path.ts", filesystemWriteViolations(17), ), "clears closed-over nested object helpers after exhaustive reassignment": sourceCase` @@ -2903,7 +2750,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json", true); - `("src/runtime/nested-object-wrapper-exhaustive-reassigned-safe.ts", []), + `("nested-object-wrapper-exhaustive-reassigned-safe.ts", []), "keeps closed-over nested helpers after optional branch assignment": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string, enabled: boolean) { @@ -2917,10 +2764,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json", true); - `( - "src/runtime/nested-wrapper-optional-branch-assigned-helper.ts", - filesystemWriteViolations(13), - ), + `("nested-wrapper-optional-branch-assigned-helper.ts", filesystemWriteViolations(13)), "keeps closed-over nested helpers after loop assignment": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string, values: string[]) { @@ -2934,7 +2778,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json", ["{}\\n"]); - `("src/runtime/nested-wrapper-loop-assigned-helper.ts", filesystemWriteViolations(13)), + `("nested-wrapper-loop-assigned-helper.ts", filesystemWriteViolations(13)), "keeps closed-over nested helpers after optional while reassignment": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string, disabled: boolean) { @@ -2948,7 +2792,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json", false); - `("src/runtime/nested-wrapper-while-reassigned-helper.ts", filesystemWriteViolations(13)), + `("nested-wrapper-while-reassigned-helper.ts", filesystemWriteViolations(13)), "keeps switch case closed-over nested helper shadows scoped": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string, mode: string) { @@ -2964,7 +2808,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json", "on"); - `("src/runtime/nested-wrapper-switch-case-shadow.ts", filesystemWriteViolations(15)), + `("nested-wrapper-switch-case-shadow.ts", filesystemWriteViolations(15)), "flags nested helper declarations inside switch cases": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string, mode: string) { @@ -2977,7 +2821,7 @@ describe("check-database-first-legacy-stores", () => { } } await persist("sessions.json", "legacy"); - `("src/runtime/nested-wrapper-switch-case-helper.ts", filesystemWriteViolations(12)), + `("nested-wrapper-switch-case-helper.ts", filesystemWriteViolations(12)), "keeps closed-over nested helpers after try assignment": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -2991,7 +2835,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json"); - `("src/runtime/nested-wrapper-try-assigned-helper.ts", filesystemWriteViolations(13)), + `("nested-wrapper-try-assigned-helper.ts", filesystemWriteViolations(13)), "flags closed-over nested helper aliases to outer wrappers": sourceCase` import { promises as fs } from "node:fs"; function writePath(path: string) { @@ -3005,7 +2849,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json"); - `("src/runtime/nested-wrapper-outer-wrapper-alias.ts", filesystemWriteViolations(13)), + `("nested-wrapper-outer-wrapper-alias.ts", filesystemWriteViolations(13)), "flags closed-over nested helper parameter default writes": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -3015,7 +2859,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json"); - `("src/runtime/nested-wrapper-parameter-default-write.ts", filesystemWriteViolations(9)), + `("nested-wrapper-parameter-default-write.ts", filesystemWriteViolations(9)), "does not use outer write aliases shadowed later in closed-over helpers": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -3028,7 +2872,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json"); - `("src/runtime/nested-wrapper-later-write-alias-shadow.ts", []), + `("nested-wrapper-later-write-alias-shadow.ts", []), "flags closed-over aliases to top-level wrappers with local metadata": sourceCase` import { promises as fs } from "node:fs"; function writePath(path: string) { @@ -3044,7 +2888,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json"); - `("src/runtime/nested-wrapper-top-level-wrapper-alias.ts", filesystemWriteViolations(15)), + `("nested-wrapper-top-level-wrapper-alias.ts", filesystemWriteViolations(15)), "flags closed-over aliases to top-level wrappers with module object metadata": sourceCase` import { promises as fs } from "node:fs"; const writer = {}; @@ -3060,10 +2904,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json"); - `( - "src/runtime/nested-wrapper-top-level-object-wrapper-alias.ts", - filesystemWriteViolations(15), - ), + `("nested-wrapper-top-level-object-wrapper-alias.ts", filesystemWriteViolations(15)), "flags legacy paths forwarded through destructured local nested object helpers": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -3080,7 +2921,7 @@ describe("check-database-first-legacy-stores", () => { } await persist("sessions.json"); `( - "src/runtime/nested-object-wrapper-local-method-destructure-closed-over-path.ts", + "nested-object-wrapper-local-method-destructure-closed-over-path.ts", filesystemWriteViolations(15), ), "flags legacy paths forwarded through destructured nested object helper methods": sourceCase` @@ -3095,7 +2936,7 @@ describe("check-database-first-legacy-stores", () => { return inner(filePath); } await persist("sessions.json"); - `("src/runtime/nested-object-wrapper-destructure-path.ts", filesystemWriteViolations(12)), + `("nested-object-wrapper-destructure-path.ts", filesystemWriteViolations(12)), "flags legacy paths forwarded through renamed nested object helper methods": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -3108,10 +2949,7 @@ describe("check-database-first-legacy-stores", () => { return save(filePath); } await persist("sessions.json"); - `( - "src/runtime/nested-object-wrapper-renamed-destructure-path.ts", - filesystemWriteViolations(12), - ), + `("nested-object-wrapper-renamed-destructure-path.ts", filesystemWriteViolations(12)), "keeps nested wrapper assignments inside optional branches": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string, useJson: boolean) { @@ -3122,7 +2960,7 @@ describe("check-database-first-legacy-stores", () => { return inner(filePath); } await persist("sessions.json", true); - `("src/runtime/nested-wrapper-optional-branch-assignment.ts", filesystemWriteViolations(10)), + `("nested-wrapper-optional-branch-assignment.ts", filesystemWriteViolations(10)), "keeps previous nested wrappers after optional safe reassignment": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string, disabled: boolean) { @@ -3133,7 +2971,7 @@ describe("check-database-first-legacy-stores", () => { return inner(filePath); } await persist("sessions.json", false); - `("src/runtime/nested-wrapper-optional-safe-reassignment.ts", filesystemWriteViolations(10)), + `("nested-wrapper-optional-safe-reassignment.ts", filesystemWriteViolations(10)), "keeps nested object wrapper assignments inside optional branches": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string, useJson: boolean) { @@ -3144,10 +2982,7 @@ describe("check-database-first-legacy-stores", () => { return writer.inner?.(filePath); } await persist("sessions.json", true); - `( - "src/runtime/nested-object-wrapper-optional-branch-assignment.ts", - filesystemWriteViolations(10), - ), + `("nested-object-wrapper-optional-branch-assignment.ts", filesystemWriteViolations(10)), "keeps nested object wrapper methods from optional whole-object assignments": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string, useJson: boolean) { @@ -3162,10 +2997,7 @@ describe("check-database-first-legacy-stores", () => { return writer.inner?.(filePath); } await persist("sessions.json", true); - `( - "src/runtime/nested-object-wrapper-optional-object-assignment.ts", - filesystemWriteViolations(14), - ), + `("nested-object-wrapper-optional-object-assignment.ts", filesystemWriteViolations(14)), "flags legacy paths after exhaustive nested wrapper assignments": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string, useJson: boolean) { @@ -3178,7 +3010,7 @@ describe("check-database-first-legacy-stores", () => { return inner(filePath); } await persist("sessions.json", true); - `("src/runtime/nested-wrapper-branch-assignment.ts", filesystemWriteViolations(12)), + `("nested-wrapper-branch-assignment.ts", filesystemWriteViolations(12)), "flags legacy paths after nested wrapper assignments inside plain blocks": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -3189,7 +3021,7 @@ describe("check-database-first-legacy-stores", () => { return inner(filePath); } await persist("sessions.json"); - `("src/runtime/nested-wrapper-block-assignment.ts", filesystemWriteViolations(10)), + `("nested-wrapper-block-assignment.ts", filesystemWriteViolations(10)), "keeps exhaustive nested wrapper assignments inside plain blocks": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string, useJson: boolean) { @@ -3204,7 +3036,7 @@ describe("check-database-first-legacy-stores", () => { return inner(filePath); } await persist("sessions.json", true); - `("src/runtime/nested-wrapper-block-branch-assignment.ts", filesystemWriteViolations(14)), + `("nested-wrapper-block-branch-assignment.ts", filesystemWriteViolations(14)), "does not leak branch-local nested wrapper shadows after exhaustive branches": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string, useJson: boolean) { @@ -3223,7 +3055,7 @@ describe("check-database-first-legacy-stores", () => { return inner(filePath); } await persist("sessions.json", true); - `("src/runtime/nested-wrapper-branch-local-shadow.ts", []), + `("nested-wrapper-branch-local-shadow.ts", []), "refreshes block-local aliases for nested wrappers assigned to outer bindings": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -3235,7 +3067,7 @@ describe("check-database-first-legacy-stores", () => { return inner(filePath); } await persist("sessions.json"); - `("src/runtime/nested-wrapper-block-local-late-alias.ts", filesystemWriteViolations(11)), + `("nested-wrapper-block-local-late-alias.ts", filesystemWriteViolations(11)), "keeps escaped nested wrapper aliases isolated from sibling blocks": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -3250,7 +3082,7 @@ describe("check-database-first-legacy-stores", () => { return inner(filePath); } await persist("sessions.json"); - `("src/runtime/nested-wrapper-sibling-block-alias-shadow.ts", filesystemWriteViolations(14)), + `("nested-wrapper-sibling-block-alias-shadow.ts", filesystemWriteViolations(14)), "refreshes merged nested wrapper assignments after later aliases": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string, useJson: boolean) { @@ -3264,7 +3096,7 @@ describe("check-database-first-legacy-stores", () => { return inner(filePath); } await persist("sessions.json", true); - `("src/runtime/nested-wrapper-branch-late-alias.ts", filesystemWriteViolations(13)), + `("nested-wrapper-branch-late-alias.ts", filesystemWriteViolations(13)), "refreshes branch-local aliases before merging nested wrapper assignments": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string, useJson: boolean) { @@ -3279,7 +3111,7 @@ describe("check-database-first-legacy-stores", () => { return inner(filePath); } await persist("sessions.json", true); - `("src/runtime/nested-wrapper-branch-local-late-alias.ts", filesystemWriteViolations(14)), + `("nested-wrapper-branch-local-late-alias.ts", filesystemWriteViolations(14)), "flags legacy paths after exhaustive nested object wrapper assignments": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string, useJson: boolean) { @@ -3300,7 +3132,7 @@ describe("check-database-first-legacy-stores", () => { return writer.inner(filePath); } await persist("sessions.json", true); - `("src/runtime/nested-object-wrapper-branch-assignment.ts", filesystemWriteViolations(20)), + `("nested-object-wrapper-branch-assignment.ts", filesystemWriteViolations(20)), "clears stale nested object wrapper methods after exhaustive object reassignment": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string, useJson: boolean) { @@ -3317,7 +3149,7 @@ describe("check-database-first-legacy-stores", () => { return writer.inner?.(filePath); } await persist("sessions.json", true); - `("src/runtime/nested-object-wrapper-exhaustive-object-reassignment.ts", []), + `("nested-object-wrapper-exhaustive-object-reassignment.ts", []), "flags legacy paths after exhaustive nested object wrapper parameter assignments": sourceCase` import { promises as fs } from "node:fs"; function persist( @@ -3333,10 +3165,7 @@ describe("check-database-first-legacy-stores", () => { return writer.inner?.(filePath); } await persist("sessions.json", {}, true); - `( - "src/runtime/nested-object-wrapper-parameter-branch-assignment.ts", - filesystemWriteViolations(15), - ), + `("nested-object-wrapper-parameter-branch-assignment.ts", filesystemWriteViolations(15)), "keeps block-local nested wrapper shadows scoped to their block": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -3349,7 +3178,7 @@ describe("check-database-first-legacy-stores", () => { return inner(filePath); } await persist("sessions.json"); - `("src/runtime/nested-wrapper-block-shadow.ts", filesystemWriteViolations(12)), + `("nested-wrapper-block-shadow.ts", filesystemWriteViolations(12)), "does not use outer nested wrappers for destructured parameter shadows": sourceCase` import { promises as fs } from "node:fs"; function helper(nextPath: string) { @@ -3359,7 +3188,7 @@ describe("check-database-first-legacy-stores", () => { return helper(filePath); } await persist({ helper: async () => {} }, "sessions.json"); - `("src/runtime/nested-wrapper-destructured-parameter-shadow.ts", []), + `("nested-wrapper-destructured-parameter-shadow.ts", []), "uses enclosing aliases when nested wrapper helpers are called": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -3370,7 +3199,7 @@ describe("check-database-first-legacy-stores", () => { return inner(filePath); } await persist("sessions.json"); - `("src/runtime/nested-wrapper-alias.ts", filesystemWriteViolations(10)), + `("nested-wrapper-alias.ts", filesystemWriteViolations(10)), "does not use caller block shadows for nested wrapper helper aliases": sourceCase` import { createRequire } from "node:module"; const req = createRequire(import.meta.url); @@ -3385,7 +3214,7 @@ describe("check-database-first-legacy-stores", () => { } } await persist("sessions.json", require); - `("src/runtime/nested-wrapper-call-shadow.ts", filesystemWriteViolations(14)), + `("nested-wrapper-call-shadow.ts", filesystemWriteViolations(14)), // Wrapper parameter and body alias state. "flags wrapper object binding defaults for explicit undefined forwarded properties": @@ -3401,10 +3230,7 @@ describe("check-database-first-legacy-stores", () => { return forward({ path: undefined }); } await persist("sessions.json"); - `( - "src/runtime/wrapper-forwarded-undefined-object-property-default.ts", - filesystemWriteViolations(12), - ), + `("wrapper-forwarded-undefined-object-property-default.ts", filesystemWriteViolations(12)), "flags legacy paths from defaulted wrapper parameters": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; function persistPath(filePath = "sessions.json") { @@ -3422,7 +3248,7 @@ describe("check-database-first-legacy-stores", () => { persistDestructured({}); persistDestructured({ filePath: undefined }); persistDestructured({ filePath: currentSqlitePath }); - `("src/runtime/defaulted-wrapper-paths.ts", filesystemWriteViolations(12, 13, 14, 15, 16)), + `("defaulted-wrapper-paths.ts", filesystemWriteViolations(12, 13, 14, 15, 16)), "does not treat ambient declarations as undefined wrapper arguments": sourceCase` import fs from "node:fs/promises"; declare const provided: string; @@ -3430,7 +3256,7 @@ describe("check-database-first-legacy-stores", () => { return fs.writeFile(filePath, "{}\\n"); } await persist(provided); - `("src/runtime/ambient-defaulted-wrapper-path.ts", []), + `("ambient-defaulted-wrapper-path.ts", []), "clears wrapper object parameter paths after reassignment": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; function persist(params: { filePath: string }) { @@ -3438,7 +3264,7 @@ describe("check-database-first-legacy-stores", () => { return writeTextAtomic(params.filePath, "{}\\n"); } await persist({ filePath: "sessions.json" }); - `("src/runtime/reassigned-wrapper-object-options.ts", []), + `("reassigned-wrapper-object-options.ts", []), "clears wrapper object parameter paths after nested block reassignment": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; function persist(params: { filePath: string }) { @@ -3448,7 +3274,7 @@ describe("check-database-first-legacy-stores", () => { return writeTextAtomic(params.filePath, "{}\\n"); } await persist({ filePath: "sessions.json" }); - `("src/runtime/nested-reassigned-wrapper-object-options.ts", []), + `("nested-reassigned-wrapper-object-options.ts", []), "does not let block-local wrapper parameter shadows clear outer paths": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; function persist(params: { filePath: string }) { @@ -3459,7 +3285,7 @@ describe("check-database-first-legacy-stores", () => { return writeTextAtomic(params.filePath, "{}\\n"); } await persist({ filePath: "sessions.json" }); - `("src/runtime/block-local-wrapper-object-options-shadow.ts", filesystemWriteViolations(10)), + `("block-local-wrapper-object-options-shadow.ts", filesystemWriteViolations(10)), "clears destructured wrapper option paths after reassignment": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; function persist({ filePath }: { filePath: string }) { @@ -3467,7 +3293,7 @@ describe("check-database-first-legacy-stores", () => { return writeTextAtomic(filePath, "{}\\n"); } await persist({ filePath: "sessions.json" }); - `("src/runtime/reassigned-destructured-wrapper-options.ts", []), + `("reassigned-destructured-wrapper-options.ts", []), "clears wrapper object property paths after reassignment": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; function persist(params: { filePath: string }) { @@ -3475,7 +3301,7 @@ describe("check-database-first-legacy-stores", () => { return writeTextAtomic(params.filePath, "{}\\n"); } await persist({ filePath: "sessions.json" }); - `("src/runtime/reassigned-wrapper-property-options.ts", []), + `("reassigned-wrapper-property-options.ts", []), "clears nested wrapper object property paths after reassignment": sourceCase` import { promises as fs } from "node:fs"; function persist(params: { paths: { filePath: string } }) { @@ -3483,7 +3309,7 @@ describe("check-database-first-legacy-stores", () => { return fs.writeFile(params.paths.filePath, "{}\\n"); } await persist({ paths: { filePath: "sessions.json" } }); - `("src/runtime/reassigned-nested-wrapper-property-options.ts", []), + `("reassigned-nested-wrapper-property-options.ts", []), "updates wrapper object property paths after reassignment from another parameter": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; function persist(params: { filePath: string }, legacy: { filePath: string }) { @@ -3491,7 +3317,7 @@ describe("check-database-first-legacy-stores", () => { return writeTextAtomic(params.filePath, "{}\\n"); } await persist({ filePath: currentSqlitePath }, { filePath: "sessions.json" }); - `("src/runtime/reassigned-wrapper-property-from-parameter.ts", filesystemWriteViolations(7)), + `("reassigned-wrapper-property-from-parameter.ts", filesystemWriteViolations(7)), "keeps wrapper object property paths after conditional reassignment": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; function persist(params: { filePath: string }) { @@ -3499,10 +3325,7 @@ describe("check-database-first-legacy-stores", () => { return writeTextAtomic(params.filePath, "{}\\n"); } await persist({ filePath: "sessions.json" }); - `( - "src/runtime/conditional-reassigned-wrapper-property-options.ts", - filesystemWriteViolations(7), - ), + `("conditional-reassigned-wrapper-property-options.ts", filesystemWriteViolations(7)), "keeps wrapper object parameter paths after conditional reassignment": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; function persist(params: { filePath: string }) { @@ -3510,10 +3333,7 @@ describe("check-database-first-legacy-stores", () => { return writeTextAtomic(params.filePath, "{}\\n"); } await persist({ filePath: "sessions.json" }); - `( - "src/runtime/conditional-reassigned-wrapper-object-options.ts", - filesystemWriteViolations(7), - ), + `("conditional-reassigned-wrapper-object-options.ts", filesystemWriteViolations(7)), "keeps wrapper object property paths after for-of reassignment": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; function persist(params: { filePath: string }) { @@ -3523,7 +3343,7 @@ describe("check-database-first-legacy-stores", () => { return writeTextAtomic(params.filePath, "{}\\n"); } await persist({ filePath: "sessions.json" }); - `("src/runtime/for-of-reassigned-wrapper-property-options.ts", filesystemWriteViolations(9)), + `("for-of-reassigned-wrapper-property-options.ts", filesystemWriteViolations(9)), "keeps wrapper object property paths after try-block reassignment": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; function persist(params: { filePath: string }) { @@ -3534,7 +3354,7 @@ describe("check-database-first-legacy-stores", () => { return writeTextAtomic(params.filePath, "{}\\n"); } await persist({ filePath: "sessions.json" }); - `("src/runtime/try-reassigned-wrapper-property-options.ts", filesystemWriteViolations(10)), + `("try-reassigned-wrapper-property-options.ts", filesystemWriteViolations(10)), "clears wrapper object property paths after exhaustive current-path assignments": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; function persist(params: { filePath: string }) { @@ -3543,7 +3363,7 @@ describe("check-database-first-legacy-stores", () => { return writeTextAtomic(params.filePath, "{}\\n"); } await persist({ filePath: "sessions.json" }); - `("src/runtime/exhaustive-current-wrapper-property-options.ts", []), + `("exhaustive-current-wrapper-property-options.ts", []), "clears wrapper object parameter paths after exhaustive current-object assignments": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; @@ -3553,7 +3373,7 @@ describe("check-database-first-legacy-stores", () => { return writeTextAtomic(params.filePath, "{}\\n"); } await persist({ filePath: "sessions.json" }); - `("src/runtime/exhaustive-current-wrapper-object-options.ts", []), + `("exhaustive-current-wrapper-object-options.ts", []), "flags wrapper object property paths after mixed exhaustive assignments": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; function persist(params: { filePath: string }, legacy: { filePath: string }) { @@ -3562,7 +3382,7 @@ describe("check-database-first-legacy-stores", () => { return writeTextAtomic(params.filePath, "{}\\n"); } await persist({ filePath: currentSqlitePath }, { filePath: "sessions.json" }); - `("src/runtime/exhaustive-mixed-wrapper-property-options.ts", filesystemWriteViolations(8)), + `("exhaustive-mixed-wrapper-property-options.ts", filesystemWriteViolations(8)), "flags wrapper object property paths after conditional reassignment from another parameter": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; @@ -3571,10 +3391,7 @@ describe("check-database-first-legacy-stores", () => { return writeTextAtomic(params.filePath, "{}\\n"); } await persist({ filePath: currentSqlitePath }, { filePath: "sessions.json" }); - `( - "src/runtime/conditional-reassigned-wrapper-property-from-parameter.ts", - filesystemWriteViolations(7), - ), + `("conditional-reassigned-wrapper-property-from-parameter.ts", filesystemWriteViolations(7)), "flags wrapper object paths after conditional reassignment from another parameter": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; @@ -3583,10 +3400,7 @@ describe("check-database-first-legacy-stores", () => { return writeTextAtomic(params.filePath, "{}\\n"); } await persist({ filePath: currentSqlitePath }, { filePath: "sessions.json" }); - `( - "src/runtime/conditional-reassigned-wrapper-object-from-parameter.ts", - filesystemWriteViolations(7), - ), + `("conditional-reassigned-wrapper-object-from-parameter.ts", filesystemWriteViolations(7)), "flags destructured wrapper paths after conditional reassignment from another parameter": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; @@ -3595,10 +3409,7 @@ describe("check-database-first-legacy-stores", () => { return writeTextAtomic(filePath, "{}\\n"); } await persist({ filePath: currentSqlitePath }, { filePath: "sessions.json" }); - `( - "src/runtime/conditional-reassigned-destructured-wrapper-options.ts", - filesystemWriteViolations(7), - ), + `("conditional-reassigned-destructured-wrapper-options.ts", filesystemWriteViolations(7)), "flags legacy paths passed through locally destructured wrapper options": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; function persist(params: { filePath: string }) { @@ -3606,7 +3417,7 @@ describe("check-database-first-legacy-stores", () => { return writeTextAtomic(filePath, "{}\\n"); } await persist({ filePath: "sessions.json" }); - `("src/runtime/local-destructured-wrapper-options.ts", filesystemWriteViolations(7)), + `("local-destructured-wrapper-options.ts", filesystemWriteViolations(7)), "flags legacy paths passed through local wrapper property aliases": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; function persist(params: { filePath: string }) { @@ -3614,7 +3425,7 @@ describe("check-database-first-legacy-stores", () => { return writeTextAtomic(filePath, "{}\\n"); } await persist({ filePath: "sessions.json" }); - `("src/runtime/local-property-alias-wrapper-options.ts", filesystemWriteViolations(7)), + `("local-property-alias-wrapper-options.ts", filesystemWriteViolations(7)), "flags legacy paths passed through local wrapper object aliases": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; function persist(params: { filePath: string }) { @@ -3622,7 +3433,7 @@ describe("check-database-first-legacy-stores", () => { return writeTextAtomic(target.filePath, "{}\\n"); } await persist({ filePath: "sessions.json" }); - `("src/runtime/local-object-alias-wrapper-options.ts", filesystemWriteViolations(7)), + `("local-object-alias-wrapper-options.ts", filesystemWriteViolations(7)), "flags wrapper object paths after reassignment from another parameter": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; function persist(params: { filePath: string }, legacy: { filePath: string }) { @@ -3630,7 +3441,7 @@ describe("check-database-first-legacy-stores", () => { return writeTextAtomic(params.filePath, "{}\\n"); } await persist({ filePath: currentSqlitePath }, { filePath: "sessions.json" }); - `("src/runtime/reassigned-wrapper-object-from-parameter.ts", filesystemWriteViolations(7)), + `("reassigned-wrapper-object-from-parameter.ts", filesystemWriteViolations(7)), "flags wrapper object property paths after nested block reassignment from another parameter": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; @@ -3641,7 +3452,7 @@ describe("check-database-first-legacy-stores", () => { return writeTextAtomic(params.filePath, "{}\\n"); } await persist({ filePath: currentSqlitePath }, { filePath: "sessions.json" }); - `("src/runtime/nested-block-reassigned-wrapper-property.ts", filesystemWriteViolations(9)), + `("nested-block-reassigned-wrapper-property.ts", filesystemWriteViolations(9)), "flags wrapper destructured paths after nested block reassignment from another parameter": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; @@ -3652,10 +3463,7 @@ describe("check-database-first-legacy-stores", () => { return writeTextAtomic(filePath, "{}\\n"); } await persist({ filePath: currentSqlitePath }, { filePath: "sessions.json" }); - `( - "src/runtime/nested-block-reassigned-wrapper-destructured.ts", - filesystemWriteViolations(9), - ), + `("nested-block-reassigned-wrapper-destructured.ts", filesystemWriteViolations(9)), "does not leak block-local wrapper path aliases into the parent block": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; function persist(params: { filePath: string }) { @@ -3666,7 +3474,7 @@ describe("check-database-first-legacy-stores", () => { return writeTextAtomic(filePath, "{}\\n"); } await persist({ filePath: "sessions.json" }); - `("src/runtime/block-local-wrapper-path-alias.ts", []), + `("block-local-wrapper-path-alias.ts", []), "flags wrapper option paths written through body-local fs aliases": sourceCase` import fs from "node:fs/promises"; function persist(params: { filePath: string }) { @@ -3674,7 +3482,7 @@ describe("check-database-first-legacy-stores", () => { return writeFile(params.filePath, "{}\\n"); } await persist({ filePath: "sessions.json" }); - `("src/runtime/body-local-fs-alias-wrapper.ts", filesystemWriteViolations(7)), + `("body-local-fs-alias-wrapper.ts", filesystemWriteViolations(7)), "flags wrapper option paths written through body-local fs method aliases": sourceCase` import fs from "node:fs/promises"; function persist(params: { filePath: string }) { @@ -3682,7 +3490,7 @@ describe("check-database-first-legacy-stores", () => { return save(params.filePath, "{}\\n"); } await persist({ filePath: "sessions.json" }); - `("src/runtime/wrapper-body-local-fs-method-alias.ts", filesystemWriteViolations(7)), + `("wrapper-body-local-fs-method-alias.ts", filesystemWriteViolations(7)), "flags wrapper option paths written through branch-assigned body-local fs aliases": sourceCase` import fs from "node:fs/promises"; @@ -3696,10 +3504,7 @@ describe("check-database-first-legacy-stores", () => { return write(params.filePath, "{}\\n"); } await persist({ filePath: "sessions.json" }, true); - `( - "src/runtime/branch-assigned-body-local-fs-alias-wrapper.ts", - filesystemWriteViolations(12), - ), + `("branch-assigned-body-local-fs-alias-wrapper.ts", filesystemWriteViolations(12)), "flags nested wrapper helpers capturing branch-assigned body-local fs aliases": sourceCase` import fs from "node:fs/promises"; function persist(params: { filePath: string }, ready: boolean) { @@ -3715,10 +3520,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist({ filePath: "sessions.json" }, true); - `( - "src/runtime/nested-wrapper-branch-assigned-body-local-fs-alias.ts", - filesystemWriteViolations(15), - ), + `("nested-wrapper-branch-assigned-body-local-fs-alias.ts", filesystemWriteViolations(15)), "flags wrapper option paths written through body-local fs object aliases": sourceCase` import fs from "node:fs/promises"; function persist(params: { filePath: string }) { @@ -3726,7 +3528,7 @@ describe("check-database-first-legacy-stores", () => { return writer.writeFile(params.filePath, "{}\\n"); } await persist({ filePath: "sessions.json" }); - `("src/runtime/body-local-fs-object-alias-wrapper.ts", filesystemWriteViolations(7)), + `("body-local-fs-object-alias-wrapper.ts", filesystemWriteViolations(7)), "flags wrapper option paths written through bracketed body-local fs object aliases": sourceCase` import fs from "node:fs/promises"; @@ -3735,7 +3537,7 @@ describe("check-database-first-legacy-stores", () => { return writer["writeFile"](params.filePath, "{}\\n"); } await persist({ filePath: "sessions.json" }); - `("src/runtime/body-local-bracket-fs-object-alias-wrapper.ts", filesystemWriteViolations(7)), + `("body-local-bracket-fs-object-alias-wrapper.ts", filesystemWriteViolations(7)), "clears wrapper body fs object aliases after object reassignment": sourceCase` import fs from "node:fs/promises"; function persist(params: { filePath: string }) { @@ -3744,7 +3546,7 @@ describe("check-database-first-legacy-stores", () => { return writer.writeFile(params.filePath, "{}\\n"); } await persist({ filePath: "sessions.json" }); - `("src/runtime/reassigned-body-local-fs-object-alias-wrapper.ts", []), + `("reassigned-body-local-fs-object-alias-wrapper.ts", []), "does not let block-local wrapper aliases mutate outer wrapper metadata": sourceCase` import fs from "node:fs/promises"; import { writeFile } from "../infra/custom-writer.js"; @@ -3757,14 +3559,14 @@ describe("check-database-first-legacy-stores", () => { await save({ filePath: "sessions.json" }); } await persist({ filePath: "cron/jobs.json" }); - `("src/runtime/block-local-wrapper-alias-metadata.ts", []), + `("block-local-wrapper-alias-metadata.ts", []), "flags wrapper option paths written through fs.promises": sourceCase` import fs from "node:fs"; function persist(params: { filePath: string }) { return fs.promises.writeFile(params.filePath, "{}\\n"); } await persist({ filePath: "sessions.json" }); - `("src/runtime/wrapper-fs-promises-write.ts", filesystemWriteViolations(6)), + `("wrapper-fs-promises-write.ts", filesystemWriteViolations(6)), "flags wrapper option paths written through outer fs module object aliases": sourceCase` import fs from "node:fs/promises"; const deps = { fs }; @@ -3772,26 +3574,26 @@ describe("check-database-first-legacy-stores", () => { return deps.fs.writeFile(params.filePath, "{}\\n"); } persist({ filePath: "sessions.json" }); - `("src/runtime/wrapper-outer-fs-module-object-alias.ts", filesystemWriteViolations(7)), + `("wrapper-outer-fs-module-object-alias.ts", filesystemWriteViolations(7)), "flags wrapper option paths written through injected fs handles": sourceCase` function persist(deps: { fs: typeof import("node:fs") }, params: { filePath: string }) { return deps.fs.promises.writeFile(params.filePath, "{}\\n"); } await persist(deps, { filePath: "sessions.json" }); - `("src/runtime/wrapper-injected-fs-write.ts", filesystemWriteViolations(5)), + `("wrapper-injected-fs-write.ts", filesystemWriteViolations(5)), "does not treat untyped wrapper fs properties as filesystem writes": sourceCase` function persist(deps: { fs: { promises: { writeFile: Function } } }, params: { filePath: string }) { return deps.fs.promises.writeFile(params.filePath, "{}\\n"); } await persist(deps, { filePath: "sessions.json" }); - `("src/runtime/wrapper-custom-fs-property.ts", []), + `("wrapper-custom-fs-property.ts", []), "flags wrapper option paths written through CommonJS fs": sourceCase` function persist(params: { filePath: string }) { const fs = require("node:fs"); return fs.promises.writeFile(params.filePath, "{}\\n"); } await persist({ filePath: "sessions.json" }); - `("src/runtime/wrapper-commonjs-fs-write.ts", filesystemWriteViolations(6)), + `("wrapper-commonjs-fs-write.ts", filesystemWriteViolations(6)), "flags wrapper options forwarded to filePath helper objects": sourceCase` import { appendRegularFile, replaceFileAtomic } from "../infra/fs-safe.js"; function append(options: { filePath: string; content: string }) { @@ -3802,7 +3604,7 @@ describe("check-database-first-legacy-stores", () => { } append({ filePath: "sessions.json", content: "{}\\n" }); replace({ filePath: "plugin-state/state.sqlite", content: "" }); - `("src/runtime/forwarded-filepath-helper-options.ts", filesystemWriteViolations(9, 10)), + `("forwarded-filepath-helper-options.ts", filesystemWriteViolations(9, 10)), "flags wrapper options forwarded through another wrapper": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; function persist(params: { filePath: string }) { @@ -3812,7 +3614,7 @@ describe("check-database-first-legacy-stores", () => { return persist(params); } save({ filePath: "sessions.json" }); - `("src/runtime/transitive-wrapper-forwarding.ts", filesystemWriteViolations(9)), + `("transitive-wrapper-forwarding.ts", filesystemWriteViolations(9)), "flags wrapper options spread through another wrapper": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; function persist(params: { filePath: string }) { @@ -3822,7 +3624,7 @@ describe("check-database-first-legacy-stores", () => { return persist({ ...params }); } save({ filePath: "sessions.json" }); - `("src/runtime/transitive-wrapper-spread-forwarding.ts", filesystemWriteViolations(9)), + `("transitive-wrapper-spread-forwarding.ts", filesystemWriteViolations(9)), "allows wrapper spread forwarding when a later property overrides the path": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; function persist(params: { filePath: string }) { @@ -3832,7 +3634,7 @@ describe("check-database-first-legacy-stores", () => { return persist({ ...params, filePath: currentSqlitePath }); } save({ filePath: "sessions.json" }); - `("src/runtime/transitive-wrapper-spread-overridden-forwarding.ts", []), + `("transitive-wrapper-spread-overridden-forwarding.ts", []), "flags wrapper spread forwarding when a later spread restores the path": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; function persist(params: { filePath: string }) { @@ -3842,10 +3644,7 @@ describe("check-database-first-legacy-stores", () => { return persist({ filePath: currentSqlitePath, ...params }); } save({ filePath: "sessions.json" }); - `( - "src/runtime/transitive-wrapper-spread-restored-forwarding.ts", - filesystemWriteViolations(9), - ), + `("transitive-wrapper-spread-restored-forwarding.ts", filesystemWriteViolations(9)), "flags wrapper options renamed through another wrapper": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; function persist(params: { filePath: string }) { @@ -3855,7 +3654,7 @@ describe("check-database-first-legacy-stores", () => { return persist({ filePath: params.storePath }); } save({ storePath: "sessions.json" }); - `("src/runtime/transitive-wrapper-renamed-forwarding.ts", filesystemWriteViolations(9)), + `("transitive-wrapper-renamed-forwarding.ts", filesystemWriteViolations(9)), "flags hoisted wrappers that use write aliases declared later": sourceCase` import fs from "node:fs/promises"; function persist(params: { filePath: string }) { @@ -3863,7 +3662,7 @@ describe("check-database-first-legacy-stores", () => { } const { writeFile } = fs; await persist({ filePath: "sessions.json" }); - `("src/runtime/late-alias-hoisted-wrapper.ts", filesystemWriteViolations(7)), + `("late-alias-hoisted-wrapper.ts", filesystemWriteViolations(7)), "flags hoisted wrappers that use renamed write aliases declared later": sourceCase` import fs from "node:fs/promises"; function persist(params: { filePath: string }) { @@ -3871,13 +3670,13 @@ describe("check-database-first-legacy-stores", () => { } const { writeFile: write } = fs; await persist({ filePath: "sessions.json" }); - `("src/runtime/late-renamed-alias-hoisted-wrapper.ts", filesystemWriteViolations(7)), + `("late-renamed-alias-hoisted-wrapper.ts", filesystemWriteViolations(7)), "flags reassigned wrapper variables": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; let persist; persist = (params: { filePath: string }) => writeTextAtomic(params.filePath, "{}\\n"); persist({ filePath: "sessions.json" }); - `("src/runtime/reassigned-wrapper-variable.ts", filesystemWriteViolations(5)), + `("reassigned-wrapper-variable.ts", filesystemWriteViolations(5)), "flags aliased wrapper variables": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; function persist(params: { filePath: string }) { @@ -3885,7 +3684,7 @@ describe("check-database-first-legacy-stores", () => { } const save = persist; save({ filePath: "sessions.json" }); - `("src/runtime/aliased-wrapper-variable.ts", filesystemWriteViolations(7)), + `("aliased-wrapper-variable.ts", filesystemWriteViolations(7)), "does not treat aliased top-level helpers as closing over wrapper parameters": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; const filePath = "not-openclaw-state.txt"; @@ -3897,7 +3696,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } persist("sessions.json"); - `("src/runtime/aliased-top-level-wrapper-closed-over-module-var.ts", []), + `("aliased-top-level-wrapper-closed-over-module-var.ts", []), // Object-backed wrapper discovery and alias tracking. "flags object method wrappers": sourceCase` @@ -3908,14 +3707,14 @@ describe("check-database-first-legacy-stores", () => { }, }; writer.persist({ filePath: "sessions.json" }); - `("src/runtime/object-method-wrapper.ts", filesystemWriteViolations(8)), + `("object-method-wrapper.ts", filesystemWriteViolations(8)), "flags object property wrapper functions": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; const writer = { persist: (params: { filePath: string }) => writeTextAtomic(params.filePath, "{}\\n"), }; writer["persist"]({ filePath: "sessions.json" }); - `("src/runtime/object-property-wrapper-function.ts", filesystemWriteViolations(6)), + `("object-property-wrapper-function.ts", filesystemWriteViolations(6)), "flags object wrapper shorthand aliases": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; function persist(params: { filePath: string }) { @@ -3923,7 +3722,7 @@ describe("check-database-first-legacy-stores", () => { } const writer = { persist }; await writer.persist({ filePath: "sessions.json" }); - `("src/runtime/object-wrapper-shorthand-alias.ts", filesystemWriteViolations(7)), + `("object-wrapper-shorthand-alias.ts", filesystemWriteViolations(7)), "flags object wrapper property aliases": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; function persist(params: { filePath: string }) { @@ -3931,7 +3730,7 @@ describe("check-database-first-legacy-stores", () => { } const writer = { save: persist }; await writer.save({ filePath: "sessions.json" }); - `("src/runtime/object-wrapper-property-alias.ts", filesystemWriteViolations(7)), + `("object-wrapper-property-alias.ts", filesystemWriteViolations(7)), "flags object wrapper methods copied through property access aliases": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -3944,7 +3743,7 @@ describe("check-database-first-legacy-stores", () => { return proxy.save(filePath); } await persist("sessions.json"); - `("src/runtime/object-wrapper-property-access-alias.ts", filesystemWriteViolations(12)), + `("object-wrapper-property-access-alias.ts", filesystemWriteViolations(12)), "flags object wrapper methods copied through whole-object aliases": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -3957,7 +3756,7 @@ describe("check-database-first-legacy-stores", () => { return proxy.save(filePath); } await persist("sessions.json"); - `("src/runtime/object-wrapper-whole-object-alias.ts", filesystemWriteViolations(12)), + `("object-wrapper-whole-object-alias.ts", filesystemWriteViolations(12)), "flags nested object wrapper methods copied through property access aliases": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -3972,10 +3771,7 @@ describe("check-database-first-legacy-stores", () => { return nested.save(filePath); } await persist("sessions.json"); - `( - "src/runtime/nested-object-wrapper-property-access-alias.ts", - filesystemWriteViolations(14), - ), + `("nested-object-wrapper-property-access-alias.ts", filesystemWriteViolations(14)), "flags destructured object wrapper methods from deep property paths": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -3992,7 +3788,7 @@ describe("check-database-first-legacy-stores", () => { return save(); } await persist("sessions.json"); - `("src/runtime/deep-object-wrapper-destructured-method.ts", filesystemWriteViolations(16)), + `("deep-object-wrapper-destructured-method.ts", filesystemWriteViolations(16)), "flags nested object wrapper methods copied through destructuring aliases": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -4007,7 +3803,7 @@ describe("check-database-first-legacy-stores", () => { return nested.save(); } await persist("sessions.json"); - `("src/runtime/nested-object-wrapper-destructured-alias.ts", filesystemWriteViolations(14)), + `("nested-object-wrapper-destructured-alias.ts", filesystemWriteViolations(14)), "flags nested object wrapper methods copied through identifier-valued properties": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -4020,10 +3816,7 @@ describe("check-database-first-legacy-stores", () => { return writer.nested.save(); } await persist("sessions.json"); - `( - "src/runtime/nested-object-wrapper-identifier-property-alias.ts", - filesystemWriteViolations(12), - ), + `("nested-object-wrapper-identifier-property-alias.ts", filesystemWriteViolations(12)), "clears stale nested object wrapper methods after object literal overwrites": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -4038,7 +3831,7 @@ describe("check-database-first-legacy-stores", () => { return writer.nested.save(filePath); } await persist("sessions.json"); - `("src/runtime/nested-object-wrapper-literal-overwrite.ts", []), + `("nested-object-wrapper-literal-overwrite.ts", []), "flags object wrapper methods copied through object spreads": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -4051,7 +3844,7 @@ describe("check-database-first-legacy-stores", () => { return proxy.save(filePath); } await persist("sessions.json"); - `("src/runtime/object-wrapper-object-spread-alias.ts", filesystemWriteViolations(12)), + `("object-wrapper-object-spread-alias.ts", filesystemWriteViolations(12)), "flags nested object wrapper methods": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -4065,7 +3858,7 @@ describe("check-database-first-legacy-stores", () => { return writer.nested.save(); } await persist("sessions.json"); - `("src/runtime/nested-object-wrapper-method.ts", filesystemWriteViolations(13)), + `("nested-object-wrapper-method.ts", filesystemWriteViolations(13)), "flags top-level nested object wrapper methods": sourceCase` import { promises as fs } from "node:fs"; const writer = { @@ -4076,7 +3869,7 @@ describe("check-database-first-legacy-stores", () => { }, }; await writer.nested.save("sessions.json"); - `("src/runtime/top-level-nested-object-wrapper-method.ts", filesystemWriteViolations(10)), + `("top-level-nested-object-wrapper-method.ts", filesystemWriteViolations(10)), "flags top-level nested object wrapper methods copied through shorthand properties": sourceCase` import { promises as fs } from "node:fs"; @@ -4087,7 +3880,7 @@ describe("check-database-first-legacy-stores", () => { }; const writer = { nested }; await writer.nested.save("sessions.json"); - `("src/runtime/top-level-nested-object-wrapper-shorthand.ts", filesystemWriteViolations(9)), + `("top-level-nested-object-wrapper-shorthand.ts", filesystemWriteViolations(9)), "flags top-level nested object wrapper methods copied through identifier properties": sourceCase` import { promises as fs } from "node:fs"; @@ -4098,10 +3891,7 @@ describe("check-database-first-legacy-stores", () => { }; const writer = { child: nested }; await writer.child.save("sessions.json"); - `( - "src/runtime/top-level-nested-object-wrapper-identifier-property.ts", - filesystemWriteViolations(9), - ), + `("top-level-nested-object-wrapper-identifier-property.ts", filesystemWriteViolations(9)), "flags top-level nested object wrapper methods copied through property access aliases": sourceCase` import { promises as fs } from "node:fs"; @@ -4114,10 +3904,7 @@ describe("check-database-first-legacy-stores", () => { }; const child = writer.nested; await child.save("sessions.json"); - `( - "src/runtime/top-level-nested-object-wrapper-property-access-alias.ts", - filesystemWriteViolations(11), - ), + `("top-level-nested-object-wrapper-property-access-alias.ts", filesystemWriteViolations(11)), "clears top-level object wrapper methods overwritten with undefined": sourceCase` import { promises as fs } from "node:fs"; function save(filePath: string) { @@ -4125,7 +3912,7 @@ describe("check-database-first-legacy-stores", () => { } const writer = { save, save: undefined }; await writer.save?.("sessions.json"); - `("src/runtime/top-level-object-wrapper-undefined-overwrite.ts", []), + `("top-level-object-wrapper-undefined-overwrite.ts", []), "clears top-level nested object wrapper methods overwritten with undefined": sourceCase` import { promises as fs } from "node:fs"; const writer = { @@ -4137,7 +3924,7 @@ describe("check-database-first-legacy-stores", () => { nested: undefined, }; await writer.nested?.save?.("sessions.json"); - `("src/runtime/top-level-nested-object-wrapper-undefined-overwrite.ts", []), + `("top-level-nested-object-wrapper-undefined-overwrite.ts", []), "does not copy object wrapper methods from shadowed objects": sourceCase` import { promises as fs } from "node:fs"; const writer = { @@ -4150,7 +3937,7 @@ describe("check-database-first-legacy-stores", () => { const alias = writer; await alias.save?.("sessions.json"); } - `("src/runtime/shadowed-object-wrapper-method-alias.ts", []), + `("shadowed-object-wrapper-method-alias.ts", []), "flags nested object wrapper methods assigned through object properties": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -4163,10 +3950,7 @@ describe("check-database-first-legacy-stores", () => { return writer.nested.save(); } await persist("sessions.json"); - `( - "src/runtime/nested-object-wrapper-property-object-assignment.ts", - filesystemWriteViolations(12), - ), + `("nested-object-wrapper-property-object-assignment.ts", filesystemWriteViolations(12)), "flags top-level nested object wrapper methods assigned through object properties": sourceCase` import { promises as fs } from "node:fs"; @@ -4178,7 +3962,7 @@ describe("check-database-first-legacy-stores", () => { }; await writer.nested.save("sessions.json"); `( - "src/runtime/top-level-nested-object-wrapper-property-object-assignment.ts", + "top-level-nested-object-wrapper-property-object-assignment.ts", filesystemWriteViolations(9), ), "flags nested object wrapper methods assigned through deep object properties": sourceCase` @@ -4189,10 +3973,7 @@ describe("check-database-first-legacy-stores", () => { return writer.nested.save(); } await persist("sessions.json"); - `( - "src/runtime/nested-object-wrapper-deep-property-assignment.ts", - filesystemWriteViolations(8), - ), + `("nested-object-wrapper-deep-property-assignment.ts", filesystemWriteViolations(8)), "clears stale nested object wrapper methods after property reassignment": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -4207,7 +3988,7 @@ describe("check-database-first-legacy-stores", () => { return writer.nested.save?.(); } await persist("sessions.json"); - `("src/runtime/nested-object-wrapper-property-object-reassignment.ts", []), + `("nested-object-wrapper-property-object-reassignment.ts", []), "clears object wrapper property aliases overwritten with undefined": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -4216,7 +3997,7 @@ describe("check-database-first-legacy-stores", () => { return writer.save?.(); } await persist("sessions.json"); - `("src/runtime/object-wrapper-property-undefined-overwrite.ts", []), + `("object-wrapper-property-undefined-overwrite.ts", []), "flags object wrapper methods assigned after declaration": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; let writer: any = {}; @@ -4226,7 +4007,7 @@ describe("check-database-first-legacy-stores", () => { }, }; await writer.persist({ filePath: "sessions.json" }); - `("src/runtime/assigned-object-wrapper-method.ts", filesystemWriteViolations(9)), + `("assigned-object-wrapper-method.ts", filesystemWriteViolations(9)), "clears object wrapper metadata after object reassignment": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; let writer: any = { @@ -4236,7 +4017,7 @@ describe("check-database-first-legacy-stores", () => { }; writer = customWriter; await writer.persist({ filePath: "sessions.json" }); - `("src/runtime/reassigned-object-wrapper-method.ts", []), + `("reassigned-object-wrapper-method.ts", []), "uses branch-local object wrapper metadata after conditional reassignment": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; const writer: any = { @@ -4248,13 +4029,13 @@ describe("check-database-first-legacy-stores", () => { writer.persist = customPersist; await writer.persist({ filePath: "sessions.json" }); } - `("src/runtime/conditional-object-wrapper-reassignment.ts", []), + `("conditional-object-wrapper-reassignment.ts", []), "flags object wrapper property assignments": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; const writer: any = {}; writer.persist = (params: { filePath: string }) => writeTextAtomic(params.filePath, "{}\\n"); await writer.persist({ filePath: "sessions.json" }); - `("src/runtime/object-wrapper-property-assignment.ts", filesystemWriteViolations(5)), + `("object-wrapper-property-assignment.ts", filesystemWriteViolations(5)), "flags nested object wrapper methods copied through property access spreads": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; const writer: any = { @@ -4266,10 +4047,7 @@ describe("check-database-first-legacy-stores", () => { }; const copy = { ...writer.nested }; await copy.save({ filePath: "sessions.json" }); - `( - "src/runtime/nested-object-wrapper-property-access-spread.ts", - filesystemWriteViolations(11), - ), + `("nested-object-wrapper-property-access-spread.ts", filesystemWriteViolations(11)), "flags extracted object wrapper methods": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; const writer = { @@ -4279,7 +4057,7 @@ describe("check-database-first-legacy-stores", () => { }; const save = writer.persist; await save({ filePath: "sessions.json" }); - `("src/runtime/extracted-object-wrapper-method.ts", filesystemWriteViolations(9)), + `("extracted-object-wrapper-method.ts", filesystemWriteViolations(9)), "flags extracted bracket object wrapper methods": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; const writer = { @@ -4289,7 +4067,7 @@ describe("check-database-first-legacy-stores", () => { }; const save = writer["persist"]; await save({ filePath: "sessions.json" }); - `("src/runtime/extracted-bracket-object-wrapper-method.ts", filesystemWriteViolations(9)), + `("extracted-bracket-object-wrapper-method.ts", filesystemWriteViolations(9)), "flags reassigned aliases from object wrapper methods": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; const writer = { @@ -4300,7 +4078,7 @@ describe("check-database-first-legacy-stores", () => { let save; save = writer.persist; await save({ filePath: "sessions.json" }); - `("src/runtime/reassigned-object-wrapper-method-alias.ts", filesystemWriteViolations(10)), + `("reassigned-object-wrapper-method-alias.ts", filesystemWriteViolations(10)), "flags destructured object wrapper methods": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; const writer = { @@ -4310,7 +4088,7 @@ describe("check-database-first-legacy-stores", () => { }; const { persist } = writer; await persist({ filePath: "sessions.json" }); - `("src/runtime/destructured-object-wrapper-method.ts", filesystemWriteViolations(9)), + `("destructured-object-wrapper-method.ts", filesystemWriteViolations(9)), "flags renamed destructured object wrapper methods": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; const writer = { @@ -4320,7 +4098,7 @@ describe("check-database-first-legacy-stores", () => { }; const { persist: save } = writer; await save({ filePath: "sessions.json" }); - `("src/runtime/renamed-destructured-object-wrapper-method.ts", filesystemWriteViolations(9)), + `("renamed-destructured-object-wrapper-method.ts", filesystemWriteViolations(9)), "flags defaulted destructured object wrapper methods": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -4329,10 +4107,7 @@ describe("check-database-first-legacy-stores", () => { return save(); } await persist("sessions.json"); - `( - "src/runtime/defaulted-destructured-object-wrapper-method.ts", - filesystemWriteViolations(8), - ), + `("defaulted-destructured-object-wrapper-method.ts", filesystemWriteViolations(8)), "does not use destructured wrapper defaults when safe callbacks are present": sourceCase` import { promises as fs } from "node:fs"; const noopParam = async (_path: string) => {}; @@ -4342,7 +4117,7 @@ describe("check-database-first-legacy-stores", () => { return save(filePath); } await persist("sessions.json"); - `("src/runtime/present-safe-callback-destructured-default.ts", []), + `("present-safe-callback-destructured-default.ts", []), "uses destructured wrapper defaults when properties are explicitly undefined": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string) { @@ -4351,7 +4126,7 @@ describe("check-database-first-legacy-stores", () => { return save(); } await persist("sessions.json"); - `("src/runtime/undefined-callback-destructured-default.ts", filesystemWriteViolations(8)), + `("undefined-callback-destructured-default.ts", filesystemWriteViolations(8)), "uses destructured wrapper defaults when properties are aliased undefined": sourceCase` import { promises as fs } from "node:fs"; const absent = undefined; @@ -4361,10 +4136,7 @@ describe("check-database-first-legacy-stores", () => { return save(); } await persist("sessions.json"); - `( - "src/runtime/aliased-undefined-callback-destructured-default.ts", - filesystemWriteViolations(9), - ), + `("aliased-undefined-callback-destructured-default.ts", filesystemWriteViolations(9)), "does not use destructured wrapper defaults after unknown spreads": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string, options: { save?: () => Promise }) { @@ -4373,7 +4145,7 @@ describe("check-database-first-legacy-stores", () => { return save(); } await persist("sessions.json", { save: async () => {} }); - `("src/runtime/object-wrapper-destructuring-default-unknown-spread.ts", []), + `("object-wrapper-destructuring-default-unknown-spread.ts", []), "does not force destructured wrapper defaults from unknown spread objects": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string, defaults: { save?: () => Promise }) { @@ -4382,7 +4154,7 @@ describe("check-database-first-legacy-stores", () => { return save(); } await persist("sessions.json", { save: async () => {} }); - `("src/runtime/object-wrapper-destructuring-default-unknown-spread-object.ts", []), + `("object-wrapper-destructuring-default-unknown-spread-object.ts", []), "does not force closed-over destructured wrapper defaults from unknown spread objects": sourceCase` import { promises as fs } from "node:fs"; @@ -4395,10 +4167,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json", { save: async () => {} }); - `( - "src/runtime/object-wrapper-destructuring-default-unknown-spread-object-closed-over.ts", - [], - ), + `("object-wrapper-destructuring-default-unknown-spread-object-closed-over.ts", []), "keeps branch-only object wrapper methods after exhaustive merge": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string, enabled: boolean) { @@ -4416,7 +4185,7 @@ describe("check-database-first-legacy-stores", () => { return save(); } await persist("sessions.json", true); - `("src/runtime/branch-only-object-wrapper-method.ts", filesystemWriteViolations(17)), + `("branch-only-object-wrapper-method.ts", filesystemWriteViolations(17)), "keeps branch-only property assigned object wrapper methods after exhaustive merge": sourceCase` import { promises as fs } from "node:fs"; @@ -4430,7 +4199,7 @@ describe("check-database-first-legacy-stores", () => { return writer.save?.(); } await persist("sessions.json", true); - `("src/runtime/branch-only-property-object-wrapper-method.ts", filesystemWriteViolations(12)), + `("branch-only-property-object-wrapper-method.ts", filesystemWriteViolations(12)), "keeps prior nested wrapper values when only one branch assigns": sourceCase` import { promises as fs } from "node:fs"; function persist(filePath: string, disabled: boolean) { @@ -4442,26 +4211,26 @@ describe("check-database-first-legacy-stores", () => { return save(filePath); } await persist("sessions.json", false); - `("src/runtime/prior-wrapper-value-branch-assignment.ts", filesystemWriteViolations(11)), + `("prior-wrapper-value-branch-assignment.ts", filesystemWriteViolations(11)), "clears wrapper metadata after non-wrapper reassignment": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; let persist = (params: { filePath: string }) => writeTextAtomic(params.filePath, "{}\\n"); persist = customSink; persist({ filePath: "sessions.json" }); - `("src/runtime/cleared-wrapper-variable.ts", []), + `("cleared-wrapper-variable.ts", []), "keeps wrapper metadata after conditional non-wrapper reassignment": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; let persist = (params: { filePath: string }) => writeTextAtomic(params.filePath, "{}\\n"); if (ready) persist = customSink; persist({ filePath: "sessions.json" }); - `("src/runtime/conditional-cleared-wrapper-variable.ts", filesystemWriteViolations(5)), + `("conditional-cleared-wrapper-variable.ts", filesystemWriteViolations(5)), "clears wrapper metadata after exhaustive non-wrapper reassignments": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; let persist = (params: { filePath: string }) => writeTextAtomic(params.filePath, "{}\\n"); if (ready) persist = customSink; else persist = customSink; persist({ filePath: "sessions.json" }); - `("src/runtime/exhaustive-cleared-wrapper-variable.ts", []), + `("exhaustive-cleared-wrapper-variable.ts", []), "keeps wrapper metadata after try-block non-wrapper reassignment": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; let persist = (params: { filePath: string }) => writeTextAtomic(params.filePath, "{}\\n"); @@ -4470,7 +4239,7 @@ describe("check-database-first-legacy-stores", () => { persist = customSink; } catch {} persist({ filePath: "sessions.json" }); - `("src/runtime/try-cleared-wrapper-variable.ts", filesystemWriteViolations(8)), + `("try-cleared-wrapper-variable.ts", filesystemWriteViolations(8)), // Tracked object paths, destructuring, and shadowing. "flags wrapper option paths read through bracket property access": sourceCase` @@ -4479,13 +4248,13 @@ describe("check-database-first-legacy-stores", () => { return writeTextAtomic(params["filePath"], "{}\\n"); } persist({ filePath: "sessions.json" }); - `("src/runtime/bracket-wrapper-property.ts", filesystemWriteViolations(6)), + `("bracket-wrapper-property.ts", filesystemWriteViolations(6)), "does not treat custom writeFile methods as wrapper filesystem writes": sourceCase` function persist(writer: { writeFile: (path: string, content: string) => void }, params: { filePath: string }) { return writer.writeFile(params.filePath, "{}\\n"); } persist(customWriter, { filePath: "sessions.json" }); - `("src/runtime/custom-writer-method-wrapper.ts", []), + `("custom-writer-method-wrapper.ts", []), "does not use outer wrapper metadata for shadowed wrapper names": sourceCase` import { writeJson } from "../infra/json-files.js"; function persist(options: { store: string }) { @@ -4497,7 +4266,7 @@ describe("check-database-first-legacy-stores", () => { } await persist({ store: "sessions.json" }); } - `("src/runtime/shadowed-wrapper-options.ts", []), + `("shadowed-wrapper-options.ts", []), "does not let loop-scoped wrapper names shadow outer wrappers": sourceCase` import { writeJson } from "../infra/json-files.js"; function persist(options: { store: string }) { @@ -4507,7 +4276,7 @@ describe("check-database-first-legacy-stores", () => { await persist(currentOptions); } await persist({ store: "sessions.json" }); - `("src/runtime/loop-scoped-wrapper-name.ts", filesystemWriteViolations(9)), + `("loop-scoped-wrapper-name.ts", filesystemWriteViolations(9)), "does not use outer wrapper metadata for destructured parameter wrapper names": sourceCase` import { writeJson } from "../infra/json-files.js"; function persist(options: { store: string }) { @@ -4516,7 +4285,7 @@ describe("check-database-first-legacy-stores", () => { function caller({ persist }: { persist: (options: { store: string }) => void }) { persist({ store: "sessions.json" }); } - `("src/runtime/destructured-wrapper-name-parameter.ts", []), + `("destructured-wrapper-name-parameter.ts", []), "does not treat sibling object metadata as the wrapper path property": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; function persist(params: { filePath: string }) { @@ -4524,32 +4293,32 @@ describe("check-database-first-legacy-stores", () => { } const params = { label: "sessions.json", filePath: currentSqlitePath }; await persist(params); - `("src/runtime/current-path-sibling-metadata.ts", []), + `("current-path-sibling-metadata.ts", []), "does not treat custom writeFile methods as direct filesystem writes": sourceCase` const params = { filePath: "sessions.json" }; await customWriter.writeFile(params.filePath, "{}\\n"); - `("src/runtime/custom-writer-method.ts", []), + `("custom-writer-method.ts", []), "flags legacy paths written through injected fs handles": sourceCase` const storePath = "sessions.json"; const params: { deps: { fs: typeof import("node:fs") } } = { deps }; await params.deps.fs.promises.writeFile(storePath, "{}\\n"); - `("src/runtime/injected-fs-write.ts", filesystemWriteViolations(4)), + `("injected-fs-write.ts", filesystemWriteViolations(4)), "does not treat custom fs properties as direct filesystem writes": sourceCase` const storePath = "sessions.json"; await client.fs.promises.writeFile(storePath, "{}\\n"); - `("src/runtime/custom-fs-property.ts", []), + `("custom-fs-property.ts", []), "updates object path metadata after property assignment": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; const params = { filePath: currentSqlitePath }; params.filePath = "sessions.json"; writeTextAtomic(params.filePath, "{}\\n"); - `("src/runtime/assigned-object-path.ts", filesystemWriteViolations(5)), + `("assigned-object-path.ts", filesystemWriteViolations(5)), "updates object path metadata after bracket property assignment": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; const params = { filePath: currentSqlitePath }; params["filePath"] = "sessions.json"; writeTextAtomic(params["filePath"], "{}\\n"); - `("src/runtime/bracket-assigned-object-path.ts", filesystemWriteViolations(5)), + `("bracket-assigned-object-path.ts", filesystemWriteViolations(5)), "updates outer object path metadata after nested property assignment": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; const params = { filePath: currentSqlitePath }; @@ -4557,7 +4326,7 @@ describe("check-database-first-legacy-stores", () => { params.filePath = "sessions.json"; } writeTextAtomic(params.filePath, "{}\\n"); - `("src/runtime/nested-assigned-object-path.ts", filesystemWriteViolations(7)), + `("nested-assigned-object-path.ts", filesystemWriteViolations(7)), "flags nested property assignments forwarded through option objects": sourceCase` import { promises as fs } from "node:fs"; function persist({ paths: { filePath } }: { paths: { filePath: string } }) { @@ -4566,13 +4335,13 @@ describe("check-database-first-legacy-stores", () => { const options = { paths: {} }; options.paths.filePath = "sessions.json"; await persist(options); - `("src/runtime/nested-wrapper-option-property-assignment.ts", filesystemWriteViolations(8)), + `("nested-wrapper-option-property-assignment.ts", filesystemWriteViolations(8)), "flags nested property assignments read directly by filesystem writes": sourceCase` import { promises as fs } from "node:fs"; const options = { paths: {} }; options.paths.filePath = "sessions.json"; await fs.writeFile(options.paths.filePath, "{}\\n"); - `("src/runtime/nested-direct-property-assignment.ts", filesystemWriteViolations(5)), + `("nested-direct-property-assignment.ts", filesystemWriteViolations(5)), "flags nested parent object assignments forwarded through option objects": sourceCase` import { promises as fs } from "node:fs"; function persist({ paths: { filePath } }: { paths: { filePath: string } }) { @@ -4581,7 +4350,7 @@ describe("check-database-first-legacy-stores", () => { const options = { paths: {} }; options.paths = { filePath: "sessions.json" }; await persist(options); - `("src/runtime/nested-wrapper-option-parent-assignment.ts", filesystemWriteViolations(8)), + `("nested-wrapper-option-parent-assignment.ts", filesystemWriteViolations(8)), "flags nested defaults after parent object property assignments": sourceCase` import { promises as fs } from "node:fs"; function persist({ paths: { filePath = "sessions.json" } }: { paths: { filePath?: string } }) { @@ -4590,10 +4359,7 @@ describe("check-database-first-legacy-stores", () => { const options = {}; options.paths = {}; await persist(options); - `( - "src/runtime/nested-wrapper-option-parent-known-empty-assignment.ts", - filesystemWriteViolations(8), - ), + `("nested-wrapper-option-parent-known-empty-assignment.ts", filesystemWriteViolations(8)), "clears nested path metadata after parent object assignments": sourceCase` import { promises as fs } from "node:fs"; function persist({ paths: { filePath } }: { paths: { filePath: string } }) { @@ -4602,7 +4368,7 @@ describe("check-database-first-legacy-stores", () => { const options = { paths: { filePath: "sessions.json" } }; options.paths = { filePath: "current-state.json" }; await persist(options); - `("src/runtime/nested-wrapper-option-parent-current-assignment.ts", []), + `("nested-wrapper-option-parent-current-assignment.ts", []), "keeps maybe missing nested properties after conditional parent object assignments": sourceCase` import { promises as fs } from "node:fs"; @@ -4614,19 +4380,19 @@ describe("check-database-first-legacy-stores", () => { options.paths = {}; } await persist(options); - `("src/runtime/conditional-nested-parent-missing-property.ts", filesystemWriteViolations(10)), + `("conditional-nested-parent-missing-property.ts", filesystemWriteViolations(10)), "clears object path metadata after current-path assignment": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; const params = { filePath: "sessions.json" }; params.filePath = currentSqlitePath; writeTextAtomic(params.filePath, "{}\\n"); - `("src/runtime/reassigned-object-path.ts", []), + `("reassigned-object-path.ts", []), "keeps legacy object metadata after conditional current-path assignment": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; const params = { filePath: "sessions.json" }; if (ready) params.filePath = currentSqlitePath; writeTextAtomic(params.filePath, "{}\\n"); - `("src/runtime/conditional-current-object-path.ts", filesystemWriteViolations(5)), + `("conditional-current-object-path.ts", filesystemWriteViolations(5)), "keeps maybe missing properties after conditional safe property assignments": sourceCase` import { promises as fs } from "node:fs"; const options = {}; @@ -4635,13 +4401,13 @@ describe("check-database-first-legacy-stores", () => { return fs.writeFile(path, "{}\\n"); } await writePath("sessions.json", options); - `("src/runtime/conditional-safe-property-missing-default.ts", filesystemWriteViolations(8)), + `("conditional-safe-property-missing-default.ts", filesystemWriteViolations(8)), "keeps legacy object metadata after loop current-path assignment": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; const params = { filePath: "sessions.json" }; while (ready) params.filePath = currentSqlitePath; writeTextAtomic(params.filePath, "{}\\n"); - `("src/runtime/loop-current-object-path.ts", filesystemWriteViolations(5)), + `("loop-current-object-path.ts", filesystemWriteViolations(5)), "does not let for-loop object bindings clear outer object metadata": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; const params = { filePath: "sessions.json" }; @@ -4649,7 +4415,7 @@ describe("check-database-first-legacy-stores", () => { await use(params); } writeTextAtomic(params.filePath, "{}\\n"); - `("src/runtime/for-loop-object-shadow.ts", filesystemWriteViolations(7)), + `("for-loop-object-shadow.ts", filesystemWriteViolations(7)), "does not leak for-loop legacy object bindings after the loop": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; const params = { filePath: currentSqlitePath }; @@ -4657,7 +4423,7 @@ describe("check-database-first-legacy-stores", () => { await use(params); } writeTextAtomic(params.filePath, "{}\\n"); - `("src/runtime/for-loop-legacy-object-shadow.ts", []), + `("for-loop-legacy-object-shadow.ts", []), "keeps legacy object metadata after conditional current-object assignment": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; let params = { filePath: "sessions.json" }; @@ -4665,7 +4431,7 @@ describe("check-database-first-legacy-stores", () => { params = { filePath: currentSqlitePath }; } writeTextAtomic(params.filePath, "{}\\n"); - `("src/runtime/conditional-current-object.ts", filesystemWriteViolations(7)), + `("conditional-current-object.ts", filesystemWriteViolations(7)), "clears object metadata after exhaustive current-object assignments": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; let params = { filePath: "sessions.json" }; @@ -4675,7 +4441,7 @@ describe("check-database-first-legacy-stores", () => { params = { filePath: currentSqlitePath }; } writeTextAtomic(params.filePath, "{}\\n"); - `("src/runtime/exhaustive-current-object.ts", []), + `("exhaustive-current-object.ts", []), "keeps outer object metadata after optional exhaustive current-object assignments": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; @@ -4688,7 +4454,7 @@ describe("check-database-first-legacy-stores", () => { } } writeTextAtomic(params.filePath, "{}\\n"); - `("src/runtime/optional-exhaustive-current-object.ts", filesystemWriteViolations(11)), + `("optional-exhaustive-current-object.ts", filesystemWriteViolations(11)), "allows branch-local writes after nested exhaustive current-object assignments": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; let params = { filePath: "sessions.json" }; @@ -4700,7 +4466,7 @@ describe("check-database-first-legacy-stores", () => { } writeTextAtomic(params.filePath, "{}\\n"); } - `("src/runtime/branch-local-exhaustive-current-object.ts", []), + `("branch-local-exhaustive-current-object.ts", []), "keeps object metadata when one exhaustive branch keeps a legacy object": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; let params = { filePath: "sessions.json" }; @@ -4710,7 +4476,7 @@ describe("check-database-first-legacy-stores", () => { params = { filePath: "sessions.json" }; } writeTextAtomic(params.filePath, "{}\\n"); - `("src/runtime/exhaustive-mixed-object.ts", filesystemWriteViolations(9)), + `("exhaustive-mixed-object.ts", filesystemWriteViolations(9)), "clears object property metadata after exhaustive current-path assignments": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; const params = { filePath: "sessions.json" }; @@ -4720,7 +4486,7 @@ describe("check-database-first-legacy-stores", () => { params.filePath = currentSqlitePath; } writeTextAtomic(params.filePath, "{}\\n"); - `("src/runtime/exhaustive-current-object-property.ts", []), + `("exhaustive-current-object-property.ts", []), "keeps legacy object metadata after try-block current-object assignment": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; let params = { filePath: "sessions.json" }; @@ -4729,7 +4495,7 @@ describe("check-database-first-legacy-stores", () => { params = { filePath: currentSqlitePath }; } catch {} writeTextAtomic(params.filePath, "{}\\n"); - `("src/runtime/try-current-object.ts", filesystemWriteViolations(8)), + `("try-current-object.ts", filesystemWriteViolations(8)), "clears outer object path metadata after nested current-path assignment": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; const params = { filePath: "sessions.json" }; @@ -4737,7 +4503,7 @@ describe("check-database-first-legacy-stores", () => { params.filePath = currentSqlitePath; } writeTextAtomic(params.filePath, "{}\\n"); - `("src/runtime/nested-reassigned-object-path.ts", []), + `("nested-reassigned-object-path.ts", []), "allows in-branch writes after object property reassignment to the current path": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; const params = { filePath: "sessions.json" }; @@ -4745,37 +4511,37 @@ describe("check-database-first-legacy-stores", () => { params.filePath = currentSqlitePath; writeTextAtomic(params.filePath, "{}\\n"); } - `("src/runtime/branch-reassigned-object-property.ts", []), + `("branch-reassigned-object-property.ts", []), "updates object path metadata after whole-object assignment": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; let params = { filePath: currentSqlitePath }; params = { filePath: "sessions.json" }; writeTextAtomic(params.filePath, "{}\\n"); - `("src/runtime/reassigned-object.ts", filesystemWriteViolations(5)), + `("reassigned-object.ts", filesystemWriteViolations(5)), "clears object path metadata after whole-object current-path assignment": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; let params = { filePath: "sessions.json" }; params = { filePath: currentSqlitePath }; writeTextAtomic(params.filePath, "{}\\n"); - `("src/runtime/reassigned-current-object.ts", []), + `("reassigned-current-object.ts", []), "flags legacy paths destructured from tracked object properties": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; const params = { filePath: "sessions.json" }; const { filePath } = params; writeTextAtomic(filePath, "{}\\n"); - `("src/runtime/destructured-tracked-object-path.ts", filesystemWriteViolations(5)), + `("destructured-tracked-object-path.ts", filesystemWriteViolations(5)), "flags legacy paths from nested destructured object properties": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; const params = { nested: { filePath: "sessions.json" } }; const { nested: { filePath } } = params; writeTextAtomic(filePath, "{}\\n"); - `("src/runtime/nested-destructured-tracked-object-path.ts", filesystemWriteViolations(5)), + `("nested-destructured-tracked-object-path.ts", filesystemWriteViolations(5)), "uses tracked nested current paths before destructured defaults": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; const params = { nested: { filePath: currentSqlitePath } }; const { nested: { filePath = "sessions.json" } } = params; writeTextAtomic(filePath, "{}\\n"); - `("src/runtime/nested-destructured-current-object-path.ts", []), + `("nested-destructured-current-object-path.ts", []), "flags nested defaults after conditional whole-object rewrites omit safe properties": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; @@ -4787,10 +4553,7 @@ describe("check-database-first-legacy-stores", () => { options = { paths: {} }; } await persist(options); - `( - "src/runtime/conditional-whole-object-rewrite-nested-default.ts", - filesystemWriteViolations(10), - ), + `("conditional-whole-object-rewrite-nested-default.ts", filesystemWriteViolations(10)), "flags nested defaults after conditional whole-object rewrites from known aliases": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; @@ -4803,47 +4566,41 @@ describe("check-database-first-legacy-stores", () => { options = source; } await persist(options); - `( - "src/runtime/conditional-whole-object-rewrite-alias-nested-default.ts", - filesystemWriteViolations(11), - ), + `("conditional-whole-object-rewrite-alias-nested-default.ts", filesystemWriteViolations(11)), "flags legacy paths from destructured default values": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; const params = {}; const { filePath = "sessions.json" } = params; writeTextAtomic(filePath, "{}\\n"); - `("src/runtime/destructured-default-object-path.ts", filesystemWriteViolations(5)), + `("destructured-default-object-path.ts", filesystemWriteViolations(5)), "flags legacy paths from inline object destructured default values": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; const { filePath = "sessions.json" } = {}; writeTextAtomic(filePath, "{}\\n"); - `("src/runtime/inline-destructured-default-object-path.ts", filesystemWriteViolations(4)), + `("inline-destructured-default-object-path.ts", filesystemWriteViolations(4)), "flags legacy paths from inline destructured object descendants": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; const { paths } = { paths: { filePath: "sessions.json" } }; writeTextAtomic(paths.filePath, "{}\\n"); - `("src/runtime/inline-destructured-object-descendant-path.ts", filesystemWriteViolations(4)), + `("inline-destructured-object-descendant-path.ts", filesystemWriteViolations(4)), "flags legacy paths destructured from tracked nested properties": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; const options = { paths: { filePath: "sessions.json" } }; const { filePath } = options.paths; writeTextAtomic(filePath, "{}\\n"); - `("src/runtime/destructured-tracked-nested-property-path.ts", filesystemWriteViolations(5)), + `("destructured-tracked-nested-property-path.ts", filesystemWriteViolations(5)), "does not force inline object destructured defaults after unknown spreads": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; declare const defaults: { filePath?: string }; const { filePath = "sessions.json" } = { ...defaults }; writeTextAtomic(filePath, "{}\\n"); - `("src/runtime/inline-destructured-default-unknown-spread.ts", []), + `("inline-destructured-default-unknown-spread.ts", []), "flags destructured defaults from explicitly undefined tracked properties": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; const params = { filePath: undefined }; const { filePath = "sessions.json" } = params; writeTextAtomic(filePath, "{}\\n"); - `( - "src/runtime/destructured-default-explicit-undefined-object-path.ts", - filesystemWriteViolations(5), - ), + `("destructured-default-explicit-undefined-object-path.ts", filesystemWriteViolations(5)), "flags wrapper defaults from destructured missing tracked properties": sourceCase` import { promises as fs } from "node:fs"; const params = {}; @@ -4852,10 +4609,7 @@ describe("check-database-first-legacy-stores", () => { } const { filePath } = params; await persist({ path: filePath }); - `( - "src/runtime/destructured-missing-property-wrapper-default.ts", - filesystemWriteViolations(8), - ), + `("destructured-missing-property-wrapper-default.ts", filesystemWriteViolations(8)), "flags wrapper defaults from inline destructured missing properties": sourceCase` import { promises as fs } from "node:fs"; function persist({ path = "sessions.json" }: { path?: string }) { @@ -4863,26 +4617,20 @@ describe("check-database-first-legacy-stores", () => { } const { filePath } = {}; await persist({ path: filePath }); - `( - "src/runtime/inline-destructured-missing-property-wrapper-default.ts", - filesystemWriteViolations(7), - ), + `("inline-destructured-missing-property-wrapper-default.ts", filesystemWriteViolations(7)), "flags destructured defaults from aliased undefined tracked properties": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; const absent = undefined; const params = { filePath: absent }; const { filePath = "sessions.json" } = params; writeTextAtomic(filePath, "{}\\n"); - `( - "src/runtime/destructured-default-aliased-undefined-object-path.ts", - filesystemWriteViolations(6), - ), + `("destructured-default-aliased-undefined-object-path.ts", filesystemWriteViolations(6)), "uses tracked object properties before destructured defaults": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; const params = { filePath: currentSqlitePath }; const { filePath = "sessions.json" } = params; writeTextAtomic(filePath, "{}\\n"); - `("src/runtime/destructured-default-current-object-path.ts", []), + `("destructured-default-current-object-path.ts", []), "flags wrapper shorthand options destructured from tracked object properties": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; function persist(params: { filePath: string }) { @@ -4891,14 +4639,14 @@ describe("check-database-first-legacy-stores", () => { const params = { filePath: "sessions.json" }; const { filePath } = params; persist({ filePath }); - `("src/runtime/destructured-shorthand-wrapper-path.ts", filesystemWriteViolations(8)), + `("destructured-shorthand-wrapper-path.ts", filesystemWriteViolations(8)), "does not treat unrelated property names as destructured wrapper paths": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; function persist({ filePath }: { filePath: string }) { return writeTextAtomic(current.filePath, "{}\\n"); } await persist({ filePath: "sessions.json" }); - `("src/runtime/unrelated-property-name-wrapper.ts", []), + `("unrelated-property-name-wrapper.ts", []), "flags wrapper option paths forwarded through object aliases": sourceCase` import { writeJson } from "../infra/json-files.js"; function persist(options: { store: string }) { @@ -4907,7 +4655,7 @@ describe("check-database-first-legacy-stores", () => { const params = { store: "sessions.json" }; const forwarded = params; await persist(forwarded); - `("src/runtime/forwarded-wrapper-options.ts", filesystemWriteViolations(8)), + `("forwarded-wrapper-options.ts", filesystemWriteViolations(8)), "flags wrapper option paths forwarded through destructured object aliases": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; function persist(params: { paths: { filePath: string } }) { @@ -4916,10 +4664,7 @@ describe("check-database-first-legacy-stores", () => { const options = { paths: { filePath: "sessions.json" } }; const { paths } = options; await persist({ paths }); - `( - "src/runtime/wrapper-option-path-destructured-object-alias.ts", - filesystemWriteViolations(8), - ), + `("wrapper-option-path-destructured-object-alias.ts", filesystemWriteViolations(8)), "does not treat sibling nested properties as wrapper option paths": sourceCase` import { promises as fs } from "node:fs"; function persist(params: { paths: { filePath: string; legacyPath: string } }) { @@ -4932,7 +4677,7 @@ describe("check-database-first-legacy-stores", () => { }, }; await persist(options); - `("src/runtime/wrapper-option-path-nested-sibling-property.ts", []), + `("wrapper-option-path-nested-sibling-property.ts", []), "clears nested wrapper option paths after object literal spread overwrites": sourceCase` import { promises as fs } from "node:fs"; function persist({ paths: { filePath = currentSqlitePath } = {} }) { @@ -4943,7 +4688,7 @@ describe("check-database-first-legacy-stores", () => { ...{ paths: {} }, }; await persist(params); - `("src/runtime/wrapper-option-path-object-spread-overwrite.ts", []), + `("wrapper-option-path-object-spread-overwrite.ts", []), "clears known nested wrapper option paths after parent rewrites": sourceCase` import { promises as fs } from "node:fs"; declare function loadNested(): { filePath?: string }; @@ -4953,7 +4698,7 @@ describe("check-database-first-legacy-stores", () => { const options = { paths: { nested: {} } }; options.paths = { nested: loadNested() }; await persist(options); - `("src/runtime/wrapper-option-path-parent-rewrite-unknown-nested.ts", []), + `("wrapper-option-path-parent-rewrite-unknown-nested.ts", []), "flags wrapper option paths forwarded through object spreads": sourceCase` import { writeJson } from "../infra/json-files.js"; function persist(options: { store: string }) { @@ -4962,7 +4707,7 @@ describe("check-database-first-legacy-stores", () => { const params = { store: "sessions.json" }; const forwarded = { ...params }; await persist(forwarded); - `("src/runtime/spread-wrapper-options.ts", filesystemWriteViolations(8)), + `("spread-wrapper-options.ts", filesystemWriteViolations(8)), "flags wrapper option paths passed through inline object spreads": sourceCase` import { writeJson } from "../infra/json-files.js"; function persist(options: { store: string }) { @@ -4971,7 +4716,7 @@ describe("check-database-first-legacy-stores", () => { const params = { store: "sessions.json" }; await persist({ ...params }); await persist({ store: currentSqlitePath, ...params }); - `("src/runtime/inline-spread-wrapper-options.ts", filesystemWriteViolations(7, 8)), + `("inline-spread-wrapper-options.ts", filesystemWriteViolations(7, 8)), "flags wrapper option paths passed through inline object literal spreads": sourceCase` import { writeJson } from "../infra/json-files.js"; function persist(options: { store: string }) { @@ -4980,10 +4725,7 @@ describe("check-database-first-legacy-stores", () => { await persist({ ...{ store: "sessions.json" } }); const params = { ...{ store: "sessions.json" } }; await persist(params); - `( - "src/runtime/inline-object-literal-spread-wrapper-options.ts", - filesystemWriteViolations(6, 8), - ), + `("inline-object-literal-spread-wrapper-options.ts", filesystemWriteViolations(6, 8)), "allows inline object spreads when a later property overrides the legacy path": sourceCase` import { writeJson } from "../infra/json-files.js"; function persist(options: { store: string }) { @@ -4991,7 +4733,7 @@ describe("check-database-first-legacy-stores", () => { } const params = { store: "sessions.json" }; await persist({ ...params, store: currentSqlitePath }); - `("src/runtime/inline-spread-wrapper-options.ts", []), + `("inline-spread-wrapper-options.ts", []), "allows inline object literal spreads when a later property overrides the legacy path": sourceCase` import { writeJson } from "../infra/json-files.js"; @@ -5000,7 +4742,7 @@ describe("check-database-first-legacy-stores", () => { } await persist({ ...{ store: "sessions.json" }, store: currentSqlitePath }); await persist({ store: "sessions.json", ...{ store: currentSqlitePath } }); - `("src/runtime/inline-object-literal-spread-current-wrapper-options.ts", []), + `("inline-object-literal-spread-current-wrapper-options.ts", []), "allows inline object spreads when a later spread overrides the legacy path": sourceCase` import { writeJson } from "../infra/json-files.js"; function persist(options: { store: string }) { @@ -5008,7 +4750,7 @@ describe("check-database-first-legacy-stores", () => { } const currentOptions = { store: currentSqlitePath }; await persist({ store: "sessions.json", ...currentOptions }); - `("src/runtime/inline-current-spread-wrapper-options.ts", []), + `("inline-current-spread-wrapper-options.ts", []), "allows nested inline object spreads when a later spread overrides the legacy path": sourceCase` import { writeJson } from "../infra/json-files.js"; @@ -5019,7 +4761,7 @@ describe("check-database-first-legacy-stores", () => { paths: { filePath: "sessions.json" }, ...{ paths: { filePath: currentSqlitePath } }, }); - `("src/runtime/nested-inline-current-spread-wrapper-options.ts", []), + `("nested-inline-current-spread-wrapper-options.ts", []), "does not copy wrapper option metadata from shadowed source objects": sourceCase` import { writeJson } from "../infra/json-files.js"; function persist(options: { store: string }) { @@ -5031,14 +4773,14 @@ describe("check-database-first-legacy-stores", () => { const forwarded = params; await persist(forwarded); } - `("src/runtime/shadowed-forwarded-wrapper-options.ts", []), + `("shadowed-forwarded-wrapper-options.ts", []), "does not treat shadowed fs alias names as wrapper filesystem writes": sourceCase` import { writeFile } from "node:fs/promises"; function persist(writeFile: (path: string, value: string) => void, params: { filePath: string }) { return writeFile(params.filePath, "{}\\n"); } await persist(customSink, { filePath: "sessions.json" }); - `("src/runtime/shadowed-fs-alias-wrapper.ts", []), + `("shadowed-fs-alias-wrapper.ts", []), "does not treat block-shadowed fs alias names as wrapper filesystem writes": sourceCase` import { writeFile } from "node:fs/promises"; { @@ -5048,7 +4790,7 @@ describe("check-database-first-legacy-stores", () => { } persist({ filePath: "sessions.json" }); } - `("src/runtime/block-shadowed-fs-alias-wrapper.ts", []), + `("block-shadowed-fs-alias-wrapper.ts", []), "does not treat destructures from shadowed fs module names as wrapper filesystem writes": sourceCase` import fs from "node:fs/promises"; @@ -5058,7 +4800,7 @@ describe("check-database-first-legacy-stores", () => { return writeFile(params.filePath, "{}\\n"); } persist({ filePath: "sessions.json" }); - `("src/runtime/shadowed-fs-module-wrapper.ts", []), + `("shadowed-fs-module-wrapper.ts", []), "does not treat shadowed wrapper parameter objects as argument paths": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; function persist(params: { filePath: string }) { @@ -5068,7 +4810,7 @@ describe("check-database-first-legacy-stores", () => { } } await persist({ filePath: "sessions.json" }); - `("src/runtime/shadowed-wrapper-parameter-object.ts", []), + `("shadowed-wrapper-parameter-object.ts", []), "does not keep object metadata for uninitialized local shadows": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; const params = { filePath: "sessions.json" }; @@ -5076,7 +4818,7 @@ describe("check-database-first-legacy-stores", () => { let params; writeTextAtomic(params.filePath, "{}\\n"); } - `("src/runtime/uninitialized-object-shadow.ts", []), + `("uninitialized-object-shadow.ts", []), "keeps catch binding shadows scoped to the catch block": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; const params = { filePath: "sessions.json" }; @@ -5086,7 +4828,7 @@ describe("check-database-first-legacy-stores", () => { writeTextAtomic(params.filePath, "{}\\n"); } writeTextAtomic(params.filePath, "{}\\n"); - `("src/runtime/catch-object-shadow.ts", filesystemWriteViolations(9)), + `("catch-object-shadow.ts", filesystemWriteViolations(9)), "keeps closed-over catch binding shadows scoped to the catch block": sourceCase` import fs from "node:fs/promises"; function persist(filePath: string) { @@ -5101,7 +4843,7 @@ describe("check-database-first-legacy-stores", () => { return inner(); } await persist("sessions.json"); - `("src/runtime/nested-wrapper-catch-shadow.ts", filesystemWriteViolations(14)), + `("nested-wrapper-catch-shadow.ts", filesystemWriteViolations(14)), "keeps wrapper catch binding shadows scoped to the catch block": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; function persist(params: { filePath: string }) { @@ -5113,7 +4855,7 @@ describe("check-database-first-legacy-stores", () => { writeTextAtomic(params.filePath, "{}\\n"); } persist({ filePath: "sessions.json" }); - `("src/runtime/wrapper-catch-object-shadow.ts", filesystemWriteViolations(11)), + `("wrapper-catch-object-shadow.ts", filesystemWriteViolations(11)), "does not keep object metadata for destructured local shadows": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; const params = { filePath: "sessions.json" }; @@ -5121,7 +4863,7 @@ describe("check-database-first-legacy-stores", () => { const { params } = source; writeTextAtomic(params.filePath, "{}\\n"); } - `("src/runtime/destructured-object-shadow.ts", []), + `("destructured-object-shadow.ts", []), "does not let unrelated nested fs aliases mark custom writes": sourceCase` import fs from "node:fs/promises"; import { writeFile } from "./custom-writer.js"; @@ -5130,7 +4872,7 @@ describe("check-database-first-legacy-stores", () => { const { writeFile } = fs; return writeFile(currentSqlitePath, "{}\\n"); } - `("src/runtime/custom-writer-shadow.ts", []), + `("custom-writer-shadow.ts", []), "does not use caller block fs aliases for outer wrapper bodies": sourceCase` import fs from "node:fs/promises"; import { writeFile } from "./custom-writer.js"; @@ -5141,7 +4883,7 @@ describe("check-database-first-legacy-stores", () => { const { writeFile } = fs; await persist({ filePath: "sessions.json" }); } - `("src/runtime/caller-block-alias-wrapper.ts", []), + `("caller-block-alias-wrapper.ts", []), "does not leak block-scoped fs aliases across wrapper body scopes": sourceCase` import fs from "node:fs/promises"; function persist(params: { filePath: string }) { @@ -5152,7 +4894,7 @@ describe("check-database-first-legacy-stores", () => { return writeFile(params.filePath, "{}\\n"); } await persist({ filePath: "sessions.json" }); - `("src/runtime/block-scoped-fs-alias-wrapper.ts", []), + `("block-scoped-fs-alias-wrapper.ts", []), "ignores shadowed destructured wrapper option names": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; function persist({ filePath }: { filePath: string }) { @@ -5162,7 +4904,7 @@ describe("check-database-first-legacy-stores", () => { } } await persist({ filePath: "sessions.json" }); - `("src/runtime/shadowed-destructured-wrapper-options.ts", []), + `("shadowed-destructured-wrapper-options.ts", []), "keeps earlier destructured wrapper option uses before later shadowing": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; function persist({ filePath }: { filePath: string }) { @@ -5172,7 +4914,7 @@ describe("check-database-first-legacy-stores", () => { } } await persist({ filePath: "sessions.json" }); - `("src/runtime/late-shadowed-destructured-wrapper-options.ts", filesystemWriteViolations(9)), + `("late-shadowed-destructured-wrapper-options.ts", filesystemWriteViolations(9)), "does not leak legacy path variable names across lexical scopes": sourceCase` import { promises as fs } from "node:fs"; { @@ -5181,7 +4923,7 @@ describe("check-database-first-legacy-stores", () => { export async function save(storePath: string) { await fs.writeFile(storePath, "{}\\n", "utf8"); } - `("src/runtime/current-store-writer.ts", []), + `("current-store-writer.ts", []), "lets inner bindings shadow outer legacy path variables": sourceCase` import { promises as fs } from "node:fs"; const storePath = "sessions.json"; @@ -5189,7 +4931,7 @@ describe("check-database-first-legacy-stores", () => { const storePath = currentSqlitePath; await fs.writeFile(storePath, "{}\\n", "utf8"); } - `("src/runtime/current-store-writer.ts", []), + `("current-store-writer.ts", []), "lets inner object properties shadow outer legacy path properties": sourceCase` import { writeTextAtomic } from "../infra/json-files.js"; const params = { filePath: "sessions.json" }; @@ -5197,12 +4939,12 @@ describe("check-database-first-legacy-stores", () => { const params = { filePath: currentSqlitePath }; await writeTextAtomic(params.filePath, "{}\\n"); } - `("src/runtime/current-store-writer.ts", []), + `("current-store-writer.ts", []), "ignores legacy filenames in write payloads": sourceCase` import { promises as fs } from "node:fs"; await fs.writeFile(reportPath, "sessions.json\\n", "utf8"); await fs.appendFile(currentLogPath, "cron/runs/job.jsonl\\n", "utf8"); - `("src/runtime/report-writer.ts", []), + `("report-writer.ts", []), // Current-debt and migration-owner policy. "flags runtime writes to sidecar SQLite and JSONL stores": sourceCase` @@ -5311,7 +5053,7 @@ describe("check-database-first-legacy-stores", () => { "flags legacy transcript bridge markers in runtime source": sourceCase` export const transcriptLocator = "sqlite-transcript://session"; export const dynamicLocator = \`sqlite-transcript://\${sessionId}\`; - `("src/runtime/transcript-bridge.ts", [ + `("transcript-bridge.ts", [ { kind: "legacy transcript bridge marker", line: 2 }, { kind: "legacy transcript bridge marker", line: 3 }, ]), diff --git a/ui/src/pages/plugins/plugins-page.routing.test.ts b/ui/src/pages/plugins/plugins-page.routing.test.ts index 88eb09cd8ba2..cf9d8399f3ef 100644 --- a/ui/src/pages/plugins/plugins-page.routing.test.ts +++ b/ui/src/pages/plugins/plugins-page.routing.test.ts @@ -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 }>( @@ -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"); diff --git a/ui/src/pages/plugins/plugins-page.test-support.ts b/ui/src/pages/plugins/plugins-page.test-support.ts index d0b384f5ebf9..21350a2dfbed 100644 --- a/ui/src/pages/plugins/plugins-page.test-support.ts +++ b/ui/src/pages/plugins/plugins-page.test-support.ts @@ -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, diff --git a/ui/src/pages/plugins/plugins-page.test.ts b/ui/src/pages/plugins/plugins-page.test.ts index 61f732ad227e..00ecab3b2981 100644 --- a/ui/src/pages/plugins/plugins-page.test.ts +++ b/ui/src/pages/plugins/plugins-page.test.ts @@ -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("#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(".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"); diff --git a/ui/src/pages/plugins/plugins-page.ts b/ui/src/pages/plugins/plugins-page.ts index 860fe5fbef06..4b9897b13405 100644 --- a/ui/src/pages/plugins/plugins-page.ts +++ b/ui/src/pages/plugins/plugins-page.ts @@ -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( + rowKey: string, + mutate: (client: GatewayBrowserClient) => Promise, + onSuccess: ( + result: Result, + client: GatewayBrowserClient, + isCurrent: () => boolean, + ) => Promise, + onError: (error: unknown) => void = (error) => { + this.setMessage(rowKey, { kind: "error", text: errorMessage(error) }); + }, + ): Promise { 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 { + 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 { + 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 { + 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: { diff --git a/ui/src/pages/plugins/view.ts b/ui/src/pages/plugins/view.ts index 2070909fc10c..1d5adece6203 100644 --- a/ui/src/pages/plugins/view.ts +++ b/ui/src/pages/plugins/view.ts @@ -281,15 +281,16 @@ function renderArtTile( name: string, iconUrl?: string, onIconError?: () => void, + className = "plugins-tile", ): TemplateResult { const art = pluginArtPath(slug); if (art) { - return html` + return html` `; } if (iconUrl) { - return html` + return html` @@ -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):

${plugin.name} ${plugin.version - ? html`v${plugin.version}` + ? includePackageName + ? html`v${plugin.version}` + : html`v${plugin.version}` : nothing}

@@ -604,13 +611,14 @@ function renderInstalledRow(plugin: PluginCatalogItem, props: PluginsViewProps): ${renderMetaLine([ plugin.origin ? originLabel(plugin.origin) : nothing, - plugin.packageName + includePackageName && plugin.packageName ? html`${plugin.packageName}` : nothing, ])}
- ${rowStateStatus(plugin)} ${renderCatalogActions(plugin, props, busy, key)} + ${plugin.installed ? rowStateStatus(plugin) : nothing} + ${renderCatalogActions(plugin, props, busy, key)}
${plugin.error ? html`