diff --git a/src/channels/plugins/plugins-core.test.ts b/src/channels/plugins/plugins-core.test.ts index 37ab09f6432..cbc4c9e4da6 100644 --- a/src/channels/plugins/plugins-core.test.ts +++ b/src/channels/plugins/plugins-core.test.ts @@ -75,6 +75,29 @@ describe("channel plugin registry", () => { const pluginIds = listChannelPlugins().map((plugin) => plugin.id); expect(pluginIds).toEqual(["telegram", "slack", "signal"]); }); + + it("refreshes cached channel lookups when the same registry instance is re-activated", () => { + const registry = createTestRegistry([ + { + pluginId: "slack", + plugin: createPlugin("slack"), + source: "test", + }, + ]); + setActivePluginRegistry(registry, "registry-test"); + expect(listChannelPlugins().map((plugin) => plugin.id)).toEqual(["slack"]); + + registry.channels = [ + { + pluginId: "telegram", + plugin: createPlugin("telegram"), + source: "test", + }, + ] as typeof registry.channels; + setActivePluginRegistry(registry, "registry-test"); + + expect(listChannelPlugins().map((plugin) => plugin.id)).toEqual(["telegram"]); + }); }); describe("channel plugin catalog", () => { diff --git a/src/secrets/resolve.test.ts b/src/secrets/resolve.test.ts index fe13de46d97..a7ea8909431 100644 --- a/src/secrets/resolve.test.ts +++ b/src/secrets/resolve.test.ts @@ -12,6 +12,14 @@ async function writeSecureFile(filePath: string, content: string, mode = 0o600): } describe("secret ref resolver", () => { + const isWindows = process.platform === "win32"; + function itPosix(name: string, fn: () => Promise | void) { + if (isWindows) { + it.skip(name, fn); + return; + } + it(name, fn); + } let fixtureRoot = ""; let caseId = 0; let execProtocolV1ScriptPath = ""; @@ -36,6 +44,12 @@ describe("secret ref resolver", () => { trustedDirs?: string[]; args?: string[]; }; + type FileProviderConfig = { + source: "file"; + path: string; + mode: "json" | "singleValue"; + timeoutMs?: number; + }; function createExecProviderConfig( command: string, @@ -67,6 +81,18 @@ describe("secret ref resolver", () => { ); } + function createFileProviderConfig( + filePath: string, + overrides: Partial = {}, + ): FileProviderConfig { + return { + source: "file", + path: filePath, + mode: "json", + ...overrides, + }; + } + beforeAll(async () => { fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secrets-resolve-")); const sharedExecDir = path.join(fixtureRoot, "shared-exec"); @@ -133,10 +159,7 @@ describe("secret ref resolver", () => { expect(value).toBe("sk-env-value"); }); - it("resolves file refs in json mode", async () => { - if (process.platform === "win32") { - return; - } + itPosix("resolves file refs in json mode", async () => { const root = await createCaseDir("file"); const filePath = path.join(root, "secrets.json"); await writeSecureFile( @@ -156,11 +179,7 @@ describe("secret ref resolver", () => { config: { secrets: { providers: { - filemain: { - source: "file", - path: filePath, - mode: "json", - }, + filemain: createFileProviderConfig(filePath), }, }, }, @@ -169,19 +188,12 @@ describe("secret ref resolver", () => { expect(value).toBe("sk-file-value"); }); - it("resolves exec refs with protocolVersion 1 response", async () => { - if (process.platform === "win32") { - return; - } - + itPosix("resolves exec refs with protocolVersion 1 response", async () => { const value = await resolveExecSecret(execProtocolV1ScriptPath); expect(value).toBe("value:openai/api-key"); }); - it("uses timeoutMs as the default no-output timeout for exec providers", async () => { - if (process.platform === "win32") { - return; - } + itPosix("uses timeoutMs as the default no-output timeout for exec providers", async () => { const root = await createCaseDir("exec-delay"); const scriptPath = path.join(root, "resolver-delay.mjs"); await writeSecureFile( @@ -215,19 +227,12 @@ describe("secret ref resolver", () => { expect(value).toBe("ok"); }); - it("supports non-JSON single-value exec output when jsonOnly is false", async () => { - if (process.platform === "win32") { - return; - } - + itPosix("supports non-JSON single-value exec output when jsonOnly is false", async () => { const value = await resolveExecSecret(execPlainScriptPath, { jsonOnly: false }); expect(value).toBe("plain-secret"); }); - it("ignores EPIPE when exec provider exits before consuming stdin", async () => { - if (process.platform === "win32") { - return; - } + itPosix("ignores EPIPE when exec provider exits before consuming stdin", async () => { const oversizedId = `openai/${"x".repeat(120_000)}`; await expect( resolveSecretRefString( @@ -248,10 +253,7 @@ describe("secret ref resolver", () => { ).rejects.toThrow('Exec provider "execmain" returned empty stdout.'); }); - it("rejects symlink command paths unless allowSymlinkCommand is enabled", async () => { - if (process.platform === "win32") { - return; - } + itPosix("rejects symlink command paths unless allowSymlinkCommand is enabled", async () => { const root = await createCaseDir("exec-link-reject"); const symlinkPath = path.join(root, "resolver-link.mjs"); await fs.symlink(execPlainScriptPath, symlinkPath); @@ -261,10 +263,7 @@ describe("secret ref resolver", () => { ); }); - it("allows symlink command paths when allowSymlinkCommand is enabled", async () => { - if (process.platform === "win32") { - return; - } + itPosix("allows symlink command paths when allowSymlinkCommand is enabled", async () => { const root = await createCaseDir("exec-link-allow"); const symlinkPath = path.join(root, "resolver-link.mjs"); await fs.symlink(execPlainScriptPath, symlinkPath); @@ -278,47 +277,43 @@ describe("secret ref resolver", () => { expect(value).toBe("plain-secret"); }); - it("handles Homebrew-style symlinked exec commands with args only when explicitly allowed", async () => { - if (process.platform === "win32") { - return; - } + itPosix( + "handles Homebrew-style symlinked exec commands with args only when explicitly allowed", + async () => { + const root = await createCaseDir("homebrew"); + const binDir = path.join(root, "opt", "homebrew", "bin"); + const cellarDir = path.join(root, "opt", "homebrew", "Cellar", "node", "25.0.0", "bin"); + await fs.mkdir(binDir, { recursive: true }); + await fs.mkdir(cellarDir, { recursive: true }); - const root = await createCaseDir("homebrew"); - const binDir = path.join(root, "opt", "homebrew", "bin"); - const cellarDir = path.join(root, "opt", "homebrew", "Cellar", "node", "25.0.0", "bin"); - await fs.mkdir(binDir, { recursive: true }); - await fs.mkdir(cellarDir, { recursive: true }); + const targetCommand = path.join(cellarDir, "node"); + const symlinkCommand = path.join(binDir, "node"); + await writeSecureFile( + targetCommand, + [ + "#!/bin/sh", + 'suffix="${1:-missing}"', + 'printf \'{"protocolVersion":1,"values":{"openai/api-key":"%s:openai/api-key"}}\' "$suffix"', + ].join("\n"), + 0o700, + ); + await fs.symlink(targetCommand, symlinkCommand); + const trustedRoot = await fs.realpath(root); - const targetCommand = path.join(cellarDir, "node"); - const symlinkCommand = path.join(binDir, "node"); - await writeSecureFile( - targetCommand, - [ - "#!/bin/sh", - 'suffix="${1:-missing}"', - 'printf \'{"protocolVersion":1,"values":{"openai/api-key":"%s:openai/api-key"}}\' "$suffix"', - ].join("\n"), - 0o700, - ); - await fs.symlink(targetCommand, symlinkCommand); - const trustedRoot = await fs.realpath(root); + await expect(resolveExecSecret(symlinkCommand, { args: ["brew"] })).rejects.toThrow( + "must not be a symlink", + ); - await expect(resolveExecSecret(symlinkCommand, { args: ["brew"] })).rejects.toThrow( - "must not be a symlink", - ); + const value = await resolveExecSecret(symlinkCommand, { + args: ["brew"], + allowSymlinkCommand: true, + trustedDirs: [trustedRoot], + }); + expect(value).toBe("brew:openai/api-key"); + }, + ); - const value = await resolveExecSecret(symlinkCommand, { - args: ["brew"], - allowSymlinkCommand: true, - trustedDirs: [trustedRoot], - }); - expect(value).toBe("brew:openai/api-key"); - }); - - it("checks trustedDirs against resolved symlink target", async () => { - if (process.platform === "win32") { - return; - } + itPosix("checks trustedDirs against resolved symlink target", async () => { const root = await createCaseDir("exec-link-trusted"); const symlinkPath = path.join(root, "resolver-link.mjs"); await fs.symlink(execPlainScriptPath, symlinkPath); @@ -332,37 +327,25 @@ describe("secret ref resolver", () => { ).rejects.toThrow("outside trustedDirs"); }); - it("rejects exec refs when protocolVersion is not 1", async () => { - if (process.platform === "win32") { - return; - } + itPosix("rejects exec refs when protocolVersion is not 1", async () => { await expect(resolveExecSecret(execProtocolV2ScriptPath)).rejects.toThrow( "protocolVersion must be 1", ); }); - it("rejects exec refs when response omits requested id", async () => { - if (process.platform === "win32") { - return; - } + itPosix("rejects exec refs when response omits requested id", async () => { await expect(resolveExecSecret(execMissingIdScriptPath)).rejects.toThrow( 'response missing id "openai/api-key"', ); }); - it("rejects exec refs with invalid JSON when jsonOnly is true", async () => { - if (process.platform === "win32") { - return; - } + itPosix("rejects exec refs with invalid JSON when jsonOnly is true", async () => { await expect(resolveExecSecret(execInvalidJsonScriptPath, { jsonOnly: true })).rejects.toThrow( "returned invalid JSON", ); }); - it("supports file singleValue mode with id=value", async () => { - if (process.platform === "win32") { - return; - } + itPosix("supports file singleValue mode with id=value", async () => { const root = await createCaseDir("file-single-value"); const filePath = path.join(root, "token.txt"); await writeSecureFile(filePath, "raw-token-value\n"); @@ -373,11 +356,9 @@ describe("secret ref resolver", () => { config: { secrets: { providers: { - rawfile: { - source: "file", - path: filePath, + rawfile: createFileProviderConfig(filePath, { mode: "singleValue", - }, + }), }, }, }, @@ -386,10 +367,7 @@ describe("secret ref resolver", () => { expect(value).toBe("raw-token-value"); }); - it("times out file provider reads when timeoutMs elapses", async () => { - if (process.platform === "win32") { - return; - } + itPosix("times out file provider reads when timeoutMs elapses", async () => { const root = await createCaseDir("file-timeout"); const filePath = path.join(root, "secrets.json"); await writeSecureFile( @@ -422,12 +400,9 @@ describe("secret ref resolver", () => { config: { secrets: { providers: { - filemain: { - source: "file", - path: filePath, - mode: "json", + filemain: createFileProviderConfig(filePath, { timeoutMs: 5, - }, + }), }, }, },