test(config): reuse fixtures for faster validation

This commit is contained in:
Peter Steinberger
2026-03-02 09:45:52 +00:00
parent fcb956a0a2
commit fd4d157e45
5 changed files with 520 additions and 378 deletions

View File

@@ -1,7 +1,8 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; 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"; import { validateConfigObjectWithPlugins } from "./config.js";
async function writePluginFixture(params: { async function writePluginFixture(params: {
@@ -31,27 +32,44 @@ async function writePluginFixture(params: {
} }
describe("config plugin validation", () => { describe("config plugin validation", () => {
const fixtureRoot = path.join(os.tmpdir(), "openclaw-config-plugin-validation"); let fixtureRoot = "";
let caseIndex = 0; 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 validateInSuite = (raw: unknown) => {
const home = path.join(fixtureRoot, `case-${caseIndex++}`); process.env.OPENCLAW_STATE_DIR = path.join(suiteHome, ".openclaw");
return fs.mkdir(home, { recursive: true }).then(() => home);
}
const validateInHome = (home: string, raw: unknown) => {
process.env.OPENCLAW_STATE_DIR = path.join(home, ".openclaw");
return validateConfigObjectWithPlugins(raw); 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 () => { afterAll(async () => {
await fs.rm(fixtureRoot, { recursive: true, force: true }); 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 () => { it("rejects missing plugin load paths", async () => {
const home = await createCaseHome(); const missingPath = path.join(suiteHome, "missing-plugin");
const missingPath = path.join(home, "missing-plugin"); const res = validateInSuite({
const res = validateInHome(home, {
agents: { list: [{ id: "pi" }] }, agents: { list: [{ id: "pi" }] },
plugins: { enabled: false, load: { paths: [missingPath] } }, 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 () => { it("warns for missing plugin ids in entries instead of failing validation", async () => {
const home = await createCaseHome(); const res = validateInSuite({
const res = validateInHome(home, {
agents: { list: [{ id: "pi" }] }, agents: { list: [{ id: "pi" }] },
plugins: { enabled: false, entries: { "missing-plugin": { enabled: true } } }, 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 () => { it("rejects missing plugin ids in allow/deny/slots", async () => {
const home = await createCaseHome(); const res = validateInSuite({
const res = validateInHome(home, {
agents: { list: [{ id: "pi" }] }, agents: { list: [{ id: "pi" }] },
plugins: { plugins: {
enabled: false, enabled: false,
@@ -105,9 +121,8 @@ describe("config plugin validation", () => {
}); });
it("warns for removed legacy plugin ids instead of failing validation", async () => { it("warns for removed legacy plugin ids instead of failing validation", async () => {
const home = await createCaseHome();
const removedId = "google-antigravity-auth"; const removedId = "google-antigravity-auth";
const res = validateInHome(home, { const res = validateInSuite({
agents: { list: [{ id: "pi" }] }, agents: { list: [{ id: "pi" }] },
plugins: { plugins: {
enabled: false, enabled: false,
@@ -147,8 +162,7 @@ describe("config plugin validation", () => {
}); });
it("surfaces plugin config diagnostics", async () => { it("surfaces plugin config diagnostics", async () => {
const home = await createCaseHome(); const pluginDir = path.join(suiteHome, "bad-plugin");
const pluginDir = path.join(home, "bad-plugin");
await writePluginFixture({ await writePluginFixture({
dir: pluginDir, dir: pluginDir,
id: "bad-plugin", id: "bad-plugin",
@@ -162,7 +176,7 @@ describe("config plugin validation", () => {
}, },
}); });
const res = validateInHome(home, { const res = validateInSuite({
agents: { list: [{ id: "pi" }] }, agents: { list: [{ id: "pi" }] },
plugins: { plugins: {
enabled: true, enabled: true,
@@ -182,8 +196,7 @@ describe("config plugin validation", () => {
}); });
it("accepts known plugin ids", async () => { it("accepts known plugin ids", async () => {
const home = await createCaseHome(); const res = validateInSuite({
const res = validateInHome(home, {
agents: { list: [{ id: "pi" }] }, agents: { list: [{ id: "pi" }] },
plugins: { enabled: false, entries: { discord: { enabled: true } } }, plugins: { enabled: false, entries: { discord: { enabled: true } } },
}); });
@@ -191,8 +204,7 @@ describe("config plugin validation", () => {
}); });
it("accepts channels.modelByChannel", async () => { it("accepts channels.modelByChannel", async () => {
const home = await createCaseHome(); const res = validateInSuite({
const res = validateInHome(home, {
agents: { list: [{ id: "pi" }] }, agents: { list: [{ id: "pi" }] },
channels: { channels: {
modelByChannel: { modelByChannel: {
@@ -206,8 +218,7 @@ describe("config plugin validation", () => {
}); });
it("accepts plugin heartbeat targets", async () => { it("accepts plugin heartbeat targets", async () => {
const home = await createCaseHome(); const pluginDir = path.join(suiteHome, "bluebubbles-plugin");
const pluginDir = path.join(home, "bluebubbles-plugin");
await writePluginFixture({ await writePluginFixture({
dir: pluginDir, dir: pluginDir,
id: "bluebubbles-plugin", id: "bluebubbles-plugin",
@@ -215,7 +226,7 @@ describe("config plugin validation", () => {
schema: { type: "object" }, schema: { type: "object" },
}); });
const res = validateInHome(home, { const res = validateInSuite({
agents: { defaults: { heartbeat: { target: "bluebubbles" } }, list: [{ id: "pi" }] }, agents: { defaults: { heartbeat: { target: "bluebubbles" } }, list: [{ id: "pi" }] },
plugins: { enabled: false, load: { paths: [pluginDir] } }, plugins: { enabled: false, load: { paths: [pluginDir] } },
}); });
@@ -223,8 +234,7 @@ describe("config plugin validation", () => {
}); });
it("rejects unknown heartbeat targets", async () => { it("rejects unknown heartbeat targets", async () => {
const home = await createCaseHome(); const res = validateInSuite({
const res = validateInHome(home, {
agents: { defaults: { heartbeat: { target: "not-a-channel" } }, list: [{ id: "pi" }] }, agents: { defaults: { heartbeat: { target: "not-a-channel" } }, list: [{ id: "pi" }] },
}); });
expect(res.ok).toBe(false); expect(res.ok).toBe(false);
@@ -237,8 +247,7 @@ describe("config plugin validation", () => {
}); });
it("accepts heartbeat directPolicy enum values", async () => { it("accepts heartbeat directPolicy enum values", async () => {
const home = await createCaseHome(); const res = validateInSuite({
const res = validateInHome(home, {
agents: { agents: {
defaults: { heartbeat: { target: "last", directPolicy: "block" } }, defaults: { heartbeat: { target: "last", directPolicy: "block" } },
list: [{ id: "pi", heartbeat: { directPolicy: "allow" } }], list: [{ id: "pi", heartbeat: { directPolicy: "allow" } }],
@@ -248,8 +257,7 @@ describe("config plugin validation", () => {
}); });
it("rejects invalid heartbeat directPolicy values", async () => { it("rejects invalid heartbeat directPolicy values", async () => {
const home = await createCaseHome(); const res = validateInSuite({
const res = validateInHome(home, {
agents: { agents: {
defaults: { heartbeat: { directPolicy: "maybe" } }, defaults: { heartbeat: { directPolicy: "maybe" } },
list: [{ id: "pi" }], list: [{ id: "pi" }],

View File

@@ -1,10 +1,16 @@
import { describe, expect, it } from "vitest"; import { beforeAll, describe, expect, it } from "vitest";
import { buildConfigSchema } from "./schema.js"; import { buildConfigSchema } from "./schema.js";
import { applyDerivedTags, CONFIG_TAGS, deriveTagsForPath } from "./schema.tags.js"; import { applyDerivedTags, CONFIG_TAGS, deriveTagsForPath } from "./schema.tags.js";
describe("config schema", () => { describe("config schema", () => {
let baseSchema: ReturnType<typeof buildConfigSchema>;
beforeAll(() => {
baseSchema = buildConfigSchema();
});
it("exports schema + hints", () => { it("exports schema + hints", () => {
const res = buildConfigSchema(); const res = baseSchema;
const schema = res.schema as { properties?: Record<string, unknown> }; const schema = res.schema as { properties?: Record<string, unknown> };
expect(schema.properties?.gateway).toBeTruthy(); expect(schema.properties?.gateway).toBeTruthy();
expect(schema.properties?.agents).toBeTruthy(); expect(schema.properties?.agents).toBeTruthy();
@@ -148,7 +154,7 @@ describe("config schema", () => {
}); });
it("covers core/built-in config paths with tags", () => { it("covers core/built-in config paths with tags", () => {
const schema = buildConfigSchema(); const schema = baseSchema;
const allowed = new Set<string>(CONFIG_TAGS); const allowed = new Set<string>(CONFIG_TAGS);
for (const [key, hint] of Object.entries(schema.uiHints)) { for (const [key, hint] of Object.entries(schema.uiHints)) {
if (!key.includes(".")) { if (!key.includes(".")) {

View File

@@ -44,10 +44,43 @@ describe("sessions", () => {
}): Promise<{ storePath: string }> { }): Promise<{ storePath: string }> {
const dir = await createCaseDir(params.prefix); const dir = await createCaseDir(params.prefix);
const storePath = path.join(dir, "sessions.json"); 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 }; 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<string> {
const parentDir = path.dirname(filePath);
const canonicalParent = await fs.realpath(parentDir).catch(() => parentDir);
return path.join(canonicalParent, path.basename(filePath));
}
const deriveSessionKeyCases = [ const deriveSessionKeyCases = [
{ {
name: "returns normalized per-sender key", name: "returns normalized per-sender key",
@@ -534,17 +567,19 @@ describe("sessions", () => {
}); });
}); });
it("resolves cross-agent absolute sessionFile paths", () => { it("resolves cross-agent absolute sessionFile paths", async () => {
const stateDir = path.resolve("/home/user/.openclaw"); const { stateDir, bot2SessionPath } = await createAgentSessionsLayout("cross-agent");
const canonicalBot2SessionPath = await fs
.realpath(bot2SessionPath)
.catch(() => bot2SessionPath);
withStateDir(stateDir, () => { withStateDir(stateDir, () => {
const bot2Session = path.join(stateDir, "agents", "bot2", "sessions", "sess-1.jsonl");
// Agent bot1 resolves a sessionFile that belongs to agent bot2 // Agent bot1 resolves a sessionFile that belongs to agent bot2
const sessionFile = resolveSessionFilePath( const sessionFile = resolveSessionFilePath(
"sess-1", "sess-1",
{ sessionFile: bot2Session }, { sessionFile: bot2SessionPath },
{ agentId: "bot1" }, { 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))); expect(resolved?.sessionsDir).toBe(path.dirname(path.resolve(storePath)));
}); });
it("resolves sibling agent absolute sessionFile using alternate agentId from options", () => { it("resolves sibling agent absolute sessionFile using alternate agentId from options", async () => {
const stateDir = path.resolve("/home/user/.openclaw"); const { stateDir, mainStorePath, bot2SessionPath } =
await createAgentSessionsLayout("sibling-agent");
const canonicalBot2SessionPath = await fs
.realpath(bot2SessionPath)
.catch(() => bot2SessionPath);
withStateDir(stateDir, () => { 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({ const opts = resolveSessionFilePathOptions({
agentId: "bot2", agentId: "bot2",
storePath: mainStorePath, storePath: mainStorePath,
}); });
const sessionFile = resolveSessionFilePath("sess-1", { sessionFile: bot2Session }, opts); const sessionFile = resolveSessionFilePath("sess-1", { sessionFile: bot2SessionPath }, opts);
expect(sessionFile).toBe(bot2Session); expect(sessionFile).toBe(canonicalBot2SessionPath);
}); });
}); });
it("falls back to derived transcript path when sessionFile is outside agent sessions directories", () => { it("falls back to derived transcript path when sessionFile is outside agent sessions directories", async () => {
withStateDir(path.resolve("/home/user/.openclaw"), () => { const { stateDir, outsidePath } = await createAgentSessionsLayout("outside-fallback");
const sessionFile = resolveSessionFilePath( const sessionFile = withStateDir(stateDir, () =>
"sess-1", resolveSessionFilePath("sess-1", { sessionFile: outsidePath }, { agentId: "bot1" }),
{ sessionFile: path.resolve("/etc/passwd") }, );
{ agentId: "bot1" }, const expectedPath = path.join(stateDir, "agents", "bot1", "sessions", "sess-1.jsonl");
); expect(await normalizePathForComparison(sessionFile)).toBe(
expect(sessionFile).toBe( await normalizePathForComparison(expectedPath),
path.join( );
path.resolve("/home/user/.openclaw"),
"agents",
"bot1",
"sessions",
"sess-1.jsonl",
),
);
});
}); });
it("updateSessionStoreEntry merges concurrent patches", async () => { it("updateSessionStoreEntry merges concurrent patches", async () => {
@@ -723,7 +752,7 @@ describe("sessions", () => {
providerOverride: "anthropic", providerOverride: "anthropic",
updatedAt: 124, 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 fs.utimes(storePath, originalStat.atime, originalStat.mtime);
await updateSessionStoreEntry({ await updateSessionStoreEntry({

View File

@@ -1,9 +1,8 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; 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 { OpenClawConfig } from "../config/config.js";
import type { SecretProviderConfig } from "../config/types.secrets.js";
import { resolveSecretRefString, resolveSecretRefValue } from "./resolve.js"; import { resolveSecretRefString, resolveSecretRefValue } from "./resolve.js";
async function writeSecureFile(filePath: string, content: string, mode = 0o600): Promise<void> { async function writeSecureFile(filePath: string, content: string, mode = 0o600): Promise<void> {
@@ -13,92 +12,69 @@ async function writeSecureFile(filePath: string, content: string, mode = 0o600):
} }
describe("secret ref resolver", () => { describe("secret ref resolver", () => {
const cleanupRoots: string[] = []; let fixtureRoot = "";
const execRef = { source: "exec", provider: "execmain", id: "openai/api-key" } as const; let caseId = 0;
const fileRef = { source: "file", provider: "filemain", id: "/providers/openai/apiKey" } as const; let execProtocolV1ScriptPath = "";
let execPlainScriptPath = "";
let execProtocolV2ScriptPath = "";
let execMissingIdScriptPath = "";
let execInvalidJsonScriptPath = "";
function isWindows(): boolean { const createCaseDir = async (label: string): Promise<string> => {
return process.platform === "win32"; const dir = path.join(fixtureRoot, `${label}-${caseId++}`);
} await fs.mkdir(dir, { recursive: true });
return dir;
};
async function createTempRoot(prefix: string): Promise<string> { beforeAll(async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secrets-resolve-"));
cleanupRoots.push(root); const sharedExecDir = path.join(fixtureRoot, "shared-exec");
return root; await fs.mkdir(sharedExecDir, { recursive: true });
}
function createProviderConfig( execProtocolV1ScriptPath = path.join(sharedExecDir, "resolver-v1.sh");
providerId: string,
provider: SecretProviderConfig,
): OpenClawConfig {
return {
secrets: {
providers: {
[providerId]: provider,
},
},
};
}
async function resolveWithProvider(params: {
ref: Parameters<typeof resolveSecretRefString>[0];
providerId: string;
provider: SecretProviderConfig;
}) {
return await resolveSecretRefString(params.ref, {
config: createProviderConfig(params.providerId, params.provider),
});
}
function createExecProvider(
command: string,
overrides?: Record<string, unknown>,
): SecretProviderConfig {
return {
source: "exec",
command,
passEnv: ["PATH"],
...overrides,
} as SecretProviderConfig;
}
async function expectExecResolveRejects(
provider: SecretProviderConfig,
message: string,
): Promise<void> {
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");
await writeSecureFile( await writeSecureFile(
scriptPath, execProtocolV1ScriptPath,
["#!/usr/bin/env node", "process.stdout.write('plain-secret');"].join("\n"), [
"#!/bin/sh",
'printf \'{"protocolVersion":1,"values":{"openai/api-key":"value:openai/api-key"}}\'',
].join("\n"),
0o700, 0o700,
); );
await fs.symlink(scriptPath, symlinkPath);
return { scriptPath, symlinkPath };
}
afterEach(async () => { execPlainScriptPath = path.join(sharedExecDir, "resolver-plain.sh");
vi.restoreAllMocks(); await writeSecureFile(
while (cleanupRoots.length > 0) { execPlainScriptPath,
const root = cleanupRoots.pop(); ["#!/bin/sh", "printf 'plain-secret'"].join("\n"),
if (!root) { 0o700,
continue; );
}
await fs.rm(root, { recursive: true, force: true }); 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 () => { 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 () => { it("resolves file refs in json mode", async () => {
if (isWindows()) { if (process.platform === "win32") {
return; return;
} }
const root = await createTempRoot("openclaw-secrets-resolve-file-"); const root = await createCaseDir("file");
const filePath = path.join(root, "secrets.json"); const filePath = path.join(root, "secrets.json");
await writeSecureFile( await writeSecureFile(
filePath, filePath,
@@ -130,111 +106,140 @@ describe("secret ref resolver", () => {
}), }),
); );
const value = await resolveWithProvider({ const value = await resolveSecretRefString(
ref: fileRef, { source: "file", provider: "filemain", id: "/providers/openai/apiKey" },
providerId: "filemain", {
provider: { config: {
source: "file", secrets: {
path: filePath, providers: {
mode: "json", filemain: {
source: "file",
path: filePath,
mode: "json",
},
},
},
},
}, },
}); );
expect(value).toBe("sk-file-value"); expect(value).toBe("sk-file-value");
}); });
it("resolves exec refs with protocolVersion 1 response", async () => { it("resolves exec refs with protocolVersion 1 response", async () => {
if (isWindows()) { if (process.platform === "win32") {
return; 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({ const value = await resolveSecretRefString(
ref: execRef, { source: "exec", provider: "execmain", id: "openai/api-key" },
providerId: "execmain", {
provider: { config: {
source: "exec", secrets: {
command: scriptPath, providers: {
passEnv: ["PATH"], execmain: {
source: "exec",
command: execProtocolV1ScriptPath,
passEnv: ["PATH"],
},
},
},
},
}, },
}); );
expect(value).toBe("value:openai/api-key"); expect(value).toBe("value:openai/api-key");
}); });
it("supports non-JSON single-value exec output when jsonOnly is false", async () => { it("supports non-JSON single-value exec output when jsonOnly is false", async () => {
if (isWindows()) { if (process.platform === "win32") {
return; 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({ const value = await resolveSecretRefString(
ref: execRef, { source: "exec", provider: "execmain", id: "openai/api-key" },
providerId: "execmain", {
provider: { config: {
source: "exec", secrets: {
command: scriptPath, providers: {
passEnv: ["PATH"], execmain: {
jsonOnly: false, source: "exec",
command: execPlainScriptPath,
passEnv: ["PATH"],
jsonOnly: false,
},
},
},
},
}, },
}); );
expect(value).toBe("plain-secret"); expect(value).toBe("plain-secret");
}); });
it("rejects symlink command paths unless allowSymlinkCommand is enabled", async () => { it("rejects symlink command paths unless allowSymlinkCommand is enabled", async () => {
if (isWindows()) { if (process.platform === "win32") {
return; return;
} }
const root = await createTempRoot("openclaw-secrets-resolve-exec-link-"); const root = await createCaseDir("exec-link-reject");
const { symlinkPath } = await createSymlinkedPlainExecCommand(root); const symlinkPath = path.join(root, "resolver-link.mjs");
await expectExecResolveRejects( await fs.symlink(execPlainScriptPath, symlinkPath);
createExecProvider(symlinkPath, { jsonOnly: false }),
"must not be a symlink", 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 () => { it("allows symlink command paths when allowSymlinkCommand is enabled", async () => {
if (isWindows()) { if (process.platform === "win32") {
return; return;
} }
const root = await createTempRoot("openclaw-secrets-resolve-exec-link-"); const root = await createCaseDir("exec-link-allow");
const { symlinkPath } = await createSymlinkedPlainExecCommand(root); const symlinkPath = path.join(root, "resolver-link.mjs");
const trustedRoot = await fs.realpath(root); await fs.symlink(execPlainScriptPath, symlinkPath);
const trustedRoot = await fs.realpath(fixtureRoot);
const value = await resolveWithProvider({ const value = await resolveSecretRefString(
ref: execRef, { source: "exec", provider: "execmain", id: "openai/api-key" },
providerId: "execmain", {
provider: createExecProvider(symlinkPath, { config: {
jsonOnly: false, secrets: {
allowSymlinkCommand: true, providers: {
trustedDirs: [trustedRoot], execmain: {
}), source: "exec",
}); command: symlinkPath,
passEnv: ["PATH"],
jsonOnly: false,
allowSymlinkCommand: true,
trustedDirs: [trustedRoot],
},
},
},
},
},
);
expect(value).toBe("plain-secret"); expect(value).toBe("plain-secret");
}); });
it("handles Homebrew-style symlinked exec commands with args only when explicitly allowed", async () => { it("handles Homebrew-style symlinked exec commands with args only when explicitly allowed", async () => {
if (isWindows()) { if (process.platform === "win32") {
return; return;
} }
const root = await createTempRoot("openclaw-secrets-resolve-homebrew-"); const root = await createCaseDir("homebrew");
const binDir = path.join(root, "opt", "homebrew", "bin"); const binDir = path.join(root, "opt", "homebrew", "bin");
const cellarDir = path.join(root, "opt", "homebrew", "Cellar", "node", "25.0.0", "bin"); const cellarDir = path.join(root, "opt", "homebrew", "Cellar", "node", "25.0.0", "bin");
await fs.mkdir(binDir, { recursive: true }); await fs.mkdir(binDir, { recursive: true });
@@ -245,12 +250,9 @@ describe("secret ref resolver", () => {
await writeSecureFile( await writeSecureFile(
targetCommand, targetCommand,
[ [
`#!${process.execPath}`, "#!/bin/sh",
"import fs from 'node:fs';", 'suffix="${1:-missing}"',
"const req = JSON.parse(fs.readFileSync(0, 'utf8'));", 'printf \'{"protocolVersion":1,"values":{"openai/api-key":"%s:openai/api-key"}}\' "$suffix"',
"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 }));",
].join("\n"), ].join("\n"),
0o700, 0o700,
); );
@@ -258,139 +260,182 @@ describe("secret ref resolver", () => {
const trustedRoot = await fs.realpath(root); const trustedRoot = await fs.realpath(root);
await expect( await expect(
resolveWithProvider({ resolveSecretRefString(
ref: execRef, { source: "exec", provider: "execmain", id: "openai/api-key" },
providerId: "execmain", {
provider: { config: {
source: "exec", secrets: {
command: symlinkCommand, providers: {
args: ["brew"], execmain: {
passEnv: ["PATH"], source: "exec",
command: symlinkCommand,
args: ["brew"],
passEnv: ["PATH"],
},
},
},
},
}, },
}), ),
).rejects.toThrow("must not be a symlink"); ).rejects.toThrow("must not be a symlink");
const value = await resolveWithProvider({ const value = await resolveSecretRefString(
ref: execRef, { source: "exec", provider: "execmain", id: "openai/api-key" },
providerId: "execmain", {
provider: { config: {
source: "exec", secrets: {
command: symlinkCommand, providers: {
args: ["brew"], execmain: {
allowSymlinkCommand: true, source: "exec",
trustedDirs: [trustedRoot], command: symlinkCommand,
args: ["brew"],
allowSymlinkCommand: true,
trustedDirs: [trustedRoot],
},
},
},
},
}, },
}); );
expect(value).toBe("brew:openai/api-key"); expect(value).toBe("brew:openai/api-key");
}); });
it("checks trustedDirs against resolved symlink target", async () => { it("checks trustedDirs against resolved symlink target", async () => {
if (isWindows()) { if (process.platform === "win32") {
return; return;
} }
const root = await createTempRoot("openclaw-secrets-resolve-exec-link-"); const root = await createCaseDir("exec-link-trusted");
const outside = await createTempRoot("openclaw-secrets-resolve-exec-out-"); const symlinkPath = path.join(root, "resolver-link.mjs");
const { symlinkPath } = await createSymlinkedPlainExecCommand(root, outside); await fs.symlink(execPlainScriptPath, symlinkPath);
await expectExecResolveRejects(
createExecProvider(symlinkPath, { await expect(
jsonOnly: false, resolveSecretRefString(
allowSymlinkCommand: true, { source: "exec", provider: "execmain", id: "openai/api-key" },
trustedDirs: [root], {
}), config: {
"outside trustedDirs", 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 () => { it("rejects exec refs when protocolVersion is not 1", async () => {
if (isWindows()) { if (process.platform === "win32") {
return; return;
} }
const root = await createTempRoot("openclaw-secrets-resolve-exec-protocol-"); await expect(
const scriptPath = path.join(root, "resolver-protocol.mjs"); resolveSecretRefString(
await writeSecureFile( { source: "exec", provider: "execmain", id: "openai/api-key" },
scriptPath, {
[ config: {
"#!/usr/bin/env node", secrets: {
"process.stdout.write(JSON.stringify({ protocolVersion: 2, values: { 'openai/api-key': 'x' } }));", providers: {
].join("\n"), execmain: {
0o700, source: "exec",
); command: execProtocolV2ScriptPath,
passEnv: ["PATH"],
await expectExecResolveRejects(createExecProvider(scriptPath), "protocolVersion must be 1"); },
},
},
},
},
),
).rejects.toThrow("protocolVersion must be 1");
}); });
it("rejects exec refs when response omits requested id", async () => { it("rejects exec refs when response omits requested id", async () => {
if (isWindows()) { if (process.platform === "win32") {
return; return;
} }
const root = await createTempRoot("openclaw-secrets-resolve-exec-id-"); await expect(
const scriptPath = path.join(root, "resolver-missing-id.mjs"); resolveSecretRefString(
await writeSecureFile( { source: "exec", provider: "execmain", id: "openai/api-key" },
scriptPath, {
[ config: {
"#!/usr/bin/env node", secrets: {
"process.stdout.write(JSON.stringify({ protocolVersion: 1, values: {} }));", providers: {
].join("\n"), execmain: {
0o700, source: "exec",
); command: execMissingIdScriptPath,
passEnv: ["PATH"],
await expectExecResolveRejects( },
createExecProvider(scriptPath), },
'response missing id "openai/api-key"', },
); },
},
),
).rejects.toThrow('response missing id "openai/api-key"');
}); });
it("rejects exec refs with invalid JSON when jsonOnly is true", async () => { it("rejects exec refs with invalid JSON when jsonOnly is true", async () => {
if (isWindows()) { if (process.platform === "win32") {
return; 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( await expect(
resolveWithProvider({ resolveSecretRefString(
ref: execRef, { source: "exec", provider: "execmain", id: "openai/api-key" },
providerId: "execmain", {
provider: { config: {
source: "exec", secrets: {
command: scriptPath, providers: {
passEnv: ["PATH"], execmain: {
jsonOnly: true, source: "exec",
command: execInvalidJsonScriptPath,
passEnv: ["PATH"],
jsonOnly: true,
},
},
},
},
}, },
}), ),
).rejects.toThrow("returned invalid JSON"); ).rejects.toThrow("returned invalid JSON");
}); });
it("supports file singleValue mode with id=value", async () => { it("supports file singleValue mode with id=value", async () => {
if (isWindows()) { if (process.platform === "win32") {
return; return;
} }
const root = await createTempRoot("openclaw-secrets-resolve-single-value-"); const root = await createCaseDir("file-single-value");
const filePath = path.join(root, "token.txt"); const filePath = path.join(root, "token.txt");
await writeSecureFile(filePath, "raw-token-value\n"); await writeSecureFile(filePath, "raw-token-value\n");
const value = await resolveWithProvider({ const value = await resolveSecretRefString(
ref: { source: "file", provider: "rawfile", id: "value" }, { source: "file", provider: "rawfile", id: "value" },
providerId: "rawfile", {
provider: { config: {
source: "file", secrets: {
path: filePath, providers: {
mode: "singleValue", rawfile: {
source: "file",
path: filePath,
mode: "singleValue",
},
},
},
},
}, },
}); );
expect(value).toBe("raw-token-value"); expect(value).toBe("raw-token-value");
}); });
it("times out file provider reads when timeoutMs elapses", async () => { it("times out file provider reads when timeoutMs elapses", async () => {
if (isWindows()) { if (process.platform === "win32") {
return; return;
} }
const root = await createTempRoot("openclaw-secrets-resolve-timeout-"); const root = await createCaseDir("file-timeout");
const filePath = path.join(root, "secrets.json"); const filePath = path.join(root, "secrets.json");
await writeSecureFile( await writeSecureFile(
filePath, filePath,
@@ -404,7 +449,7 @@ describe("secret ref resolver", () => {
); );
const originalReadFile = fs.readFile.bind(fs); const originalReadFile = fs.readFile.bind(fs);
vi.spyOn(fs, "readFile").mockImplementation((( const readFileSpy = vi.spyOn(fs, "readFile").mockImplementation(((
targetPath: Parameters<typeof fs.readFile>[0], targetPath: Parameters<typeof fs.readFile>[0],
options?: Parameters<typeof fs.readFile>[1], options?: Parameters<typeof fs.readFile>[1],
) => { ) => {
@@ -414,18 +459,29 @@ describe("secret ref resolver", () => {
return originalReadFile(targetPath, options); return originalReadFile(targetPath, options);
}) as typeof fs.readFile); }) as typeof fs.readFile);
await expect( try {
resolveWithProvider({ await expect(
ref: fileRef, resolveSecretRefString(
providerId: "filemain", { source: "file", provider: "filemain", id: "/providers/openai/apiKey" },
provider: { {
source: "file", config: {
path: filePath, secrets: {
mode: "json", providers: {
timeoutMs: 5, filemain: {
}, source: "file",
}), path: filePath,
).rejects.toThrow('File provider "filemain" timed out'); mode: "json",
timeoutMs: 5,
},
},
},
},
},
),
).rejects.toThrow('File provider "filemain" timed out');
} finally {
readFileSpy.mockRestore();
}
}); });
it("rejects misconfigured provider source mismatches", async () => { it("rejects misconfigured provider source mismatches", async () => {
@@ -433,7 +489,15 @@ describe("secret ref resolver", () => {
resolveSecretRefValue( resolveSecretRefValue(
{ source: "exec", provider: "default", id: "abc" }, { 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"'); ).rejects.toThrow('has source "env" but ref requests "exec"');

View File

@@ -1,50 +1,19 @@
import { execFileSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import { chmodSync } from "node:fs"; 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 os from "node:os";
import path from "node:path"; 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 SCRIPT = path.join(process.cwd(), "scripts", "ios-team-id.sh");
const XCODE_PLIST_PATH = path.join("Library", "Preferences", "com.apple.dt.Xcode.plist"); let fixtureRoot = "";
let caseId = 0;
const DEFAULTS_WITH_ACCOUNT_SCRIPT = `#!/usr/bin/env bash
if [[ "$3" == "DVTDeveloperAccountManagerAppleIDLists" ]]; then
echo '(identifier = "dev@example.com";)'
exit 0
fi
exit 0`;
async function writeExecutable(filePath: string, body: string): Promise<void> { async function writeExecutable(filePath: string, body: string): Promise<void> {
await writeFile(filePath, body, "utf8"); await writeFile(filePath, body, "utf8");
chmodSync(filePath, 0o755); chmodSync(filePath, 0o755);
} }
async function setupFixture(params?: {
provisioningProfiles?: Record<string, string>;
}): 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<void> {
await writeExecutable(path.join(binDir, "defaults"), DEFAULTS_WITH_ACCOUNT_SCRIPT);
}
function runScript( function runScript(
homeDir: string, homeDir: string,
extraEnv: Record<string, string> = {}, extraEnv: Record<string, string> = {},
@@ -79,19 +48,51 @@ function runScript(
} }
describe("scripts/ios-team-id.sh", () => { 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<string> {
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 () => { it("falls back to Xcode-managed provisioning profiles when preference teams are empty", async () => {
const { homeDir, binDir } = await setupFixture({ const homeDir = await createHomeDir();
provisioningProfiles: { const binDir = path.join(homeDir, "bin");
"one.mobileprovision": "stub", 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( await writeExecutable(
path.join(binDir, "plutil"), path.join(binDir, "plutil"),
`#!/usr/bin/env bash `#!/usr/bin/env bash
echo '{}'`, 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( await writeExecutable(
path.join(binDir, "security"), path.join(binDir, "security"),
`#!/usr/bin/env bash `#!/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 () => { 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( await writeExecutable(
path.join(binDir, "plutil"), path.join(binDir, "plutil"),
@@ -149,19 +154,37 @@ exit 1`,
}); });
it("honors IOS_PREFERRED_TEAM_ID when multiple profile teams are available", async () => { it("honors IOS_PREFERRED_TEAM_ID when multiple profile teams are available", async () => {
const { homeDir, binDir } = await setupFixture({ const homeDir = await createHomeDir();
provisioningProfiles: { const binDir = path.join(homeDir, "bin");
"one.mobileprovision": "stub1", await mkdir(binDir, { recursive: true });
"two.mobileprovision": "stub2", 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( await writeExecutable(
path.join(binDir, "plutil"), path.join(binDir, "plutil"),
`#!/usr/bin/env bash `#!/usr/bin/env bash
echo '{}'`, 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( await writeExecutable(
path.join(binDir, "security"), path.join(binDir, "security"),
`#!/usr/bin/env bash `#!/usr/bin/env bash
@@ -190,14 +213,26 @@ exit 0`,
}); });
it("matches preferred team IDs even when parser output uses CRLF line endings", async () => { 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( await writeExecutable(
path.join(binDir, "plutil"), path.join(binDir, "plutil"),
`#!/usr/bin/env bash `#!/usr/bin/env bash
echo '{}'`, 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( await writeExecutable(
path.join(binDir, "fake-python"), path.join(binDir, "fake-python"),
`#!/usr/bin/env bash `#!/usr/bin/env bash