test(config): reuse fixtures for faster validation
This commit is contained in:
@@ -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" }],
|
||||||
|
|||||||
@@ -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(".")) {
|
||||||
|
|||||||
@@ -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,39 +644,33 @@ 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" },
|
|
||||||
);
|
);
|
||||||
expect(sessionFile).toBe(
|
const expectedPath = path.join(stateDir, "agents", "bot1", "sessions", "sess-1.jsonl");
|
||||||
path.join(
|
expect(await normalizePathForComparison(sessionFile)).toBe(
|
||||||
path.resolve("/home/user/.openclaw"),
|
await normalizePathForComparison(expectedPath),
|
||||||
"agents",
|
|
||||||
"bot1",
|
|
||||||
"sessions",
|
|
||||||
"sess-1.jsonl",
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
it("updateSessionStoreEntry merges concurrent patches", async () => {
|
it("updateSessionStoreEntry merges concurrent patches", async () => {
|
||||||
const mainSessionKey = "agent:main:main";
|
const mainSessionKey = "agent:main:main";
|
||||||
@@ -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({
|
||||||
|
|||||||
@@ -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> {
|
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
|
||||||
cleanupRoots.push(root);
|
|
||||||
return root;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createProviderConfig(
|
|
||||||
providerId: string,
|
|
||||||
provider: SecretProviderConfig,
|
|
||||||
): OpenClawConfig {
|
|
||||||
return {
|
|
||||||
secrets: {
|
|
||||||
providers: {
|
|
||||||
[providerId]: provider,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveWithProvider(params: {
|
beforeAll(async () => {
|
||||||
ref: Parameters<typeof resolveSecretRefString>[0];
|
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secrets-resolve-"));
|
||||||
providerId: string;
|
const sharedExecDir = path.join(fixtureRoot, "shared-exec");
|
||||||
provider: SecretProviderConfig;
|
await fs.mkdir(sharedExecDir, { recursive: true });
|
||||||
}) {
|
|
||||||
return await resolveSecretRefString(params.ref, {
|
|
||||||
config: createProviderConfig(params.providerId, params.provider),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function createExecProvider(
|
execProtocolV1ScriptPath = path.join(sharedExecDir, "resolver-v1.sh");
|
||||||
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: {
|
||||||
|
secrets: {
|
||||||
|
providers: {
|
||||||
|
filemain: {
|
||||||
source: "file",
|
source: "file",
|
||||||
path: filePath,
|
path: filePath,
|
||||||
mode: "json",
|
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: {
|
||||||
|
secrets: {
|
||||||
|
providers: {
|
||||||
|
execmain: {
|
||||||
source: "exec",
|
source: "exec",
|
||||||
command: scriptPath,
|
command: execProtocolV1ScriptPath,
|
||||||
passEnv: ["PATH"],
|
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: {
|
||||||
|
secrets: {
|
||||||
|
providers: {
|
||||||
|
execmain: {
|
||||||
source: "exec",
|
source: "exec",
|
||||||
command: scriptPath,
|
command: execPlainScriptPath,
|
||||||
passEnv: ["PATH"],
|
passEnv: ["PATH"],
|
||||||
jsonOnly: false,
|
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: {
|
||||||
|
secrets: {
|
||||||
|
providers: {
|
||||||
|
execmain: {
|
||||||
|
source: "exec",
|
||||||
|
command: symlinkPath,
|
||||||
|
passEnv: ["PATH"],
|
||||||
jsonOnly: false,
|
jsonOnly: false,
|
||||||
allowSymlinkCommand: true,
|
allowSymlinkCommand: true,
|
||||||
trustedDirs: [trustedRoot],
|
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: {
|
||||||
|
secrets: {
|
||||||
|
providers: {
|
||||||
|
execmain: {
|
||||||
source: "exec",
|
source: "exec",
|
||||||
command: symlinkCommand,
|
command: symlinkCommand,
|
||||||
args: ["brew"],
|
args: ["brew"],
|
||||||
passEnv: ["PATH"],
|
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: {
|
||||||
|
secrets: {
|
||||||
|
providers: {
|
||||||
|
execmain: {
|
||||||
source: "exec",
|
source: "exec",
|
||||||
command: symlinkCommand,
|
command: symlinkCommand,
|
||||||
args: ["brew"],
|
args: ["brew"],
|
||||||
allowSymlinkCommand: true,
|
allowSymlinkCommand: true,
|
||||||
trustedDirs: [trustedRoot],
|
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(
|
||||||
|
resolveSecretRefString(
|
||||||
|
{ source: "exec", provider: "execmain", id: "openai/api-key" },
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
secrets: {
|
||||||
|
providers: {
|
||||||
|
execmain: {
|
||||||
|
source: "exec",
|
||||||
|
command: symlinkPath,
|
||||||
|
passEnv: ["PATH"],
|
||||||
jsonOnly: false,
|
jsonOnly: false,
|
||||||
allowSymlinkCommand: true,
|
allowSymlinkCommand: true,
|
||||||
trustedDirs: [root],
|
trustedDirs: [root],
|
||||||
}),
|
},
|
||||||
"outside trustedDirs",
|
},
|
||||||
);
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).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: {
|
||||||
|
secrets: {
|
||||||
|
providers: {
|
||||||
|
execmain: {
|
||||||
source: "exec",
|
source: "exec",
|
||||||
command: scriptPath,
|
command: execInvalidJsonScriptPath,
|
||||||
passEnv: ["PATH"],
|
passEnv: ["PATH"],
|
||||||
jsonOnly: true,
|
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: {
|
||||||
|
secrets: {
|
||||||
|
providers: {
|
||||||
|
rawfile: {
|
||||||
source: "file",
|
source: "file",
|
||||||
path: filePath,
|
path: filePath,
|
||||||
mode: "singleValue",
|
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);
|
||||||
|
|
||||||
|
try {
|
||||||
await expect(
|
await expect(
|
||||||
resolveWithProvider({
|
resolveSecretRefString(
|
||||||
ref: fileRef,
|
{ source: "file", provider: "filemain", id: "/providers/openai/apiKey" },
|
||||||
providerId: "filemain",
|
{
|
||||||
provider: {
|
config: {
|
||||||
|
secrets: {
|
||||||
|
providers: {
|
||||||
|
filemain: {
|
||||||
source: "file",
|
source: "file",
|
||||||
path: filePath,
|
path: filePath,
|
||||||
mode: "json",
|
mode: "json",
|
||||||
timeoutMs: 5,
|
timeoutMs: 5,
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
).rejects.toThrow('File provider "filemain" timed out');
|
).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"');
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
it("falls back to Xcode-managed provisioning profiles when preference teams are empty", async () => {
|
beforeAll(async () => {
|
||||||
const { homeDir, binDir } = await setupFixture({
|
fixtureRoot = await mkdtemp(path.join(os.tmpdir(), "openclaw-ios-team-id-"));
|
||||||
provisioningProfiles: {
|
|
||||||
"one.mobileprovision": "stub",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 = 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(
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user