import { describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; const callGateway = vi.fn(); vi.mock("../gateway/call.js", () => ({ callGateway, })); const { resolveCommandSecretRefsViaGateway } = await import("./command-secret-gateway.js"); describe("resolveCommandSecretRefsViaGateway", () => { function makeTalkApiKeySecretRefConfig(envKey: string): OpenClawConfig { return { talk: { apiKey: { source: "env", provider: "default", id: envKey }, }, } as OpenClawConfig; } async function withEnvValue( envKey: string, value: string | undefined, fn: () => Promise, ): Promise { const priorValue = process.env[envKey]; if (value === undefined) { delete process.env[envKey]; } else { process.env[envKey] = value; } try { await fn(); } finally { if (priorValue === undefined) { delete process.env[envKey]; } else { process.env[envKey] = priorValue; } } } async function resolveTalkApiKey(params: { envKey: string; commandName?: string; mode?: "strict" | "summary"; }) { return resolveCommandSecretRefsViaGateway({ config: makeTalkApiKeySecretRefConfig(params.envKey), commandName: params.commandName ?? "memory status", targetIds: new Set(["talk.apiKey"]), mode: params.mode, }); } function expectTalkApiKeySecretRef( result: Awaited>, envKey: string, ) { expect(result.resolvedConfig.talk?.apiKey).toEqual({ source: "env", provider: "default", id: envKey, }); } function expectGatewayUnavailableLocalFallbackDiagnostics( result: Awaited>, ) { expect( result.diagnostics.some((entry) => entry.includes("gateway secrets.resolve unavailable")), ).toBe(true); expect( result.diagnostics.some((entry) => entry.includes("resolved command secrets locally")), ).toBe(true); } it("returns config unchanged when no target SecretRefs are configured", async () => { const config = { talk: { apiKey: "plain", // pragma: allowlist secret }, } as OpenClawConfig; const result = await resolveCommandSecretRefsViaGateway({ config, commandName: "memory status", targetIds: new Set(["talk.apiKey"]), }); expect(result.resolvedConfig).toEqual(config); expect(callGateway).not.toHaveBeenCalled(); }); it("skips gateway resolution when all configured target refs are inactive", async () => { const config = { agents: { list: [ { id: "main", memorySearch: { enabled: false, remote: { apiKey: { source: "env", provider: "default", id: "AGENT_MEMORY_API_KEY" }, }, }, }, ], }, } as unknown as OpenClawConfig; const result = await resolveCommandSecretRefsViaGateway({ config, commandName: "status", targetIds: new Set(["agents.list[].memorySearch.remote.apiKey"]), }); expect(callGateway).not.toHaveBeenCalled(); expect(result.resolvedConfig).toEqual(config); expect(result.diagnostics).toEqual([ "agents.list.0.memorySearch.remote.apiKey: agent or memorySearch override is disabled.", ]); }); it("hydrates requested SecretRef targets from gateway snapshot assignments", async () => { callGateway.mockResolvedValueOnce({ assignments: [ { path: "talk.apiKey", pathSegments: ["talk", "apiKey"], value: "sk-live", }, ], diagnostics: [], }); const config = { talk: { apiKey: { source: "env", provider: "default", id: "TALK_API_KEY" }, }, } as OpenClawConfig; const result = await resolveCommandSecretRefsViaGateway({ config, commandName: "memory status", targetIds: new Set(["talk.apiKey"]), }); expect(callGateway).toHaveBeenCalledWith( expect.objectContaining({ config, method: "secrets.resolve", requiredMethods: ["secrets.resolve"], params: { commandName: "memory status", targetIds: ["talk.apiKey"], }, }), ); expect(result.resolvedConfig.talk?.apiKey).toBe("sk-live"); }); it("fails fast when gateway-backed resolution is unavailable", async () => { const envKey = "TALK_API_KEY_FAILFAST"; const priorValue = process.env[envKey]; delete process.env[envKey]; callGateway.mockRejectedValueOnce(new Error("gateway closed")); try { await expect( resolveCommandSecretRefsViaGateway({ config: { talk: { apiKey: { source: "env", provider: "default", id: envKey }, }, } as OpenClawConfig, commandName: "memory status", targetIds: new Set(["talk.apiKey"]), }), ).rejects.toThrow(/failed to resolve secrets from the active gateway snapshot/i); } finally { if (priorValue === undefined) { delete process.env[envKey]; } else { process.env[envKey] = priorValue; } } }); it("falls back to local resolution when gateway secrets.resolve is unavailable", async () => { const priorValue = process.env.TALK_API_KEY; process.env.TALK_API_KEY = "local-fallback-key"; // pragma: allowlist secret callGateway.mockRejectedValueOnce(new Error("gateway closed")); try { const result = await resolveCommandSecretRefsViaGateway({ config: { talk: { apiKey: { source: "env", provider: "default", id: "TALK_API_KEY" }, }, secrets: { providers: { default: { source: "env" }, }, }, } as OpenClawConfig, commandName: "memory status", targetIds: new Set(["talk.apiKey"]), }); expect(result.resolvedConfig.talk?.apiKey).toBe("local-fallback-key"); expect( result.diagnostics.some((entry) => entry.includes("gateway secrets.resolve unavailable")), ).toBe(true); expect( result.diagnostics.some((entry) => entry.includes("resolved command secrets locally")), ).toBe(true); } finally { if (priorValue === undefined) { delete process.env.TALK_API_KEY; } else { process.env.TALK_API_KEY = priorValue; } } }); it("falls back to local resolution for web search SecretRefs when gateway is unavailable", async () => { const envKey = "WEB_SEARCH_GEMINI_API_KEY_LOCAL_FALLBACK"; await withEnvValue(envKey, "gemini-local-fallback-key", async () => { callGateway.mockRejectedValueOnce(new Error("gateway closed")); const result = await resolveCommandSecretRefsViaGateway({ config: { tools: { web: { search: { provider: "gemini", gemini: { apiKey: { source: "env", provider: "default", id: envKey }, }, }, }, }, } as OpenClawConfig, commandName: "agent", targetIds: new Set(["tools.web.search.gemini.apiKey"]), }); expect(result.resolvedConfig.tools?.web?.search?.gemini?.apiKey).toBe( "gemini-local-fallback-key", ); expect(result.targetStatesByPath["tools.web.search.gemini.apiKey"]).toBe("resolved_local"); expectGatewayUnavailableLocalFallbackDiagnostics(result); }); }); it("falls back to local resolution for Firecrawl SecretRefs when gateway is unavailable", async () => { const envKey = "WEB_FETCH_FIRECRAWL_API_KEY_LOCAL_FALLBACK"; await withEnvValue(envKey, "firecrawl-local-fallback-key", async () => { callGateway.mockRejectedValueOnce(new Error("gateway closed")); const result = await resolveCommandSecretRefsViaGateway({ config: { tools: { web: { fetch: { firecrawl: { apiKey: { source: "env", provider: "default", id: envKey }, }, }, }, }, } as OpenClawConfig, commandName: "agent", targetIds: new Set(["tools.web.fetch.firecrawl.apiKey"]), }); expect(result.resolvedConfig.tools?.web?.fetch?.firecrawl?.apiKey).toBe( "firecrawl-local-fallback-key", ); expect(result.targetStatesByPath["tools.web.fetch.firecrawl.apiKey"]).toBe("resolved_local"); expectGatewayUnavailableLocalFallbackDiagnostics(result); }); }); it("marks web SecretRefs inactive when the web surface is disabled during local fallback", async () => { callGateway.mockRejectedValueOnce(new Error("gateway closed")); const result = await resolveCommandSecretRefsViaGateway({ config: { tools: { web: { search: { enabled: false, gemini: { apiKey: { source: "env", provider: "default", id: "WEB_SEARCH_DISABLED_KEY" }, }, }, }, }, } as OpenClawConfig, commandName: "agent", targetIds: new Set(["tools.web.search.gemini.apiKey"]), }); expect(result.hadUnresolvedTargets).toBe(false); expect(result.targetStatesByPath["tools.web.search.gemini.apiKey"]).toBe("inactive_surface"); expect( result.diagnostics.some((entry) => entry.includes("tools.web.search.gemini.apiKey: tools.web.search is disabled."), ), ).toBe(true); }); it("returns a version-skew hint when gateway does not support secrets.resolve", async () => { const envKey = "TALK_API_KEY_UNSUPPORTED"; callGateway.mockRejectedValueOnce(new Error("unknown method: secrets.resolve")); await withEnvValue(envKey, undefined, async () => { await expect(resolveTalkApiKey({ envKey })).rejects.toThrow( /does not support secrets\.resolve/i, ); }); }); it("returns a version-skew hint when required-method capability check fails", async () => { const envKey = "TALK_API_KEY_REQUIRED_METHOD"; callGateway.mockRejectedValueOnce( new Error( 'active gateway does not support required method "secrets.resolve" for "secrets.resolve".', ), ); await withEnvValue(envKey, undefined, async () => { await expect(resolveTalkApiKey({ envKey })).rejects.toThrow( /does not support secrets\.resolve/i, ); }); }); it("fails when gateway returns an invalid secrets.resolve payload", async () => { callGateway.mockResolvedValueOnce({ assignments: "not-an-array", diagnostics: [], }); await expect( resolveCommandSecretRefsViaGateway({ config: { talk: { apiKey: { source: "env", provider: "default", id: "TALK_API_KEY" }, }, } as OpenClawConfig, commandName: "memory status", targetIds: new Set(["talk.apiKey"]), }), ).rejects.toThrow(/invalid secrets\.resolve payload/i); }); it("fails when gateway assignment path does not exist in local config", async () => { callGateway.mockResolvedValueOnce({ assignments: [ { path: "talk.providers.elevenlabs.apiKey", pathSegments: ["talk", "providers", "elevenlabs", "apiKey"], value: "sk-live", }, ], diagnostics: [], }); await expect( resolveCommandSecretRefsViaGateway({ config: { talk: { apiKey: { source: "env", provider: "default", id: "TALK_API_KEY" }, }, } as OpenClawConfig, commandName: "memory status", targetIds: new Set(["talk.apiKey"]), }), ).rejects.toThrow(/Path segment does not exist/i); }); it("fails when configured refs remain unresolved after gateway assignments are applied", async () => { const envKey = "TALK_API_KEY_STRICT_UNRESOLVED"; callGateway.mockResolvedValueOnce({ assignments: [], diagnostics: [], }); await withEnvValue(envKey, undefined, async () => { await expect(resolveTalkApiKey({ envKey })).rejects.toThrow( /talk\.apiKey is unresolved in the active runtime snapshot/i, ); }); }); it("allows unresolved refs when gateway diagnostics mark the target as inactive", async () => { callGateway.mockResolvedValueOnce({ assignments: [], diagnostics: [ "talk.apiKey: secret ref is configured on an inactive surface; skipping command-time assignment.", ], }); const result = await resolveTalkApiKey({ envKey: "TALK_API_KEY" }); expectTalkApiKeySecretRef(result, "TALK_API_KEY"); expect(result.diagnostics).toEqual([ "talk.apiKey: secret ref is configured on an inactive surface; skipping command-time assignment.", ]); }); it("uses inactiveRefPaths from structured response without parsing diagnostic text", async () => { callGateway.mockResolvedValueOnce({ assignments: [], diagnostics: ["talk api key inactive"], inactiveRefPaths: ["talk.apiKey"], }); const result = await resolveTalkApiKey({ envKey: "TALK_API_KEY" }); expectTalkApiKeySecretRef(result, "TALK_API_KEY"); expect(result.diagnostics).toEqual(["talk api key inactive"]); }); it("allows unresolved array-index refs when gateway marks concrete paths inactive", async () => { callGateway.mockResolvedValueOnce({ assignments: [], diagnostics: ["memory search ref inactive"], inactiveRefPaths: ["agents.list.0.memorySearch.remote.apiKey"], }); const config = { agents: { list: [ { id: "main", memorySearch: { remote: { apiKey: { source: "env", provider: "default", id: "MISSING_MEMORY_API_KEY" }, }, }, }, ], }, } as unknown as OpenClawConfig; const result = await resolveCommandSecretRefsViaGateway({ config, commandName: "memory status", targetIds: new Set(["agents.list[].memorySearch.remote.apiKey"]), }); expect(result.resolvedConfig.agents?.list?.[0]?.memorySearch?.remote?.apiKey).toEqual({ source: "env", provider: "default", id: "MISSING_MEMORY_API_KEY", }); expect(result.diagnostics).toEqual(["memory search ref inactive"]); }); it("degrades unresolved refs in summary mode instead of throwing", async () => { const envKey = "TALK_API_KEY_SUMMARY_MISSING"; callGateway.mockResolvedValueOnce({ assignments: [], diagnostics: [], }); await withEnvValue(envKey, undefined, async () => { const result = await resolveTalkApiKey({ envKey, commandName: "status", mode: "summary", }); expect(result.resolvedConfig.talk?.apiKey).toBeUndefined(); expect(result.hadUnresolvedTargets).toBe(true); expect(result.targetStatesByPath["talk.apiKey"]).toBe("unresolved"); expect( result.diagnostics.some((entry) => entry.includes("talk.apiKey is unavailable in this command path"), ), ).toBe(true); }); }); it("uses targeted local fallback after an incomplete gateway snapshot", async () => { const envKey = "TALK_API_KEY_PARTIAL_GATEWAY"; callGateway.mockResolvedValueOnce({ assignments: [], diagnostics: [], }); await withEnvValue(envKey, "recovered-locally", async () => { const result = await resolveTalkApiKey({ envKey, commandName: "status", mode: "summary", }); expect(result.resolvedConfig.talk?.apiKey).toBe("recovered-locally"); expect(result.hadUnresolvedTargets).toBe(false); expect(result.targetStatesByPath["talk.apiKey"]).toBe("resolved_local"); expect( result.diagnostics.some((entry) => entry.includes( "resolved 1 secret path locally after the gateway snapshot was incomplete", ), ), ).toBe(true); }); }); it("limits strict local fallback analysis to unresolved gateway paths", async () => { const gatewayResolvedKey = "TALK_API_KEY_PARTIAL_GATEWAY_RESOLVED"; const locallyRecoveredKey = "TALK_API_KEY_PARTIAL_GATEWAY_LOCAL"; const priorGatewayResolvedValue = process.env[gatewayResolvedKey]; const priorLocallyRecoveredValue = process.env[locallyRecoveredKey]; delete process.env[gatewayResolvedKey]; process.env[locallyRecoveredKey] = "recovered-locally"; callGateway.mockResolvedValueOnce({ assignments: [ { path: "talk.apiKey", pathSegments: ["talk", "apiKey"], value: "resolved-by-gateway", }, ], diagnostics: [], }); try { const result = await resolveCommandSecretRefsViaGateway({ config: { talk: { apiKey: { source: "env", provider: "default", id: gatewayResolvedKey }, providers: { elevenlabs: { apiKey: { source: "env", provider: "default", id: locallyRecoveredKey }, }, }, }, } as OpenClawConfig, commandName: "message send", targetIds: new Set(["talk.apiKey", "talk.providers.*.apiKey"]), }); expect(result.resolvedConfig.talk?.apiKey).toBe("resolved-by-gateway"); expect(result.resolvedConfig.talk?.providers?.elevenlabs?.apiKey).toBe("recovered-locally"); expect(result.hadUnresolvedTargets).toBe(false); expect(result.targetStatesByPath["talk.apiKey"]).toBe("resolved_gateway"); expect(result.targetStatesByPath["talk.providers.elevenlabs.apiKey"]).toBe("resolved_local"); } finally { if (priorGatewayResolvedValue === undefined) { delete process.env[gatewayResolvedKey]; } else { process.env[gatewayResolvedKey] = priorGatewayResolvedValue; } if (priorLocallyRecoveredValue === undefined) { delete process.env[locallyRecoveredKey]; } else { process.env[locallyRecoveredKey] = priorLocallyRecoveredValue; } } }); it("limits local fallback to targeted refs in read-only modes", async () => { const talkEnvKey = "TALK_API_KEY_TARGET_ONLY"; const gatewayEnvKey = "GATEWAY_PASSWORD_UNRELATED"; const priorTalkValue = process.env[talkEnvKey]; const priorGatewayValue = process.env[gatewayEnvKey]; process.env[talkEnvKey] = "target-only"; delete process.env[gatewayEnvKey]; callGateway.mockRejectedValueOnce(new Error("gateway closed")); try { const result = await resolveCommandSecretRefsViaGateway({ config: { talk: { apiKey: { source: "env", provider: "default", id: talkEnvKey }, }, gateway: { auth: { password: { source: "env", provider: "default", id: gatewayEnvKey }, }, }, } as OpenClawConfig, commandName: "status", targetIds: new Set(["talk.apiKey"]), mode: "summary", }); expect(result.resolvedConfig.talk?.apiKey).toBe("target-only"); expect(result.hadUnresolvedTargets).toBe(false); expect(result.targetStatesByPath["talk.apiKey"]).toBe("resolved_local"); } finally { if (priorTalkValue === undefined) { delete process.env[talkEnvKey]; } else { process.env[talkEnvKey] = priorTalkValue; } if (priorGatewayValue === undefined) { delete process.env[gatewayEnvKey]; } else { process.env[gatewayEnvKey] = priorGatewayValue; } } }); it("degrades unresolved refs in operational read-only mode", async () => { const envKey = "TALK_API_KEY_OPERATIONAL_MISSING"; const priorValue = process.env[envKey]; delete process.env[envKey]; callGateway.mockRejectedValueOnce(new Error("gateway closed")); try { const result = await resolveCommandSecretRefsViaGateway({ config: { talk: { apiKey: { source: "env", provider: "default", id: envKey }, }, } as OpenClawConfig, commandName: "channels resolve", targetIds: new Set(["talk.apiKey"]), mode: "operational_readonly", }); expect(result.resolvedConfig.talk?.apiKey).toBeUndefined(); expect(result.hadUnresolvedTargets).toBe(true); expect(result.targetStatesByPath["talk.apiKey"]).toBe("unresolved"); expect( result.diagnostics.some((entry) => entry.includes("attempted local command-secret resolution"), ), ).toBe(true); } finally { if (priorValue === undefined) { delete process.env[envKey]; } else { process.env[envKey] = priorValue; } } }); });