From fd4d157e4583fbf291faa2f9f84578646d640f9c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 2 Mar 2026 09:45:52 +0000 Subject: [PATCH] test(config): reuse fixtures for faster validation --- src/config/config.plugin-validation.test.ts | 78 +-- src/config/schema.test.ts | 12 +- src/config/sessions.test.ts | 89 ++- src/secrets/resolve.test.ts | 586 +++++++++++--------- test/scripts/ios-team-id.test.ts | 133 +++-- 5 files changed, 520 insertions(+), 378 deletions(-) diff --git a/src/config/config.plugin-validation.test.ts b/src/config/config.plugin-validation.test.ts index 02542eac39b..0bb3c10cb92 100644 --- a/src/config/config.plugin-validation.test.ts +++ b/src/config/config.plugin-validation.test.ts @@ -1,7 +1,8 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { clearPluginManifestRegistryCache } from "../plugins/manifest-registry.js"; import { validateConfigObjectWithPlugins } from "./config.js"; async function writePluginFixture(params: { @@ -31,27 +32,44 @@ async function writePluginFixture(params: { } describe("config plugin validation", () => { - const fixtureRoot = path.join(os.tmpdir(), "openclaw-config-plugin-validation"); - let caseIndex = 0; + let fixtureRoot = ""; + let suiteHome = ""; + const envSnapshot = { + OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR, + OPENCLAW_PLUGIN_MANIFEST_CACHE_MS: process.env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS, + }; - function createCaseHome() { - const home = path.join(fixtureRoot, `case-${caseIndex++}`); - return fs.mkdir(home, { recursive: true }).then(() => home); - } - - const validateInHome = (home: string, raw: unknown) => { - process.env.OPENCLAW_STATE_DIR = path.join(home, ".openclaw"); + const validateInSuite = (raw: unknown) => { + process.env.OPENCLAW_STATE_DIR = path.join(suiteHome, ".openclaw"); return validateConfigObjectWithPlugins(raw); }; + beforeAll(async () => { + fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-config-plugin-validation-")); + suiteHome = path.join(fixtureRoot, "home"); + await fs.mkdir(suiteHome, { recursive: true }); + process.env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS = "10000"; + clearPluginManifestRegistryCache(); + }); + afterAll(async () => { await fs.rm(fixtureRoot, { recursive: true, force: true }); + clearPluginManifestRegistryCache(); + if (envSnapshot.OPENCLAW_STATE_DIR === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = envSnapshot.OPENCLAW_STATE_DIR; + } + if (envSnapshot.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS === undefined) { + delete process.env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS; + } else { + process.env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS = envSnapshot.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS; + } }); it("rejects missing plugin load paths", async () => { - const home = await createCaseHome(); - const missingPath = path.join(home, "missing-plugin"); - const res = validateInHome(home, { + const missingPath = path.join(suiteHome, "missing-plugin"); + const res = validateInSuite({ agents: { list: [{ id: "pi" }] }, plugins: { enabled: false, load: { paths: [missingPath] } }, }); @@ -66,8 +84,7 @@ describe("config plugin validation", () => { }); it("warns for missing plugin ids in entries instead of failing validation", async () => { - const home = await createCaseHome(); - const res = validateInHome(home, { + const res = validateInSuite({ agents: { list: [{ id: "pi" }] }, plugins: { enabled: false, entries: { "missing-plugin": { enabled: true } } }, }); @@ -82,8 +99,7 @@ describe("config plugin validation", () => { }); it("rejects missing plugin ids in allow/deny/slots", async () => { - const home = await createCaseHome(); - const res = validateInHome(home, { + const res = validateInSuite({ agents: { list: [{ id: "pi" }] }, plugins: { enabled: false, @@ -105,9 +121,8 @@ describe("config plugin validation", () => { }); it("warns for removed legacy plugin ids instead of failing validation", async () => { - const home = await createCaseHome(); const removedId = "google-antigravity-auth"; - const res = validateInHome(home, { + const res = validateInSuite({ agents: { list: [{ id: "pi" }] }, plugins: { enabled: false, @@ -147,8 +162,7 @@ describe("config plugin validation", () => { }); it("surfaces plugin config diagnostics", async () => { - const home = await createCaseHome(); - const pluginDir = path.join(home, "bad-plugin"); + const pluginDir = path.join(suiteHome, "bad-plugin"); await writePluginFixture({ dir: pluginDir, id: "bad-plugin", @@ -162,7 +176,7 @@ describe("config plugin validation", () => { }, }); - const res = validateInHome(home, { + const res = validateInSuite({ agents: { list: [{ id: "pi" }] }, plugins: { enabled: true, @@ -182,8 +196,7 @@ describe("config plugin validation", () => { }); it("accepts known plugin ids", async () => { - const home = await createCaseHome(); - const res = validateInHome(home, { + const res = validateInSuite({ agents: { list: [{ id: "pi" }] }, plugins: { enabled: false, entries: { discord: { enabled: true } } }, }); @@ -191,8 +204,7 @@ describe("config plugin validation", () => { }); it("accepts channels.modelByChannel", async () => { - const home = await createCaseHome(); - const res = validateInHome(home, { + const res = validateInSuite({ agents: { list: [{ id: "pi" }] }, channels: { modelByChannel: { @@ -206,8 +218,7 @@ describe("config plugin validation", () => { }); it("accepts plugin heartbeat targets", async () => { - const home = await createCaseHome(); - const pluginDir = path.join(home, "bluebubbles-plugin"); + const pluginDir = path.join(suiteHome, "bluebubbles-plugin"); await writePluginFixture({ dir: pluginDir, id: "bluebubbles-plugin", @@ -215,7 +226,7 @@ describe("config plugin validation", () => { schema: { type: "object" }, }); - const res = validateInHome(home, { + const res = validateInSuite({ agents: { defaults: { heartbeat: { target: "bluebubbles" } }, list: [{ id: "pi" }] }, plugins: { enabled: false, load: { paths: [pluginDir] } }, }); @@ -223,8 +234,7 @@ describe("config plugin validation", () => { }); it("rejects unknown heartbeat targets", async () => { - const home = await createCaseHome(); - const res = validateInHome(home, { + const res = validateInSuite({ agents: { defaults: { heartbeat: { target: "not-a-channel" } }, list: [{ id: "pi" }] }, }); expect(res.ok).toBe(false); @@ -237,8 +247,7 @@ describe("config plugin validation", () => { }); it("accepts heartbeat directPolicy enum values", async () => { - const home = await createCaseHome(); - const res = validateInHome(home, { + const res = validateInSuite({ agents: { defaults: { heartbeat: { target: "last", directPolicy: "block" } }, list: [{ id: "pi", heartbeat: { directPolicy: "allow" } }], @@ -248,8 +257,7 @@ describe("config plugin validation", () => { }); it("rejects invalid heartbeat directPolicy values", async () => { - const home = await createCaseHome(); - const res = validateInHome(home, { + const res = validateInSuite({ agents: { defaults: { heartbeat: { directPolicy: "maybe" } }, list: [{ id: "pi" }], diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index eaabe2841b1..2646387533b 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -1,10 +1,16 @@ -import { describe, expect, it } from "vitest"; +import { beforeAll, describe, expect, it } from "vitest"; import { buildConfigSchema } from "./schema.js"; import { applyDerivedTags, CONFIG_TAGS, deriveTagsForPath } from "./schema.tags.js"; describe("config schema", () => { + let baseSchema: ReturnType; + + beforeAll(() => { + baseSchema = buildConfigSchema(); + }); + it("exports schema + hints", () => { - const res = buildConfigSchema(); + const res = baseSchema; const schema = res.schema as { properties?: Record }; expect(schema.properties?.gateway).toBeTruthy(); expect(schema.properties?.agents).toBeTruthy(); @@ -148,7 +154,7 @@ describe("config schema", () => { }); it("covers core/built-in config paths with tags", () => { - const schema = buildConfigSchema(); + const schema = baseSchema; const allowed = new Set(CONFIG_TAGS); for (const [key, hint] of Object.entries(schema.uiHints)) { if (!key.includes(".")) { diff --git a/src/config/sessions.test.ts b/src/config/sessions.test.ts index ea4eaa8b41e..63aab751362 100644 --- a/src/config/sessions.test.ts +++ b/src/config/sessions.test.ts @@ -44,10 +44,43 @@ describe("sessions", () => { }): Promise<{ storePath: string }> { const dir = await createCaseDir(params.prefix); const storePath = path.join(dir, "sessions.json"); - await fs.writeFile(storePath, JSON.stringify(params.entries, null, 2), "utf-8"); + await fs.writeFile(storePath, JSON.stringify(params.entries), "utf-8"); return { storePath }; } + async function createAgentSessionsLayout(label: string): Promise<{ + stateDir: string; + mainStorePath: string; + bot2SessionPath: string; + outsidePath: string; + }> { + const stateDir = await createCaseDir(label); + const mainSessionsDir = path.join(stateDir, "agents", "main", "sessions"); + const bot1SessionsDir = path.join(stateDir, "agents", "bot1", "sessions"); + const bot2SessionsDir = path.join(stateDir, "agents", "bot2", "sessions"); + await fs.mkdir(mainSessionsDir, { recursive: true }); + await fs.mkdir(bot1SessionsDir, { recursive: true }); + await fs.mkdir(bot2SessionsDir, { recursive: true }); + + const mainStorePath = path.join(mainSessionsDir, "sessions.json"); + await fs.writeFile(mainStorePath, "{}", "utf-8"); + + const bot2SessionPath = path.join(bot2SessionsDir, "sess-1.jsonl"); + await fs.writeFile(bot2SessionPath, "{}", "utf-8"); + + const outsidePath = path.join(stateDir, "outside", "not-a-session.jsonl"); + await fs.mkdir(path.dirname(outsidePath), { recursive: true }); + await fs.writeFile(outsidePath, "{}", "utf-8"); + + return { stateDir, mainStorePath, bot2SessionPath, outsidePath }; + } + + async function normalizePathForComparison(filePath: string): Promise { + const parentDir = path.dirname(filePath); + const canonicalParent = await fs.realpath(parentDir).catch(() => parentDir); + return path.join(canonicalParent, path.basename(filePath)); + } + const deriveSessionKeyCases = [ { name: "returns normalized per-sender key", @@ -534,17 +567,19 @@ describe("sessions", () => { }); }); - it("resolves cross-agent absolute sessionFile paths", () => { - const stateDir = path.resolve("/home/user/.openclaw"); + it("resolves cross-agent absolute sessionFile paths", async () => { + const { stateDir, bot2SessionPath } = await createAgentSessionsLayout("cross-agent"); + const canonicalBot2SessionPath = await fs + .realpath(bot2SessionPath) + .catch(() => bot2SessionPath); withStateDir(stateDir, () => { - const bot2Session = path.join(stateDir, "agents", "bot2", "sessions", "sess-1.jsonl"); // Agent bot1 resolves a sessionFile that belongs to agent bot2 const sessionFile = resolveSessionFilePath( "sess-1", - { sessionFile: bot2Session }, + { sessionFile: bot2SessionPath }, { agentId: "bot1" }, ); - expect(sessionFile).toBe(bot2Session); + expect(sessionFile).toBe(canonicalBot2SessionPath); }); }); @@ -609,38 +644,32 @@ describe("sessions", () => { expect(resolved?.sessionsDir).toBe(path.dirname(path.resolve(storePath))); }); - it("resolves sibling agent absolute sessionFile using alternate agentId from options", () => { - const stateDir = path.resolve("/home/user/.openclaw"); + it("resolves sibling agent absolute sessionFile using alternate agentId from options", async () => { + const { stateDir, mainStorePath, bot2SessionPath } = + await createAgentSessionsLayout("sibling-agent"); + const canonicalBot2SessionPath = await fs + .realpath(bot2SessionPath) + .catch(() => bot2SessionPath); withStateDir(stateDir, () => { - const mainStorePath = path.join(stateDir, "agents", "main", "sessions", "sessions.json"); - const bot2Session = path.join(stateDir, "agents", "bot2", "sessions", "sess-1.jsonl"); const opts = resolveSessionFilePathOptions({ agentId: "bot2", storePath: mainStorePath, }); - const sessionFile = resolveSessionFilePath("sess-1", { sessionFile: bot2Session }, opts); - expect(sessionFile).toBe(bot2Session); + const sessionFile = resolveSessionFilePath("sess-1", { sessionFile: bot2SessionPath }, opts); + expect(sessionFile).toBe(canonicalBot2SessionPath); }); }); - it("falls back to derived transcript path when sessionFile is outside agent sessions directories", () => { - withStateDir(path.resolve("/home/user/.openclaw"), () => { - const sessionFile = resolveSessionFilePath( - "sess-1", - { sessionFile: path.resolve("/etc/passwd") }, - { agentId: "bot1" }, - ); - expect(sessionFile).toBe( - path.join( - path.resolve("/home/user/.openclaw"), - "agents", - "bot1", - "sessions", - "sess-1.jsonl", - ), - ); - }); + it("falls back to derived transcript path when sessionFile is outside agent sessions directories", async () => { + const { stateDir, outsidePath } = await createAgentSessionsLayout("outside-fallback"); + const sessionFile = withStateDir(stateDir, () => + resolveSessionFilePath("sess-1", { sessionFile: outsidePath }, { agentId: "bot1" }), + ); + const expectedPath = path.join(stateDir, "agents", "bot1", "sessions", "sess-1.jsonl"); + expect(await normalizePathForComparison(sessionFile)).toBe( + await normalizePathForComparison(expectedPath), + ); }); it("updateSessionStoreEntry merges concurrent patches", async () => { @@ -723,7 +752,7 @@ describe("sessions", () => { providerOverride: "anthropic", updatedAt: 124, }; - await fs.writeFile(storePath, JSON.stringify(externalStore, null, 2), "utf-8"); + await fs.writeFile(storePath, JSON.stringify(externalStore), "utf-8"); await fs.utimes(storePath, originalStat.atime, originalStat.mtime); await updateSessionStoreEntry({ diff --git a/src/secrets/resolve.test.ts b/src/secrets/resolve.test.ts index 5c691bd7b6f..e11bb6e7963 100644 --- a/src/secrets/resolve.test.ts +++ b/src/secrets/resolve.test.ts @@ -1,9 +1,8 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import type { SecretProviderConfig } from "../config/types.secrets.js"; import { resolveSecretRefString, resolveSecretRefValue } from "./resolve.js"; async function writeSecureFile(filePath: string, content: string, mode = 0o600): Promise { @@ -13,92 +12,69 @@ async function writeSecureFile(filePath: string, content: string, mode = 0o600): } describe("secret ref resolver", () => { - const cleanupRoots: string[] = []; - const execRef = { source: "exec", provider: "execmain", id: "openai/api-key" } as const; - const fileRef = { source: "file", provider: "filemain", id: "/providers/openai/apiKey" } as const; + let fixtureRoot = ""; + let caseId = 0; + let execProtocolV1ScriptPath = ""; + let execPlainScriptPath = ""; + let execProtocolV2ScriptPath = ""; + let execMissingIdScriptPath = ""; + let execInvalidJsonScriptPath = ""; - function isWindows(): boolean { - return process.platform === "win32"; - } + const createCaseDir = async (label: string): Promise => { + const dir = path.join(fixtureRoot, `${label}-${caseId++}`); + await fs.mkdir(dir, { recursive: true }); + return dir; + }; - async function createTempRoot(prefix: string): Promise { - const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); - cleanupRoots.push(root); - return root; - } + beforeAll(async () => { + fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secrets-resolve-")); + const sharedExecDir = path.join(fixtureRoot, "shared-exec"); + await fs.mkdir(sharedExecDir, { recursive: true }); - function createProviderConfig( - providerId: string, - provider: SecretProviderConfig, - ): OpenClawConfig { - return { - secrets: { - providers: { - [providerId]: provider, - }, - }, - }; - } - - async function resolveWithProvider(params: { - ref: Parameters[0]; - providerId: string; - provider: SecretProviderConfig; - }) { - return await resolveSecretRefString(params.ref, { - config: createProviderConfig(params.providerId, params.provider), - }); - } - - function createExecProvider( - command: string, - overrides?: Record, - ): SecretProviderConfig { - return { - source: "exec", - command, - passEnv: ["PATH"], - ...overrides, - } as SecretProviderConfig; - } - - async function expectExecResolveRejects( - provider: SecretProviderConfig, - message: string, - ): Promise { - await expect( - resolveWithProvider({ - ref: execRef, - providerId: "execmain", - provider, - }), - ).rejects.toThrow(message); - } - - async function createSymlinkedPlainExecCommand( - root: string, - targetRoot = root, - ): Promise<{ scriptPath: string; symlinkPath: string }> { - const scriptPath = path.join(targetRoot, "resolver-target.mjs"); - const symlinkPath = path.join(root, "resolver-link.mjs"); + execProtocolV1ScriptPath = path.join(sharedExecDir, "resolver-v1.sh"); await writeSecureFile( - scriptPath, - ["#!/usr/bin/env node", "process.stdout.write('plain-secret');"].join("\n"), + execProtocolV1ScriptPath, + [ + "#!/bin/sh", + 'printf \'{"protocolVersion":1,"values":{"openai/api-key":"value:openai/api-key"}}\'', + ].join("\n"), 0o700, ); - await fs.symlink(scriptPath, symlinkPath); - return { scriptPath, symlinkPath }; - } - afterEach(async () => { - vi.restoreAllMocks(); - while (cleanupRoots.length > 0) { - const root = cleanupRoots.pop(); - if (!root) { - continue; - } - await fs.rm(root, { recursive: true, force: true }); + execPlainScriptPath = path.join(sharedExecDir, "resolver-plain.sh"); + await writeSecureFile( + execPlainScriptPath, + ["#!/bin/sh", "printf 'plain-secret'"].join("\n"), + 0o700, + ); + + execProtocolV2ScriptPath = path.join(sharedExecDir, "resolver-v2.sh"); + await writeSecureFile( + execProtocolV2ScriptPath, + ["#!/bin/sh", 'printf \'{"protocolVersion":2,"values":{"openai/api-key":"x"}}\''].join("\n"), + 0o700, + ); + + execMissingIdScriptPath = path.join(sharedExecDir, "resolver-missing-id.sh"); + await writeSecureFile( + execMissingIdScriptPath, + ["#!/bin/sh", 'printf \'{"protocolVersion":1,"values":{}}\''].join("\n"), + 0o700, + ); + + execInvalidJsonScriptPath = path.join(sharedExecDir, "resolver-invalid-json.sh"); + await writeSecureFile( + execInvalidJsonScriptPath, + ["#!/bin/sh", "printf 'not-json'"].join("\n"), + 0o700, + ); + }); + + afterAll(async () => { + if (!fixtureRoot) { + return; } + await fs.rm(fixtureRoot, { recursive: true, force: true }); }); it("resolves env refs via implicit default env provider", async () => { @@ -114,10 +90,10 @@ describe("secret ref resolver", () => { }); it("resolves file refs in json mode", async () => { - if (isWindows()) { + if (process.platform === "win32") { return; } - const root = await createTempRoot("openclaw-secrets-resolve-file-"); + const root = await createCaseDir("file"); const filePath = path.join(root, "secrets.json"); await writeSecureFile( filePath, @@ -130,111 +106,140 @@ describe("secret ref resolver", () => { }), ); - const value = await resolveWithProvider({ - ref: fileRef, - providerId: "filemain", - provider: { - source: "file", - path: filePath, - mode: "json", + const value = await resolveSecretRefString( + { source: "file", provider: "filemain", id: "/providers/openai/apiKey" }, + { + config: { + secrets: { + providers: { + filemain: { + source: "file", + path: filePath, + mode: "json", + }, + }, + }, + }, }, - }); + ); expect(value).toBe("sk-file-value"); }); it("resolves exec refs with protocolVersion 1 response", async () => { - if (isWindows()) { + if (process.platform === "win32") { return; } - const root = await createTempRoot("openclaw-secrets-resolve-exec-"); - const scriptPath = path.join(root, "resolver.mjs"); - await writeSecureFile( - scriptPath, - [ - "#!/usr/bin/env node", - "import fs from 'node:fs';", - "const req = JSON.parse(fs.readFileSync(0, 'utf8'));", - "const values = Object.fromEntries((req.ids ?? []).map((id) => [id, `value:${id}`]));", - "process.stdout.write(JSON.stringify({ protocolVersion: 1, values }));", - ].join("\n"), - 0o700, - ); - const value = await resolveWithProvider({ - ref: execRef, - providerId: "execmain", - provider: { - source: "exec", - command: scriptPath, - passEnv: ["PATH"], + const value = await resolveSecretRefString( + { source: "exec", provider: "execmain", id: "openai/api-key" }, + { + config: { + secrets: { + providers: { + execmain: { + source: "exec", + command: execProtocolV1ScriptPath, + passEnv: ["PATH"], + }, + }, + }, + }, }, - }); + ); expect(value).toBe("value:openai/api-key"); }); it("supports non-JSON single-value exec output when jsonOnly is false", async () => { - if (isWindows()) { + if (process.platform === "win32") { return; } - const root = await createTempRoot("openclaw-secrets-resolve-exec-plain-"); - const scriptPath = path.join(root, "resolver-plain.mjs"); - await writeSecureFile( - scriptPath, - ["#!/usr/bin/env node", "process.stdout.write('plain-secret');"].join("\n"), - 0o700, - ); - const value = await resolveWithProvider({ - ref: execRef, - providerId: "execmain", - provider: { - source: "exec", - command: scriptPath, - passEnv: ["PATH"], - jsonOnly: false, + const value = await resolveSecretRefString( + { source: "exec", provider: "execmain", id: "openai/api-key" }, + { + config: { + secrets: { + providers: { + execmain: { + source: "exec", + command: execPlainScriptPath, + passEnv: ["PATH"], + jsonOnly: false, + }, + }, + }, + }, }, - }); + ); expect(value).toBe("plain-secret"); }); it("rejects symlink command paths unless allowSymlinkCommand is enabled", async () => { - if (isWindows()) { + if (process.platform === "win32") { return; } - const root = await createTempRoot("openclaw-secrets-resolve-exec-link-"); - const { symlinkPath } = await createSymlinkedPlainExecCommand(root); - await expectExecResolveRejects( - createExecProvider(symlinkPath, { jsonOnly: false }), - "must not be a symlink", - ); + const root = await createCaseDir("exec-link-reject"); + const symlinkPath = path.join(root, "resolver-link.mjs"); + await fs.symlink(execPlainScriptPath, symlinkPath); + + await expect( + resolveSecretRefString( + { source: "exec", provider: "execmain", id: "openai/api-key" }, + { + config: { + secrets: { + providers: { + execmain: { + source: "exec", + command: symlinkPath, + passEnv: ["PATH"], + jsonOnly: false, + }, + }, + }, + }, + }, + ), + ).rejects.toThrow("must not be a symlink"); }); it("allows symlink command paths when allowSymlinkCommand is enabled", async () => { - if (isWindows()) { + if (process.platform === "win32") { return; } - const root = await createTempRoot("openclaw-secrets-resolve-exec-link-"); - const { symlinkPath } = await createSymlinkedPlainExecCommand(root); - const trustedRoot = await fs.realpath(root); + const root = await createCaseDir("exec-link-allow"); + const symlinkPath = path.join(root, "resolver-link.mjs"); + await fs.symlink(execPlainScriptPath, symlinkPath); + const trustedRoot = await fs.realpath(fixtureRoot); - const value = await resolveWithProvider({ - ref: execRef, - providerId: "execmain", - provider: createExecProvider(symlinkPath, { - jsonOnly: false, - allowSymlinkCommand: true, - trustedDirs: [trustedRoot], - }), - }); + const value = await resolveSecretRefString( + { source: "exec", provider: "execmain", id: "openai/api-key" }, + { + config: { + secrets: { + providers: { + execmain: { + source: "exec", + command: symlinkPath, + passEnv: ["PATH"], + jsonOnly: false, + allowSymlinkCommand: true, + trustedDirs: [trustedRoot], + }, + }, + }, + }, + }, + ); expect(value).toBe("plain-secret"); }); it("handles Homebrew-style symlinked exec commands with args only when explicitly allowed", async () => { - if (isWindows()) { + if (process.platform === "win32") { return; } - const root = await createTempRoot("openclaw-secrets-resolve-homebrew-"); + 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 }); @@ -245,12 +250,9 @@ describe("secret ref resolver", () => { await writeSecureFile( targetCommand, [ - `#!${process.execPath}`, - "import fs from 'node:fs';", - "const req = JSON.parse(fs.readFileSync(0, 'utf8'));", - "const suffix = process.argv[2] ?? 'missing';", - "const values = Object.fromEntries((req.ids ?? []).map((id) => [id, `${suffix}:${id}`]));", - "process.stdout.write(JSON.stringify({ protocolVersion: 1, values }));", + "#!/bin/sh", + 'suffix="${1:-missing}"', + 'printf \'{"protocolVersion":1,"values":{"openai/api-key":"%s:openai/api-key"}}\' "$suffix"', ].join("\n"), 0o700, ); @@ -258,139 +260,182 @@ describe("secret ref resolver", () => { const trustedRoot = await fs.realpath(root); await expect( - resolveWithProvider({ - ref: execRef, - providerId: "execmain", - provider: { - source: "exec", - command: symlinkCommand, - args: ["brew"], - passEnv: ["PATH"], + resolveSecretRefString( + { source: "exec", provider: "execmain", id: "openai/api-key" }, + { + config: { + secrets: { + providers: { + execmain: { + source: "exec", + command: symlinkCommand, + args: ["brew"], + passEnv: ["PATH"], + }, + }, + }, + }, }, - }), + ), ).rejects.toThrow("must not be a symlink"); - const value = await resolveWithProvider({ - ref: execRef, - providerId: "execmain", - provider: { - source: "exec", - command: symlinkCommand, - args: ["brew"], - allowSymlinkCommand: true, - trustedDirs: [trustedRoot], + const value = await resolveSecretRefString( + { source: "exec", provider: "execmain", id: "openai/api-key" }, + { + config: { + secrets: { + providers: { + execmain: { + source: "exec", + command: symlinkCommand, + args: ["brew"], + allowSymlinkCommand: true, + trustedDirs: [trustedRoot], + }, + }, + }, + }, }, - }); + ); expect(value).toBe("brew:openai/api-key"); }); it("checks trustedDirs against resolved symlink target", async () => { - if (isWindows()) { + if (process.platform === "win32") { return; } - const root = await createTempRoot("openclaw-secrets-resolve-exec-link-"); - const outside = await createTempRoot("openclaw-secrets-resolve-exec-out-"); - const { symlinkPath } = await createSymlinkedPlainExecCommand(root, outside); - await expectExecResolveRejects( - createExecProvider(symlinkPath, { - jsonOnly: false, - allowSymlinkCommand: true, - trustedDirs: [root], - }), - "outside trustedDirs", - ); + const root = await createCaseDir("exec-link-trusted"); + const symlinkPath = path.join(root, "resolver-link.mjs"); + await fs.symlink(execPlainScriptPath, symlinkPath); + + await expect( + resolveSecretRefString( + { source: "exec", provider: "execmain", id: "openai/api-key" }, + { + config: { + secrets: { + providers: { + execmain: { + source: "exec", + command: symlinkPath, + passEnv: ["PATH"], + jsonOnly: false, + allowSymlinkCommand: true, + trustedDirs: [root], + }, + }, + }, + }, + }, + ), + ).rejects.toThrow("outside trustedDirs"); }); it("rejects exec refs when protocolVersion is not 1", async () => { - if (isWindows()) { + if (process.platform === "win32") { return; } - const root = await createTempRoot("openclaw-secrets-resolve-exec-protocol-"); - const scriptPath = path.join(root, "resolver-protocol.mjs"); - await writeSecureFile( - scriptPath, - [ - "#!/usr/bin/env node", - "process.stdout.write(JSON.stringify({ protocolVersion: 2, values: { 'openai/api-key': 'x' } }));", - ].join("\n"), - 0o700, - ); - - await expectExecResolveRejects(createExecProvider(scriptPath), "protocolVersion must be 1"); + await expect( + resolveSecretRefString( + { source: "exec", provider: "execmain", id: "openai/api-key" }, + { + config: { + secrets: { + providers: { + execmain: { + source: "exec", + command: execProtocolV2ScriptPath, + passEnv: ["PATH"], + }, + }, + }, + }, + }, + ), + ).rejects.toThrow("protocolVersion must be 1"); }); it("rejects exec refs when response omits requested id", async () => { - if (isWindows()) { + if (process.platform === "win32") { return; } - const root = await createTempRoot("openclaw-secrets-resolve-exec-id-"); - const scriptPath = path.join(root, "resolver-missing-id.mjs"); - await writeSecureFile( - scriptPath, - [ - "#!/usr/bin/env node", - "process.stdout.write(JSON.stringify({ protocolVersion: 1, values: {} }));", - ].join("\n"), - 0o700, - ); - - await expectExecResolveRejects( - createExecProvider(scriptPath), - 'response missing id "openai/api-key"', - ); + await expect( + resolveSecretRefString( + { source: "exec", provider: "execmain", id: "openai/api-key" }, + { + config: { + secrets: { + providers: { + execmain: { + source: "exec", + command: execMissingIdScriptPath, + passEnv: ["PATH"], + }, + }, + }, + }, + }, + ), + ).rejects.toThrow('response missing id "openai/api-key"'); }); it("rejects exec refs with invalid JSON when jsonOnly is true", async () => { - if (isWindows()) { + if (process.platform === "win32") { return; } - const root = await createTempRoot("openclaw-secrets-resolve-exec-json-"); - const scriptPath = path.join(root, "resolver-invalid-json.mjs"); - await writeSecureFile( - scriptPath, - ["#!/usr/bin/env node", "process.stdout.write('not-json');"].join("\n"), - 0o700, - ); - await expect( - resolveWithProvider({ - ref: execRef, - providerId: "execmain", - provider: { - source: "exec", - command: scriptPath, - passEnv: ["PATH"], - jsonOnly: true, + resolveSecretRefString( + { source: "exec", provider: "execmain", id: "openai/api-key" }, + { + config: { + secrets: { + providers: { + execmain: { + source: "exec", + command: execInvalidJsonScriptPath, + passEnv: ["PATH"], + jsonOnly: true, + }, + }, + }, + }, }, - }), + ), ).rejects.toThrow("returned invalid JSON"); }); it("supports file singleValue mode with id=value", async () => { - if (isWindows()) { + if (process.platform === "win32") { return; } - const root = await createTempRoot("openclaw-secrets-resolve-single-value-"); + const root = await createCaseDir("file-single-value"); const filePath = path.join(root, "token.txt"); await writeSecureFile(filePath, "raw-token-value\n"); - const value = await resolveWithProvider({ - ref: { source: "file", provider: "rawfile", id: "value" }, - providerId: "rawfile", - provider: { - source: "file", - path: filePath, - mode: "singleValue", + const value = await resolveSecretRefString( + { source: "file", provider: "rawfile", id: "value" }, + { + config: { + secrets: { + providers: { + rawfile: { + source: "file", + path: filePath, + mode: "singleValue", + }, + }, + }, + }, }, - }); + ); expect(value).toBe("raw-token-value"); }); it("times out file provider reads when timeoutMs elapses", async () => { - if (isWindows()) { + if (process.platform === "win32") { return; } - const root = await createTempRoot("openclaw-secrets-resolve-timeout-"); + const root = await createCaseDir("file-timeout"); const filePath = path.join(root, "secrets.json"); await writeSecureFile( filePath, @@ -404,7 +449,7 @@ describe("secret ref resolver", () => { ); const originalReadFile = fs.readFile.bind(fs); - vi.spyOn(fs, "readFile").mockImplementation((( + const readFileSpy = vi.spyOn(fs, "readFile").mockImplementation((( targetPath: Parameters[0], options?: Parameters[1], ) => { @@ -414,18 +459,29 @@ describe("secret ref resolver", () => { return originalReadFile(targetPath, options); }) as typeof fs.readFile); - await expect( - resolveWithProvider({ - ref: fileRef, - providerId: "filemain", - provider: { - source: "file", - path: filePath, - mode: "json", - timeoutMs: 5, - }, - }), - ).rejects.toThrow('File provider "filemain" timed out'); + try { + await expect( + resolveSecretRefString( + { source: "file", provider: "filemain", id: "/providers/openai/apiKey" }, + { + config: { + secrets: { + providers: { + filemain: { + source: "file", + path: filePath, + mode: "json", + timeoutMs: 5, + }, + }, + }, + }, + }, + ), + ).rejects.toThrow('File provider "filemain" timed out'); + } finally { + readFileSpy.mockRestore(); + } }); it("rejects misconfigured provider source mismatches", async () => { @@ -433,7 +489,15 @@ describe("secret ref resolver", () => { resolveSecretRefValue( { source: "exec", provider: "default", id: "abc" }, { - config: createProviderConfig("default", { source: "env" }), + config: { + secrets: { + providers: { + default: { + source: "env", + }, + }, + }, + }, }, ), ).rejects.toThrow('has source "env" but ref requests "exec"'); diff --git a/test/scripts/ios-team-id.test.ts b/test/scripts/ios-team-id.test.ts index f445693d93c..d787e038540 100644 --- a/test/scripts/ios-team-id.test.ts +++ b/test/scripts/ios-team-id.test.ts @@ -1,50 +1,19 @@ import { execFileSync } from "node:child_process"; import { chmodSync } from "node:fs"; -import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; const SCRIPT = path.join(process.cwd(), "scripts", "ios-team-id.sh"); -const XCODE_PLIST_PATH = path.join("Library", "Preferences", "com.apple.dt.Xcode.plist"); - -const DEFAULTS_WITH_ACCOUNT_SCRIPT = `#!/usr/bin/env bash -if [[ "$3" == "DVTDeveloperAccountManagerAppleIDLists" ]]; then - echo '(identifier = "dev@example.com";)' - exit 0 -fi -exit 0`; +let fixtureRoot = ""; +let caseId = 0; async function writeExecutable(filePath: string, body: string): Promise { await writeFile(filePath, body, "utf8"); chmodSync(filePath, 0o755); } -async function setupFixture(params?: { - provisioningProfiles?: Record; -}): Promise<{ homeDir: string; binDir: string }> { - const homeDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-ios-team-id-")); - const binDir = path.join(homeDir, "bin"); - await mkdir(binDir, { recursive: true }); - await mkdir(path.join(homeDir, "Library", "Preferences"), { recursive: true }); - await writeFile(path.join(homeDir, XCODE_PLIST_PATH), ""); - - const provisioningProfiles = params?.provisioningProfiles; - if (provisioningProfiles) { - const profilesDir = path.join(homeDir, "Library", "MobileDevice", "Provisioning Profiles"); - await mkdir(profilesDir, { recursive: true }); - for (const [name, body] of Object.entries(provisioningProfiles)) { - await writeFile(path.join(profilesDir, name), body); - } - } - - return { homeDir, binDir }; -} - -async function writeDefaultsWithSignedInAccount(binDir: string): Promise { - await writeExecutable(path.join(binDir, "defaults"), DEFAULTS_WITH_ACCOUNT_SCRIPT); -} - function runScript( homeDir: string, extraEnv: Record = {}, @@ -79,19 +48,51 @@ function runScript( } describe("scripts/ios-team-id.sh", () => { + beforeAll(async () => { + fixtureRoot = await mkdtemp(path.join(os.tmpdir(), "openclaw-ios-team-id-")); + }); + + afterAll(async () => { + if (!fixtureRoot) { + return; + } + await rm(fixtureRoot, { recursive: true, force: true }); + }); + + async function createHomeDir(): Promise { + const homeDir = path.join(fixtureRoot, `case-${caseId++}`); + await mkdir(homeDir, { recursive: true }); + return homeDir; + } + it("falls back to Xcode-managed provisioning profiles when preference teams are empty", async () => { - const { homeDir, binDir } = await setupFixture({ - provisioningProfiles: { - "one.mobileprovision": "stub", - }, + const homeDir = await createHomeDir(); + const binDir = path.join(homeDir, "bin"); + await mkdir(binDir, { recursive: true }); + await mkdir(path.join(homeDir, "Library", "Preferences"), { recursive: true }); + await mkdir(path.join(homeDir, "Library", "MobileDevice", "Provisioning Profiles"), { + recursive: true, }); + await writeFile(path.join(homeDir, "Library", "Preferences", "com.apple.dt.Xcode.plist"), ""); + await writeFile( + path.join(homeDir, "Library", "MobileDevice", "Provisioning Profiles", "one.mobileprovision"), + "stub", + ); await writeExecutable( path.join(binDir, "plutil"), `#!/usr/bin/env bash echo '{}'`, ); - await writeDefaultsWithSignedInAccount(binDir); + await writeExecutable( + path.join(binDir, "defaults"), + `#!/usr/bin/env bash +if [[ "$3" == "DVTDeveloperAccountManagerAppleIDLists" ]]; then + echo '(identifier = "dev@example.com";)' + exit 0 +fi +exit 0`, + ); await writeExecutable( path.join(binDir, "security"), `#!/usr/bin/env bash @@ -119,7 +120,11 @@ exit 0`, }); it("prints actionable guidance when Xcode account exists but no Team ID is resolvable", async () => { - const { homeDir, binDir } = await setupFixture(); + const homeDir = await createHomeDir(); + const binDir = path.join(homeDir, "bin"); + await mkdir(binDir, { recursive: true }); + await mkdir(path.join(homeDir, "Library", "Preferences"), { recursive: true }); + await writeFile(path.join(homeDir, "Library", "Preferences", "com.apple.dt.Xcode.plist"), ""); await writeExecutable( path.join(binDir, "plutil"), @@ -149,19 +154,37 @@ exit 1`, }); it("honors IOS_PREFERRED_TEAM_ID when multiple profile teams are available", async () => { - const { homeDir, binDir } = await setupFixture({ - provisioningProfiles: { - "one.mobileprovision": "stub1", - "two.mobileprovision": "stub2", - }, + const homeDir = await createHomeDir(); + const binDir = path.join(homeDir, "bin"); + await mkdir(binDir, { recursive: true }); + await mkdir(path.join(homeDir, "Library", "Preferences"), { recursive: true }); + await mkdir(path.join(homeDir, "Library", "MobileDevice", "Provisioning Profiles"), { + recursive: true, }); + await writeFile(path.join(homeDir, "Library", "Preferences", "com.apple.dt.Xcode.plist"), ""); + await writeFile( + path.join(homeDir, "Library", "MobileDevice", "Provisioning Profiles", "one.mobileprovision"), + "stub1", + ); + await writeFile( + path.join(homeDir, "Library", "MobileDevice", "Provisioning Profiles", "two.mobileprovision"), + "stub2", + ); await writeExecutable( path.join(binDir, "plutil"), `#!/usr/bin/env bash echo '{}'`, ); - await writeDefaultsWithSignedInAccount(binDir); + await writeExecutable( + path.join(binDir, "defaults"), + `#!/usr/bin/env bash +if [[ "$3" == "DVTDeveloperAccountManagerAppleIDLists" ]]; then + echo '(identifier = "dev@example.com";)' + exit 0 +fi +exit 0`, + ); await writeExecutable( path.join(binDir, "security"), `#!/usr/bin/env bash @@ -190,14 +213,26 @@ exit 0`, }); it("matches preferred team IDs even when parser output uses CRLF line endings", async () => { - const { homeDir, binDir } = await setupFixture(); + const homeDir = await createHomeDir(); + const binDir = path.join(homeDir, "bin"); + await mkdir(binDir, { recursive: true }); + await mkdir(path.join(homeDir, "Library", "Preferences"), { recursive: true }); + await writeFile(path.join(homeDir, "Library", "Preferences", "com.apple.dt.Xcode.plist"), ""); await writeExecutable( path.join(binDir, "plutil"), `#!/usr/bin/env bash echo '{}'`, ); - await writeDefaultsWithSignedInAccount(binDir); + await writeExecutable( + path.join(binDir, "defaults"), + `#!/usr/bin/env bash +if [[ "$3" == "DVTDeveloperAccountManagerAppleIDLists" ]]; then + echo '(identifier = "dev@example.com";)' + exit 0 +fi +exit 0`, + ); await writeExecutable( path.join(binDir, "fake-python"), `#!/usr/bin/env bash