From daee265f32de85b800c14a72d97b7657673db49c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 12:40:22 -0700 Subject: [PATCH 01/28] fix(openai): own queued realtime audio buffer snapshots --- .../realtime-audio-buffer-ownership.test.ts | 123 ++++++++++++++++++ .../openai/realtime-quicksilver-bridge.ts | 6 +- extensions/openai/realtime-voice-provider.ts | 6 +- 3 files changed, 131 insertions(+), 4 deletions(-) create mode 100644 extensions/openai/realtime-audio-buffer-ownership.test.ts diff --git a/extensions/openai/realtime-audio-buffer-ownership.test.ts b/extensions/openai/realtime-audio-buffer-ownership.test.ts new file mode 100644 index 000000000000..0f496a264222 --- /dev/null +++ b/extensions/openai/realtime-audio-buffer-ownership.test.ts @@ -0,0 +1,123 @@ +import { once } from "node:events"; +import type { RealtimeVoiceBridge } from "openclaw/plugin-sdk/realtime-voice"; +import { describe, expect, it, vi } from "vitest"; +import WebSocket, { WebSocketServer } from "ws"; +import { OpenAIQuicksilverVoiceBridge } from "./realtime-quicksilver-bridge.js"; +import { buildOpenAIRealtimeVoiceProvider } from "./realtime-voice-provider.js"; + +type RealtimeProviderKind = "native" | "gpt-live"; + +async function withRealtimeProvider( + kind: RealtimeProviderKind, + prepareAudio: (bridge: RealtimeVoiceBridge) => void, +): Promise>> { + const audioEventType = kind === "native" ? "input_audio_buffer.append" : "input_audio.append"; + const server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + await once(server, "listening"); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("expected an available local realtime WebSocket address"); + } + const received: Array> = []; + server.once("connection", (socket) => { + socket.on("message", (payload) => { + const event = JSON.parse(payload.toString()) as Record; + received.push(event); + if (event.type === "session.update") { + socket.send( + JSON.stringify( + kind === "native" + ? { type: "session.updated" } + : { + type: "session.started", + session: { id: "fixture-live", expires_at: Math.floor(Date.now() / 1000) + 60 }, + }, + ), + ); + } + }); + }); + + const endpoint = `http://127.0.0.1:${address.port}`; + const bridge = + kind === "native" + ? buildOpenAIRealtimeVoiceProvider().createBridge({ + providerConfig: { + apiKey: "fixture-local", // pragma: allowlist secret + azureEndpoint: endpoint, + azureDeployment: "fixture-realtime", + }, + onAudio: vi.fn(), + onClearAudio: vi.fn(), + }) + : new OpenAIQuicksilverVoiceBridge({ + providerConfig: {}, + model: "gpt-live-1-codex", + audioFormat: { encoding: "pcm16", sampleRateHz: 24000, channels: 1 }, + resolveAuth: async () => ({ type: "api-key", token: "fixture-local" }), + webSocketFactory: (_url, options) => new WebSocket(endpoint, options), + onAudio: vi.fn(), + onClearAudio: vi.fn(), + }); + + try { + prepareAudio(bridge); + await bridge.connect(); + await vi.waitFor(() => { + expect(received.some((event) => event.type === audioEventType)).toBe(true); + }); + return received; + } finally { + bridge.close(); + for (const client of server.clients) { + client.terminate(); + } + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } +} + +describe("OpenAI realtime queued audio buffer ownership", () => { + it.each(["native", "gpt-live"])( + "%s preserves each reusable producer frame until the real WebSocket is ready", + async (kind) => { + const audioEventType = kind === "native" ? "input_audio_buffer.append" : "input_audio.append"; + const received = await withRealtimeProvider(kind, (bridge) => { + const producerAllocation = Buffer.alloc(2 * 1024 * 1024, 0x7f); + const producerView = producerAllocation.subarray(0, 1); + bridge.sendAudio(producerView); + producerAllocation[0] = 0x41; + bridge.sendAudio(producerView); + producerAllocation[0] = 0; + }); + + expect(received.filter((event) => event.type === audioEventType)).toEqual([ + { type: audioEventType, audio: "fw==" }, + { type: audioEventType, audio: "QQ==" }, + ]); + }, + ); + + it.each(["native", "gpt-live"])( + "%s rejects oversized producer frames before allocating a queued copy", + async (kind) => { + const audioEventType = kind === "native" ? "input_audio_buffer.append" : "input_audio.append"; + const received = await withRealtimeProvider(kind, (bridge) => { + const oversized = Buffer.alloc(1024 * 1024 + 1); + const copyBuffer = vi.spyOn(Buffer, "from"); + try { + bridge.sendAudio(oversized); + expect(copyBuffer).not.toHaveBeenCalled(); + } finally { + copyBuffer.mockRestore(); + } + bridge.sendAudio(Buffer.from([0x7f])); + }); + + expect(received.filter((event) => event.type === audioEventType)).toEqual([ + { type: audioEventType, audio: "fw==" }, + ]); + }, + ); +}); diff --git a/extensions/openai/realtime-quicksilver-bridge.ts b/extensions/openai/realtime-quicksilver-bridge.ts index de8583cfcca1..6d43551f56df 100644 --- a/extensions/openai/realtime-quicksilver-bridge.ts +++ b/extensions/openai/realtime-quicksilver-bridge.ts @@ -568,8 +568,10 @@ export class OpenAIQuicksilverVoiceBridge implements RealtimeVoiceBridge { ) { return; } - this.pendingAudio.push(audio); - this.pendingAudioBytes += audio.byteLength; + // Capture transports can recycle caller-owned views before the provider becomes ready. + const queuedAudio = Buffer.from(audio); + this.pendingAudio.push(queuedAudio); + this.pendingAudioBytes += queuedAudio.byteLength; } private resetTerminalState(): void { diff --git a/extensions/openai/realtime-voice-provider.ts b/extensions/openai/realtime-voice-provider.ts index 9e8e448c65ab..907783437518 100644 --- a/extensions/openai/realtime-voice-provider.ts +++ b/extensions/openai/realtime-voice-provider.ts @@ -1684,8 +1684,10 @@ class OpenAIRealtimeVoiceBridge implements RealtimeVoiceBridge { ) { return; } - this.pendingAudio.push(audio); - this.pendingAudioBytes += audio.byteLength; + // Capture transports can recycle caller-owned views before the provider becomes ready. + const queuedAudio = Buffer.from(audio); + this.pendingAudio.push(queuedAudio); + this.pendingAudioBytes += queuedAudio.byteLength; } private clearPendingAudio(): void { From c5dd9c3095bdf01bb1d0950de88de1f6ef04a32d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 11:48:07 -0700 Subject: [PATCH 02/28] test(secrets): dedupe runtime state fixtures (#117563) --- src/secrets/provider-integrations.test.ts | 608 +++++++---------- src/secrets/runtime-state.test.ts | 768 +++++----------------- 2 files changed, 390 insertions(+), 986 deletions(-) diff --git a/src/secrets/provider-integrations.test.ts b/src/secrets/provider-integrations.test.ts index 458020d3faf0..4d81aa736fe3 100644 --- a/src/secrets/provider-integrations.test.ts +++ b/src/secrets/provider-integrations.test.ts @@ -35,6 +35,22 @@ function writeSecureFile(file: string, contents: string): void { fs.chmodSync(file, 0o600); } +function writePluginManifest(rootDir: string, manifest: Record): void { + fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8"); + fs.writeFileSync( + path.join(rootDir, "openclaw.plugin.json"), + JSON.stringify({ + ...manifest, + configSchema: { + type: "object", + additionalProperties: false, + properties: {}, + }, + }), + "utf8", + ); +} + function createCandidate( rootDir: string, idHint: string, @@ -48,6 +64,16 @@ function createCandidate( }; } +function loadTestRegistry( + rootDir: string, + idHint: string, + origin: PluginOrigin = "global", +): PluginManifestRegistry { + return loadPluginManifestRegistry({ + candidates: [createCandidate(rootDir, idHint, origin)], + }); +} + function pluginIntegrationProviderConfig(pluginId: string, integrationId: string) { return { source: "exec" as const, @@ -67,45 +93,33 @@ afterEach(() => { describe("secret provider integration presets", () => { it("materializes plugin manifest exec providers without provider-specific core code", () => { const rootDir = makeTempDir(); - fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8"); makeSecureDir(path.join(rootDir, "bin")); writeSecureFile(path.join(rootDir, "bin", "resolve.mjs"), "process.stdin.resume();\n"); - fs.writeFileSync( - path.join(rootDir, "openclaw.plugin.json"), - JSON.stringify({ - id: "acme-secrets", - name: "Acme Secrets", - secretProviderIntegrations: { - acme: { - providerAlias: "acme", - displayName: "Acme Vault", - description: "Acme exec resolver", - source: "exec", - command: "${node}", - args: ["./bin/resolve.mjs", "--profile", "work"], - timeoutMs: 3000, - noOutputTimeoutMs: 3000, - maxOutputBytes: 4096, - passEnv: ["HOME"], - env: { - ACME_PROFILE: "work", - }, - jsonOnly: false, + writePluginManifest(rootDir, { + id: "acme-secrets", + name: "Acme Secrets", + secretProviderIntegrations: { + acme: { + providerAlias: "acme", + displayName: "Acme Vault", + description: "Acme exec resolver", + source: "exec", + command: "${node}", + args: ["./bin/resolve.mjs", "--profile", "work"], + timeoutMs: 3000, + noOutputTimeoutMs: 3000, + maxOutputBytes: 4096, + passEnv: ["HOME"], + env: { + ACME_PROFILE: "work", }, + jsonOnly: false, }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }), - "utf8", - ); - - const registry = loadPluginManifestRegistry({ - candidates: [createCandidate(rootDir, "acme-secrets")], + }, }); + const registry = loadTestRegistry(rootDir, "acme-secrets"); + expect(registry.diagnostics).toEqual([]); expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([ { @@ -144,36 +158,24 @@ describe("secret provider integration presets", () => { it("normalizes manifest exec provider options to SecretRef provider schema limits", () => { const rootDir = makeTempDir(); - fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8"); writeSecureFile(path.join(rootDir, "resolve.mjs"), "process.stdin.resume();\n"); - fs.writeFileSync( - path.join(rootDir, "openclaw.plugin.json"), - JSON.stringify({ - id: "bounded-secrets", - secretProviderIntegrations: { - bounded: { - source: "exec", - command: "${node}", - args: ["./resolve.mjs", "ok", "x".repeat(1025)], - timeoutMs: 120001, - noOutputTimeoutMs: 1.5, - maxOutputBytes: 20 * 1024 * 1024 + 1, - passEnv: ["GOOD_ENV", "bad-env"], - }, + writePluginManifest(rootDir, { + id: "bounded-secrets", + secretProviderIntegrations: { + bounded: { + source: "exec", + command: "${node}", + args: ["./resolve.mjs", "ok", "x".repeat(1025)], + timeoutMs: 120001, + noOutputTimeoutMs: 1.5, + maxOutputBytes: 20 * 1024 * 1024 + 1, + passEnv: ["GOOD_ENV", "bad-env"], }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }), - "utf8", - ); - - const registry = loadPluginManifestRegistry({ - candidates: [createCandidate(rootDir, "bounded-secrets")], + }, }); + const registry = loadTestRegistry(rootDir, "bounded-secrets"); + expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([ { id: "bounded", @@ -203,31 +205,19 @@ describe("secret provider integration presets", () => { it("skips presets whose provider alias cannot be used as a SecretRef provider", () => { const rootDir = makeTempDir(); - fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8"); - fs.writeFileSync( - path.join(rootDir, "openclaw.plugin.json"), - JSON.stringify({ - id: "bad-secrets", - secretProviderIntegrations: { - bad: { - providerAlias: "../bad", - source: "exec", - command: "${node}", - }, + writePluginManifest(rootDir, { + id: "bad-secrets", + secretProviderIntegrations: { + bad: { + providerAlias: "../bad", + source: "exec", + command: "${node}", }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }), - "utf8", - ); - - const registry = loadPluginManifestRegistry({ - candidates: [createCandidate(rootDir, "bad-secrets")], + }, }); + const registry = loadTestRegistry(rootDir, "bad-secrets"); + expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([]); }); @@ -236,54 +226,34 @@ describe("secret provider integration presets", () => { const longPluginRootDir = makeTempDir(); const longPluginId = `plugin-${"x".repeat(129)}`; const longIntegrationId = `integration-${"x".repeat(129)}`; - fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8"); fs.writeFileSync(path.join(rootDir, "resolve.mjs"), "process.stdin.resume();\n", "utf8"); - fs.writeFileSync(path.join(longPluginRootDir, "index.ts"), "export default {};\n", "utf8"); fs.writeFileSync( path.join(longPluginRootDir, "resolve.mjs"), "process.stdin.resume();\n", "utf8", ); - fs.writeFileSync( - path.join(rootDir, "openclaw.plugin.json"), - JSON.stringify({ - id: "long-integration-secrets", - secretProviderIntegrations: { - [longIntegrationId]: { - providerAlias: "short-alias", - source: "exec", - command: "${node}", - args: ["./resolve.mjs"], - }, + writePluginManifest(rootDir, { + id: "long-integration-secrets", + secretProviderIntegrations: { + [longIntegrationId]: { + providerAlias: "short-alias", + source: "exec", + command: "${node}", + args: ["./resolve.mjs"], }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, + }, + }); + writePluginManifest(longPluginRootDir, { + id: longPluginId, + secretProviderIntegrations: { + vault: { + providerAlias: "short-plugin-alias", + source: "exec", + command: "${node}", + args: ["./resolve.mjs"], }, - }), - "utf8", - ); - fs.writeFileSync( - path.join(longPluginRootDir, "openclaw.plugin.json"), - JSON.stringify({ - id: longPluginId, - secretProviderIntegrations: { - vault: { - providerAlias: "short-plugin-alias", - source: "exec", - command: "${node}", - args: ["./resolve.mjs"], - }, - }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }), - "utf8", - ); + }, + }); const registry = loadPluginManifestRegistry({ candidates: [ @@ -299,61 +269,39 @@ describe("secret provider integration presets", () => { "skips non-node manifest preset commands for %s plugin roots", (origin) => { const rootDir = makeTempDir(); - fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8"); fs.mkdirSync(path.join(rootDir, "bin")); - fs.writeFileSync( - path.join(rootDir, "openclaw.plugin.json"), - JSON.stringify({ - id: `${origin}-secrets`, - ...(origin === "bundled" ? { enabledByDefault: true } : {}), - secretProviderIntegrations: { - vault: { - providerAlias: "vault", - source: "exec", - command: "./bin/vault-resolver", - }, + writePluginManifest(rootDir, { + id: `${origin}-secrets`, + ...(origin === "bundled" ? { enabledByDefault: true } : {}), + secretProviderIntegrations: { + vault: { + providerAlias: "vault", + source: "exec", + command: "./bin/vault-resolver", }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }), - "utf8", - ); - - const registry = loadPluginManifestRegistry({ - candidates: [createCandidate(rootDir, `${origin}-secrets`, origin)], + }, }); + const registry = loadTestRegistry(rootDir, `${origin}-secrets`, origin); + expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([]); }, ); it("skips presets from disabled installed plugins", () => { const rootDir = makeTempDir(); - fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8"); writeSecureFile(path.join(rootDir, "resolve.mjs"), "process.stdin.resume();\n"); - fs.writeFileSync( - path.join(rootDir, "openclaw.plugin.json"), - JSON.stringify({ - id: "disabled-secrets", - secretProviderIntegrations: { - vault: { - providerAlias: "vault", - source: "exec", - command: "${node}", - args: ["./resolve.mjs"], - }, + writePluginManifest(rootDir, { + id: "disabled-secrets", + secretProviderIntegrations: { + vault: { + providerAlias: "vault", + source: "exec", + command: "${node}", + args: ["./resolve.mjs"], }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }), - "utf8", - ); + }, + }); const registry = loadPluginManifestRegistry({ candidates: [createCandidate(rootDir, "disabled-secrets", "global")], @@ -386,28 +334,18 @@ describe("secret provider integration presets", () => { it("applies plugin id aliases when filtering disabled presets", () => { const rootDir = makeTempDir(); - fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8"); writeSecureFile(path.join(rootDir, "resolve.mjs"), "process.stdin.resume();\n"); - fs.writeFileSync( - path.join(rootDir, "openclaw.plugin.json"), - JSON.stringify({ - id: "openai", - secretProviderIntegrations: { - vault: { - providerAlias: "vault", - source: "exec", - command: "${node}", - args: ["./resolve.mjs"], - }, + writePluginManifest(rootDir, { + id: "openai", + secretProviderIntegrations: { + vault: { + providerAlias: "vault", + source: "exec", + command: "${node}", + args: ["./resolve.mjs"], }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }), - "utf8", - ); + }, + }); const config = { plugins: { entries: { @@ -429,32 +367,20 @@ describe("secret provider integration presets", () => { it("exposes bundled presets enabled by platform default", () => { const rootDir = makeTempDir(); - fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8"); writeSecureFile(path.join(rootDir, "resolve.mjs"), "process.stdin.resume();\n"); - fs.writeFileSync( - path.join(rootDir, "openclaw.plugin.json"), - JSON.stringify({ - id: "platform-secrets", - enabledByDefaultOnPlatforms: [process.platform], - secretProviderIntegrations: { - vault: { - providerAlias: "vault", - source: "exec", - command: "${node}", - args: ["./resolve.mjs"], - }, + writePluginManifest(rootDir, { + id: "platform-secrets", + enabledByDefaultOnPlatforms: [process.platform], + secretProviderIntegrations: { + vault: { + providerAlias: "vault", + source: "exec", + command: "${node}", + args: ["./resolve.mjs"], }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }), - "utf8", - ); - const registry = loadPluginManifestRegistry({ - candidates: [createCandidate(rootDir, "platform-secrets", "bundled")], + }, }); + const registry = loadTestRegistry(rootDir, "platform-secrets", "bundled"); expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([ { @@ -473,33 +399,21 @@ describe("secret provider integration presets", () => { const rootDir = makeTempDir(); const linkParent = makeTempDir(); const linkRoot = path.join(linkParent, "plugin-link"); - fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8"); writeSecureFile(path.join(rootDir, "resolve.mjs"), "process.stdin.resume();\n"); - fs.writeFileSync( - path.join(rootDir, "openclaw.plugin.json"), - JSON.stringify({ - id: "linked-secrets", - secretProviderIntegrations: { - vault: { - providerAlias: "vault", - source: "exec", - command: "${node}", - args: ["./resolve.mjs"], - }, + writePluginManifest(rootDir, { + id: "linked-secrets", + secretProviderIntegrations: { + vault: { + providerAlias: "vault", + source: "exec", + command: "${node}", + args: ["./resolve.mjs"], }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }), - "utf8", - ); + }, + }); fs.symlinkSync(rootDir, linkRoot); - const registry = loadPluginManifestRegistry({ - candidates: [createCandidate(linkRoot, "linked-secrets", "global")], - }); + const registry = loadTestRegistry(linkRoot, "linked-secrets", "global"); expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([ { @@ -517,32 +431,20 @@ describe("secret provider integration presets", () => { "skips secret provider presets from %s plugin roots", (origin) => { const rootDir = makeTempDir(); - fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8"); - fs.writeFileSync( - path.join(rootDir, "openclaw.plugin.json"), - JSON.stringify({ - id: `${origin}-secrets`, - secretProviderIntegrations: { - vault: { - providerAlias: "vault", - source: "exec", - command: "${node}", - args: ["./resolve.mjs"], - }, + writePluginManifest(rootDir, { + id: `${origin}-secrets`, + secretProviderIntegrations: { + vault: { + providerAlias: "vault", + source: "exec", + command: "${node}", + args: ["./resolve.mjs"], }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }), - "utf8", - ); - - const registry = loadPluginManifestRegistry({ - candidates: [createCandidate(rootDir, `${origin}-secrets`, origin)], + }, }); + const registry = loadTestRegistry(rootDir, `${origin}-secrets`, origin); + expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([]); }, ); @@ -550,7 +452,6 @@ describe("secret provider integration presets", () => { it("resolves a node-based plugin preset with plugin trusted dirs", async () => { const rootDir = makeTempDir(); const resolverPath = path.join(rootDir, "bin", "resolve.mjs"); - fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8"); makeSecureDir(path.dirname(resolverPath)); writeSecureFile( resolverPath, @@ -565,32 +466,21 @@ describe("secret provider integration presets", () => { "});", ].join("\n"), ); - fs.writeFileSync( - path.join(rootDir, "openclaw.plugin.json"), - JSON.stringify({ - id: "vault-secrets", - secretProviderIntegrations: { - vault: { - providerAlias: "vault", - source: "exec", - command: "${node}", - args: ["./bin/resolve.mjs"], - allowInsecurePath: true, - }, + writePluginManifest(rootDir, { + id: "vault-secrets", + secretProviderIntegrations: { + vault: { + providerAlias: "vault", + source: "exec", + command: "${node}", + args: ["./bin/resolve.mjs"], + allowInsecurePath: true, }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }), - "utf8", - ); + }, + }); await withSecureTestNodeExecPath(async () => { - const registry = loadPluginManifestRegistry({ - candidates: [createCandidate(rootDir, "vault-secrets", "global")], - }); + const registry = loadTestRegistry(rootDir, "vault-secrets", "global"); const [preset] = listSecretProviderIntegrationPresets({ manifestRegistry: registry }); if (!preset) { throw new Error("Expected vault preset"); @@ -624,28 +514,18 @@ describe("secret provider integration presets", () => { it("fails closed when a plugin-managed provider is disabled", async () => { const rootDir = makeTempDir(); const resolverPath = path.join(rootDir, "resolve.mjs"); - fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8"); fs.writeFileSync(resolverPath, "process.stdin.resume();\n", "utf8"); - fs.writeFileSync( - path.join(rootDir, "openclaw.plugin.json"), - JSON.stringify({ - id: "revoked-secrets", - secretProviderIntegrations: { - vault: { - providerAlias: "vault", - source: "exec", - command: "${node}", - args: ["./resolve.mjs"], - }, + writePluginManifest(rootDir, { + id: "revoked-secrets", + secretProviderIntegrations: { + vault: { + providerAlias: "vault", + source: "exec", + command: "${node}", + args: ["./resolve.mjs"], }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }), - "utf8", - ); + }, + }); const config = { plugins: { entries: { @@ -715,31 +595,19 @@ describe("secret provider integration presets", () => { it("skips node presets without a plugin-root relative entrypoint arg", () => { const rootDir = makeTempDir(); - fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8"); - fs.writeFileSync( - path.join(rootDir, "openclaw.plugin.json"), - JSON.stringify({ - id: "bad-trust-secrets", - secretProviderIntegrations: { - bad: { - source: "exec", - command: "${node}", - args: ["--import", "./bin/hook.mjs", "./bin/resolve.mjs"], - }, + writePluginManifest(rootDir, { + id: "bad-trust-secrets", + secretProviderIntegrations: { + bad: { + source: "exec", + command: "${node}", + args: ["--import", "./bin/hook.mjs", "./bin/resolve.mjs"], }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }), - "utf8", - ); - - const registry = loadPluginManifestRegistry({ - candidates: [createCandidate(rootDir, "bad-trust-secrets")], + }, }); + const registry = loadTestRegistry(rootDir, "bad-trust-secrets"); + expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([]); }); @@ -748,38 +616,26 @@ describe("secret provider integration presets", () => { () => { const rootDir = makeTempDir(); const outsideDir = makeTempDir(); - fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8"); fs.mkdirSync(path.join(rootDir, "bin")); fs.writeFileSync(path.join(outsideDir, "resolve.mjs"), "process.stdin.resume();\n"); fs.symlinkSync( path.join(outsideDir, "resolve.mjs"), path.join(rootDir, "bin", "resolve.mjs"), ); - fs.writeFileSync( - path.join(rootDir, "openclaw.plugin.json"), - JSON.stringify({ - id: "symlink-secrets", - secretProviderIntegrations: { - vault: { - providerAlias: "vault", - source: "exec", - command: "${node}", - args: ["./bin/resolve.mjs"], - }, + writePluginManifest(rootDir, { + id: "symlink-secrets", + secretProviderIntegrations: { + vault: { + providerAlias: "vault", + source: "exec", + command: "${node}", + args: ["./bin/resolve.mjs"], }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }), - "utf8", - ); - - const registry = loadPluginManifestRegistry({ - candidates: [createCandidate(rootDir, "symlink-secrets")], + }, }); + const registry = loadTestRegistry(rootDir, "symlink-secrets"); + expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([]); }, ); @@ -790,34 +646,22 @@ describe("secret provider integration presets", () => { const linkedRoot = path.join(parentDir, "linked-plugin"); makeSecureDir(realRoot); fs.symlinkSync(realRoot, linkedRoot, "dir"); - fs.writeFileSync(path.join(realRoot, "index.ts"), "export default {};\n", "utf8"); makeSecureDir(path.join(realRoot, "bin")); writeSecureFile(path.join(realRoot, "bin", "resolve.mjs"), "process.stdin.resume();\n"); - fs.writeFileSync( - path.join(realRoot, "openclaw.plugin.json"), - JSON.stringify({ - id: "linked-root-secrets", - secretProviderIntegrations: { - vault: { - providerAlias: "vault", - source: "exec", - command: "${node}", - args: ["./bin/resolve.mjs"], - }, + writePluginManifest(realRoot, { + id: "linked-root-secrets", + secretProviderIntegrations: { + vault: { + providerAlias: "vault", + source: "exec", + command: "${node}", + args: ["./bin/resolve.mjs"], }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }), - "utf8", - ); - - const registry = loadPluginManifestRegistry({ - candidates: [createCandidate(linkedRoot, "linked-root-secrets")], + }, }); + const registry = loadTestRegistry(linkedRoot, "linked-root-secrets"); + expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([ { id: "vault", @@ -845,36 +689,24 @@ describe("secret provider integration presets", () => { () => { const rootDir = makeTempDir(); const binDir = path.join(rootDir, "bin"); - fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8"); fs.mkdirSync(binDir); fs.writeFileSync(path.join(binDir, "resolve.mjs"), "process.stdin.resume();\n"); fs.chmodSync(binDir, 0o777); try { - fs.writeFileSync( - path.join(rootDir, "openclaw.plugin.json"), - JSON.stringify({ - id: "writable-parent-secrets", - secretProviderIntegrations: { - vault: { - providerAlias: "vault", - source: "exec", - command: "${node}", - args: ["./bin/resolve.mjs"], - }, + writePluginManifest(rootDir, { + id: "writable-parent-secrets", + secretProviderIntegrations: { + vault: { + providerAlias: "vault", + source: "exec", + command: "${node}", + args: ["./bin/resolve.mjs"], }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }), - "utf8", - ); - - const registry = loadPluginManifestRegistry({ - candidates: [createCandidate(rootDir, "writable-parent-secrets")], + }, }); + const registry = loadTestRegistry(rootDir, "writable-parent-secrets"); + expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([]); } finally { fs.chmodSync(binDir, 0o700); diff --git a/src/secrets/runtime-state.test.ts b/src/secrets/runtime-state.test.ts index d5e56e103c49..54df4aec2e6a 100644 --- a/src/secrets/runtime-state.test.ts +++ b/src/secrets/runtime-state.test.ts @@ -73,6 +73,61 @@ function preparedGatewayAuthSnapshot( }); } +type ActivateOptions = Omit< + Parameters[0], + "snapshot" | "refreshContext" | "refreshHandler" +>; + +function activateSnapshot( + snapshot: PreparedSecretsRuntimeSnapshot, + options: ActivateOptions = {}, +): void { + activateSecretsRuntimeSnapshotState({ + snapshot, + refreshContext: null, + refreshHandler: null, + ...options, + }); +} + +type ActivateIfCurrentOptions = Omit< + Parameters[0], + "snapshot" | "expectedRevision" | "refreshContext" | "refreshHandler" +> & { expectedRevision?: number }; + +function activateSnapshotIfCurrent( + snapshot: PreparedSecretsRuntimeSnapshot, + options: ActivateIfCurrentOptions = {}, +): boolean { + return activateSecretsRuntimeSnapshotStateIfCurrent({ + snapshot, + expectedRevision: options.expectedRevision ?? getActiveSecretsRuntimeSnapshotRevision(), + refreshContext: null, + refreshHandler: null, + ...options, + }); +} + +type RestoreIfCurrentOptions = Omit< + Parameters[0], + "snapshot" | "ownedSnapshot" | "expectedRevision" | "refreshContext" | "refreshHandler" +> & { expectedRevision?: number }; + +function restoreSnapshotIfCurrent( + snapshot: PreparedSecretsRuntimeSnapshot, + ownedSnapshot: PreparedSecretsRuntimeSnapshot, + options: RestoreIfCurrentOptions = {}, +): boolean { + return restoreSecretsRuntimeSnapshotStateIfCurrent({ + snapshot, + ownedSnapshot, + expectedRevision: options.expectedRevision ?? getActiveSecretsRuntimeSnapshotRevision(), + refreshContext: null, + refreshHandler: null, + ...options, + }); +} + describe("secrets runtime state", () => { let envSnapshot: ReturnType; const autoCleanupTempDirs = useAutoCleanupTempDirTracker(afterEach); @@ -121,11 +176,7 @@ describe("secrets runtime state", () => { authStores: [], }); - activateSecretsRuntimeSnapshotState({ - snapshot, - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot(snapshot); const configSnapshot = getActiveSecretsRuntimeConfigSnapshot(); const fullSnapshot = getActiveSecretsRuntimeSnapshot(); @@ -147,11 +198,7 @@ describe("secrets runtime state", () => { config: { gateway: { auth: { mode: "token", token: "resolved-debug-token" } } }, authStores: [], }); - activateSecretsRuntimeSnapshotState({ - snapshot, - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot(snapshot); const rawSourceConfig = { gateway: { port: 19_030 } } satisfies OpenClawConfig; const secretsSourceConfig = { ...rawSourceConfig, @@ -159,13 +206,12 @@ describe("secrets runtime state", () => { } satisfies OpenClawConfig; expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: { ...snapshot, sourceConfig: secretsSourceConfig }, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - runtimeSourceConfig: rawSourceConfig, - }), + activateSnapshotIfCurrent( + { ...snapshot, sourceConfig: secretsSourceConfig }, + { + runtimeSourceConfig: rawSourceConfig, + }, + ), ).toBe(true); expect(getRuntimeConfigSourceSnapshot()).toEqual(rawSourceConfig); @@ -176,15 +222,13 @@ describe("secrets runtime state", () => { it("rejects a source-only secrets write after runtime config ownership changes", () => { const initialConfig = { gateway: { port: 19_030 } } satisfies OpenClawConfig; const concurrentConfig = { gateway: { port: 19_031 } } satisfies OpenClawConfig; - activateSecretsRuntimeSnapshotState({ - snapshot: preparedSnapshot({ + activateSnapshot( + preparedSnapshot({ sourceConfig: initialConfig, config: initialConfig, authStores: [], }), - refreshContext: null, - refreshHandler: null, - }); + ); const staleMetadata = getRuntimeConfigSnapshotMetadata(); if (!staleMetadata) { throw new Error("expected runtime config metadata"); @@ -213,16 +257,14 @@ describe("secrets runtime state", () => { }, }, } satisfies OpenClawConfig; - activateSecretsRuntimeSnapshotState({ - snapshot: preparedSnapshot({ + activateSnapshot( + preparedSnapshot({ sourceConfig: initialSource, config: runtimeConfig, authStores: [], }), - refreshContext: null, - refreshHandler: null, - runtimeSourceConfig: initialSource, - }); + { runtimeSourceConfig: initialSource }, + ); const runtimeMetadata = getRuntimeConfigSnapshotMetadata(); if (!runtimeMetadata) { throw new Error("expected runtime config metadata"); @@ -240,11 +282,8 @@ describe("secrets runtime state", () => { const descendant = structuredClone(active); descendant.config.models!.providers!.openai!.baseUrl = "https://refreshed.example.invalid/v1"; expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: descendant, + activateSnapshotIfCurrent(descendant, { expectedRevision: committedRevision, - refreshContext: null, - refreshHandler: null, runtimeSourceConfig: nextSource, preserveActivationLineage: true, }), @@ -303,11 +342,7 @@ describe("secrets runtime state", () => { agentDir, ); - activateSecretsRuntimeSnapshotState({ - snapshot, - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot(snapshot); expect( getRuntimeAuthProfileStoreSnapshot(agentDir)?.usageStats?.["openai:default"], @@ -323,11 +358,7 @@ describe("secrets runtime state", () => { "openai:default": { type: "api_key", provider: "openai", key }, }, }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot("sk-old", 19_001), - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot(snapshot("sk-old", 19_001)); const previous = getActiveSecretsRuntimeSnapshot(); const previousRevision = getActiveSecretsRuntimeSnapshotRevision(); const candidate = snapshot("sk-old", 19_002); @@ -337,23 +368,10 @@ describe("secrets runtime state", () => { key: "sk-rejected-candidate", }; expect(previous).not.toBeNull(); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: previousRevision, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate, { expectedRevision: previousRevision })).toBe(true); const candidateRevision = getActiveSecretsRuntimeSnapshotRevision(); expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous!, - expectedRevision: candidateRevision, - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), + restoreSnapshotIfCurrent(previous!, candidate, { expectedRevision: candidateRevision }), ).toBe(true); expect(getActiveSecretsRuntimeSnapshot()?.config.gateway?.port).toBe(19_001); expect(getRuntimeAuthProfileStoreSnapshot(agentDir)?.profiles["openai:default"]).toMatchObject({ @@ -389,11 +407,7 @@ describe("secrets runtime state", () => { lastGood: { provider: "provider-a:default" }, usageStats: { "provider-b:default": { lastUsed: 1 } }, }; - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot(predecessorProfiles, 19_001, predecessorState), - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot(snapshot(predecessorProfiles, 19_001, predecessorState)); const previous = getActiveSecretsRuntimeSnapshot()!; const previousRevision = getActiveSecretsRuntimeSnapshotRevision(); const activationProfiles = { @@ -426,14 +440,7 @@ describe("secrets runtime state", () => { 19_002, preparedState, ); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: previousRevision, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate, { expectedRevision: previousRevision })).toBe(true); const liveAfterActivation = getRuntimeAuthProfileStoreSnapshot(agentDir)!; liveAfterActivation.order = { provider: ["provider-q:login", "provider-b:default"] }; liveAfterActivation.lastGood = { provider: "provider-q:login" }; @@ -442,15 +449,7 @@ describe("secrets runtime state", () => { }; setRuntimeAuthProfileStoreSnapshot(liveAfterActivation, agentDir); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); const restored = getRuntimeAuthProfileStoreSnapshot(agentDir)?.profiles; expect(restored?.["provider-a:default"]).toMatchObject({ key: "a-old" }); expect(restored?.["provider-b:default"]).toMatchObject({ key: "b-external" }); @@ -475,11 +474,7 @@ describe("secrets runtime state", () => { "openai:default": { type: "api_key", provider: "openai", key }, }, }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot("sk-old", 19_001), - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot(snapshot("sk-old", 19_001)); const previous = getActiveSecretsRuntimeSnapshot()!; const previousRevision = getActiveSecretsRuntimeSnapshotRevision(); setRuntimeAuthProfileStoreSnapshot( @@ -492,23 +487,8 @@ describe("secrets runtime state", () => { provider: "anthropic", key: "sk-rejected-candidate", }; - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: previousRevision, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate, { expectedRevision: previousRevision })).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); expect(getActiveSecretsRuntimeSnapshot()?.config.gateway?.port).toBe(19_001); expect(getRuntimeAuthProfileStoreSnapshot(agentDir)?.profiles["openai:default"]).toMatchObject({ key: finalKey, @@ -577,21 +557,10 @@ describe("secrets runtime state", () => { }, runtimeExternalProfileIds: aExternal ? ["provider-a:default"] : undefined, }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot(baselineAKey, "b-old", 19_001), - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot(snapshot(baselineAKey, "b-old", 19_001)); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot(candidateAKey, "b-old", 19_002); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); setRuntimeAuthProfileStoreSnapshot( snapshot(currentAKey, "b-external", 19_002, currentAExternal).authStores[0]!.store, agentDir, @@ -602,15 +571,7 @@ describe("secrets runtime state", () => { profileIds: ["provider-b:default"], }); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); const restored = getRuntimeAuthProfileStoreSnapshot(agentDir)?.profiles; if (expectedAKey === null) { expect(restored?.["provider-a:default"]).toBeUndefined(); @@ -639,21 +600,10 @@ describe("secrets runtime state", () => { }, runtimeLocalProfileIds, }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot("sk-old", 19_001), - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot(snapshot("sk-old", 19_001)); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot("sk-candidate", 19_002); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); noteRuntimeAuthProfileStorePersistedMutation(undefined, { credentialsChanged: true, stateChanged: false, @@ -661,15 +611,7 @@ describe("secrets runtime state", () => { }); setRuntimeAuthProfileStoreSnapshot(candidate.authStores[0]!.store, agentDir); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); expect(getRuntimeAuthProfileStoreSnapshot(agentDir)?.profiles["openai:default"]).toMatchObject({ key: expected, }); @@ -697,25 +639,12 @@ describe("secrets runtime state", () => { provider: "openai", key: "sk-external-y", }; - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot( - { "openai:x": profileX, "openai:y": profileY }, - ["openai:x", "openai:y"], - 19_001, - ), - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot( + snapshot({ "openai:x": profileX, "openai:y": profileY }, ["openai:x", "openai:y"], 19_001), + ); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot({ "openai:y": profileY }, ["openai:y"], 19_002); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); noteRuntimeAuthProfileStorePersistedMutation(undefined, { credentialsChanged: true, stateChanged: false, @@ -723,15 +652,7 @@ describe("secrets runtime state", () => { }); setRuntimeAuthProfileStoreSnapshot(candidate.authStores[0]!.store, agentDir); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); expect(getRuntimeAuthProfileStoreSnapshot(agentDir)).toBeUndefined(); }); @@ -753,21 +674,10 @@ describe("secrets runtime state", () => { runtimeExternalProfileIds: owner === "external" ? ["openai:x"] : [], runtimeLocalProfileIds: owner === "local" ? ["openai:x"] : [], }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot("sk-external-old", "external", 19_001), - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot(snapshot("sk-external-old", "external", 19_001)); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot("sk-candidate", candidateOwner, 19_002); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); if (mutateCandidateOwner) { noteRuntimeAuthProfileStorePersistedMutation( candidateOwner === "local" ? agentDir : undefined, @@ -784,15 +694,7 @@ describe("secrets runtime state", () => { agentDir, ); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); if (mutateCandidateOwner) { expect(getRuntimeAuthProfileStoreSnapshot(agentDir)).toBeUndefined(); } else { @@ -827,25 +729,16 @@ describe("secrets runtime state", () => { runtimeExternalProfileIds: owner === "external" ? ["openai:x"] : [], runtimeLocalProfileIds: ["anthropic:stable", ...(owner === "local" ? ["openai:x"] : [])], }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot( + activateSnapshot( + snapshot( baselineOwner === "absent" ? null : "sk-baseline", baselineOwner === "local" ? "local" : "inherited", 19_001, ), - refreshContext: null, - refreshHandler: null, - }); + ); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot("sk-external", "external", 19_002); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); noteRuntimeAuthProfileStorePersistedMutation( baselineOwner === "inherited" ? undefined : agentDir, { @@ -856,15 +749,7 @@ describe("secrets runtime state", () => { ); setRuntimeAuthProfileStoreSnapshot(candidate.authStores[0]!.store, agentDir); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); expect(getRuntimeAuthProfileStoreSnapshot(agentDir)).toBeUndefined(); }, ); @@ -898,35 +783,16 @@ describe("secrets runtime state", () => { baselineOwner === "local" ? "local" : "inherited", 19_001, ); - activateSecretsRuntimeSnapshotState({ - snapshot: baseline, - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot(baseline); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot("sk-external", "external", 19_002); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); setRuntimeAuthProfileStoreSnapshot( snapshot("sk-external-refresh", "external", 19_002).authStores[0]!.store, agentDir, ); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); const restored = getRuntimeAuthProfileStoreSnapshot(agentDir); if (baselineOwner === "absent") { expect(restored?.profiles["openai:x"]).toBeUndefined(); @@ -953,35 +819,16 @@ describe("secrets runtime state", () => { runtimeExternalProfileIds: owner === "external" ? ["openai:x"] : [], runtimeLocalProfileIds: owner === "local" ? ["openai:x"] : [], }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot("sk-old", candidateOwner, 19_001), - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot(snapshot("sk-old", candidateOwner, 19_001)); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot("sk-candidate", candidateOwner, 19_002); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); setRuntimeAuthProfileStoreSnapshot( snapshot("sk-candidate", currentOwner, 19_002).authStores[0]!.store, agentDir, ); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); const restored = getRuntimeAuthProfileStoreSnapshot(agentDir); expect(restored?.profiles["openai:x"]).toMatchObject({ key: "sk-candidate" }); if (currentOwner === "local") { @@ -1003,31 +850,12 @@ describe("secrets runtime state", () => { runtimeExternalProfileIds: [], runtimeExternalProfileIdsAuthoritative: authoritative ? true : undefined, }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot(true, 19_001), - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot(snapshot(true, 19_001)); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot(false, 19_002); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); expect(getRuntimeAuthProfileStoreSnapshot(agentDir)).toMatchObject({ runtimeExternalProfileIds: [], runtimeExternalProfileIdsAuthoritative: true, @@ -1046,35 +874,16 @@ describe("secrets runtime state", () => { runtimeExternalProfileIds: [], runtimeExternalProfileIdsAuthoritative: authoritative ? true : undefined, }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot("sk-old", false, 19_001), - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot(snapshot("sk-old", false, 19_001)); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot("sk-old", true, 19_002); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); setRuntimeAuthProfileStoreSnapshot( snapshot("sk-current", true, 19_002).authStores[0]!.store, agentDir, ); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); const restored = getRuntimeAuthProfileStoreSnapshot(agentDir); expect(restored?.profiles["openai:x"]).toMatchObject({ key: "sk-current" }); expect(restored?.runtimeExternalProfileIdsAuthoritative).toBeUndefined(); @@ -1093,21 +902,10 @@ describe("secrets runtime state", () => { }, runtimeExternalProfileIds: ["openai:external"], }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot("sk-old", 19_001), - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot(snapshot("sk-old", 19_001)); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot("sk-candidate", 19_002); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); noteRuntimeAuthProfileStorePersistedMutation(undefined, { credentialsChanged: true, stateChanged: false, @@ -1115,15 +913,7 @@ describe("secrets runtime state", () => { }); setRuntimeAuthProfileStoreSnapshot(snapshot(current, 19_002).authStores[0]!.store, agentDir); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); expect(getRuntimeAuthProfileStoreSnapshot(agentDir)?.profiles["openai:external"]).toMatchObject( { key: expected, @@ -1146,21 +936,10 @@ describe("secrets runtime state", () => { }, runtimeLocalProfileIds: ["anthropic:stable", "openai:default"], }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot("sk-old", 19_001), - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot(snapshot("sk-old", 19_001)); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot("sk-candidate", 19_002); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); for (let index = 0; index < 300; index += 1) { noteRuntimeAuthProfileStorePersistedMutation(agentDir, { credentialsChanged: true, @@ -1169,15 +948,7 @@ describe("secrets runtime state", () => { }); } - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); expect(getRuntimeAuthProfileStoreSnapshot(agentDir)).toBeUndefined(); }); @@ -1206,21 +977,10 @@ describe("secrets runtime state", () => { snapshot("sk-old", previousRef, 19_001).authStores[0]!.store, agentDir, ); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot("sk-old", previousRef, 19_001), - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot(snapshot("sk-old", previousRef, 19_001)); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot("sk-candidate", candidateRef, 19_002); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); setRuntimeAuthProfileStoreSnapshot( snapshot("sk-descendant", candidateRef, 19_002).authStores[0]!.store, agentDir, @@ -1236,15 +996,7 @@ describe("secrets runtime state", () => { ); } - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); expect(getRuntimeAuthProfileStoreSnapshot(agentDir)).toBeUndefined(); expect( ensureAuthProfileStoreWithoutExternalProfiles(agentDir).profiles["openai:default"], @@ -1356,21 +1108,10 @@ describe("secrets runtime state", () => { ] : [], }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot(true, 19_001), - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot(snapshot(true, 19_001)); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot(false, 19_002); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); if (mutationOwner !== "none") { noteRuntimeAuthProfileStorePersistedMutation( mutationOwner === "custom" ? agentDir : undefined, @@ -1382,15 +1123,7 @@ describe("secrets runtime state", () => { ); } - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); if (expectMissing) { expect(getRuntimeAuthProfileStoreSnapshot(agentDir)).toBeUndefined(); } else { @@ -1425,21 +1158,10 @@ describe("secrets runtime state", () => { ] : [], }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot(true, 19_001), - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot(snapshot(true, 19_001)); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot(false, 19_002); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); noteRuntimeAuthProfileStorePersistedMutation(undefined, { credentialsChanged: true, profileSetChanged: true, @@ -1447,15 +1169,7 @@ describe("secrets runtime state", () => { profileIds: ["openai:new-main"], }); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); expect(getRuntimeAuthProfileStoreSnapshot(agentDir)).toBeUndefined(); }); @@ -1468,32 +1182,13 @@ describe("secrets runtime state", () => { "openai:default": { type: "api_key", provider: "openai", key }, }, }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot("sk-old", 19_001), - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot(snapshot("sk-old", 19_001)); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot("sk-candidate", 19_002); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); clearRuntimeAuthProfileStoreSnapshots(); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); expect(getRuntimeAuthProfileStoreSnapshot(agentDir)).toBeUndefined(); }); @@ -1515,40 +1210,20 @@ describe("secrets runtime state", () => { "openai:default": { type: "api_key", provider: "openai", key, keyRef }, }, }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot("sk-old", previousRef, 19_001), - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot(snapshot("sk-old", previousRef, 19_001)); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot("sk-candidate", candidateRef, 19_002); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); const candidateRevision = getActiveSecretsRuntimeSnapshotRevision(); expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: snapshot("sk-refreshed", candidateRef, 19_002), + activateSnapshotIfCurrent(snapshot("sk-refreshed", candidateRef, 19_002), { expectedRevision: candidateRevision, - refreshContext: null, - refreshHandler: null, preserveActivationLineage: true, }), ).toBe(true); expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: candidateRevision, - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), + restoreSnapshotIfCurrent(previous, candidate, { expectedRevision: candidateRevision }), ).toBe(true); expect(getRuntimeAuthProfileStoreSnapshot(agentDir)?.profiles["openai:default"]).toMatchObject({ key: changedRef ? "sk-old" : "sk-refreshed", @@ -1565,11 +1240,7 @@ describe("secrets runtime state", () => { "openai:default": { type: "api_key", provider: "openai", key }, }, }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot("sk-old", 19_011), - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot(snapshot("sk-old", 19_011)); setRuntimeAuthProfileStoreSnapshot( { version: 1, @@ -1583,24 +1254,9 @@ describe("secrets runtime state", () => { const previousRevision = getActiveSecretsRuntimeSnapshotRevision(); const candidate = snapshot("sk-live", 19_012); expect(previous).not.toBeNull(); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: previousRevision, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate, { expectedRevision: previousRevision })).toBe(true); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous!, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous!, candidate)).toBe(true); expect(getActiveSecretsRuntimeSnapshot()?.config.gateway?.port).toBe(19_011); expect(getRuntimeAuthProfileStoreSnapshot(agentDir)?.profiles["openai:default"]).toMatchObject({ key: "sk-live", @@ -1667,16 +1323,14 @@ describe("secrets runtime state", () => { }, authStores: [], }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot({ + activateSnapshot( + snapshot({ sourcePort: 19_021, runtimePort: 19_021, apiKey: "sk-old", keyRef: previousKeyInput, }), - refreshContext: null, - refreshHandler: null, - }); + ); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot({ sourcePort: 19_022, @@ -1684,14 +1338,7 @@ describe("secrets runtime state", () => { apiKey: "sk-candidate", keyRef: candidateKeyInput, }); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); const candidateRevision = getActiveSecretsRuntimeSnapshotRevision(); const providerRefresh = snapshot({ sourcePort: 19_022, @@ -1700,23 +1347,14 @@ describe("secrets runtime state", () => { keyRef: candidateKeyInput, }); expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: providerRefresh, + activateSnapshotIfCurrent(providerRefresh, { expectedRevision: candidateRevision, - refreshContext: null, - refreshHandler: null, preserveActivationLineage: true, }), ).toBe(true); expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - ownedSnapshot: candidate, - expectedRevision: candidateRevision, - refreshContext: null, - refreshHandler: null, - }), + restoreSnapshotIfCurrent(previous, candidate, { expectedRevision: candidateRevision }), ).toBe(true); expect(getActiveSecretsRuntimeSnapshot()?.config.gateway?.port).toBe(19_021); expect(getActiveSecretsRuntimeSnapshot()?.config.models?.providers?.openai?.apiKey).toBe( @@ -1822,25 +1460,16 @@ describe("secrets runtime state", () => { }, ], }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot({ sourceConfig: previousSourceConfig, apiKey: "sk-old", port: 19_031 }), - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot( + snapshot({ sourceConfig: previousSourceConfig, apiKey: "sk-old", port: 19_031 }), + ); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot({ sourceConfig: candidateSourceConfig, apiKey: "sk-candidate", port: 19_032, }); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); if (evictLineage) { for (let index = 0; index < 300; index += 1) { noteRuntimeAuthProfileStorePersistedMutation(agentDir, { @@ -1852,27 +1481,21 @@ describe("secrets runtime state", () => { } const candidateRevision = getActiveSecretsRuntimeSnapshotRevision(); expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: snapshot({ + activateSnapshotIfCurrent( + snapshot({ sourceConfig: candidateSourceConfig, apiKey: "sk-refreshed", port: 19_032, }), - expectedRevision: candidateRevision, - refreshContext: null, - refreshHandler: null, - preserveActivationLineage: true, - }), + { + expectedRevision: candidateRevision, + preserveActivationLineage: true, + }, + ), ).toBe(true); expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - ownedSnapshot: candidate, - expectedRevision: candidateRevision, - refreshContext: null, - refreshHandler: null, - }), + restoreSnapshotIfCurrent(previous, candidate, { expectedRevision: candidateRevision }), ).toBe(true); const restored = getActiveSecretsRuntimeSnapshot(); expect(restored?.sourceConfig).toMatchObject(previousSourceConfig); @@ -1935,16 +1558,14 @@ describe("secrets runtime state", () => { }, ], }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot({ + activateSnapshot( + snapshot({ key: "sk-old", owner: capturedOwner, providerPath: "/tmp/old-secrets.json", port: 19_041, }), - refreshContext: null, - refreshHandler: null, - }); + ); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot({ key: "sk-candidate", @@ -1952,14 +1573,7 @@ describe("secrets runtime state", () => { providerPath: "/tmp/rejected-secrets.json", port: 19_042, }); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); noteRuntimeAuthProfileStorePersistedMutation(agentDir, { credentialsChanged: true, stateChanged: false, @@ -1975,15 +1589,7 @@ describe("secrets runtime state", () => { agentDir, ); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); expect(getRuntimeAuthProfileStoreSnapshot(agentDir)).toBeUndefined(); }, ); @@ -2048,16 +1654,14 @@ describe("secrets runtime state", () => { }, ], }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot({ + activateSnapshot( + snapshot({ key: "sk-old", keyRef: previousRef, port: 19_051, sourceConfig: previousSourceConfig, }), - refreshContext: null, - refreshHandler: null, - }); + ); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot({ key: "sk-candidate", @@ -2065,14 +1669,7 @@ describe("secrets runtime state", () => { port: 19_052, sourceConfig: candidateSourceConfig, }); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); noteRuntimeAuthProfileStorePersistedMutation(agentDir, { credentialsChanged: true, stateChanged: false, @@ -2088,15 +1685,7 @@ describe("secrets runtime state", () => { agentDir, ); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); if (affectedProvider) { expect(getRuntimeAuthProfileStoreSnapshot(agentDir)).toBeUndefined(); } else { @@ -2156,29 +1745,20 @@ describe("secrets runtime state", () => { }, ], }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot({ + activateSnapshot( + snapshot({ includeProfile: false, providerPath: "/tmp/old-secrets.json", port: 19_061, }), - refreshContext: null, - refreshHandler: null, - }); + ); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot({ includeProfile: false, providerPath: "/tmp/rejected-secrets.json", port: 19_062, }); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); if (currentOwner === "local") { noteRuntimeAuthProfileStorePersistedMutation(agentDir, { credentialsChanged: true, @@ -2196,15 +1776,7 @@ describe("secrets runtime state", () => { agentDir, ); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); expect(getRuntimeAuthProfileStoreSnapshot(agentDir)).toBeUndefined(); }, ); From 02eb7988574e42a9fe0026f0e700b25b96f45c7e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 2 Aug 2026 02:48:20 +0800 Subject: [PATCH 03/28] fix(ui): reclaim reentrant Talk audio meters --- ui/src/pages/chat/realtime-talk-audio.ts | 4 +++- ui/src/pages/chat/realtime-talk-google-live.ts | 5 ----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/ui/src/pages/chat/realtime-talk-audio.ts b/ui/src/pages/chat/realtime-talk-audio.ts index 8b4511d69ecd..e4a264ee1f41 100644 --- a/ui/src/pages/chat/realtime-talk-audio.ts +++ b/ui/src/pages/chat/realtime-talk-audio.ts @@ -160,8 +160,10 @@ export class RealtimeTalkMediaStreamMeter { analyser.fftSize = this.samples.length; analyser.smoothingTimeConstant = 0; source.connect(analyser); - this.publishCurrentLevel(); this.timer = globalThis.setInterval(() => this.publishCurrentLevel(), 100); + // The initial level callback can synchronously stop its owning transport. + // Own the interval first so that reentrant cleanup cannot leave it behind. + this.publishCurrentLevel(); } catch { // Metering is feedback only; capture must still work if Web Audio analysis // is unavailable in an otherwise functional WebRTC browser. diff --git a/ui/src/pages/chat/realtime-talk-google-live.ts b/ui/src/pages/chat/realtime-talk-google-live.ts index d507c45ec251..2dbc4f909723 100644 --- a/ui/src/pages/chat/realtime-talk-google-live.ts +++ b/ui/src/pages/chat/realtime-talk-google-live.ts @@ -216,11 +216,6 @@ export class GoogleLiveRealtimeTalkTransport implements RealtimeTalkTransport { const inputMeter = new RealtimeTalkMediaStreamMeter(this.ctx.callbacks.onInputLevel); this.inputMeter = inputMeter; inputMeter.start(this.media, this.inputContext); - if (this.closed || !this.lifecycle.isActive || this.inputMeter !== inputMeter) { - // start() publishes synchronously before installing its interval. A - // reentrant stop must reclaim the interval that start() installs next. - inputMeter.stop(false); - } this.assertActivationCurrent(); } this.startMicrophonePump(); From f35c34343a9b18b6e6c721edf658841f209b35bd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 11:48:24 -0700 Subject: [PATCH 04/28] fix(plugins): fail blocked enable commands (#117536) Co-authored-by: Peter Steinberger --- src/cli/plugins-cli.policy.test.ts | 8 +++++--- src/cli/plugins-cli.runtime.ts | 8 +++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/cli/plugins-cli.policy.test.ts b/src/cli/plugins-cli.policy.test.ts index 87fc744612a3..14cc09d25a50 100644 --- a/src/cli/plugins-cli.policy.test.ts +++ b/src/cli/plugins-cli.policy.test.ts @@ -118,7 +118,7 @@ describe("plugins cli policy mutations", () => { plugins: { allow: ["other-plugin"] }, reason: "blocked by allowlist", }, - ])("does not mutate plugin state when $policy blocks enablement", async ({ plugins, reason }) => { + ])("fails without mutations when $policy blocks enablement", async ({ plugins, reason }) => { const sourceConfig = { plugins } as OpenClawConfig; loadConfig.mockReturnValue(sourceConfig); enablePluginInConfig.mockReturnValue({ @@ -129,11 +129,13 @@ describe("plugins cli policy mutations", () => { }); mockPluginRegistry(["alpha"]); - await runPluginsCommand(["plugins", "enable", "alpha"]); + await expect(runPluginsCommand(["plugins", "enable", "alpha"])).rejects.toThrow("__exit__:1"); + expect(replaceConfigFile).not.toHaveBeenCalled(); expect(writeConfigFile).not.toHaveBeenCalled(); expect(refreshPluginRegistry).not.toHaveBeenCalled(); - expect(runtimeLogs).toContain(`Plugin "alpha" could not be enabled (${reason}).`); + expect(runtimeErrors).toContain(`Plugin "alpha" could not be enabled (${reason}).`); + expect(runtimeLogs).not.toContain(`Plugin "alpha" could not be enabled (${reason}).`); }); it("refuses plugin enablement in Nix mode before config mutation", async () => { diff --git a/src/cli/plugins-cli.runtime.ts b/src/cli/plugins-cli.runtime.ts index e52f5dfa4679..1d1c44761cb7 100644 --- a/src/cli/plugins-cli.runtime.ts +++ b/src/cli/plugins-cli.runtime.ts @@ -205,12 +205,10 @@ async function runPluginsEnableCommandUnlocked(idInput: string): Promise { }); // A blocked request must not displace the active slot or rewrite persisted state. if (!enableResult.enabled) { - defaultRuntime.log( - theme.warn( - `Plugin "${id}" could not be enabled (${enableResult.reason ?? "unknown reason"}).`, - ), + defaultRuntime.error( + `Plugin "${id}" could not be enabled (${enableResult.reason ?? "unknown reason"}).`, ); - return; + return defaultRuntime.exit(1); } const { applySlotSelectionForPlugin } = await loadPluginSlotSelection(); From b9a84f49a91c7d11fa88bb87b8c033f772de3cda Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 2 Aug 2026 02:48:24 +0800 Subject: [PATCH 05/28] test(ui): cover reentrant Talk meter cleanup --- ui/src/pages/chat/realtime-talk-audio.test.ts | 38 +++++++++++++++++++ .../chat/realtime-talk-gateway-relay.test.ts | 19 ++++++++++ .../pages/chat/realtime-talk-webrtc.test.ts | 36 ++++++++++++++++++ 3 files changed, 93 insertions(+) diff --git a/ui/src/pages/chat/realtime-talk-audio.test.ts b/ui/src/pages/chat/realtime-talk-audio.test.ts index b0db4684e9b6..bb771a9e6263 100644 --- a/ui/src/pages/chat/realtime-talk-audio.test.ts +++ b/ui/src/pages/chat/realtime-talk-audio.test.ts @@ -95,6 +95,44 @@ describe("RealtimeTalkMediaStreamMeter", () => { expect(close).toHaveBeenCalledOnce(); }); + it("reclaims its interval when the initial level callback stops it", () => { + vi.useFakeTimers(); + const close = vi.fn(async () => undefined); + const disconnectSource = vi.fn(); + const disconnectAnalyser = vi.fn(); + class MockAudioContext { + readonly close = close; + createMediaStreamSource() { + return { connect: vi.fn(), disconnect: disconnectSource }; + } + createAnalyser() { + return { + fftSize: 0, + smoothingTimeConstant: 0, + disconnect: disconnectAnalyser, + getFloatTimeDomainData: (samples: Float32Array) => samples.fill(0.25), + }; + } + } + vi.stubGlobal("AudioContext", MockAudioContext); + const onLevel = vi.fn((level: number) => { + if (level > 0) { + meter.stop(); + } + }); + const meter = new RealtimeTalkMediaStreamMeter(onLevel); + + meter.start({} as MediaStream); + meter.stop(); + meter.stop(); + vi.advanceTimersByTime(1_000); + + expect(vi.getTimerCount()).toBe(0); + expect(disconnectSource).toHaveBeenCalledOnce(); + expect(disconnectAnalyser).toHaveBeenCalledOnce(); + expect(close).toHaveBeenCalledOnce(); + }); + it("closes an owned AudioContext when analyser setup fails", () => { const close = vi.fn(async () => undefined); class MockAudioContext { diff --git a/ui/src/pages/chat/realtime-talk-gateway-relay.test.ts b/ui/src/pages/chat/realtime-talk-gateway-relay.test.ts index aac2ba302117..a729faa6a7e8 100644 --- a/ui/src/pages/chat/realtime-talk-gateway-relay.test.ts +++ b/ui/src/pages/chat/realtime-talk-gateway-relay.test.ts @@ -614,6 +614,25 @@ describe("GatewayRelayRealtimeTalkTransport", () => { expect(onInputLevel).toHaveBeenLastCalledWith(0); }); + it("reclaims the input meter when its first level update stops the transport", async () => { + vi.useFakeTimers(); + const client = createClient(); + const onInputLevel = vi.fn((level: number) => { + if (level > 0) { + transport.stop(); + } + }); + const transport = createTransport({ client, callbacks: { onInputLevel } }); + + await expect(transport.start()).resolves.toBe("ready"); + transport.stop(); + transport.stop(); + vi.advanceTimersByTime(1_000); + + expect(vi.getTimerCount()).toBe(0); + expect(requestCallsFor(client, "talk.session.close")).toHaveLength(1); + }); + it("bounds stalled microphone appends and aborts every owner on stop", async () => { const onStatus = vi.fn(); const client = createClient(); diff --git a/ui/src/pages/chat/realtime-talk-webrtc.test.ts b/ui/src/pages/chat/realtime-talk-webrtc.test.ts index 6bef1ac0e370..8a662e211f2d 100644 --- a/ui/src/pages/chat/realtime-talk-webrtc.test.ts +++ b/ui/src/pages/chat/realtime-talk-webrtc.test.ts @@ -223,6 +223,42 @@ describe("WebRtcSdpRealtimeTalkTransport", () => { expect(close).toHaveBeenCalledOnce(); }); + it("reclaims the input meter when its first level update stops the transport", async () => { + vi.useFakeTimers(); + stubAnswerSdpFetch(); + const close = vi.fn(async () => undefined); + class MockAudioContext { + readonly close = close; + createMediaStreamSource() { + return { connect: vi.fn(), disconnect: vi.fn() }; + } + createAnalyser() { + return { + fftSize: 0, + smoothingTimeConstant: 0, + disconnect: vi.fn(), + getFloatTimeDomainData: (samples: Float32Array) => samples.fill(0.25), + }; + } + } + vi.stubGlobal("AudioContext", MockAudioContext); + const onInputLevel = vi.fn((level: number) => { + if (level > 0) { + transport.stop(); + } + }); + const transport = createOpenAiTransport({}, { onInputLevel }); + + await expect(transport.start()).resolves.toBe("cancelled"); + transport.stop(); + transport.stop(); + vi.advanceTimersByTime(1_000); + + expect(vi.getTimerCount()).toBe(0); + expect(stopInputTrack).toHaveBeenCalledOnce(); + expect(close).toHaveBeenCalledOnce(); + }); + it("does not continue WebRTC setup when stopped while microphone access is pending", async () => { const fetchMock = vi.fn(async () => new Response("answer-sdp")); vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); From bb21c89b191dacf627526239658b1679c37f2437 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 2 Aug 2026 02:48:55 +0800 Subject: [PATCH 06/28] fix(ui): bound realtime Talk conversation text --- .../pages/chat/realtime-talk-conversation.ts | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/ui/src/pages/chat/realtime-talk-conversation.ts b/ui/src/pages/chat/realtime-talk-conversation.ts index 0d83e80d84b8..87d4ef5f81a7 100644 --- a/ui/src/pages/chat/realtime-talk-conversation.ts +++ b/ui/src/pages/chat/realtime-talk-conversation.ts @@ -1,4 +1,6 @@ // Control UI chat module implements realtime talk conversation behavior. +import { sliceUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; + type RealtimeTalkConversationRole = "user" | "assistant"; export type RealtimeTalkConversationEntry = { @@ -25,6 +27,9 @@ type RealtimeTalkTranscriptUpdate = { }; const MAX_CONVERSATION_ENTRIES = 60; +const MAX_CONVERSATION_ENTRY_CHARS = 8_000; +const CONVERSATION_ENTRY_PREFIX_CHARS = 256; +const CONVERSATION_ENTRY_TRUNCATION_MARKER = "\n…\n"; const USER_FINAL_REWRITE_GRACE_MS = 1_500; export function createRealtimeTalkConversationState(): RealtimeTalkConversationState { @@ -96,7 +101,12 @@ function upsertRealtimeConversationEntry( const id = `rt-${state.nextEntryId}`; const entries = [ ...state.entries, - { id, role, text: text.trimStart(), isStreaming: !isFinal }, + { + id, + role, + text: boundRealtimeConversationText(text.trimStart()), + isStreaming: !isFinal, + }, ].slice(-MAX_CONVERSATION_ENTRIES); return rememberRealtimeConversationEntry( { ...state, entries, nextEntryId: state.nextEntryId + 1 }, @@ -115,10 +125,11 @@ function upsertRealtimeConversationEntry( if (!entry) { return upsertRealtimeConversationEntry(state, role, null, text, isFinal, nowMs); } - const updatedText = + const mergedText = role === "assistant" ? mergeAssistantTranscriptText(entry.text, text, isFinal) : mergeRealtimeTranscriptText(entry.text, text, isFinal); + const updatedText = boundRealtimeConversationText(mergedText); const entries = entry.text === updatedText && entry.isStreaming === !isFinal ? state.entries @@ -263,6 +274,24 @@ function mergeRealtimeTranscriptText(existing: string, incoming: string, isFinal return `${existing}${separator}${suffix}`; } +function boundRealtimeConversationText(text: string): string { + if (text.length <= MAX_CONVERSATION_ENTRY_CHARS) { + return text; + } + // Keep the opening context for late full-final replacement detection and + // the newest tail for the visible conversation. Reuse the original prefix + // so repeated streaming deltas do not move the truncation boundary. + const markerIndex = text.indexOf(CONVERSATION_ENTRY_TRUNCATION_MARKER); + const prefix = + markerIndex > 0 + ? text.slice(0, markerIndex) + : sliceUtf16Safe(text, 0, CONVERSATION_ENTRY_PREFIX_CHARS); + const tailChars = + MAX_CONVERSATION_ENTRY_CHARS - prefix.length - CONVERSATION_ENTRY_TRUNCATION_MARKER.length; + const tail = sliceUtf16Safe(text, -tailChars); + return `${prefix}${CONVERSATION_ENTRY_TRUNCATION_MARKER}${tail}`; +} + function looksLikeTranscriptReplacement(existing: string, incoming: string): boolean { const existingWords = transcriptWords(existing); const incomingWords = transcriptWords(incoming); From 063907e9701c9bc7baef52e7e1e86d8e4395d8a2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 11:53:50 -0700 Subject: [PATCH 07/28] refactor(plugins): consolidate registry snapshots (#117561) --- src/plugins/installed-plugin-index-hash.ts | 22 - ...try-contributions.current-snapshot.test.ts | 23 +- ...plugin-registry-snapshot.lifecycle.test.ts | 24 - src/plugins/plugin-registry-snapshot.test.ts | 312 ++++++-- src/plugins/plugin-registry-snapshot.ts | 751 ++++++++---------- src/plugins/plugin-registry.test.ts | 158 ++-- 6 files changed, 664 insertions(+), 626 deletions(-) delete mode 100644 src/plugins/plugin-registry-snapshot.lifecycle.test.ts diff --git a/src/plugins/installed-plugin-index-hash.ts b/src/plugins/installed-plugin-index-hash.ts index dce1f01186e4..f66b1a992344 100644 --- a/src/plugins/installed-plugin-index-hash.ts +++ b/src/plugins/installed-plugin-index-hash.ts @@ -59,25 +59,3 @@ export function safeFileSignature(filePath: string): InstalledPluginFileSignatur return undefined; } } - -/** Compares current file metadata with a stored installed-plugin file signature. */ -export function fileSignatureMatches( - filePath: string, - signature: InstalledPluginFileSignature | undefined, -): boolean | undefined { - if (!signature) { - return undefined; - } - if (typeof signature.ctimeMs !== "number") { - return undefined; - } - const current = safeFileSignature(filePath); - if (!current) { - return false; - } - return ( - current.size === signature.size && - current.mtimeMs === signature.mtimeMs && - current.ctimeMs === signature.ctimeMs - ); -} diff --git a/src/plugins/plugin-registry-contributions.current-snapshot.test.ts b/src/plugins/plugin-registry-contributions.current-snapshot.test.ts index c32ada20d875..fb17ac9d974b 100644 --- a/src/plugins/plugin-registry-contributions.current-snapshot.test.ts +++ b/src/plugins/plugin-registry-contributions.current-snapshot.test.ts @@ -1,5 +1,6 @@ // Verifies current plugin registry contribution snapshots. -import { afterEach, describe, expect, it } from "vitest"; +import fs from "node:fs"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { setCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-snapshot.js"; import { clearCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-state.js"; @@ -8,6 +9,7 @@ import type { InstalledPluginIndex } from "./installed-plugin-index.js"; import type { PluginManifestRecord } from "./manifest-registry.js"; import type { PluginMetadataSnapshot } from "./plugin-metadata-snapshot.types.js"; import { loadPluginManifestRegistryForPluginRegistry } from "./plugin-registry-contributions.js"; +import { loadPluginRegistrySnapshotWithMetadata } from "./plugin-registry-snapshot.js"; afterEach(() => { clearCurrentPluginMetadataSnapshot(); @@ -141,7 +143,7 @@ describe("loadPluginManifestRegistryForPluginRegistry current snapshot", () => { expect(loadPluginManifestRegistryForPluginRegistry({ config, env }).plugins).toEqual([]); }); - it("does not reuse current metadata for explicit registry inputs or diagnostics", () => { + it("keeps explicit registry inputs authoritative and reuses current diagnostics", () => { const config: OpenClawConfig = {}; const env = { HOME: "/tmp/openclaw-test-home", @@ -190,11 +192,26 @@ describe("loadPluginManifestRegistryForPluginRegistry current snapshot", () => { }), { config, env, workspaceDir }, ); + const readDirectory = vi.spyOn(fs, "readdirSync"); + const readFile = vi.spyOn(fs, "readFileSync"); + const statFile = vi.spyOn(fs, "statSync"); expect( loadPluginManifestRegistryForPluginRegistry({ config, env, workspaceDir }).plugins.map( (plugin) => plugin.id, ), - ).toEqual([]); + ).toEqual(["enabled"]); + expect( + loadPluginRegistrySnapshotWithMetadata({ config, env, workspaceDir }).diagnostics, + ).toEqual([ + { + level: "info", + code: "persisted-registry-missing", + message: "missing", + }, + ]); + expect(readDirectory).not.toHaveBeenCalled(); + expect(readFile).not.toHaveBeenCalled(); + expect(statFile).not.toHaveBeenCalled(); }); }); diff --git a/src/plugins/plugin-registry-snapshot.lifecycle.test.ts b/src/plugins/plugin-registry-snapshot.lifecycle.test.ts deleted file mode 100644 index 92c662cb16c4..000000000000 --- a/src/plugins/plugin-registry-snapshot.lifecycle.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - getCurrentPluginMetadataSnapshotState, - setCurrentPluginMetadataSnapshotState, -} from "./current-plugin-metadata-state.js"; -import { clearPluginMetadataLifecycleCaches } from "./plugin-metadata-lifecycle.js"; -import "./plugin-registry-snapshot.js"; - -vi.mock("./current-plugin-metadata-snapshot.js", () => ({ - getCurrentPluginMetadataSnapshot: vi.fn(() => undefined), -})); - -afterEach(() => { - clearPluginMetadataLifecycleCaches(); -}); - -describe("plugin registry snapshot lifecycle", () => { - it("clears registry metadata when the snapshot facade is mocked", () => { - setCurrentPluginMetadataSnapshotState({ plugins: [] }, "mocked-snapshot-facade"); - - expect(() => clearPluginMetadataLifecycleCaches()).not.toThrow(); - expect(getCurrentPluginMetadataSnapshotState().snapshot).toBeUndefined(); - }); -}); diff --git a/src/plugins/plugin-registry-snapshot.test.ts b/src/plugins/plugin-registry-snapshot.test.ts index 31b741432694..360c80bff4e8 100644 --- a/src/plugins/plugin-registry-snapshot.test.ts +++ b/src/plugins/plugin-registry-snapshot.test.ts @@ -16,7 +16,6 @@ import { } from "./installed-plugin-index.js"; import { markRetainedManagedNpmInstall } from "./managed-npm-retention.js"; import { loadPluginManifestRegistryForInstalledIndex } from "./manifest-registry-installed.js"; -import { clearPluginMetadataLifecycleCaches } from "./plugin-metadata-lifecycle.js"; import type { PluginMetadataSnapshot } from "./plugin-metadata-snapshot.types.js"; import { loadPluginRegistrySnapshotWithMetadata } from "./plugin-registry-snapshot.js"; import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js"; @@ -275,7 +274,7 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => { }); }); - it("does not treat diagnostic current metadata as provided registry input", () => { + it("reuses diagnostic current metadata without promoting its registry source", () => { const env = { ...createHermeticEnv(makeTempDir()), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1", @@ -300,6 +299,7 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => { configFingerprint: "", workspaceDir, index, + registrySource: "derived", registryDiagnostics: [ { level: "info", @@ -333,10 +333,27 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => { }, { config, env, workspaceDir }, ); + const readDirectory = vi.spyOn(fs, "readdirSync"); + const readFile = vi.spyOn(fs, "readFileSync"); + const statFile = vi.spyOn(fs, "statSync"); const result = loadPluginRegistrySnapshotWithMetadata({ config, env, workspaceDir }); - expect(result.source).not.toBe("provided"); + expect(result).toEqual({ + snapshot: index, + source: "derived", + diagnostics: [ + { + level: "info", + code: "persisted-registry-missing", + message: "missing", + }, + ], + manifestRegistry: { plugins: [], diagnostics: [] }, + }); + expect(readDirectory).not.toHaveBeenCalled(); + expect(readFile).not.toHaveBeenCalled(); + expect(statFile).not.toHaveBeenCalled(); }); it("does not reuse current metadata when explicit derivation inputs are supplied", () => { @@ -559,75 +576,7 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => { expect(result.diagnostics).toStrictEqual([]); }); - it("reuses a memoized registry without polling plugin files", () => { - const tempRoot = makeTempDir(); - const workspaceDir = path.join(tempRoot, "workspace"); - const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" }; - const config = {}; - const first = loadPluginRegistrySnapshotWithMetadata({ config, env, workspaceDir }); - const readDirectory = vi.spyOn(fs, "readdirSync"); - const readFile = vi.spyOn(fs, "readFileSync"); - const statFile = vi.spyOn(fs, "statSync"); - - expect(loadPluginRegistrySnapshotWithMetadata({ config, env, workspaceDir })).toBe(first); - expect(readDirectory).not.toHaveBeenCalled(); - expect(readFile).not.toHaveBeenCalled(); - expect(statFile).not.toHaveBeenCalled(); - }); - - it("retains only the current process-lifecycle registry graph", () => { - const tempRoot = makeTempDir(); - const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" }; - const firstWorkspace = path.join(tempRoot, "first-workspace"); - const secondWorkspace = path.join(tempRoot, "second-workspace"); - - const first = loadPluginRegistrySnapshotWithMetadata({ - config: {}, - env, - workspaceDir: firstWorkspace, - }); - const second = loadPluginRegistrySnapshotWithMetadata({ - config: {}, - env, - workspaceDir: secondWorkspace, - }); - const refreshedFirst = loadPluginRegistrySnapshotWithMetadata({ - config: {}, - env, - workspaceDir: firstWorkspace, - }); - - expect(second).not.toBe(first); - expect(refreshedFirst).not.toBe(first); - expect( - loadPluginRegistrySnapshotWithMetadata({ - config: {}, - env, - workspaceDir: firstWorkspace, - }), - ).toBe(refreshedFirst); - }); - - it("refreshes workspace plugin discovery on explicit metadata invalidation", () => { - const tempRoot = makeTempDir(); - const workspaceDir = path.join(tempRoot, "workspace"); - const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" }; - - const first = loadPluginRegistrySnapshotWithMetadata({ config: {}, env, workspaceDir }); - expect(first.snapshot.plugins.map((plugin) => plugin.pluginId)).not.toContain("demo"); - - writePackagePlugin(path.join(workspaceDir, ".openclaw", "extensions", "demo")); - - const second = loadPluginRegistrySnapshotWithMetadata({ config: {}, env, workspaceDir }); - expect(second).toBe(first); - - clearPluginMetadataLifecycleCaches(); - - const refreshed = loadPluginRegistrySnapshotWithMetadata({ config: {}, env, workspaceDir }); - expect(refreshed.snapshot.plugins.map((plugin) => plugin.pluginId)).toContain("demo"); - }); - - it("ignores malformed load paths while memoizing snapshots", () => { + it("ignores malformed load paths while deriving snapshots", () => { const tempRoot = makeTempDir(); const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" }; const config = { @@ -673,6 +622,36 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => { expect(result.diagnostics).toStrictEqual([]); }); + it("rebuilds when an explicit candidate moves identical package metadata", () => { + const tempRoot = makeTempDir(); + const rootDir = path.join(tempRoot, "workspace"); + const stateDir = path.join(tempRoot, "state"); + const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" }; + const packageContents = JSON.stringify({ name: "demo", version: "1.0.0" }); + const baseCandidate = createCandidate(rootDir); + fs.writeFileSync(path.join(rootDir, "package.json"), packageContents, "utf8"); + const persisted = loadInstalledPluginIndex({ + candidates: [{ ...baseCandidate, packageDir: rootDir }], + config: {}, + env, + }); + writePersistedInstalledPluginIndexSync(persisted, { stateDir }); + const nestedPackageDir = path.join(rootDir, "nested"); + fs.mkdirSync(nestedPackageDir, { recursive: true }); + fs.writeFileSync(path.join(nestedPackageDir, "package.json"), packageContents, "utf8"); + + const result = loadPluginRegistrySnapshotWithMetadata({ + candidates: [{ ...baseCandidate, packageDir: nestedPackageDir }], + config: {}, + env, + stateDir, + }); + + expect(result.source).toBe("derived"); + expectDiagnosticsContainCode(result.diagnostics, "persisted-registry-stale-source"); + expect(result.snapshot.plugins[0]?.packageJson?.path).toBe("nested/package.json"); + }); + it("derives a complete index when a configured load-path plugin is missing", () => { const tempRoot = makeTempDir(); const firstRoot = path.join(tempRoot, "first"); @@ -810,7 +789,24 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => { const metaDir = path.join(rootDir, "..meta"); fs.mkdirSync(metaDir, { recursive: true }); const packageJsonPath = path.join(metaDir, "package.json"); - fs.writeFileSync(packageJsonPath, JSON.stringify({ name: "demo", version: "1.0.0" }), "utf8"); + fs.writeFileSync( + packageJsonPath, + JSON.stringify({ + name: "demo", + version: "1.0.0", + openclaw: { + channel: { + id: "demo", + label: "Demo", + commands: { + nativeCommandsAutoEnabled: true, + nativeSkillsAutoEnabled: false, + }, + }, + }, + }), + "utf8", + ); const index = loadInstalledPluginIndex({ config, env }); const [plugin] = index.plugins; if (!plugin) { @@ -842,6 +838,17 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => { expect(result.source).toBe("persisted"); expect(result.diagnostics).toStrictEqual([]); + expect(result.manifestRegistry).toBeUndefined(); + const registry = loadPluginManifestRegistryForInstalledIndex({ + index: result.snapshot, + config, + env, + includeDisabled: true, + }); + expect(registry.plugins[0]?.channelCatalogMeta?.commands).toEqual({ + nativeCommandsAutoEnabled: true, + nativeSkillsAutoEnabled: false, + }); }); it.runIf(process.platform !== "win32")( @@ -857,6 +864,7 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => { const config = { plugins: { load: { paths: [rootDir] }, + entries: { demo: { enabled: false } }, }, }; writePackagePlugin(rootDir); @@ -902,6 +910,72 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => { }, ); + it.runIf(process.platform !== "win32")( + "rejects dangling root, source, and manifest links for disabled records", + () => { + for (const artifact of ["root", "source", "manifest"] as const) { + const tempRoot = makeTempDir(); + const rootDir = path.join(tempRoot, "workspace"); + const stateDir = path.join(tempRoot, "state"); + const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" }; + const config = { + plugins: { + load: { paths: [rootDir] }, + entries: { demo: { enabled: false } }, + }, + }; + writePackagePlugin(rootDir); + writePersistedInstalledPluginIndexSync(loadInstalledPluginIndex({ config, env }), { + stateDir, + }); + const artifactPath = + artifact === "root" + ? rootDir + : path.join(rootDir, artifact === "source" ? "index.ts" : "openclaw.plugin.json"); + fs.rmSync(artifactPath, { recursive: artifact === "root" }); + fs.symlinkSync(path.join(tempRoot, "missing"), artifactPath); + + const result = loadPluginRegistrySnapshotWithMetadata({ config, env, stateDir }); + + expect([artifact, result.source]).toEqual([artifact, "derived"]); + expectDiagnosticsContainCode(result.diagnostics, "persisted-registry-stale-source"); + } + }, + ); + + it("rejects escaped missing package metadata for disabled records", () => { + const tempRoot = makeTempDir(); + const rootDir = path.join(tempRoot, "workspace"); + const stateDir = path.join(tempRoot, "state"); + const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" }; + const config = { + plugins: { + load: { paths: [rootDir] }, + entries: { demo: { enabled: false } }, + }, + }; + writePackagePlugin(rootDir); + const index = loadInstalledPluginIndex({ config, env }); + const plugin = requirePluginRecord(index.plugins, "demo"); + writePersistedInstalledPluginIndexSync( + { + ...index, + plugins: [ + { + ...plugin, + packageJson: { path: "../gone/package.json", hash: "missing" }, + }, + ], + }, + { stateDir }, + ); + + const result = loadPluginRegistrySnapshotWithMetadata({ config, env, stateDir }); + + expect(result.source).toBe("derived"); + expectDiagnosticsContainCode(result.diagnostics, "persisted-registry-stale-source"); + }); + it("detects same-size same-mtime manifest replacements", () => { const tempRoot = makeTempDir(); const rootDir = path.join(tempRoot, "workspace"); @@ -1048,10 +1122,10 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => { expect(result.snapshot.plugins.map((plugin) => plugin.pluginId)).toEqual(["codex", "whatsapp"]); }); - it("resolves a persisted bundled root only once per registry load", () => { + it("keeps missing disabled bundled records under the trusted bundled root", () => { const tempRoot = makeTempDir(); - const packageRoot = path.join(tempRoot, "openclaw"); - const bundledRoot = path.join(packageRoot, "dist", "extensions"); + const bundledRoot = path.join(tempRoot, "dist", "extensions"); + const pluginRoot = path.join(bundledRoot, "whatsapp"); const stateDir = path.join(tempRoot, "state"); const env = { OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot, @@ -1059,22 +1133,41 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => { OPENCLAW_VERSION: "2026.4.26", VITEST: "true", }; - const pluginIds = ["bundled-one", "bundled-two", "bundled-three", "bundled-four"]; - - for (const pluginId of pluginIds) { - writeBundledPlugin(path.join(bundledRoot, pluginId), pluginId, "index.js"); - } - const index = loadInstalledPluginIndex({ config: {}, env, stateDir }); + const config = { plugins: { entries: { whatsapp: { enabled: false } } } }; + writeBundledPlugin(pluginRoot, "whatsapp", "index.js"); + const index = loadInstalledPluginIndex({ config, env, stateDir }); writePersistedInstalledPluginIndexSync(index, { stateDir }); - const realpathSpy = vi.spyOn(fs, "realpathSync"); + fs.rmSync(pluginRoot, { recursive: true }); - const result = loadPluginRegistrySnapshotWithMetadata({ config: {}, env, stateDir }); + const result = loadPluginRegistrySnapshotWithMetadata({ config, env, stateDir }); expect(result.source).toBe("persisted"); - expect(result.snapshot.plugins.map((plugin) => plugin.pluginId).toSorted()).toEqual( - pluginIds.toSorted(), - ); - expect(realpathSpy.mock.calls.filter(([filePath]) => filePath === bundledRoot)).toHaveLength(1); + expect(result.snapshot.plugins.map((plugin) => plugin.pluginId)).toEqual(["whatsapp"]); + expect(result.snapshot.plugins[0]?.enabled).toBe(false); + }); + + it("keeps missing disabled inventory beside unchanged configured plugins", () => { + const tempRoot = makeTempDir(); + const liveRoot = path.join(tempRoot, "live"); + const missingRoot = path.join(tempRoot, "missing"); + const stateDir = path.join(tempRoot, "state"); + const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" }; + const config = { + plugins: { + load: { paths: [liveRoot, missingRoot] }, + entries: { missing: { enabled: false } }, + }, + }; + writePackagePlugin(liveRoot, { pluginId: "live" }); + writePackagePlugin(missingRoot, { pluginId: "missing" }); + const index = loadInstalledPluginIndex({ config, env }); + writePersistedInstalledPluginIndexSync(index, { stateDir }); + fs.rmSync(missingRoot, { recursive: true }); + + const result = loadPluginRegistrySnapshotWithMetadata({ config, env, stateDir }); + + expect(result.source).toBe("persisted"); + expect(result.snapshot.plugins.map((plugin) => plugin.pluginId)).toEqual(["live", "missing"]); }); it("treats a persisted source bundled root as stale once its built peer appears", () => { @@ -1112,6 +1205,49 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => { ]); }); + it("replaces a persisted built root when its source plugin opts out of bundled output", () => { + const tempRoot = makeTempDir(); + const packageRoot = path.join(tempRoot, "openclaw"); + const bundledRoot = path.join(packageRoot, "dist", "extensions"); + const sourcePluginDir = path.join(packageRoot, "extensions", "whatsapp"); + const stateDir = path.join(tempRoot, "state"); + const env = { + OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot, + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_VERSION: "2026.4.26", + VITEST: "true", + }; + + fs.mkdirSync(path.join(packageRoot, "src"), { recursive: true }); + fs.writeFileSync(path.join(packageRoot, ".git"), "gitdir: /tmp/mock\n", "utf8"); + fs.writeFileSync(path.join(packageRoot, "pnpm-workspace.yaml"), "packages: []\n", "utf8"); + writeBundledPlugin(sourcePluginDir, "whatsapp", "index.ts"); + writeBundledPlugin(path.join(bundledRoot, "whatsapp"), "whatsapp", "index.js"); + + const builtIndex = loadInstalledPluginIndex({ config: {}, env, stateDir }); + expect(builtIndex.plugins.map((plugin) => plugin.rootDir)).toEqual([ + fs.realpathSync(path.join(bundledRoot, "whatsapp")), + ]); + writePersistedInstalledPluginIndexSync(builtIndex, { stateDir }); + fs.writeFileSync( + path.join(sourcePluginDir, "package.json"), + JSON.stringify({ + name: "@openclaw/whatsapp", + version: "1.0.0", + openclaw: { extensions: ["./index.ts"], build: { bundledDist: false } }, + }), + "utf8", + ); + + const result = loadPluginRegistrySnapshotWithMetadata({ config: {}, env, stateDir }); + + expect(result.source).toBe("derived"); + expectDiagnosticsContainCode(result.diagnostics, "persisted-registry-stale-source"); + expect(result.snapshot.plugins.map((plugin) => plugin.rootDir)).toEqual([ + fs.realpathSync(sourcePluginDir), + ]); + }); + it("keeps a persisted bind-mounted source overlay when its built peer exists", () => { const tempRoot = makeTempDir(); const packageRoot = path.join(tempRoot, "openclaw"); diff --git a/src/plugins/plugin-registry-snapshot.ts b/src/plugins/plugin-registry-snapshot.ts index 147590d83bb6..151692c971c4 100644 --- a/src/plugins/plugin-registry-snapshot.ts +++ b/src/plugins/plugin-registry-snapshot.ts @@ -1,20 +1,16 @@ // Builds stable snapshots of plugin registry contributions. -import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; +import { isDeepStrictEqual } from "node:util"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope-config.js"; import { tryReadJsonSync } from "../infra/json-files.js"; -import { resolveUserPath } from "../utils.js"; -import { resolveCompatibilityHostVersion } from "../version.js"; import { resolveBundledPluginsDir } from "./bundled-dir.js"; import { buildLegacyBundledRootPath } from "./bundled-load-path-aliases.js"; import { listBundledSourceOverlayDirs } from "./bundled-source-overlays.js"; import { normalizePluginsConfig } from "./config-state.js"; import { getCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-snapshot.js"; -import { clearCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-state.js"; -import { discoverConfiguredPluginLoadPaths, type PluginDiscoveryResult } from "./discovery.js"; -import { resolveActivePluginInstallRoots } from "./install-root-context.js"; -import { fileSignatureMatches, hashJson } from "./installed-plugin-index-hash.js"; +import type { PluginDiscoveryResult } from "./discovery.js"; +import { safeFileSignature, safeHashFile } from "./installed-plugin-index-hash.js"; import { hasOptionalMissingPluginManifestFile } from "./installed-plugin-index-manifest.js"; import { loadInstalledPluginIndexInstallRecordsSync } from "./installed-plugin-index-record-reader.js"; import { @@ -26,7 +22,6 @@ import { } from "./installed-plugin-index-store.js"; import { getInstalledPluginRecord, - extractPluginInstallRecordsFromInstalledPluginIndex, hasMissingConfigPathActivationMetadata, isInstalledPluginEnabled, loadInstalledPluginIndexWithDiscovery, @@ -36,12 +31,67 @@ import { type LoadInstalledPluginIndexParams, type RefreshInstalledPluginIndexParams, } from "./installed-plugin-index.js"; -import { loadPluginManifestRegistry, type PluginManifestRegistry } from "./manifest-registry.js"; +import type { PluginManifestRegistry } from "./manifest-registry.js"; import { getPackageManifestMetadata, type PackageManifest } from "./manifest.js"; -import { safeRealpathSync } from "./path-safety.js"; -import { registerPluginMetadataProcessMemoLifecycleClear } from "./plugin-metadata-lifecycle.js"; +import { isPathInside, safeRealpathSync } from "./path-safety.js"; import type { PluginRegistrySnapshotSource } from "./plugin-registry-snapshot.types.js"; +function resolvePluginRegistryContent( + index: InstalledPluginIndex, + comparePackageJsonPath: boolean, + excludedPlugins?: ReadonlyMap, +): unknown { + const { + generatedAtMs: _generatedAtMs, + refreshReason: _refreshReason, + warning: _warning, + ...content + } = index; + const excludedRoots = [...(excludedPlugins?.values() ?? [])].map((root) => path.resolve(root)); + const exclusionPathCache = new Map(); + return { + ...content, + diagnostics: excludedPlugins + ? content.diagnostics.filter( + (diagnostic) => + !( + (diagnostic.pluginId && excludedPlugins.has(diagnostic.pluginId)) || + (diagnostic.source && + excludedRoots.some((root) => + isContainedPluginPath(root, diagnostic.source!, exclusionPathCache), + )) + ), + ) + : content.diagnostics, + installRecords: excludedPlugins + ? Object.fromEntries( + Object.entries(content.installRecords).filter( + ([pluginId]) => !excludedPlugins.has(pluginId), + ), + ) + : content.installRecords, + plugins: content.plugins + .filter((plugin) => !excludedPlugins?.has(plugin.pluginId)) + .map((plugin) => { + const { manifestFile: _manifestFile, packageJson, ...record } = plugin; + if (!packageJson) { + return record; + } + if (!comparePackageJsonPath) { + return record; + } + const { + fileSignature: _fileSignature, + path: packageJsonPath, + ...stablePackageJson + } = packageJson; + return Object.assign(record, { + packageJson: Object.assign(stablePackageJson, { path: packageJsonPath }), + }); + }), + }; +} + export type PluginRegistrySnapshot = InstalledPluginIndex; export type PluginRegistryRecord = InstalledPluginIndexRecord; type PluginRegistryInspection = InstalledPluginIndexStoreInspection; @@ -65,36 +115,6 @@ type PluginRegistrySnapshotResult = { manifestRegistry?: PluginManifestRegistry; }; -const REGISTRY_SNAPSHOT_MEMO_ENV_KEYS = [ - "APPDATA", - "HOME", - "OPENCLAW_BUNDLED_PLUGINS_DIR", - "OPENCLAW_COMPATIBILITY_HOST_VERSION", - "OPENCLAW_CONFIG_PATH", - "OPENCLAW_DISABLE_BUNDLED_PLUGINS", - "OPENCLAW_DISABLE_BUNDLED_SOURCE_OVERLAYS", - "OPENCLAW_HOME", - "OPENCLAW_NIX_MODE", - "OPENCLAW_STATE_DIR", - "USERPROFILE", - "XDG_CONFIG_HOME", -] as const; - -type PluginRegistrySnapshotMemo = { - key: string; - result: PluginRegistrySnapshotResult; -}; - -let pluginRegistrySnapshotMemo: PluginRegistrySnapshotMemo | undefined; - -function clearLoadPluginRegistrySnapshotMemo(): void { - pluginRegistrySnapshotMemo = undefined; - // A retired registry must not leave its published metadata graph behind. - clearCurrentPluginMetadataSnapshot(); -} - -registerPluginMetadataProcessMemoLifecycleClear(clearLoadPluginRegistrySnapshotMemo); - export type LoadPluginRegistryParams = LoadInstalledPluginIndexParams & InstalledPluginIndexStoreOptions & { index?: PluginRegistrySnapshot; @@ -105,68 +125,6 @@ type GetPluginRecordParams = LoadPluginRegistryParams & { pluginId: string; }; -function pickRegistrySnapshotMemoEnv(env: NodeJS.ProcessEnv): Record { - return Object.fromEntries( - REGISTRY_SNAPSHOT_MEMO_ENV_KEYS.flatMap((key) => { - const value = env[key]; - return value === undefined ? [] : [[key, value]]; - }), - ); -} - -function canMemoizePluginRegistrySnapshot(params: LoadPluginRegistryParams): boolean { - return ( - params.index === undefined && - params.candidates === undefined && - params.diagnostics === undefined && - params.discovery === undefined && - params.installRecords === undefined && - params.now === undefined && - params.filePath === undefined && - params.pluginIndexFilePath === undefined - ); -} - -function resolvePluginRegistrySnapshotMemoKey( - params: LoadPluginRegistryParams, - env: NodeJS.ProcessEnv, -): string | undefined { - if (!canMemoizePluginRegistrySnapshot(params)) { - return undefined; - } - return hashJson({ - config: params.config ?? null, - cwd: process.cwd(), - env: pickRegistrySnapshotMemoEnv(env), - installRoots: resolveActivePluginInstallRoots(env), - hostContractVersion: resolveCompatibilityHostVersion(env), - preferPersisted: params.preferPersisted ?? null, - // Install, reload, and persisted-index writes clear this memo explicitly. - // Polling roots or SQLite here would put discovery back on every hot lookup. - stateDir: params.stateDir ? resolveUserPath(params.stateDir, env) : null, - workspaceDir: params.workspaceDir ? resolveUserPath(params.workspaceDir, env) : null, - }); -} - -function findPluginRegistrySnapshotMemo( - key: string | undefined, -): PluginRegistrySnapshotResult | undefined { - return key && pluginRegistrySnapshotMemo?.key === key - ? pluginRegistrySnapshotMemo.result - : undefined; -} - -function rememberPluginRegistrySnapshotMemo( - key: string | undefined, - result: PluginRegistrySnapshotResult, -): PluginRegistrySnapshotResult { - if (!key) { - return result; - } - pluginRegistrySnapshotMemo = { key, result }; - return result; -} - function canReuseCurrentPluginMetadataSnapshot(params: LoadPluginRegistryParams): boolean { return ( params.preferPersisted !== false && @@ -176,6 +134,7 @@ function canReuseCurrentPluginMetadataSnapshot(params: LoadPluginRegistryParams) params.installRecords === undefined && params.candidates === undefined && params.diagnostics === undefined && + params.discovery === undefined && params.now === undefined ); } @@ -186,266 +145,194 @@ function loadCurrentPluginRegistrySnapshotResult( if (!canReuseCurrentPluginMetadataSnapshot(params)) { return undefined; } - const env = params.env ?? process.env; const current = getCurrentPluginMetadataSnapshot({ config: params.config, - env, + env: params.env ?? process.env, ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), }); - if (!current || current.registryDiagnostics.length > 0) { + if (!current) { return undefined; } return { snapshot: current.index, - source: "provided", + source: + current.registrySource ?? (current.registryDiagnostics.length > 0 ? "derived" : "provided"), diagnostics: current.registryDiagnostics, + ...(current.discovery ? { discovery: current.discovery } : {}), manifestRegistry: current.manifestRegistry, }; } -function hasMissingPersistedPluginSource(index: InstalledPluginIndex): boolean { +function fileContentMatches( + filePath: string, + hash: string, + signature?: InstalledPluginIndexRecord["manifestFile"], + trustSignature = true, +): boolean { + const current = safeFileSignature(filePath); + if (!current) { + return false; + } + if ( + trustSignature && + signature?.ctimeMs !== undefined && + current.size === signature.size && + current.mtimeMs === signature.mtimeMs && + current.ctimeMs === signature.ctimeMs + ) { + return true; + } + return safeHashFile({ filePath, diagnostics: [], required: false }) === hash; +} + +function isContainedPluginPath( + rootPath: string, + targetPath: string, + cache: Map, +): boolean { + // Project unresolved suffixes from the nearest real ancestor so missing disabled + // artifacts stay inspectable without accepting symlink or path-alias escapes. + const resolveProjectedPath = (inputPath: string): string | null => { + const target = path.resolve(inputPath); + for (let cursor = target; ; cursor = path.dirname(cursor)) { + try { + fs.lstatSync(cursor); + const realCursor = safeRealpathSync(cursor, cache); + return realCursor ? path.resolve(realCursor, path.relative(cursor, target)) : null; + } catch { + if (cursor === path.dirname(cursor)) { + return null; + } + } + } + }; + const root = resolveProjectedPath(rootPath); + const target = resolveProjectedPath(targetPath); + return Boolean(root && target && isPathInside(root, target)); +} + +function hasStalePersistedPluginFiles(index: InstalledPluginIndex): boolean { + const realpathCache = new Map(); return index.plugins.some((plugin) => { - if (!plugin.enabled) { + if (!isContainedPluginPath(plugin.rootDir, plugin.rootDir, realpathCache)) { + return true; + } + if (!fs.existsSync(plugin.rootDir) && plugin.enabled) { + return true; + } + for (const artifactPath of [plugin.source, plugin.setupSource, plugin.manifestPath]) { + if (artifactPath && !isContainedPluginPath(plugin.rootDir, artifactPath, realpathCache)) { + return true; + } + } + if ( + plugin.enabled && + ((plugin.source ? !fs.existsSync(plugin.source) : false) || + (plugin.setupSource ? !fs.existsSync(plugin.setupSource) : false)) + ) { + return true; + } + if (!hasOptionalMissingPluginManifestFile(plugin)) { + if (!fs.existsSync(plugin.manifestPath)) { + if (plugin.enabled) { + return true; + } + } else if ( + !fileContentMatches(plugin.manifestPath, plugin.manifestHash, plugin.manifestFile) + ) { + return true; + } + } + if (!plugin.packageJson) { return false; } - return ( - !fs.existsSync(plugin.rootDir) || - (!hasOptionalMissingPluginManifestFile(plugin) && !fs.existsSync(plugin.manifestPath)) || - (plugin.source ? !fs.existsSync(plugin.source) : false) || - (plugin.setupSource ? !fs.existsSync(plugin.setupSource) : false) + const packageJsonPath = path.resolve(plugin.rootDir, plugin.packageJson.path); + if (!isContainedPluginPath(plugin.rootDir, packageJsonPath, realpathCache)) { + return true; + } + if (!fs.existsSync(packageJsonPath)) { + return plugin.enabled; + } + if (!isRealPathInside(plugin.rootDir, packageJsonPath, realpathCache)) { + return true; + } + return !fileContentMatches( + packageJsonPath, + plugin.packageJson.hash, + plugin.packageJson.fileSignature, + plugin.origin === "bundled", ); }); } -function hasMismatchedPersistedConfigPathPlugins( - index: InstalledPluginIndex, - params: LoadPluginRegistryParams, - env: NodeJS.ProcessEnv, - realpathCache: Map, -): boolean { - const loadPaths = normalizePluginsConfig(params.config?.plugins).loadPaths; - const discovery = discoverConfiguredPluginLoadPaths({ - loadPaths, - workspaceDir: params.workspaceDir, - env, - }); - const configuredRoots = loadPluginManifestRegistry({ - config: params.config, - workspaceDir: params.workspaceDir, - env, - candidates: discovery.candidates, - diagnostics: discovery.diagnostics, - installRecords: extractPluginInstallRecordsFromInstalledPluginIndex(index), - }).plugins.map((plugin) => resolveComparablePath(plugin.rootDir, realpathCache)); - const persistedRoots = index.plugins - .filter((plugin) => plugin.origin === "config") - .map((plugin) => resolveComparablePath(plugin.rootDir, realpathCache)); - if (configuredRoots.length !== persistedRoots.length) { - return true; - } - return configuredRoots.some((rootDir, position) => rootDir !== persistedRoots[position]); -} - -function resolveComparablePath(filePath: string, realpathCache: Map): string { - return safeRealpathSync(filePath, realpathCache) ?? path.resolve(filePath); -} - -function isRelativePathInsideOrEqual(relativePath: string): boolean { - return ( - relativePath === "" || - (relativePath !== ".." && - !relativePath.startsWith(`..${path.sep}`) && - !path.isAbsolute(relativePath)) - ); -} - -function isPathInsideOrEqual( - childPath: string, +function isRealPathInside( parentPath: string, - realpathCache: Map, + childPath: string, + cache: Map, ): boolean { - const relative = path.relative( - resolveComparablePath(parentPath, realpathCache), - resolveComparablePath(childPath, realpathCache), - ); - return isRelativePathInsideOrEqual(relative); + const parent = safeRealpathSync(parentPath, cache); + const child = safeRealpathSync(childPath, cache); + return Boolean(parent && child && isPathInside(parent, child)); } -function hasMismatchedPersistedBundledPluginRoot( +function hasMismatchedPersistedBundledRoot( index: InstalledPluginIndex, env: NodeJS.ProcessEnv, - realpathCache: Map, ): boolean { - const bundledPluginsDir = resolveBundledPluginsDir(env); - if (!bundledPluginsDir) { + const bundledRoot = resolveBundledPluginsDir(env); + if (!bundledRoot) { return false; } - let sourceOverlayDirs: string[] | undefined; + const realpathCache = new Map(); + const overlays = listBundledSourceOverlayDirs({ bundledRoot, env }); + const legacyRoot = buildLegacyBundledRootPath(bundledRoot); + const sourceCheckout = + legacyRoot && + fs.existsSync(path.join(path.dirname(legacyRoot), ".git")) && + fs.existsSync(path.join(path.dirname(legacyRoot), "pnpm-workspace.yaml")) && + fs.existsSync(path.join(path.dirname(legacyRoot), "src")); return index.plugins.some((plugin) => { if (plugin.origin !== "bundled") { return false; } - sourceOverlayDirs ??= listBundledSourceOverlayDirs({ - bundledRoot: bundledPluginsDir, - env, - }); - return !isAllowedPersistedBundledPluginRoot( - plugin, - bundledPluginsDir, - sourceOverlayDirs, - realpathCache, - ); - }); -} - -function isAllowedPersistedBundledPluginRoot( - plugin: InstalledPluginIndexRecord, - bundledPluginsDir: string, - sourceOverlayDirs: readonly string[], - realpathCache: Map, -): boolean { - const pluginRootDir = plugin.rootDir; - const legacyRoot = buildLegacyBundledRootPath(bundledPluginsDir); - if (isPathInsideOrEqual(pluginRootDir, bundledPluginsDir, realpathCache)) { - if (!legacyRoot || !isSourceCheckoutBundledPluginRoot(legacyRoot)) { - return true; - } - const relativePluginRoot = path.relative( - resolveComparablePath(bundledPluginsDir, realpathCache), - resolveComparablePath(pluginRootDir, realpathCache), - ); - return !sourcePluginOptsOutOfBundledDist(path.join(legacyRoot, relativePluginRoot)); - } - if ( - sourceOverlayDirs.some((overlayDir) => - isPathInsideOrEqual(pluginRootDir, overlayDir, realpathCache), - ) - ) { - return true; - } - if (!legacyRoot || !isSourceCheckoutBundledPluginRoot(legacyRoot)) { - return false; - } - const relativePluginRoot = path.relative( - resolveComparablePath(legacyRoot, realpathCache), - resolveComparablePath(pluginRootDir, realpathCache), - ); - if (!isRelativePathInsideOrEqual(relativePluginRoot)) { - return false; - } - if (plugin.packageBuild?.bundledDist === false) { - return true; - } - if (sourcePluginOptsOutOfBundledDist(path.join(legacyRoot, relativePluginRoot))) { - // Older index records lack packageBuild. Re-derive once so runtime loading - // and OpenClaw fingerprint the same source-only artifact. - return false; - } - // Discovery prefers a built plugin whenever the same child exists in the - // packaged root. Keep source-only bundled plugins, but invalidate stale - // source records once their built peer appears. - return !fs.existsSync(path.join(bundledPluginsDir, relativePluginRoot)); -} - -function sourcePluginOptsOutOfBundledDist(pluginRootDir: string): boolean { - const packageJson = tryReadJsonSync(path.join(pluginRootDir, "package.json")); - return getPackageManifestMetadata(packageJson ?? undefined)?.build?.bundledDist === false; -} - -function isSourceCheckoutBundledPluginRoot(extensionsDir: string): boolean { - const packageRoot = path.dirname(extensionsDir); - return ( - fs.existsSync(extensionsDir) && - fs.existsSync(path.join(packageRoot, ".git")) && - fs.existsSync(path.join(packageRoot, "pnpm-workspace.yaml")) && - fs.existsSync(path.join(packageRoot, "src")) - ); -} - -function hashExistingFile(filePath: string): string | null { - try { - return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); - } catch { - return null; - } -} - -function resolveRecordPackageJsonPath( - plugin: InstalledPluginIndexRecord, - realpathCache: Map, -): string | null { - const packageJsonPath = plugin.packageJson?.path; - if (!packageJsonPath) { - return null; - } - const rootDir = plugin.rootDir || path.dirname(plugin.manifestPath); - const resolved = path.resolve(rootDir, packageJsonPath); - const relative = path.relative(rootDir, resolved); - if (!isRelativePathInsideOrEqual(relative)) { - return null; - } - const realRelative = path.relative( - resolveComparablePath(rootDir, realpathCache), - resolveComparablePath(resolved, realpathCache), - ); - return isRelativePathInsideOrEqual(realRelative) ? resolved : null; -} - -function hasStalePersistedPluginDiagnostics(index: InstalledPluginIndex): boolean { - return index.diagnostics.some((diag) => { - const source = diag.source; - return ( - typeof diag.pluginId === "string" && - diag.pluginId.trim().length > 0 && - typeof source === "string" && - path.isAbsolute(source) && - !fs.existsSync(source) - ); - }); -} - -function hasStalePersistedPluginMetadata( - index: InstalledPluginIndex, - realpathCache: Map, -): boolean { - return index.plugins.some((plugin) => { - if (!hasOptionalMissingPluginManifestFile(plugin)) { - const manifestSignatureMatches = fileSignatureMatches( - plugin.manifestPath, - plugin.manifestFile, + if (!plugin.enabled && !fs.existsSync(plugin.rootDir)) { + const allowedRoots = [bundledRoot, ...overlays, ...(legacyRoot ? [legacyRoot] : [])]; + return !allowedRoots.some((root) => + isContainedPluginPath(root, plugin.rootDir, realpathCache), ); - if (manifestSignatureMatches !== true) { - const manifestHash = hashExistingFile(plugin.manifestPath); - if (manifestHash && manifestHash !== plugin.manifestHash) { - return true; - } + } + if (isRealPathInside(bundledRoot, plugin.rootDir, realpathCache)) { + if (!sourceCheckout) { + return false; } + const resolvedBundledRoot = safeRealpathSync(bundledRoot, realpathCache) ?? bundledRoot; + const resolvedPluginRoot = safeRealpathSync(plugin.rootDir, realpathCache) ?? plugin.rootDir; + const sourcePackage = tryReadJsonSync( + path.join( + legacyRoot, + path.relative(resolvedBundledRoot, resolvedPluginRoot), + "package.json", + ), + ); + return getPackageManifestMetadata(sourcePackage ?? undefined)?.build?.bundledDist === false; } - const packageJsonPath = resolveRecordPackageJsonPath(plugin, realpathCache); - if (!plugin.packageJson?.hash) { - return false; - } - if (!packageJsonPath) { - return true; - } - const packageJsonSignatureMatches = fileSignatureMatches( - packageJsonPath, - plugin.packageJson.fileSignature, + return ( + !overlays.some((root) => isRealPathInside(root, plugin.rootDir, realpathCache)) && + !( + plugin.packageBuild?.bundledDist === false && + legacyRoot && + isRealPathInside(legacyRoot, plugin.rootDir, realpathCache) + ) ); - if (packageJsonSignatureMatches === true && plugin.origin === "bundled") { - return false; - } - if (packageJsonSignatureMatches === false) { - return hashExistingFile(packageJsonPath) !== plugin.packageJson.hash; - } - // Fast same-size rewrites can preserve observable stat fields on some filesystems. - const packageJsonHash = hashExistingFile(packageJsonPath); - return packageJsonHash !== plugin.packageJson.hash; }); } -function loadSnapshotInstallRecords(params: LoadPluginRegistryParams, env: NodeJS.ProcessEnv) { - return loadInstalledPluginIndexInstallRecordsSync({ +function hasRecoveredInstallRecordsMissingFromPersistedIndex( + index: InstalledPluginIndex, + params: LoadPluginRegistryParams, + env: NodeJS.ProcessEnv, +): boolean { + const installRecords = loadInstalledPluginIndexInstallRecordsSync({ env, ...(params.stateDir ? { stateDir: params.stateDir } : {}), ...(params.filePath @@ -454,28 +341,32 @@ function loadSnapshotInstallRecords(params: LoadPluginRegistryParams, env: NodeJ ? { filePath: params.pluginIndexFilePath } : {}), }); + const pluginIds = new Set(index.plugins.map((plugin) => plugin.pluginId)); + return Object.keys(installRecords).some( + (pluginId) => !index.installRecords?.[pluginId] || !pluginIds.has(pluginId), + ); } -function hasRecoveredInstallRecordsMissingFromPersistedIndex( +function requiresDerivedRegistryValidation( index: InstalledPluginIndex, - installRecords: ReturnType, + params: LoadPluginRegistryParams, env: NodeJS.ProcessEnv, + hasStalePluginFiles: () => boolean, ): boolean { - const persistedRecords = extractPluginInstallRecordsFromInstalledPluginIndex(index); - const persistedPluginIds = new Set(index.plugins.map((plugin) => plugin.pluginId)); - return Object.entries(installRecords).some(([pluginId, record]) => { - if (persistedRecords[pluginId] && persistedPluginIds.has(pluginId)) { - return false; - } - const installPaths = [record.installPath, record.sourcePath].filter( - (candidate): candidate is string => - typeof candidate === "string" && candidate.trim().length > 0, - ); - if (installPaths.length === 0) { - return true; - } - return installPaths.some((installPath) => fs.existsSync(resolveUserPath(installPath, env))); - }); + return ( + params.candidates !== undefined || + params.discovery !== undefined || + params.diagnostics !== undefined || + params.installRecords !== undefined || + normalizePluginsConfig(params.config?.plugins).loadPaths.length > 0 || + hasMissingConfigPathActivationMetadata(index) || + index.diagnostics.some(({ pluginId, source }) => + Boolean(pluginId && source && path.isAbsolute(source) && !fs.existsSync(source)), + ) || + hasMismatchedPersistedBundledRoot(index, env) || + hasStalePluginFiles() || + hasRecoveredInstallRecordsMissingFromPersistedIndex(index, params, env) + ); } export function loadPluginRegistrySnapshotWithMetadata( @@ -494,96 +385,117 @@ export function loadPluginRegistrySnapshotWithMetadata( } const env = params.env ?? process.env; - const memoKey = resolvePluginRegistrySnapshotMemoKey(params, env); - const memo = findPluginRegistrySnapshotMemo(memoKey); - if (memo) { - return memo; - } - // Bound canonical paths to this registry build; lifecycle changes must - // never reuse security-sensitive symlink or plugin-root resolutions. - const realpathCache = new Map(); - const diagnostics: PluginRegistrySnapshotDiagnostic[] = []; 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) - ) { - diagnostics.push({ - level: "warn", - code: "persisted-registry-stale-policy", - message: - "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)) { - 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)) { - 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) - ) { - 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)) { - 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)) { - 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)) { - 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, - loadSnapshotInstallRecords(params, env), - env, - ) - ) { - 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, - source: "persisted", - diagnostics, - }; - return rememberPluginRegistrySnapshotMemo(memoKey, persistedResult); - } - } else { - diagnostics.push({ - level: "info", - code: "persisted-registry-missing", - message: "Persisted plugin registry is missing or invalid; using derived plugin index.", - }); - } + if (!persistedReadsEnabled) { + const derived = loadInstalledPluginIndexWithDiscovery({ + ...params, + installRecords: params.installRecords ?? {}, + }); + return { + snapshot: derived.index, + source: "derived", + diagnostics: [], + discovery: derived.discovery, + manifestRegistry: derived.manifestRegistry, + }; + } + + const diagnostics: PluginRegistrySnapshotDiagnostic[] = []; + const persistedIndex = readPersistedInstalledPluginIndexSync(params); + let stalePluginFiles: boolean | undefined; + const hasStalePluginFiles = () => + (stalePluginFiles ??= persistedIndex ? hasStalePersistedPluginFiles(persistedIndex) : false); + if (!persistedIndex) { + diagnostics.push({ + level: "info", + code: "persisted-registry-missing", + message: "Persisted plugin registry is missing or invalid; using derived plugin index.", + }); + } else if ( + params.config && + persistedIndex.policyHash !== resolveInstalledPluginIndexPolicyHash(params.config) + ) { + diagnostics.push({ + level: "warn", + code: "persisted-registry-stale-policy", + message: + "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 (!requiresDerivedRegistryValidation(persistedIndex, params, env, hasStalePluginFiles)) { + return { + snapshot: persistedIndex, + source: "persisted", + diagnostics, + }; } const derived = loadInstalledPluginIndexWithDiscovery({ ...params, - installRecords: persistedReadsEnabled ? params.installRecords : (params.installRecords ?? {}), + ...(params.filePath && !params.pluginIndexFilePath + ? { pluginIndexFilePath: params.filePath } + : {}), }); - return rememberPluginRegistrySnapshotMemo(memoKey, { + const comparePackageJsonPath = + params.candidates !== undefined || params.discovery !== undefined || hasStalePluginFiles(); + const excludedMissingDisabledPlugins = new Map(); + if ( + persistedIndex && + params.candidates === undefined && + params.discovery === undefined && + params.installRecords === undefined && + !hasStalePluginFiles() && + !hasMismatchedPersistedBundledRoot(persistedIndex, env) + ) { + const derivedPluginIds = new Set(derived.index.plugins.map((plugin) => plugin.pluginId)); + for (const plugin of persistedIndex.plugins) { + if (!plugin.enabled && !derivedPluginIds.has(plugin.pluginId)) { + excludedMissingDisabledPlugins.set(plugin.pluginId, plugin.rootDir); + } + } + } + const contentMatches = + persistedIndex && + diagnostics.length === 0 && + isDeepStrictEqual( + resolvePluginRegistryContent( + persistedIndex, + comparePackageJsonPath, + excludedMissingDisabledPlugins, + ), + resolvePluginRegistryContent( + derived.index, + comparePackageJsonPath, + excludedMissingDisabledPlugins, + ), + ); + if (persistedIndex && contentMatches) { + const packageMetadataMatches = isDeepStrictEqual( + resolvePluginRegistryContent(persistedIndex, true), + resolvePluginRegistryContent(derived.index, true), + ); + return { + snapshot: persistedIndex, + source: "persisted", + diagnostics, + discovery: derived.discovery, + ...(packageMetadataMatches ? { manifestRegistry: derived.manifestRegistry } : {}), + }; + } else if (persistedIndex && diagnostics.length === 0) { + diagnostics.push({ + level: "warn", + code: "persisted-registry-stale-source", + message: + "Persisted plugin registry no longer matches current plugin discovery or metadata; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.", + }); + } + + return { snapshot: derived.index, source: "derived", diagnostics, discovery: derived.discovery, manifestRegistry: derived.manifestRegistry, - }); + }; } function resolveSnapshot(params: LoadPluginRegistryParams = {}): PluginRegistrySnapshot { @@ -595,6 +507,7 @@ export function loadPluginRegistrySnapshot( ): PluginRegistrySnapshot { return resolveSnapshot(params); } + export function getPluginRecord(params: GetPluginRecordParams): PluginRegistryRecord | undefined { return getInstalledPluginRecord(resolveSnapshot(params), params.pluginId); } diff --git a/src/plugins/plugin-registry.test.ts b/src/plugins/plugin-registry.test.ts index 5dc51b2f8bc1..7bfd7dcdcd66 100644 --- a/src/plugins/plugin-registry.test.ts +++ b/src/plugins/plugin-registry.test.ts @@ -4,10 +4,7 @@ import fs from "node:fs"; import path from "node:path"; import { expectDefined } from "@openclaw/normalization-core"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { - closeOpenClawStateDatabaseForTest, - runOpenClawStateWriteTransaction, -} from "../state/openclaw-state-db.js"; +import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import type { PluginCandidate } from "./discovery.js"; import { readPersistedInstalledPluginIndex, @@ -169,15 +166,6 @@ function createIndex( }; } -function createPersistableIndex(pluginId: string): InstalledPluginIndex { - const index = createIndex(pluginId); - const plugins = index.plugins.map((plugin) => Object.assign({}, plugin, { enabled: false })); - return { - ...index, - plugins, - }; -} - function requireRecord(value: unknown, label: string): Record { if (!value || typeof value !== "object") { throw new Error(`expected ${label}`); @@ -330,6 +318,29 @@ describe("plugin registry facade", () => { ).toEqual(["demo"]); }); + it("keeps missing disabled records inspectable from the persisted registry", async () => { + const stateDir = makeTempDir(); + const rootDir = makeTempDir(); + const config = { plugins: { entries: { demo: { enabled: false } } } }; + const env = hermeticEnv(); + const persisted = loadPluginRegistrySnapshot({ + candidates: [createCandidate(rootDir)], + config, + env, + preferPersisted: false, + }); + await writePersistedInstalledPluginIndex(persisted, { stateDir }); + fs.rmSync(rootDir, { recursive: true }); + + const result = loadPluginRegistrySnapshotWithMetadata({ stateDir, config, env }); + + expect(result.source).toBe("persisted"); + expectPluginRecordFields(getPluginRecord({ index: result.snapshot, pluginId: "demo" }), { + pluginId: "demo", + enabled: false, + }); + }); + it("resolves contribution owners from a plugin lookup table without rereading manifests", () => { const rootDir = makeTempDir(); const candidate = createCandidate(rootDir); @@ -471,7 +482,7 @@ describe("plugin registry facade", () => { expect(normalizedConfig.allow).toEqual(["demo"]); }); - it("reads the persisted registry before deriving from discovered candidates", async () => { + it("treats explicit discovered candidates as authoritative", async () => { const stateDir = makeTempDir(); const rootDir = makeTempDir(); const persistedRootDir = makeTempDir(); @@ -509,13 +520,70 @@ describe("plugin registry facade", () => { env: hermeticEnv(), }); - expect(result.source).toBe("persisted"); - expect(result.diagnostics).toStrictEqual([]); + expect(result.source).toBe("derived"); + expectDiagnosticCodes(result.diagnostics, ["persisted-registry-stale-source"]); expect(listPluginRecords({ index: result.snapshot }).map((plugin) => plugin.pluginId)).toEqual([ - "persisted", + "demo", ]); }); + it("keeps content-equivalent timestamp changes on the persisted path", async () => { + const stateDir = makeTempDir(); + const rootDir = makeTempDir(); + const env = hermeticEnv(); + const persisted = loadPluginRegistrySnapshot({ + candidates: [createCandidate(rootDir)], + env, + preferPersisted: false, + }); + await writePersistedInstalledPluginIndex( + { + ...persisted, + plugins: [ + { + ...expectDefined(persisted.plugins[0], "persisted plugin test invariant"), + syntheticAuthRefs: ["demo"], + }, + ...persisted.plugins.slice(1), + ], + }, + { stateDir }, + ); + const manifestPath = path.join(rootDir, "openclaw.plugin.json"); + const future = new Date(Date.now() + 1_000); + fs.utimesSync(manifestPath, future, future); + + const result = loadPluginRegistrySnapshotWithMetadata({ stateDir, env }); + + expect(result.source).toBe("persisted"); + expect(result.snapshot.plugins[0]?.syntheticAuthRefs).toEqual(["demo"]); + }); + + it("reads install records from a custom SQLite registry path", async () => { + const tempDir = makeTempDir(); + const rootDir = makeTempDir(); + const filePath = path.join(tempDir, "custom-registry.sqlite"); + const env = hermeticEnv(); + const persisted = loadPluginRegistrySnapshot({ + candidates: [createCandidate(rootDir)], + env, + preferPersisted: false, + }); + persisted.installRecords = { + demo: { source: "npm", spec: "demo@1.0.0", installPath: rootDir }, + }; + await writePersistedInstalledPluginIndex(persisted, { filePath }); + + const result = loadPluginRegistrySnapshotWithMetadata({ filePath, env }); + + expect(result.source).toBe("persisted"); + expectInstallRecord(result.snapshot.installRecords, "demo", { + source: "npm", + spec: "demo@1.0.0", + installPath: rootDir, + }); + }); + it("falls back to the derived registry when persisted source paths are missing", async () => { const stateDir = makeTempDir(); const rootDir = makeTempDir(); @@ -819,7 +887,7 @@ describe("plugin registry facade", () => { expectSnapshotPluginIds(result.snapshot, ["demo"]); }); - it("reuses config-scoped derived registries within the process", () => { + it("derives config-scoped registries for cold callers", () => { const stateDir = makeTempDir(); const workspaceDir = makeTempDir(); const bundledRoot = makeTempDir(); @@ -853,7 +921,7 @@ describe("plugin registry facade", () => { expect(first.source).toBe("derived"); expect(second.source).toBe("derived"); expect(manifestReadsAfterFirst).toBeGreaterThan(0); - expect(manifestReadsAfterSecond).toBe(manifestReadsAfterFirst); + expect(manifestReadsAfterSecond).toBeGreaterThan(manifestReadsAfterFirst); }); it("reloads profile extensions after the metadata lifecycle is cleared", () => { @@ -881,7 +949,7 @@ describe("plugin registry facade", () => { expectSnapshotPluginIds(second.snapshot, ["first", "second"]); }); - it("keys the process registry memo by resolved host contract version", () => { + it("derives the resolved host contract version", () => { const stateDir = makeTempDir(); const bundledRoot = makeTempDir(); const rootDir = path.join(bundledRoot, "demo"); @@ -907,56 +975,6 @@ describe("plugin registry facade", () => { expect(second.snapshot.hostContractVersion).toBe("2026.4.26"); }); - it("clears the process registry memo after persisted registry writes", async () => { - const stateDir = makeTempDir(); - const env = hermeticEnv(); - await writePersistedInstalledPluginIndex(createPersistableIndex("first"), { stateDir }); - - const first = loadPluginRegistrySnapshotWithMetadata({ stateDir, env }); - await writePersistedInstalledPluginIndex(createPersistableIndex("second"), { stateDir }); - const second = loadPluginRegistrySnapshotWithMetadata({ stateDir, env }); - - expect(first.source).toBe("persisted"); - expect(second.source).toBe("persisted"); - expectSnapshotPluginIds(first.snapshot, ["first"]); - expectSnapshotPluginIds(second.snapshot, ["second"]); - }); - - it("reloads externally changed persisted state after the metadata lifecycle is cleared", async () => { - const stateDir = makeTempDir(); - const env = hermeticEnv(); - await writePersistedInstalledPluginIndex(createPersistableIndex("first"), { stateDir }); - const first = loadPluginRegistrySnapshotWithMetadata({ stateDir, env }); - const external = createPersistableIndex("second-external"); - runOpenClawStateWriteTransaction( - ({ db }) => { - db.prepare( - ` - UPDATE installed_plugin_index - SET plugins_json = ?, - install_records_json = ?, - diagnostics_json = ?, - updated_at_ms = ? - WHERE index_key = 'installed-plugin-index' - `, - ).run( - JSON.stringify(external.plugins), - JSON.stringify(external.installRecords), - JSON.stringify(external.diagnostics), - Date.now(), - ); - }, - { env: { ...env, OPENCLAW_STATE_DIR: stateDir } }, - ); - clearPluginMetadataLifecycleCaches(); - const second = loadPluginRegistrySnapshotWithMetadata({ stateDir, env }); - - expect(first.source).toBe("persisted"); - expect(second.source).toBe("persisted"); - expectSnapshotPluginIds(first.snapshot, ["first"]); - expectSnapshotPluginIds(second.snapshot, ["second-external"]); - }); - it("derives a fresh registry without persisted install records when caller disables persisted reads", async () => { const stateDir = makeTempDir(); const rootDir = makeTempDir(); From 74858fa463069afd4ec12905690c57d5aad08c74 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 2 Aug 2026 02:48:56 +0800 Subject: [PATCH 08/28] test(ui): cover bounded realtime Talk entries --- .../chat/realtime-talk-conversation.test.ts | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/ui/src/pages/chat/realtime-talk-conversation.test.ts b/ui/src/pages/chat/realtime-talk-conversation.test.ts index 9811a79342c7..02911429dc7a 100644 --- a/ui/src/pages/chat/realtime-talk-conversation.test.ts +++ b/ui/src/pages/chat/realtime-talk-conversation.test.ts @@ -128,6 +128,99 @@ describe("realtime Talk conversation", () => { ]); }); + it("bounds streamed assistant delta growth while retaining useful context", () => { + let state = createRealtimeTalkConversationState(); + const opening = "Opening context stays visible. "; + + state = updateRealtimeTalkConversation(state, { + role: "assistant", + text: `${opening}${"a".repeat(7_900)}`, + final: false, + nowMs: 1, + }); + state = updateRealtimeTalkConversation(state, { + role: "assistant", + text: "b".repeat(500), + final: false, + nowMs: 2, + }); + state = updateRealtimeTalkConversation(state, { + role: "assistant", + text: `${"c".repeat(500)}NEWEST`, + final: false, + nowMs: 3, + }); + + expect(state.entries[0]?.text.length).toBeLessThanOrEqual(8_000); + expect(state.entries[0]?.text.startsWith(opening)).toBe(true); + expect(state.entries[0]?.text).toContain("\n…\n"); + expect(state.entries[0]?.text.split("\n…\n")).toHaveLength(2); + expect(state.entries[0]?.text.endsWith("NEWEST")).toBe(true); + }); + + it("replaces a bounded assistant stream with the authoritative final transcript", () => { + let state = createRealtimeTalkConversationState(); + const opening = "Original opening context. "; + + state = updateRealtimeTalkConversation(state, { + role: "assistant", + text: `${opening}${"draft ".repeat(1_600)}`, + final: false, + nowMs: 1, + }); + expect(state.entries[0]?.text).toContain("\n…\n"); + + state = updateRealtimeTalkConversation(state, { + role: "assistant", + text: `${opening}corrected ${"final ".repeat(1_600)}DONE`, + final: true, + nowMs: 2, + }); + + expect(state.entries[0]?.text.length).toBeLessThanOrEqual(8_000); + expect(state.entries[0]?.text.startsWith(`${opening}corrected `)).toBe(true); + expect(state.entries[0]?.text).not.toContain("draft "); + expect(state.entries[0]?.text.endsWith("DONE")).toBe(true); + expect(state.entries[0]?.isStreaming).toBe(false); + }); + + it("does not expose dangling surrogates at a bounded transcript edge", () => { + let state = createRealtimeTalkConversationState(); + const transcript = `${"a".repeat(8_000)}🚀${"b".repeat(7_740)}`; + + state = updateRealtimeTalkConversation(state, { + role: "assistant", + text: transcript, + final: true, + nowMs: 1, + }); + + const text = state.entries[0]?.text ?? ""; + expect(text.length).toBeLessThanOrEqual(8_000); + expect(text).not.toMatch( + /(?:[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { + let state = createRealtimeTalkConversationState(); + + state = updateRealtimeTalkConversation(state, { + role, + text: `Useful opening. ${"x".repeat(9_000)}NEWEST`, + final: true, + nowMs: 1, + }); + + expect(state.entries[0]?.text.length).toBeLessThanOrEqual(8_000); + expect(state.entries[0]?.text.startsWith("Useful opening. ")).toBe(true); + expect(state.entries[0]?.text.endsWith("NEWEST")).toBe(true); + expect(state.entries[0]?.isStreaming).toBe(false); + }, + ); + it("keeps alternating realtime turns as separate bubbles", () => { let state = createRealtimeTalkConversationState(); From 9a120f364fd008f10be2b912248b5479ac0eca69 Mon Sep 17 00:00:00 2001 From: RileyJJY <0668000974@xydigit.com> Date: Sun, 2 Aug 2026 02:55:52 +0800 Subject: [PATCH 09/28] fix(irc): strip markdown from outbound text (#112961) --- extensions/irc/src/send.test.ts | 73 +++++++++++++++++++++++++++++++++ extensions/irc/src/send.ts | 9 ++-- 2 files changed, 77 insertions(+), 5 deletions(-) diff --git a/extensions/irc/src/send.test.ts b/extensions/irc/src/send.test.ts index 09bb36edc79c..e18cf5522dd7 100644 --- a/extensions/irc/src/send.test.ts +++ b/extensions/irc/src/send.test.ts @@ -10,11 +10,13 @@ const hoisted = vi.hoisted(() => { const loadConfig = vi.fn(); const resolveMarkdownTableMode = vi.fn(() => "preserve"); const convertMarkdownTables = vi.fn((text: string) => text); + const stripMarkdown = vi.fn((text: string) => text); const record = vi.fn(); return { loadConfig, resolveMarkdownTableMode, convertMarkdownTables, + stripMarkdown, record, normalizeIrcMessagingTarget: vi.fn((value: string) => value.trim()), connectIrcClient: vi.fn(), @@ -47,6 +49,14 @@ vi.mock("openclaw/plugin-sdk/plugin-config-runtime", async () => { string, unknown >; + return original; +}); + +vi.mock("openclaw/plugin-sdk/markdown-table-runtime", async () => { + const original = (await vi.importActual("openclaw/plugin-sdk/markdown-table-runtime")) as Record< + string, + unknown + >; return { ...original, resolveMarkdownTableMode: hoisted.resolveMarkdownTableMode, @@ -61,6 +71,7 @@ vi.mock("openclaw/plugin-sdk/text-chunking", async () => { return { ...original, convertMarkdownTables: hoisted.convertMarkdownTables, + stripMarkdown: hoisted.stripMarkdown, }; }); @@ -71,6 +82,7 @@ function resetHoistedMocks() { hoisted.loadConfig.mockReset(); hoisted.resolveMarkdownTableMode.mockReset().mockReturnValue("preserve"); hoisted.convertMarkdownTables.mockReset().mockImplementation((text: string) => text); + hoisted.stripMarkdown.mockReset().mockImplementation((text: string) => text); hoisted.record.mockReset(); hoisted.normalizeIrcMessagingTarget .mockReset() @@ -85,6 +97,7 @@ afterAll(() => { vi.doUnmock("./connect-options.js"); vi.doUnmock("./protocol.js"); vi.doUnmock("openclaw/plugin-sdk/plugin-config-runtime"); + vi.doUnmock("openclaw/plugin-sdk/markdown-table-runtime"); vi.doUnmock("openclaw/plugin-sdk/text-chunking"); vi.resetModules(); }); @@ -159,6 +172,39 @@ describe("sendMessageIrc cfg threading", () => { }); }); + it("strips markdown after table conversion before sending to IRC", async () => { + const providedCfg = { + channels: { + irc: { + host: "irc.example.com", + nick: "openclaw", + }, + }, + } as unknown as CoreConfig; + const client = { + isReady: vi.fn(() => true), + sendPrivmsg: vi.fn(), + } as unknown as IrcClient; + hoisted.resolveMarkdownTableMode.mockReturnValue("bullets"); + hoisted.convertMarkdownTables.mockReturnValue("**Status**\n- [docs](https://example.com)"); + hoisted.stripMarkdown.mockReturnValue("Status\n- docs (https://example.com)"); + + await sendMessageIrc("#room", " | a |\n| - |\n| **docs** | ", { + cfg: providedCfg, + client, + }); + + expect(hoisted.convertMarkdownTables).toHaveBeenCalledWith( + "| a |\n| - |\n| **docs** |", + "bullets", + ); + expect(hoisted.stripMarkdown).toHaveBeenCalledWith("**Status**\n- [docs](https://example.com)"); + expect(client.sendPrivmsg).toHaveBeenCalledWith( + "#room", + "Status\n- docs (https://example.com)", + ); + }); + it("fails hard when cfg is omitted", async () => { const client = { isReady: vi.fn(() => true), @@ -254,6 +300,33 @@ describe("sendMessageIrc cfg threading", () => { }); }); + it("rejects stripped-empty replies before adding reply metadata", async () => { + const providedCfg = { + channels: { + irc: { + host: "irc.example.com", + nick: "openclaw", + }, + }, + } as unknown as CoreConfig; + const client = { + isReady: vi.fn(() => true), + sendPrivmsg: vi.fn(), + } as unknown as IrcClient; + hoisted.stripMarkdown.mockReturnValue(""); + + await expect( + sendMessageIrc("#room", "#", { + cfg: providedCfg, + client, + replyTo: "irc-parent-1", + }), + ).rejects.toThrow("Message must be non-empty for IRC sends"); + + expect(client.sendPrivmsg).not.toHaveBeenCalled(); + expect(hoisted.record).not.toHaveBeenCalled(); + }); + it("declares message adapter durable text, media, and reply with receipt proofs", async () => { const providedCfg = { channels: { diff --git a/extensions/irc/src/send.ts b/extensions/irc/src/send.ts index 2776c6d0be05..4bc2efc069b8 100644 --- a/extensions/irc/src/send.ts +++ b/extensions/irc/src/send.ts @@ -5,7 +5,7 @@ import { } from "openclaw/plugin-sdk/channel-outbound"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime"; import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime"; -import { convertMarkdownTables } from "openclaw/plugin-sdk/text-chunking"; +import { convertMarkdownTables, stripMarkdown } from "openclaw/plugin-sdk/text-chunking"; import { resolveIrcAccount } from "./accounts.js"; import type { IrcClient } from "./client.js"; import { connectIrcClient } from "./client.js"; @@ -78,12 +78,11 @@ export async function sendMessageIrc( channel: "irc", accountId: account.accountId, }); - const prepared = convertMarkdownTables(text.trim(), tableMode); - const payload = opts.replyTo ? `${prepared}\n\n[reply:${opts.replyTo}]` : prepared; - - if (!payload.trim()) { + const prepared = stripMarkdown(convertMarkdownTables(text.trim(), tableMode)); + if (!prepared.trim()) { throw new Error("Message must be non-empty for IRC sends"); } + const payload = opts.replyTo ? `${prepared}\n\n[reply:${opts.replyTo}]` : prepared; const client = opts.client; if (client?.isReady()) { From b0ec8bbdfae5f29e5e5b31a55645d189f5df69c4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 11:55:59 -0700 Subject: [PATCH 10/28] fix(cli): explain filtered plugin policy without unsafe recovery (#117556) Co-authored-by: Peter Steinberger --- src/cli/plugins-list-command.test.ts | 151 +++++++++++++++++++++++++++ src/cli/plugins-list-command.ts | 14 ++- 2 files changed, 160 insertions(+), 5 deletions(-) diff --git a/src/cli/plugins-list-command.test.ts b/src/cli/plugins-list-command.test.ts index 40b8e7bdf0f2..dd59ef998be9 100644 --- a/src/cli/plugins-list-command.test.ts +++ b/src/cli/plugins-list-command.test.ts @@ -1,5 +1,6 @@ // Plugins list command tests cover plugin list command execution and output. import { afterEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { OutputRuntimeEnv } from "../runtime.js"; function createJsonRuntime(writes: unknown[]): OutputRuntimeEnv { @@ -14,6 +15,65 @@ function createJsonRuntime(writes: unknown[]): OutputRuntimeEnv { }; } +type SnapshotPlugin = { + id: string; + enabled: boolean; + commands?: string[]; + agentHarnessIds?: string[]; +}; + +function mockPluginListSnapshot(plugins: SnapshotPlugin[], config: OpenClawConfig = {}): void { + vi.doMock("../config/config.js", () => ({ + getRuntimeConfig: () => config, + })); + vi.doMock("../plugins/status-snapshot.js", () => ({ + buildPluginRegistrySnapshotReport: () => ({ + workspaceDir: "/workspace", + registrySource: "config", + registryDiagnostics: [], + plugins, + diagnostics: [], + }), + })); +} + +function mockHumanListModules(importedModules: string[] = []): void { + vi.doMock("../plugins/source-display.js", () => { + importedModules.push("source-display"); + return { + formatPluginSourceForTable: vi.fn(), + resolvePluginSourceRoots: vi.fn(), + }; + }); + vi.doMock("../../packages/terminal-core/src/table.js", () => { + importedModules.push("table"); + return { + getTerminalTableWidth: vi.fn(), + renderTable: vi.fn(), + }; + }); + vi.doMock("../../packages/terminal-core/src/theme.js", () => { + importedModules.push("theme"); + return { + theme: { + muted: (value: string) => value, + }, + }; + }); + vi.doMock("./command-format.js", () => { + importedModules.push("command-format"); + return { + formatCliCommand: (value: string) => `formatted(${value})`, + }; + }); + vi.doMock("./plugins-list-format.js", () => { + importedModules.push("plugins-list-format"); + return { + formatPluginLine: vi.fn(), + }; + }); +} + describe("runPluginsListCommand", () => { afterEach(() => { vi.doUnmock("../config/config.js"); @@ -22,6 +82,8 @@ describe("runPluginsListCommand", () => { vi.doUnmock("../plugins/source-display.js"); vi.doUnmock("../terminal/table.js"); vi.doUnmock("../terminal/theme.js"); + vi.doUnmock("../../packages/terminal-core/src/table.js"); + vi.doUnmock("../../packages/terminal-core/src/theme.js"); vi.doUnmock("./command-format.js"); vi.doUnmock("./plugins-list-format.js"); vi.resetModules(); @@ -114,4 +176,93 @@ describe("runPluginsListCommand", () => { }, ]); }); + + it.each([ + { label: "normal", options: { enabled: true } }, + { label: "verbose", options: { enabled: true, verbose: true } }, + ])( + "explains an empty enabled-only $label list when plugins are installed", + async ({ options }) => { + mockPluginListSnapshot([{ id: "disabled-plugin", enabled: false }]); + mockHumanListModules(); + const { runPluginsListCommand } = await import("./plugins-list-command.js"); + const writes: unknown[] = []; + + await runPluginsListCommand(options, createJsonRuntime(writes)); + + expect(writes).toEqual([ + "No enabled plugins found. Run formatted(openclaw plugins list) to inspect installed plugins.", + ]); + }, + ); + + it.each([ + { label: "normal", options: { enabled: true } }, + { label: "verbose", options: { enabled: true, verbose: true } }, + ])("explains a globally disabled $label plugin inventory", async ({ options }) => { + mockPluginListSnapshot([{ id: "disabled-plugin", enabled: false }], { + plugins: { enabled: false }, + }); + mockHumanListModules(); + const { runPluginsListCommand } = await import("./plugins-list-command.js"); + const writes: unknown[] = []; + + await runPluginsListCommand(options, createJsonRuntime(writes)); + + expect(writes).toEqual([ + "No enabled plugins found. Plugins are globally disabled. Run formatted(openclaw plugins list) to inspect installed plugins.", + ]); + }); + + it.each([ + { label: "denylist", config: { plugins: { deny: ["disabled-plugin"] } } }, + { + label: "allowlist", + config: { plugins: { allow: ["allowed-plugin"] } }, + }, + ])("does not suggest a blocked mutation for a $label", async ({ config }) => { + mockPluginListSnapshot([{ id: "disabled-plugin", enabled: false }], config); + mockHumanListModules(); + const { runPluginsListCommand } = await import("./plugins-list-command.js"); + const writes: unknown[] = []; + + await runPluginsListCommand({ enabled: true }, createJsonRuntime(writes)); + + expect(writes).toEqual([ + "No enabled plugins found. Run formatted(openclaw plugins list) to inspect installed plugins.", + ]); + }); + + it("keeps install guidance when an enabled-only list has no installed plugins", async () => { + mockPluginListSnapshot([]); + mockHumanListModules(); + const { runPluginsListCommand } = await import("./plugins-list-command.js"); + const writes: unknown[] = []; + + await runPluginsListCommand({ enabled: true }, createJsonRuntime(writes)); + + expect(writes).toEqual([ + "No plugins found. Run formatted(openclaw plugins install ) to add one, or formatted(openclaw plugins list --json) to inspect raw discovery state.", + ]); + }); + + it("keeps empty enabled-only JSON lazy when every installed plugin is disabled", async () => { + const importedHumanModules: string[] = []; + mockPluginListSnapshot([{ id: "disabled-plugin", enabled: false }]); + mockHumanListModules(importedHumanModules); + const { runPluginsListCommand } = await import("./plugins-list-command.js"); + const writes: unknown[] = []; + + await runPluginsListCommand({ enabled: true, json: true }, createJsonRuntime(writes)); + + expect(importedHumanModules).toEqual([]); + expect(writes).toEqual([ + { + workspaceDir: "/workspace", + registry: { source: "config", diagnostics: [] }, + plugins: [], + diagnostics: [], + }, + ]); + }); }); diff --git a/src/cli/plugins-list-command.ts b/src/cli/plugins-list-command.ts index 8ab4c35fd85c..9cbbf05101b1 100644 --- a/src/cli/plugins-list-command.ts +++ b/src/cli/plugins-list-command.ts @@ -76,11 +76,15 @@ export async function runPluginsListCommand( } = await loadHumanListModules(); if (list.length === 0) { - runtime.log( - theme.muted( - `No plugins found. Run ${formatCliCommand("openclaw plugins install ")} to add one, or ${formatCliCommand("openclaw plugins list --json")} to inspect raw discovery state.`, - ), - ); + const message = + opts.enabled && report.plugins.length > 0 + ? `${ + cfg.plugins?.enabled === false + ? "No enabled plugins found. Plugins are globally disabled." + : "No enabled plugins found." + } Run ${formatCliCommand("openclaw plugins list")} to inspect installed plugins.` + : `No plugins found. Run ${formatCliCommand("openclaw plugins install ")} to add one, or ${formatCliCommand("openclaw plugins list --json")} to inspect raw discovery state.`; + runtime.log(theme.muted(message)); return; } From 1e9a1405206d31a68c40c5517f1974eb3bef93cb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 11:57:20 -0700 Subject: [PATCH 11/28] fix(skills): preserve profile in ClawHub command hints (#117555) Co-authored-by: Peter Steinberger --- src/cli/skills-cli.format.ts | 3 +- src/cli/skills-cli.test.ts | 62 +++++++++++++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/cli/skills-cli.format.ts b/src/cli/skills-cli.format.ts index b48479e9de19..b1fe75918b90 100644 --- a/src/cli/skills-cli.format.ts +++ b/src/cli/skills-cli.format.ts @@ -36,7 +36,8 @@ function appendClawHubHint(output: string, json?: boolean): string { if (json) { return output; } - return `${output}\n\nTip: use \`openclaw skills search\`, \`openclaw skills install\`, and \`openclaw skills update\` for ClawHub-backed skills.`; + const command = formatCliCommand("openclaw skills"); + return `${output}\n\nTip: use \`${command} search\`, \`${command} install\`, and \`${command} update\` for ClawHub-backed skills.`; } function formatSkillStatus(skill: SkillStatusEntry): string { diff --git a/src/cli/skills-cli.test.ts b/src/cli/skills-cli.test.ts index 4744477c5c45..a52f6b24c439 100644 --- a/src/cli/skills-cli.test.ts +++ b/src/cli/skills-cli.test.ts @@ -1,5 +1,5 @@ // Skills CLI tests cover skill listing, install, and command output behavior. -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { SkillStatusEntry, SkillStatusReport } from "../skills/discovery/status.js"; import { createEmptyInstallChecks } from "./requirements-test-fixtures.js"; import { formatSkillInfo, formatSkillsCheck, formatSkillsList } from "./skills-cli.format.js"; @@ -51,6 +51,66 @@ function createMockReport(skills: SkillStatusEntry[]): SkillStatusReport { } describe("skills-cli", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + describe("ClawHub command hints", () => { + it.each([ + { + name: "named profile", + profile: "work", + container: "", + prefix: "openclaw --profile work", + }, + { + name: "managed container", + profile: "", + container: "demo", + prefix: "openclaw --container demo", + }, + { + name: "default profile", + profile: "default", + container: "", + prefix: "openclaw", + }, + ])("preserves the $name on every human skill surface", ({ profile, container, prefix }) => { + vi.stubEnv("OPENCLAW_PROFILE", profile); + vi.stubEnv("OPENCLAW_CONTAINER_HINT", container); + const report = createMockReport([]); + const outputs = [ + formatSkillsList(report, {}), + formatSkillInfo(report, "missing-skill", {}), + formatSkillsCheck(report, {}), + ]; + + for (const output of outputs) { + for (const action of ["search", "install", "update"]) { + expect(output).toContain(`${prefix} skills ${action}`); + } + } + }); + + it("keeps profile and container guidance out of machine-readable skill output", () => { + vi.stubEnv("OPENCLAW_PROFILE", "work"); + vi.stubEnv("OPENCLAW_CONTAINER_HINT", "demo"); + const report = createMockReport([]); + const outputs = [ + formatSkillsList(report, { json: true }), + formatSkillInfo(report, "missing-skill", { json: true }), + formatSkillsCheck(report, { json: true }), + ]; + + for (const output of outputs) { + expect(() => JSON.parse(output)).not.toThrow(); + expect(output).not.toContain("Tip:"); + expect(output).not.toContain("openclaw --profile"); + expect(output).not.toContain("openclaw --container"); + } + }); + }); + describe("formatSkillsList", () => { it("formats empty skills list", () => { const report = createMockReport([]); From 7563e40e47b737c355c48441a6909456f115a72d Mon Sep 17 00:00:00 2001 From: Javier Ailbirt Date: Sat, 1 Aug 2026 18:59:08 +0000 Subject: [PATCH 12/28] fix(googlechat): drop invalid thread resource names before send (#108324) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reply routing can hand sendGoogleChatMessage a `thread` that is not a valid `spaces/{space}/threads/{thread}` resource name — a bare id, a `spaces/{space}/messages/{message}` name, or a thread from a different (or wrongly-cased) space. The Chat API rejects the whole request with `400 INVALID_ARGUMENT`, so the reply is never delivered even though the agent already produced it (and did any side effects). Guard the send so the `thread` field and the messageReplyOption fallback are applied only when the thread is a well-formed name belonging to the target space; otherwise post to the space as a new thread. Revives the approach of #28153 (auto-closed as stale) and addresses the failure mode behind #64313. --- extensions/googlechat/src/api.ts | 20 +++++++-- extensions/googlechat/src/targets.test.ts | 52 +++++++++++++++++++++++ 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/extensions/googlechat/src/api.ts b/extensions/googlechat/src/api.ts index bd73b05702f8..8bc04101c8e7 100644 --- a/extensions/googlechat/src/api.ts +++ b/extensions/googlechat/src/api.ts @@ -178,6 +178,19 @@ async function fetchBuffer( }); } +/** + * A Google Chat `thread` must be a `spaces/{space}/threads/{thread}` resource + * name that belongs to the target space. Reply routing sometimes yields other + * shapes — a bare id, a `spaces/{space}/messages/{message}` name, or a thread + * from a different (or wrongly-cased) space — and passing any of those makes the + * Chat API reject the whole send with `400 INVALID_ARGUMENT`. Accept only a + * well-formed, same-space thread name; callers drop the rest so the message + * still delivers to the space (as a new thread) instead of failing outright. + */ +function isUsableGoogleChatThreadName(thread: string, space: string): boolean { + return /^spaces\/[^/]+\/threads\/[^/]+$/.test(thread) && thread.startsWith(`${space}/threads/`); +} + export async function sendGoogleChatMessage(params: { account: ResolvedGoogleChatAccount; space: string; @@ -186,6 +199,7 @@ export async function sendGoogleChatMessage(params: { cardsV2?: GoogleChatCardV2[]; }): Promise<{ messageName?: string; threadName?: string } | null> { const { account, space, text, thread, cardsV2 } = params; + const usableThread = thread && isUsableGoogleChatThreadName(thread, space) ? thread : undefined; if ( text && (!cardsV2 || cardsV2.length === 0) && @@ -200,11 +214,11 @@ export async function sendGoogleChatMessage(params: { if (cardsV2 && cardsV2.length > 0) { body.cardsV2 = cardsV2; } - if (thread) { - body.thread = { name: thread }; + if (usableThread) { + body.thread = { name: usableThread }; } const urlObj = new URL(`${CHAT_API_BASE}/${space}/messages`); - if (thread) { + if (usableThread) { urlObj.searchParams.set("messageReplyOption", "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD"); } const url = urlObj.toString(); diff --git a/extensions/googlechat/src/targets.test.ts b/extensions/googlechat/src/targets.test.ts index d9db2f68e865..a315bf0cba0c 100644 --- a/extensions/googlechat/src/targets.test.ts +++ b/extensions/googlechat/src/targets.test.ts @@ -568,6 +568,58 @@ describe("sendGoogleChatMessage", () => { expect(String(url)).not.toContain("messageReplyOption="); }); + it.each([ + ["a bare id", "113887189178345237288721356"], + ["a thread key without prefix", "pytxeqyhqck"], + ["a message resource name", "spaces/AAA/messages/1720896000000.000000"], + ["a space resource name", "spaces/AAA"], + ["a thread from a different space", "spaces/BBB/threads/xyz"], + ])( + "drops an invalid thread resource name (%s) and posts to the space", + async (_label, badThread) => { + const fetchMock = stubSuccessfulSend("spaces/AAA/messages/126"); + + const result = await sendGoogleChatMessage({ + account, + space: "spaces/AAA", + text: "hello", + thread: badThread, + }); + + const url = mockCallArg(fetchMock); + const init = mockCallArg(fetchMock, 0, 1) as RequestInit | undefined; + // Invalid thread must not be forwarded, and the reply option must be omitted + // so the Chat API accepts the send instead of returning 400 INVALID_ARGUMENT. + expect(String(url)).not.toContain("messageReplyOption="); + if (typeof init?.body !== "string") { + throw new Error("Expected Google Chat request body"); + } + const body = JSON.parse(init.body) as { thread?: unknown }; + expect(body.thread).toBeUndefined(); + expect(result).toEqual({ messageName: "spaces/AAA/messages/126" }); + }, + ); + + it("keeps a valid same-space thread resource name", async () => { + const fetchMock = stubSuccessfulSend("spaces/AAA/messages/127", "spaces/AAA/threads/xyz"); + + await sendGoogleChatMessage({ + account, + space: "spaces/AAA", + text: "hello", + thread: "spaces/AAA/threads/xyz", + }); + + const url = mockCallArg(fetchMock); + const init = mockCallArg(fetchMock, 0, 1) as RequestInit | undefined; + expect(String(url)).toContain("messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD"); + if (typeof init?.body !== "string") { + throw new Error("Expected Google Chat request body"); + } + const body = JSON.parse(init.body) as { thread?: { name?: unknown } }; + expect(body.thread?.name).toBe("spaces/AAA/threads/xyz"); + }); + it("sends cardsV2 with the text fallback", async () => { const fetchMock = stubSuccessfulSend("spaces/AAA/messages/125"); const cardsV2 = [ From 71b35c3e1db158b21983fde1e456dd6f0b6b3797 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 11:59:31 -0700 Subject: [PATCH 13/28] fix(openai): keep embedding identity stable across upgrades (#117557) Co-authored-by: Peter Steinberger --- .../openai/memory-embedding-adapter.test.ts | 111 +++++++++++++++++- extensions/openai/memory-embedding-adapter.ts | 22 +++- 2 files changed, 130 insertions(+), 3 deletions(-) diff --git a/extensions/openai/memory-embedding-adapter.test.ts b/extensions/openai/memory-embedding-adapter.test.ts index 2e0f40c8896e..2442e2b32149 100644 --- a/extensions/openai/memory-embedding-adapter.test.ts +++ b/extensions/openai/memory-embedding-adapter.test.ts @@ -1,6 +1,10 @@ // Openai tests cover memory embedding adapter plugin behavior. -import type { MemoryEmbeddingProvider } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + resolveRemoteEmbeddingBearerClient, + type MemoryEmbeddingProvider, +} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings"; +import { hashText } from "openclaw/plugin-sdk/memory-core-host-engine-storage"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ createOpenAiEmbeddingProvider: vi.fn(), @@ -27,6 +31,10 @@ const provider: MemoryEmbeddingProvider = { }; describe("OpenAI memory embedding adapter", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + beforeEach(() => { mocks.createOpenAiEmbeddingProvider.mockReset(); mocks.runOpenAiEmbeddingBatches.mockClear(); @@ -43,6 +51,105 @@ describe("OpenAI memory embedding adapter", () => { }); }); + it("keeps native OpenAI embedding cache identity stable across OpenClaw versions", async () => { + const createForVersion = async (version: string) => { + vi.stubEnv("OPENCLAW_VERSION", version); + const client = await resolveRemoteEmbeddingBearerClient({ + provider: "openai", + defaultBaseUrl: "https://api.openai.com/v1", + options: { + config: { models: {} } as never, + model: "text-embedding-3-small", + remote: { apiKey: "fixture-secret" }, + }, + }); + mocks.createOpenAiEmbeddingProvider.mockResolvedValueOnce({ + provider, + client: { ...client, model: "text-embedding-3-small" }, + }); + const result = await openAiMemoryEmbeddingProviderAdapter.create({ + config: {} as never, + provider: "openai", + model: "text-embedding-3-small", + fallback: "none", + }); + return { headers: client.headers, cacheKeyData: result.runtime?.cacheKeyData }; + }; + + const previous = await createForVersion("2026.7.1"); + const current = await createForVersion("2026.7.2"); + + expect(previous.headers).toMatchObject({ + Authorization: "Bearer fixture-secret", + version: "2026.7.1", + "User-Agent": "openclaw/2026.7.1", + }); + expect(current.headers).toMatchObject({ + Authorization: "Bearer fixture-secret", + version: "2026.7.2", + "User-Agent": "openclaw/2026.7.2", + }); + expect(current.cacheKeyData).toEqual(previous.cacheKeyData); + expect(hashText(JSON.stringify(current.cacheKeyData))).toBe( + hashText(JSON.stringify(previous.cacheKeyData)), + ); + expect(current.cacheKeyData).toMatchObject({ + provider: "openai", + baseUrl: "https://api.openai.com/v1", + model: "text-embedding-3-small", + headers: [ + ["Content-Type", "application/json"], + ["originator", "openclaw"], + ], + }); + expect(JSON.stringify(current.cacheKeyData)).not.toContain("fixture-secret"); + }); + + it("preserves custom endpoint tenant and version-like cache identity headers", async () => { + const createForTenant = async (tenant: string) => { + const client = await resolveRemoteEmbeddingBearerClient({ + provider: "bailian-embedding", + defaultBaseUrl: "https://embeddings.example/v1", + options: { + config: { models: {} } as never, + model: "text-embedding-v3", + remote: { + apiKey: "fixture-secret", + headers: { + "X-Tenant": tenant, + version: "tenant-api-v2", + "User-Agent": "tenant-client/2", + }, + }, + }, + }); + mocks.createOpenAiEmbeddingProvider.mockResolvedValueOnce({ + provider, + client: { ...client, model: "text-embedding-v3" }, + }); + return await openAiMemoryEmbeddingProviderAdapter.create({ + config: {} as never, + provider: "bailian-embedding", + model: "text-embedding-v3", + fallback: "none", + }); + }; + + const first = await createForTenant("tenant-a"); + const second = await createForTenant("tenant-b"); + const headers = first.runtime?.cacheKeyData?.headers; + + expect(headers).toEqual( + expect.arrayContaining([ + ["X-Tenant", "tenant-a"], + ["version", "tenant-api-v2"], + ["User-Agent", "tenant-client/2"], + ]), + ); + expect(first.runtime?.cacheKeyData).not.toEqual(second.runtime?.cacheKeyData); + expect(JSON.stringify(first.runtime?.cacheKeyData)).not.toContain("fixture-secret"); + }); + it("sends document input_type in OpenAI batch embedding requests", async () => { const result = await openAiMemoryEmbeddingProviderAdapter.create({ config: {} as never, diff --git a/extensions/openai/memory-embedding-adapter.ts b/extensions/openai/memory-embedding-adapter.ts index 43b41e8fccf8..7046092bcebb 100644 --- a/extensions/openai/memory-embedding-adapter.ts +++ b/extensions/openai/memory-embedding-adapter.ts @@ -11,6 +11,23 @@ import { DEFAULT_OPENAI_EMBEDDING_MODEL, } from "./embedding-provider.js"; +function resolveEmbeddingCacheExcludedHeaders(providerId: string, baseUrl: string): string[] { + const excludedHeaders = ["authorization"]; + if (providerId !== "openai") { + return excludedHeaders; + } + try { + if (new URL(baseUrl).hostname.toLowerCase().replace(/\.+$/, "") === "api.openai.com") { + // Native attribution changes on every upgrade; cache identity must describe embeddings, + // not the OpenClaw build that requested them. + excludedHeaders.push("version", "user-agent"); + } + } catch { + // Invalid URLs are handled by the embedding client; keep existing custom-header identity. + } + return excludedHeaders; +} + export const openAiMemoryEmbeddingProviderAdapter: MemoryEmbeddingProviderAdapter = { id: "openai", defaultModel: DEFAULT_OPENAI_EMBEDDING_MODEL, @@ -37,7 +54,10 @@ export const openAiMemoryEmbeddingProviderAdapter: MemoryEmbeddingProviderAdapte model: client.model, outputDimensionality: client.outputDimensionality, documentInputType: client.documentInputType ?? client.inputType, - headers: sanitizeEmbeddingCacheHeaders(client.headers, ["authorization"]), + headers: sanitizeEmbeddingCacheHeaders( + client.headers, + resolveEmbeddingCacheExcludedHeaders(resolvedProvider, client.baseUrl), + ), }, batchEmbed: async (batch) => { const inputType = client.documentInputType ?? client.inputType; From 7facf157e67d7902dd645f33026a253e006ed0e0 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 2 Aug 2026 02:59:32 +0800 Subject: [PATCH 14/28] test(openai): parse realtime websocket frames safely --- .../openai/realtime-audio-buffer-ownership.test.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/extensions/openai/realtime-audio-buffer-ownership.test.ts b/extensions/openai/realtime-audio-buffer-ownership.test.ts index 0f496a264222..dd26c050f524 100644 --- a/extensions/openai/realtime-audio-buffer-ownership.test.ts +++ b/extensions/openai/realtime-audio-buffer-ownership.test.ts @@ -1,12 +1,21 @@ import { once } from "node:events"; import type { RealtimeVoiceBridge } from "openclaw/plugin-sdk/realtime-voice"; import { describe, expect, it, vi } from "vitest"; -import WebSocket, { WebSocketServer } from "ws"; +import WebSocket, { type RawData, WebSocketServer } from "ws"; import { OpenAIQuicksilverVoiceBridge } from "./realtime-quicksilver-bridge.js"; import { buildOpenAIRealtimeVoiceProvider } from "./realtime-voice-provider.js"; type RealtimeProviderKind = "native" | "gpt-live"; +function parseWebSocketMessage(data: RawData): Record { + const bytes = Buffer.isBuffer(data) + ? data + : Array.isArray(data) + ? Buffer.concat(data) + : Buffer.from(data); + return JSON.parse(bytes.toString("utf8")) as Record; +} + async function withRealtimeProvider( kind: RealtimeProviderKind, prepareAudio: (bridge: RealtimeVoiceBridge) => void, @@ -21,7 +30,7 @@ async function withRealtimeProvider( const received: Array> = []; server.once("connection", (socket) => { socket.on("message", (payload) => { - const event = JSON.parse(payload.toString()) as Record; + const event = parseWebSocketMessage(payload); received.push(event); if (event.type === "session.update") { socket.send( From a7c52ef6c1806bedc0c9f4f1c482c4a2ac164c1b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 2 Aug 2026 03:02:21 +0800 Subject: [PATCH 15/28] fix(ui): harden Talk transcript marker bounds --- .../pages/chat/realtime-talk-conversation.test.ts | 15 +++++++++++++++ ui/src/pages/chat/realtime-talk-conversation.ts | 10 ++++++---- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/ui/src/pages/chat/realtime-talk-conversation.test.ts b/ui/src/pages/chat/realtime-talk-conversation.test.ts index 02911429dc7a..08a44cae000c 100644 --- a/ui/src/pages/chat/realtime-talk-conversation.test.ts +++ b/ui/src/pages/chat/realtime-talk-conversation.test.ts @@ -202,6 +202,21 @@ describe("realtime Talk conversation", () => { ); }); + it("does not trust a natural truncation marker outside the bounded prefix", () => { + let state = createRealtimeTalkConversationState(); + + state = updateRealtimeTalkConversation(state, { + role: "assistant", + text: `${"a".repeat(7_998)}\n…\n${"b".repeat(500)}NEWEST`, + final: true, + nowMs: 1, + }); + + expect(state.entries[0]?.text.length).toBeLessThanOrEqual(8_000); + expect(state.entries[0]?.text.startsWith("a".repeat(256))).toBe(true); + expect(state.entries[0]?.text.endsWith("NEWEST")).toBe(true); + }); + it.each(["user", "assistant"] as const)( "bounds oversized final %s entries while retaining the newest text", (role) => { diff --git a/ui/src/pages/chat/realtime-talk-conversation.ts b/ui/src/pages/chat/realtime-talk-conversation.ts index 87d4ef5f81a7..04fbd493b041 100644 --- a/ui/src/pages/chat/realtime-talk-conversation.ts +++ b/ui/src/pages/chat/realtime-talk-conversation.ts @@ -282,10 +282,12 @@ function boundRealtimeConversationText(text: string): string { // the newest tail for the visible conversation. Reuse the original prefix // so repeated streaming deltas do not move the truncation boundary. const markerIndex = text.indexOf(CONVERSATION_ENTRY_TRUNCATION_MARKER); - const prefix = - markerIndex > 0 - ? text.slice(0, markerIndex) - : sliceUtf16Safe(text, 0, CONVERSATION_ENTRY_PREFIX_CHARS); + const hasBoundedPrefix = + markerIndex >= CONVERSATION_ENTRY_PREFIX_CHARS - 1 && + markerIndex <= CONVERSATION_ENTRY_PREFIX_CHARS; + const prefix = hasBoundedPrefix + ? text.slice(0, markerIndex) + : sliceUtf16Safe(text, 0, CONVERSATION_ENTRY_PREFIX_CHARS); const tailChars = MAX_CONVERSATION_ENTRY_CHARS - prefix.length - CONVERSATION_ENTRY_TRUNCATION_MARKER.length; const tail = sliceUtf16Safe(text, -tailChars); From ebf121af6ab5036beda157722467ef5065ba58c3 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 2 Aug 2026 03:06:19 +0800 Subject: [PATCH 16/28] perf(gateway): mark recovery before model preparation (#117544) * perf(gateway): keep recovery runtime off startup path * perf(gateway): mark recovery before model preparation * test(gateway): lock recovery marking failure order --- .../server-startup-post-attach.test.ts | 56 ++++++++++++++++++- src/gateway/server-startup-post-attach.ts | 33 +++++++---- 2 files changed, 75 insertions(+), 14 deletions(-) diff --git a/src/gateway/server-startup-post-attach.test.ts b/src/gateway/server-startup-post-attach.test.ts index fd11656a7f43..c7fb7ee20231 100644 --- a/src/gateway/server-startup-post-attach.test.ts +++ b/src/gateway/server-startup-post-attach.test.ts @@ -123,8 +123,11 @@ vi.mock("../agents/subagent-registry.js", () => ({ scheduleSubagentOrphanRecovery: hoisted.scheduleSubagentOrphanRecovery, })); -vi.mock("../agents/main-session-restart-recovery.js", () => ({ +vi.mock("../agents/main-session-restart-recovery-marking.js", () => ({ markStartupOrphanedMainSessionsForRecovery: hoisted.markStartupOrphanedMainSessionsForRecovery, +})); + +vi.mock("../agents/main-session-restart-recovery.js", () => ({ scheduleRestartAbortedMainSessionRecovery: hoisted.scheduleRestartAbortedMainSessionRecovery, })); @@ -1812,9 +1815,12 @@ describe("startGatewayPostAttachRuntime", () => { }); }); - it("marks startup main-session orphans before channel startup", async () => { + it("marks startup main-session orphans before model runtime and channel startup", async () => { const events: string[] = []; let releaseMarking: (() => void) | undefined; + const prewarmPrimaryModel = vi.fn(async () => { + events.push("model-runtime"); + }); const startChannels = vi.fn(async () => { events.push("channels"); }); @@ -1835,6 +1841,7 @@ describe("startGatewayPostAttachRuntime", () => { defaultWorkspaceDir: "/tmp/openclaw-workspace", deps: {} as never, startChannels, + prewarmPrimaryModel, log: { warn: vi.fn() }, logHooks: { info: vi.fn(), @@ -1858,11 +1865,54 @@ describe("startGatewayPostAttachRuntime", () => { releaseMarking(); await sidecars; - expect(events).toEqual(["main-session-mark:start", "main-session-mark:done", "channels"]); + expect(events).toEqual([ + "main-session-mark:start", + "main-session-mark:done", + "model-runtime", + "channels", + ]); + expect(prewarmPrimaryModel).toHaveBeenCalledTimes(1); expect(startChannels).toHaveBeenCalledTimes(1); expect(hoisted.scheduleRestartAbortedMainSessionRecovery).not.toHaveBeenCalled(); }); + it("marks startup main-session orphans before propagating model runtime failure", async () => { + const modelRuntimeError = new Error("model runtime unavailable"); + const startChannels = vi.fn(async () => {}); + const prewarmPrimaryModel = vi.fn(async () => { + throw modelRuntimeError; + }); + hoisted.markStartupOrphanedMainSessionsForRecovery.mockResolvedValueOnce({ + marked: 1, + skipped: 0, + }); + + await expect( + startGatewaySidecars({ + cfg: { hooks: { internal: { enabled: false } } } as never, + pluginRegistry: createPostAttachParams().pluginRegistry, + defaultWorkspaceDir: "/tmp/openclaw-workspace", + deps: {} as never, + startChannels, + prewarmPrimaryModel, + log: { warn: vi.fn() }, + logHooks: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + logChannels: { + info: vi.fn(), + error: vi.fn(), + }, + }), + ).rejects.toBe(modelRuntimeError); + + expect(hoisted.markStartupOrphanedMainSessionsForRecovery).toHaveBeenCalledTimes(1); + expect(prewarmPrimaryModel).toHaveBeenCalledTimes(1); + expect(startChannels).not.toHaveBeenCalled(); + }); + it("logs startup main-session marker failures and still starts channels", async () => { const log = { warn: vi.fn() }; const startChannels = vi.fn(async () => {}); diff --git a/src/gateway/server-startup-post-attach.ts b/src/gateway/server-startup-post-attach.ts index d3071265911e..c8cbe4f16ce6 100644 --- a/src/gateway/server-startup-post-attach.ts +++ b/src/gateway/server-startup-post-attach.ts @@ -55,6 +55,10 @@ type GatewayMemoryStartupPolicy = const loadMainSessionRestartRecoveryModule = createLazyRuntimeModule( () => import("../agents/main-session-restart-recovery.js"), ); +// Startup only needs orphan marking; keep resume and delivery runtime out of the pre-channel path. +const loadMainSessionRestartRecoveryMarkingModule = createLazyRuntimeModule( + () => import("../agents/main-session-restart-recovery-marking.js"), +); const loadAgentDefaultsModule = createLazyRuntimeModule(() => import("../agents/defaults.js")); @@ -658,6 +662,24 @@ export async function startGatewaySidecars(params: { const skipChannels = isTruthyEnvValue(process.env.OPENCLAW_SKIP_CHANNELS) || isTruthyEnvValue(process.env.OPENCLAW_SKIP_PROVIDERS); + // These runs were orphaned by the previous Gateway lifecycle. Record that fact + // even if this process later fails model preparation and never starts channels. + await measureStartup(params.startupTrace, "sidecars.main-session-recovery", async () => { + try { + const { markStartupOrphanedMainSessionsForRecovery } = await measureStartup( + params.startupTrace, + "sidecars.main-session-recovery-load", + loadMainSessionRestartRecoveryMarkingModule, + ); + await measureStartup(params.startupTrace, "sidecars.main-session-recovery-scan", () => + markStartupOrphanedMainSessionsForRecovery({ cfg: params.cfg }), + ); + } catch (err) { + params.log.warn( + `main-session startup orphan marking failed before channel startup: ${String(err)}`, + ); + } + }); // Agent RPC remains available when transports are disabled. Publish configured/static facts before // accepting work; live provider catalogs stay advisory and never enter the Gateway lifecycle. await measureStartup(params.startupTrace, "sidecars.model-runtime", () => @@ -671,17 +693,6 @@ export async function startGatewaySidecars(params: { params.prewarmPrimaryModel, ), ); - await measureStartup(params.startupTrace, "sidecars.main-session-recovery", async () => { - try { - const { markStartupOrphanedMainSessionsForRecovery } = - await loadMainSessionRestartRecoveryModule(); - await markStartupOrphanedMainSessionsForRecovery({ cfg: params.cfg }); - } catch (err) { - params.log.warn( - `main-session startup orphan marking failed before channel startup: ${String(err)}`, - ); - } - }); await measureStartup(params.startupTrace, "sidecars.channels", async () => { if (!skipChannels) { try { From c5090b9cf503afebc01a20bc47ac9309d4b3a3e2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 12:09:52 -0700 Subject: [PATCH 17/28] refactor(policy): centralize doctor health-check descriptors (#117578) --- .../policy/src/doctor/check-factory.test.ts | 111 ++++++++ extensions/policy/src/doctor/check-factory.ts | 26 ++ .../policy/src/doctor/scopes/channels.ts | 150 ++++------ extensions/policy/src/doctor/scopes/core.ts | 55 +--- .../policy/src/doctor/scopes/data-auth.ts | 145 +++------- .../src/doctor/scopes/exec-approvals.ts | 120 ++------ .../policy/src/doctor/scopes/gateway.ts | 174 +++--------- .../policy/src/doctor/scopes/model-network.ts | 75 ++--- .../policy/src/doctor/scopes/routing.ts | 64 ++--- .../policy/src/doctor/scopes/sandbox.ts | 152 +++------- extensions/policy/src/doctor/scopes/tools.ts | 262 +++++------------- 11 files changed, 454 insertions(+), 880 deletions(-) create mode 100644 extensions/policy/src/doctor/check-factory.test.ts create mode 100644 extensions/policy/src/doctor/check-factory.ts diff --git a/extensions/policy/src/doctor/check-factory.test.ts b/extensions/policy/src/doctor/check-factory.test.ts new file mode 100644 index 000000000000..e06db1d66d37 --- /dev/null +++ b/extensions/policy/src/doctor/check-factory.test.ts @@ -0,0 +1,111 @@ +import type { + HealthCheckContext, + HealthFinding, + HealthRepairContext, + HealthRepairResult, +} from "openclaw/plugin-sdk/health"; +import { describe, expect, it, vi } from "vitest"; +import { createPolicyScopedChecks } from "./check-factory.js"; +import { CHECK_IDS } from "./check-ids.js"; +import type { PolicyEvaluation } from "./types.js"; + +describe("policy scoped health checks", () => { + const evaluation = {} as PolicyEvaluation; + const context = {} as HealthCheckContext; + + it("preserves registration order, descriptions, metadata, and repair capability", () => { + const repair = vi.fn(async (): Promise => ({ changes: [] })); + const checks = createPolicyScopedChecks( + { + evaluatePolicy: vi.fn(async () => evaluation), + findingsForCheck: vi.fn(() => []), + }, + [ + [CHECK_IDS.policyMissingFile, "The policy file exists."], + [CHECK_IDS.policyDeniedChannelProvider, "Channels satisfy policy.", repair], + ], + ); + + expect( + checks.map(({ id, description, kind, source }) => ({ id, description, kind, source })), + ).toEqual([ + { + id: CHECK_IDS.policyMissingFile, + description: "The policy file exists.", + kind: "plugin", + source: "policy", + }, + { + id: CHECK_IDS.policyDeniedChannelProvider, + description: "Channels satisfy policy.", + kind: "plugin", + source: "policy", + }, + ]); + expect(Object.hasOwn(checks[0]!, "repair")).toBe(false); + expect(Object.hasOwn(checks[1]!, "repair")).toBe(true); + expect(checks[1]).toMatchObject({ repair }); + }); + + it("awaits the policy evaluation before selecting findings for the same check", async () => { + const findings: HealthFinding[] = [ + { checkId: CHECK_IDS.policyMissingFile, severity: "error", message: "Missing policy." }, + ]; + let releaseEvaluation!: () => void; + const evaluationGate = new Promise((resolve) => { + releaseEvaluation = resolve; + }); + const evaluatePolicy = vi.fn(async (received: HealthCheckContext) => { + expect(received).toBe(context); + await evaluationGate; + return evaluation; + }); + const findingsForCheck = vi.fn(() => findings); + const [check] = createPolicyScopedChecks({ evaluatePolicy, findingsForCheck }, [ + [CHECK_IDS.policyMissingFile, "The policy file exists."], + ]); + + const result = check!.detect(context); + expect(evaluatePolicy).toHaveBeenCalledOnce(); + expect(findingsForCheck).not.toHaveBeenCalled(); + + releaseEvaluation(); + await expect(result).resolves.toBe(findings); + expect(findingsForCheck).toHaveBeenCalledExactlyOnceWith( + evaluation, + CHECK_IDS.policyMissingFile, + ); + }); + + it("propagates evaluation failures without selecting findings", async () => { + const failure = new Error("policy evaluation failed"); + const evaluatePolicy = vi.fn(async () => { + throw failure; + }); + const findingsForCheck = vi.fn(() => []); + const [check] = createPolicyScopedChecks({ evaluatePolicy, findingsForCheck }, [ + [CHECK_IDS.policyMissingFile, "The policy file exists."], + ]); + + await expect(check!.detect(context)).rejects.toBe(failure); + expect(findingsForCheck).not.toHaveBeenCalled(); + }); + + it("retains the original repair callback and its exact result promise", () => { + const repairContext = {} as HealthRepairContext; + const findings: HealthFinding[] = []; + const pendingRepair = Promise.resolve({ changes: ["repaired"] }); + const repair = vi.fn(() => pendingRepair); + const [check] = createPolicyScopedChecks( + { + evaluatePolicy: vi.fn(async () => evaluation), + findingsForCheck: vi.fn(() => []), + }, + [[CHECK_IDS.policyDeniedChannelProvider, "Channels satisfy policy.", repair]], + ); + + expect(check).toMatchObject({ repair }); + expect(check!.repair!(repairContext, findings)).toBe(pendingRepair); + expect(repair).toHaveBeenCalledExactlyOnceWith(repairContext, findings); + }); +}); diff --git a/extensions/policy/src/doctor/check-factory.ts b/extensions/policy/src/doctor/check-factory.ts new file mode 100644 index 000000000000..2beca23a7cfa --- /dev/null +++ b/extensions/policy/src/doctor/check-factory.ts @@ -0,0 +1,26 @@ +import type { HealthCheck } from "openclaw/plugin-sdk/health"; +import type { POLICY_CHECK_IDS } from "./check-ids.js"; +import type { PolicyDoctorCheckDeps } from "./types.js"; + +type PolicyDoctorCheckDefinition = readonly [ + id: (typeof POLICY_CHECK_IDS)[number], + description: string, + repair?: NonNullable, +]; + +export function createPolicyScopedChecks( + deps: Pick, + definitions: readonly PolicyDoctorCheckDefinition[], +): readonly HealthCheck[] { + const { evaluatePolicy, findingsForCheck } = deps; + return definitions.map(([id, description, repair]) => ({ + id, + kind: "plugin", + description, + source: "policy", + async detect(ctx) { + return findingsForCheck(await evaluatePolicy(ctx), id); + }, + ...(repair ? { repair } : {}), + })); +} diff --git a/extensions/policy/src/doctor/scopes/channels.ts b/extensions/policy/src/doctor/scopes/channels.ts index 7f0c72d6f081..05d6bed85fdc 100644 --- a/extensions/policy/src/doctor/scopes/channels.ts +++ b/extensions/policy/src/doctor/scopes/channels.ts @@ -1,6 +1,7 @@ // Policy doctor health-check factories for one policy scope. import type { HealthCheck } from "openclaw/plugin-sdk/health"; import { repairPolicyAutomaticNarrower } from "../automatic-repairs.js"; +import { createPolicyScopedChecks } from "../check-factory.js"; import { CHECK_IDS } from "../check-ids.js"; import type { PolicyDoctorCheckDeps } from "../types.js"; @@ -10,109 +11,66 @@ export function createPolicyChannelProviderChecks( const { channelIdsFromFindings, disableChannels, - evaluatePolicy, - findingsForCheck, workspaceRepairsDisabledResult, workspaceRepairsEnabled, } = deps; - const policyChannelsDeniedProviderCheck: HealthCheck = { - id: CHECK_IDS.policyDeniedChannelProvider, - kind: "plugin", - description: "Configured channels satisfy policy deny rules.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyDeniedChannelProvider); - }, - async repair(ctx, findings) { - if (!workspaceRepairsEnabled(ctx)) { - return workspaceRepairsDisabledResult("channel config"); - } - const channelIds = channelIdsFromFindings(findings); - if (channelIds.length === 0) { + return createPolicyScopedChecks(deps, [ + [ + CHECK_IDS.policyDeniedChannelProvider, + "Configured channels satisfy policy deny rules.", + async (ctx, findings) => { + if (!workspaceRepairsEnabled(ctx)) { + return workspaceRepairsDisabledResult("channel config"); + } + const channelIds = channelIdsFromFindings(findings); + if (channelIds.length === 0) { + return { + status: "skipped", + reason: "no channel findings matched a configurable channel", + changes: [], + }; + } + const next = disableChannels(ctx.cfg, channelIds); + if (next.changed.length === 0) { + return { + status: "skipped", + reason: "matching channels were already disabled or missing", + changes: [], + }; + } return { - status: "skipped", - reason: "no channel findings matched a configurable channel", - changes: [], + config: next.config, + changes: next.changed.map( + (id) => `Disabled channels.${id}.enabled for policy conformance.`, + ), }; - } - const next = disableChannels(ctx.cfg, channelIds); - if (next.changed.length === 0) { - return { - status: "skipped", - reason: "matching channels were already disabled or missing", - changes: [], - }; - } - return { - config: next.config, - changes: next.changed.map( - (id) => `Disabled channels.${id}.enabled for policy conformance.`, - ), - }; - }, - }; - - return [policyChannelsDeniedProviderCheck]; + }, + ], + ]); } export function createPolicyIngressChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] { - const { evaluatePolicy, findingsForCheck } = deps; - - const policyIngressDmPolicyUnapprovedCheck: HealthCheck = { - id: CHECK_IDS.policyIngressDmPolicyUnapproved, - kind: "plugin", - description: "Channel direct-message access policy matches ingress requirements.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyIngressDmPolicyUnapproved); - }, - }; - const policyIngressDmScopeUnapprovedCheck: HealthCheck = { - id: CHECK_IDS.policyIngressDmScopeUnapproved, - kind: "plugin", - description: "Direct-message sessions use the policy-required isolation scope.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyIngressDmScopeUnapproved); - }, - }; - const policyIngressOpenGroupsDeniedCheck: HealthCheck = { - id: CHECK_IDS.policyIngressOpenGroupsDenied, - kind: "plugin", - description: "Channel group access does not use open group policy when denied.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyIngressOpenGroupsDenied); - }, - async repair(ctx, findings) { - return repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyIngressOpenGroupsDenied); - }, - }; - const policyIngressGroupMentionRequiredCheck: HealthCheck = { - id: CHECK_IDS.policyIngressGroupMentionRequired, - kind: "plugin", - description: "Channel group access keeps mention gates enabled when required.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyIngressGroupMentionRequired, - ); - }, - async repair(ctx, findings) { - return repairPolicyAutomaticNarrower( - ctx, - findings, - CHECK_IDS.policyIngressGroupMentionRequired, - ); - }, - }; - - return [ - policyIngressDmPolicyUnapprovedCheck, - policyIngressDmScopeUnapprovedCheck, - policyIngressOpenGroupsDeniedCheck, - policyIngressGroupMentionRequiredCheck, - ]; + return createPolicyScopedChecks(deps, [ + [ + CHECK_IDS.policyIngressDmPolicyUnapproved, + "Channel direct-message access policy matches ingress requirements.", + ], + [ + CHECK_IDS.policyIngressDmScopeUnapproved, + "Direct-message sessions use the policy-required isolation scope.", + ], + [ + CHECK_IDS.policyIngressOpenGroupsDenied, + "Channel group access does not use open group policy when denied.", + async (ctx, findings) => + repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyIngressOpenGroupsDenied), + ], + [ + CHECK_IDS.policyIngressGroupMentionRequired, + "Channel group access keeps mention gates enabled when required.", + async (ctx, findings) => + repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyIngressGroupMentionRequired), + ], + ]); } diff --git a/extensions/policy/src/doctor/scopes/core.ts b/extensions/policy/src/doctor/scopes/core.ts index 7255cc6d0c21..21d93305ad16 100644 --- a/extensions/policy/src/doctor/scopes/core.ts +++ b/extensions/policy/src/doctor/scopes/core.ts @@ -1,52 +1,17 @@ // Policy doctor health-check factories for one policy scope. import type { HealthCheck } from "openclaw/plugin-sdk/health"; +import { createPolicyScopedChecks } from "../check-factory.js"; import { CHECK_IDS } from "../check-ids.js"; import type { PolicyDoctorCheckDeps } from "../types.js"; export function createPolicyCoreChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] { - const { evaluatePolicy, findingsForCheck } = deps; - - const policyMissingFileCheck: HealthCheck = { - id: CHECK_IDS.policyMissingFile, - kind: "plugin", - description: "The enabled Policy plugin has a policy file to verify.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyMissingFile); - }, - }; - const policyHashMismatchCheck: HealthCheck = { - id: CHECK_IDS.policyHashMismatch, - kind: "plugin", - description: "The policy file matches the configured expected hash.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyHashMismatch); - }, - }; - const policyAttestationMismatchCheck: HealthCheck = { - id: CHECK_IDS.policyAttestationMismatch, - kind: "plugin", - description: "The current policy check matches the accepted attestation.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyAttestationMismatch); - }, - }; - const policyInvalidFileCheck: HealthCheck = { - id: CHECK_IDS.policyInvalidFile, - kind: "plugin", - description: "The enabled policy file parses before policy checks run.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyInvalidFile); - }, - }; - - return [ - policyMissingFileCheck, - policyInvalidFileCheck, - policyHashMismatchCheck, - policyAttestationMismatchCheck, - ]; + return createPolicyScopedChecks(deps, [ + [CHECK_IDS.policyMissingFile, "The enabled Policy plugin has a policy file to verify."], + [CHECK_IDS.policyInvalidFile, "The enabled policy file parses before policy checks run."], + [CHECK_IDS.policyHashMismatch, "The policy file matches the configured expected hash."], + [ + CHECK_IDS.policyAttestationMismatch, + "The current policy check matches the accepted attestation.", + ], + ]); } diff --git a/extensions/policy/src/doctor/scopes/data-auth.ts b/extensions/policy/src/doctor/scopes/data-auth.ts index 866e6480f23a..9bb0fbf15248 100644 --- a/extensions/policy/src/doctor/scopes/data-auth.ts +++ b/extensions/policy/src/doctor/scopes/data-auth.ts @@ -1,118 +1,49 @@ // Policy doctor health-check factories for one policy scope. import type { HealthCheck } from "openclaw/plugin-sdk/health"; import { repairPolicyAutomaticNarrower } from "../automatic-repairs.js"; +import { createPolicyScopedChecks } from "../check-factory.js"; import { CHECK_IDS } from "../check-ids.js"; import type { PolicyDoctorCheckDeps } from "../types.js"; export function createPolicyDataAuthChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] { - const { evaluatePolicy, findingsForCheck } = deps; - - const policyDataHandlingTelemetryContentCaptureCheck: HealthCheck = { - id: CHECK_IDS.policyDataHandlingTelemetryContentCapture, - kind: "plugin", - description: "Telemetry content capture remains disabled when policy denies it.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyDataHandlingTelemetryContentCapture, - ); - }, - repair(ctx, findings) { - return repairPolicyAutomaticNarrower( - ctx, - findings, - CHECK_IDS.policyDataHandlingTelemetryContentCapture, - ); - }, - }; - const policyDataHandlingSessionRetentionNotEnforcedCheck: HealthCheck = { - id: CHECK_IDS.policyDataHandlingSessionRetentionNotEnforced, - kind: "plugin", - description: "Session retention maintenance is enforced when policy requires it.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyDataHandlingSessionRetentionNotEnforced, - ); - }, - }; - const policyDataHandlingSessionTranscriptMemoryCheck: HealthCheck = { - id: CHECK_IDS.policyDataHandlingSessionTranscriptMemory, - kind: "plugin", - description: "Session transcript memory indexing remains disabled when policy denies it.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyDataHandlingSessionTranscriptMemory, - ); - }, - }; - const policySecretsUnmanagedProviderCheck: HealthCheck = { - id: CHECK_IDS.policySecretsUnmanagedProvider, - kind: "plugin", - description: + return createPolicyScopedChecks(deps, [ + [ + CHECK_IDS.policyDataHandlingTelemetryContentCapture, + "Telemetry content capture remains disabled when policy denies it.", + (ctx, findings) => + repairPolicyAutomaticNarrower( + ctx, + findings, + CHECK_IDS.policyDataHandlingTelemetryContentCapture, + ), + ], + [ + CHECK_IDS.policyDataHandlingSessionRetentionNotEnforced, + "Session retention maintenance is enforced when policy requires it.", + ], + [ + CHECK_IDS.policyDataHandlingSessionTranscriptMemory, + "Session transcript memory indexing remains disabled when policy denies it.", + ], + [ + CHECK_IDS.policySecretsUnmanagedProvider, "OpenClaw config SecretRefs use configured secret providers when policy requires managed providers.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policySecretsUnmanagedProvider); - }, - }; - const policySecretsDeniedProviderSourceCheck: HealthCheck = { - id: CHECK_IDS.policySecretsDeniedProviderSource, - kind: "plugin", - description: + ], + [ + CHECK_IDS.policySecretsDeniedProviderSource, "OpenClaw config secret providers and SecretRefs do not use sources denied by policy.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policySecretsDeniedProviderSource, - ); - }, - }; - const policySecretsInsecureProviderCheck: HealthCheck = { - id: CHECK_IDS.policySecretsInsecureProvider, - kind: "plugin", - description: + ], + [ + CHECK_IDS.policySecretsInsecureProvider, "Configured secret providers do not opt into insecure posture unless policy allows it.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policySecretsInsecureProvider); - }, - }; - const policyAuthProfileInvalidMetadataCheck: HealthCheck = { - id: CHECK_IDS.policyAuthProfileInvalidMetadata, - kind: "plugin", - description: "OpenClaw config auth profiles declare required provider and mode metadata.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyAuthProfileInvalidMetadata, - ); - }, - }; - const policyAuthProfileUnapprovedModeCheck: HealthCheck = { - id: CHECK_IDS.policyAuthProfileUnapprovedMode, - kind: "plugin", - description: "OpenClaw config auth profile modes stay within the policy allowlist.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyAuthProfileUnapprovedMode); - }, - }; - - return [ - policyDataHandlingTelemetryContentCaptureCheck, - policyDataHandlingSessionRetentionNotEnforcedCheck, - policyDataHandlingSessionTranscriptMemoryCheck, - policySecretsUnmanagedProviderCheck, - policySecretsDeniedProviderSourceCheck, - policySecretsInsecureProviderCheck, - policyAuthProfileInvalidMetadataCheck, - policyAuthProfileUnapprovedModeCheck, - ]; + ], + [ + CHECK_IDS.policyAuthProfileInvalidMetadata, + "OpenClaw config auth profiles declare required provider and mode metadata.", + ], + [ + CHECK_IDS.policyAuthProfileUnapprovedMode, + "OpenClaw config auth profile modes stay within the policy allowlist.", + ], + ]); } diff --git a/extensions/policy/src/doctor/scopes/exec-approvals.ts b/extensions/policy/src/doctor/scopes/exec-approvals.ts index bf9f10d08f79..605502fa7860 100644 --- a/extensions/policy/src/doctor/scopes/exec-approvals.ts +++ b/extensions/policy/src/doctor/scopes/exec-approvals.ts @@ -1,100 +1,40 @@ // Policy doctor health-check factories for one policy scope. import type { HealthCheck } from "openclaw/plugin-sdk/health"; +import { createPolicyScopedChecks } from "../check-factory.js"; import { CHECK_IDS } from "../check-ids.js"; import type { PolicyDoctorCheckDeps } from "../types.js"; export function createPolicyExecApprovalChecks( deps: PolicyDoctorCheckDeps, ): readonly HealthCheck[] { - const { evaluatePolicy, findingsForCheck } = deps; - - const policyExecApprovalsMissingCheck: HealthCheck = { - id: CHECK_IDS.policyExecApprovalsMissing, - kind: "plugin", - description: "Required exec approvals artifact is present for policy conformance.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyExecApprovalsMissing); - }, - }; - const policyExecApprovalsInvalidCheck: HealthCheck = { - id: CHECK_IDS.policyExecApprovalsInvalid, - kind: "plugin", - description: "Exec approvals artifact parses before policy checks run.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyExecApprovalsInvalid); - }, - }; - const policyExecApprovalsDefaultSecurityUnapprovedCheck: HealthCheck = { - id: CHECK_IDS.policyExecApprovalsDefaultSecurityUnapproved, - kind: "plugin", - description: "Exec approval defaults use a policy-approved security mode.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyExecApprovalsDefaultSecurityUnapproved, - ); - }, - }; - const policyExecApprovalsAgentSecurityUnapprovedCheck: HealthCheck = { - id: CHECK_IDS.policyExecApprovalsAgentSecurityUnapproved, - kind: "plugin", - description: "Per-agent exec approval settings use policy-approved security modes.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyExecApprovalsAgentSecurityUnapproved, - ); - }, - }; - const policyExecApprovalsAutoAllowSkillsEnabledCheck: HealthCheck = { - id: CHECK_IDS.policyExecApprovalsAutoAllowSkillsEnabled, - kind: "plugin", - description: + return createPolicyScopedChecks(deps, [ + [ + CHECK_IDS.policyExecApprovalsMissing, + "Required exec approvals artifact is present for policy conformance.", + ], + [ + CHECK_IDS.policyExecApprovalsInvalid, + "Exec approvals artifact parses before policy checks run.", + ], + [ + CHECK_IDS.policyExecApprovalsDefaultSecurityUnapproved, + "Exec approval defaults use a policy-approved security mode.", + ], + [ + CHECK_IDS.policyExecApprovalsAgentSecurityUnapproved, + "Per-agent exec approval settings use policy-approved security modes.", + ], + [ + CHECK_IDS.policyExecApprovalsAutoAllowSkillsEnabled, "Exec approval agents do not implicitly auto-allow skill CLIs unless policy allows it.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyExecApprovalsAutoAllowSkillsEnabled, - ); - }, - }; - const policyExecApprovalsAllowlistMissingCheck: HealthCheck = { - id: CHECK_IDS.policyExecApprovalsAllowlistMissing, - kind: "plugin", - description: "Exec approval allowlists include every pattern required by policy.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyExecApprovalsAllowlistMissing, - ); - }, - }; - const policyExecApprovalsAllowlistUnexpectedCheck: HealthCheck = { - id: CHECK_IDS.policyExecApprovalsAllowlistUnexpected, - kind: "plugin", - description: "Exec approval allowlists do not contain patterns outside policy.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyExecApprovalsAllowlistUnexpected, - ); - }, - }; - - return [ - policyExecApprovalsMissingCheck, - policyExecApprovalsInvalidCheck, - policyExecApprovalsDefaultSecurityUnapprovedCheck, - policyExecApprovalsAgentSecurityUnapprovedCheck, - policyExecApprovalsAutoAllowSkillsEnabledCheck, - policyExecApprovalsAllowlistMissingCheck, - policyExecApprovalsAllowlistUnexpectedCheck, - ]; + ], + [ + CHECK_IDS.policyExecApprovalsAllowlistMissing, + "Exec approval allowlists include every pattern required by policy.", + ], + [ + CHECK_IDS.policyExecApprovalsAllowlistUnexpected, + "Exec approval allowlists do not contain patterns outside policy.", + ], + ]); } diff --git a/extensions/policy/src/doctor/scopes/gateway.ts b/extensions/policy/src/doctor/scopes/gateway.ts index 8418e9143b38..1916a212cef5 100644 --- a/extensions/policy/src/doctor/scopes/gateway.ts +++ b/extensions/policy/src/doctor/scopes/gateway.ts @@ -3,140 +3,58 @@ import { isRecord } from "openclaw/plugin-sdk/channel-secret-basic-runtime"; import type { HealthCheck, HealthFinding } from "openclaw/plugin-sdk/health"; import type { PolicyEvidence } from "../../policy-state.js"; import { repairPolicyAutomaticNarrower } from "../automatic-repairs.js"; +import { createPolicyScopedChecks } from "../check-factory.js"; import { CHECK_IDS } from "../check-ids.js"; import { previewPolicyReviewRequiredRepair } from "../review-required-repairs.js"; import type { PolicyDoctorCheckDeps } from "../types.js"; import { readPolicyBoolean, readStringList } from "../utils.js"; export function createPolicyGatewayChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] { - const { evaluatePolicy, findingsForCheck } = deps; - - const policyGatewayNonLoopbackBindCheck: HealthCheck = { - id: CHECK_IDS.policyGatewayNonLoopbackBind, - kind: "plugin", - description: "Gateway bind posture matches policy exposure requirements.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyGatewayNonLoopbackBind); - }, - repair(ctx, findings) { - return previewPolicyReviewRequiredRepair( - ctx, - findings, - CHECK_IDS.policyGatewayNonLoopbackBind, - ); - }, - }; - const policyGatewayAuthDisabledCheck: HealthCheck = { - id: CHECK_IDS.policyGatewayAuthDisabled, - kind: "plugin", - description: "Gateway authentication remains enabled when required by policy.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyGatewayAuthDisabled); - }, - }; - const policyGatewayRateLimitMissingCheck: HealthCheck = { - id: CHECK_IDS.policyGatewayRateLimitMissing, - kind: "plugin", - description: "Gateway authentication rate-limit posture is explicit when required by policy.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyGatewayRateLimitMissing); - }, - }; - const policyGatewayControlUiInsecureCheck: HealthCheck = { - id: CHECK_IDS.policyGatewayControlUiInsecure, - kind: "plugin", - description: "Gateway Control UI insecure exposure toggles remain disabled by policy.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyGatewayControlUiInsecure); - }, - repair(ctx, findings) { - return repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyGatewayControlUiInsecure); - }, - }; - const policyGatewayTailscaleFunnelCheck: HealthCheck = { - id: CHECK_IDS.policyGatewayTailscaleFunnel, - kind: "plugin", - description: "Gateway Tailscale Funnel exposure matches policy.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyGatewayTailscaleFunnel); - }, - }; - const policyGatewayRemoteEnabledCheck: HealthCheck = { - id: CHECK_IDS.policyGatewayRemoteEnabled, - kind: "plugin", - description: "Remote gateway mode matches policy.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyGatewayRemoteEnabled); - }, - repair(ctx, findings) { - return repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyGatewayRemoteEnabled); - }, - }; - const policyGatewayHttpEndpointEnabledCheck: HealthCheck = { - id: CHECK_IDS.policyGatewayHttpEndpointEnabled, - kind: "plugin", - description: "Gateway HTTP API endpoints match policy.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyGatewayHttpEndpointEnabled, - ); - }, - repair(ctx, findings) { - return repairPolicyAutomaticNarrower( - ctx, - findings, - CHECK_IDS.policyGatewayHttpEndpointEnabled, - ); - }, - }; - const policyGatewayHttpUrlFetchUnrestrictedCheck: HealthCheck = { - id: CHECK_IDS.policyGatewayHttpUrlFetchUnrestricted, - kind: "plugin", - description: "Gateway HTTP URL-fetch inputs have allowlists when required by policy.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyGatewayHttpUrlFetchUnrestricted, - ); - }, - }; - const policyGatewayNodeCommandDeniedCheck: HealthCheck = { - id: CHECK_IDS.policyGatewayNodeCommandDenied, - kind: "plugin", - description: "Gateway node command allowlists match policy.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyGatewayNodeCommandDenied); - }, - repair(ctx, findings) { - return previewPolicyReviewRequiredRepair( - ctx, - findings, - CHECK_IDS.policyGatewayNodeCommandDenied, - ); - }, - }; - - return [ - policyGatewayNonLoopbackBindCheck, - policyGatewayAuthDisabledCheck, - policyGatewayRateLimitMissingCheck, - policyGatewayControlUiInsecureCheck, - policyGatewayTailscaleFunnelCheck, - policyGatewayRemoteEnabledCheck, - policyGatewayHttpEndpointEnabledCheck, - policyGatewayHttpUrlFetchUnrestrictedCheck, - policyGatewayNodeCommandDeniedCheck, - ]; + return createPolicyScopedChecks(deps, [ + [ + CHECK_IDS.policyGatewayNonLoopbackBind, + "Gateway bind posture matches policy exposure requirements.", + (ctx, findings) => + previewPolicyReviewRequiredRepair(ctx, findings, CHECK_IDS.policyGatewayNonLoopbackBind), + ], + [ + CHECK_IDS.policyGatewayAuthDisabled, + "Gateway authentication remains enabled when required by policy.", + ], + [ + CHECK_IDS.policyGatewayRateLimitMissing, + "Gateway authentication rate-limit posture is explicit when required by policy.", + ], + [ + CHECK_IDS.policyGatewayControlUiInsecure, + "Gateway Control UI insecure exposure toggles remain disabled by policy.", + (ctx, findings) => + repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyGatewayControlUiInsecure), + ], + [CHECK_IDS.policyGatewayTailscaleFunnel, "Gateway Tailscale Funnel exposure matches policy."], + [ + CHECK_IDS.policyGatewayRemoteEnabled, + "Remote gateway mode matches policy.", + (ctx, findings) => + repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyGatewayRemoteEnabled), + ], + [ + CHECK_IDS.policyGatewayHttpEndpointEnabled, + "Gateway HTTP API endpoints match policy.", + (ctx, findings) => + repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyGatewayHttpEndpointEnabled), + ], + [ + CHECK_IDS.policyGatewayHttpUrlFetchUnrestricted, + "Gateway HTTP URL-fetch inputs have allowlists when required by policy.", + ], + [ + CHECK_IDS.policyGatewayNodeCommandDenied, + "Gateway node command allowlists match policy.", + (ctx, findings) => + previewPolicyReviewRequiredRepair(ctx, findings, CHECK_IDS.policyGatewayNodeCommandDenied), + ], + ]); } export function gatewayExposureFindings( diff --git a/extensions/policy/src/doctor/scopes/model-network.ts b/extensions/policy/src/doctor/scopes/model-network.ts index 6ec13e916da4..17ce92db5791 100644 --- a/extensions/policy/src/doctor/scopes/model-network.ts +++ b/extensions/policy/src/doctor/scopes/model-network.ts @@ -2,6 +2,7 @@ import type { HealthCheck, HealthFinding } from "openclaw/plugin-sdk/health"; import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared"; import type { PolicyEvidence } from "../../policy-state.js"; +import { createPolicyScopedChecks } from "../check-factory.js"; import { CHECK_IDS } from "../check-ids.js"; import type { PolicyDoctorCheckDeps } from "../types.js"; import { readPolicyBoolean, readStringList } from "../utils.js"; @@ -9,61 +10,25 @@ import { readPolicyBoolean, readStringList } from "../utils.js"; export function createPolicyModelNetworkChecks( deps: PolicyDoctorCheckDeps, ): readonly HealthCheck[] { - const { evaluatePolicy, findingsForCheck } = deps; - - const policyMcpDeniedServerCheck: HealthCheck = { - id: CHECK_IDS.policyDeniedMcpServer, - kind: "plugin", - description: "Configured MCP servers do not match policy deny rules.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyDeniedMcpServer); - }, - }; - const policyMcpUnapprovedServerCheck: HealthCheck = { - id: CHECK_IDS.policyUnapprovedMcpServer, - kind: "plugin", - description: "Configured MCP servers do not match policy allow rules.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyUnapprovedMcpServer); - }, - }; - const policyModelsDeniedProviderCheck: HealthCheck = { - id: CHECK_IDS.policyDeniedModelProvider, - kind: "plugin", - description: "Configured model providers do not match policy deny rules.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyDeniedModelProvider); - }, - }; - const policyModelsUnapprovedProviderCheck: HealthCheck = { - id: CHECK_IDS.policyUnapprovedModelProvider, - kind: "plugin", - description: "Configured model providers do not match policy allow rules.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyUnapprovedModelProvider); - }, - }; - const policyNetworkPrivateAccessCheck: HealthCheck = { - id: CHECK_IDS.policyPrivateNetworkAccess, - kind: "plugin", - description: "Network SSRF policy settings match private-network requirements.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyPrivateNetworkAccess); - }, - }; - - return [ - policyMcpDeniedServerCheck, - policyMcpUnapprovedServerCheck, - policyModelsDeniedProviderCheck, - policyModelsUnapprovedProviderCheck, - policyNetworkPrivateAccessCheck, - ]; + return createPolicyScopedChecks(deps, [ + [CHECK_IDS.policyDeniedMcpServer, "Configured MCP servers do not match policy deny rules."], + [ + CHECK_IDS.policyUnapprovedMcpServer, + "Configured MCP servers do not match policy allow rules.", + ], + [ + CHECK_IDS.policyDeniedModelProvider, + "Configured model providers do not match policy deny rules.", + ], + [ + CHECK_IDS.policyUnapprovedModelProvider, + "Configured model providers do not match policy allow rules.", + ], + [ + CHECK_IDS.policyPrivateNetworkAccess, + "Network SSRF policy settings match private-network requirements.", + ], + ]); } export function mcpServerFindings( diff --git a/extensions/policy/src/doctor/scopes/routing.ts b/extensions/policy/src/doctor/scopes/routing.ts index f40241ac2ce7..c7b3bc0801a1 100644 --- a/extensions/policy/src/doctor/scopes/routing.ts +++ b/extensions/policy/src/doctor/scopes/routing.ts @@ -1,51 +1,25 @@ import type { HealthCheck } from "openclaw/plugin-sdk/health"; +import { createPolicyScopedChecks } from "../check-factory.js"; import { CHECK_IDS } from "../check-ids.js"; import type { PolicyDoctorCheckDeps } from "../types.js"; export function createPolicyRoutingChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] { - const { evaluatePolicy, findingsForCheck } = deps; - return [ - { - id: CHECK_IDS.policyRoutingBindingsRequired, - kind: "plugin", - description: "Routing policy has at least one channel route binding when required.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyRoutingBindingsRequired); - }, - }, - { - id: CHECK_IDS.policyRoutingBindingChannelUnconfigured, - kind: "plugin", - description: "Route bindings name channels present in configuration.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyRoutingBindingChannelUnconfigured, - ); - }, - }, - { - id: CHECK_IDS.policyRoutingAgentMismatch, - kind: "plugin", - description: "Authored routing probes resolve to their expected agents.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyRoutingAgentMismatch); - }, - }, - { - id: CHECK_IDS.policyRoutingMatchKindMismatch, - kind: "plugin", - description: "Authored routing probes match at their expected specificity.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyRoutingMatchKindMismatch, - ); - }, - }, - ]; + return createPolicyScopedChecks(deps, [ + [ + CHECK_IDS.policyRoutingBindingsRequired, + "Routing policy has at least one channel route binding when required.", + ], + [ + CHECK_IDS.policyRoutingBindingChannelUnconfigured, + "Route bindings name channels present in configuration.", + ], + [ + CHECK_IDS.policyRoutingAgentMismatch, + "Authored routing probes resolve to their expected agents.", + ], + [ + CHECK_IDS.policyRoutingMatchKindMismatch, + "Authored routing probes match at their expected specificity.", + ], + ]); } diff --git a/extensions/policy/src/doctor/scopes/sandbox.ts b/extensions/policy/src/doctor/scopes/sandbox.ts index 5988614b6fca..9e526da5e73a 100644 --- a/extensions/policy/src/doctor/scopes/sandbox.ts +++ b/extensions/policy/src/doctor/scopes/sandbox.ts @@ -1,123 +1,43 @@ // Policy doctor health-check factories for one policy scope. import type { HealthCheck } from "openclaw/plugin-sdk/health"; +import { createPolicyScopedChecks } from "../check-factory.js"; import { CHECK_IDS } from "../check-ids.js"; import type { PolicyDoctorCheckDeps } from "../types.js"; export function createPolicySandboxChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] { - const { evaluatePolicy, findingsForCheck } = deps; - - const policySandboxModeUnapprovedCheck: HealthCheck = { - id: CHECK_IDS.policySandboxModeUnapproved, - kind: "plugin", - description: "Sandbox mode config satisfies policy requirements.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policySandboxModeUnapproved); - }, - }; - const policySandboxBackendUnapprovedCheck: HealthCheck = { - id: CHECK_IDS.policySandboxBackendUnapproved, - kind: "plugin", - description: "Sandbox backend config satisfies policy requirements.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policySandboxBackendUnapproved); - }, - }; - const policySandboxContainerPostureUnobservableCheck: HealthCheck = { - id: CHECK_IDS.policySandboxContainerPostureUnobservable, - kind: "plugin", - description: "Sandbox container posture policy only targets observable container backends.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policySandboxContainerPostureUnobservable, - ); - }, - }; - const policySandboxContainerHostNetworkDeniedCheck: HealthCheck = { - id: CHECK_IDS.policySandboxContainerHostNetworkDenied, - kind: "plugin", - description: "Sandbox container config avoids host network mode.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policySandboxContainerHostNetworkDenied, - ); - }, - }; - const policySandboxContainerNamespaceJoinDeniedCheck: HealthCheck = { - id: CHECK_IDS.policySandboxContainerNamespaceJoinDenied, - kind: "plugin", - description: "Sandbox container config avoids joining another container network namespace.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policySandboxContainerNamespaceJoinDenied, - ); - }, - }; - const policySandboxContainerMountModeRequiredCheck: HealthCheck = { - id: CHECK_IDS.policySandboxContainerMountModeRequired, - kind: "plugin", - description: "Sandbox container mounts are read-only when policy requires it.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policySandboxContainerMountModeRequired, - ); - }, - }; - const policySandboxContainerRuntimeSocketMountCheck: HealthCheck = { - id: CHECK_IDS.policySandboxContainerRuntimeSocketMount, - kind: "plugin", - description: "Sandbox container mounts avoid host container runtime sockets.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policySandboxContainerRuntimeSocketMount, - ); - }, - }; - const policySandboxContainerUnconfinedProfileCheck: HealthCheck = { - id: CHECK_IDS.policySandboxContainerUnconfinedProfile, - kind: "plugin", - description: "Sandbox container profile config avoids unconfined profiles.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policySandboxContainerUnconfinedProfile, - ); - }, - }; - const policySandboxBrowserCdpSourceRangeMissingCheck: HealthCheck = { - id: CHECK_IDS.policySandboxBrowserCdpSourceRangeMissing, - kind: "plugin", - description: "Sandbox browser CDP config includes a source range when policy requires it.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policySandboxBrowserCdpSourceRangeMissing, - ); - }, - }; - - return [ - policySandboxModeUnapprovedCheck, - policySandboxBackendUnapprovedCheck, - policySandboxContainerPostureUnobservableCheck, - policySandboxContainerHostNetworkDeniedCheck, - policySandboxContainerNamespaceJoinDeniedCheck, - policySandboxContainerMountModeRequiredCheck, - policySandboxContainerRuntimeSocketMountCheck, - policySandboxContainerUnconfinedProfileCheck, - policySandboxBrowserCdpSourceRangeMissingCheck, - ]; + return createPolicyScopedChecks(deps, [ + [CHECK_IDS.policySandboxModeUnapproved, "Sandbox mode config satisfies policy requirements."], + [ + CHECK_IDS.policySandboxBackendUnapproved, + "Sandbox backend config satisfies policy requirements.", + ], + [ + CHECK_IDS.policySandboxContainerPostureUnobservable, + "Sandbox container posture policy only targets observable container backends.", + ], + [ + CHECK_IDS.policySandboxContainerHostNetworkDenied, + "Sandbox container config avoids host network mode.", + ], + [ + CHECK_IDS.policySandboxContainerNamespaceJoinDenied, + "Sandbox container config avoids joining another container network namespace.", + ], + [ + CHECK_IDS.policySandboxContainerMountModeRequired, + "Sandbox container mounts are read-only when policy requires it.", + ], + [ + CHECK_IDS.policySandboxContainerRuntimeSocketMount, + "Sandbox container mounts avoid host container runtime sockets.", + ], + [ + CHECK_IDS.policySandboxContainerUnconfinedProfile, + "Sandbox container profile config avoids unconfined profiles.", + ], + [ + CHECK_IDS.policySandboxBrowserCdpSourceRangeMissing, + "Sandbox browser CDP config includes a source range when policy requires it.", + ], + ]); } diff --git a/extensions/policy/src/doctor/scopes/tools.ts b/extensions/policy/src/doctor/scopes/tools.ts index 087d75f36d12..e977383e3757 100644 --- a/extensions/policy/src/doctor/scopes/tools.ts +++ b/extensions/policy/src/doctor/scopes/tools.ts @@ -1,211 +1,77 @@ // Policy doctor health-check factories for one policy scope. import type { HealthCheck } from "openclaw/plugin-sdk/health"; import { repairPolicyAutomaticNarrower } from "../automatic-repairs.js"; +import { createPolicyScopedChecks } from "../check-factory.js"; import { CHECK_IDS } from "../check-ids.js"; import type { PolicyDoctorCheckDeps } from "../types.js"; export function createPolicyAgentToolChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] { - const { evaluatePolicy, findingsForCheck } = deps; - - const policyAgentsWorkspaceAccessDeniedCheck: HealthCheck = { - id: CHECK_IDS.policyAgentsWorkspaceAccessDenied, - kind: "plugin", - description: "Agent sandbox workspace access matches policy.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyAgentsWorkspaceAccessDenied, - ); - }, - }; - const policyAgentsToolNotDeniedCheck: HealthCheck = { - id: CHECK_IDS.policyAgentsToolNotDenied, - kind: "plugin", - description: "Agent workspace mutation/runtime tools are denied when policy requires it.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyAgentsToolNotDenied); - }, - repair(ctx, findings) { - return repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyAgentsToolNotDenied); - }, - }; - const policyToolsProfileUnapprovedCheck: HealthCheck = { - id: CHECK_IDS.policyToolsProfileUnapproved, - kind: "plugin", - description: "Configured tool profiles match policy allow rules.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsProfileUnapproved); - }, - }; - const policyToolsFsWorkspaceOnlyRequiredCheck: HealthCheck = { - id: CHECK_IDS.policyToolsFsWorkspaceOnlyRequired, - kind: "plugin", - description: "Filesystem tools use workspace-only posture when policy requires it.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyToolsFsWorkspaceOnlyRequired, - ); - }, - }; - const policyToolsExecSecurityUnapprovedCheck: HealthCheck = { - id: CHECK_IDS.policyToolsExecSecurityUnapproved, - kind: "plugin", - description: "Exec tool security mode matches policy allow rules.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyToolsExecSecurityUnapproved, - ); - }, - }; - const policyToolsExecAskUnapprovedCheck: HealthCheck = { - id: CHECK_IDS.policyToolsExecAskUnapproved, - kind: "plugin", - description: "Exec tool ask mode matches policy allow rules.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsExecAskUnapproved); - }, - }; - const policyToolsExecHostUnapprovedCheck: HealthCheck = { - id: CHECK_IDS.policyToolsExecHostUnapproved, - kind: "plugin", - description: "Exec tool host routing matches policy allow rules.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsExecHostUnapproved); - }, - }; - const policyToolsElevatedEnabledCheck: HealthCheck = { - id: CHECK_IDS.policyToolsElevatedEnabled, - kind: "plugin", - description: "Elevated tool mode remains disabled when policy requires it.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsElevatedEnabled); - }, - repair(ctx, findings) { - return repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyToolsElevatedEnabled); - }, - }; - const policyToolsAlsoAllowMissingCheck: HealthCheck = { - id: CHECK_IDS.policyToolsAlsoAllowMissing, - kind: "plugin", - description: "Configured tools.alsoAllow entries include policy expected lists.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsAlsoAllowMissing); - }, - }; - const policyToolsAlsoAllowUnexpectedCheck: HealthCheck = { - id: CHECK_IDS.policyToolsAlsoAllowUnexpected, - kind: "plugin", - description: "Configured tools.alsoAllow entries match policy expected lists.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsAlsoAllowUnexpected); - }, - }; - const policyToolsRequiredDenyMissingCheck: HealthCheck = { - id: CHECK_IDS.policyToolsRequiredDenyMissing, - kind: "plugin", - description: "Configured tool deny lists include tools required by policy.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsRequiredDenyMissing); - }, - repair(ctx, findings) { - return repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyToolsRequiredDenyMissing); - }, - }; - - return [ - policyAgentsWorkspaceAccessDeniedCheck, - policyAgentsToolNotDeniedCheck, - policyToolsProfileUnapprovedCheck, - policyToolsFsWorkspaceOnlyRequiredCheck, - policyToolsExecSecurityUnapprovedCheck, - policyToolsExecAskUnapprovedCheck, - policyToolsExecHostUnapprovedCheck, - policyToolsElevatedEnabledCheck, - policyToolsAlsoAllowMissingCheck, - policyToolsAlsoAllowUnexpectedCheck, - policyToolsRequiredDenyMissingCheck, - ]; + return createPolicyScopedChecks(deps, [ + [CHECK_IDS.policyAgentsWorkspaceAccessDenied, "Agent sandbox workspace access matches policy."], + [ + CHECK_IDS.policyAgentsToolNotDenied, + "Agent workspace mutation/runtime tools are denied when policy requires it.", + (ctx, findings) => + repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyAgentsToolNotDenied), + ], + [CHECK_IDS.policyToolsProfileUnapproved, "Configured tool profiles match policy allow rules."], + [ + CHECK_IDS.policyToolsFsWorkspaceOnlyRequired, + "Filesystem tools use workspace-only posture when policy requires it.", + ], + [ + CHECK_IDS.policyToolsExecSecurityUnapproved, + "Exec tool security mode matches policy allow rules.", + ], + [CHECK_IDS.policyToolsExecAskUnapproved, "Exec tool ask mode matches policy allow rules."], + [CHECK_IDS.policyToolsExecHostUnapproved, "Exec tool host routing matches policy allow rules."], + [ + CHECK_IDS.policyToolsElevatedEnabled, + "Elevated tool mode remains disabled when policy requires it.", + (ctx, findings) => + repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyToolsElevatedEnabled), + ], + [ + CHECK_IDS.policyToolsAlsoAllowMissing, + "Configured tools.alsoAllow entries include policy expected lists.", + ], + [ + CHECK_IDS.policyToolsAlsoAllowUnexpected, + "Configured tools.alsoAllow entries match policy expected lists.", + ], + [ + CHECK_IDS.policyToolsRequiredDenyMissing, + "Configured tool deny lists include tools required by policy.", + (ctx, findings) => + repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyToolsRequiredDenyMissing), + ], + ]); } export function createPolicyToolMetadataChecks( deps: PolicyDoctorCheckDeps, ): readonly HealthCheck[] { - const { evaluatePolicy, findingsForCheck } = deps; - - const policyUnmigratedToolsFileCheck: HealthCheck = { - id: CHECK_IDS.policyUnmigratedToolsFile, - kind: "plugin", - description: "Governed tool declarations have been migrated from TOOLS.md into AGENTS.md.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyUnmigratedToolsFile); - }, - }; - const policyToolsMissingRiskCheck: HealthCheck = { - id: CHECK_IDS.policyMissingToolRisk, - kind: "plugin", - description: "AGENTS.md tool policy entries declare explicit risk levels.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyMissingToolRisk); - }, - }; - const policyToolsUnknownRiskCheck: HealthCheck = { - id: CHECK_IDS.policyUnknownToolRisk, - kind: "plugin", - description: "AGENTS.md tool policy entries use known risk levels.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyUnknownToolRisk); - }, - }; - const policyToolsMissingSensitivityCheck: HealthCheck = { - id: CHECK_IDS.policyMissingToolSensitivity, - kind: "plugin", - description: "AGENTS.md tool policy entries declare default artifact sensitivity.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyMissingToolSensitivity); - }, - }; - const policyToolsUnknownSensitivityCheck: HealthCheck = { - id: CHECK_IDS.policyUnknownToolSensitivity, - kind: "plugin", - description: "AGENTS.md tool policy entries use known sensitivity levels.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyUnknownToolSensitivity); - }, - }; - const policyToolsMissingOwnerCheck: HealthCheck = { - id: CHECK_IDS.policyMissingToolOwner, - kind: "plugin", - description: "AGENTS.md tool policy entries declare an accountable owner.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyMissingToolOwner); - }, - }; - - return [ - policyUnmigratedToolsFileCheck, - policyToolsMissingRiskCheck, - policyToolsUnknownRiskCheck, - policyToolsMissingSensitivityCheck, - policyToolsMissingOwnerCheck, - policyToolsUnknownSensitivityCheck, - ]; + return createPolicyScopedChecks(deps, [ + [ + CHECK_IDS.policyUnmigratedToolsFile, + "Governed tool declarations have been migrated from TOOLS.md into AGENTS.md.", + ], + [ + CHECK_IDS.policyMissingToolRisk, + "AGENTS.md tool policy entries declare explicit risk levels.", + ], + [CHECK_IDS.policyUnknownToolRisk, "AGENTS.md tool policy entries use known risk levels."], + [ + CHECK_IDS.policyMissingToolSensitivity, + "AGENTS.md tool policy entries declare default artifact sensitivity.", + ], + [ + CHECK_IDS.policyMissingToolOwner, + "AGENTS.md tool policy entries declare an accountable owner.", + ], + [ + CHECK_IDS.policyUnknownToolSensitivity, + "AGENTS.md tool policy entries use known sensitivity levels.", + ], + ]); } From bf99e43ee7d3416ef0893f4d0321f5e59f6ee564 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 12:14:23 -0700 Subject: [PATCH 18/28] refactor: consolidate full release child workflows (#117385) --- .github/workflows/full-release-validation.yml | 1243 +++++------------ .../package-acceptance-workflow.test.ts | 516 ++++++- .../plugin-prerelease-test-plan.test.ts | 21 +- test/scripts/release-no-push-workflow.test.ts | 3 +- 4 files changed, 873 insertions(+), 910 deletions(-) diff --git a/.github/workflows/full-release-validation.yml b/.github/workflows/full-release-validation.yml index 25d7c07d63c2..5a811a13e53f 100644 --- a/.github/workflows/full-release-validation.yml +++ b/.github/workflows/full-release-validation.yml @@ -426,43 +426,111 @@ jobs: id: dispatch env: GH_TOKEN: ${{ github.token }} + CHILD_WORKFLOW_KIND: ci TARGET_REF: ${{ inputs.ref }} TARGET_CONTEXT_REF: ${{ inputs.target_context_ref }} TARGET_SHA: ${{ needs.resolve_target.outputs.sha }} CHILD_WORKFLOW_REF: ${{ github.ref_name }} PARENT_WORKFLOW_SHA: ${{ github.sha }} FAIL_FAST: ${{ inputs.fail_fast }} - run: | + run: &full_release_child_dispatch | set -euo pipefail + FAIL_FAST="${FAIL_FAST:-false}" + + gh_with_retry() { + local output status attempt + for attempt in 1 2 3 4 5 6; do + set +e + output="$(gh "$@" 2>&1)" + status=$? + set -e + if [[ "$status" -eq 0 ]]; then + printf '%s\n' "$output" + return 0 + fi + if [[ "$output" == *"Bad credentials"* || "$output" == *"HTTP 401"* || "$output" == *"secondary rate limit"* || "$output" == *"API rate limit"* || "$output" == *"HTTP 429"* || "$output" == *"abuse detection"* || "$output" == *"Sorry. Your account was suspended"* || "$output" =~ $GH_TRANSIENT_SERVER_OR_NETWORK_PATTERN ]]; then + echo "::warning::gh $* failed on attempt ${attempt}: ${output}" >&2 + sleep $((attempt * 10)) + continue + fi + printf '%s\n' "$output" >&2 + return "$status" + done + printf '%s\n' "$output" >&2 + return "$status" + } + + fetch_child_run_json() { + gh_with_retry api "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}" + } + + fetch_child_jobs() { + if [[ "$workflow" == "npm-telegram-beta-e2e.yml" || "$workflow" == "openclaw-performance.yml" ]]; then + gh_with_retry run view "$run_id" --json jobs --jq '.jobs[]' + return + fi + gh_with_retry api --paginate "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/jobs?per_page=100" --jq '.jobs[]' + } + + read_child_run_field() { + local field="$1" + if [[ "$workflow" == "npm-telegram-beta-e2e.yml" || "$workflow" == "openclaw-performance.yml" ]]; then + case "$field" in + head_sha) field=headSha ;; + html_url) field=url ;; + esac + gh_with_retry run view "$run_id" --json "$field" --jq ".${field} // \"\"" + return + fi + fetch_child_run_json | jq -r ".${field} // \"\"" + } + + release_check_blocking_job() { + if [[ "$RELEASE_PROFILE" == "beta" && "$1" == "Run package acceptance / Telegram package acceptance / "* ]]; then + return 1 + fi + case "$1" in + "resolve_target" | \ + "Prepare release package artifact" | \ + "install_smoke_release_checks / "* | \ + "Run package acceptance" | \ + "Run package acceptance / "*) + return 0 + ;; + esac + return 1 + } + + release_checks_advisory_only() { + local run_json="$1" + local verifier_conclusion name saw_advisory failed + verifier_conclusion="$( + jq -r '.jobs[] | select(.name == "Verify release checks") | .conclusion' <<< "$run_json" | + tail -n 1 + )" + if [[ "$verifier_conclusion" != "success" ]]; then + return 1 + fi + saw_advisory=0 + failed=0 + while IFS= read -r name; do + [[ -z "${name// }" ]] && continue + if release_check_blocking_job "$name"; then + echo "::error::${name} is a package-safety Tideclaw alpha release-check lane." + failed=1 + else + saw_advisory=1 + fi + done < <(jq -r '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | .name' <<< "$run_json") + [[ "$saw_advisory" == "1" && "$failed" == "0" ]] + } dispatch_and_wait() { local workflow="$1" local dispatch_run_name="$2" shift 2 + local dispatch_output dispatch_status matches_json match_count run_id status conclusion url poll_count run_json jobs_json child_head_sha encoded_workflow_ref current_workflow_sha - local dispatch_output dispatch_status matches_json match_count run_id status conclusion url poll_count child_head_sha encoded_workflow_ref current_workflow_sha - gh_with_retry() { - local output status attempt - for attempt in 1 2 3 4 5 6; do - set +e - output="$(gh "$@" 2>&1)" - status=$? - set -e - if [[ "$status" -eq 0 ]]; then - printf '%s\n' "$output" - return 0 - fi - if [[ "$output" == *"Bad credentials"* || "$output" == *"HTTP 401"* || "$output" == *"secondary rate limit"* || "$output" == *"API rate limit"* || "$output" == *"HTTP 429"* || "$output" == *"abuse detection"* || "$output" == *"Sorry. Your account was suspended"* || "$output" =~ $GH_TRANSIENT_SERVER_OR_NETWORK_PATTERN ]]; then - echo "::warning::gh $* failed on attempt ${attempt}: ${output}" >&2 - sleep $((attempt * 10)) - continue - fi - printf '%s\n' "$output" >&2 - return "$status" - done - printf '%s\n' "$output" >&2 - return "$status" - } encoded_workflow_ref="$(jq -rn --arg value "$CHILD_WORKFLOW_REF" '$value | @uri')" current_workflow_sha="$( gh_with_retry api "repos/${GITHUB_REPOSITORY}/commits/${encoded_workflow_ref}" --jq .sha @@ -471,13 +539,13 @@ jobs: echo "::error::Child workflow ref ${CHILD_WORKFLOW_REF} moved to ${current_workflow_sha}, expected ${PARENT_WORKFLOW_SHA}; refusing dispatch." >&2 return 1 fi + # A failed dispatch POST can still create a run. Never retry it; recover only by exact run name. set +e dispatch_output="$(gh workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1)" dispatch_status=$? set -e printf '%s\n' "$dispatch_output" - if [[ "$dispatch_status" -ne 0 && ! "$dispatch_output" =~ $GH_TRANSIENT_SERVER_OR_NETWORK_PATTERN ]]; then echo "::error::${workflow} dispatch failed with non-ambiguous status ${dispatch_status}; refusing adoption polling." >&2 exit "$dispatch_status" @@ -504,7 +572,6 @@ jobs: fi sleep 5 done - if [[ -z "$run_id" ]]; then echo "::error::Could not find exact dispatched run ${dispatch_run_name}; dispatch status ${dispatch_status}. The dispatch was not retried to avoid creating a duplicate child." >&2 exit 1 @@ -512,48 +579,21 @@ jobs: if [[ "$dispatch_status" -ne 0 ]]; then echo "::warning::${workflow} dispatch returned status ${dispatch_status}; adopted exact run ${run_id}." >&2 fi - echo "Dispatched ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" echo "run_id=${run_id}" >> "$GITHUB_OUTPUT" - fetch_child_run_json() { - gh_with_retry api "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}" - } - - fetch_child_jobs() { - gh_with_retry api --paginate "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/jobs?per_page=100" --jq '.jobs[]' - } - - fail_fast_failed_jobs() { - if [[ "$FAIL_FAST" != "true" ]]; then - return 0 - fi - local failed_jobs_json - if ! failed_jobs_json="$( - fetch_child_jobs | - jq -s '[.[] | select(.status == "completed" and .conclusion != "success" and .conclusion != "skipped")]' - )"; then - echo "::warning::Could not list ${workflow} child jobs; continuing with authoritative workflow conclusion." - return 0 - fi - if jq -e 'length > 0' <<< "$failed_jobs_json" >/dev/null; then - echo "::error::${workflow} has failed child jobs before the workflow completed; cancelling the remaining matrix." - jq '.[] | {name, conclusion, url: .html_url}' <<< "$failed_jobs_json" - cancel_child - trap - EXIT INT TERM - exit 1 - fi - } - cancel_child() { - if [[ -n "${run_id:-}" ]]; then - echo "Cancelling child workflow ${workflow}: ${run_id}" >&2 - gh run cancel "$run_id" >/dev/null 2>&1 || true + if [[ -n "${active_child_run_id:-}" ]]; then + echo "Cancelling child workflow ${active_child_workflow}: ${active_child_run_id}" >&2 + gh run cancel "$active_child_run_id" >/dev/null 2>&1 || true fi } + # EXIT traps run after function locals unwind; preserve only adopted child identity. + active_child_workflow="$workflow" + active_child_run_id="$run_id" trap cancel_child EXIT INT TERM - child_head_sha="$(fetch_child_run_json | jq -r '.head_sha // ""')" + child_head_sha="$(read_child_run_field head_sha)" if [[ "$child_head_sha" != "$PARENT_WORKFLOW_SHA" ]]; then echo "::error::${workflow} child run used workflow SHA ${child_head_sha}, expected parent workflow SHA ${PARENT_WORKFLOW_SHA}." cancel_child @@ -561,9 +601,70 @@ jobs: exit 1 fi + fail_fast_failed_jobs() { + if [[ "$FAIL_FAST" != "true" ]]; then + return 0 + fi + local failed_jobs_json + if [[ "$workflow" == "openclaw-release-checks.yml" && "$CHILD_WORKFLOW_REF" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then + return 0 + fi + if [[ "$workflow" == "npm-telegram-beta-e2e.yml" ]]; then + failed_jobs_json="$( + gh_with_retry run view "$run_id" --json jobs \ + --jq '[.jobs[] | select(.status == "completed" and .conclusion != "success" and .conclusion != "skipped")]' + )" + elif ! failed_jobs_json="$( + fetch_child_jobs | + jq -s '[.[] | select(.status == "completed" and .conclusion != "success" and .conclusion != "skipped")]' + )"; then + echo "::warning::Could not list ${workflow} child jobs; continuing with authoritative workflow conclusion." + return 0 + fi + if [[ "$workflow" == "openclaw-release-checks.yml" ]]; then + # Advisory QA jobs are owned by the child's status-artifact verifier. + failed_jobs_json="$( + jq '[.[] | select( + ((.name | startswith("Run QA Lab parity lane (")) + or .name == "Run QA Lab parity report" + or (.name | startswith("Run QA Lab runtime-pair lane (")) + or .name == "Verify QA Lab runtime-pair lanes" + or .name == "Run QA Lab live Discord lane" + or .name == "Run QA Lab live WhatsApp lane" + or .name == "Run QA Lab live Slack lane") + | not)]' <<< "$failed_jobs_json" + )" + if [[ "$RELEASE_PROFILE" == "beta" ]]; then + # Beta live-provider and Telegram package checks are advisory; repo E2E is blocking. + failed_jobs_json="$( + jq '[.[] | select( + (((.name | startswith("Run repo/live E2E validation / ")) + and ((.name | contains("Docker live")) + or (.name | contains("Live media suites")) + or (.name | contains("validate_live_provider_suites")) + or (.name | contains("validate_release_live_cache")) + or (.name | contains("prepare_live_test_image")))) + or (.name | startswith("Run package acceptance / Telegram package acceptance / "))) + | not)]' <<< "$failed_jobs_json" + )" + fi + fi + if jq -e 'length > 0' <<< "$failed_jobs_json" >/dev/null; then + if [[ "$workflow" == "npm-telegram-beta-e2e.yml" ]]; then + echo "::error::npm-telegram-beta-e2e.yml has failed child jobs before the workflow completed; cancelling the remaining run." + else + echo "::error::${workflow} has failed child jobs before the workflow completed; cancelling the remaining matrix." + fi + jq '.[] | {name, conclusion, url: (.url // .html_url)}' <<< "$failed_jobs_json" + cancel_child + trap - EXIT INT TERM + exit 1 + fi + } + poll_count=0 while true; do - status="$(fetch_child_run_json | jq -r '.status')" + status="$(read_child_run_field status)" if [[ "$status" == "completed" ]]; then break fi @@ -573,41 +674,204 @@ jobs: fi if (( poll_count % 10 == 0 )); then echo "Still waiting on ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" - fetch_child_jobs | jq 'select(.status != "completed") | {name, status, url: .html_url}' || true + fetch_child_jobs | jq 'select(.status != "completed") | {name, status, url: (.url // .html_url)}' || true fi sleep 60 done trap - EXIT INT TERM - conclusion="$(fetch_child_run_json | jq -r '.conclusion // ""')" - url="$(fetch_child_run_json | jq -r '.html_url')" + if [[ "$workflow" == "openclaw-release-checks.yml" ]]; then + jobs_json="$(fetch_child_jobs | jq -s '{jobs: [.[] | {name, conclusion, url: .html_url}]}')" + run_json="$( + jq -s '.[0] + .[1]' \ + <(fetch_child_run_json | jq '{conclusion: (.conclusion // ""), url: .html_url}') \ + <(printf '%s\n' "$jobs_json") + )" + conclusion="$(jq -r '.conclusion' <<< "$run_json")" + url="$(jq -r '.url' <<< "$run_json")" + else + conclusion="$(read_child_run_field conclusion)" + url="$(read_child_run_field html_url)" + fi echo "${workflow} finished with ${conclusion}: ${url}" echo "url=${url}" >> "$GITHUB_OUTPUT" echo "conclusion=${conclusion}" >> "$GITHUB_OUTPUT" - if [[ "$conclusion" != "success" ]]; then - fetch_child_jobs | jq 'select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url: .html_url}' || true - exit 1 + if [[ "$conclusion" == "success" ]]; then + return 0 fi + if [[ "$workflow" == "openclaw-performance.yml" && "$RELEASE_PROFILE" == "beta" ]]; then + echo "::warning::OpenClaw Performance ended with ${conclusion}; advisory for beta: ${url}" + return 0 + fi + if [[ "$workflow" == "openclaw-release-checks.yml" ]]; then + jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url}' <<< "$run_json" || true + if [[ "$CHILD_WORKFLOW_REF" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]] && release_checks_advisory_only "$run_json"; then + echo "::warning::${workflow} ended with ${conclusion}, but Verify release checks accepted Tideclaw alpha advisory lanes." + return 0 + fi + else + if [[ "$workflow" == "openclaw-performance.yml" ]]; then + echo "::error::OpenClaw Performance ended with ${conclusion}: ${url}" + fi + fetch_child_jobs | jq 'select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url: (.url // .html_url)}' || true + fi + exit 1 } - { - echo "### Normal CI" - echo - echo "- Target ref: \`${TARGET_REF}\`" - echo "- Target SHA: \`${TARGET_SHA}\`" - } >> "$GITHUB_STEP_SUMMARY" - - dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-ci" - dispatch_run_name="CI ${dispatch_id}" - args=(-f target_ref="$TARGET_SHA" -f include_android=true -f dispatch_id="$dispatch_id") - if [[ "$TARGET_REF" =~ ^v[0-9]{4}\.[0-9]+\.[0-9]+(-(alpha|beta)\.[0-9]+)?$ ]]; then - args+=(-f historical_target_tag="$TARGET_REF") - elif [[ "$TARGET_CONTEXT_REF" =~ ^v[0-9]{4}\.[0-9]+\.[0-9]+(-(alpha|beta)\.[0-9]+)?$ ]]; then - args+=(-f historical_target_tag="$TARGET_CONTEXT_REF") - elif [[ "$TARGET_CONTEXT_REF" =~ ^(release/[0-9]{4}\.[0-9]+\.[0-9]+|extended-stable/[0-9]{4}\.[0-9]+\.33)$ ]]; then - args+=(-f release_candidate_ref="$TARGET_CONTEXT_REF") - fi - dispatch_and_wait ci.yml "$dispatch_run_name" "${args[@]}" + case "$CHILD_WORKFLOW_KIND" in + ci) + { + echo "### Normal CI" + echo + echo "- Target ref: \`${TARGET_REF}\`" + echo "- Target SHA: \`${TARGET_SHA}\`" + } >> "$GITHUB_STEP_SUMMARY" + dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-ci" + dispatch_run_name="CI ${dispatch_id}" + args=(-f target_ref="$TARGET_SHA" -f include_android=true -f dispatch_id="$dispatch_id") + if [[ "$TARGET_REF" =~ ^v[0-9]{4}\.[0-9]+\.[0-9]+(-(alpha|beta)\.[0-9]+)?$ ]]; then + args+=(-f historical_target_tag="$TARGET_REF") + elif [[ "$TARGET_CONTEXT_REF" =~ ^v[0-9]{4}\.[0-9]+\.[0-9]+(-(alpha|beta)\.[0-9]+)?$ ]]; then + args+=(-f historical_target_tag="$TARGET_CONTEXT_REF") + elif [[ "$TARGET_CONTEXT_REF" =~ ^(release/[0-9]{4}\.[0-9]+\.[0-9]+|extended-stable/[0-9]{4}\.[0-9]+\.33)$ ]]; then + args+=(-f release_candidate_ref="$TARGET_CONTEXT_REF") + fi + dispatch_and_wait ci.yml "$dispatch_run_name" "${args[@]}" + ;; + plugin-prerelease) + { + echo "### Plugin prerelease" + echo + echo "- Target ref: \`${TARGET_REF}\`" + echo "- Target SHA: \`${TARGET_SHA}\`" + } >> "$GITHUB_STEP_SUMMARY" + dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-plugin-prerelease" + dispatch_run_name="Plugin Prerelease ${dispatch_id}" + args=(-f target_ref="$TARGET_SHA" -f expected_sha="$TARGET_SHA" -f full_release_validation=true -f dispatch_id="$dispatch_id") + if [[ -n "${CANDIDATE_ARTIFACT_JSON// }" ]]; then + args+=(-f candidate_artifact_json="$CANDIDATE_ARTIFACT_JSON") + fi + dispatch_and_wait plugin-prerelease.yml "$dispatch_run_name" "${args[@]}" + ;; + release-checks) + { + echo "### Release/live/Docker/QA validation" + echo + echo "- Target ref: \`${TARGET_REF}\`" + echo "- Target SHA: \`${TARGET_SHA}\`" + echo "- Provider: \`${PROVIDER}\`" + echo "- Cross-OS mode: \`${MODE}\`" + echo "- Release profile: \`${RELEASE_PROFILE}\`" + echo "- Release soak lanes: \`${RUN_RELEASE_SOAK}\`" + echo "- Rerun group: \`${RERUN_GROUP}\`" + if [[ -n "${LIVE_SUITE_FILTER// }" ]]; then + echo "- Live suite filter: \`${LIVE_SUITE_FILTER}\`" + fi + if [[ -n "${CROSS_OS_SUITE_FILTER// }" ]]; then + echo "- Cross-OS suite filter: \`${CROSS_OS_SUITE_FILTER}\`" + fi + if [[ -n "${RELEASE_PACKAGE_SPEC// }" ]]; then + echo "- Release package spec: \`${RELEASE_PACKAGE_SPEC}\`" + fi + if [[ -n "${PACKAGE_ACCEPTANCE_PACKAGE_SPEC// }" ]]; then + echo "- Package Acceptance package spec: \`${PACKAGE_ACCEPTANCE_PACKAGE_SPEC}\`" + fi + if [[ -n "${CODEX_PLUGIN_SPEC// }" ]]; then + echo "- Codex plugin spec: \`${CODEX_PLUGIN_SPEC}\`" + fi + } >> "$GITHUB_STEP_SUMMARY" + child_rerun_group="$RERUN_GROUP" + if [[ "$child_rerun_group" == "release-checks" ]]; then + child_rerun_group=all + fi + release_checks_target_ref="${TARGET_CONTEXT_REF:-$TARGET_REF}" + args=( + -f ref="$release_checks_target_ref" + -f expected_sha="$TARGET_SHA" + -f provider="$PROVIDER" + -f mode="$MODE" + -f release_profile="$RELEASE_PROFILE" + -f run_release_soak="$RUN_RELEASE_SOAK" + -f fail_fast="$FAIL_FAST" + -f allow_unreleased_changelog="$ALLOW_UNRELEASED_CHANGELOG" + -f rerun_group="$child_rerun_group" + ) + if [[ -n "${TARGET_CONTEXT_REF// }" ]]; then + args+=(-f allow_frozen_target_scenario_omissions=true) + fi + if [[ -n "${LIVE_SUITE_FILTER// }" ]]; then + args+=(-f live_suite_filter="$LIVE_SUITE_FILTER") + fi + if [[ -n "${CROSS_OS_SUITE_FILTER// }" ]]; then + args+=(-f cross_os_suite_filter="$CROSS_OS_SUITE_FILTER") + fi + if [[ -n "${RELEASE_PACKAGE_SPEC// }" ]]; then + args+=(-f release_package_spec="$RELEASE_PACKAGE_SPEC") + fi + if [[ -n "${PACKAGE_ACCEPTANCE_PACKAGE_SPEC// }" ]]; then + args+=(-f package_acceptance_package_spec="$PACKAGE_ACCEPTANCE_PACKAGE_SPEC") + fi + if [[ -n "${CODEX_PLUGIN_SPEC// }" ]]; then + args+=(-f codex_plugin_spec="$CODEX_PLUGIN_SPEC") + fi + if [[ -n "${CANDIDATE_ARTIFACT_JSON// }" ]]; then + args+=(-f candidate_artifact_json="$CANDIDATE_ARTIFACT_JSON") + fi + dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-release-checks" + dispatch_run_name="OpenClaw Release Checks ${dispatch_id}" + args+=(-f dispatch_id="$dispatch_id") + dispatch_and_wait openclaw-release-checks.yml "$dispatch_run_name" "${args[@]}" + ;; + npm-telegram) + args=(-f package_spec="$PACKAGE_SPEC" -f harness_ref="$TARGET_SHA" -f provider_mode="$PROVIDER_MODE") + if [[ -n "${SCENARIO// }" ]]; then + args+=(-f scenario="$SCENARIO") + fi + dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-npm-telegram" + dispatch_run_name="NPM Telegram Beta E2E ${dispatch_id}" + args+=(-f dispatch_id="$dispatch_id") + dispatch_and_wait npm-telegram-beta-e2e.yml "$dispatch_run_name" "${args[@]}" + ;; + performance) + fail_on_regression=true + if [[ "$RELEASE_PROFILE" == "beta" ]]; then + fail_on_regression=false + fi + { + echo "### Product performance" + echo + echo "- Target SHA: \`${TARGET_SHA}\`" + echo "- Profile: \`release\`" + echo "- Repeat: \`3\`" + echo "- Deep profile: \`false\`" + echo "- Live OpenAI candidate: \`false\`" + echo "- Regression gate: \`${fail_on_regression}\`" + echo "- Report publication: disabled (artifacts only)" + if [[ "$RELEASE_PROFILE" == "beta" ]]; then + echo "- Release impact: advisory" + else + echo "- Release impact: blocking" + fi + } >> "$GITHUB_STEP_SUMMARY" + dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + dispatch_run_name="OpenClaw Performance ${dispatch_id}" + args=( + -f target_ref="$TARGET_SHA" + -f profile=release + -f repeat=3 + -f deep_profile=false + -f live_openai_candidate=false + -f fail_on_regression="$fail_on_regression" + -f publish_reports=false + -f dispatch_id="$dispatch_id" + ) + dispatch_and_wait openclaw-performance.yml "$dispatch_run_name" "${args[@]}" + ;; + *) + echo "::error::Unsupported full-release child workflow kind ${CHILD_WORKFLOW_KIND}." >&2 + exit 2 + ;; + esac plugin_prerelease: name: Run plugin prerelease validation @@ -624,184 +888,14 @@ jobs: id: dispatch env: GH_TOKEN: ${{ github.token }} + CHILD_WORKFLOW_KIND: plugin-prerelease TARGET_REF: ${{ inputs.ref }} TARGET_SHA: ${{ needs.resolve_target.outputs.sha }} CHILD_WORKFLOW_REF: ${{ github.ref_name }} PARENT_WORKFLOW_SHA: ${{ github.sha }} CANDIDATE_ARTIFACT_JSON: ${{ needs.prepare_release_candidate.outputs.candidate_artifact_json }} FAIL_FAST: ${{ inputs.fail_fast }} - run: | - set -euo pipefail - - dispatch_and_wait() { - local workflow="$1" - local dispatch_run_name="$2" - shift 2 - - local dispatch_output dispatch_status matches_json match_count run_id status conclusion url poll_count child_head_sha encoded_workflow_ref current_workflow_sha - gh_with_retry() { - local output status attempt - for attempt in 1 2 3 4 5 6; do - set +e - output="$(gh "$@" 2>&1)" - status=$? - set -e - if [[ "$status" -eq 0 ]]; then - printf '%s\n' "$output" - return 0 - fi - if [[ "$output" == *"Bad credentials"* || "$output" == *"HTTP 401"* || "$output" == *"secondary rate limit"* || "$output" == *"API rate limit"* || "$output" == *"HTTP 429"* || "$output" == *"abuse detection"* || "$output" == *"Sorry. Your account was suspended"* || "$output" =~ $GH_TRANSIENT_SERVER_OR_NETWORK_PATTERN ]]; then - echo "::warning::gh $* failed on attempt ${attempt}: ${output}" >&2 - sleep $((attempt * 10)) - continue - fi - printf '%s\n' "$output" >&2 - return "$status" - done - printf '%s\n' "$output" >&2 - return "$status" - } - encoded_workflow_ref="$(jq -rn --arg value "$CHILD_WORKFLOW_REF" '$value | @uri')" - current_workflow_sha="$( - gh_with_retry api "repos/${GITHUB_REPOSITORY}/commits/${encoded_workflow_ref}" --jq .sha - )" - if [[ "$current_workflow_sha" != "$PARENT_WORKFLOW_SHA" ]]; then - echo "::error::Child workflow ref ${CHILD_WORKFLOW_REF} moved to ${current_workflow_sha}, expected ${PARENT_WORKFLOW_SHA}; refusing dispatch." >&2 - return 1 - fi - # A failed dispatch POST can still create a run. Never retry it; recover only by exact run name. - set +e - dispatch_output="$(gh workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1)" - dispatch_status=$? - set -e - printf '%s\n' "$dispatch_output" - - if [[ "$dispatch_status" -ne 0 && ! "$dispatch_output" =~ $GH_TRANSIENT_SERVER_OR_NETWORK_PATTERN ]]; then - echo "::error::${workflow} dispatch failed with non-ambiguous status ${dispatch_status}; refusing adoption polling." >&2 - exit "$dispatch_status" - fi - - run_id="" - for _ in $(seq 1 60); do - if matches_json="$( - DISPATCH_RUN_NAME="$dispatch_run_name" CHILD_WORKFLOW_REF="$CHILD_WORKFLOW_REF" \ - gh_with_retry api -X GET "repos/${GITHUB_REPOSITORY}/actions/workflows/${workflow}/runs" \ - -F event=workflow_dispatch \ - -F per_page=100 \ - --jq '[.workflow_runs[] | select(.display_title == env.DISPATCH_RUN_NAME and .head_branch == env.CHILD_WORKFLOW_REF) | .id]' - )"; then - match_count="$(jq 'length' <<< "$matches_json")" - if (( match_count > 1 )); then - echo "::error::Multiple runs matched ${dispatch_run_name}; refusing to guess." >&2 - exit 1 - fi - if (( match_count == 1 )); then - run_id="$(jq -r '.[0]' <<< "$matches_json")" - break - fi - fi - sleep 5 - done - - if [[ -z "$run_id" ]]; then - echo "::error::Could not find exact dispatched run ${dispatch_run_name}; dispatch status ${dispatch_status}. The dispatch was not retried to avoid creating a duplicate child." >&2 - exit 1 - fi - if [[ "$dispatch_status" -ne 0 ]]; then - echo "::warning::${workflow} dispatch returned status ${dispatch_status}; adopted exact run ${run_id}." >&2 - fi - - echo "Dispatched ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" - echo "run_id=${run_id}" >> "$GITHUB_OUTPUT" - - fetch_child_run_json() { - gh_with_retry api "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}" - } - - fetch_child_jobs() { - gh_with_retry api --paginate "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/jobs?per_page=100" --jq '.jobs[]' - } - - fail_fast_failed_jobs() { - if [[ "$FAIL_FAST" != "true" ]]; then - return 0 - fi - local failed_jobs_json - if ! failed_jobs_json="$( - fetch_child_jobs | - jq -s '[.[] | select(.status == "completed" and .conclusion != "success" and .conclusion != "skipped")]' - )"; then - echo "::warning::Could not list ${workflow} child jobs; continuing with authoritative workflow conclusion." - return 0 - fi - if jq -e 'length > 0' <<< "$failed_jobs_json" >/dev/null; then - echo "::error::${workflow} has failed child jobs before the workflow completed; cancelling the remaining matrix." - jq '.[] | {name, conclusion, url: .html_url}' <<< "$failed_jobs_json" - cancel_child - trap - EXIT INT TERM - exit 1 - fi - } - - cancel_child() { - if [[ -n "${run_id:-}" ]]; then - echo "Cancelling child workflow ${workflow}: ${run_id}" >&2 - gh run cancel "$run_id" >/dev/null 2>&1 || true - fi - } - trap cancel_child EXIT INT TERM - - child_head_sha="$(fetch_child_run_json | jq -r '.head_sha // ""')" - if [[ "$child_head_sha" != "$PARENT_WORKFLOW_SHA" ]]; then - echo "::error::${workflow} child run used workflow SHA ${child_head_sha}, expected parent workflow SHA ${PARENT_WORKFLOW_SHA}." - cancel_child - trap - EXIT INT TERM - exit 1 - fi - - poll_count=0 - while true; do - status="$(fetch_child_run_json | jq -r '.status')" - if [[ "$status" == "completed" ]]; then - break - fi - poll_count=$((poll_count + 1)) - if (( poll_count % 5 == 0 )); then - fail_fast_failed_jobs - fi - if (( poll_count % 10 == 0 )); then - echo "Still waiting on ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" - fetch_child_jobs | jq 'select(.status != "completed") | {name, status, url: .html_url}' || true - fi - sleep 60 - done - trap - EXIT INT TERM - - conclusion="$(fetch_child_run_json | jq -r '.conclusion // ""')" - url="$(fetch_child_run_json | jq -r '.html_url')" - echo "${workflow} finished with ${conclusion}: ${url}" - echo "url=${url}" >> "$GITHUB_OUTPUT" - echo "conclusion=${conclusion}" >> "$GITHUB_OUTPUT" - if [[ "$conclusion" != "success" ]]; then - fetch_child_jobs | jq 'select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url: .html_url}' || true - exit 1 - fi - } - - { - echo "### Plugin prerelease" - echo - echo "- Target ref: \`${TARGET_REF}\`" - echo "- Target SHA: \`${TARGET_SHA}\`" - } >> "$GITHUB_STEP_SUMMARY" - - dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-plugin-prerelease" - dispatch_run_name="Plugin Prerelease ${dispatch_id}" - args=(-f target_ref="$TARGET_SHA" -f expected_sha="$TARGET_SHA" -f full_release_validation=true -f dispatch_id="$dispatch_id") - if [[ -n "${CANDIDATE_ARTIFACT_JSON// }" ]]; then - args+=(-f candidate_artifact_json="$CANDIDATE_ARTIFACT_JSON") - fi - dispatch_and_wait plugin-prerelease.yml "$dispatch_run_name" "${args[@]}" + run: *full_release_child_dispatch release_checks: name: Run release/live/Docker/QA validation @@ -820,6 +914,7 @@ jobs: id: dispatch env: GH_TOKEN: ${{ github.token }} + CHILD_WORKFLOW_KIND: release-checks TARGET_REF: ${{ inputs.ref }} TARGET_CONTEXT_REF: ${{ inputs.target_context_ref }} TARGET_SHA: ${{ needs.resolve_target.outputs.sha }} @@ -838,325 +933,7 @@ jobs: PACKAGE_ACCEPTANCE_PACKAGE_SPEC: ${{ inputs.package_acceptance_package_spec }} CODEX_PLUGIN_SPEC: ${{ inputs.codex_plugin_spec }} CANDIDATE_ARTIFACT_JSON: ${{ needs.prepare_release_candidate.outputs.candidate_artifact_json }} - run: | - set -euo pipefail - - dispatch_and_wait() { - local workflow="$1" - local dispatch_run_name="$2" - shift 2 - - local dispatch_output dispatch_status matches_json match_count run_id status conclusion url poll_count run_json child_head_sha encoded_workflow_ref current_workflow_sha - gh_with_retry() { - local output status attempt - for attempt in 1 2 3 4 5 6; do - set +e - output="$(gh "$@" 2>&1)" - status=$? - set -e - if [[ "$status" -eq 0 ]]; then - printf '%s\n' "$output" - return 0 - fi - if [[ "$output" == *"Bad credentials"* || "$output" == *"HTTP 401"* || "$output" == *"secondary rate limit"* || "$output" == *"API rate limit"* || "$output" == *"HTTP 429"* || "$output" == *"abuse detection"* || "$output" == *"Sorry. Your account was suspended"* || "$output" =~ $GH_TRANSIENT_SERVER_OR_NETWORK_PATTERN ]]; then - echo "::warning::gh $* failed on attempt ${attempt}: ${output}" >&2 - sleep $((attempt * 10)) - continue - fi - printf '%s\n' "$output" >&2 - return "$status" - done - printf '%s\n' "$output" >&2 - return "$status" - } - encoded_workflow_ref="$(jq -rn --arg value "$CHILD_WORKFLOW_REF" '$value | @uri')" - current_workflow_sha="$( - gh_with_retry api "repos/${GITHUB_REPOSITORY}/commits/${encoded_workflow_ref}" --jq .sha - )" - if [[ "$current_workflow_sha" != "$PARENT_WORKFLOW_SHA" ]]; then - echo "::error::Child workflow ref ${CHILD_WORKFLOW_REF} moved to ${current_workflow_sha}, expected ${PARENT_WORKFLOW_SHA}; refusing dispatch." >&2 - return 1 - fi - # A failed dispatch POST can still create a run. Never retry it; recover only by exact run name. - set +e - dispatch_output="$(gh workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1)" - dispatch_status=$? - set -e - printf '%s\n' "$dispatch_output" - - if [[ "$dispatch_status" -ne 0 && ! "$dispatch_output" =~ $GH_TRANSIENT_SERVER_OR_NETWORK_PATTERN ]]; then - echo "::error::${workflow} dispatch failed with non-ambiguous status ${dispatch_status}; refusing adoption polling." >&2 - exit "$dispatch_status" - fi - - run_id="" - for _ in $(seq 1 60); do - if matches_json="$( - DISPATCH_RUN_NAME="$dispatch_run_name" CHILD_WORKFLOW_REF="$CHILD_WORKFLOW_REF" \ - gh_with_retry api -X GET "repos/${GITHUB_REPOSITORY}/actions/workflows/${workflow}/runs" \ - -F event=workflow_dispatch \ - -F per_page=100 \ - --jq '[.workflow_runs[] | select(.display_title == env.DISPATCH_RUN_NAME and .head_branch == env.CHILD_WORKFLOW_REF) | .id]' - )"; then - match_count="$(jq 'length' <<< "$matches_json")" - if (( match_count > 1 )); then - echo "::error::Multiple runs matched ${dispatch_run_name}; refusing to guess." >&2 - exit 1 - fi - if (( match_count == 1 )); then - run_id="$(jq -r '.[0]' <<< "$matches_json")" - break - fi - fi - sleep 5 - done - - if [[ -z "$run_id" ]]; then - echo "::error::Could not find exact dispatched run ${dispatch_run_name}; dispatch status ${dispatch_status}. The dispatch was not retried to avoid creating a duplicate child." >&2 - exit 1 - fi - if [[ "$dispatch_status" -ne 0 ]]; then - echo "::warning::${workflow} dispatch returned status ${dispatch_status}; adopted exact run ${run_id}." >&2 - fi - - echo "Dispatched ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" - echo "run_id=${run_id}" >> "$GITHUB_OUTPUT" - - fetch_child_run_json() { - gh_with_retry api "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}" - } - - fetch_child_jobs() { - gh_with_retry api --paginate "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/jobs?per_page=100" --jq '.jobs[]' - } - - release_check_blocking_job() { - if [[ "$RELEASE_PROFILE" == "beta" && "$1" == "Run package acceptance / Telegram package acceptance / "* ]]; then - return 1 - fi - case "$1" in - "resolve_target" | \ - "Prepare release package artifact" | \ - "install_smoke_release_checks / "* | \ - "Run package acceptance" | \ - "Run package acceptance / "*) - return 0 - ;; - esac - return 1 - } - - release_checks_advisory_only() { - local run_json="$1" - local verifier_conclusion name saw_advisory failed - - verifier_conclusion="$( - jq -r '.jobs[] | select(.name == "Verify release checks") | .conclusion' <<< "$run_json" | - tail -n 1 - )" - if [[ "$verifier_conclusion" != "success" ]]; then - return 1 - fi - - saw_advisory=0 - failed=0 - while IFS= read -r name; do - [[ -z "${name// }" ]] && continue - if release_check_blocking_job "$name"; then - echo "::error::${name} is a package-safety Tideclaw alpha release-check lane." - failed=1 - else - saw_advisory=1 - fi - done < <(jq -r '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | .name' <<< "$run_json") - - [[ "$saw_advisory" == "1" && "$failed" == "0" ]] - } - - fail_fast_failed_jobs() { - if [[ "$FAIL_FAST" != "true" ]]; then - return 0 - fi - local failed_jobs_json - if [[ "$workflow" == "openclaw-release-checks.yml" && "$CHILD_WORKFLOW_REF" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then - return 0 - fi - if ! failed_jobs_json="$( - fetch_child_jobs | - jq -s '[.[] | select(.status == "completed" and .conclusion != "success" and .conclusion != "skipped")]' - )"; then - echo "::warning::Could not list ${workflow} child jobs; continuing with authoritative workflow conclusion." - return 0 - fi - if [[ "$workflow" == "openclaw-release-checks.yml" ]]; then - # These jobs are continue-on-error in the child workflow. Let its - # status-artifact verifier decide whether their evidence is usable. - failed_jobs_json="$( - jq '[.[] | select( - ((.name | startswith("Run QA Lab parity lane (")) - or .name == "Run QA Lab parity report" - or (.name | startswith("Run QA Lab runtime-pair lane (")) - or .name == "Verify QA Lab runtime-pair lanes" - or .name == "Run QA Lab live Discord lane" - or .name == "Run QA Lab live WhatsApp lane" - or .name == "Run QA Lab live Slack lane") - | not)]' <<< "$failed_jobs_json" - )" - fi - if [[ "$workflow" == "openclaw-release-checks.yml" && "$RELEASE_PROFILE" == "beta" ]]; then - # Beta treats live-provider suites as advisory (live_advisory in - # openclaw-live-and-e2e-checks-reusable.yml); their failures must - # not fail-fast-cancel the remaining release-check matrix. Repo - # E2E under the same caller stays blocking. - failed_jobs_json="$( - jq '[.[] | select( - (((.name | startswith("Run repo/live E2E validation / ")) - and ((.name | contains("Docker live")) - or (.name | contains("Live media suites")) - or (.name | contains("validate_live_provider_suites")) - or (.name | contains("validate_release_live_cache")) - or (.name | contains("prepare_live_test_image")))) - or (.name | startswith("Run package acceptance / Telegram package acceptance / "))) - | not)]' <<< "$failed_jobs_json" - )" - fi - if jq -e 'length > 0' <<< "$failed_jobs_json" >/dev/null; then - echo "::error::${workflow} has failed child jobs before the workflow completed; cancelling the remaining matrix." - jq '.[] | {name, conclusion, url: .html_url}' <<< "$failed_jobs_json" - cancel_child - trap - EXIT INT TERM - exit 1 - fi - } - - cancel_child() { - if [[ -n "${run_id:-}" ]]; then - echo "Cancelling child workflow ${workflow}: ${run_id}" >&2 - gh run cancel "$run_id" >/dev/null 2>&1 || true - fi - } - trap cancel_child EXIT INT TERM - - child_head_sha="$(fetch_child_run_json | jq -r '.head_sha // ""')" - if [[ "$child_head_sha" != "$PARENT_WORKFLOW_SHA" ]]; then - echo "::error::${workflow} child run used workflow SHA ${child_head_sha}, expected parent workflow SHA ${PARENT_WORKFLOW_SHA}." - cancel_child - trap - EXIT INT TERM - exit 1 - fi - - poll_count=0 - while true; do - status="$(fetch_child_run_json | jq -r '.status')" - if [[ "$status" == "completed" ]]; then - break - fi - poll_count=$((poll_count + 1)) - if (( poll_count % 5 == 0 )); then - fail_fast_failed_jobs - fi - if (( poll_count % 10 == 0 )); then - echo "Still waiting on ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" - fetch_child_jobs | jq 'select(.status != "completed") | {name, status, url: .html_url}' || true - fi - sleep 60 - done - trap - EXIT INT TERM - - jobs_json="$(fetch_child_jobs | jq -s '{jobs: [.[] | {name, conclusion, url: .html_url}]}')" - run_json="$( - jq -s '.[0] + .[1]' \ - <(fetch_child_run_json | jq '{conclusion: (.conclusion // ""), url: .html_url}') \ - <(printf '%s\n' "$jobs_json") - )" - conclusion="$(jq -r '.conclusion' <<< "$run_json")" - url="$(jq -r '.url' <<< "$run_json")" - echo "${workflow} finished with ${conclusion}: ${url}" - echo "url=${url}" >> "$GITHUB_OUTPUT" - echo "conclusion=${conclusion}" >> "$GITHUB_OUTPUT" - if [[ "$conclusion" != "success" ]]; then - jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url}' <<< "$run_json" || true - if [[ "$workflow" == "openclaw-release-checks.yml" && "$CHILD_WORKFLOW_REF" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then - if release_checks_advisory_only "$run_json"; then - echo "::warning::${workflow} ended with ${conclusion}, but Verify release checks accepted Tideclaw alpha advisory lanes." - return 0 - fi - fi - exit 1 - fi - } - - { - echo "### Release/live/Docker/QA validation" - echo - echo "- Target ref: \`${TARGET_REF}\`" - echo "- Target SHA: \`${TARGET_SHA}\`" - echo "- Provider: \`${PROVIDER}\`" - echo "- Cross-OS mode: \`${MODE}\`" - echo "- Release profile: \`${RELEASE_PROFILE}\`" - echo "- Release soak lanes: \`${RUN_RELEASE_SOAK}\`" - echo "- Rerun group: \`${RERUN_GROUP}\`" - if [[ -n "${LIVE_SUITE_FILTER// }" ]]; then - echo "- Live suite filter: \`${LIVE_SUITE_FILTER}\`" - fi - if [[ -n "${CROSS_OS_SUITE_FILTER// }" ]]; then - echo "- Cross-OS suite filter: \`${CROSS_OS_SUITE_FILTER}\`" - fi - if [[ -n "${RELEASE_PACKAGE_SPEC// }" ]]; then - echo "- Release package spec: \`${RELEASE_PACKAGE_SPEC}\`" - fi - if [[ -n "${PACKAGE_ACCEPTANCE_PACKAGE_SPEC// }" ]]; then - echo "- Package Acceptance package spec: \`${PACKAGE_ACCEPTANCE_PACKAGE_SPEC}\`" - fi - if [[ -n "${CODEX_PLUGIN_SPEC// }" ]]; then - echo "- Codex plugin spec: \`${CODEX_PLUGIN_SPEC}\`" - fi - } >> "$GITHUB_STEP_SUMMARY" - - child_rerun_group="$RERUN_GROUP" - if [[ "$child_rerun_group" == "release-checks" ]]; then - child_rerun_group=all - fi - - release_checks_target_ref="${TARGET_CONTEXT_REF:-$TARGET_REF}" - - args=( - -f ref="$release_checks_target_ref" - -f expected_sha="$TARGET_SHA" - -f provider="$PROVIDER" - -f mode="$MODE" - -f release_profile="$RELEASE_PROFILE" - -f run_release_soak="$RUN_RELEASE_SOAK" - -f fail_fast="$FAIL_FAST" - -f allow_unreleased_changelog="$ALLOW_UNRELEASED_CHANGELOG" - -f rerun_group="$child_rerun_group" - ) - if [[ -n "${TARGET_CONTEXT_REF// }" ]]; then - args+=(-f allow_frozen_target_scenario_omissions=true) - fi - if [[ -n "${LIVE_SUITE_FILTER// }" ]]; then - args+=(-f live_suite_filter="$LIVE_SUITE_FILTER") - fi - if [[ -n "${CROSS_OS_SUITE_FILTER// }" ]]; then - args+=(-f cross_os_suite_filter="$CROSS_OS_SUITE_FILTER") - fi - if [[ -n "${RELEASE_PACKAGE_SPEC// }" ]]; then - args+=(-f release_package_spec="$RELEASE_PACKAGE_SPEC") - fi - if [[ -n "${PACKAGE_ACCEPTANCE_PACKAGE_SPEC// }" ]]; then - args+=(-f package_acceptance_package_spec="$PACKAGE_ACCEPTANCE_PACKAGE_SPEC") - fi - if [[ -n "${CODEX_PLUGIN_SPEC// }" ]]; then - args+=(-f codex_plugin_spec="$CODEX_PLUGIN_SPEC") - fi - if [[ -n "${CANDIDATE_ARTIFACT_JSON// }" ]]; then - args+=(-f candidate_artifact_json="$CANDIDATE_ARTIFACT_JSON") - fi - - dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-release-checks" - dispatch_run_name="OpenClaw Release Checks ${dispatch_id}" - args+=(-f dispatch_id="$dispatch_id") - dispatch_and_wait openclaw-release-checks.yml "$dispatch_run_name" "${args[@]}" + run: *full_release_child_dispatch npm_telegram: name: Run package Telegram E2E @@ -1174,6 +951,7 @@ jobs: id: dispatch env: GH_TOKEN: ${{ github.token }} + CHILD_WORKFLOW_KIND: npm-telegram CHILD_WORKFLOW_REF: ${{ github.ref_name }} PARENT_WORKFLOW_SHA: ${{ github.sha }} TARGET_SHA: ${{ needs.resolve_target.outputs.sha }} @@ -1181,156 +959,7 @@ jobs: PROVIDER_MODE: ${{ inputs.npm_telegram_provider_mode }} SCENARIO: ${{ inputs.npm_telegram_scenario }} FAIL_FAST: ${{ inputs.fail_fast }} - run: | - set -euo pipefail - - gh_with_retry() { - local output status attempt - for attempt in 1 2 3 4 5 6; do - set +e - output="$(gh "$@" 2>&1)" - status=$? - set -e - if [[ "$status" -eq 0 ]]; then - printf '%s\n' "$output" - return 0 - fi - if [[ "$output" == *"Bad credentials"* || "$output" == *"HTTP 401"* || "$output" == *"secondary rate limit"* || "$output" == *"API rate limit"* || "$output" == *"HTTP 429"* || "$output" == *"abuse detection"* || "$output" == *"Sorry. Your account was suspended"* || "$output" =~ $GH_TRANSIENT_SERVER_OR_NETWORK_PATTERN ]]; then - echo "::warning::gh $* failed on attempt ${attempt}: ${output}" >&2 - sleep $((attempt * 10)) - continue - fi - printf '%s\n' "$output" >&2 - return "$status" - done - printf '%s\n' "$output" >&2 - return "$status" - } - - encoded_workflow_ref="$(jq -rn --arg value "$CHILD_WORKFLOW_REF" '$value | @uri')" - current_workflow_sha="$( - gh_with_retry api "repos/${GITHUB_REPOSITORY}/commits/${encoded_workflow_ref}" --jq .sha - )" - if [[ "$current_workflow_sha" != "$PARENT_WORKFLOW_SHA" ]]; then - echo "::error::Child workflow ref ${CHILD_WORKFLOW_REF} moved to ${current_workflow_sha}, expected ${PARENT_WORKFLOW_SHA}; refusing dispatch." >&2 - exit 1 - fi - - args=(-f package_spec="$PACKAGE_SPEC" -f harness_ref="$TARGET_SHA" -f provider_mode="$PROVIDER_MODE") - if [[ -n "${SCENARIO// }" ]]; then - args+=(-f scenario="$SCENARIO") - fi - - dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-npm-telegram" - dispatch_run_name="NPM Telegram Beta E2E ${dispatch_id}" - args+=(-f dispatch_id="$dispatch_id") - - # A failed dispatch POST can still create a run. Never retry it; recover only by exact run name. - set +e - dispatch_output="$(gh workflow run npm-telegram-beta-e2e.yml --ref "$CHILD_WORKFLOW_REF" "${args[@]}" 2>&1)" - dispatch_status=$? - set -e - printf '%s\n' "$dispatch_output" - - if [[ "$dispatch_status" -ne 0 && ! "$dispatch_output" =~ $GH_TRANSIENT_SERVER_OR_NETWORK_PATTERN ]]; then - echo "::error::npm-telegram-beta-e2e.yml dispatch failed with non-ambiguous status ${dispatch_status}; refusing adoption polling." >&2 - exit "$dispatch_status" - fi - - run_id="" - for _ in $(seq 1 60); do - if matches_json="$( - DISPATCH_RUN_NAME="$dispatch_run_name" CHILD_WORKFLOW_REF="$CHILD_WORKFLOW_REF" \ - gh_with_retry api -X GET "repos/${GITHUB_REPOSITORY}/actions/workflows/npm-telegram-beta-e2e.yml/runs" \ - -F event=workflow_dispatch \ - -F per_page=100 \ - --jq '[.workflow_runs[] | select(.display_title == env.DISPATCH_RUN_NAME and .head_branch == env.CHILD_WORKFLOW_REF) | .id]' - )"; then - match_count="$(jq 'length' <<< "$matches_json")" - if (( match_count > 1 )); then - echo "::error::Multiple runs matched ${dispatch_run_name}; refusing to guess." >&2 - exit 1 - fi - if (( match_count == 1 )); then - run_id="$(jq -r '.[0]' <<< "$matches_json")" - break - fi - fi - sleep 5 - done - - if [[ -z "$run_id" ]]; then - echo "::error::Could not find exact dispatched run ${dispatch_run_name}; dispatch status ${dispatch_status}. The dispatch was not retried to avoid creating a duplicate child." >&2 - exit 1 - fi - if [[ "$dispatch_status" -ne 0 ]]; then - echo "::warning::npm-telegram-beta-e2e.yml dispatch returned status ${dispatch_status}; adopted exact run ${run_id}." >&2 - fi - - echo "Dispatched npm-telegram-beta-e2e.yml: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" - echo "run_id=${run_id}" >> "$GITHUB_OUTPUT" - - cancel_child() { - if [[ -n "${run_id:-}" ]]; then - echo "Cancelling child workflow npm-telegram-beta-e2e.yml: ${run_id}" >&2 - gh run cancel "$run_id" >/dev/null 2>&1 || true - fi - } - trap cancel_child EXIT INT TERM - - child_head_sha="$(gh_with_retry run view "$run_id" --json headSha --jq '.headSha // ""')" - if [[ "$child_head_sha" != "$PARENT_WORKFLOW_SHA" ]]; then - echo "::error::npm-telegram-beta-e2e.yml child run used workflow SHA ${child_head_sha}, expected parent workflow SHA ${PARENT_WORKFLOW_SHA}." - cancel_child - trap - EXIT INT TERM - exit 1 - fi - - fail_fast_failed_jobs() { - if [[ "$FAIL_FAST" != "true" ]]; then - return 0 - fi - local failed_jobs_json - failed_jobs_json="$( - gh_with_retry run view "$run_id" --json jobs \ - --jq '[.jobs[] | select(.status == "completed" and .conclusion != "success" and .conclusion != "skipped")]' - )" - if jq -e 'length > 0' <<< "$failed_jobs_json" >/dev/null; then - echo "::error::npm-telegram-beta-e2e.yml has failed child jobs before the workflow completed; cancelling the remaining run." - jq '.[] | {name, conclusion, url}' <<< "$failed_jobs_json" - cancel_child - trap - EXIT INT TERM - exit 1 - fi - } - - poll_count=0 - while true; do - status="$(gh_with_retry run view "$run_id" --json status --jq '.status')" - if [[ "$status" == "completed" ]]; then - break - fi - poll_count=$((poll_count + 1)) - if (( poll_count % 5 == 0 )); then - fail_fast_failed_jobs - fi - if (( poll_count % 10 == 0 )); then - echo "Still waiting on npm-telegram-beta-e2e.yml: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" - gh_with_retry run view "$run_id" --json jobs --jq '.jobs[] | select(.status != "completed") | {name, status, url}' || true - fi - sleep 60 - done - trap - EXIT INT TERM - - conclusion="$(gh_with_retry run view "$run_id" --json conclusion --jq '.conclusion')" - url="$(gh_with_retry run view "$run_id" --json url --jq '.url')" - echo "npm-telegram-beta-e2e.yml finished with ${conclusion}: ${url}" - echo "url=${url}" >> "$GITHUB_OUTPUT" - echo "conclusion=${conclusion}" >> "$GITHUB_OUTPUT" - if [[ "$conclusion" != "success" ]]; then - gh_with_retry run view "$run_id" --json jobs --jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url}' || true - exit 1 - fi + run: *full_release_child_dispatch performance: name: Run product performance evidence @@ -1349,170 +978,12 @@ jobs: id: dispatch env: GH_TOKEN: ${{ github.token }} + CHILD_WORKFLOW_KIND: performance RELEASE_PROFILE: ${{ inputs.release_profile }} TARGET_SHA: ${{ needs.resolve_target.outputs.sha }} CHILD_WORKFLOW_REF: ${{ github.ref_name }} PARENT_WORKFLOW_SHA: ${{ github.sha }} - run: | - set -euo pipefail - - gh_with_retry() { - local output status attempt - for attempt in 1 2 3 4 5 6; do - set +e - output="$(gh "$@" 2>&1)" - status=$? - set -e - if [[ "$status" -eq 0 ]]; then - printf '%s\n' "$output" - return 0 - fi - if [[ "$output" == *"Bad credentials"* || "$output" == *"HTTP 401"* || "$output" == *"secondary rate limit"* || "$output" == *"API rate limit"* || "$output" == *"HTTP 429"* || "$output" == *"abuse detection"* || "$output" == *"Sorry. Your account was suspended"* || "$output" =~ $GH_TRANSIENT_SERVER_OR_NETWORK_PATTERN ]]; then - echo "::warning::gh $* failed on attempt ${attempt}: ${output}" >&2 - sleep $((attempt * 10)) - continue - fi - printf '%s\n' "$output" >&2 - return "$status" - done - printf '%s\n' "$output" >&2 - return "$status" - } - - encoded_workflow_ref="$(jq -rn --arg value "$CHILD_WORKFLOW_REF" '$value | @uri')" - current_workflow_sha="$( - gh_with_retry api "repos/${GITHUB_REPOSITORY}/commits/${encoded_workflow_ref}" --jq .sha - )" - if [[ "$current_workflow_sha" != "$PARENT_WORKFLOW_SHA" ]]; then - echo "::error::Child workflow ref ${CHILD_WORKFLOW_REF} moved to ${current_workflow_sha}, expected ${PARENT_WORKFLOW_SHA}; refusing dispatch." >&2 - exit 1 - fi - - fail_on_regression=true - if [[ "$RELEASE_PROFILE" == "beta" ]]; then - fail_on_regression=false - fi - - { - echo "### Product performance" - echo - echo "- Target SHA: \`${TARGET_SHA}\`" - echo "- Profile: \`release\`" - echo "- Repeat: \`3\`" - echo "- Deep profile: \`false\`" - echo "- Live OpenAI candidate: \`false\`" - echo "- Regression gate: \`${fail_on_regression}\`" - echo "- Report publication: disabled (artifacts only)" - if [[ "$RELEASE_PROFILE" == "beta" ]]; then - echo "- Release impact: advisory" - else - echo "- Release impact: blocking" - fi - } >> "$GITHUB_STEP_SUMMARY" - - dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" - dispatch_run_name="OpenClaw Performance ${dispatch_id}" - - # A failed dispatch POST can still create a run. Never retry it; recover only by exact run name. - set +e - dispatch_output="$(gh workflow run openclaw-performance.yml \ - --ref "$CHILD_WORKFLOW_REF" \ - -f target_ref="$TARGET_SHA" \ - -f profile=release \ - -f repeat=3 \ - -f deep_profile=false \ - -f live_openai_candidate=false \ - -f fail_on_regression="$fail_on_regression" \ - -f publish_reports=false \ - -f dispatch_id="$dispatch_id" 2>&1)" - dispatch_status=$? - set -e - printf '%s\n' "$dispatch_output" - - if [[ "$dispatch_status" -ne 0 && ! "$dispatch_output" =~ $GH_TRANSIENT_SERVER_OR_NETWORK_PATTERN ]]; then - echo "::error::openclaw-performance.yml dispatch failed with non-ambiguous status ${dispatch_status}; refusing adoption polling." >&2 - exit "$dispatch_status" - fi - - run_id="" - for _ in $(seq 1 60); do - if matches_json="$( - DISPATCH_RUN_NAME="$dispatch_run_name" CHILD_WORKFLOW_REF="$CHILD_WORKFLOW_REF" \ - gh_with_retry api -X GET "repos/${GITHUB_REPOSITORY}/actions/workflows/openclaw-performance.yml/runs" \ - -F event=workflow_dispatch \ - -F per_page=100 \ - --jq '[.workflow_runs[] | select(.display_title == env.DISPATCH_RUN_NAME and .head_branch == env.CHILD_WORKFLOW_REF) | .id]' - )"; then - match_count="$(jq 'length' <<< "$matches_json")" - if (( match_count > 1 )); then - echo "::error::Multiple runs matched ${dispatch_run_name}; refusing to guess." >&2 - exit 1 - fi - if (( match_count == 1 )); then - run_id="$(jq -r '.[0]' <<< "$matches_json")" - break - fi - fi - sleep 5 - done - - if [[ -z "$run_id" ]]; then - echo "::error::Could not find exact dispatched run ${dispatch_run_name}; dispatch status ${dispatch_status}. The dispatch was not retried to avoid creating a duplicate child." >&2 - exit 1 - fi - if [[ "$dispatch_status" -ne 0 ]]; then - echo "::warning::openclaw-performance.yml dispatch returned status ${dispatch_status}; adopted exact run ${run_id}." >&2 - fi - - echo "Dispatched openclaw-performance.yml: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" - echo "run_id=${run_id}" >> "$GITHUB_OUTPUT" - - cancel_child() { - if [[ -n "${run_id:-}" ]]; then - echo "Cancelling child workflow openclaw-performance.yml: ${run_id}" >&2 - gh run cancel "$run_id" >/dev/null 2>&1 || true - fi - } - trap cancel_child EXIT INT TERM - - child_head_sha="$(gh_with_retry run view "$run_id" --json headSha --jq '.headSha // ""')" - if [[ "$child_head_sha" != "$PARENT_WORKFLOW_SHA" ]]; then - echo "::error::openclaw-performance.yml child run used workflow SHA ${child_head_sha}, expected parent workflow SHA ${PARENT_WORKFLOW_SHA}." - cancel_child - trap - EXIT INT TERM - exit 1 - fi - - poll_count=0 - while true; do - status="$(gh_with_retry run view "$run_id" --json status --jq '.status')" - if [[ "$status" == "completed" ]]; then - break - fi - poll_count=$((poll_count + 1)) - if (( poll_count % 10 == 0 )); then - echo "Still waiting on openclaw-performance.yml: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" - gh_with_retry run view "$run_id" --json jobs --jq '.jobs[] | select(.status != "completed") | {name, status, url}' || true - fi - sleep 60 - done - trap - EXIT INT TERM - - conclusion="$(gh_with_retry run view "$run_id" --json conclusion --jq '.conclusion')" - url="$(gh_with_retry run view "$run_id" --json url --jq '.url')" - echo "openclaw-performance.yml finished with ${conclusion}: ${url}" - echo "url=${url}" >> "$GITHUB_OUTPUT" - echo "conclusion=${conclusion}" >> "$GITHUB_OUTPUT" - if [[ "$conclusion" != "success" ]]; then - if [[ "$RELEASE_PROFILE" == "beta" ]]; then - echo "::warning::OpenClaw Performance ended with ${conclusion}; advisory for beta: ${url}" - exit 0 - fi - echo "::error::OpenClaw Performance ended with ${conclusion}: ${url}" - gh_with_retry run view "$run_id" --json jobs --jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url}' || true - exit 1 - fi - + run: *full_release_child_dispatch summary: name: Verify full validation needs: diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index c643fb6cb6a9..1afcb8f2cc7b 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -42,6 +42,48 @@ const ANDROID_RELEASE_WORKFLOW = ".github/workflows/android-release.yml"; const STABLE_MAIN_CLOSEOUT_WORKFLOW = ".github/workflows/openclaw-stable-main-closeout.yml"; const WINDOWS_NODE_RELEASE_WORKFLOW = ".github/workflows/windows-node-release.yml"; const FULL_RELEASE_VALIDATION_WORKFLOW = ".github/workflows/full-release-validation.yml"; +const FULL_RELEASE_CHILD_DISPATCHES = [ + { + jobName: "normal_ci", + kind: "ci", + nonceSuffix: "-ci", + runName: "CI", + stepName: "Dispatch and monitor CI", + workflow: "ci.yml", + }, + { + jobName: "plugin_prerelease", + kind: "plugin-prerelease", + nonceSuffix: "-plugin-prerelease", + runName: "Plugin Prerelease", + stepName: "Dispatch and monitor plugin prerelease", + workflow: "plugin-prerelease.yml", + }, + { + jobName: "release_checks", + kind: "release-checks", + nonceSuffix: "-release-checks", + runName: "OpenClaw Release Checks", + stepName: "Dispatch and monitor release checks", + workflow: "openclaw-release-checks.yml", + }, + { + jobName: "npm_telegram", + kind: "npm-telegram", + nonceSuffix: "-npm-telegram", + runName: "NPM Telegram Beta E2E", + stepName: "Dispatch and monitor npm Telegram E2E", + workflow: "npm-telegram-beta-e2e.yml", + }, + { + jobName: "performance", + kind: "performance", + nonceSuffix: "", + runName: "OpenClaw Performance", + stepName: "Dispatch and monitor OpenClaw Performance", + workflow: "openclaw-performance.yml", + }, +] as const; const REPO_ROOT = process.env.GITHUB_WORKSPACE ?? process.cwd(); const RELEASE_MAINTAINER_SKILL = resolve( REPO_ROOT, @@ -184,6 +226,192 @@ function expectTextToIncludeAll(text: string | undefined, snippets: string[]): v } } +function runFullReleaseChildDispatch( + child: (typeof FULL_RELEASE_CHILD_DISPATCHES)[number], + overrides: Record = {}, +) { + const step = workflowStep( + workflowJob(FULL_RELEASE_VALIDATION_WORKFLOW, child.jobName), + child.stepName, + ); + const script = step.run; + if (!script) { + throw new Error(`Expected full release child dispatch script for ${child.jobName}`); + } + + const workdir = tempDirs.make("full-release-child-dispatch-"); + const ghPath = resolve(workdir, "gh"); + const sleepPath = resolve(workdir, "sleep"); + const callsPath = resolve(workdir, "gh-calls.jsonl"); + const statusPath = resolve(workdir, "status-polls"); + writeFileSync(callsPath, ""); + writeFileSync( + ghPath, + `#!${process.execPath} +const fs = require("node:fs"); +const args = process.argv.slice(2); +const env = process.env; +fs.appendFileSync(env.MOCK_GH_CALLS, JSON.stringify({ + args, + childWorkflowRef: env.CHILD_WORKFLOW_REF, + dispatchRunName: env.DISPATCH_RUN_NAME, +}) + "\\n"); +const jobs = JSON.parse(env.MOCK_GH_JOBS); +const conclusion = env.MOCK_GH_CONCLUSION; +const url = "https://github.com/openclaw/openclaw/actions/runs/101"; +function nextStatus() { + const statuses = JSON.parse(env.MOCK_GH_STATUSES); + let index = 0; + try { index = Number(fs.readFileSync(env.MOCK_GH_STATUS_POLLS, "utf8")); } catch {} + fs.writeFileSync(env.MOCK_GH_STATUS_POLLS, String(index + 1)); + return statuses[Math.min(index, statuses.length - 1)]; +} +if (args[0] === "workflow" && args[1] === "run") { + if (env.MOCK_GH_DISPATCH_ERROR) { + console.error(env.MOCK_GH_DISPATCH_ERROR); + process.exit(1); + } + console.log("Created workflow_dispatch event."); +} else if (args[0] === "api" && args.some((value) => value.includes("/commits/"))) { + console.log(env.MOCK_GH_CURRENT_SHA); +} else if (args[0] === "api" && args.some((value) => value.includes("/actions/workflows/") && value.endsWith("/runs"))) { + console.log(env.MOCK_GH_MATCHES); +} else if (args[0] === "api" && args.some((value) => value.includes("/jobs?"))) { + if (env.MOCK_GH_JOBS_ERROR) { + console.error(env.MOCK_GH_JOBS_ERROR); + process.exit(1); + } + jobs.forEach((job) => console.log(JSON.stringify(job))); +} else if (args[0] === "api" && args.some((value) => value.includes("/actions/runs/"))) { + if (env.MOCK_GH_STATUS_ERROR && fs.existsSync(env.MOCK_GH_STATUS_POLLS)) { + console.error(env.MOCK_GH_STATUS_ERROR); + process.exit(1); + } + console.log(JSON.stringify({ + conclusion, + head_sha: env.MOCK_GH_CHILD_SHA, + html_url: url, + status: nextStatus(), + })); +} else if (args[0] === "run" && args[1] === "view") { + const field = args[args.indexOf("--json") + 1]; + if (field === "status" && env.MOCK_GH_STATUS_ERROR) { + console.error(env.MOCK_GH_STATUS_ERROR); + process.exit(1); + } + if (field === "jobs") { + if (env.MOCK_GH_JOBS_ERROR) { + console.error(env.MOCK_GH_JOBS_ERROR); + process.exit(1); + } + const query = args[args.indexOf("--jq") + 1]; + if (query.startsWith("[.jobs")) { + console.log(JSON.stringify(jobs.filter((job) => job.status === "completed" && job.conclusion !== "success" && job.conclusion !== "skipped"))); + } else { + jobs.forEach((job) => console.log(JSON.stringify(job))); + } + } else { + console.log({ + conclusion, + headSha: env.MOCK_GH_CHILD_SHA, + status: field === "status" ? nextStatus() : undefined, + url, + }[field]); + } +} else if (args[0] !== "run" || args[1] !== "cancel") { + console.error("Unexpected mock gh invocation: " + JSON.stringify(args)); + process.exit(2); +} +`, + ); + chmodSync(ghPath, 0o755); + writeFileSync(sleepPath, "#!/bin/sh\nexit 0\n"); + chmodSync(sleepPath, 0o755); + + const parentSha = "a".repeat(40); + const defaultJobs = [ + { + conclusion: "success", + html_url: "https://github.com/openclaw/openclaw/actions/runs/101/job/201", + name: "Verify release checks", + status: "completed", + url: "https://github.com/openclaw/openclaw/actions/runs/101/job/201", + }, + ]; + const stepValues: Record = { + ALLOW_UNRELEASED_CHANGELOG: "false", + CANDIDATE_ARTIFACT_JSON: "", + CHILD_WORKFLOW_KIND: child.kind, + CHILD_WORKFLOW_REF: "main", + CODEX_PLUGIN_SPEC: "", + CROSS_OS_SUITE_FILTER: "", + FAIL_FAST: "false", + GH_TOKEN: "fixture-token", + LIVE_SUITE_FILTER: "", + MODE: "both", + PACKAGE_ACCEPTANCE_PACKAGE_SPEC: "", + PACKAGE_SPEC: "openclaw@beta", + PARENT_WORKFLOW_SHA: parentSha, + PROVIDER: "openai", + PROVIDER_MODE: "mock-openai", + RELEASE_PACKAGE_SPEC: "", + RELEASE_PROFILE: "stable", + RERUN_GROUP: "all", + RUN_RELEASE_SOAK: "false", + SCENARIO: "", + TARGET_CONTEXT_REF: "", + TARGET_REF: "main", + TARGET_SHA: "b".repeat(40), + }; + const stepEnv = Object.fromEntries( + Object.keys(step.env ?? {}).map((name) => { + const value = stepValues[name]; + if (value === undefined) { + throw new Error(`Missing child dispatch fixture value for ${child.jobName}.${name}`); + } + return [name, value]; + }), + ); + const result = spawnSync("bash", ["-c", script], { + cwd: workdir, + encoding: "utf8", + env: { + ...stepEnv, + GH_TRANSIENT_SERVER_OR_NETWORK_PATTERN: + readWorkflow(FULL_RELEASE_VALIDATION_WORKFLOW).env + ?.GH_TRANSIENT_SERVER_OR_NETWORK_PATTERN ?? "HTTP 5[0-9][0-9]", + GITHUB_OUTPUT: resolve(workdir, "github-output"), + GITHUB_REPOSITORY: "openclaw/openclaw", + GITHUB_RUN_ATTEMPT: "2", + GITHUB_RUN_ID: "77", + GITHUB_STEP_SUMMARY: resolve(workdir, "github-summary"), + MOCK_GH_CALLS: callsPath, + MOCK_GH_CHILD_SHA: parentSha, + MOCK_GH_CONCLUSION: "success", + MOCK_GH_CURRENT_SHA: parentSha, + MOCK_GH_JOBS: JSON.stringify(defaultJobs), + MOCK_GH_MATCHES: "[101]", + MOCK_GH_STATUSES: '["completed"]', + MOCK_GH_STATUS_POLLS: statusPath, + PATH: `${workdir}:${process.env.PATH}`, + ...overrides, + }, + timeout: 10_000, + }); + const calls = readFileSync(callsPath, "utf8") + .split("\n") + .filter(Boolean) + .map( + (line) => + JSON.parse(line) as { + args: string[]; + childWorkflowRef: string; + dispatchRunName?: string; + }, + ); + return { calls, result }; +} + function runPackageAcceptanceSummary(params: { advisory?: boolean; dockerArtifactResult?: string; @@ -1205,10 +1433,10 @@ describe("package acceptance workflow", () => { it("requires full release child workflows to run at the parent workflow SHA", () => { const workflow = readFileSync(FULL_RELEASE_VALIDATION_WORKFLOW, "utf8"); const releaseChecksWorkflow = readFileSync(RELEASE_CHECKS_WORKFLOW, "utf8"); - const performanceJob = workflow.slice( - workflow.indexOf(" performance:\n"), - workflow.indexOf("\n summary:"), - ); + const performanceJob = workflowStep( + workflowJob(FULL_RELEASE_VALIDATION_WORKFLOW, "performance"), + "Dispatch and monitor OpenClaw Performance", + ).run; expect(workflow).toContain("TARGET_SHA: ${{ needs.resolve_target.outputs.sha }}"); expect(workflow).toContain("CHILD_WORKFLOW_REF: ${{ github.ref_name }}"); @@ -1315,22 +1543,24 @@ describe("package acceptance workflow", () => { }); it("keeps child-job fail-fast polling best-effort", () => { - const workflow = readFileSync(FULL_RELEASE_VALIDATION_WORKFLOW, "utf8"); - expect(workflow.match(/continuing with authoritative workflow conclusion\./gu)).toHaveLength(3); + for (const child of FULL_RELEASE_CHILD_DISPATCHES.slice(0, 3)) { + const dispatch = workflowStep( + workflowJob(FULL_RELEASE_VALIDATION_WORKFLOW, child.jobName), + child.stepName, + ); + expect(dispatch.env?.CHILD_WORKFLOW_KIND).toBe(child.kind); + expect(dispatch.run).toContain("continuing with authoritative workflow conclusion."); + } }); it("adopts exact full-release child runs without retrying ambiguous dispatch posts", () => { - const childDispatches = [ - ["normal_ci", "Dispatch and monitor CI"], - ["plugin_prerelease", "Dispatch and monitor plugin prerelease"], - ["release_checks", "Dispatch and monitor release checks"], - ["npm_telegram", "Dispatch and monitor npm Telegram E2E"], - ["performance", "Dispatch and monitor OpenClaw Performance"], - ] as const; - const dispatchScripts = childDispatches.map(([jobName, stepName]) => { - const job = workflowJob(FULL_RELEASE_VALIDATION_WORKFLOW, jobName); - return workflowStep(job, stepName).run ?? ""; + const dispatchScripts = FULL_RELEASE_CHILD_DISPATCHES.map((child) => { + const job = workflowJob(FULL_RELEASE_VALIDATION_WORKFLOW, child.jobName); + const step = workflowStep(job, child.stepName); + expect(step.env?.CHILD_WORKFLOW_KIND).toBe(child.kind); + return step.run ?? ""; }); + expect(new Set(dispatchScripts).size).toBe(1); for (const script of dispatchScripts) { expect(script.match(/gh workflow run/gu)).toHaveLength(1); @@ -1411,7 +1641,7 @@ describe("package acceptance workflow", () => { const workflow = readFileSync(FULL_RELEASE_VALIDATION_WORKFLOW, "utf8"); const retryCalls = workflow.split("\n").filter((line) => line.includes("gh_with_retry ")); - expect(retryCalls).toHaveLength(37); + expect(retryCalls.length).toBeGreaterThan(0); for (const call of retryCalls) { expect(call).toMatch(/gh_with_retry (api|run view)/u); } @@ -1437,6 +1667,254 @@ describe("package acceptance workflow", () => { ); }); + it.each(FULL_RELEASE_CHILD_DISPATCHES)( + "rejects moved workflow refs before dispatching $jobName", + (child) => { + const { calls, result } = runFullReleaseChildDispatch(child, { + MOCK_GH_CURRENT_SHA: "c".repeat(40), + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("refusing dispatch."); + expect(calls.filter(({ args }) => args[0] === "workflow")).toHaveLength(0); + }, + ); + + it.each(FULL_RELEASE_CHILD_DISPATCHES)( + "adopts the one exact $jobName child after an ambiguous dispatch without reposting", + (child) => { + const { calls, result } = runFullReleaseChildDispatch(child, { + MOCK_GH_DISPATCH_ERROR: "HTTP 500: Failed to run workflow dispatch", + }); + const dispatchCalls = calls.filter(({ args }) => args[0] === "workflow"); + const adoptionCall = calls.find(({ args }) => args.includes("-X")); + + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); + expect(result.stderr).toContain("adopted exact run 101"); + expect(dispatchCalls).toHaveLength(1); + expect(dispatchCalls[0]?.args.slice(0, 5)).toEqual([ + "workflow", + "run", + child.workflow, + "--ref", + "main", + ]); + expect(adoptionCall).toMatchObject({ + childWorkflowRef: "main", + dispatchRunName: `${child.runName} full-release-validation-77-2${child.nonceSuffix}`, + }); + expect(adoptionCall?.args).toContain( + "[.workflow_runs[] | select(.display_title == env.DISPATCH_RUN_NAME and .head_branch == env.CHILD_WORKFLOW_REF) | .id]", + ); + }, + ); + + it.each(FULL_RELEASE_CHILD_DISPATCHES)( + "refuses duplicate exact adoption candidates for $jobName", + (child) => { + const { calls, result } = runFullReleaseChildDispatch(child, { + MOCK_GH_MATCHES: "[101, 102]", + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("Multiple runs matched"); + expect(calls.filter(({ args }) => args[0] === "workflow")).toHaveLength(1); + expect(calls.filter(({ args }) => args[0] === "run" && args[1] === "cancel")).toHaveLength(0); + }, + ); + + it.each(FULL_RELEASE_CHILD_DISPATCHES)( + "refuses to adopt or retry a non-transient $jobName dispatch failure", + (child) => { + const { calls, result } = runFullReleaseChildDispatch(child, { + MOCK_GH_DISPATCH_ERROR: "HTTP 422: Validation Failed", + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("refusing adoption polling"); + expect(calls.filter(({ args }) => args[0] === "workflow")).toHaveLength(1); + expect(calls.some(({ args }) => args.includes("-X"))).toBe(false); + expect(calls.some(({ args }) => args[0] === "run" && args[1] === "cancel")).toBe(false); + }, + ); + + it.each(FULL_RELEASE_CHILD_DISPATCHES)( + "cancels exactly the adopted $jobName child when its workflow SHA differs", + (child) => { + const { calls, result } = runFullReleaseChildDispatch(child, { + MOCK_GH_CHILD_SHA: "c".repeat(40), + }); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("expected parent workflow SHA"); + expect(calls.filter(({ args }) => args[0] === "run" && args[1] === "cancel")).toEqual([ + expect.objectContaining({ args: ["run", "cancel", "101"] }), + ]); + }, + ); + + it.each(FULL_RELEASE_CHILD_DISPATCHES)( + "cancels exactly the adopted $jobName child when monitoring fails unexpectedly", + (child) => { + const { calls, result } = runFullReleaseChildDispatch(child, { + MOCK_GH_STATUS_ERROR: "HTTP 403: Resource not accessible by integration", + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("HTTP 403"); + expect(calls.filter(({ args }) => args[0] === "run" && args[1] === "cancel")).toEqual([ + expect.objectContaining({ args: ["run", "cancel", "101"] }), + ]); + }, + ); + + it.each(FULL_RELEASE_CHILD_DISPATCHES.slice(0, 4))( + "cancels the exact $jobName child after its first blocking failed job", + (child) => { + const { calls, result } = runFullReleaseChildDispatch(child, { + FAIL_FAST: "true", + MOCK_GH_JOBS: JSON.stringify([ + { + conclusion: "failure", + html_url: "https://github.com/openclaw/openclaw/actions/runs/101/job/201", + name: "Run package acceptance", + status: "completed", + url: "https://github.com/openclaw/openclaw/actions/runs/101/job/201", + }, + ]), + MOCK_GH_STATUSES: JSON.stringify([ + "in_progress", + "in_progress", + "in_progress", + "in_progress", + "in_progress", + "in_progress", + "completed", + ]), + }); + + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(1); + expect(result.stdout).toContain("has failed child jobs before the workflow completed"); + expect(calls.filter(({ args }) => args[0] === "run" && args[1] === "cancel")).toHaveLength(1); + }, + ); + + it("keeps CI fail-fast job lookups advisory but npm Telegram job lookups fail-closed", () => { + const overrides = { + FAIL_FAST: "true", + MOCK_GH_JOBS_ERROR: "HTTP 403: Resource not accessible by integration", + MOCK_GH_STATUSES: JSON.stringify([ + "in_progress", + "in_progress", + "in_progress", + "in_progress", + "in_progress", + "in_progress", + "completed", + ]), + }; + const normalCi = runFullReleaseChildDispatch(FULL_RELEASE_CHILD_DISPATCHES[0], overrides); + const npmTelegram = runFullReleaseChildDispatch(FULL_RELEASE_CHILD_DISPATCHES[3], overrides); + + expect(normalCi.result.status, normalCi.result.stderr).toBe(0); + expect(normalCi.result.stdout).toContain("continuing with authoritative workflow conclusion."); + expect(npmTelegram.result.status).toBe(1); + expect( + npmTelegram.calls.filter(({ args }) => args[0] === "run" && args[1] === "cancel"), + `${npmTelegram.result.stdout}\n${npmTelegram.result.stderr}\n${JSON.stringify(npmTelegram.calls)}`, + ).toHaveLength(1); + }); + + it.each([ + { expectedStatus: 0, jobName: "Run QA Lab parity lane (sqlite)" }, + { expectedStatus: 0, jobName: "Run QA Lab live Discord lane" }, + { expectedStatus: 0, jobName: "Run repo/live E2E validation / Docker live" }, + { + expectedStatus: 0, + jobName: "Run package acceptance / Telegram package acceptance / mock-openai", + }, + { expectedStatus: 1, jobName: "Run repo/live E2E validation / Repo E2E" }, + { expectedStatus: 1, jobName: "Run package acceptance / Verify package integrity" }, + ])("preserves beta fail-fast ownership for $jobName", ({ expectedStatus, jobName }) => { + const { calls, result } = runFullReleaseChildDispatch(FULL_RELEASE_CHILD_DISPATCHES[2], { + FAIL_FAST: "true", + MOCK_GH_JOBS: JSON.stringify([ + { + conclusion: "failure", + html_url: "https://github.com/openclaw/openclaw/actions/runs/101/job/201", + name: jobName, + status: "completed", + }, + ]), + MOCK_GH_STATUSES: JSON.stringify([ + "in_progress", + "in_progress", + "in_progress", + "in_progress", + "in_progress", + "in_progress", + "completed", + ]), + RELEASE_PROFILE: "beta", + }); + + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(expectedStatus); + expect(calls.filter(({ args }) => args[0] === "run" && args[1] === "cancel")).toHaveLength( + expectedStatus, + ); + }); + + it.each([ + { expectedStatus: 0, failOnRegression: "false", profile: "beta" }, + { expectedStatus: 1, failOnRegression: "true", profile: "stable" }, + ])( + "keeps failed product performance $profile release behavior unchanged", + ({ expectedStatus, failOnRegression, profile }) => { + const { calls, result } = runFullReleaseChildDispatch(FULL_RELEASE_CHILD_DISPATCHES[4], { + MOCK_GH_CONCLUSION: "failure", + RELEASE_PROFILE: profile, + }); + const dispatch = calls.find(({ args }) => args[0] === "workflow"); + + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(expectedStatus); + expect(dispatch?.args).toContain(`fail_on_regression=${failOnRegression}`); + if (profile === "beta") { + expect(result.stdout).toContain("advisory for beta"); + } + }, + ); + + it.each([ + { expectedStatus: 0, failingJob: "Run optional live-provider check" }, + { expectedStatus: 1, failingJob: "Run package acceptance" }, + ])("keeps Tideclaw alpha package-safety lanes blocking", ({ expectedStatus, failingJob }) => { + const { result } = runFullReleaseChildDispatch(FULL_RELEASE_CHILD_DISPATCHES[2], { + CHILD_WORKFLOW_REF: "tideclaw/alpha/2026-08-01-0000Z", + MOCK_GH_CONCLUSION: "failure", + MOCK_GH_JOBS: JSON.stringify([ + { + conclusion: "success", + html_url: "https://github.com/openclaw/openclaw/actions/runs/101/job/201", + name: "Verify release checks", + status: "completed", + }, + { + conclusion: "failure", + html_url: "https://github.com/openclaw/openclaw/actions/runs/101/job/202", + name: failingJob, + status: "completed", + }, + ]), + }); + + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(expectedStatus); + if (expectedStatus === 0) { + expect(result.stdout).toContain("accepted Tideclaw alpha advisory lanes"); + } else { + expect(result.stdout).toContain("package-safety Tideclaw alpha release-check lane"); + } + }); + it("keeps exhaustive update migration as a separate manual package gate", () => { const workflow = readFileSync(UPDATE_MIGRATION_WORKFLOW, "utf8"); const packageWorkflow = readFileSync(PACKAGE_ACCEPTANCE_WORKFLOW, "utf8"); @@ -3115,6 +3593,7 @@ describe("package artifact reuse", () => { expect(npmTelegramJob.if).toContain("inputs.rerun_group == 'npm-telegram'"); expect(npmTelegramJob.if).not.toContain("inputs.rerun_group == 'all'"); expect(dispatchStep.env).toEqual({ + CHILD_WORKFLOW_KIND: "npm-telegram", CHILD_WORKFLOW_REF: "${{ github.ref_name }}", FAIL_FAST: "${{ inputs.fail_fast }}", GH_TOKEN: "${{ github.token }}", @@ -3126,7 +3605,8 @@ describe("package artifact reuse", () => { }); expectTextToIncludeAll(dispatchStep.run, [ 'dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-npm-telegram"', - 'dispatch_output="$(gh workflow run npm-telegram-beta-e2e.yml --ref "$CHILD_WORKFLOW_REF" "${args[@]}" 2>&1)"', + 'dispatch_output="$(gh workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1)"', + 'dispatch_and_wait npm-telegram-beta-e2e.yml "$dispatch_run_name" "${args[@]}"', ".display_title == env.DISPATCH_RUN_NAME and .head_branch == env.CHILD_WORKFLOW_REF", "The dispatch was not retried to avoid creating a duplicate child.", 'if [[ "$child_head_sha" != "$PARENT_WORKFLOW_SHA" ]]; then', diff --git a/test/scripts/plugin-prerelease-test-plan.test.ts b/test/scripts/plugin-prerelease-test-plan.test.ts index 82c898c03778..8cf1934cca4d 100644 --- a/test/scripts/plugin-prerelease-test-plan.test.ts +++ b/test/scripts/plugin-prerelease-test-plan.test.ts @@ -446,7 +446,9 @@ describe("scripts/lib/plugin-prerelease-test-plan.mjs", () => { expect(releaseWorkflowSource).toContain('--arg targetContextRef "$TARGET_CONTEXT_REF"'); expect(releaseWorkflowSource).toContain("targetContextRef: $targetContextRef"); expect(normalCiScript).toContain('dispatch_and_wait ci.yml "$dispatch_run_name" "${args[@]}"'); - expect(normalCiScript).not.toContain("full_release_validation=true"); + const normalCiDispatchCase = normalCiScript.match(/^\s*ci\)\n([\s\S]*?)^\s*;;$/mu)?.[1]; + expect(normalCiDispatchCase).toContain('dispatch_and_wait ci.yml "$dispatch_run_name"'); + expect(normalCiDispatchCase).not.toContain("full_release_validation=true"); expect(pluginPrereleaseScript).toContain( 'args=(-f target_ref="$TARGET_SHA" -f expected_sha="$TARGET_SHA" -f full_release_validation=true -f dispatch_id="$dispatch_id")', ); @@ -676,10 +678,19 @@ describe("scripts/lib/plugin-prerelease-test-plan.mjs", () => { default: false, type: "boolean", }); - expect( - fullReleaseSource.match(/has failed child jobs before the workflow completed/gu)?.length, - ).toBeGreaterThanOrEqual(3); - expect(fullReleaseSource.match(/if \[\[ "\$FAIL_FAST" != "true" \]\]; then/gu)?.length).toBe(4); + for (const [jobName, kind] of [ + ["normal_ci", "ci"], + ["plugin_prerelease", "plugin-prerelease"], + ["release_checks", "release-checks"], + ["npm_telegram", "npm-telegram"], + ] as const) { + const dispatch: WorkflowStep = fullReleaseWorkflow.jobs[jobName].steps[0]; + expect(dispatch.env?.CHILD_WORKFLOW_KIND).toBe(kind); + expect(dispatch.env?.FAIL_FAST).toBe("${{ inputs.fail_fast }}"); + expect(dispatch.run).toContain('if [[ "$FAIL_FAST" != "true" ]]; then'); + expect(dispatch.run).toContain("has failed child jobs before the workflow completed"); + } + expect(fullReleaseWorkflow.jobs.performance.steps[0].env).not.toHaveProperty("FAIL_FAST"); expect(fullReleaseSource).toContain('-f fail_fast="$FAIL_FAST"'); expect(fullReleaseSource).toContain( "npm-telegram-beta-e2e.yml has failed child jobs before the workflow completed; cancelling the remaining run.", diff --git a/test/scripts/release-no-push-workflow.test.ts b/test/scripts/release-no-push-workflow.test.ts index 0634754efbb4..e626f82727f9 100644 --- a/test/scripts/release-no-push-workflow.test.ts +++ b/test/scripts/release-no-push-workflow.test.ts @@ -366,7 +366,8 @@ describe("release validation no-push transport", () => { expect(fullText).toContain("dispatch_and_wait plugin-prerelease.yml"); expect(fullText).toContain("dispatch_and_wait openclaw-release-checks.yml"); - expect(fullText).toContain("gh workflow run openclaw-performance.yml"); + expect(fullText).toContain("dispatch_and_wait openclaw-performance.yml"); + expect(fullText).toContain('gh workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@"'); const preparePackage = job(release, "prepare_release_package"); const live = job(release, "live_repo_e2e_release_checks"); From 4d6a63b7ee0a7456d067616eb0ab7d02aed04b91 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 12:18:11 -0700 Subject: [PATCH 19/28] fix(tasks): validate notification policy before persistence (#117554) Co-authored-by: Peter Steinberger --- src/tasks/task-registry-record-api.ts | 24 +++--- src/tasks/task-registry.store.test.ts | 108 +++++++++++++++++++++++++- 2 files changed, 120 insertions(+), 12 deletions(-) diff --git a/src/tasks/task-registry-record-api.ts b/src/tasks/task-registry-record-api.ts index d881c145f1e1..ff979491b58e 100644 --- a/src/tasks/task-registry-record-api.ts +++ b/src/tasks/task-registry-record-api.ts @@ -40,16 +40,17 @@ import { tasks, tryPersistTaskUpsert, } from "./task-registry-state.js"; -import type { - JsonValue, - TaskDeliveryState, - TaskDeliveryStatus, - TaskNotifyPolicy, - TaskRecord, - TaskRuntime, - TaskScopeKind, - TaskStatus, - TaskTerminalOutcome, +import { + parseTaskNotifyPolicy, + type JsonValue, + type TaskDeliveryState, + type TaskDeliveryStatus, + type TaskNotifyPolicy, + type TaskRecord, + type TaskRuntime, + type TaskScopeKind, + type TaskStatus, + type TaskTerminalOutcome, } from "./task-registry.types.js"; import { resolveTaskCleanupAfter } from "./task-retention.js"; @@ -531,9 +532,10 @@ export function updateTaskNotifyPolicyById(params: { taskId: string; notifyPolicy: TaskNotifyPolicy; }): TaskRecord | null { + const notifyPolicy = parseTaskNotifyPolicy(params.notifyPolicy); ensureTaskRegistryReady(); return updateTask(params.taskId, { - notifyPolicy: params.notifyPolicy, + notifyPolicy, lastEventAt: Date.now(), }); } diff --git a/src/tasks/task-registry.store.test.ts b/src/tasks/task-registry.store.test.ts index d4db16617ee8..e58d4b9ddf01 100644 --- a/src/tasks/task-registry.store.test.ts +++ b/src/tasks/task-registry.store.test.ts @@ -42,7 +42,7 @@ import { loadTaskRegistryStateFromSqlite, saveTaskRegistryStateToSqlite, } from "./task-registry.store.sqlite.js"; -import type { TaskDeliveryState, TaskRecord } from "./task-registry.types.js"; +import type { TaskDeliveryState, TaskNotifyPolicy, TaskRecord } from "./task-registry.types.js"; import { parseOptionalTaskTerminalOutcome, parseTaskDeliveryStatus, @@ -355,6 +355,112 @@ describe("task-registry store runtime", () => { ); }); + it.each(["verbose", "", "state-change", "DONE_ONLY"])( + "rejects an invalid notification policy before it can poison a SQLite restart (%s)", + async (invalidPolicy) => { + await withOpenClawTestState( + { layout: "state-only", prefix: "openclaw-task-invalid-notify-" }, + async () => { + resetTaskRegistryForTests(); + const created = createTaskRecord({ + runtime: "acp", + ownerKey: "agent:main:main", + scopeKind: "session", + childSessionKey: "agent:main:acp:notify-policy", + runId: "run-invalid-notify-policy", + task: "Keep the task registry readable", + status: "running", + deliveryStatus: "pending", + notifyPolicy: "done_only", + }); + const database = openOpenClawStateDatabase(); + const db = getNodeSqliteKysely(database.db); + + let mutationError: string | null = null; + try { + updateTaskNotifyPolicyById({ + taskId: created.taskId, + notifyPolicy: invalidPolicy as TaskNotifyPolicy, + }); + } catch (error) { + mutationError = error instanceof Error ? error.message : String(error); + } + + const persisted = executeSqliteQueryTakeFirstSync( + database.db, + db + .selectFrom("task_runs") + .select("notify_policy") + .where("task_id", "=", created.taskId), + ); + + let restoredPolicy: TaskNotifyPolicy | null = null; + let restoreError: string | null = null; + try { + reloadTaskRegistryFromStore(); + restoredPolicy = getTaskById(created.taskId)?.notifyPolicy ?? null; + } catch (error) { + restoreError = error instanceof Error ? error.message : String(error); + } + + try { + expect({ + mutationError, + persistedPolicy: persisted?.notify_policy, + restoredPolicy, + restoreError, + }).toEqual({ + mutationError: `Invalid persisted task notify policy: ${JSON.stringify(invalidPolicy)}`, + persistedPolicy: "done_only", + restoredPolicy: "done_only", + restoreError: null, + }); + } finally { + if (persisted?.notify_policy !== "done_only") { + executeSqliteQuerySync( + database.db, + db + .updateTable("task_runs") + .set({ notify_policy: "done_only" }) + .where("task_id", "=", created.taskId), + ); + } + resetTaskRegistryForTests({ persist: false }); + } + }, + ); + }, + ); + + it.each(["done_only", "state_changes", "silent"] as const)( + "persists valid notification policy %s across a fresh SQLite restart", + async (notifyPolicy) => { + await withOpenClawTestState( + { layout: "state-only", prefix: "openclaw-task-valid-notify-" }, + async () => { + resetTaskRegistryForTests(); + const created = createTaskRecord({ + runtime: "acp", + ownerKey: "agent:main:main", + scopeKind: "session", + childSessionKey: "agent:main:acp:notify-policy", + runId: "run-valid-notify-policy", + task: "Preserve valid notification policies", + status: "running", + deliveryStatus: "pending", + notifyPolicy: "done_only", + }); + + expect( + updateTaskNotifyPolicyById({ taskId: created.taskId, notifyPolicy })?.notifyPolicy, + ).toBe(notifyPolicy); + reloadTaskRegistryFromStore(); + expect(getTaskById(created.taskId)?.notifyPolicy).toBe(notifyPolicy); + }, + ); + }, + ); + it("rejects corrupt persisted task rows during sqlite restore", async () => { await withOpenClawTestState( { layout: "state-only", prefix: "openclaw-task-store-corrupt-" }, From 07822aaefdb249c2678bea03cca6c8db4863a650 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 2 Aug 2026 03:19:48 +0800 Subject: [PATCH 20/28] fix(ui): preserve Talk transcript surrogate bounds --- .../chat/realtime-talk-conversation.test.ts | 23 +++++++++++++++++++ .../pages/chat/realtime-talk-conversation.ts | 7 +++--- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/ui/src/pages/chat/realtime-talk-conversation.test.ts b/ui/src/pages/chat/realtime-talk-conversation.test.ts index 08a44cae000c..49cb5e1ab315 100644 --- a/ui/src/pages/chat/realtime-talk-conversation.test.ts +++ b/ui/src/pages/chat/realtime-talk-conversation.test.ts @@ -217,6 +217,29 @@ describe("realtime Talk conversation", () => { expect(state.entries[0]?.text.endsWith("NEWEST")).toBe(true); }); + it.each([255, 256])( + "does not retain a lone high surrogate before a natural marker at offset %i", + (markerOffset) => { + let state = createRealtimeTalkConversationState(); + const retainedText = "a".repeat(markerOffset - 1); + + state = updateRealtimeTalkConversation(state, { + role: "assistant", + text: `${retainedText}\uD800\n…\n${"b".repeat(8_000)}NEWEST`, + final: true, + nowMs: 1, + }); + + const text = state.entries[0]?.text ?? ""; + expect(text.length).toBeLessThanOrEqual(8_000); + expect(text.startsWith(`${retainedText}\n…\n`)).toBe(true); + expect(text.endsWith("NEWEST")).toBe(true); + expect(text).not.toMatch( + /(?:[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { diff --git a/ui/src/pages/chat/realtime-talk-conversation.ts b/ui/src/pages/chat/realtime-talk-conversation.ts index 04fbd493b041..74c275db500c 100644 --- a/ui/src/pages/chat/realtime-talk-conversation.ts +++ b/ui/src/pages/chat/realtime-talk-conversation.ts @@ -285,9 +285,10 @@ function boundRealtimeConversationText(text: string): string { const hasBoundedPrefix = markerIndex >= CONVERSATION_ENTRY_PREFIX_CHARS - 1 && markerIndex <= CONVERSATION_ENTRY_PREFIX_CHARS; - const prefix = hasBoundedPrefix - ? text.slice(0, markerIndex) - : sliceUtf16Safe(text, 0, CONVERSATION_ENTRY_PREFIX_CHARS); + const prefixEnd = hasBoundedPrefix ? markerIndex : CONVERSATION_ENTRY_PREFIX_CHARS; + // A natural marker can follow malformed provider text ending in a lone high + // surrogate. Keep that code unit out of the retained truncation boundary. + const prefix = sliceUtf16Safe(text, 0, prefixEnd).replace(/[\uD800-\uDBFF]$/, ""); const tailChars = MAX_CONVERSATION_ENTRY_CHARS - prefix.length - CONVERSATION_ENTRY_TRUNCATION_MARKER.length; const tail = sliceUtf16Safe(text, -tailChars); From c263c273c75008ed3d3e0788ffc8373726802ce4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 12:20:31 -0700 Subject: [PATCH 21/28] fix(status): honor explicit local RPC fallback timeouts (#117519) Co-authored-by: Peter Steinberger --- src/commands/status.scan.shared.test.ts | 140 +++++++++++++++++++----- src/commands/status.scan.shared.ts | 6 +- 2 files changed, 120 insertions(+), 26 deletions(-) diff --git a/src/commands/status.scan.shared.test.ts b/src/commands/status.scan.shared.test.ts index 58ca7f554cf2..a2c8a50d17ba 100644 --- a/src/commands/status.scan.shared.test.ts +++ b/src/commands/status.scan.shared.test.ts @@ -1,8 +1,19 @@ // Status scan shared tests cover gateway probe snapshots, Tailscale URLs, and shared scan helpers. +import { once } from "node:events"; +import type { AddressInfo } from "node:net"; import path from "node:path"; import { DatabaseSync } from "node:sqlite"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { WebSocketServer } from "ws"; import { cleanupTempDirs, makeTempDir } from "../../test/helpers/temp-dir.js"; +import { parseStatusRouteArgs } from "../cli/program/route-args.js"; +import { + buildMinimalGatewayHelloOkPayload, + closeMinimalGatewayServer, + parseMinimalGatewayRequestFrame, + sendMinimalGatewayConnectChallenge, + sendMinimalGatewayResponse, +} from "../gateway/minimal-gateway.test-helpers.js"; import { buildTailscaleHttpsUrl, resolveGatewayProbeSnapshot, @@ -325,39 +336,118 @@ describe("resolveGatewayProbeSnapshot", () => { expect(gatewayCall.timeoutMs).toBe(2000); }); - it("does not raise an explicit local status RPC fallback timeout", async () => { + it.each([1, 50, 999, 1000, 2000, 8000])( + "does not raise an explicit local status RPC fallback timeout (%i ms)", + async (timeoutMs) => { + mocks.resolveGatewayProbeTarget.mockReturnValue({ + mode: "local", + gatewayMode: "local", + remoteUrlMissing: false, + }); + mocks.probeGateway.mockResolvedValue({ + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: null, + error: "timeout", + close: null, + auth: { + role: null, + scopes: [], + capability: "unknown", + }, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + mocks.callGateway.mockResolvedValue({ sessions: 1 }); + + await resolveGatewayProbeSnapshot({ + cfg: {}, + opts: { timeoutMs }, + }); + + const probeCall = readProbeCall(); + expect(probeCall).not.toHaveProperty("preauthHandshakeTimeoutMs"); + expect(probeCall.timeoutMs).toBe(timeoutMs); + expect(readGatewayCall().timeoutMs).toBe(Math.min(2000, timeoutMs)); + }, + ); + + it("enforces an explicit CLI timeout against a real local fallback status RPC", async () => { + const gateway = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + await once(gateway, "listening"); + const address = gateway.address() as AddressInfo; + const url = `ws://127.0.0.1:${address.port}`; + const observedMethods: string[] = []; + gateway.on("connection", (socket) => { + sendMinimalGatewayConnectChallenge(socket); + socket.on("message", (data) => { + const frame = parseMinimalGatewayRequestFrame(data); + if (frame.type !== "req" || !frame.id || !frame.method) { + return; + } + const requestId = frame.id; + if (frame.method === "connect") { + sendMinimalGatewayResponse( + socket, + requestId, + buildMinimalGatewayHelloOkPayload({ + methods: ["system-presence", "status"], + auth: { role: "operator", scopes: ["operator.read"] }, + }), + ); + return; + } + observedMethods.push(frame.method); + if (frame.method === "status") { + const responseTimer = setTimeout(() => { + if (socket.readyState === socket.OPEN) { + sendMinimalGatewayResponse(socket, requestId, { sessions: 1 }); + } + }, 400); + responseTimer.unref(); + } + }); + }); + + mocks.buildGatewayConnectionDetailsWithResolvers.mockReturnValue({ + url, + urlSource: "local loopback", + message: `Gateway target: ${url}`, + }); mocks.resolveGatewayProbeTarget.mockReturnValue({ mode: "local", gatewayMode: "local", remoteUrlMissing: false, }); - mocks.probeGateway.mockResolvedValue({ - ok: false, - url: "ws://127.0.0.1:18789", - connectLatencyMs: null, - error: "timeout", - close: null, - auth: { - role: null, - scopes: [], - capability: "unknown", - }, - health: null, - status: null, - presence: null, - configSnapshot: null, + mocks.probeGateway.mockImplementation(async (...args: unknown[]) => { + const { probeGateway } = + await vi.importActual("../gateway/probe.js"); + return await probeGateway(...(args as Parameters)); }); - mocks.callGateway.mockResolvedValue({ sessions: 1 }); - - await resolveGatewayProbeSnapshot({ - cfg: {}, - opts: { timeoutMs: 1000 }, + mocks.callGateway.mockImplementation(async (...args: unknown[]) => { + const { callGateway } = + await vi.importActual("../gateway/call.js"); + return await callGateway(...(args as Parameters)); }); + const parsed = parseStatusRouteArgs(["node", "openclaw", "status", "--timeout", "250"]); + expect(parsed?.timeoutMs).toBe(250); - const probeCall = readProbeCall(); - expect(probeCall).not.toHaveProperty("preauthHandshakeTimeoutMs"); - expect(probeCall.timeoutMs).toBe(1000); - expect(readGatewayCall().timeoutMs).toBe(1000); + try { + const result = await resolveGatewayProbeSnapshot({ + cfg: { gateway: { auth: { mode: "none" } } }, + opts: { timeoutMs: parsed?.timeoutMs }, + }); + + expect(readProbeCall().timeoutMs).toBe(250); + expect(readGatewayCall().timeoutMs).toBe(250); + expect(observedMethods).toEqual(["system-presence", "status"]); + expect(result.gatewayProbe?.ok).toBe(false); + expect(result.gatewayProbe?.error).toContain("timeout"); + } finally { + await closeMinimalGatewayServer(gateway); + } }); it("lets callGateway reuse paired-device auth for local status RPC fallback", async () => { diff --git a/src/commands/status.scan.shared.ts b/src/commands/status.scan.shared.ts index fa70f4abd7b4..eb61f71bd4d0 100644 --- a/src/commands/status.scan.shared.ts +++ b/src/commands/status.scan.shared.ts @@ -208,7 +208,11 @@ async function applyLocalStatusRpcFallback(params: { if (!shouldTryLocalStatusRpcFallback(params)) { return params.gatewayProbe; } - const boundedFallbackTimeoutMs = Math.min(2000, Math.max(1000, params.timeoutMs)); + // Explicit probe budgets are operator-owned; only implicit fallback defaults get a floor. + const boundedFallbackTimeoutMs = Math.min( + 2000, + params.timeoutMsExplicit ? params.timeoutMs : Math.max(1000, params.timeoutMs), + ); // The fallback uses the gateway status RPC because it can succeed after probe handshake ambiguity. const status = await loadGatewayCallModule() .then(({ callGateway }) => From 1a0d3b5c4023871e896a40b28b84542e00134e9a Mon Sep 17 00:00:00 2001 From: zengLingbiao Date: Sun, 2 Aug 2026 03:26:27 +0800 Subject: [PATCH 22/28] fix(feishu): cancel unread streaming-card error bodies before release (#117312) --- .../src/streaming-card.error-release.test.ts | 151 ++++++++++++++++++ extensions/feishu/src/streaming-card.ts | 12 ++ 2 files changed, 163 insertions(+) create mode 100644 extensions/feishu/src/streaming-card.error-release.test.ts diff --git a/extensions/feishu/src/streaming-card.error-release.test.ts b/extensions/feishu/src/streaming-card.error-release.test.ts new file mode 100644 index 000000000000..b540899387ca --- /dev/null +++ b/extensions/feishu/src/streaming-card.error-release.test.ts @@ -0,0 +1,151 @@ +// Feishu streaming card tests exercise error-path response body cancellation +// through a real guarded HTTP transport against a loopback server. +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const loopback = vi.hoisted(() => ({ + baseUrl: "", + releases: [] as Array<{ bodyIsNull: boolean; bodyUsed: boolean }>, + authStatus: 200, + createStatus: 200, + settingsStatus: 200, +})); + +vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchWithSsrFGuard: async (...args: Parameters) => { + const [params] = args; + const url = new URL(params.url); + const redirected = new URL(`${url.pathname}${url.search}`, loopback.baseUrl).toString(); + const guarded = await actual.fetchWithSsrFGuard({ + ...params, + policy: { allowPrivateNetwork: true }, + url: redirected, + }); + return { + ...guarded, + release: async () => { + loopback.releases.push({ + bodyIsNull: guarded.response.body === null, + bodyUsed: guarded.response.bodyUsed, + }); + await guarded.release(); + }, + }; + }, + }; +}); + +const { FeishuStreamingSession } = await import("./streaming-card.js"); + +function writeJson(res: import("node:http").ServerResponse, payload: unknown, status = 200): void { + res.writeHead(status, { "content-type": "application/json" }); + res.end(JSON.stringify(payload)); +} + +let server: Server; + +beforeAll(async () => { + server = createServer((req, res) => { + const url = new URL(req.url ?? "/", "http://127.0.0.1"); + if (url.pathname.includes("/auth/")) { + if (loopback.authStatus === 200) { + writeJson(res, { code: 0, msg: "ok", tenant_access_token: "token", expire: 3600 }); + } else { + writeJson(res, { error: "tenant token rejected" }, loopback.authStatus); + } + return; + } + if (url.pathname.endsWith("/settings")) { + if (loopback.settingsStatus === 200) { + writeJson(res, { code: 0, msg: "ok" }); + } else { + writeJson(res, { error: "settings rejected" }, loopback.settingsStatus); + } + return; + } + if (loopback.createStatus === 200) { + writeJson(res, { code: 0, msg: "ok", data: { card_id: "card_1" } }); + } else { + writeJson(res, { error: "card create rejected" }, loopback.createStatus); + } + }); + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address() as AddressInfo; + loopback.baseUrl = `http://127.0.0.1:${address.port}`; +}); + +afterAll(async () => { + await new Promise((resolve) => { + server.close(() => resolve()); + }); +}); + +beforeEach(() => { + loopback.releases = []; + loopback.authStatus = 200; + loopback.createStatus = 200; + loopback.settingsStatus = 200; +}); + +describe("feishu streaming card error-path body release", () => { + it("cancels the unread tenant-token error body before release", async () => { + loopback.authStatus = 500; + const session = new FeishuStreamingSession({} as never, { + appId: "app_error_token", + appSecret: "secret", + }); + + await expect(session.start("chat_id", "open_id")).rejects.toThrow( + "Token request failed with HTTP 500", + ); + + expect(loopback.releases).toEqual([{ bodyIsNull: false, bodyUsed: true }]); + }); + + it("cancels the unread create-card error body before release", async () => { + loopback.createStatus = 500; + const session = new FeishuStreamingSession({} as never, { + appId: "app_error_create", + appSecret: "secret", + }); + + await expect(session.start("chat_id", "open_id")).rejects.toThrow( + "Create card request failed with HTTP 500", + ); + + expect(loopback.releases).toEqual([ + { bodyIsNull: false, bodyUsed: true }, + { bodyIsNull: false, bodyUsed: true }, + ]); + }); + + it("cancels the unread close-settings error body before release", async () => { + const client = { + im: { + message: { + create: async () => ({ code: 0, data: { message_id: "msg_1" } }), + }, + }, + }; + const session = new FeishuStreamingSession(client as never, { + appId: "app_error_close", + appSecret: "secret", + }); + await session.start("chat_id", "open_id"); + loopback.settingsStatus = 500; + + await expect(session.close()).resolves.toBe(false); + + expect(loopback.releases).toEqual([ + { bodyIsNull: false, bodyUsed: true }, + { bodyIsNull: false, bodyUsed: true }, + { bodyIsNull: false, bodyUsed: true }, + ]); + }); +}); diff --git a/extensions/feishu/src/streaming-card.ts b/extensions/feishu/src/streaming-card.ts index 086d805a56a9..88c00f972b92 100644 --- a/extensions/feishu/src/streaming-card.ts +++ b/extensions/feishu/src/streaming-card.ts @@ -128,12 +128,22 @@ function resolveAllowedHostnames(domain?: FeishuDomain): string[] { return ["open.feishu.cn"]; } +function cancelUnreadResponseBody(response: Response): void { + // A rejected response leaves its body unread; start cancellation before the + // guarded dispatcher is released so the connection is not leaked. Do not + // await: debug capture can tee the stream and deadlock a waiter. + if (!response.bodyUsed) { + void response.body?.cancel().catch(() => undefined); + } +} + async function assertSuccessfulCardKitResponse( response: Response, auditContext: string, action: string, ): Promise { if (!response.ok) { + cancelUnreadResponseBody(response); throw new Error(`${action} failed with HTTP ${response.status}`); } const data = await readFeishuJsonResponse(response, auditContext); @@ -174,6 +184,7 @@ async function getToken(creds: Credentials, deps?: FeishuStreamingDeps): Promise }; try { if (!response.ok) { + cancelUnreadResponseBody(response); throw new Error(`Token request failed with HTTP ${response.status}`); } data = await readFeishuJsonResponse(response, "feishu.streaming-card.token"); @@ -328,6 +339,7 @@ export class FeishuStreamingSession { }; try { if (!createRes.ok) { + cancelUnreadResponseBody(createRes); throw new Error(`Create card request failed with HTTP ${createRes.status}`); } createData = await readFeishuJsonResponse(createRes, "feishu.streaming-card.create"); From 2a5a64b51f5c50974b7bfc35f14e46dc0faa8f0a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 12:26:58 -0700 Subject: [PATCH 23/28] fix(tasks): sanitize every human task and flow detail (#117568) Co-authored-by: Peter Steinberger --- src/commands/flows.test.ts | 232 +++++++++++++++++++++++++++++++++++++ src/commands/flows.ts | 33 ++++-- src/commands/tasks.test.ts | 218 ++++++++++++++++++++++++++++++---- src/commands/tasks.ts | 55 +++++---- 4 files changed, 480 insertions(+), 58 deletions(-) diff --git a/src/commands/flows.test.ts b/src/commands/flows.test.ts index 83c41e15f5cc..ce4262dcb3b6 100644 --- a/src/commands/flows.test.ts +++ b/src/commands/flows.test.ts @@ -4,6 +4,7 @@ import type { RuntimeEnv } from "../runtime.js"; import { createRunningTaskRun as createRunningTaskRunOrNull } from "../tasks/task-executor.js"; import { createManagedTaskFlow as createManagedTaskFlowOrNull } from "../tasks/task-flow-registry.js"; import type { TaskFlowRecord } from "../tasks/task-flow-registry.types.js"; +import { markTaskLostById, markTaskTerminalById } from "../tasks/task-registry.js"; import type { TaskRecord } from "../tasks/task-registry.types.js"; import { resetTaskFlowRegistryForTests, @@ -282,6 +283,237 @@ describe("flows commands", () => { }); }); + it.each(["failed", "timed_out", "lost"] as const)( + "shows the persisted failure reason for linked %s tasks", + async (status) => { + await withTaskFlowCommandStateDir(async () => { + const flow = createManagedTaskFlow({ + ownerKey: "agent:main:main", + controllerId: "tests/flows-command-failure-detail", + goal: "Inspect child task failures", + status: "running", + }); + const task = createRunningTaskRun({ + runtime: "subagent", + ownerKey: "agent:main:main", + scopeKind: "session", + parentFlowId: flow.flowId, + childSessionKey: `agent:main:flow-child-${status}`, + runId: `run-flow-child-${status}`, + label: "Inspect linked child", + task: "Inspect linked child", + notifyPolicy: "silent", + startedAt: Date.now(), + progressSummary: "Outdated child progress", + }); + const error = `${status}: linked provider credentials need attention`; + const endedAt = Date.now(); + + if (status === "lost") { + markTaskLostById({ taskId: task.taskId, endedAt, error }); + } else { + markTaskTerminalById({ + taskId: task.taskId, + status, + endedAt, + error, + terminalSummary: "Generic child completion summary", + }); + } + + const runtime = createRuntime(); + await flowsShowCommand({ lookup: flow.flowId }, runtime); + + const lines = vi.mocked(runtime.log).mock.calls.map(([line]) => String(line)); + const linkedTaskLine = lines.find((line) => line.startsWith(`- ${task.taskId} `)); + expect(linkedTaskLine).toContain("Inspect linked child"); + expect(linkedTaskLine).toContain(error); + expect(linkedTaskLine).not.toContain("Outdated child progress"); + expect(linkedTaskLine).not.toContain("Generic child completion summary"); + + const jsonRuntime = createRuntime(); + await flowsShowCommand({ lookup: flow.flowId, json: true }, jsonRuntime); + expect(vi.mocked(jsonRuntime.writeJson).mock.calls[0]?.[0]).toMatchObject({ + tasks: [expect.objectContaining({ status, error })], + }); + }); + }, + ); + + it("includes running progress and terminal completion summaries for linked tasks", async () => { + await withTaskFlowCommandStateDir(async () => { + const flow = createManagedTaskFlow({ + ownerKey: "agent:main:main", + controllerId: "tests/flows-command-task-progress", + goal: "Inspect child task updates", + status: "running", + }); + const running = createRunningTaskRun({ + runtime: "subagent", + ownerKey: "agent:main:main", + scopeKind: "session", + parentFlowId: flow.flowId, + childSessionKey: "agent:main:flow-child-running", + runId: "run-flow-child-running", + label: "Inspect running child", + task: "Inspect running child", + notifyPolicy: "silent", + startedAt: Date.now(), + progressSummary: "Downloading provider metadata", + }); + const completed = createRunningTaskRun({ + runtime: "subagent", + ownerKey: "agent:main:main", + scopeKind: "session", + parentFlowId: flow.flowId, + childSessionKey: "agent:main:flow-child-completed", + runId: "run-flow-child-completed", + label: "Inspect completed child", + task: "Inspect completed child", + notifyPolicy: "silent", + startedAt: Date.now(), + }); + markTaskTerminalById({ + taskId: completed.taskId, + status: "succeeded", + endedAt: Date.now(), + terminalSummary: "Provider metadata refreshed", + }); + + const runtime = createRuntime(); + await flowsShowCommand({ lookup: flow.flowId }, runtime); + + const lines = vi.mocked(runtime.log).mock.calls.map(([line]) => String(line)); + expect(lines.find((line) => line.startsWith(`- ${running.taskId} `))).toContain( + "Downloading provider metadata", + ); + expect(lines.find((line) => line.startsWith(`- ${completed.taskId} `))).toContain( + "Provider metadata refreshed", + ); + }); + }); + + it("sanitizes linked task failure reasons before terminal display", async () => { + await withTaskFlowCommandStateDir(async () => { + const flow = createManagedTaskFlow({ + ownerKey: "agent:main:main", + controllerId: "tests/flows-command-task-safety", + goal: "Inspect unsafe child error", + status: "running", + }); + const task = createRunningTaskRun({ + runtime: "subagent", + ownerKey: "agent:main:main", + scopeKind: "session", + parentFlowId: flow.flowId, + childSessionKey: "agent:main:flow-child-safety", + runId: "run-flow-child-safety", + label: "Inspect child safely", + task: "Inspect child safely", + notifyPolicy: "silent", + startedAt: Date.now(), + }); + markTaskTerminalById({ + taskId: task.taskId, + status: "failed", + endedAt: Date.now(), + error: "Provider \u001b[31mrejected\nforged: yes", + }); + + const runtime = createRuntime(); + await flowsShowCommand({ lookup: flow.flowId }, runtime); + + const lines = vi.mocked(runtime.log).mock.calls.map(([line]) => String(line)); + const linkedTaskLine = lines.find((line) => line.startsWith(`- ${task.taskId} `)); + expect(linkedTaskLine).toContain("Provider rejected forged: yes"); + expect(linkedTaskLine).not.toContain("\u001b"); + expect(linkedTaskLine).not.toContain("\n"); + }); + }); + + it("sanitizes persisted linked task identifiers while preserving raw flow JSON", async () => { + await withTaskFlowCommandStateDir(async () => { + const unsafe = "\u001b]52;c;Zm9yZ2Vk\u0007\nforged: yes"; + const flow = createManagedTaskFlow({ + ownerKey: "agent:main:main", + controllerId: `controller${unsafe}`, + goal: `goal${unsafe}`, + currentStep: `step${unsafe}`, + status: "running", + }); + const task = createRunningTaskRun({ + runtime: "subagent", + ownerKey: "agent:main:main", + scopeKind: "session", + parentFlowId: flow.flowId, + childSessionKey: `agent:main:child${unsafe}`, + runId: `run${unsafe}`, + label: `label${unsafe}`, + task: `prompt${unsafe}`, + notifyPolicy: "silent", + startedAt: Date.now(), + }); + markTaskTerminalById({ + taskId: task.taskId, + status: "failed", + endedAt: Date.now(), + error: `error${unsafe}`, + }); + + const humanRuntime = createRuntime(); + await flowsShowCommand({ lookup: flow.flowId }, humanRuntime); + + const lines = vi.mocked(humanRuntime.log).mock.calls.map(([line]) => String(line)); + const linkedTaskLine = lines.find((line) => line.startsWith(`- ${task.taskId} `)); + expect(linkedTaskLine).toContain("label"); + expect(linkedTaskLine).toContain("error"); + for (const line of lines) { + expect(line).not.toContain("\u001b"); + expect(line).not.toContain("\u0007"); + expect(line).not.toContain("\n"); + } + + const jsonRuntime = createRuntime(); + await flowsShowCommand({ lookup: flow.flowId, json: true }, jsonRuntime); + expect(vi.mocked(jsonRuntime.writeJson).mock.calls[0]?.[0]).toMatchObject({ + goal: `goal${unsafe}`, + currentStep: `step${unsafe}`, + tasks: [ + expect.objectContaining({ + childSessionKey: `agent:main:child${unsafe}`, + runId: `run${unsafe}`, + label: `label${unsafe}`, + task: `prompt${unsafe}`, + error: `error${unsafe}`, + }), + ], + }); + }); + }); + + it("sanitizes untrusted TaskFlow filters and lookup errors", async () => { + await withTaskFlowCommandStateDir(async () => { + const unsafe = "\u001b]52;c;Zm9yZ2Vk\u0007\nforged: yes"; + const filterRuntime = createRuntime(); + await flowsListCommand({ status: `running${unsafe}` }, filterRuntime); + + const lookupRuntime = createRuntime(); + await flowsShowCommand({ lookup: `missing${unsafe}` }, lookupRuntime); + + const lines = [ + ...vi.mocked(filterRuntime.log).mock.calls.map(([line]) => String(line)), + ...vi.mocked(lookupRuntime.error).mock.calls.map(([line]) => String(line)), + ]; + expect(lines.some((line) => line.includes("Status filter: running"))).toBe(true); + expect(lines.some((line) => line.includes("TaskFlow not found: missing"))).toBe(true); + for (const line of lines) { + expect(line).not.toContain("\u001b"); + expect(line).not.toContain("\u0007"); + expect(line).not.toContain("\n"); + } + }); + }); + it("shows TaskFlows with Date-invalid timestamps without crashing", async () => { await withTaskFlowCommandStateDir(async () => { const flow = createManagedTaskFlow({ diff --git a/src/commands/flows.ts b/src/commands/flows.ts index ad85553ee992..56e51c992f00 100644 --- a/src/commands/flows.ts +++ b/src/commands/flows.ts @@ -17,6 +17,7 @@ import { listTaskFlowRecords, resolveTaskFlowForLookupToken, } from "../tasks/task-flow-runtime-internal.js"; +import { formatTaskStatusDetail } from "../tasks/task-status.js"; const ID_PAD = 10; const STATUS_PAD = 10; @@ -25,7 +26,7 @@ const REV_PAD = 6; const CTRL_PAD = 20; function formatFlowLookupMiss(lookup: string): string { - return `TaskFlow not found: ${lookup}. Run ${formatCliCommand("openclaw tasks flow list")} to see recent flow ids.`; + return `TaskFlow not found: ${sanitizeTerminalText(lookup)}. Run ${formatCliCommand("openclaw tasks flow list")} to see recent flow ids.`; } function truncate(value: string, maxChars: number) { @@ -47,11 +48,7 @@ function safeFlowDisplayText(value: string | undefined, maxChars?: number): stri } function shortToken(value: string | undefined, maxChars = ID_PAD): string { - const trimmed = normalizeOptionalString(value); - if (!trimmed) { - return "n/a"; - } - return truncate(trimmed, maxChars); + return safeFlowDisplayText(normalizeOptionalString(value), maxChars); } function formatFlowTimestamp(value: number | undefined | null): string { @@ -178,7 +175,7 @@ export async function flowsListCommand( runtime.log(info(`TaskFlows: ${flows.length}`)); runtime.log(info(`TaskFlow pressure: ${formatFlowListSummary(flows)}`)); if (statusFilter) { - runtime.log(info(`Status filter: ${statusFilter}`)); + runtime.log(info(`Status filter: ${sanitizeTerminalText(statusFilter)}`)); } if (flows.length === 0) { runtime.log( @@ -234,7 +231,7 @@ export async function flowsShowCommand( `tasks: ${taskSummary.total} total · ${taskSummary.active} active · ${taskSummary.failures} issues`, ]; for (const line of lines) { - runtime.log(line); + runtime.log(sanitizeTerminalText(line)); } if (tasks.length === 0) { runtime.log("Linked tasks: none"); @@ -243,7 +240,13 @@ export async function flowsShowCommand( runtime.log("Linked tasks:"); for (const task of tasks) { const safeLabel = safeFlowDisplayText(task.label ?? task.task); - runtime.log(`- ${task.taskId} ${task.status} ${task.runId ?? "n/a"} ${safeLabel}`); + const detail = formatTaskStatusDetail(task); + const safeDetail = detail ? ` · ${safeFlowDisplayText(detail)}` : ""; + runtime.log( + sanitizeTerminalText( + `- ${task.taskId} ${task.status} ${safeFlowDisplayText(task.runId)} ${safeLabel}${safeDetail}`, + ), + ); } } @@ -260,15 +263,21 @@ export async function flowsCancelCommand(opts: { lookup: string }, runtime: Runt flowId: flow.flowId, }); if (!result.found) { - runtime.error(result.reason ?? formatFlowLookupMiss(opts.lookup)); + runtime.error(sanitizeTerminalText(result.reason ?? formatFlowLookupMiss(opts.lookup))); runtime.exit(1); return; } if (!result.cancelled) { - runtime.error(result.reason ?? `Could not cancel TaskFlow: ${opts.lookup}`); + runtime.error( + sanitizeTerminalText(result.reason ?? `Could not cancel TaskFlow: ${opts.lookup}`), + ); runtime.exit(1); return; } const updated = getTaskFlowById(flow.flowId) ?? result.flow ?? flow; - runtime.log(`Cancelled ${updated.flowId} (${updated.syncMode}) with status ${updated.status}.`); + runtime.log( + sanitizeTerminalText( + `Cancelled ${updated.flowId} (${updated.syncMode}) with status ${updated.status}.`, + ), + ); } diff --git a/src/commands/tasks.test.ts b/src/commands/tasks.test.ts index 084f87c5c513..32cb5a917269 100644 --- a/src/commands/tasks.test.ts +++ b/src/commands/tasks.test.ts @@ -12,6 +12,8 @@ import type { TaskFlowRecord } from "../tasks/task-flow-registry.types.js"; import { createTaskRecord as createTaskRecordOrNull, getTaskById, + markTaskLostById, + markTaskTerminalById, reloadTaskRegistryFromStore, } from "../tasks/task-registry.js"; import * as taskRegistryMaintenance from "../tasks/task-registry.maintenance.js"; @@ -79,6 +81,28 @@ function jsonRoundTrip(value: T): T { return JSON.parse(serialized) as T; } +const UNSAFE_TASK_TERMINAL_TEXT = "\u001b]52;c;Zm9yZ2Vk\u0007\nforged: yes"; + +function createInspectableTask(params: Partial[0]> = {}) { + return createTaskRecord({ + runtime: "cli", + ownerKey: "agent:main:main", + scopeKind: "session", + status: "running", + notifyPolicy: "silent", + task: "Inspect a background task", + ...params, + }); +} + +function expectSafeTaskOutput(runtime: RuntimeEnv, channel: "log" | "error" = "log") { + for (const [line] of vi.mocked(runtime[channel]).mock.calls) { + for (const control of ["\u001b", "\u0007", "\n", "\r"]) { + expect(String(line)).not.toContain(control); + } + } +} + const zeroTaskAuditCounts = { delivery_failed: 0, inconsistent_timestamps: 0, @@ -97,31 +121,28 @@ async function writeSessionEntries( } } +function resetTaskCommandRuntime() { + taskRegistryMaintenance.stopTaskRegistryMaintenance(); + taskRegistryMaintenance.resetTaskRegistryMaintenanceRuntimeForTests(); + resetConfigRuntimeState(); + resetDetachedTaskLifecycleRuntimeForTests(); + resetTaskRegistryDeliveryRuntimeForTests(); + resetTaskRegistryForTests({ persist: false }); + resetTaskFlowRegistryForTests({ persist: false }); + closeOpenClawAgentDatabasesForTest(); +} + async function withTaskCommandStateDir( run: (state: OpenClawTestState) => Promise, ): Promise { await withOpenClawTestState( { layout: "state-only", prefix: "openclaw-tasks-command-" }, async (state) => { - taskRegistryMaintenance.stopTaskRegistryMaintenance(); - taskRegistryMaintenance.resetTaskRegistryMaintenanceRuntimeForTests(); - resetConfigRuntimeState(); - resetDetachedTaskLifecycleRuntimeForTests(); - resetTaskRegistryDeliveryRuntimeForTests(); - resetTaskRegistryForTests({ persist: false }); - resetTaskFlowRegistryForTests({ persist: false }); - closeOpenClawAgentDatabasesForTest(); + resetTaskCommandRuntime(); try { await run(state); } finally { - taskRegistryMaintenance.stopTaskRegistryMaintenance(); - taskRegistryMaintenance.resetTaskRegistryMaintenanceRuntimeForTests(); - resetConfigRuntimeState(); - resetDetachedTaskLifecycleRuntimeForTests(); - resetTaskRegistryDeliveryRuntimeForTests(); - resetTaskRegistryForTests({ persist: false }); - resetTaskFlowRegistryForTests({ persist: false }); - closeOpenClawAgentDatabasesForTest(); + resetTaskCommandRuntime(); } }, ); @@ -134,14 +155,7 @@ describe("tasks commands", () => { afterEach(() => { vi.useRealTimers(); - taskRegistryMaintenance.stopTaskRegistryMaintenance(); - taskRegistryMaintenance.resetTaskRegistryMaintenanceRuntimeForTests(); - resetConfigRuntimeState(); - resetDetachedTaskLifecycleRuntimeForTests(); - resetTaskRegistryDeliveryRuntimeForTests(); - resetTaskRegistryForTests({ persist: false }); - resetTaskFlowRegistryForTests({ persist: false }); - closeOpenClawAgentDatabasesForTest(); + resetTaskCommandRuntime(); mocks.callGateway.mockReset(); }); @@ -390,6 +404,54 @@ describe("tasks commands", () => { }); }); + it.each(["gateway", "local"] as const)( + "sanitizes untrusted %s task cancellation output", + async (owner) => { + await withTaskCommandStateDir(async () => { + const unsafe = UNSAFE_TASK_TERMINAL_TEXT; + const gatewayOwned = owner === "gateway"; + const task = createInspectableTask({ + runtime: gatewayOwned ? "cron" : "cli", + ownerKey: gatewayOwned ? "" : "agent:main:main", + scopeKind: gatewayOwned ? "system" : "session", + runId: `run${unsafe}`, + }); + if (gatewayOwned) { + mocks.callGateway.mockResolvedValueOnce({ + found: true, + cancelled: true, + task: { + taskId: `${task.taskId}${unsafe}`, + runtime: `cron${unsafe}`, + runId: task.runId, + }, + }); + } + const runtime = createRuntime(); + await tasksCancelCommand({ lookup: task.taskId }, runtime); + expect(runtime.log).toHaveBeenCalledWith( + expect.stringContaining(`Cancelled ${task.taskId}`), + ); + expectSafeTaskOutput(runtime); + if (!gatewayOwned) { + expect(getTaskById(task.taskId)).toMatchObject({ + status: "cancelled", + runId: `run${unsafe}`, + }); + return; + } + mocks.callGateway.mockResolvedValueOnce({ + found: true, + cancelled: false, + reason: `gateway refused${unsafe}`, + }); + const failureRuntime = createRuntime(); + await tasksCancelCommand({ lookup: task.taskId }, failureRuntime); + expectSafeTaskOutput(failureRuntime, "error"); + }); + }, + ); + it("fails ACP task cancellation loudly when the live gateway is unavailable", async () => { await withTaskCommandStateDir(async () => { const task = createTaskRecord({ @@ -680,6 +742,110 @@ describe("tasks commands", () => { }); }); + it("sanitizes every persisted task surface while preserving raw task JSON", async () => { + await withTaskCommandStateDir(async () => { + const unsafe = UNSAFE_TASK_TERMINAL_TEXT; + const task = createInspectableTask({ + sourceId: `source${unsafe}`, + childSessionKey: `agent:main:child${unsafe}`, + parentTaskId: `parent${unsafe}`, + agentId: `worker${unsafe}`, + runId: `run${unsafe}`, + label: `label${unsafe}`, + task: `prompt${unsafe}`, + progressSummary: `progress${unsafe}`, + terminalSummary: `summary${unsafe}`, + }); + markTaskLostById({ taskId: task.taskId, endedAt: Date.now(), error: `error${unsafe}` }); + const showRuntime = createRuntime(); + const listRuntime = createRuntime(); + const auditRuntime = createRuntime(); + await tasksShowCommand({ lookup: task.taskId }, showRuntime); + await tasksListCommand({}, listRuntime); + await tasksAuditCommand({}, auditRuntime); + for (const runtime of [showRuntime, listRuntime, auditRuntime]) { + expectSafeTaskOutput(runtime); + } + const shown = vi + .mocked(showRuntime.log) + .mock.calls.map(([line]) => String(line)) + .join("|"); + for (const field of [ + "sourceId", + "childSessionKey", + "parentTaskId", + "agentId", + "runId", + "label", + "task", + "error", + "progressSummary", + "terminalSummary", + ]) { + expect(shown).toContain(`${field}:`); + } + expect(vi.mocked(listRuntime.log).mock.calls.flat().join("|")).toContain("error"); + expect(vi.mocked(auditRuntime.log).mock.calls.flat().join("|")).toContain("error"); + const jsonRuntime = createRuntime(); + await tasksShowCommand({ lookup: task.taskId, json: true }, jsonRuntime); + expect(readFirstJsonLog(jsonRuntime)).toEqual(jsonRoundTrip(getTaskById(task.taskId))); + expect(getTaskById(task.taskId)).toMatchObject({ + runId: `run${unsafe}`, + error: `error${unsafe}`, + }); + const filteredListRuntime = createRuntime(); + await tasksListCommand( + { runtime: `cron${unsafe}`, status: `running${unsafe}` }, + filteredListRuntime, + ); + const filteredAuditRuntime = createRuntime(); + await tasksAuditCommand( + { + severity: `warn${unsafe}` as TaskSystemAuditSeverity, + code: `lost${unsafe}` as TaskSystemAuditCode, + }, + filteredAuditRuntime, + ); + for (const runtime of [filteredListRuntime, filteredAuditRuntime]) { + expectSafeTaskOutput(runtime); + } + const lookupRuntime = createRuntime(); + await tasksShowCommand({ lookup: `missing${unsafe}` }, lookupRuntime); + expectSafeTaskOutput(lookupRuntime, "error"); + }); + }); + + it.each(["failed", "timed_out", "lost"] as const)( + "shows the persisted failure reason for %s tasks in list summaries", + async (status) => { + await withTaskCommandStateDir(async () => { + const task = createInspectableTask({ + runId: `task-list-${status}`, + label: "Original task title", + progressSummary: "Outdated running progress", + terminalSummary: "Generic terminal summary", + }); + const error = `${status}: upstream credentials need attention`; + const terminal = { taskId: task.taskId, endedAt: Date.now(), error }; + if (status === "lost") { + markTaskLostById(terminal); + } else { + markTaskTerminalById({ + ...terminal, + status, + terminalSummary: "Generic terminal summary", + }); + } + const runtime = createRuntime(); + await tasksListCommand({}, runtime); + const output = vi.mocked(runtime.log).mock.calls.flat().join("|"); + expect(output).toContain(error); + expect(output).not.toContain("Outdated running progress"); + expect(output).not.toContain("Generic terminal summary"); + }); + }, + ); + it("keeps task list summaries within their UTF-16 column limit", async () => { await withTaskCommandStateDir(async () => { createTaskRecord({ @@ -691,6 +857,8 @@ describe("tasks commands", () => { task: "Inspect task summary", terminalSummary: `${"y".repeat(78)}🚀xx`, }); + createInspectableTask({ progressSummary: "Fetching provider credentials" }); + createInspectableTask({ status: "succeeded", label: "Human-readable task title" }); const runtime = createRuntime(); await tasksListCommand({}, runtime); @@ -701,6 +869,8 @@ describe("tasks commands", () => { .join("\n"); expect(output).toContain(`${"y".repeat(78)}…`); expect(output).not.toContain("🚀"); + expect(output).toContain("Fetching provider credentials"); + expect(output).toContain("Human-readable task title"); }); }); diff --git a/src/commands/tasks.ts b/src/commands/tasks.ts index 97fe4bf0e565..2d3d10186b86 100644 --- a/src/commands/tasks.ts +++ b/src/commands/tasks.ts @@ -4,6 +4,7 @@ import { timestampMsToIsoString } from "@openclaw/normalization-core/number-coercion"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; +import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js"; import { isRich, theme } from "../../packages/terminal-core/src/theme.js"; import { formatCliCommand } from "../cli/command-format.js"; import { formatLookupMiss } from "../cli/error-format.js"; @@ -42,6 +43,7 @@ import { } from "../tasks/task-registry.reconcile.js"; import { summarizeTaskRecords } from "../tasks/task-registry.summary.js"; import type { TaskNotifyPolicy, TaskRecord } from "../tasks/task-registry.types.js"; +import { formatTaskStatusDetail } from "../tasks/task-status.js"; import { buildTaskSystemAuditJsonPayload, buildTaskSystemAuditFindings, @@ -62,7 +64,7 @@ const info = theme.info; function formatTaskLookupMiss(lookup: string): string { return formatLookupMiss({ noun: "Task", - value: lookup, + value: sanitizeTerminalText(lookup), listCommand: "openclaw tasks list", valueLabel: "task id", }); @@ -208,11 +210,11 @@ function truncate(value: string, maxChars: number) { } function shortToken(value: string | undefined, maxChars = ID_PAD): string { - const trimmed = normalizeOptionalString(value); - if (!trimmed) { + const sanitized = sanitizeTerminalText(normalizeOptionalString(value) ?? "").trim(); + if (!sanitized) { return "n/a"; } - return truncate(trimmed, maxChars); + return truncate(sanitized, maxChars); } function formatTaskStatusCell(status: string, rich: boolean) { @@ -245,10 +247,9 @@ function formatTaskRows(tasks: TaskRecord[], rich: boolean) { const lines = [rich ? theme.heading(header) : header]; for (const task of tasks) { const summary = truncate( - normalizeOptionalString(task.terminalSummary) || - normalizeOptionalString(task.progressSummary) || - normalizeOptionalString(task.label) || - task.task.trim(), + sanitizeTerminalText( + formatTaskStatusDetail(task) || normalizeOptionalString(task.label) || task.task.trim(), + ), 80, ); const line = [ @@ -257,7 +258,7 @@ function formatTaskRows(tasks: TaskRecord[], rich: boolean) { formatTaskStatusCell(task.status, rich), task.deliveryStatus.padEnd(DELIVERY_PAD), shortToken(task.runId, RUN_PAD).padEnd(RUN_PAD), - truncate(normalizeOptionalString(task.childSessionKey) || "n/a", 36).padEnd(36), + shortToken(task.childSessionKey, 36).padEnd(36), summary, ].join(" "); lines.push(line.trimEnd()); @@ -318,7 +319,7 @@ function formatAuditRows(findings: TaskSystemAuditFinding[], rich: boolean) { shortToken(finding.token).padEnd(ID_PAD), status, formatAgeMs(finding.ageMs).padEnd(8), - truncate(finding.detail, 88), + truncate(sanitizeTerminalText(finding.detail), 88), ] .join(" ") .trimEnd(), @@ -372,10 +373,10 @@ export async function tasksListCommand( runtime.log(info(`Background tasks: ${tasks.length}`)); runtime.log(info(`Task pressure: ${formatTaskListSummary(tasks)}`)); if (runtimeFilter) { - runtime.log(info(`Runtime filter: ${runtimeFilter}`)); + runtime.log(info(`Runtime filter: ${sanitizeTerminalText(runtimeFilter)}`)); } if (statusFilter) { - runtime.log(info(`Status filter: ${statusFilter}`)); + runtime.log(info(`Status filter: ${sanitizeTerminalText(statusFilter)}`)); } if (tasks.length === 0) { runtime.log( @@ -432,7 +433,7 @@ export async function tasksShowCommand( ...(task.terminalSummary ? [`terminalSummary: ${task.terminalSummary}`] : []), ]; for (const line of lines) { - runtime.log(line); + runtime.log(sanitizeTerminalText(line)); } } @@ -456,7 +457,9 @@ export async function tasksNotifyCommand( runtime.exit(1); return; } - runtime.log(`Updated ${updated.taskId} notify policy to ${updated.notifyPolicy}.`); + runtime.log( + sanitizeTerminalText(`Updated ${updated.taskId} notify policy to ${updated.notifyPolicy}.`), + ); } /** Cancels a detached task run by lookup token. */ @@ -470,18 +473,24 @@ export async function tasksCancelCommand(opts: { lookup: string }, runtime: Runt const gatewayResult = await tryCancelGatewayOwnedTaskViaGateway(task); if (gatewayResult) { if (!gatewayResult.found) { - runtime.error(gatewayResult.reason ?? formatTaskLookupMiss(opts.lookup)); + runtime.error( + sanitizeTerminalText(gatewayResult.reason ?? formatTaskLookupMiss(opts.lookup)), + ); runtime.exit(1); return; } if (!gatewayResult.cancelled) { - runtime.error(gatewayResult.reason ?? `Could not cancel task: ${opts.lookup}`); + runtime.error( + sanitizeTerminalText(gatewayResult.reason ?? `Could not cancel task: ${opts.lookup}`), + ); runtime.exit(1); return; } const updated = gatewayResult.task; runtime.log( - `Cancelled ${updated?.taskId ?? updated?.id ?? task.taskId} (${updated?.runtime ?? task.runtime})${updated?.runId ? ` run ${updated.runId}` : ""}.`, + sanitizeTerminalText( + `Cancelled ${updated?.taskId ?? updated?.id ?? task.taskId} (${updated?.runtime ?? task.runtime})${updated?.runId ? ` run ${updated.runId}` : ""}.`, + ), ); return; } @@ -490,18 +499,20 @@ export async function tasksCancelCommand(opts: { lookup: string }, runtime: Runt taskId: task.taskId, }); if (!result.found) { - runtime.error(result.reason ?? formatTaskLookupMiss(opts.lookup)); + runtime.error(sanitizeTerminalText(result.reason ?? formatTaskLookupMiss(opts.lookup))); runtime.exit(1); return; } if (!result.cancelled) { - runtime.error(result.reason ?? `Could not cancel task: ${opts.lookup}`); + runtime.error(sanitizeTerminalText(result.reason ?? `Could not cancel task: ${opts.lookup}`)); runtime.exit(1); return; } const updated = getTaskById(task.taskId); runtime.log( - `Cancelled ${updated?.taskId ?? task.taskId} (${updated?.runtime ?? task.runtime})${updated?.runId ? ` run ${updated.runId}` : ""}.`, + sanitizeTerminalText( + `Cancelled ${updated?.taskId ?? task.taskId} (${updated?.runtime ?? task.runtime})${updated?.runId ? ` run ${updated.runId}` : ""}.`, + ), ); } @@ -549,10 +560,10 @@ export async function tasksAuditCommand( runtime.log(info(`Showing ${filteredFindings.length} matching findings.`)); } if (severityFilter) { - runtime.log(info(`Severity filter: ${severityFilter}`)); + runtime.log(info(`Severity filter: ${sanitizeTerminalText(severityFilter)}`)); } if (codeFilter) { - runtime.log(info(`Code filter: ${codeFilter}`)); + runtime.log(info(`Code filter: ${sanitizeTerminalText(codeFilter)}`)); } if (limit) { runtime.log(info(`Limit: ${limit}`)); From acf28495b1ae8b911c38a9980eea303709f7a64f Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 2 Aug 2026 03:29:21 +0800 Subject: [PATCH 24/28] fix(ci): repair plugin prerelease validation (#117562) --- scripts/lib/state-schema-inline-plugin.d.mts | 9 ++++ scripts/lib/state-schema-inline-plugin.mjs | 40 ++++++++++++++++++ .../capability-provider-runtime.test.ts | 4 +- .../migration-provider-runtime.test.ts | 2 +- ...s.runtime.consult-current-snapshot.test.ts | 4 +- .../web-fetch-providers.runtime.test.ts | 7 ++-- test/vitest/vitest.shared.config.ts | 2 + tsdown.config.ts | 42 +++---------------- 8 files changed, 65 insertions(+), 45 deletions(-) create mode 100644 scripts/lib/state-schema-inline-plugin.d.mts create mode 100644 scripts/lib/state-schema-inline-plugin.mjs diff --git a/scripts/lib/state-schema-inline-plugin.d.mts b/scripts/lib/state-schema-inline-plugin.d.mts new file mode 100644 index 000000000000..329eaf70ac1a --- /dev/null +++ b/scripts/lib/state-schema-inline-plugin.d.mts @@ -0,0 +1,9 @@ +export const STATE_SCHEMA_INLINE_PLUGIN_NAME: string; + +export function createStateSchemaInlinePlugin(rootDir?: string): { + name: string; + load( + this: { addWatchFile(id: string): void }, + id: string, + ): { code: string; moduleType: "js" } | null; +}; diff --git a/scripts/lib/state-schema-inline-plugin.mjs b/scripts/lib/state-schema-inline-plugin.mjs new file mode 100644 index 000000000000..fb48b55d8f0d --- /dev/null +++ b/scripts/lib/state-schema-inline-plugin.mjs @@ -0,0 +1,40 @@ +import fs from "node:fs"; +import path from "node:path"; + +export const STATE_SCHEMA_INLINE_PLUGIN_NAME = "openclaw:inline-state-schemas"; + +const STATE_SCHEMA_MODULES = [ + { + modulePath: "src/state/openclaw-state-schema.ts", + schemaPath: "src/state/openclaw-state-schema.sql", + exportName: "OPENCLAW_STATE_SCHEMA_SQL", + }, + { + modulePath: "src/state/openclaw-agent-schema.ts", + schemaPath: "src/state/openclaw-agent-schema.sql", + exportName: "OPENCLAW_AGENT_SCHEMA_SQL", + }, +]; + +/** Inline canonical schema bytes so bundled consumers need no SQL asset. */ +export function createStateSchemaInlinePlugin(rootDir = process.cwd()) { + const schemasByModulePath = new Map( + STATE_SCHEMA_MODULES.map((schema) => [path.resolve(rootDir, schema.modulePath), schema]), + ); + + return { + name: STATE_SCHEMA_INLINE_PLUGIN_NAME, + load(id) { + const schema = schemasByModulePath.get(path.resolve(id)); + if (!schema) { + return null; + } + const schemaPath = path.resolve(rootDir, schema.schemaPath); + this.addWatchFile(schemaPath); + return { + code: `export const ${schema.exportName} = ${JSON.stringify(fs.readFileSync(schemaPath, "utf8"))};\n`, + moduleType: "js", + }; + }, + }; +} diff --git a/src/plugins/capability-provider-runtime.test.ts b/src/plugins/capability-provider-runtime.test.ts index 7f9ddd94cba3..9aacbec76877 100644 --- a/src/plugins/capability-provider-runtime.test.ts +++ b/src/plugins/capability-provider-runtime.test.ts @@ -80,8 +80,8 @@ vi.mock("./manifest-registry.js", async (importOriginal) => { }; }); -vi.mock("./plugin-registry.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("./plugin-registry-snapshot.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, loadPluginRegistrySnapshot: mocks.loadPluginRegistrySnapshot, diff --git a/src/plugins/migration-provider-runtime.test.ts b/src/plugins/migration-provider-runtime.test.ts index ab91bc697aa0..fa710f244440 100644 --- a/src/plugins/migration-provider-runtime.test.ts +++ b/src/plugins/migration-provider-runtime.test.ts @@ -61,7 +61,7 @@ vi.mock("./active-runtime-registry.js", () => ({ }, })); -vi.mock("./plugin-registry.js", () => ({ +vi.mock("./plugin-registry-snapshot.js", () => ({ loadPluginRegistrySnapshot: mocks.loadPluginRegistrySnapshot, loadPluginRegistrySnapshotWithMetadata: mocks.loadPluginRegistrySnapshotWithMetadata, })); diff --git a/src/plugins/providers.runtime.consult-current-snapshot.test.ts b/src/plugins/providers.runtime.consult-current-snapshot.test.ts index da2fa927c1f0..b08058ccfe5c 100644 --- a/src/plugins/providers.runtime.consult-current-snapshot.test.ts +++ b/src/plugins/providers.runtime.consult-current-snapshot.test.ts @@ -16,8 +16,8 @@ import { resetPluginRuntimeStateForTest } from "./runtime.js"; const loadPluginRegistrySnapshotWithMetadata = vi.hoisted(() => vi.fn()); const loadPluginManifestRegistryForInstalledIndex = vi.hoisted(() => vi.fn()); -vi.mock("./plugin-registry.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("./plugin-registry-snapshot.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, loadPluginRegistrySnapshotWithMetadata: (params: unknown) => diff --git a/src/plugins/web-fetch-providers.runtime.test.ts b/src/plugins/web-fetch-providers.runtime.test.ts index 5b010654b66d..9ded1713f780 100644 --- a/src/plugins/web-fetch-providers.runtime.test.ts +++ b/src/plugins/web-fetch-providers.runtime.test.ts @@ -104,9 +104,10 @@ function createRuntimeWebFetchProvider() { describe("resolvePluginWebFetchProviders", () => { beforeAll(async () => { - vi.doMock("./plugin-registry.js", async () => { - const actual = - await vi.importActual("./plugin-registry.js"); + vi.doMock("./plugin-registry-snapshot.js", async () => { + const actual = await vi.importActual( + "./plugin-registry-snapshot.js", + ); return { ...actual, loadPluginRegistrySnapshotWithMetadata: () => ({ diff --git a/test/vitest/vitest.shared.config.ts b/test/vitest/vitest.shared.config.ts index 5da7b85e5c85..33d2fbe5e696 100644 --- a/test/vitest/vitest.shared.config.ts +++ b/test/vitest/vitest.shared.config.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url"; import acpCorePackageJson from "../../packages/acp-core/package.json" with { type: "json" }; import { pluginSdkSubpaths } from "../../scripts/lib/plugin-sdk-entries.mjs"; import privateLocalOnlyPluginSdkSubpaths from "../../scripts/lib/plugin-sdk-private-local-only-subpaths.json" with { type: "json" }; +import { createStateSchemaInlinePlugin } from "../../scripts/lib/state-schema-inline-plugin.mjs"; import { detectVitestHostInfo as detectVitestHostInfoImpl, isCiLikeEnv, @@ -158,6 +159,7 @@ if (!isCI && localScheduling.throttledBySystem && shouldPrintVitestThrottle(proc export const sharedVitestConfig = { root: repoRoot, envDir: false as const, + plugins: [createStateSchemaInlinePlugin(repoRoot)], resolve: { alias: [ { diff --git a/tsdown.config.ts b/tsdown.config.ts index 1ca9275f7644..2dcafbe5956d 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -11,6 +11,10 @@ import { pluginSdkEntrypoints, productionPluginSdkEntrypoints, } from "./scripts/lib/plugin-sdk-entries.mjs"; +import { + createStateSchemaInlinePlugin, + STATE_SCHEMA_INLINE_PLUGIN_NAME, +} from "./scripts/lib/state-schema-inline-plugin.mjs"; import { TSDOWN_PACKAGE_CONFIG_GROUP, TSDOWN_UNIFIED_CONFIG_GROUP, @@ -46,43 +50,7 @@ const env = { const OUTPUT_SOURCE_MAPS = process.env.OUTPUT_SOURCE_MAPS === "1"; const RUN_NODE_SKIP_DTS_BUILD = process.env.OPENCLAW_RUN_NODE_SKIP_DTS_BUILD === "1"; const TSDOWN_DECLARATIONS = !RUN_NODE_SKIP_DTS_BUILD; -export const STATE_SCHEMA_INLINE_PLUGIN_NAME = "openclaw:inline-state-schemas"; - -const STATE_SCHEMA_MODULES = [ - { - modulePath: "src/state/openclaw-state-schema.ts", - schemaPath: "src/state/openclaw-state-schema.sql", - exportName: "OPENCLAW_STATE_SCHEMA_SQL", - }, - { - modulePath: "src/state/openclaw-agent-schema.ts", - schemaPath: "src/state/openclaw-agent-schema.sql", - exportName: "OPENCLAW_AGENT_SCHEMA_SQL", - }, -] as const; - -/** Inline canonical schema bytes so packaged database opens need no SQL asset. */ -export function createStateSchemaInlinePlugin(rootDir: string = process.cwd()) { - const schemasByModulePath = new Map( - STATE_SCHEMA_MODULES.map((schema) => [path.resolve(rootDir, schema.modulePath), schema]), - ); - - return { - name: STATE_SCHEMA_INLINE_PLUGIN_NAME, - load(this: { addWatchFile(id: string): void }, id: string) { - const schema = schemasByModulePath.get(path.resolve(id)); - if (!schema) { - return null; - } - const schemaPath = path.resolve(rootDir, schema.schemaPath); - this.addWatchFile(schemaPath); - return { - code: `export const ${schema.exportName} = ${JSON.stringify(fs.readFileSync(schemaPath, "utf8"))};\n`, - moduleType: "js" as const, - }; - }, - }; -} +export { createStateSchemaInlinePlugin, STATE_SCHEMA_INLINE_PLUGIN_NAME }; const SUPPRESSED_EVAL_WARNING_PATHS = [ "@protobufjs/inquire/index.js", From 6531ca91f457a9fdcbc0005a59927ae68c763898 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 2 Aug 2026 03:30:17 +0800 Subject: [PATCH 25/28] fix(perf): separate warm and first-device health probes (#117525) * fix(perf): separate warm and first-device health probes * fix(perf): configure first-device health probe * fix(perf): require connected health probes * fix(perf): preserve generic health benchmark state --- .github/workflows/openclaw-performance.yml | 3 +- scripts/bench-cli-startup.ts | 46 ++++++-- test/scripts/bench-cli-startup.test.ts | 38 +++++-- .../scripts/cli-startup-bench-spawner.test.ts | 103 ++++++++++++++++++ .../openclaw-performance-workflow.test.ts | 7 ++ 5 files changed, 178 insertions(+), 19 deletions(-) diff --git a/.github/workflows/openclaw-performance.yml b/.github/workflows/openclaw-performance.yml index 0185f411b367..66aac720f846 100644 --- a/.github/workflows/openclaw-performance.yml +++ b/.github/workflows/openclaw-performance.yml @@ -807,7 +807,8 @@ jobs: OPENCLAW_HOME="$gateway_home" OPENCLAW_STATE_DIR="$gateway_state" OPENCLAW_CONFIG_PATH="$gateway_config" OPENCLAW_GATEWAY_PORT="$gateway_port" \ node --import tsx scripts/bench-cli-startup.ts \ - --case gatewayHealthJson \ + --case gatewayHealthJsonConnected \ + --case gatewayHealthJsonFirstDevice \ --case configGetGatewayPort \ --runs "$source_runs" \ --warmup 1 \ diff --git a/scripts/bench-cli-startup.ts b/scripts/bench-cli-startup.ts index affb00029acd..349849d8b149 100644 --- a/scripts/bench-cli-startup.ts +++ b/scripts/bench-cli-startup.ts @@ -12,6 +12,7 @@ type CommandCase = { name: string; args: string[]; presets: readonly string[]; + stateScope?: "case" | "sample"; expectedExitCodes?: readonly number[]; expectedNonzeroOutputIncludes?: readonly string[]; firstOutputBudgetMs?: number; @@ -444,6 +445,19 @@ const COMMAND_CASES: readonly CommandCase[] = [ expectedExitCodes: [0, 1], expectedNonzeroOutputIncludes: ['"ok"', '"gateway_transport_error"'], }, + { + id: "gatewayHealthJsonConnected", + name: "gateway health --json (connected)", + args: ["gateway", "health", "--json"], + presets: [], + stateScope: "case", + }, + { + id: "gatewayHealthJsonFirstDevice", + name: "gateway health --json (first device)", + args: ["gateway", "health", "--json"], + presets: [], + }, { id: "configGetGatewayPort", name: "config get gateway.port", @@ -649,6 +663,8 @@ function buildConfigFixture(commandCase: CommandCase): Record | if ( commandCase.id !== "configGetGatewayPort" && commandCase.id !== "gatewayHealthJson" && + commandCase.id !== "gatewayHealthJsonConnected" && + commandCase.id !== "gatewayHealthJsonFirstDevice" && commandCase.id !== "health" && commandCase.id !== "healthJson" ) { @@ -717,8 +733,10 @@ async function runSample(params: { cpuProfDir?: string; heapProfDir?: string; rssHookPath: string; + runRoot?: string; }): Promise { - const runRoot = mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-bench-home-")); + const runRoot = params.runRoot ?? mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-bench-home-")); + const ownsRunRoot = params.runRoot == null; const stateDir = path.join(runRoot, ".openclaw"); const configPath = path.join(stateDir, "openclaw.json"); const configFixture = buildConfigFixture(params.commandCase); @@ -849,7 +867,9 @@ async function runSample(params: { }); }); } finally { - rmSync(runRoot, { recursive: true, force: true }); + if (ownsRunRoot) { + rmSync(runRoot, { recursive: true, force: true }); + } } } @@ -939,14 +959,24 @@ async function runCase(params: { }): Promise { const samples: Sample[] = []; const totalRuns = params.warmup + params.runs; - for (let i = 0; i < totalRuns; i += 1) { - const sample = await runSample(params); - if (i < params.warmup) { - continue; + const caseRunRoot = + params.commandCase.stateScope === "case" + ? mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-bench-home-")) + : undefined; + try { + for (let i = 0; i < totalRuns; i += 1) { + const sample = await runSample({ ...params, runRoot: caseRunRoot }); + if (i < params.warmup) { + continue; + } + samples.push(sample); + } + return samples; + } finally { + if (caseRunRoot) { + rmSync(caseRunRoot, { recursive: true, force: true }); } - samples.push(sample); } - return samples; } function tailLines(value: string, maxLines: number): string { diff --git a/test/scripts/bench-cli-startup.test.ts b/test/scripts/bench-cli-startup.test.ts index a4792df914de..506d76812331 100644 --- a/test/scripts/bench-cli-startup.test.ts +++ b/test/scripts/bench-cli-startup.test.ts @@ -462,6 +462,18 @@ describe("bench-cli-startup", () => { args: ["gateway", "health", "--json"], presets: ["real"], }, + { + id: "gatewayHealthJsonConnected", + name: "gateway health --json (connected)", + args: ["gateway", "health", "--json"], + presets: [], + }, + { + id: "gatewayHealthJsonFirstDevice", + name: "gateway health --json (first device)", + args: ["gateway", "health", "--json"], + presets: [], + }, { id: "health", name: "health", args: ["health"], presets: ["startup", "real"] }, { id: "healthJson", @@ -485,16 +497,22 @@ describe("bench-cli-startup", () => { expect(testing.parseGatewayPortEnv("::1")).toBe(32123); expect(testing.parseGatewayPortEnv("[::1]")).toBe(32123); - expect( - withEnv({ OPENCLAW_GATEWAY_PORT: "45678" }, () => - testing.buildConfigFixture({ - id: "gatewayHealthJson", - name: "gateway health --json", - args: ["gateway", "health", "--json"], - presets: ["real"], - }), - ), - ).toMatchObject({ gateway: { port: 45678 } }); + for (const id of [ + "gatewayHealthJson", + "gatewayHealthJsonConnected", + "gatewayHealthJsonFirstDevice", + ]) { + expect( + withEnv({ OPENCLAW_GATEWAY_PORT: "45678" }, () => + testing.buildConfigFixture({ + id, + name: "gateway health --json", + args: ["gateway", "health", "--json"], + presets: [], + }), + ), + ).toMatchObject({ gateway: { port: 45678 } }); + } for (const invalid of ["45678abc", "127.0.0.1:45678abc"]) { expect(() => diff --git a/test/scripts/cli-startup-bench-spawner.test.ts b/test/scripts/cli-startup-bench-spawner.test.ts index 14c6f0f43d9b..50a84d997b61 100644 --- a/test/scripts/cli-startup-bench-spawner.test.ts +++ b/test/scripts/cli-startup-bench-spawner.test.ts @@ -34,6 +34,109 @@ describe("CLI startup benchmark script spawners", () => { ); }); + it("reuses warmed state for gateway health while isolating first-device samples", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bench-state-scope-test-")); + try { + const fixturePath = path.join(tmpDir, "record-home.mjs"); + const homeLogPath = path.join(tmpDir, "homes.log"); + fs.writeFileSync( + fixturePath, + [ + 'import { appendFileSync } from "node:fs";', + "appendFileSync(process.env.OPENCLAW_BENCH_HOME_LOG, `${process.env.HOME}\\n`);", + "console.log('{\"ok\":true}');", + "", + ].join("\n"), + ); + + const runCase = (caseId: string) => { + fs.rmSync(homeLogPath, { force: true }); + execFileSync( + process.execPath, + [ + "--import", + "tsx", + "scripts/bench-cli-startup.ts", + "--entry", + fixturePath, + "--case", + caseId, + "--runs", + "2", + "--warmup", + "1", + ], + { + cwd: process.cwd(), + env: { + ...process.env, + OPENCLAW_BENCH_HOME_LOG: homeLogPath, + }, + stdio: "pipe", + }, + ); + return fs.readFileSync(homeLogPath, "utf8").trim().split("\n"); + }; + + const warmedHomes = runCase("gatewayHealthJsonConnected"); + expect(warmedHomes).toHaveLength(3); + expect(new Set(warmedHomes).size).toBe(1); + expect(warmedHomes.every((home) => !fs.existsSync(home))).toBe(true); + + for (const caseId of ["gatewayHealthJson", "gatewayHealthJsonFirstDevice"]) { + const sampleHomes = runCase(caseId); + expect(sampleHomes).toHaveLength(3); + expect(new Set(sampleHomes).size).toBe(3); + expect(sampleHomes.every((home) => !fs.existsSync(home))).toBe(true); + } + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("requires connected gateway health probes to exit successfully", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bench-connected-test-")); + try { + const fixturePath = path.join(tmpDir, "transport-error.mjs"); + fs.writeFileSync( + fixturePath, + [ + 'console.log(\'{"ok":false,"gateway_transport_error":"closed"}\');', + "process.exitCode = 1;", + "", + ].join("\n"), + ); + + const runCase = (caseId: string) => + spawnSync( + process.execPath, + [ + "--import", + "tsx", + "scripts/bench-cli-startup.ts", + "--entry", + fixturePath, + "--case", + caseId, + "--runs", + "1", + "--warmup", + "0", + ], + { cwd: process.cwd(), encoding: "utf8" }, + ); + + expect(runCase("gatewayHealthJson").status).toBe(0); + for (const caseId of ["gatewayHealthJsonConnected", "gatewayHealthJsonFirstDevice"]) { + const result = runCase(caseId); + expect(result.status).toBe(1); + expect(result.stderr).toContain(`${caseId} sample 1: exited with code 1`); + } + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it("does not require unrelated fixture cases for a narrowed preset", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bench-budget-test-")); try { diff --git a/test/scripts/openclaw-performance-workflow.test.ts b/test/scripts/openclaw-performance-workflow.test.ts index 0f1129dde2f9..522df47e675e 100644 --- a/test/scripts/openclaw-performance-workflow.test.ts +++ b/test/scripts/openclaw-performance-workflow.test.ts @@ -254,6 +254,13 @@ describe("OpenClaw performance workflow", () => { expect(run.indexOf(probeCap)).toBeLessThan(run.indexOf(boundedProbe)); }); + it("measures warmed and first-device gateway health separately", () => { + const run = findStep("Run OpenClaw source performance probes", "source_performance").run ?? ""; + + expect(run).toContain("--case gatewayHealthJsonConnected \\"); + expect(run).toContain("--case gatewayHealthJsonFirstDevice \\"); + }); + it("isolates required publication in a fresh artifact-consuming job", () => { const workflow = readWorkflow(); const publisher = workflow.jobs?.publish; From 7c9794b5c65c9e28d6ae359f8924c86d82ab4abd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 12:30:49 -0700 Subject: [PATCH 26/28] fix(whatsapp): normalize future-proof QA poll and video note ingress (#117579) Co-authored-by: Peter Steinberger --- .../whatsapp/src/qa-driver.runtime.test.ts | 94 +++++++++++++++++++ extensions/whatsapp/src/qa-driver.runtime.ts | 21 ++--- 2 files changed, 101 insertions(+), 14 deletions(-) diff --git a/extensions/whatsapp/src/qa-driver.runtime.test.ts b/extensions/whatsapp/src/qa-driver.runtime.test.ts index f4395916ff73..45d0e2b08561 100644 --- a/extensions/whatsapp/src/qa-driver.runtime.test.ts +++ b/extensions/whatsapp/src/qa-driver.runtime.test.ts @@ -462,6 +462,100 @@ describe("startWhatsAppQaDriverSession", () => { await session.close(); }); + it.each([ + ...[ + { name: "captionless video note", message: { ptvMessage: {} } }, + { + name: "ephemeral captionless video note", + message: { ephemeralMessage: { message: { ptvMessage: {} } } }, + }, + { + name: "edited captionless video note", + message: { editedMessage: { message: { ptvMessage: {} } } }, + }, + ].map(({ name, message }) => ({ + name, + message, + expected: { kind: "media", mediaType: "video/mp4", text: "" }, + })), + ...[ + "pollCreationMessage", + "pollCreationMessageV2", + "pollCreationMessageV3", + "pollCreationMessageV5", + ].flatMap((pollKey) => { + const poll = { + [pollKey]: { + name: "Choose a time", + options: [{ optionName: "Morning" }, { optionName: "Afternoon" }], + }, + }; + const expected = { + kind: "poll", + poll: { question: "Choose a time", options: ["Morning", "Afternoon"] }, + }; + return [ + { name: pollKey, message: poll, expected }, + { + name: `ephemeral ${pollKey}`, + message: { ephemeralMessage: { message: poll } }, + expected, + }, + ]; + }), + ...["pollCreationMessageV3", "pollCreationMessageV5"].flatMap((pollKey) => { + const poll = { + [pollKey]: { + name: "Choose a time", + options: [{ optionName: "Morning" }, { optionName: "Afternoon" }], + }, + }; + const wrappedPoll = { pollCreationMessageV4: { message: poll } }; + const expected = { + kind: "poll", + poll: { question: "Choose a time", options: ["Morning", "Afternoon"] }, + }; + return [ + { name: `future-proof version-4 ${pollKey}`, message: wrappedPoll, expected }, + { + name: `ephemeral future-proof version-4 ${pollKey}`, + message: { ephemeralMessage: { message: wrappedPoll } }, + expected, + }, + { name: `edited ${pollKey}`, message: { editedMessage: { message: poll } }, expected }, + ]; + }), + ])("resolves live ingress waiters for $name", async ({ message, expected }) => { + const sock = createMockSocket(); + mocks.createWaSocket.mockResolvedValue(sock); + mocks.waitForWaConnection.mockResolvedValue(undefined); + mocks.jidToE164.mockReturnValue("+15551234567"); + + const session = await startWhatsAppQaDriverSession({ + authDir: "/tmp/openclaw-whatsapp-auth", + }); + + try { + const observed = session.waitForMessage({ + timeoutMs: 150, + match: (candidate) => candidate.kind === expected.kind, + }); + sock.ev.emit("messages.upsert", { + messages: [ + { + key: { fromMe: false, id: "observed-message", remoteJid: "12345@lid" }, + message, + } as WAMessage, + ], + }); + + await expect(observed).resolves.toMatchObject(expected); + expect(session.getObservedMessages()).toHaveLength(1); + } finally { + await session.close(); + } + }); + it("uses canonical WhatsApp media MIME defaults when Baileys omits MIME", async () => { const sock = createMockSocket(); mocks.createWaSocket.mockResolvedValue(sock); diff --git a/extensions/whatsapp/src/qa-driver.runtime.ts b/extensions/whatsapp/src/qa-driver.runtime.ts index dab09b25ad8b..43462d160449 100644 --- a/extensions/whatsapp/src/qa-driver.runtime.ts +++ b/extensions/whatsapp/src/qa-driver.runtime.ts @@ -1,5 +1,5 @@ // Whatsapp plugin module implements qa driver behavior. -import type { ConnectionState, proto, WAMessage } from "baileys"; +import { getContentType, type ConnectionState, type proto, type WAMessage } from "baileys"; import { formatLocationText } from "openclaw/plugin-sdk/channel-inbound"; import { describeReplyContext, @@ -180,19 +180,10 @@ function findMessageSection( if (current.depth >= 4) { continue; } - for (const wrapperName of [ - "botInvokeMessage", - "documentWithCaptionMessage", - "ephemeralMessage", - "groupMentionedMessage", - "viewOnceMessage", - "viewOnceMessageV2", - "viewOnceMessageV2Extension", - ]) { - const wrapper = current.value[wrapperName]; - if (isRecord(wrapper) && isRecord(wrapper.message)) { - queue.push({ depth: current.depth + 1, value: wrapper.message }); - } + const contentType = getContentType(current.value as proto.IMessage); + const wrapper = contentType ? current.value[contentType] : undefined; + if (isRecord(wrapper) && isRecord(wrapper.message)) { + queue.push({ depth: current.depth + 1, value: wrapper.message }); } } return undefined; @@ -218,6 +209,7 @@ function readPoll(message: unknown): WhatsAppQaDriverObservedPoll | undefined { "pollCreationMessage", "pollCreationMessageV2", "pollCreationMessageV3", + "pollCreationMessageV5", ]); if (!poll) { return undefined; @@ -244,6 +236,7 @@ function readMedia(message: unknown): const mediaSections = [ "imageMessage", "videoMessage", + "ptvMessage", "audioMessage", "documentMessage", "stickerMessage", From 165c968f951c8fc3a863409bf14f91256aee5321 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 12:39:24 -0700 Subject: [PATCH 27/28] fix(memory): recover restored session freshness (#117548) --- .../memory/manager-session-sync-state.test.ts | 8 + .../src/memory/manager-session-sync-state.ts | 4 +- .../src/memory/manager-source-sync-ops.ts | 27 +- .../manager-sync-ops.startup-catchup.test.ts | 265 +++++++++++++++++- 4 files changed, 289 insertions(+), 15 deletions(-) diff --git a/extensions/memory-core/src/memory/manager-session-sync-state.test.ts b/extensions/memory-core/src/memory/manager-session-sync-state.test.ts index 80d674014a42..fd0846619efa 100644 --- a/extensions/memory-core/src/memory/manager-session-sync-state.test.ts +++ b/extensions/memory-core/src/memory/manager-session-sync-state.test.ts @@ -106,6 +106,12 @@ describe("memory session sync state", () => { mtimeMs: 250, size: 20, }, + { + absPath: "/tmp/sessions/rolled-back.jsonl", + path: "sessions/rolled-back.jsonl", + mtimeMs: 150, + size: 20, + }, { absPath: "/tmp/sessions/resized.jsonl", path: "sessions/resized.jsonl", @@ -124,6 +130,7 @@ describe("memory session sync state", () => { { path: "sessions/sub-ms-newer.jsonl", hash: "hash-sub-ms", mtime: 100.25, size: 10 }, { path: "sessions/invalidated.jsonl", hash: "", mtime: 200, size: 20 }, { path: "sessions/newer.jsonl", hash: "hash-newer", mtime: 200, size: 20 }, + { path: "sessions/rolled-back.jsonl", hash: "hash-rolled-back", mtime: 200, size: 20 }, { path: "sessions/resized.jsonl", hash: "hash-resized", mtime: 300, size: 30 }, ], }); @@ -132,6 +139,7 @@ describe("memory session sync state", () => { "/tmp/sessions/sub-ms-newer.jsonl", "/tmp/sessions/invalidated.jsonl", "/tmp/sessions/newer.jsonl", + "/tmp/sessions/rolled-back.jsonl", "/tmp/sessions/resized.jsonl", "/tmp/sessions/missing.jsonl", ]); diff --git a/extensions/memory-core/src/memory/manager-session-sync-state.ts b/extensions/memory-core/src/memory/manager-session-sync-state.ts index 7857762185c6..7be696a96892 100644 --- a/extensions/memory-core/src/memory/manager-session-sync-state.ts +++ b/extensions/memory-core/src/memory/manager-session-sync-state.ts @@ -26,7 +26,9 @@ export function resolveMemorySessionStartupDirtyFiles(params: { dirtyFiles.push(file.absPath); continue; } - if (file.size !== indexedSize || file.mtimeMs > indexedMtimeMs) { + // File mtimes and SQLite session updatedAt values can move backward after + // restore/reset. The downstream content-hash gate suppresses unchanged rewrites. + if (file.size !== indexedSize || file.mtimeMs !== indexedMtimeMs) { dirtyFiles.push(file.absPath); } } diff --git a/extensions/memory-core/src/memory/manager-source-sync-ops.ts b/extensions/memory-core/src/memory/manager-source-sync-ops.ts index 2d9bcad63a27..0f3b992a97fc 100644 --- a/extensions/memory-core/src/memory/manager-source-sync-ops.ts +++ b/extensions/memory-core/src/memory/manager-source-sync-ops.ts @@ -174,6 +174,29 @@ export abstract class MemoryManagerSourceSyncOps extends MemoryManagerSessionSyn const deleteChunksByPathAndSource = this.db.prepare( `DELETE FROM memory_index_chunks WHERE path = ? AND source = ?`, ); + const updateUnchangedSessionSourceMetadata = this.db.prepare( + `UPDATE memory_index_sources + SET mtime = ?, size = ? + WHERE path = ? AND source = 'sessions' AND hash = ?`, + ); + const refreshUnchangedSessionSourceMetadata = (entry: MemoryIndexEntry): boolean => { + // Hash equality preserves chunks and embeddings; only converge the source + // fingerprint so restored sessions do not repeat catch-up on every startup. + return ( + updateUnchangedSessionSourceMetadata.run(entry.mtimeMs, entry.size, entry.path, entry.hash) + .changes === 1 + ); + }; + const canSkipUnchangedSessionEntry = ( + entry: MemoryIndexEntry, + absPath: string, + existingHash: string | undefined, + ): boolean => { + if (params.needsFullReindex || existingHash !== entry.hash) { + return false; + } + return !this.sessionsDirtyFiles.has(absPath) || refreshUnchangedSessionSourceMetadata(entry); + }; const deleteFtsRowsByPathAndSource = this.fts.enabled && this.fts.available ? this.db.prepare(`DELETE FROM ${FTS_TABLE} WHERE path = ? AND source = ?`) @@ -340,7 +363,7 @@ export abstract class MemoryManagerSourceSyncOps extends MemoryManagerSessionSyn path: entry.path, existingHashes, }); - if (!params.needsFullReindex && existingHash === entry.hash) { + if (canSkipUnchangedSessionEntry(entry, absPath, existingHash)) { if (params.progress) { params.progress.completed += 1; params.progress.report({ @@ -412,7 +435,7 @@ export abstract class MemoryManagerSourceSyncOps extends MemoryManagerSessionSyn path: entry.path, existingHashes, }); - if (!params.needsFullReindex && existingHash === entry.hash) { + if (canSkipUnchangedSessionEntry(entry, absPath, existingHash)) { if (params.progress) { params.progress.completed += 1; params.progress.report({ diff --git a/extensions/memory-core/src/memory/manager-sync-ops.startup-catchup.test.ts b/extensions/memory-core/src/memory/manager-sync-ops.startup-catchup.test.ts index a1324deae121..968c2d8891d5 100644 --- a/extensions/memory-core/src/memory/manager-sync-ops.startup-catchup.test.ts +++ b/extensions/memory-core/src/memory/manager-sync-ops.startup-catchup.test.ts @@ -2,13 +2,16 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import type { DatabaseSync } from "node:sqlite"; +import { DatabaseSync } from "node:sqlite"; import { resolveSessionTranscriptsDirForAgent, type OpenClawConfig, type ResolvedMemorySearchConfig, } from "openclaw/plugin-sdk/memory-core-host-engine-foundation"; -import { statSessionEntrySync } from "openclaw/plugin-sdk/memory-core-host-engine-qmd"; +import { + buildSessionEntry, + statSessionEntrySync, +} from "openclaw/plugin-sdk/memory-core-host-engine-qmd"; import { MEMORY_CHUNKING_VERSION, type MemorySource, @@ -64,8 +67,43 @@ type MemorySessionTranscriptUpdate = { const originalStartupStateDir = process.env.OPENCLAW_STATE_DIR; const originalStartupConfigPath = process.env.OPENCLAW_CONFIG_PATH; let transcriptUpdateListener: ((update: MemorySessionTranscriptUpdate) => void) | undefined; +const startupHarnessDatabases = new Set(); type SourceStateRow = { path: string; hash: string; mtime: number; size: number }; + +function createStartupHarnessDatabase(sourceRows: SourceStateRow[]): DatabaseSync { + const db = new DatabaseSync(":memory:"); + db.exec(` + CREATE TABLE memory_index_sources ( + path TEXT NOT NULL, + source TEXT NOT NULL, + hash TEXT NOT NULL, + mtime REAL NOT NULL, + size INTEGER NOT NULL, + UNIQUE(path, source) + ); + CREATE TABLE memory_index_chunks ( + id TEXT PRIMARY KEY, + path TEXT NOT NULL, + source TEXT NOT NULL, + model TEXT NOT NULL + ); + CREATE TABLE memory_index_source_update_audit (path TEXT NOT NULL); + CREATE TRIGGER memory_index_source_update_audit_trigger + AFTER UPDATE ON memory_index_sources + BEGIN + INSERT INTO memory_index_source_update_audit (path) VALUES (NEW.path); + END; + `); + const insert = db.prepare( + `INSERT INTO memory_index_sources (path, source, hash, mtime, size) VALUES (?, 'sessions', ?, ?, ?)`, + ); + for (const row of sourceRows) { + insert.run(row.path, row.hash, row.mtime, row.size); + } + startupHarnessDatabases.add(db); + return db; +} function setStartupStateDir(stateDir: string): void { Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir); } @@ -148,16 +186,37 @@ class SessionStartupCatchupHarness extends MemoryManagerSyncOps { sourceRows: SourceStateRow[], private readonly indexSessionUpdates = false, private readonly subscribeToRealEvents = false, + private readonly deferSessionIndex = false, + database?: DatabaseSync, ) { super(); this.sources.add("sessions"); - this.db = { - prepare: () => ({ - all: () => sourceRows, - get: () => undefined, - run: () => undefined, - }), - } as unknown as DatabaseSync; + this.db = database ?? createStartupHarnessDatabase(sourceRows); + } + + restartForStartup(): SessionStartupCatchupHarness { + return new SessionStartupCatchupHarness( + [], + this.indexSessionUpdates, + false, + this.deferSessionIndex, + this.db, + ); + } + + getIndexedSourceState(pathname: string): SourceStateRow | undefined { + return this.db + .prepare( + `SELECT path, hash, mtime, size FROM memory_index_sources WHERE path = ? AND source = 'sessions'`, + ) + .get(pathname) as SourceStateRow | undefined; + } + + getSourceMetadataUpdateCount(): number { + const row = this.db + .prepare(`SELECT COUNT(*) AS count FROM memory_index_source_update_audit`) + .get() as { count: number }; + return row.count; } async catchUp(): Promise { @@ -172,6 +231,13 @@ class SessionStartupCatchupHarness extends MemoryManagerSyncOps { await this.runSync(params); } + async runArchiveSyncForTest(): Promise { + await this.syncArchiveFiles({ + needsFullReindex: false, + deferIndex: this.deferSessionIndex, + }); + } + getDirtyArchiveFiles(): string[] { return Array.from(this.sessionsDirtyFiles); } @@ -273,7 +339,10 @@ class SessionStartupCatchupHarness extends MemoryManagerSyncOps { protected async sync(params?: MemorySyncParams): Promise { this.syncCalls.push(params ?? {}); this.pendingSyncWork = this.indexSessionUpdates - ? this.syncArchiveFiles({ needsFullReindex: false }).then(() => undefined) + ? this.syncArchiveFiles({ + needsFullReindex: false, + deferIndex: this.deferSessionIndex, + }).then(() => undefined) : Promise.resolve(); await this.pendingSyncWork; } @@ -333,20 +402,29 @@ describe("session startup catch-up", () => { restoreStartupEnv(); clearRuntimeConfigSnapshot(); clearConfigCache(); + for (const database of startupHarnessDatabases) { + database.close(); + } + startupHarnessDatabases.clear(); closeOpenClawAgentDatabasesForTest(); await fs.rm(stateDir, { recursive: true, force: true }); }); async function writeSessionFile( name: string, + content = "startup catchup", + timestamp?: string, ): Promise<{ filePath: string; size: number; mtimeMs: number }> { const sessionsDir = resolveSessionTranscriptsDirForAgent("main"); await fs.mkdir(sessionsDir, { recursive: true }); const filePath = path.join(sessionsDir, name); await fs.writeFile( filePath, - JSON.stringify({ type: "message", message: { role: "user", content: "startup catchup" } }) + - "\n", + JSON.stringify({ + type: "message", + ...(timestamp ? { timestamp } : {}), + message: { role: "user", content }, + }) + "\n", "utf-8", ); const stat = await fs.stat(filePath); @@ -533,6 +611,169 @@ describe("session startup catch-up", () => { expect(harness.syncCalls).toEqual([]); }); + it("indexes a same-size file transcript whose mtime rolled back", async () => { + const archiveName = "thread.jsonl.deleted.2026-08-01T10-00-00.000Z"; + const original = await writeSessionFile(archiveName, "version before"); + const originalEntry = await buildSessionEntry(original.filePath); + if (!originalEntry) { + throw new Error("expected original file transcript entry"); + } + const replacement = await writeSessionFile(archiveName, "version after!"); + expect(replacement.size).toBe(original.size); + const rolledBackMtime = new Date(Math.max(1, original.mtimeMs - 60_000)); + await fs.utimes(replacement.filePath, rolledBackMtime, rolledBackMtime); + const rolledBack = await fs.stat(replacement.filePath); + expect(rolledBack.mtimeMs).toBeLessThan(original.mtimeMs); + + const harness = new SessionStartupCatchupHarness( + [ + { + path: originalEntry.path, + hash: originalEntry.hash, + mtime: original.mtimeMs, + size: original.size, + }, + ], + true, + ); + + await expect(harness.catchUp()).resolves.toEqual([replacement.filePath]); + await harness.waitForSessionSync(); + + expect(harness.syncCalls).toEqual([{ reason: "session-startup-catchup" }]); + expect(harness.indexedPaths).toEqual([`sessions/main/${archiveName}`]); + expect(harness.indexedContents).toEqual(["User: version after!"]); + }); + + it("converges an unchanged file mtime rollback after direct session sync", async () => { + const archiveName = "thread.jsonl.deleted.2026-08-01T11-00-00.000Z"; + const messageTimestamp = "2026-08-01T10:30:00.000Z"; + const original = await writeSessionFile(archiveName, "unchanged content", messageTimestamp); + const originalEntry = await buildSessionEntry(original.filePath); + if (!originalEntry) { + throw new Error("expected original file transcript entry"); + } + const rolledBackMtime = new Date(Math.max(1, original.mtimeMs - 60_000)); + await fs.utimes(original.filePath, rolledBackMtime, rolledBackMtime); + const restoredEntry = await buildSessionEntry(original.filePath); + if (!restoredEntry) { + throw new Error("expected restored file transcript entry"); + } + expect(restoredEntry.hash).toBe(originalEntry.hash); + + const harness = new SessionStartupCatchupHarness( + [ + { + path: originalEntry.path, + hash: originalEntry.hash, + mtime: original.mtimeMs, + size: original.size, + }, + ], + true, + ); + + await expect(harness.catchUp()).resolves.toEqual([original.filePath]); + await harness.waitForSessionSync(); + expect(harness.indexedPaths).toEqual([]); + expect(harness.getIndexedSourceState(originalEntry.path)).toEqual({ + path: originalEntry.path, + hash: originalEntry.hash, + mtime: restoredEntry.mtimeMs, + size: restoredEntry.size, + }); + expect(harness.getSourceMetadataUpdateCount()).toBe(1); + + const restarted = harness.restartForStartup(); + await expect(restarted.catchUp()).resolves.toEqual([]); + expect(restarted.syncCalls).toEqual([]); + expect(restarted.indexedPaths).toEqual([]); + }); + + it("indexes a SQLite transcript whose updatedAt rolled back", async () => { + const session = await writeSqliteSession({ + content: "SQLite rollback", + updatedAt: 10, + }); + const state = statSessionEntrySync(session.sessionKey, { + agentId: "main", + sessionId: session.sessionId, + storePath: session.storePath, + sessionKey: session.sessionKey, + updatedAtMs: 10, + }); + if (!state) { + throw new Error("expected SQLite transcript state"); + } + const harness = new SessionStartupCatchupHarness( + [ + { + path: state.path, + hash: "previous-hash", + mtime: 20, + size: state.size, + }, + ], + true, + ); + + await expect(harness.catchUp()).resolves.toEqual([session.sessionKey]); + await harness.waitForSessionSync(); + + expect(harness.syncCalls).toEqual([{ reason: "session-startup-catchup" }]); + expect(harness.indexedPaths).toEqual([session.corpusPath]); + expect(harness.indexedContents).toEqual(["User: SQLite rollback"]); + }); + + it("converges an unchanged SQLite updatedAt rollback after deferred session sync", async () => { + const session = await writeSqliteSession({ updatedAt: 10 }); + const entry = await buildSessionEntry(session.sessionKey, { + agentId: "main", + sessionId: session.sessionId, + storePath: session.storePath, + sessionKey: session.sessionKey, + updatedAtMs: 10, + sessionKind: "interactive", + }); + if (!entry) { + throw new Error("expected SQLite transcript entry"); + } + const harness = new SessionStartupCatchupHarness( + [ + { + path: entry.path, + hash: entry.hash, + mtime: 20, + size: entry.size, + }, + ], + true, + false, + true, + ); + + await expect(harness.catchUp()).resolves.toEqual([session.sessionKey]); + await harness.waitForSessionSync(); + + expect(harness.syncCalls).toEqual([{ reason: "session-startup-catchup" }]); + expect(harness.indexedPaths).toEqual([]); + expect(harness.indexedContents).toEqual([]); + expect(harness.getIndexedSourceState(entry.path)).toEqual({ + path: entry.path, + hash: entry.hash, + mtime: entry.mtimeMs, + size: entry.size, + }); + expect(harness.getSourceMetadataUpdateCount()).toBe(1); + + const restarted = harness.restartForStartup(); + await expect(restarted.catchUp()).resolves.toEqual([]); + expect(restarted.syncCalls).toEqual([]); + expect(restarted.indexedPaths).toEqual([]); + await restarted.runArchiveSyncForTest(); + expect(restarted.getSourceMetadataUpdateCount()).toBe(1); + }); + it("does not fall back to full session sync when identity targets normalize away", async () => { await writeSessionFile("thread.jsonl"); const harness = new SessionStartupCatchupHarness([]); From 26aa884c6914ab8ba65d743bbc09e0410b47b54e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 12:41:34 -0700 Subject: [PATCH 28/28] refactor(ui): consolidate cron form control rendering (#117576) --- ui/src/pages/cron/view.test.ts | 31 +- ui/src/pages/cron/view.ts | 1047 ++++++++++++-------------------- 2 files changed, 398 insertions(+), 680 deletions(-) diff --git a/ui/src/pages/cron/view.test.ts b/ui/src/pages/cron/view.test.ts index ed361c19095b..06eb8edd6ac8 100644 --- a/ui/src/pages/cron/view.test.ts +++ b/ui/src/pages/cron/view.test.ts @@ -602,19 +602,38 @@ describe("cron view editor", () => { expect(onClosePanel).toHaveBeenCalledTimes(1); }); - it("wires form changes from prompt and name inputs", () => { + it("wires shared text and select controls without changing their field ownership", () => { const onFormChange = vi.fn(); - const container = renderView({ createOpen: true, onFormChange }); + const container = renderView({ + createOpen: true, + channels: ["telegram"], + channelMeta: [{ id: "telegram", label: "", detailLabel: "Telegram" }], + channelLabels: { telegram: "Telegram fallback" }, + form: { ...DEFAULT_CRON_FORM, scheduleKind: "cron", failureAlertMode: "custom" }, + onFormChange, + }); const prompt = getElement(container, "#cron-payload-text", HTMLTextAreaElement); prompt.value = "do the thing"; prompt.dispatchEvent(new Event("input", { bubbles: true })); expect(onFormChange).toHaveBeenCalledWith({ payloadText: "do the thing" }); - const name = getElement(container, "#cron-name", HTMLInputElement); - name.value = "Thing"; - name.dispatchEvent(new Event("input", { bubbles: true })); - expect(onFormChange).toHaveBeenCalledWith({ name: "Thing" }); + for (const field of ["name", "sessionKey", "deliveryAccountId", "payloadModel"] as const) { + const id = `cron-${field.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`; + const input = getElement(container, `#${id}`, HTMLInputElement); + if (field === "sessionKey" || field === "deliveryAccountId") { + expect(input.placeholder).toBe(field === "sessionKey" ? "agent:main:main" : "default"); + } + input.value = field; + input.dispatchEvent(new Event("input", { bubbles: true })); + expect(onFormChange).toHaveBeenLastCalledWith({ [field]: field }); + } + + const channel = getElement(container, "#cron-failure-alert-channel", HTMLSelectElement); + channel.value = "telegram"; + expect(channel.selectedOptions[0]?.textContent).toBe("Telegram fallback"); + channel.dispatchEvent(new Event("change", { bubbles: true })); + expect(onFormChange).toHaveBeenLastCalledWith({ failureAlertChannel: "telegram" }); }); it("switches schedule inputs by segmented kind and wires kind changes", () => { diff --git a/ui/src/pages/cron/view.ts b/ui/src/pages/cron/view.ts index cd32fb7ffbfb..d61454156a3a 100644 --- a/ui/src/pages/cron/view.ts +++ b/ui/src/pages/cron/view.ts @@ -133,40 +133,24 @@ type CronProps = { // ── Shared option helpers ── function buildChannelOptions(props: CronProps): string[] { - const options = ["last", ...props.channels.filter(Boolean)]; const current = props.form.deliveryChannel?.trim(); - if (current && !options.includes(current)) { - options.push(current); - } - const seen = new Set(); - return options.filter((value) => { - if (seen.has(value)) { - return false; - } - seen.add(value); - return true; - }); + return uniqueStrings(["last", ...props.channels.filter(Boolean), ...(current ? [current] : [])]); } function resolveChannelLabel(props: CronProps, channel: string): string { - if (channel === "last") { - return "last"; - } - const meta = props.channelMeta?.find((entry) => entry.id === channel); - if (meta?.label) { - return meta.label; - } - return props.channelLabels?.[channel] ?? channel; + return channel === "last" + ? channel + : props.channelMeta?.find((entry) => entry.id === channel)?.label || + (props.channelLabels?.[channel] ?? channel); } function renderSuggestionList(id: string, options: string[]) { const clean = uniqueStrings(normalizeStringEntries(options)); - if (clean.length === 0) { - return nothing; - } - return html` - ${clean.map((value) => html` `)} - `; + return clean.length === 0 + ? nothing + : html` + ${clean.map((value) => html` `)} + `; } // ── Validation summary helpers ── @@ -178,45 +162,27 @@ type BlockingField = { inputId: string; }; +const CRON_FIELD_LABEL_KEYS: Record = { + name: "cron.form.fieldName", + scheduleAt: "cron.form.runAt", + everyAmount: "cron.form.every", + cronExpr: "cron.form.expression", + staggerAmount: "cron.form.staggerWindow", + payloadText: "cron.form.assistantTaskPrompt", + payloadModel: "cron.form.model", + payloadThinking: "cron.form.thinking", + timeoutSeconds: "cron.form.timeoutSeconds", + deliveryTo: "cron.form.to", + failureAlertAfter: "cron.form.failureAlertAfter", + failureAlertCooldownSeconds: "cron.form.failureAlertCooldown", +}; + function errorIdForField(key: CronFieldKey) { return `cron-error-${key}`; } -function inputIdForField(key: CronFieldKey) { - if (key === "name") { - return "cron-name"; - } - if (key === "scheduleAt") { - return "cron-schedule-at"; - } - if (key === "everyAmount") { - return "cron-every-amount"; - } - if (key === "cronExpr") { - return "cron-cron-expr"; - } - if (key === "staggerAmount") { - return "cron-stagger-amount"; - } - if (key === "payloadText") { - return "cron-payload-text"; - } - if (key === "payloadModel") { - return "cron-payload-model"; - } - if (key === "payloadThinking") { - return "cron-payload-thinking"; - } - if (key === "timeoutSeconds") { - return "cron-timeout-seconds"; - } - if (key === "failureAlertAfter") { - return "cron-failure-alert-after"; - } - if (key === "failureAlertCooldownSeconds") { - return "cron-failure-alert-cooldown-seconds"; - } - return "cron-delivery-to"; +function inputIdForField(key: string) { + return `cron-${key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`; } function fieldLabelForKey( @@ -224,29 +190,13 @@ function fieldLabelForKey( form: CronFormState, deliveryMode: CronFormState["deliveryMode"], ) { - if (key === "payloadText") { - return form.payloadKind === "systemEvent" - ? t("cron.form.mainTimelineMessage") - : t("cron.form.assistantTaskPrompt"); + if (key === "payloadText" && form.payloadKind === "systemEvent") { + return t("cron.form.mainTimelineMessage"); } - if (key === "deliveryTo") { - return deliveryMode === "webhook" ? t("cron.form.webhookUrl") : t("cron.form.to"); + if (key === "deliveryTo" && deliveryMode === "webhook") { + return t("cron.form.webhookUrl"); } - const labels: Record = { - name: t("cron.form.fieldName"), - scheduleAt: t("cron.form.runAt"), - everyAmount: t("cron.form.every"), - cronExpr: t("cron.form.expression"), - staggerAmount: t("cron.form.staggerWindow"), - payloadText: t("cron.form.assistantTaskPrompt"), - payloadModel: t("cron.form.model"), - payloadThinking: t("cron.form.thinking"), - timeoutSeconds: t("cron.form.timeoutSeconds"), - deliveryTo: t("cron.form.to"), - failureAlertAfter: t("cron.form.failureAlertAfter"), - failureAlertCooldownSeconds: t("cron.form.failureAlertCooldown"), - }; - return labels[key]; + return t(CRON_FIELD_LABEL_KEYS[key]); } function collectBlockingFields( @@ -254,34 +204,19 @@ function collectBlockingFields( form: CronFormState, deliveryMode: CronFormState["deliveryMode"], ): BlockingField[] { - const orderedKeys: CronFieldKey[] = [ - "name", - "scheduleAt", - "everyAmount", - "cronExpr", - "staggerAmount", - "payloadText", - "payloadModel", - "payloadThinking", - "timeoutSeconds", - "deliveryTo", - "failureAlertAfter", - "failureAlertCooldownSeconds", - ]; - const fields: BlockingField[] = []; - for (const key of orderedKeys) { + return (Object.keys(CRON_FIELD_LABEL_KEYS) as CronFieldKey[]).flatMap((key) => { const message = errors[key]; - if (!message) { - continue; - } - fields.push({ - key, - label: fieldLabelForKey(key, form, deliveryMode), - message, - inputId: inputIdForField(key), - }); - } - return fields; + return message + ? [ + { + key, + label: fieldLabelForKey(key, form, deliveryMode), + message, + inputId: inputIdForField(key), + }, + ] + : []; + }); } function focusFormField(id: string) { @@ -347,19 +282,122 @@ function renderFieldRow(params: { `; } -function renderToggleRow(params: { +type CronStringFormField = { + [Field in keyof CronFormState]: CronFormState[Field] extends string ? Field : never; +}[keyof CronFormState]; + +type CronBooleanFormField = { + [Field in keyof CronFormState]: CronFormState[Field] extends boolean ? Field : never; +}[keyof CronFormState]; + +type CronInputOptions = { label: string; - checked: boolean; help?: string; + placeholder?: string; + list?: string; + type?: string; + required?: boolean; disabled?: boolean; - onChange: (checked: boolean) => void; -}) { + mono?: boolean; + errorKey?: CronFieldKey; + describeError?: boolean; +}; + +function renderCronInput(props: CronProps, field: CronStringFormField, options: CronInputOptions) { + const error = options.errorKey ? props.fieldErrors[options.errorKey] : undefined; + const describedBy = + error && options.errorKey && options.describeError !== false + ? errorIdForField(options.errorKey) + : undefined; + return html` + + props.onFormChange({ [field]: (event.currentTarget as HTMLInputElement).value })} + /> + `; +} + +function renderCronInputField( + props: CronProps, + field: CronStringFormField, + options: CronInputOptions, +) { + const errorKey = options.errorKey; + return renderFieldRow({ + label: options.label, + controlId: inputIdForField(field), + required: options.required, + help: options.help, + error: errorKey ? props.fieldErrors[errorKey] : undefined, + errorId: errorKey ? errorIdForField(errorKey) : undefined, + control: renderCronInput(props, field, options), + }); +} + +type CronSelectOption = { value: string; label: string }; + +type CronSelectOptions = { + label: string; + options: readonly CronSelectOption[]; + help?: string; + value?: string; + disabled?: boolean; + standalone?: boolean; +}; + +function renderCronSelect( + props: CronProps, + field: CronStringFormField, + options: CronSelectOptions, +) { + return html` + + `; +} + +function renderCronSelectField( + props: CronProps, + field: CronStringFormField, + options: CronSelectOptions, +) { + return renderFieldRow({ + label: options.label, + controlId: inputIdForField(field), + help: options.help, + control: renderCronSelect(props, field, options), + }); +} + +function renderToggleRow( + props: CronProps, + field: CronBooleanFormField, + params: { label: string; help?: string }, +) { return renderSettingsToggleRow({ title: params.label, description: params.help, - checked: params.checked, - disabled: params.disabled, - onChange: params.onChange, + checked: props.form[field], + onChange: (checked) => props.onFormChange({ [field]: checked }), }); } @@ -517,6 +555,32 @@ function renderToolbar(props: CronProps, hasAdvancedJobsFilters: boolean) { `; } +function renderJobsFilter( + props: CronProps, + field: keyof Parameters[0], + params: { + label: string; + value: string; + options: readonly CronSelectOption[]; + testId?: string; + }, +) { + return html` + + `; +} + function renderJobsFilterPopover(props: CronProps, active: boolean) { return html`