diff --git a/src/gateway/server.talk-config.test.ts b/src/gateway/server.talk-config.test.ts index ad9027f36fc..6723f30cd45 100644 --- a/src/gateway/server.talk-config.test.ts +++ b/src/gateway/server.talk-config.test.ts @@ -20,6 +20,26 @@ import { withServer } from "./test-with-server.js"; installGatewayTestHooks({ scope: "suite" }); type GatewaySocket = Parameters[0]>[0]; +type SecretRef = { source?: string; provider?: string; id?: string }; +type TalkConfigPayload = { + config?: { + talk?: { + provider?: string; + providers?: { + elevenlabs?: { voiceId?: string; apiKey?: string | SecretRef }; + }; + resolved?: { + provider?: string; + config?: { voiceId?: string; apiKey?: string | SecretRef }; + }; + apiKey?: string | SecretRef; + voiceId?: string; + silenceTimeoutMs?: number; + }; + session?: { mainKey?: string }; + ui?: { seamColor?: string }; + }; +}; const TALK_CONFIG_DEVICE_PATH = path.join( os.tmpdir(), `openclaw-talk-config-device-${process.pid}.json`, @@ -67,6 +87,37 @@ async function writeTalkConfig(config: { await writeConfigFile({ talk: config }); } +async function fetchTalkConfig( + ws: GatewaySocket, + params?: { includeSecrets?: boolean } | Record, +) { + return rpcReq(ws, "talk.config", params ?? {}); +} + +function expectElevenLabsTalkConfig( + talk: TalkConfigPayload["config"] extends { talk?: infer T } ? T : never, + expected: { + voiceId?: string; + apiKey?: string | SecretRef; + silenceTimeoutMs?: number; + }, +) { + expect(talk?.provider).toBe("elevenlabs"); + expect(talk?.providers?.elevenlabs?.voiceId).toBe(expected.voiceId); + expect(talk?.resolved?.provider).toBe("elevenlabs"); + expect(talk?.resolved?.config?.voiceId).toBe(expected.voiceId); + expect(talk?.voiceId).toBe(expected.voiceId); + + if ("apiKey" in expected) { + expect(talk?.providers?.elevenlabs?.apiKey).toEqual(expected.apiKey); + expect(talk?.resolved?.config?.apiKey).toEqual(expected.apiKey); + expect(talk?.apiKey).toEqual(expected.apiKey); + } + if ("silenceTimeoutMs" in expected) { + expect(talk?.silenceTimeoutMs).toBe(expected.silenceTimeoutMs); + } +} + describe("gateway talk.config", () => { it("returns redacted talk config for read scope", async () => { const { writeConfigFile } = await import("../config/config.js"); @@ -86,35 +137,26 @@ describe("gateway talk.config", () => { await withServer(async (ws) => { await connectOperator(ws, ["operator.read"]); - const res = await rpcReq<{ - config?: { - talk?: { - provider?: string; - providers?: { - elevenlabs?: { voiceId?: string; apiKey?: string }; - }; - resolved?: { - provider?: string; - config?: { voiceId?: string; apiKey?: string }; - }; - apiKey?: string; - voiceId?: string; - silenceTimeoutMs?: number; - }; - }; - }>(ws, "talk.config", {}); + const res = await fetchTalkConfig(ws); expect(res.ok).toBe(true); - expect(res.payload?.config?.talk?.provider).toBe("elevenlabs"); - expect(res.payload?.config?.talk?.providers?.elevenlabs?.voiceId).toBe("voice-123"); - expect(res.payload?.config?.talk?.providers?.elevenlabs?.apiKey).toBe( - "__OPENCLAW_REDACTED__", - ); - expect(res.payload?.config?.talk?.resolved?.provider).toBe("elevenlabs"); - expect(res.payload?.config?.talk?.resolved?.config?.voiceId).toBe("voice-123"); - expect(res.payload?.config?.talk?.resolved?.config?.apiKey).toBe("__OPENCLAW_REDACTED__"); - expect(res.payload?.config?.talk?.voiceId).toBe("voice-123"); - expect(res.payload?.config?.talk?.apiKey).toBe("__OPENCLAW_REDACTED__"); - expect(res.payload?.config?.talk?.silenceTimeoutMs).toBe(1500); + expectElevenLabsTalkConfig(res.payload?.config?.talk, { + voiceId: "voice-123", + apiKey: "__OPENCLAW_REDACTED__", + silenceTimeoutMs: 1500, + }); + expect(res.payload?.config?.session?.mainKey).toBe("main-test"); + expect(res.payload?.config?.ui?.seamColor).toBe("#112233"); + }); + }); + + it("rejects invalid talk.config params", async () => { + await writeTalkConfig({ apiKey: "secret-key-abc" }); // pragma: allowlist secret + + await withServer(async (ws) => { + await connectOperator(ws, ["operator.read"]); + const res = await fetchTalkConfig(ws, { includeSecrets: "yes" }); + expect(res.ok).toBe(false); + expect(res.error?.message).toContain("invalid talk.config params"); }); }); @@ -123,22 +165,25 @@ describe("gateway talk.config", () => { await withServer(async (ws) => { await connectOperator(ws, ["operator.read"]); - const res = await rpcReq(ws, "talk.config", { includeSecrets: true }); + const res = await fetchTalkConfig(ws, { includeSecrets: true }); expect(res.ok).toBe(false); expect(res.error?.message).toContain("missing scope: operator.talk.secrets"); }); }); - it("returns secrets for operator.talk.secrets scope", async () => { + it.each([ + ["operator.talk.secrets", ["operator.read", "operator.write", "operator.talk.secrets"]], + ["operator.admin", ["operator.read", "operator.admin"]], + ] as const)("returns secrets for %s scope", async (_label, scopes) => { await writeTalkConfig({ apiKey: "secret-key-abc" }); // pragma: allowlist secret await withServer(async (ws) => { - await connectOperator(ws, ["operator.read", "operator.write", "operator.talk.secrets"]); - const res = await rpcReq<{ config?: { talk?: { apiKey?: string } } }>(ws, "talk.config", { - includeSecrets: true, - }); + await connectOperator(ws, [...scopes]); + const res = await fetchTalkConfig(ws, { includeSecrets: true }); expect(res.ok).toBe(true); - expect(res.payload?.config?.talk?.apiKey).toBe("secret-key-abc"); + expectElevenLabsTalkConfig(res.payload?.config?.talk, { + apiKey: "secret-key-abc", + }); }); }); @@ -154,44 +199,15 @@ describe("gateway talk.config", () => { await withEnvAsync({ ELEVENLABS_API_KEY: "env-elevenlabs-key" }, async () => { await withServer(async (ws) => { await connectOperator(ws, ["operator.read", "operator.write", "operator.talk.secrets"]); - const res = await rpcReq<{ - config?: { - talk?: { - apiKey?: { source?: string; provider?: string; id?: string }; - providers?: { - elevenlabs?: { - apiKey?: { source?: string; provider?: string; id?: string }; - }; - }; - resolved?: { - provider?: string; - config?: { - apiKey?: { source?: string; provider?: string; id?: string }; - }; - }; - }; - }; - }>(ws, "talk.config", { - includeSecrets: true, - }); + const res = await fetchTalkConfig(ws, { includeSecrets: true }); expect(res.ok).toBe(true); expect(validateTalkConfigResult(res.payload)).toBe(true); - expect(res.payload?.config?.talk?.apiKey).toEqual({ + const secretRef = { source: "env", provider: "default", id: "ELEVENLABS_API_KEY", - }); - expect(res.payload?.config?.talk?.providers?.elevenlabs?.apiKey).toEqual({ - source: "env", - provider: "default", - id: "ELEVENLABS_API_KEY", - }); - expect(res.payload?.config?.talk?.resolved?.provider).toBe("elevenlabs"); - expect(res.payload?.config?.talk?.resolved?.config?.apiKey).toEqual({ - source: "env", - provider: "default", - id: "ELEVENLABS_API_KEY", - }); + } satisfies SecretRef; + expectElevenLabsTalkConfig(res.payload?.config?.talk, { apiKey: secretRef }); }); }); }); @@ -212,27 +228,11 @@ describe("gateway talk.config", () => { await withServer(async (ws) => { await connectOperator(ws, ["operator.read"]); - const res = await rpcReq<{ - config?: { - talk?: { - provider?: string; - providers?: { - elevenlabs?: { voiceId?: string }; - }; - resolved?: { - provider?: string; - config?: { voiceId?: string }; - }; - voiceId?: string; - }; - }; - }>(ws, "talk.config", {}); + const res = await fetchTalkConfig(ws); expect(res.ok).toBe(true); - expect(res.payload?.config?.talk?.provider).toBe("elevenlabs"); - expect(res.payload?.config?.talk?.providers?.elevenlabs?.voiceId).toBe("voice-normalized"); - expect(res.payload?.config?.talk?.resolved?.provider).toBe("elevenlabs"); - expect(res.payload?.config?.talk?.resolved?.config?.voiceId).toBe("voice-normalized"); - expect(res.payload?.config?.talk?.voiceId).toBe("voice-normalized"); + expectElevenLabsTalkConfig(res.payload?.config?.talk, { + voiceId: "voice-normalized", + }); }); }); }); diff --git a/src/infra/path-env.test.ts b/src/infra/path-env.test.ts index a439602d653..75c63f11d17 100644 --- a/src/infra/path-env.test.ts +++ b/src/infra/path-env.test.ts @@ -72,26 +72,39 @@ describe("ensureOpenClawCliOnPath", () => { } }); - it("prepends the bundled app bin dir when a sibling openclaw exists", () => { - const tmp = abs("/tmp/openclaw-path/case-bundled"); + function setupAppCliRoot(name: string) { + const tmp = abs(`/tmp/openclaw-path/${name}`); const appBinDir = path.join(tmp, "AppBin"); - const cliPath = path.join(appBinDir, "openclaw"); + const appCli = path.join(appBinDir, "openclaw"); setDir(tmp); setDir(appBinDir); - setExe(cliPath); + setExe(appCli); + return { tmp, appBinDir, appCli }; + } + function bootstrapPath(params: { + execPath: string; + cwd: string; + homeDir: string; + platform: NodeJS.Platform; + allowProjectLocalBin?: boolean; + }) { + ensureOpenClawCliOnPath(params); + return (process.env.PATH ?? "").split(path.delimiter); + } + + it("prepends the bundled app bin dir when a sibling openclaw exists", () => { + const { tmp, appBinDir, appCli } = setupAppCliRoot("case-bundled"); process.env.PATH = "/usr/bin"; delete process.env.OPENCLAW_PATH_BOOTSTRAPPED; - ensureOpenClawCliOnPath({ - execPath: cliPath, + const updated = bootstrapPath({ + execPath: appCli, cwd: tmp, homeDir: tmp, platform: "darwin", }); - - const updated = process.env.PATH ?? ""; - expect(updated.split(path.delimiter)[0]).toBe(appBinDir); + expect(updated[0]).toBe(appBinDir); }); it("is idempotent", () => { @@ -107,13 +120,7 @@ describe("ensureOpenClawCliOnPath", () => { }); it("prepends mise shims when available", () => { - const tmp = abs("/tmp/openclaw-path/case-mise"); - const appBinDir = path.join(tmp, "AppBin"); - const appCli = path.join(appBinDir, "openclaw"); - setDir(tmp); - setDir(appBinDir); - setExe(appCli); - + const { tmp, appBinDir, appCli } = setupAppCliRoot("case-mise"); const miseDataDir = path.join(tmp, "mise"); const shimsDir = path.join(miseDataDir, "shims"); setDir(miseDataDir); @@ -123,62 +130,92 @@ describe("ensureOpenClawCliOnPath", () => { process.env.PATH = "/usr/bin"; delete process.env.OPENCLAW_PATH_BOOTSTRAPPED; - ensureOpenClawCliOnPath({ + const updated = bootstrapPath({ execPath: appCli, cwd: tmp, homeDir: tmp, platform: "darwin", }); - - const updated = process.env.PATH ?? ""; - const parts = updated.split(path.delimiter); - const appBinIndex = parts.indexOf(appBinDir); - const shimsIndex = parts.indexOf(shimsDir); + const appBinIndex = updated.indexOf(appBinDir); + const shimsIndex = updated.indexOf(shimsDir); expect(appBinIndex).toBeGreaterThanOrEqual(0); expect(shimsIndex).toBeGreaterThan(appBinIndex); }); - it("only appends project-local node_modules/.bin when explicitly enabled", () => { - const tmp = abs("/tmp/openclaw-path/case-project-local"); - const appBinDir = path.join(tmp, "AppBin"); - const appCli = path.join(appBinDir, "openclaw"); - setDir(tmp); - setDir(appBinDir); - setExe(appCli); - - const localBinDir = path.join(tmp, "node_modules", ".bin"); - const localCli = path.join(localBinDir, "openclaw"); - setDir(path.join(tmp, "node_modules")); - setDir(localBinDir); - setExe(localCli); - - process.env.PATH = "/usr/bin"; - delete process.env.OPENCLAW_PATH_BOOTSTRAPPED; - - ensureOpenClawCliOnPath({ - execPath: appCli, - cwd: tmp, - homeDir: tmp, - platform: "darwin", - }); - const withoutOptIn = (process.env.PATH ?? "").split(path.delimiter); - expect(withoutOptIn.includes(localBinDir)).toBe(false); - - process.env.PATH = "/usr/bin"; - delete process.env.OPENCLAW_PATH_BOOTSTRAPPED; - - ensureOpenClawCliOnPath({ - execPath: appCli, - cwd: tmp, - homeDir: tmp, - platform: "darwin", + it.each([ + { + name: "explicit option", + envValue: undefined, allowProjectLocalBin: true, + }, + { + name: "truthy env", + envValue: "1", + allowProjectLocalBin: undefined, + }, + ])( + "only appends project-local node_modules/.bin when enabled via $name", + ({ envValue, allowProjectLocalBin }) => { + const { tmp, appCli } = setupAppCliRoot("case-project-local"); + const localBinDir = path.join(tmp, "node_modules", ".bin"); + const localCli = path.join(localBinDir, "openclaw"); + setDir(path.join(tmp, "node_modules")); + setDir(localBinDir); + setExe(localCli); + + process.env.PATH = "/usr/bin"; + delete process.env.OPENCLAW_PATH_BOOTSTRAPPED; + delete process.env.OPENCLAW_ALLOW_PROJECT_LOCAL_BIN; + + const withoutOptIn = bootstrapPath({ + execPath: appCli, + cwd: tmp, + homeDir: tmp, + platform: "darwin", + }); + expect(withoutOptIn.includes(localBinDir)).toBe(false); + + process.env.PATH = "/usr/bin"; + delete process.env.OPENCLAW_PATH_BOOTSTRAPPED; + if (envValue === undefined) { + delete process.env.OPENCLAW_ALLOW_PROJECT_LOCAL_BIN; + } else { + process.env.OPENCLAW_ALLOW_PROJECT_LOCAL_BIN = envValue; + } + + const withOptIn = bootstrapPath({ + execPath: appCli, + cwd: tmp, + homeDir: tmp, + platform: "darwin", + ...(allowProjectLocalBin === undefined ? {} : { allowProjectLocalBin }), + }); + const usrBinIndex = withOptIn.indexOf("/usr/bin"); + const localIndex = withOptIn.indexOf(localBinDir); + expect(usrBinIndex).toBeGreaterThanOrEqual(0); + expect(localIndex).toBeGreaterThan(usrBinIndex); + }, + ); + + it("prepends XDG_BIN_HOME ahead of other user bin fallbacks", () => { + const { tmp, appCli } = setupAppCliRoot("case-xdg-bin-home"); + const xdgBinHome = path.join(tmp, "xdg-bin"); + const localBin = path.join(tmp, ".local", "bin"); + setDir(xdgBinHome); + setDir(path.join(tmp, ".local")); + setDir(localBin); + + process.env.PATH = "/usr/bin"; + process.env.XDG_BIN_HOME = xdgBinHome; + delete process.env.OPENCLAW_PATH_BOOTSTRAPPED; + + const updated = bootstrapPath({ + execPath: appCli, + cwd: tmp, + homeDir: tmp, + platform: "linux", }); - const withOptIn = (process.env.PATH ?? "").split(path.delimiter); - const usrBinIndex = withOptIn.indexOf("/usr/bin"); - const localIndex = withOptIn.indexOf(localBinDir); - expect(usrBinIndex).toBeGreaterThanOrEqual(0); - expect(localIndex).toBeGreaterThan(usrBinIndex); + expect(updated.indexOf(xdgBinHome)).toBeLessThan(updated.indexOf(localBin)); }); it("prepends Linuxbrew dirs when present", () => { @@ -200,15 +237,12 @@ describe("ensureOpenClawCliOnPath", () => { delete process.env.HOMEBREW_BREW_FILE; delete process.env.XDG_BIN_HOME; - ensureOpenClawCliOnPath({ + const parts = bootstrapPath({ execPath: path.join(execDir, "node"), cwd: tmp, homeDir: tmp, platform: "linux", }); - - const updated = process.env.PATH ?? ""; - const parts = updated.split(path.delimiter); expect(parts[0]).toBe(linuxbrewBin); expect(parts[1]).toBe(linuxbrewSbin); });