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 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" }],

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 { applyDerivedTags, CONFIG_TAGS, deriveTagsForPath } from "./schema.tags.js";
describe("config schema", () => {
let baseSchema: ReturnType<typeof buildConfigSchema>;
beforeAll(() => {
baseSchema = buildConfigSchema();
});
it("exports schema + hints", () => {
const res = buildConfigSchema();
const res = baseSchema;
const schema = res.schema as { properties?: Record<string, unknown> };
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<string>(CONFIG_TAGS);
for (const [key, hint] of Object.entries(schema.uiHints)) {
if (!key.includes(".")) {

View File

@@ -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<string> {
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({

View File

@@ -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<void> {
@@ -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<string> => {
const dir = path.join(fixtureRoot, `${label}-${caseId++}`);
await fs.mkdir(dir, { recursive: true });
return dir;
};
async function createTempRoot(prefix: string): Promise<string> {
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<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");
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<typeof fs.readFile>[0],
options?: Parameters<typeof fs.readFile>[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"');

View File

@@ -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<void> {
await writeFile(filePath, body, "utf8");
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(
homeDir: string,
extraEnv: Record<string, string> = {},
@@ -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<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 () => {
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